@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.
- package/README.md +18 -3
- 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/phases.js +2 -1
- package/dist/task/prompts.d.ts +7 -1
- package/dist/task/prompts.js +14 -4
- 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,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
|
-
* `###
|
|
48
|
-
* when the date is known, and a `recent:` line only when the list is
|
|
49
|
-
|
|
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
|
-
* `###
|
|
77
|
-
* when the date is known, and a `recent:` line only when the list is
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
}):
|
|
99
|
+
}, details: Pick<DocsDetails, 'ecosystem'>): CachePackage | undefined;
|
|
86
100
|
export {};
|