@mjasnikovs/pi-task 0.39.5 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +24 -9
  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,93 @@
1
+ /**
2
+ * eco-hackage — reading Haskell packages for the docs Worker tool.
3
+ *
4
+ * Two things make Haskell unlike the other rows.
5
+ *
6
+ * A package is distributed as a tarball, not as a checked-out tree: cabal keeps
7
+ * `<name>-<version>.tar.gz` and nothing else, so a package has to be extracted
8
+ * before it can be read. Extraction is a spawn, and a row's `resolve` is
9
+ * synchronous, so it happens in `acquire` — which is also where a package cabal
10
+ * has never fetched is downloaded from Hackage. Both paths end the same way: an
11
+ * extracted tree under the tool's own modules directory.
12
+ *
13
+ * And a Haskell MODULE name is not its PACKAGE name. `Data.Aeson` lives in
14
+ * `aeson`; asking Hackage for `Data.Aeson` finds nothing. That is the mistake
15
+ * issue #18 opened on, so it is refused by name with the correction in the
16
+ * message rather than silently missing.
17
+ */
18
+ import { type ResolvedPackage } from './docs-resolve.js';
19
+ import type { NpmVersionInfo } from './npm-version.js';
20
+ export declare function isValidHackageName(name: string): boolean;
21
+ /** True for a dotted Haskell MODULE name, which is never a package name. */
22
+ export declare function looksLikeModuleName(name: string): boolean;
23
+ /**
24
+ * Every package version this build resolved, name to version.
25
+ *
26
+ * Three sources in falling order of authority. `dist-newstyle/cache/plan.json` is
27
+ * what cabal actually built and is exact. A `cabal.project.freeze` is what the
28
+ * project asked for and is exact too, but may predate the build. `stack.yaml.lock`
29
+ * covers a stack project, which has no plan.json at all. None of them present is
30
+ * a real answer — the project pins nothing here — and the caller then falls back
31
+ * to whatever Hackage calls latest.
32
+ */
33
+ export declare function resolvedVersions(cwd: string): Record<string, string> | undefined;
34
+ export declare function parsePlanJson(text: string): Record<string, string> | undefined;
35
+ /**
36
+ * `cabal.project.freeze` states each pin as `any.<name> ==<version>`. Scanned
37
+ * across the whole file rather than line by line: cabal writes the first
38
+ * constraint on the `constraints:` line itself and the rest indented below it.
39
+ */
40
+ export declare function parseFreeze(text: string): Record<string, string>;
41
+ /** `stack.yaml.lock` names each extra dep as `hackage: <name>-<version>@sha256:…`. */
42
+ export declare function parseStackLock(text: string): Record<string, string>;
43
+ export declare function hackageVersion(name: string, cwd: string): string | null;
44
+ export declare function hackageTarballUrl(name: string, version: string): string;
45
+ /** The tarball cabal has already downloaded, in any of its package directories. */
46
+ export declare function findCabalTarball(name: string, version: string, packageDirs: readonly string[]): string | null;
47
+ /** Every version of `name` cabal holds a tarball for, oldest first. */
48
+ export declare function cachedVersions(name: string, packageDirs: readonly string[]): string[];
49
+ /** Where the tool keeps its own extracted copies. */
50
+ export declare function hackageExtractDir(modulesDir: string): string;
51
+ /** Library sources only — `tests/` and `benchmarks/` answer no API question. */
52
+ export declare const HACKAGE_SKIP_DIRS: readonly ["tests", "test", "benchmarks", "bench", "examples", "dist-newstyle", "golden"];
53
+ export interface HackageResolveDirs {
54
+ modulesDir: string;
55
+ }
56
+ /**
57
+ * Find an EXTRACTED package. A tarball cabal holds is not readable in place, and
58
+ * unpacking it is a spawn, so getting it into this state is `acquire`'s job.
59
+ */
60
+ export declare function resolveHackage(name: string, cwd: string, dirs: HackageResolveDirs): ResolvedPackage;
61
+ /**
62
+ * The newest version Hackage does not deprecate. `/preferred` returns them newest
63
+ * first, with the deprecated ones in their own list — so this never names a
64
+ * version the maintainer has withdrawn.
65
+ */
66
+ export declare function hackageLatest(name: string, fetchFn: typeof fetch, signal?: AbortSignal): Promise<NpmVersionInfo | null>;
67
+ /** Where a Haskell declaration begins, so a chunk never splits a signature. */
68
+ export declare const HACKAGE_DECL_SPLIT_RE: RegExp;
69
+ /**
70
+ * Reduce Haskell source to its public API surface: the module header with its
71
+ * export list, every top-level type signature, every type and class
72
+ * declaration, and the haddock attached to each.
73
+ *
74
+ * Equations go. In Haskell the signature IS the interface and the equations
75
+ * below it are the implementation, so dropping them loses nothing a caller can
76
+ * name — and an `instance` body is the same thing under another keyword, which
77
+ * is why only its head survives.
78
+ */
79
+ /**
80
+ * Blank out `{- … -}` block comments, nesting included, keeping line count.
81
+ *
82
+ * Without this, code inside a comment is read as real API. `vector`'s
83
+ * `thawMany` is commented out and surfaced as a genuine signature sitting
84
+ * between two real functions — a plausible declaration for a function that does
85
+ * not exist, which is the worst answer this tool can give.
86
+ *
87
+ * `{-|` and `{-^` are haddock, not commentary, and are handled by the caller.
88
+ */
89
+ export declare function stripBlockComments(src: string): string;
90
+ export declare function haskellSurface(rawSrc: string): string;
91
+ export declare function isHaskellFile(name: string): boolean;
92
+ /** The `name:` field of the project's own `.cabal` file. */
93
+ export declare function hackageProjectName(cwd: string): string | null;
@@ -0,0 +1,508 @@
1
+ /**
2
+ * eco-hackage — reading Haskell packages for the docs Worker tool.
3
+ *
4
+ * Two things make Haskell unlike the other rows.
5
+ *
6
+ * A package is distributed as a tarball, not as a checked-out tree: cabal keeps
7
+ * `<name>-<version>.tar.gz` and nothing else, so a package has to be extracted
8
+ * before it can be read. Extraction is a spawn, and a row's `resolve` is
9
+ * synchronous, so it happens in `acquire` — which is also where a package cabal
10
+ * has never fetched is downloaded from Hackage. Both paths end the same way: an
11
+ * extracted tree under the tool's own modules directory.
12
+ *
13
+ * And a Haskell MODULE name is not its PACKAGE name. `Data.Aeson` lives in
14
+ * `aeson`; asking Hackage for `Data.Aeson` finds nothing. That is the mistake
15
+ * issue #18 opened on, so it is refused by name with the correction in the
16
+ * message rather than silently missing.
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { ResolveError } from './docs-resolve.js';
21
+ import { findAtOrAbove } from './eco-cargo.js';
22
+ const HACKAGE = 'https://hackage.haskell.org/package';
23
+ /** Where cabal files a downloaded tarball, under any of its package directories. */
24
+ const HACKAGE_REPO = 'hackage.haskell.org';
25
+ export function isValidHackageName(name) {
26
+ return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(name);
27
+ }
28
+ /** True for a dotted Haskell MODULE name, which is never a package name. */
29
+ export function looksLikeModuleName(name) {
30
+ return /^[A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)+$/.test(name);
31
+ }
32
+ function safeRead(file) {
33
+ try {
34
+ return fs.readFileSync(file, 'utf8');
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** The first existing file at `rel` at or above `cwd`, siblings included. */
41
+ function findUpOrDown(cwd, rel) {
42
+ return findAtOrAbove(cwd, ...rel);
43
+ }
44
+ /**
45
+ * Every package version this build resolved, name to version.
46
+ *
47
+ * Three sources in falling order of authority. `dist-newstyle/cache/plan.json` is
48
+ * what cabal actually built and is exact. A `cabal.project.freeze` is what the
49
+ * project asked for and is exact too, but may predate the build. `stack.yaml.lock`
50
+ * covers a stack project, which has no plan.json at all. None of them present is
51
+ * a real answer — the project pins nothing here — and the caller then falls back
52
+ * to whatever Hackage calls latest.
53
+ */
54
+ export function resolvedVersions(cwd) {
55
+ const plan = findUpOrDown(cwd, ['dist-newstyle', 'cache', 'plan.json']);
56
+ if (plan) {
57
+ const parsed = parsePlanJson(safeRead(plan) ?? '');
58
+ if (parsed)
59
+ return parsed;
60
+ }
61
+ const freeze = findUpOrDown(cwd, ['cabal.project.freeze']);
62
+ if (freeze) {
63
+ const parsed = parseFreeze(safeRead(freeze) ?? '');
64
+ if (Object.keys(parsed).length)
65
+ return parsed;
66
+ }
67
+ const stackLock = findUpOrDown(cwd, ['stack.yaml.lock']);
68
+ if (stackLock) {
69
+ const parsed = parseStackLock(safeRead(stackLock) ?? '');
70
+ if (Object.keys(parsed).length)
71
+ return parsed;
72
+ }
73
+ return undefined;
74
+ }
75
+ export function parsePlanJson(text) {
76
+ let body;
77
+ try {
78
+ body = JSON.parse(text);
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ const plan = body['install-plan'];
84
+ if (!Array.isArray(plan))
85
+ return undefined;
86
+ const out = {};
87
+ for (const entry of plan) {
88
+ const name = entry['pkg-name'];
89
+ const version = entry['pkg-version'];
90
+ if (typeof name === 'string' && typeof version === 'string')
91
+ out[name] = version;
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * `cabal.project.freeze` states each pin as `any.<name> ==<version>`. Scanned
97
+ * across the whole file rather than line by line: cabal writes the first
98
+ * constraint on the `constraints:` line itself and the rest indented below it.
99
+ */
100
+ export function parseFreeze(text) {
101
+ const out = {};
102
+ const re = /\bany\.([A-Za-z0-9-]+)\s*==\s*([0-9][0-9.]*)/g;
103
+ let m;
104
+ while ((m = re.exec(text)))
105
+ out[m[1]] = m[2];
106
+ return out;
107
+ }
108
+ /** `stack.yaml.lock` names each extra dep as `hackage: <name>-<version>@sha256:…`. */
109
+ export function parseStackLock(text) {
110
+ const out = {};
111
+ for (const raw of text.split('\n')) {
112
+ const m = /hackage:\s*([A-Za-z0-9-]+)-([0-9][0-9.]*)@/.exec(raw);
113
+ if (m)
114
+ out[m[1]] = m[2];
115
+ }
116
+ return out;
117
+ }
118
+ export function hackageVersion(name, cwd) {
119
+ return resolvedVersions(cwd)?.[name] ?? null;
120
+ }
121
+ export function hackageTarballUrl(name, version) {
122
+ return `${HACKAGE}/${name}-${version}/${name}-${version}.tar.gz`;
123
+ }
124
+ /** The tarball cabal has already downloaded, in any of its package directories. */
125
+ export function findCabalTarball(name, version, packageDirs) {
126
+ for (const dir of packageDirs) {
127
+ const candidate = path.join(dir, HACKAGE_REPO, name, version, `${name}-${version}.tar.gz`);
128
+ if (fs.existsSync(candidate))
129
+ return candidate;
130
+ }
131
+ return null;
132
+ }
133
+ /** Every version of `name` cabal holds a tarball for, oldest first. */
134
+ export function cachedVersions(name, packageDirs) {
135
+ const found = new Set();
136
+ for (const dir of packageDirs) {
137
+ try {
138
+ for (const entry of fs.readdirSync(path.join(dir, HACKAGE_REPO, name))) {
139
+ if (/^[0-9]/.test(entry))
140
+ found.add(entry);
141
+ }
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ }
147
+ return [...found].sort(compareVersions);
148
+ }
149
+ function compareVersions(a, b) {
150
+ const pa = a.split('.').map(Number);
151
+ const pb = b.split('.').map(Number);
152
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
153
+ const d = (pa[i] || 0) - (pb[i] || 0);
154
+ if (d !== 0)
155
+ return d;
156
+ }
157
+ return 0;
158
+ }
159
+ /** Where the tool keeps its own extracted copies. */
160
+ export function hackageExtractDir(modulesDir) {
161
+ return path.join(modulesDir, 'hackage');
162
+ }
163
+ function newestExtracted(dir, name) {
164
+ let best = null;
165
+ try {
166
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
167
+ if (!entry.isDirectory())
168
+ continue;
169
+ const cut = entry.name.lastIndexOf('-');
170
+ if (cut < 0 || entry.name.slice(0, cut) !== name)
171
+ continue;
172
+ const version = entry.name.slice(cut + 1);
173
+ if (!/^\d/.test(version))
174
+ continue;
175
+ if (!best || compareVersions(version, best.version) > 0) {
176
+ best = { root: path.join(dir, entry.name), version };
177
+ }
178
+ }
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ return best;
184
+ }
185
+ const README_NAMES = ['README.md', 'README.markdown', 'readme.md', 'ReadMe.md', 'changelog.md'];
186
+ function readmeIn(root) {
187
+ for (const name of README_NAMES) {
188
+ const abs = path.join(root, name);
189
+ if (fs.existsSync(abs))
190
+ return abs;
191
+ }
192
+ return null;
193
+ }
194
+ /**
195
+ * The module that carries the package's headline API — `aeson` documents itself
196
+ * in `Data/Aeson.hs`. Matched by the last path segment, since a package name is
197
+ * lowercase and its module path is not.
198
+ */
199
+ function entryIn(root, name) {
200
+ const wanted = `${name.replace(/-/g, '').toLowerCase()}.hs`;
201
+ const files = walkHaskell(root);
202
+ const headline = files.find(f => path.basename(f).toLowerCase() === wanted);
203
+ if (headline)
204
+ return headline;
205
+ return files[0] ?? null;
206
+ }
207
+ /** Library sources only — `tests/` and `benchmarks/` answer no API question. */
208
+ export const HACKAGE_SKIP_DIRS = [
209
+ 'tests',
210
+ 'test',
211
+ 'benchmarks',
212
+ 'bench',
213
+ 'examples',
214
+ 'dist-newstyle',
215
+ 'golden'
216
+ ];
217
+ function walkHaskell(root) {
218
+ const out = [];
219
+ const stack = [root];
220
+ while (stack.length) {
221
+ const dir = stack.pop();
222
+ let entries;
223
+ try {
224
+ entries = fs.readdirSync(dir, { withFileTypes: true });
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ for (const entry of entries) {
230
+ if (entry.isDirectory()) {
231
+ if (!HACKAGE_SKIP_DIRS.includes(entry.name)) {
232
+ stack.push(path.join(dir, entry.name));
233
+ }
234
+ }
235
+ else if (entry.isFile() && entry.name.endsWith('.hs')) {
236
+ out.push(path.join(dir, entry.name));
237
+ }
238
+ }
239
+ }
240
+ return out.sort();
241
+ }
242
+ /**
243
+ * Find an EXTRACTED package. A tarball cabal holds is not readable in place, and
244
+ * unpacking it is a spawn, so getting it into this state is `acquire`'s job.
245
+ */
246
+ export function resolveHackage(name, cwd, dirs) {
247
+ if (looksLikeModuleName(name)) {
248
+ throw new ResolveError('invalid_name', `"${name}" is a Haskell MODULE name. Pass the Hackage PACKAGE that ships it `
249
+ + '(for example "aeson", not "Data.Aeson").');
250
+ }
251
+ if (!isValidHackageName(name)) {
252
+ throw new ResolveError('invalid_name', `Invalid Hackage package name: "${name}"`);
253
+ }
254
+ const extractDir = hackageExtractDir(dirs.modulesDir);
255
+ const pinned = hackageVersion(name, cwd);
256
+ const exact = pinned ? path.join(extractDir, `${name}-${pinned}`) : null;
257
+ // A pin that is not unpacked is not_installed, NOT an invitation to answer from
258
+ // whatever version another build left behind. Nothing marks that substitution,
259
+ // so the version banner stays empty and the swap reaches the model silently.
260
+ const found = exact ?
261
+ fs.existsSync(exact) ?
262
+ { root: exact, version: pinned }
263
+ : null
264
+ : newestExtracted(extractDir, name);
265
+ if (!found) {
266
+ throw new ResolveError('not_installed', `Hackage package "${name}"${pinned ? ` v${pinned}` : ''} is not unpacked under `
267
+ + `${extractDir}.`);
268
+ }
269
+ return {
270
+ ecosystem: 'hackage',
271
+ name,
272
+ version: found.version,
273
+ root: found.root,
274
+ entry: entryIn(found.root, name),
275
+ readme: readmeIn(found.root)
276
+ };
277
+ }
278
+ /**
279
+ * The newest version Hackage does not deprecate. `/preferred` returns them newest
280
+ * first, with the deprecated ones in their own list — so this never names a
281
+ * version the maintainer has withdrawn.
282
+ */
283
+ export async function hackageLatest(name, fetchFn, signal) {
284
+ if (!isValidHackageName(name))
285
+ return null;
286
+ try {
287
+ const response = await fetchFn(`${HACKAGE}/${encodeURIComponent(name)}/preferred`, {
288
+ headers: { accept: 'application/json' },
289
+ ...(signal ? { signal } : {})
290
+ });
291
+ if (!response.ok)
292
+ return null;
293
+ const body = (await response.json());
294
+ const versions = body['normal-version'];
295
+ if (!Array.isArray(versions) || typeof versions[0] !== 'string')
296
+ return null;
297
+ return { pkg: name, latest: versions[0], recent: versions.slice(0, 10) };
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ }
303
+ // ── surface extraction ──────────────────────────────────────────────────────
304
+ /** Where a Haskell declaration begins, so a chunk never splits a signature. */
305
+ export const HACKAGE_DECL_SPLIT_RE = /^(?:[a-z_][\w']*\s*::|data\s|newtype\s|type\s|class\s|instance\s|pattern\s)/m;
306
+ const SIGNATURE_RE = /^[a-z_][\w']*(?:\s*,\s*[a-z_][\w']*)*\s*::/;
307
+ const OPERATOR_SIGNATURE_RE = /^\([^)]+\)\s*::/;
308
+ /** The same heads with the `::` wrapped onto the next line. */
309
+ const BARE_NAME_RE = /^(?:[a-z_][\w']*|\([^)]+\))(?:\s*,\s*(?:[a-z_][\w']*|\([^)]+\)))*\s*$/;
310
+ const TYPE_HEAD_RE = /^(data|newtype|type|class|instance|pattern|foreign import)\b/;
311
+ const HADDOCK_RE = /^--\s*[|^]/;
312
+ /**
313
+ * Reduce Haskell source to its public API surface: the module header with its
314
+ * export list, every top-level type signature, every type and class
315
+ * declaration, and the haddock attached to each.
316
+ *
317
+ * Equations go. In Haskell the signature IS the interface and the equations
318
+ * below it are the implementation, so dropping them loses nothing a caller can
319
+ * name — and an `instance` body is the same thing under another keyword, which
320
+ * is why only its head survives.
321
+ */
322
+ /**
323
+ * Blank out `{- … -}` block comments, nesting included, keeping line count.
324
+ *
325
+ * Without this, code inside a comment is read as real API. `vector`'s
326
+ * `thawMany` is commented out and surfaced as a genuine signature sitting
327
+ * between two real functions — a plausible declaration for a function that does
328
+ * not exist, which is the worst answer this tool can give.
329
+ *
330
+ * `{-|` and `{-^` are haddock, not commentary, and are handled by the caller.
331
+ */
332
+ export function stripBlockComments(src) {
333
+ const out = [];
334
+ let depth = 0;
335
+ for (const line of src.split('\n')) {
336
+ let kept = '';
337
+ let i = 0;
338
+ while (i < line.length) {
339
+ if (line.startsWith('{-', i) && !isHaddockOpen(line, i)) {
340
+ depth++;
341
+ i += 2;
342
+ continue;
343
+ }
344
+ if (line.startsWith('-}', i) && depth > 0) {
345
+ depth--;
346
+ i += 2;
347
+ continue;
348
+ }
349
+ // A `--` line comment outside a block runs to end of line, and may
350
+ // legitimately contain `{-`.
351
+ if (depth === 0 && line.startsWith('--', i)) {
352
+ kept += line.slice(i);
353
+ break;
354
+ }
355
+ if (depth === 0)
356
+ kept += line[i];
357
+ i++;
358
+ }
359
+ out.push(kept);
360
+ }
361
+ return out.join('\n');
362
+ }
363
+ /** `{-|` and `{-^` open haddock; `{-#` opens a pragma, which is not a comment. */
364
+ function isHaddockOpen(line, i) {
365
+ const c = line[i + 2];
366
+ return c === '|' || c === '^' || c === '#';
367
+ }
368
+ /** A `{-| … -}` haddock block, flattened to the `-- |` form the rest of the pass keeps. */
369
+ function haddockBlockAt(lines, start) {
370
+ if (!/^\{-[|^]/.test(lines[start].trim()))
371
+ return null;
372
+ const text = [];
373
+ for (let i = start; i < lines.length; i++) {
374
+ const closed = lines[i].includes('-}');
375
+ text.push(`-- ${lines[i]
376
+ .replace(/^\s*\{-[|^]?/, '')
377
+ .replace(/-\}\s*$/, '')
378
+ .trim()}`);
379
+ if (closed)
380
+ return { text: text.filter(l => l.trim() !== '--'), next: i + 1 };
381
+ }
382
+ return { text: text.filter(l => l.trim() !== '--'), next: lines.length };
383
+ }
384
+ export function haskellSurface(rawSrc) {
385
+ // Haddock blocks survive; ordinary `{- … -}` commentary does not.
386
+ const src = stripBlockComments(rawSrc);
387
+ const lines = src.split('\n');
388
+ const out = [];
389
+ let i;
390
+ // The export list is the module's own statement of its API, and it spans
391
+ // however many lines it takes to balance the parentheses.
392
+ const moduleStart = lines.findIndex(l => /^module\s/.test(l));
393
+ if (moduleStart >= 0) {
394
+ // The `{-| Module : … -}` header sits ABOVE the `module` keyword, and for
395
+ // most Hackage packages it IS the headline documentation. Emitting from
396
+ // `module` down dropped every line of it.
397
+ for (let h = 0; h < moduleStart; h++) {
398
+ const block = haddockBlockAt(lines, h);
399
+ if (!block)
400
+ continue;
401
+ out.push(...block.text);
402
+ h = block.next - 1;
403
+ }
404
+ if (out.length)
405
+ out.push('');
406
+ let depth = 0;
407
+ let seenParen = false;
408
+ for (i = moduleStart; i < lines.length; i++) {
409
+ out.push(lines[i]);
410
+ for (const c of lines[i]) {
411
+ if (c === '(') {
412
+ depth++;
413
+ seenParen = true;
414
+ }
415
+ else if (c === ')')
416
+ depth--;
417
+ }
418
+ if (/\bwhere\b/.test(lines[i]) && (!seenParen || depth <= 0)) {
419
+ i++;
420
+ break;
421
+ }
422
+ }
423
+ out.push('');
424
+ }
425
+ else {
426
+ i = 0;
427
+ }
428
+ let pending = [];
429
+ while (i < lines.length) {
430
+ const line = lines[i];
431
+ if (line.trim() === '') {
432
+ i++;
433
+ continue;
434
+ }
435
+ if (/^\s/.test(line)) {
436
+ // A continuation with no head above it belongs to something dropped.
437
+ i++;
438
+ continue;
439
+ }
440
+ if (HADDOCK_RE.test(line)) {
441
+ pending.push(line);
442
+ i++;
443
+ continue;
444
+ }
445
+ // `{-| … -}` is how most modules write their documentation, and the
446
+ // module header block above all. Dropping it loses the part a reader
447
+ // actually wants.
448
+ const haddockBlock = haddockBlockAt(lines, i);
449
+ if (haddockBlock) {
450
+ pending.push(...haddockBlock.text);
451
+ i = haddockBlock.next;
452
+ continue;
453
+ }
454
+ if (line.startsWith('--') || line.startsWith('{-#') || /^import\s/.test(line)) {
455
+ pending = [];
456
+ i++;
457
+ continue;
458
+ }
459
+ const block = [line];
460
+ let j = i + 1;
461
+ while (j < lines.length && (lines[j].trim() === '' || /^\s/.test(lines[j]))) {
462
+ block.push(lines[j]);
463
+ j++;
464
+ }
465
+ // Drop the blank tail the block picked up on its way to the next head.
466
+ while (block.length && block[block.length - 1].trim() === '')
467
+ block.pop();
468
+ // A signature whose `::` starts the CONTINUATION line is the same
469
+ // declaration, and it is how most multi-constraint signatures are written:
470
+ // decode
471
+ // :: FromJSON a
472
+ // => ByteString -> Maybe a
473
+ // Matching the head line alone drops those, and their haddock with them.
474
+ const wrappedSignature = BARE_NAME_RE.test(line) && (block[1]?.trim().startsWith('::') ?? false);
475
+ if (SIGNATURE_RE.test(line) || OPERATOR_SIGNATURE_RE.test(line) || wrappedSignature) {
476
+ out.push(...pending, ...block, '');
477
+ }
478
+ else if (TYPE_HEAD_RE.test(line)) {
479
+ const head = TYPE_HEAD_RE.exec(line)[1];
480
+ // An instance's indented lines are definitions, not fields.
481
+ out.push(...pending, ...(head === 'instance' ? [line] : block), '');
482
+ }
483
+ pending = [];
484
+ i = j;
485
+ }
486
+ return out
487
+ .join('\n')
488
+ .replace(/\n{3,}/g, '\n\n')
489
+ .trim();
490
+ }
491
+ export function isHaskellFile(name) {
492
+ return name.endsWith('.hs');
493
+ }
494
+ /** The `name:` field of the project's own `.cabal` file. */
495
+ export function hackageProjectName(cwd) {
496
+ let entries;
497
+ try {
498
+ entries = fs.readdirSync(cwd);
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ const cabal = entries.find(e => e.endsWith('.cabal'));
504
+ if (!cabal)
505
+ return null;
506
+ const match = /^\s*name\s*:\s*(\S+)/m.exec(safeRead(path.join(cwd, cabal)) ?? '');
507
+ return match ? match[1] : null;
508
+ }
@@ -44,6 +44,8 @@ export interface NpmVersionOpts {
44
44
  */
45
45
  export declare function npmVersionLookup(pkg: string, opts?: NpmVersionOpts): Promise<NpmVersionInfo | null>;
46
46
  /** Format an NpmVersionInfo as a short Markdown block for EXTERNAL CONTEXT: a
47
- * `### npm: <pkg>` heading, a `latest:` line that gains ` (published YYYY-MM-DD)`
48
- * when the date is known, and a `recent:` line only when the list is non-empty. */
49
- export declare function formatNpmVersionSection(info: NpmVersionInfo): string;
47
+ * `### <registry>: <pkg>` heading, a `latest:` line that gains ` (published
48
+ * YYYY-MM-DD)` when the date is known, and a `recent:` line only when the list is
49
+ * non-empty. The label defaults to npm, which is where every caller but the docs
50
+ * tool's non-npm rows reads from. */
51
+ export declare function formatNpmVersionSection(info: NpmVersionInfo, label?: string): string;
@@ -73,10 +73,12 @@ export async function npmVersionLookup(pkg, opts = {}) {
73
73
  }
74
74
  }
75
75
  /** Format an NpmVersionInfo as a short Markdown block for EXTERNAL CONTEXT: a
76
- * `### npm: <pkg>` heading, a `latest:` line that gains ` (published YYYY-MM-DD)`
77
- * when the date is known, and a `recent:` line only when the list is non-empty. */
78
- export function formatNpmVersionSection(info) {
79
- const lines = [`### npm: ${info.pkg}`, `latest: ${info.latest}`];
76
+ * `### <registry>: <pkg>` heading, a `latest:` line that gains ` (published
77
+ * YYYY-MM-DD)` when the date is known, and a `recent:` line only when the list is
78
+ * non-empty. The label defaults to npm, which is where every caller but the docs
79
+ * tool's non-npm rows reads from. */
80
+ export function formatNpmVersionSection(info, label = 'npm') {
81
+ const lines = [`### ${label}: ${info.pkg}`, `latest: ${info.latest}`];
80
82
  if (info.publishedAt) {
81
83
  const date = info.publishedAt.slice(0, 10);
82
84
  lines[1] += ` (published ${date})`;
@@ -3,8 +3,10 @@ import { openCache as defaultOpenCache } from './docs-cache.js';
3
3
  import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
4
4
  import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
+ import { type EcosystemId } from './docs-ecosystems.js';
6
7
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
7
8
  import type { SpawnFn } from '../shared/child-process.js';
9
+ import { type CachePackage } from './shared.js';
8
10
  interface DocsDetails {
9
11
  version?: string;
10
12
  hitCache?: boolean;
@@ -13,7 +15,9 @@ interface DocsDetails {
13
15
  childExitCode?: number;
14
16
  indexingMs?: number;
15
17
  indexedFiles?: number;
16
- resolveError?: 'not_installed' | 'invalid_name';
18
+ /** Which registry the answer was read from. Absent for the project-source path. */
19
+ ecosystem?: EcosystemId;
20
+ resolveError?: 'not_installed' | 'invalid_name' | 'unsupported_ecosystem' | 'ambiguous_ecosystem';
17
21
  cacheError?: string;
18
22
  aborted?: boolean;
19
23
  autoInstalled?: boolean;
@@ -73,14 +77,24 @@ export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerpt
73
77
  /** The docs cache key: a package's answer is per (module, question), with the question
74
78
  * lowercased and its whitespace collapsed so phrasing variants share one entry. Returns
75
79
  * null for the project-source `.` lookup, which is never cached — the working tree
76
- * mutates as tasks implement. */
80
+ * mutates as tasks implement.
81
+ *
82
+ * The ecosystem joins the key only when the caller named one, so the keys of every
83
+ * call that lets the manifest decide are the ones they always were. */
77
84
  export declare function docsCacheKey(params: {
78
85
  module: string;
79
86
  query: string;
87
+ ecosystem?: string;
80
88
  }): string | null;
81
89
  /** Package provenance for per-entry resume invalidation: the package ROOT of the
82
- * specifier (`hono/client` → `hono`), and undefined for the project-source `.`. */
90
+ * specifier (`hono/client` → `hono`), and undefined for the project-source `.`.
91
+ *
92
+ * The ecosystem comes from the RESOLVED lookup, not from the optional argument.
93
+ * Reading the argument would record npm for every single-manifest cargo or cabal
94
+ * project — the ordinary case, since the argument only exists to disambiguate a
95
+ * polyglot repo — and the entry's version would then be looked for in a
96
+ * `package.json` that is not there, leaving it un-prunable forever. */
83
97
  export declare function docsCachePkg(params: {
84
98
  module: string;
85
- }): string | undefined;
99
+ }, details: Pick<DocsDetails, 'ecosystem'>): CachePackage | undefined;
86
100
  export {};