@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,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
|
+
}
|