@mjasnikovs/pi-task 0.39.5 → 0.40.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 (35) hide show
  1. package/README.md +18 -3
  2. package/dist/task/context-attribution.js +18 -6
  3. package/dist/task/external-context.d.ts +8 -1
  4. package/dist/task/external-context.js +52 -4
  5. package/dist/task/phases.js +2 -1
  6. package/dist/task/prompts.d.ts +7 -1
  7. package/dist/task/prompts.js +14 -4
  8. package/dist/workers/docs-cache.js +50 -3
  9. package/dist/workers/docs-chunk.d.ts +6 -3
  10. package/dist/workers/docs-chunk.js +8 -5
  11. package/dist/workers/docs-core.d.ts +27 -3
  12. package/dist/workers/docs-core.js +104 -41
  13. package/dist/workers/docs-ecosystems.d.ts +173 -0
  14. package/dist/workers/docs-ecosystems.js +449 -0
  15. package/dist/workers/docs-index.d.ts +2 -1
  16. package/dist/workers/docs-index.js +55 -27
  17. package/dist/workers/docs-project.d.ts +10 -0
  18. package/dist/workers/docs-project.js +86 -24
  19. package/dist/workers/docs-resolve.d.ts +6 -1
  20. package/dist/workers/docs-resolve.js +4 -3
  21. package/dist/workers/docs-retrieve.d.ts +2 -0
  22. package/dist/workers/docs-retrieve.js +11 -11
  23. package/dist/workers/eco-cargo.d.ts +115 -0
  24. package/dist/workers/eco-cargo.js +793 -0
  25. package/dist/workers/eco-hackage.d.ts +93 -0
  26. package/dist/workers/eco-hackage.js +508 -0
  27. package/dist/workers/npm-version.d.ts +5 -3
  28. package/dist/workers/npm-version.js +6 -4
  29. package/dist/workers/pi-worker-docs.d.ts +18 -4
  30. package/dist/workers/pi-worker-docs.js +57 -19
  31. package/dist/workers/research-cache.d.ts +2 -13
  32. package/dist/workers/research-cache.js +22 -46
  33. package/dist/workers/shared.d.ts +16 -5
  34. package/dist/workers/shared.js +0 -0
  35. package/package.json +1 -1
@@ -0,0 +1,449 @@
1
+ /**
2
+ * docs-ecosystems — one row per package registry the docs Worker tool can read.
3
+ *
4
+ * The docs pipeline used to be npm all the way down with nothing saying so: it
5
+ * resolved through `node_modules`, chunked TypeScript, and auto-installed any
6
+ * unknown name from npm. Common Rust and Haskell package names also exist on
7
+ * npm, so a question about `aeson` or `tokio` returned a confident answer about
8
+ * an unrelated JavaScript package — a wrong answer, not a miss.
9
+ *
10
+ * A row states the whole of what one registry needs: how to spot its manifest,
11
+ * how to find a package on disk, how to fetch one that is absent, and how to cut
12
+ * its source into retrievable chunks. Rows live in code and arrive as pull
13
+ * requests, so a new ecosystem is reviewable and testable rather than a user
14
+ * string that either works or silently does not.
15
+ */
16
+ import { spawn as nodeSpawn } from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { runAutoInstall, findDeclaredRange, extractParentPackage, resolveTypeSourceForDocs, getDocsModulesDir } from './docs-core.js';
21
+ import { resolvePackage, isDtsFile, isValidModuleName } from './docs-resolve.js';
22
+ import { DECL_SPLIT_RE } from './docs-chunk.js';
23
+ import { npmVersionLookup } from './npm-version.js';
24
+ import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, CARGO_DECL_SPLIT_RE } from './eco-cargo.js';
25
+ import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, findCabalTarball, cachedVersions, resolvedVersions, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS } from './eco-hackage.js';
26
+ import { runChild } from '../shared/child-process.js';
27
+ /**
28
+ * Is any of `names` present at `cwd` or above it?
29
+ *
30
+ * Detection has to reach as far as resolution does. Node resolves a package by
31
+ * walking `node_modules` UPWARD, and cargo and cabal read the nearest manifest
32
+ * above them, so a session started in `~/project/src` is in an npm project by
33
+ * every rule that matters — and a cwd-only check would refuse every lookup
34
+ * there.
35
+ */
36
+ function existsAtOrAbove(cwd, names) {
37
+ let dir = cwd;
38
+ while (true) {
39
+ if (names.some(n => fs.existsSync(path.join(dir, n))))
40
+ return true;
41
+ const up = path.dirname(dir);
42
+ if (up === dir)
43
+ return false;
44
+ dir = up;
45
+ }
46
+ }
47
+ /**
48
+ * The same walk, checking each ancestor's immediate children too.
49
+ *
50
+ * npm does not need this — node resolves straight up — but cargo and cabal keep
51
+ * their manifest in a SIBLING of where a session usually starts. From a Tauri
52
+ * repo's `src/`, the crate is in `../src-tauri`, and without the sideways step
53
+ * the project reads as npm-only and `tokio` resolves to npm's web scraper: the
54
+ * original bug, from one directory over.
55
+ */
56
+ function foundAtOrAbove(cwd, atDir) {
57
+ let dir = cwd;
58
+ while (true) {
59
+ if (atDir(dir) || childDirs(dir).some(atDir))
60
+ return true;
61
+ const up = path.dirname(dir);
62
+ if (up === dir)
63
+ return false;
64
+ dir = up;
65
+ }
66
+ }
67
+ /**
68
+ * Production defaults for {@link EcosystemIo}. The environment is read HERE and
69
+ * nowhere in a row, so a test injects a directory instead of setting a variable
70
+ * that outlives it.
71
+ */
72
+ export function defaultEcosystemIo(overrides = {}) {
73
+ return {
74
+ spawn: nodeSpawn,
75
+ fetch,
76
+ modulesDir: getDocsModulesDir(),
77
+ cargoHome: process.env.CARGO_HOME?.trim() || path.join(os.homedir(), '.cargo'),
78
+ cabalPackageDirs: defaultCabalPackageDirs(),
79
+ ...overrides
80
+ };
81
+ }
82
+ /** What acquiring a package produced, whatever the registry. */
83
+ /**
84
+ * Both cabal layouts, because a machine may have either: `CABAL_DIR` when set,
85
+ * then the classic `~/.cabal`, then the XDG path newer cabal versions use.
86
+ */
87
+ function defaultCabalPackageDirs() {
88
+ const home = os.homedir();
89
+ const configured = process.env.CABAL_DIR?.trim();
90
+ const cacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(home, '.cache');
91
+ return [
92
+ ...(configured ? [path.join(configured, 'packages')] : []),
93
+ path.join(home, '.cabal', 'packages'),
94
+ path.join(cacheHome, 'cabal', 'packages')
95
+ ];
96
+ }
97
+ /**
98
+ * The npm row, with the pieces a caller may have replaced left as parameters.
99
+ *
100
+ * `docsRaw` already takes `resolvePackage` and `npmVersionLookup` as injection
101
+ * hooks, and those hooks must keep reaching the resolution they are injected
102
+ * for. A per-call row carries them; {@link ECOSYSTEMS} holds the plain one.
103
+ */
104
+ export function npmProfile(hooks = {}) {
105
+ const resolve = hooks.resolvePackage ?? resolvePackage;
106
+ const versionLookup = hooks.npmVersionLookup ?? npmVersionLookup;
107
+ return {
108
+ id: 'npm',
109
+ why: "The original and only ecosystem this tool read. Resolution is Node's own "
110
+ + 'node_modules walk, the documented surface is the .d.ts files a package ships, '
111
+ + 'and a missing package is installed with --ignore-scripts because the name is '
112
+ + 'model-chosen.',
113
+ registryLabel: 'npm',
114
+ manifestLabel: 'package.json',
115
+ // `node_modules` without a package.json counts: a directory that has one
116
+ // is an npm project whether or not it declares itself.
117
+ detect: cwd => existsAtOrAbove(cwd, ['package.json', 'node_modules']),
118
+ isValidName: isValidModuleName,
119
+ parentPackage: extractParentPackage,
120
+ resolve: (name, cwd) => resolve(name, cwd),
121
+ declaredRange: findDeclaredRange,
122
+ acquire: (name, range, io) => runAutoInstall(io.spawn, name, {
123
+ signal: io.signal,
124
+ versionRange: range ?? undefined
125
+ }),
126
+ afterResolve: (pkg, requested, cwd, io) => resolveTypeSourceForDocs(pkg, requested, cwd, io.spawn, resolve, io.signal),
127
+ latest: (name, io) => versionLookup(name, io.signal === undefined ? {} : { signal: io.signal }),
128
+ isSurfaceFile: isDtsFile,
129
+ surface: content => content,
130
+ declSplitRe: DECL_SPLIT_RE,
131
+ commentPrefix: '//',
132
+ // A nested node_modules is another package's surface, never this one's.
133
+ skipDirs: ['node_modules'],
134
+ surfaceLabel: '.d.ts files or README',
135
+ packageSubject: 'an npm package',
136
+ projectGlobs: ['*.ts', '*.tsx'],
137
+ projectName: npmProjectName,
138
+ declaredDeps: npmDeclaredDeps
139
+ };
140
+ }
141
+ const NPM_DEP_BLOCKS = [
142
+ 'dependencies',
143
+ 'devDependencies',
144
+ 'peerDependencies',
145
+ 'optionalDependencies'
146
+ ];
147
+ /**
148
+ * The package.json manifest, not the lockfile: a lockfile is rewritten by
149
+ * installs that change no resolved version, and pruning digests on that would
150
+ * cost reuse for no correctness gain.
151
+ */
152
+ export function npmDeclaredDeps(cwd) {
153
+ let json;
154
+ try {
155
+ json = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ const out = {};
161
+ for (const block of NPM_DEP_BLOCKS) {
162
+ const deps = json[block];
163
+ if (!deps || typeof deps !== 'object')
164
+ continue;
165
+ for (const [name, range] of Object.entries(deps)) {
166
+ if (typeof range === 'string' && !(name in out))
167
+ out[name] = range;
168
+ }
169
+ }
170
+ // No dependency block at all is a real, stable state (a dependency-free repo).
171
+ return out;
172
+ }
173
+ /** A project's own name from its package.json, or null when it declares none. */
174
+ export function npmProjectName(cwd) {
175
+ try {
176
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
177
+ return pkg.name ?? null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * A crate's `.crate` tarball is fetched to a FILE and handed to `tar`, not piped:
185
+ * `runChild` writes only strings to a child's stdin, so gzip bytes cannot go
186
+ * through it. Windows ships bsdtar as `tar`, which reads `-xzf` the same way.
187
+ */
188
+ async function acquireCrate(name, version, io) {
189
+ const dir = path.join(io.modulesDir, 'cargo');
190
+ const crate = crateOf(name);
191
+ // A unique name per download. Research children run concurrently, and on a
192
+ // fixed path one child's post-extract delete lands between another's write
193
+ // and its `tar`, which then fails on a crate that was in fact fetched.
194
+ const archive = path.join(dir, `.${crate}-${version}.${process.pid}.${Date.now()}.crate`);
195
+ try {
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ const response = await io.fetch(crateTarballUrl(crate, version), {
198
+ headers: { 'user-agent': 'pi-task (github.com/mjasnikovs/pi-task)' },
199
+ ...(io.signal ? { signal: io.signal } : {})
200
+ });
201
+ if (!response.ok) {
202
+ return {
203
+ success: false,
204
+ installDir: dir,
205
+ stderr: `crates.io returned ${response.status} for ${crate} ${version}`
206
+ };
207
+ }
208
+ fs.writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
209
+ }
210
+ catch (err) {
211
+ return {
212
+ success: false,
213
+ installDir: dir,
214
+ stderr: err instanceof Error ? err.message : String(err)
215
+ };
216
+ }
217
+ return extractArchive(archive, dir, io);
218
+ }
219
+ /**
220
+ * Unpack an archive and then delete it. The extracted tree is what every later
221
+ * call reads, so keeping the tarball beside it only spends disk — and a `.tar.gz`
222
+ * sitting among the package directories trips anything that treats the extract
223
+ * directory as a list of packages.
224
+ */
225
+ async function extractArchive(archive, dir, io) {
226
+ const result = await runChild(io.spawn, { command: 'tar', args: ['-xzf', archive, '-C', dir] }, dir, io.signal, { mode: 'text', discardStdout: true });
227
+ const success = result.exitCode === 0 && !result.aborted;
228
+ if (success)
229
+ fs.rmSync(archive, { force: true });
230
+ return { success, installDir: dir, stderr: result.stderr };
231
+ }
232
+ const cargoProfile = {
233
+ id: 'cargo',
234
+ why: 'Rust ships no declarations file, so the surface is cut out of .rs source. '
235
+ + 'Versions come from Cargo.lock, not from a range: cargo has already resolved '
236
+ + 'them, and the newest wins when a workspace holds two majors — the answer '
237
+ + 'header states which was read. Note the asymmetry this leaves: in a repo with '
238
+ + 'both manifests the docs tool answers from cargo while the final gate still '
239
+ + 'runs the npm test command. Widening the gate is a separate change.',
240
+ registryLabel: 'crates.io',
241
+ manifestLabel: 'Cargo.toml',
242
+ // One level down as well: a Tauri repo declares package.json at the root and
243
+ // keeps its crate in `src-tauri/`.
244
+ detect: cwd => foundAtOrAbove(cwd, d => fs.existsSync(path.join(d, 'Cargo.toml'))),
245
+ isValidName: isValidCrateName,
246
+ parentPackage: crateOf,
247
+ resolve: (name, cwd, io) => resolveCrate(name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }),
248
+ // Cargo has already resolved every version; the lock IS the pin.
249
+ declaredRange: (name, cwd) => lockedVersion(name, cwd),
250
+ acquire: async (name, range, io) => {
251
+ // Asked even when the range is known: the download host wants the name as
252
+ // PUBLISHED, and only the API knows whether that is `tokio-util` or
253
+ // `tokio_util`. A null answer falls back to the caller's spelling.
254
+ const info = await cratesLatest(name, io.fetch, io.signal);
255
+ const version = range ?? info?.latest;
256
+ if (!version) {
257
+ return {
258
+ success: false,
259
+ installDir: path.join(io.modulesDir, 'cargo'),
260
+ stderr: `No published version found for crate "${crateOf(name)}".`
261
+ };
262
+ }
263
+ return acquireCrate(info?.pkg ?? name, version, io);
264
+ },
265
+ latest: (name, io) => cratesLatest(name, io.fetch, io.signal),
266
+ isSurfaceFile: isRustFile,
267
+ surface: content => rustSurface(content),
268
+ declSplitRe: CARGO_DECL_SPLIT_RE,
269
+ commentPrefix: '//',
270
+ skipDirs: ['tests', 'benches', 'examples', 'target'],
271
+ surfaceLabel: '.rs source or README',
272
+ packageSubject: 'a Rust crate from crates.io',
273
+ projectGlobs: ['*.rs'],
274
+ projectName: cargoProjectName,
275
+ declaredDeps: lockedDeps
276
+ };
277
+ /**
278
+ * Unpack a Hackage tarball into the tool's own directory. Whether it came from
279
+ * cabal's cache or from Hackage, a `.tar.gz` is not readable in place, and
280
+ * `runChild` writes only strings to stdin — so the archive is always a file on
281
+ * disk and `tar` always reads it from there. Windows ships bsdtar as `tar`.
282
+ */
283
+ async function extractTarball(archive, dir, io) {
284
+ // Cabal's own cached tarball must survive; only a copy this tool downloaded
285
+ // into its own directory is deleted after unpacking.
286
+ if (path.dirname(archive) !== dir) {
287
+ const result = await runChild(io.spawn, { command: 'tar', args: ['-xzf', archive, '-C', dir] }, dir, io.signal, { mode: 'text', discardStdout: true });
288
+ return {
289
+ success: result.exitCode === 0 && !result.aborted,
290
+ installDir: dir,
291
+ stderr: result.stderr
292
+ };
293
+ }
294
+ return extractArchive(archive, dir, io);
295
+ }
296
+ const hackageProfile = {
297
+ id: 'hackage',
298
+ why: 'A Hackage package is a tarball, not a checked-out tree, so it is unpacked '
299
+ + 'before it can be read — which is why acquire runs even for a package cabal '
300
+ + 'already holds. Versions come from what the build actually resolved '
301
+ + '(dist-newstyle/cache/plan.json), then a freeze file, then a stack lock. The '
302
+ + 'row refuses a dotted MODULE name outright: Data.Aeson is not a package, and '
303
+ + 'answering it from the wrong registry is the bug this whole table exists for.',
304
+ registryLabel: 'hackage',
305
+ manifestLabel: '*.cabal',
306
+ detect: cwd => foundAtOrAbove(cwd, hasCabalManifest),
307
+ isValidName: isValidHackageName,
308
+ parentPackage: name => name,
309
+ resolve: (name, cwd, io) => resolveHackage(name, cwd, { modulesDir: io.modulesDir }),
310
+ declaredRange: (name, cwd) => hackageVersion(name, cwd),
311
+ acquire: async (name, range, io) => {
312
+ const dir = hackageExtractDir(io.modulesDir);
313
+ const version = range
314
+ ?? cachedVersions(name, io.cabalPackageDirs).pop()
315
+ ?? (await hackageLatest(name, io.fetch, io.signal))?.latest;
316
+ if (!version) {
317
+ return {
318
+ success: false,
319
+ installDir: dir,
320
+ stderr: `No published version found for Hackage package "${name}".`
321
+ };
322
+ }
323
+ try {
324
+ fs.mkdirSync(dir, { recursive: true });
325
+ const cached = findCabalTarball(name, version, io.cabalPackageDirs);
326
+ if (cached)
327
+ return await extractTarball(cached, dir, io);
328
+ const response = await io.fetch(hackageTarballUrl(name, version), {
329
+ ...(io.signal ? { signal: io.signal } : {})
330
+ });
331
+ if (!response.ok) {
332
+ return {
333
+ success: false,
334
+ installDir: dir,
335
+ stderr: `hackage returned ${response.status} for ${name} ${version}`
336
+ };
337
+ }
338
+ const archive = path.join(dir, `.${name}-${version}.${process.pid}.${Date.now()}.tar.gz`);
339
+ fs.writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
340
+ return await extractTarball(archive, dir, io);
341
+ }
342
+ catch (err) {
343
+ return {
344
+ success: false,
345
+ installDir: dir,
346
+ stderr: err instanceof Error ? err.message : String(err)
347
+ };
348
+ }
349
+ },
350
+ latest: (name, io) => hackageLatest(name, io.fetch, io.signal),
351
+ isSurfaceFile: isHaskellFile,
352
+ surface: haskellSurface,
353
+ declSplitRe: HACKAGE_DECL_SPLIT_RE,
354
+ commentPrefix: '--',
355
+ skipDirs: HACKAGE_SKIP_DIRS,
356
+ surfaceLabel: '.hs source or README',
357
+ packageSubject: 'a Haskell package from Hackage',
358
+ projectGlobs: ['*.hs'],
359
+ projectName: hackageProjectName,
360
+ declaredDeps: resolvedVersions
361
+ };
362
+ /** A cabal, stack or hpack project declares itself with one of these. */
363
+ function hasCabalManifest(cwd) {
364
+ if (['cabal.project', 'stack.yaml', 'package.yaml'].some(f => fs.existsSync(path.join(cwd, f)))) {
365
+ return true;
366
+ }
367
+ try {
368
+ // A FILE named `<pkg>.cabal`. `~/.cabal` is cabal's own config DIRECTORY
369
+ // and ends in the same six characters, so a bare suffix test makes every
370
+ // directory under a Haskell developer's home look like a cabal project.
371
+ return fs
372
+ .readdirSync(cwd, { withFileTypes: true })
373
+ .some(e => e.isFile() && e.name.length > '.cabal'.length && e.name.endsWith('.cabal'));
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ }
379
+ export const ECOSYSTEMS = {
380
+ npm: npmProfile(),
381
+ cargo: cargoProfile,
382
+ hackage: hackageProfile
383
+ };
384
+ /** Which ecosystems `cwd` looks like a project of, in roster order. */
385
+ export function detectEcosystems(cwd, roster = Object.values(ECOSYSTEMS)) {
386
+ return roster.filter(p => p.detect(cwd)).map(p => p.id);
387
+ }
388
+ /**
389
+ * Which ecosystem a lookup belongs to. The MANIFEST decides, never the model:
390
+ * `text`, `base`, `aeson`, `tokio` and `clap` are all real npm packages as well
391
+ * as Haskell or Rust ones, so a name alone cannot say which registry was meant,
392
+ * and guessing npm returns a confident answer about the wrong package.
393
+ *
394
+ * A refusal is a result, not a failure: the caller reports it and installs
395
+ * nothing.
396
+ */
397
+ export function chooseEcosystem(input) {
398
+ const roster = input.roster ?? Object.values(ECOSYSTEMS);
399
+ const detected = detectEcosystems(input.cwd, roster);
400
+ const rowOf = (id) => roster.find(p => p.id === id);
401
+ const manifests = roster.map(p => p.manifestLabel).join(', ');
402
+ if (input.requested) {
403
+ if (detected.includes(input.requested)) {
404
+ return { ok: true, profile: rowOf(input.requested), detected };
405
+ }
406
+ const wanted = roster.find(p => p.id === input.requested);
407
+ return {
408
+ ok: false,
409
+ reason: 'not_detected',
410
+ detected,
411
+ message: `No ${input.requested} project here: ${input.cwd} has no `
412
+ + `${wanted?.manifestLabel ?? input.requested} manifest.`
413
+ };
414
+ }
415
+ if (detected.length === 0) {
416
+ return {
417
+ ok: false,
418
+ reason: 'none',
419
+ detected,
420
+ message: `${input.cwd} holds no package manifest this tool reads (${manifests}), `
421
+ + 'so there is no registry to look the name up in and nothing was installed.'
422
+ };
423
+ }
424
+ if (detected.length === 1)
425
+ return { ok: true, profile: rowOf(detected[0]), detected };
426
+ // Several manifests. A row whose MANIFEST names the package wins: in a Tauri
427
+ // repo `semver` is pinned by Cargo.lock at 1.0.28 and merely sits in
428
+ // node_modules as a transitive copy nothing declared, and taking the npm one
429
+ // because npm leads the roster is the issue-#18 mistake with both packages
430
+ // installed instead of neither.
431
+ const declaring = input.declaresPackage ? detected.filter(id => input.declaresPackage(rowOf(id))) : [];
432
+ if (declaring.length === 1)
433
+ return { ok: true, profile: rowOf(declaring[0]), detected };
434
+ if (declaring.length === 0 && input.resolvesLocally) {
435
+ // Nobody declares it. A copy on disk is the only evidence left.
436
+ for (const id of detected) {
437
+ if (input.resolvesLocally(rowOf(id)))
438
+ return { ok: true, profile: rowOf(id), detected };
439
+ }
440
+ }
441
+ return {
442
+ ok: false,
443
+ reason: 'ambiguous',
444
+ detected,
445
+ message: `${input.cwd} holds manifests for ${detected.join(' and ')}, and this package `
446
+ + 'is installed in none of them, so which registry to read is not decidable. '
447
+ + `Pass ecosystem: "${detected[0]}" (or one of: ${detected.join(', ')}) to say which.`
448
+ };
449
+ }
@@ -1,9 +1,10 @@
1
1
  import type { CacheHandle } from './docs-cache.js';
2
2
  import { type ResolvedPackage } from './docs-resolve.js';
3
+ import { type EcosystemProfile } from './docs-ecosystems.js';
3
4
  export interface IndexResult {
4
5
  hitCache: boolean;
5
6
  filesIngested: number;
6
7
  chunksWritten: number;
7
8
  contentHash: string;
8
9
  }
9
- export declare function ensureIndexed(cache: CacheHandle, pkg: ResolvedPackage): IndexResult;
10
+ export declare function ensureIndexed(cache: CacheHandle, pkg: ResolvedPackage, profile?: EcosystemProfile): IndexResult;
@@ -1,15 +1,39 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
- import { isDtsFile } from './docs-resolve.js';
4
+ import {} from './docs-resolve.js';
5
5
  import { chunkDeclarations, chunkReadme } from './docs-chunk.js';
6
+ import { ECOSYSTEMS } from './docs-ecosystems.js';
6
7
  const ZERO_SEP = Buffer.from([0]);
7
- function computeContentHash(pkg) {
8
+ /**
9
+ * The gate that decides whether a package needs re-indexing.
10
+ *
11
+ * The entry file goes in SURFACED, not raw. What is cached is the extractor's
12
+ * OUTPUT, so a build whose extractor changed has stale chunks even though every
13
+ * byte on disk is identical — a crate indexed before the braced-`use` fix keeps
14
+ * `pub use crate::runtime::;` forever, because name, version and file bytes all
15
+ * still match. Surfacing here costs one file and makes the hash answer the
16
+ * question actually being asked: would re-reading produce the same chunks?
17
+ *
18
+ * The CHUNKER counts too, for the same reason: the rows are chunks, not surface,
19
+ * so a fix to where a declaration is cut leaves stale rows behind on its own.
20
+ *
21
+ * It is not total. An extractor change that alters only files BELOW the entry
22
+ * goes unnoticed; deleting the cache is still the escape hatch for that.
23
+ */
24
+ function computeContentHash(pkg, profile) {
8
25
  const hash = createHash('sha256');
9
26
  hash.update(Buffer.from(`${pkg.name}@${pkg.version}`, 'utf8'));
10
27
  hash.update(ZERO_SEP);
11
- if (pkg.entryDts && fs.existsSync(pkg.entryDts)) {
12
- hash.update(fs.readFileSync(pkg.entryDts));
28
+ hash.update(Buffer.from(`${profile.declSplitRe.source}\u0000${profile.commentPrefix}`, 'utf8'));
29
+ hash.update(ZERO_SEP);
30
+ if (pkg.entry && fs.existsSync(pkg.entry)) {
31
+ try {
32
+ hash.update(Buffer.from(profile.surface(fs.readFileSync(pkg.entry, 'utf8')), 'utf8'));
33
+ }
34
+ catch {
35
+ hash.update(fs.readFileSync(pkg.entry));
36
+ }
13
37
  }
14
38
  hash.update(ZERO_SEP);
15
39
  if (pkg.readme && fs.existsSync(pkg.readme)) {
@@ -17,7 +41,7 @@ function computeContentHash(pkg) {
17
41
  }
18
42
  return hash.digest('hex');
19
43
  }
20
- function walkDts(root) {
44
+ function walkSurface(root, profile) {
21
45
  const out = [];
22
46
  const stack = [root];
23
47
  while (stack.length) {
@@ -30,7 +54,7 @@ function walkDts(root) {
30
54
  continue;
31
55
  }
32
56
  for (const entry of entries) {
33
- if (entry.name === 'node_modules')
57
+ if (profile.skipDirs.includes(entry.name))
34
58
  continue;
35
59
  const full = path.join(dir, entry.name);
36
60
  if (entry.isSymbolicLink()) {
@@ -47,37 +71,40 @@ function walkDts(root) {
47
71
  const stat = fs.statSync(realPath);
48
72
  if (stat.isDirectory())
49
73
  stack.push(realPath);
50
- else if (stat.isFile() && isDtsFile(realPath))
74
+ else if (stat.isFile() && profile.isSurfaceFile(realPath))
51
75
  out.push(realPath);
52
76
  continue;
53
77
  }
54
78
  if (entry.isDirectory())
55
79
  stack.push(full);
56
- else if (entry.isFile() && isDtsFile(entry.name))
80
+ else if (entry.isFile() && profile.isSurfaceFile(entry.name))
57
81
  out.push(full);
58
82
  }
59
83
  }
60
84
  return out.sort();
61
85
  }
62
- function collectFiles(pkg) {
86
+ function collectFiles(pkg, profile) {
63
87
  return {
64
- dts: walkDts(pkg.root),
88
+ surface: walkSurface(pkg.root, profile),
65
89
  readme: pkg.readme
66
90
  };
67
91
  }
68
- function ingestBody(cache, pkg, contentHash) {
92
+ function ingestBody(cache, pkg, profile, contentHash) {
93
+ const ecosystem = profile.id;
69
94
  const inside = cache.db
70
- .prepare('SELECT content_hash FROM packages WHERE name = ? AND version = ?')
71
- .get(pkg.name, pkg.version);
95
+ .prepare('SELECT content_hash FROM packages WHERE ecosystem = ? AND name = ? AND version = ?')
96
+ .get(ecosystem, pkg.name, pkg.version);
72
97
  if (inside && inside.content_hash === contentHash) {
73
98
  return { hitCache: true, filesIngested: 0, chunksWritten: 0 };
74
99
  }
75
- cache.db.prepare('DELETE FROM chunks WHERE name = ? AND version = ?').run(pkg.name, pkg.version);
76
- const files = collectFiles(pkg);
100
+ cache.db
101
+ .prepare('DELETE FROM chunks WHERE ecosystem = ? AND name = ? AND version = ?')
102
+ .run(ecosystem, pkg.name, pkg.version);
103
+ const files = collectFiles(pkg, profile);
77
104
  let chunksWritten = 0;
78
105
  let filesIngested = 0;
79
- const insertChunk = cache.db.prepare('INSERT INTO chunks (name, version, file_path, kind, content) VALUES (?, ?, ?, ?, ?)');
80
- for (const abs of files.dts) {
106
+ const insertChunk = cache.db.prepare('INSERT INTO chunks (ecosystem, name, version, file_path, kind, content) VALUES (?, ?, ?, ?, ?, ?)');
107
+ for (const abs of files.surface) {
81
108
  // Normalise the separator before storing, so the same package indexes to
82
109
  // the same rows whatever built the path. The value is a MODEL-FACING
83
110
  // label: chunkDeclarations turns it into the chunk's `// <path>` header,
@@ -90,12 +117,12 @@ function ingestBody(cache, pkg, contentHash) {
90
117
  catch {
91
118
  continue;
92
119
  }
93
- const chunks = chunkDeclarations(raw, rel);
120
+ const chunks = chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix);
94
121
  if (!chunks.length)
95
122
  continue;
96
123
  filesIngested++;
97
124
  for (const c of chunks) {
98
- insertChunk.run(pkg.name, pkg.version, rel, 'dts', c);
125
+ insertChunk.run(ecosystem, pkg.name, pkg.version, rel, 'dts', c);
99
126
  chunksWritten++;
100
127
  }
101
128
  }
@@ -106,28 +133,29 @@ function ingestBody(cache, pkg, contentHash) {
106
133
  if (chunks.length) {
107
134
  filesIngested++;
108
135
  for (const c of chunks) {
109
- insertChunk.run(pkg.name, pkg.version, rel, 'readme', c);
136
+ insertChunk.run(ecosystem, pkg.name, pkg.version, rel, 'readme', c);
110
137
  chunksWritten++;
111
138
  }
112
139
  }
113
140
  }
114
141
  cache.db
115
- .prepare('INSERT OR REPLACE INTO packages (name, version, content_hash, indexed_at) VALUES (?, ?, ?, ?)')
116
- .run(pkg.name, pkg.version, contentHash, Date.now());
142
+ .prepare('INSERT OR REPLACE INTO packages (ecosystem, name, version, content_hash, indexed_at) VALUES (?, ?, ?, ?, ?)')
143
+ .run(ecosystem, pkg.name, pkg.version, contentHash, Date.now());
117
144
  return { hitCache: false, filesIngested, chunksWritten };
118
145
  }
119
- export function ensureIndexed(cache, pkg) {
120
- const contentHash = computeContentHash(pkg);
146
+ export function ensureIndexed(cache, pkg, profile = ECOSYSTEMS[pkg.ecosystem]) {
147
+ const ecosystem = profile.id;
148
+ const contentHash = computeContentHash(pkg, profile);
121
149
  const existing = cache.db
122
- .prepare('SELECT content_hash FROM packages WHERE name = ? AND version = ?')
123
- .get(pkg.name, pkg.version);
150
+ .prepare('SELECT content_hash FROM packages WHERE ecosystem = ? AND name = ? AND version = ?')
151
+ .get(ecosystem, pkg.name, pkg.version);
124
152
  if (existing && existing.content_hash === contentHash) {
125
153
  return { hitCache: true, filesIngested: 0, chunksWritten: 0, contentHash };
126
154
  }
127
155
  cache.db.exec('BEGIN IMMEDIATE');
128
156
  let result;
129
157
  try {
130
- result = ingestBody(cache, pkg, contentHash);
158
+ result = ingestBody(cache, pkg, profile, contentHash);
131
159
  cache.db.exec('COMMIT');
132
160
  }
133
161
  catch (err) {
@@ -2,7 +2,15 @@ import type { CacheHandle } from './docs-cache.js';
2
2
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
3
3
  import type { RetrievedChunk } from './docs-retrieve.js';
4
4
  import type { DocsCorpus } from './docs-lookup.js';
5
+ /**
6
+ * Scope value for project-source rows. It sits in the same column as a registry
7
+ * id because these rows share the tables, but it is NOT one: a project is keyed
8
+ * by a hash of its cwd, so no registry could name it.
9
+ */
10
+ export declare const PROJECT_SCOPE = "project";
5
11
  export declare function getProjectName(cwd: string): string;
12
+ /** The extensions a given project is indexed for — `.ts/.tsx`, `.rs`, `.hs`. */
13
+ export declare function projectSourceLabel(cwd: string): string;
6
14
  export declare function cwdKey(cwd: string): string;
7
15
  /**
8
16
  * Which source files make up the project.
@@ -48,6 +56,8 @@ export type ProjectDocsRawResult = {
48
56
  version: string;
49
57
  hitCache: boolean;
50
58
  filesIngested: number;
59
+ /** The extensions this project was actually searched for, e.g. `.rs`. */
60
+ sourceLabel: string;
51
61
  } | {
52
62
  kind: 'error';
53
63
  projectName: string;