@nitpicker/crawler 0.11.0 → 0.12.0

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 (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. package/lib/utils/error/error-emitter.js +0 -29
@@ -0,0 +1,309 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { acquireArchiveLock, ArchiveLockError } from '../archive-lock.js';
4
+ import { Database } from '../database.js';
5
+ import { peekTarTopDir } from '../filesystem/peek-tar-top-dir.js';
6
+ import { rename } from '../filesystem/rename.js';
7
+ import { untar } from '../filesystem/untar.js';
8
+ import { IncompatibleArchiveError } from '../meta/types.js';
9
+ import { computeArchiveCacheKey } from './compute-archive-cache-key.js';
10
+ /**
11
+ * How long {@link extractArchiveToCache} waits in total for a peer
12
+ * extractor to finish (ms). 5 minutes is enough for the largest archives
13
+ * the crawler currently produces (~10 GB untars in ~10 s; 5 min gives
14
+ * 30× headroom) while still bounded so a deadlocked process eventually
15
+ * surfaces as a real error instead of hanging the viewer indefinitely.
16
+ */
17
+ const PEER_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
18
+ /**
19
+ * Polling interval while waiting on a peer extractor (ms). Short enough
20
+ * to keep latency low when the peer is fast, long enough to avoid
21
+ * burning CPU on a busy `fs.stat` loop.
22
+ */
23
+ const PEER_WAIT_POLL_MS = 50;
24
+ /**
25
+ * Marker file dropped into a cache directory only after the extraction
26
+ * has fully completed AND the contents have passed a structural sanity
27
+ * check (db.sqlite present, schema compatible, migrations applied).
28
+ *
29
+ * Internal — the file name is an implementation detail. Tests should
30
+ * assert through externally observable behaviour (cache hit on second
31
+ * open) rather than importing this constant.
32
+ */
33
+ const READY_MARKER = '.nitpicker-cache-ready';
34
+ /**
35
+ * In-process deduplication for concurrent {@link extractArchiveToCache}
36
+ * calls targeting the same `cacheDir`.
37
+ *
38
+ * The file-based {@link acquireArchiveLock} (mkdir-based) cannot defend
39
+ * against same-process race windows because the lock holder writes its
40
+ * `pid.txt` in a separate `await` from the directory creation: a sibling
41
+ * promise that observes the `EEXIST` between those two steps sees an
42
+ * empty `pid.txt`, mistakes the lock for stale, and clobbers it.
43
+ *
44
+ * Single-process dedup eliminates that race entirely for the common
45
+ * "viewer + MCP in the same process" topology while still letting the
46
+ * inner file lock guard cross-process collisions.
47
+ */
48
+ const inFlightByCacheDir = new Map();
49
+ /**
50
+ * Untar a `.nitpicker` archive into the given cache directory.
51
+ *
52
+ * Concurrency / re-entry contract:
53
+ *
54
+ * - Two viewers opening the same archive race for the cache lock; the
55
+ * loser waits and recheck-loops until the winner writes the ready
56
+ * marker, then short-circuits.
57
+ * - Same-process concurrent callers dedupe through
58
+ * `inFlightByCacheDir` so they share one extraction promise.
59
+ *
60
+ * Per-archive staging:
61
+ *
62
+ * - The tar's inner directory is extracted into `${cacheDir}.staging/`
63
+ * first, then atomically renamed into `cacheDir`. Crucially, the
64
+ * staging path is keyed off `cacheDir` (not the tar's inner directory
65
+ * name) so two archives that happen to share an inner-dir name never
66
+ * collide on the same staging slot.
67
+ *
68
+ * Integrity guard:
69
+ *
70
+ * - After rename, we run the writer-side `Database.connect` so all
71
+ * migrations apply (column adds, new tables) and the version check
72
+ * fires before the ready marker is written. A corrupt or incompatible
73
+ * archive therefore poisons no cache entry — the cacheDir is removed
74
+ * and the next caller retries from scratch.
75
+ *
76
+ * TOCTOU guard:
77
+ *
78
+ * - The cache key is recomputed from the file's stat AFTER the untar
79
+ * completes. If the file changed mid-flight (a concurrent
80
+ * `crawl --append` rewrote it), the freshly-landed cache contents do
81
+ * not correspond to the original key, so we abort and remove the
82
+ * cacheDir so the caller upstream sees the inconsistency rather than
83
+ * serving misattributed data.
84
+ * @param archivePath - Absolute path to the source `.nitpicker` file.
85
+ * @param cacheRoot - Absolute path to the cache root directory.
86
+ * @param cacheDir - Absolute path the extracted contents should end up at.
87
+ * @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
88
+ * after extraction to detect concurrent writers; must match.
89
+ * @returns Resolves once `cacheDir` is ready to be opened read-only.
90
+ */
91
+ export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, cacheKey) {
92
+ if (await isCacheDirReady(cacheDir)) {
93
+ return;
94
+ }
95
+ const existing = inFlightByCacheDir.get(cacheDir);
96
+ if (existing) {
97
+ return existing;
98
+ }
99
+ const promise = runExtraction(archivePath, cacheRoot, cacheDir, cacheKey).finally(() => {
100
+ inFlightByCacheDir.delete(cacheDir);
101
+ });
102
+ inFlightByCacheDir.set(cacheDir, promise);
103
+ return promise;
104
+ }
105
+ /**
106
+ * The actual extraction work. Split out from {@link extractArchiveToCache}
107
+ * so the in-process dedup map can wrap it without disturbing the
108
+ * extraction flow itself.
109
+ * @param archivePath - Absolute path to the source `.nitpicker` file.
110
+ * @param cacheRoot - Absolute path to the cache root directory.
111
+ * @param cacheDir - Absolute path the extracted contents should end up at.
112
+ * @param cacheKey - Pre-extraction cache key, re-verified post-extraction.
113
+ */
114
+ async function runExtraction(archivePath, cacheRoot, cacheDir, cacheKey) {
115
+ await fs.mkdir(cacheRoot, { recursive: true });
116
+ const releaseLock = await acquireLockWithPeerWait(cacheDir);
117
+ try {
118
+ if (await isCacheDirReady(cacheDir)) {
119
+ return;
120
+ }
121
+ // Half-populated cacheDir recovery: another extractor crashed
122
+ // before writing the marker. Rename it aside instead of `rm`-ing
123
+ // in place so any reader that somehow still holds an fd on the
124
+ // old contents (the cache layer doesn't refcount readers) is not
125
+ // pulled out from under. The renamed quarantine dir is left for
126
+ // OS-level temp cleanup.
127
+ await quarantineHalfPopulatedCacheDir(cacheDir);
128
+ const stagingDir = `${cacheDir}.staging`;
129
+ // Stage clean: a previous crashed run may have left
130
+ // `<cacheDir>.staging/` around. `untar` would happily merge into
131
+ // it (with `newer:true` cherrypicking entries) so wipe first.
132
+ await fs.rm(stagingDir, { recursive: true, force: true });
133
+ await fs.mkdir(stagingDir, { recursive: true });
134
+ await untar(archivePath, { cwd: stagingDir });
135
+ // Concurrent-writer detection: if the source archive changed
136
+ // during our untar, the contents we just landed do NOT match the
137
+ // `cacheKey` that named the directory. Drop the staging dir and
138
+ // surface as an error so the caller (or a retry) can pick up the
139
+ // new key.
140
+ const postKey = await computeArchiveCacheKey(archivePath);
141
+ if (postKey !== cacheKey) {
142
+ await fs.rm(stagingDir, { recursive: true, force: true });
143
+ throw new Error(`Archive changed during cache extraction (key ${cacheKey} -> ${postKey}); ` +
144
+ `refusing to land mismatched contents at ${cacheDir}.`);
145
+ }
146
+ const innerDirName = await peekTarTopDir(archivePath);
147
+ const extractedInner = path.resolve(stagingDir, innerDirName);
148
+ // Move the inner directory into the cache slot. Any old quarantine
149
+ // is already aside, so the target is guaranteed empty.
150
+ await rename(extractedInner, cacheDir, true);
151
+ // The empty `stagingDir` wrapper is no longer useful — best effort
152
+ // remove (ignore errors, OS cleanup catches stragglers).
153
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => { });
154
+ // Validate + migrate: open in WRITER mode so the full migration
155
+ // stack runs (initSchema / migrate*). On failure (db.sqlite
156
+ // missing, incompatible archive, broken schema), tear down the
157
+ // cache entry so the next caller does not get stuck on a
158
+ // "ready"-marked but broken cache.
159
+ try {
160
+ await runMigrationsOnCacheDir(cacheDir);
161
+ }
162
+ catch (error) {
163
+ await fs.rm(cacheDir, { recursive: true, force: true });
164
+ throw error;
165
+ }
166
+ await fs.writeFile(path.join(cacheDir, READY_MARKER), '', 'utf8');
167
+ }
168
+ finally {
169
+ await releaseLock();
170
+ }
171
+ }
172
+ /**
173
+ * Acquire the cache lock, blocking until any peer extractor finishes
174
+ * (rather than failing fast on `EEXIST` the way the writer-oriented
175
+ * {@link acquireArchiveLock} does). Returns the release function once
176
+ * the lock is held.
177
+ * @param cacheDir - Absolute path the lock guards.
178
+ */
179
+ async function acquireLockWithPeerWait(cacheDir) {
180
+ const startedAt = Date.now();
181
+ while (true) {
182
+ if (await isCacheDirReady(cacheDir)) {
183
+ // Peer finished and the marker is up. Return a no-op release
184
+ // so the caller can `finally`-await it without branching.
185
+ return async () => { };
186
+ }
187
+ try {
188
+ return await acquireArchiveLock(cacheDir);
189
+ }
190
+ catch (error) {
191
+ if (!(error instanceof ArchiveLockError)) {
192
+ throw error;
193
+ }
194
+ if (Date.now() - startedAt > PEER_WAIT_TIMEOUT_MS) {
195
+ throw error;
196
+ }
197
+ await sleep(PEER_WAIT_POLL_MS);
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Open the migrated DB in writer mode so all migrations apply, then
203
+ * release the handle. Cache entries land migrated, so the subsequent
204
+ * `Archive.connect` read-only open never needs to mutate the cache dir
205
+ * (and never silently misses a newly-added column).
206
+ * @param cacheDir - Absolute path to the freshly-extracted cache dir.
207
+ */
208
+ async function runMigrationsOnCacheDir(cacheDir) {
209
+ const dbPath = path.join(cacheDir, 'db.sqlite');
210
+ if (!(await fileExists(dbPath))) {
211
+ throw new Error(`Cache directory does not contain db.sqlite: ${cacheDir}`);
212
+ }
213
+ const db = await Database.connect({ filename: dbPath, readOnly: false });
214
+ try {
215
+ // `Database.connect` ran migrations during init. Closing here
216
+ // flushes WAL + drops the handle so the read-only re-open in
217
+ // `Archive.connect` does not race the writer connection.
218
+ await db.destroy();
219
+ }
220
+ catch (error) {
221
+ // Propagate after attempting cleanup of the connection (best
222
+ // effort — the cache dir tear-down happens in the caller).
223
+ if (error instanceof IncompatibleArchiveError) {
224
+ throw error;
225
+ }
226
+ throw error;
227
+ }
228
+ }
229
+ /**
230
+ * Move a half-populated cacheDir aside so the next extraction can land
231
+ * cleanly without yanking live readers' files out from under them. The
232
+ * quarantine path gets a unique suffix so two crashes in succession
233
+ * don't collide.
234
+ * @param cacheDir - Absolute path of the cache slot to free.
235
+ */
236
+ async function quarantineHalfPopulatedCacheDir(cacheDir) {
237
+ try {
238
+ await fs.access(cacheDir);
239
+ }
240
+ catch {
241
+ // Nothing to quarantine.
242
+ return;
243
+ }
244
+ if (await isCacheDirReady(cacheDir)) {
245
+ // Already settled — caller will short-circuit, no quarantine needed.
246
+ return;
247
+ }
248
+ const quarantinePath = `${cacheDir}.corrupt.${process.pid}.${nextQuarantineCounter()}`;
249
+ try {
250
+ await fs.rename(cacheDir, quarantinePath);
251
+ }
252
+ catch {
253
+ // If rename fails (e.g. cross-volume), fall back to direct
254
+ // removal — accepting the rm-while-reader risk this single time
255
+ // since we have no other option.
256
+ await fs.rm(cacheDir, { recursive: true, force: true });
257
+ }
258
+ }
259
+ /**
260
+ * Monotonic counter appended to quarantine directory names so multiple
261
+ * recoveries in a single process never collide on the same path. Resets
262
+ * to zero after `2 ** 32` increments; that bound is far larger than any
263
+ * realistic per-process quarantine count.
264
+ */
265
+ let quarantineCounter = 0;
266
+ /**
267
+ * Issue the next quarantine sequence number.
268
+ * @returns A non-negative integer, monotonically increasing per process.
269
+ */
270
+ function nextQuarantineCounter() {
271
+ quarantineCounter = (quarantineCounter + 1) % 0x1_00_00_00_00;
272
+ return quarantineCounter;
273
+ }
274
+ /**
275
+ * Async existence probe — avoids blocking the event loop on the warm
276
+ * cache-hit fast path.
277
+ * @param targetPath - Absolute path to probe.
278
+ * @returns `true` if the path is reachable via `fs.access`.
279
+ */
280
+ async function fileExists(targetPath) {
281
+ try {
282
+ await fs.access(targetPath);
283
+ return true;
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ }
289
+ /**
290
+ * Whether a cache directory contains a fully-validated extraction. The
291
+ * READY marker is the only trustworthy signal — sibling files alone
292
+ * cannot be trusted because a half-extracted dir can also contain a
293
+ * `db.sqlite`.
294
+ * @param cacheDir - Absolute path to a candidate cache directory.
295
+ * @returns `true` if the marker exists.
296
+ */
297
+ async function isCacheDirReady(cacheDir) {
298
+ return await fileExists(path.join(cacheDir, READY_MARKER));
299
+ }
300
+ /**
301
+ * Promise-based sleep used by the peer-wait poll. Kept inline so the
302
+ * file has no external sleep dependency and so the implementation can
303
+ * later swap for an AbortSignal-aware version without touching the
304
+ * call site.
305
+ * @param ms - Milliseconds to wait.
306
+ */
307
+ function sleep(ms) {
308
+ return new Promise((resolve) => setTimeout(resolve, ms));
309
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolve the directory where extracted `.nitpicker` archives are cached.
3
+ *
4
+ * Resolution order:
5
+ *
6
+ * 1. `NITPICKER_TAR_CACHE_DIR` env — explicit override (CI, testing, or
7
+ * operators who want the cache on a specific volume). Must be a
8
+ * path; values that look like boolean / sentinel words (e.g. `0`,
9
+ * `false`) are ignored to keep the cache from landing somewhere
10
+ * surprising when the user mistakes the env contract.
11
+ * 2. `<os.tmpdir()>/nitpicker/cache/` — default. Lives under the OS
12
+ * temp directory so the platform's own cleanup (macOS reboot, Linux
13
+ * `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale entries
14
+ * without bespoke logic on our side.
15
+ *
16
+ * The returned path is absolute. The caller is responsible for creating
17
+ * it on demand (this function is pure).
18
+ * @returns Absolute path to the cache root directory.
19
+ */
20
+ export declare function getArchiveCacheRoot(): string;
@@ -0,0 +1,53 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /**
4
+ * Env values that look like a "disable" sentinel rather than a path.
5
+ *
6
+ * A user who copy-pastes the `NITPICKER_DISABLE_TAR_CACHE` convention
7
+ * onto `NITPICKER_TAR_CACHE_DIR` (e.g. `NITPICKER_TAR_CACHE_DIR=0`
8
+ * thinking it means "use default") would otherwise silently land the
9
+ * cache at `$PWD/0/` — multi-gigabyte extracts polluting the project
10
+ * tree, never reclaimed by OS temp cleanup. Reject these explicitly
11
+ * and fall back to the default location instead.
12
+ */
13
+ const SENTINEL_LIKE_OVERRIDES = new Set([
14
+ '0',
15
+ '1',
16
+ 'false',
17
+ 'true',
18
+ 'no',
19
+ 'yes',
20
+ 'off',
21
+ 'on',
22
+ 'null',
23
+ 'undefined',
24
+ ]);
25
+ /**
26
+ * Resolve the directory where extracted `.nitpicker` archives are cached.
27
+ *
28
+ * Resolution order:
29
+ *
30
+ * 1. `NITPICKER_TAR_CACHE_DIR` env — explicit override (CI, testing, or
31
+ * operators who want the cache on a specific volume). Must be a
32
+ * path; values that look like boolean / sentinel words (e.g. `0`,
33
+ * `false`) are ignored to keep the cache from landing somewhere
34
+ * surprising when the user mistakes the env contract.
35
+ * 2. `<os.tmpdir()>/nitpicker/cache/` — default. Lives under the OS
36
+ * temp directory so the platform's own cleanup (macOS reboot, Linux
37
+ * `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale entries
38
+ * without bespoke logic on our side.
39
+ *
40
+ * The returned path is absolute. The caller is responsible for creating
41
+ * it on demand (this function is pure).
42
+ * @returns Absolute path to the cache root directory.
43
+ */
44
+ export function getArchiveCacheRoot() {
45
+ const envOverride = process.env.NITPICKER_TAR_CACHE_DIR;
46
+ if (envOverride && envOverride.trim().length > 0) {
47
+ const trimmed = envOverride.trim();
48
+ if (!SENTINEL_LIKE_OVERRIDES.has(trimmed.toLowerCase())) {
49
+ return path.resolve(trimmed);
50
+ }
51
+ }
52
+ return path.resolve(os.tmpdir(), 'nitpicker', 'cache');
53
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Whether the tar cache is disabled by the `NITPICKER_DISABLE_TAR_CACHE`
3
+ * env var.
4
+ *
5
+ * Useful for two scenarios:
6
+ *
7
+ * 1. Debugging — bypass the cache to reproduce the cold-start behaviour
8
+ * against a fresh tmpDir.
9
+ * 2. Sandboxed CI — when the runner's tmpfs would not survive between
10
+ * steps anyway, the cache only adds first-step overhead.
11
+ *
12
+ * Accepted truthy values: `1`, `true`, `yes`, `on` (case-insensitive).
13
+ * Anything else (including unset) keeps the cache enabled.
14
+ * @returns `true` when the cache should be bypassed.
15
+ * @example
16
+ * ```ts
17
+ * if (isArchiveCacheDisabled()) {
18
+ * // Fall back to the writer path; cwd tmpDir + close-time cleanup.
19
+ * const archive = await Archive.open({ filePath });
20
+ * ...
21
+ * }
22
+ * ```
23
+ */
24
+ export declare function isArchiveCacheDisabled(): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Whether the tar cache is disabled by the `NITPICKER_DISABLE_TAR_CACHE`
3
+ * env var.
4
+ *
5
+ * Useful for two scenarios:
6
+ *
7
+ * 1. Debugging — bypass the cache to reproduce the cold-start behaviour
8
+ * against a fresh tmpDir.
9
+ * 2. Sandboxed CI — when the runner's tmpfs would not survive between
10
+ * steps anyway, the cache only adds first-step overhead.
11
+ *
12
+ * Accepted truthy values: `1`, `true`, `yes`, `on` (case-insensitive).
13
+ * Anything else (including unset) keeps the cache enabled.
14
+ * @returns `true` when the cache should be bypassed.
15
+ * @example
16
+ * ```ts
17
+ * if (isArchiveCacheDisabled()) {
18
+ * // Fall back to the writer path; cwd tmpDir + close-time cleanup.
19
+ * const archive = await Archive.open({ filePath });
20
+ * ...
21
+ * }
22
+ * ```
23
+ */
24
+ export function isArchiveCacheDisabled() {
25
+ const raw = process.env.NITPICKER_DISABLE_TAR_CACHE;
26
+ if (!raw) {
27
+ return false;
28
+ }
29
+ const normalized = raw.trim().toLowerCase();
30
+ return (normalized === '1' ||
31
+ normalized === 'true' ||
32
+ normalized === 'yes' ||
33
+ normalized === 'on');
34
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Build the absolute path to a single archive's cache directory.
3
+ *
4
+ * The directory name is `<cacheKey>-<safeBasename>`:
5
+ *
6
+ * - `cacheKey` carries the freshness signal (size/mtime/ctime) and is
7
+ * what actually drives cache hit vs miss.
8
+ * - `safeBasename` is appended purely so a human running `ls` on the
9
+ * cache root can recognise which archive an entry belongs to. It is
10
+ * NOT used to disambiguate keys — two archives with identical inode
11
+ * metadata (e.g. an identical copy under a different name) intentionally
12
+ * share an entry under the first basename that landed there.
13
+ *
14
+ * The basename is sanitised: anything outside `[A-Za-z0-9._-]` becomes
15
+ * `_`. This keeps the path portable across filesystems (no spaces,
16
+ * unicode normalisation surprises, Windows-reserved chars) and removes
17
+ * any chance that a crafted archive name could escape the cache root
18
+ * (e.g. via `..` or path separators), independent of the upstream
19
+ * `path.basename` call that already drops directory components.
20
+ * @param cacheRoot - Absolute path returned by `getArchiveCacheRoot()`.
21
+ * @param cacheKey - The freshness key from `computeArchiveCacheKey()`.
22
+ * @param archivePath - Absolute path to the source `.nitpicker` file;
23
+ * only its basename contributes to the cache entry name.
24
+ * @returns Absolute path to the per-archive cache directory.
25
+ */
26
+ export declare function resolveArchiveCacheDir(cacheRoot: string, cacheKey: string, archivePath: string): string;
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Build the absolute path to a single archive's cache directory.
4
+ *
5
+ * The directory name is `<cacheKey>-<safeBasename>`:
6
+ *
7
+ * - `cacheKey` carries the freshness signal (size/mtime/ctime) and is
8
+ * what actually drives cache hit vs miss.
9
+ * - `safeBasename` is appended purely so a human running `ls` on the
10
+ * cache root can recognise which archive an entry belongs to. It is
11
+ * NOT used to disambiguate keys — two archives with identical inode
12
+ * metadata (e.g. an identical copy under a different name) intentionally
13
+ * share an entry under the first basename that landed there.
14
+ *
15
+ * The basename is sanitised: anything outside `[A-Za-z0-9._-]` becomes
16
+ * `_`. This keeps the path portable across filesystems (no spaces,
17
+ * unicode normalisation surprises, Windows-reserved chars) and removes
18
+ * any chance that a crafted archive name could escape the cache root
19
+ * (e.g. via `..` or path separators), independent of the upstream
20
+ * `path.basename` call that already drops directory components.
21
+ * @param cacheRoot - Absolute path returned by `getArchiveCacheRoot()`.
22
+ * @param cacheKey - The freshness key from `computeArchiveCacheKey()`.
23
+ * @param archivePath - Absolute path to the source `.nitpicker` file;
24
+ * only its basename contributes to the cache entry name.
25
+ * @returns Absolute path to the per-archive cache directory.
26
+ */
27
+ export function resolveArchiveCacheDir(cacheRoot, cacheKey, archivePath) {
28
+ const rawBasename = path.basename(archivePath, path.extname(archivePath));
29
+ const safeBasename = rawBasename.replaceAll(/[^\w.-]+/g, '_').slice(0, 80);
30
+ const dirName = safeBasename.length > 0 ? `${cacheKey}-${safeBasename}` : cacheKey;
31
+ return path.resolve(cacheRoot, dirName);
32
+ }