@mjasnikovs/pi-task 0.39.4 → 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.
- package/README.md +20 -5
- package/dist/task/auto-orchestrator.d.ts +36 -0
- package/dist/task/auto-orchestrator.js +43 -6
- package/dist/task/cancel-points.d.ts +34 -6
- package/dist/task/cancel-points.js +62 -10
- package/dist/task/child-status.js +13 -1
- package/dist/task/context-attribution.js +18 -6
- package/dist/task/external-context.d.ts +8 -1
- package/dist/task/external-context.js +52 -4
- package/dist/task/orchestrator.js +47 -5
- package/dist/task/phases.js +2 -1
- package/dist/task/plan-orchestrator.js +10 -1
- package/dist/task/prompts.d.ts +7 -1
- package/dist/task/prompts.js +14 -4
- package/dist/task/research-worker.js +11 -0
- package/dist/task/run-bracket.js +13 -1
- package/dist/task/task-gates.js +34 -0
- package/dist/workers/docs-cache.js +50 -3
- package/dist/workers/docs-chunk.d.ts +6 -3
- package/dist/workers/docs-chunk.js +8 -5
- package/dist/workers/docs-core.d.ts +27 -3
- package/dist/workers/docs-core.js +104 -41
- package/dist/workers/docs-ecosystems.d.ts +173 -0
- package/dist/workers/docs-ecosystems.js +449 -0
- package/dist/workers/docs-index.d.ts +2 -1
- package/dist/workers/docs-index.js +55 -27
- package/dist/workers/docs-project.d.ts +10 -0
- package/dist/workers/docs-project.js +86 -24
- package/dist/workers/docs-resolve.d.ts +6 -1
- package/dist/workers/docs-resolve.js +4 -3
- package/dist/workers/docs-retrieve.d.ts +2 -0
- package/dist/workers/docs-retrieve.js +11 -11
- package/dist/workers/eco-cargo.d.ts +115 -0
- package/dist/workers/eco-cargo.js +793 -0
- package/dist/workers/eco-hackage.d.ts +93 -0
- package/dist/workers/eco-hackage.js +508 -0
- package/dist/workers/npm-version.d.ts +5 -3
- package/dist/workers/npm-version.js +6 -4
- package/dist/workers/pi-worker-docs.d.ts +18 -4
- package/dist/workers/pi-worker-docs.js +57 -19
- package/dist/workers/research-cache.d.ts +2 -13
- package/dist/workers/research-cache.js +22 -46
- package/dist/workers/shared.d.ts +16 -5
- package/dist/workers/shared.js +0 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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() &&
|
|
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() &&
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
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.
|
|
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
|
|
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;
|