@mjasnikovs/pi-task 0.40.28 → 0.40.30

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-4508%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-4631%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -201,11 +201,14 @@ Resolves an installed package, indexes its API surface and README into a local S
201
201
  | `npm` | `package.json`, or a `node_modules/` directory | the `.d.ts` files the package ships, plus README | the installed `package.json` |
202
202
  | `cargo` | `Cargo.toml`, at the directory or one level below it | `.rs` source reduced to public item heads, doc comments and attributes | `Cargo.lock` |
203
203
  | `hackage` | `*.cabal`, `cabal.project`, `stack.yaml` or `package.yaml` | `.hs` source reduced to the export list, signatures and type declarations | `dist-newstyle/cache/plan.json`, then `cabal.project.freeze`, then `stack.yaml.lock` |
204
+ | `go` | `go.mod` or `go.work`, at the directory, above it or one level below | `.go` source reduced to exported declarations, struct fields, interface methods and doc comments | `go.mod`, which since Go 1.17 carries the resolved closure |
204
205
 
205
206
  - **No manifest, no lookup.** In a directory with none of the above the tool refuses, spawns nothing and installs nothing, and points you at `pi-worker-search` / `pi-worker-fetch` instead.
206
207
  - **Two manifests** (a Tauri app, say) are resolved by whichever registry already has the package on disk. If neither does, the call is refused as ambiguous and you pass `ecosystem: "cargo"` to say which.
207
- - A package the project does not have is fetched once into a dedicated cache dir: `npm install --ignore-scripts` for npm, the `.crate` tarball for cargo, the Hackage tarball (or cabal's own cached copy) for hackage.
208
+ - A package the project does not have is fetched once into a dedicated cache dir: `npm install --ignore-scripts` for npm, the `.crate` tarball for cargo, the Hackage tarball (or cabal's own cached copy) for hackage, the module zip from `proxy.golang.org` for go.
208
209
  - A Haskell **module** name is refused by name — `Data.Aeson` is not a package, `aeson` is.
210
+ - For **go you pass the import path**, `github.com/gin-gonic/gin/binding` or `net/http`, because that is what a Go file names. The module serving it is worked out by asking the proxy for the longest prefix that resolves — `gin/binding` belongs to `gin`, while `aws-sdk-go-v2/service/s3` is its own module. Vendored source and the local module cache are read first and cost nothing.
211
+ - The **Go standard library** is not on the proxy. It is read from a local `GOROOT` where there is one, and otherwise sliced out of the `golang.org/toolchain` archive over HTTP range requests — about 2 MB for `net/http` against 83 MB for the archive.
209
212
  - The first call for a `(ecosystem, package, version)` triple pays a one-time ingestion cost; later calls are FTS-only.
210
213
  - Cache lives at `${XDG_CACHE_HOME:-~/.cache}/pi-worker/docs.sqlite` — delete it to reset.
211
214
 
@@ -260,7 +263,7 @@ them checked in.
260
263
 
261
264
  ```sh
262
265
  bun install
263
- bun run test # 4508 tests pass across 243 files (1 skip)
266
+ bun run test # 4631 tests pass across 247 files (1 skip)
264
267
  bun run lint # prettier + eslint + tsc --noEmit
265
268
  bun run build # tsc → dist/
266
269
  ```
@@ -0,0 +1,86 @@
1
+ /**
2
+ * zip — reading a zip archive in-process, whole or a slice at a time.
3
+ *
4
+ * Go module zips are why this exists. `unzip` is absent from a default Windows
5
+ * install and GNU tar cannot read zip at all, so shelling out behaves
6
+ * differently on every machine the agent runs on.
7
+ *
8
+ * The slice half is what makes the Go standard library affordable. It ships
9
+ * inside an 83 MB toolchain archive of which any one package is under a
10
+ * megabyte, and a reader that can pull the central directory and then only the
11
+ * entries it wants leaves the other 82 MB on the server.
12
+ */
13
+ /** The comment is a 16-bit length, so the EOCD starts no further back than this. */
14
+ export declare const EOCD_SEARCH_SPAN: number;
15
+ export interface ZipEntry {
16
+ name: string;
17
+ method: number;
18
+ compressedSize: number;
19
+ uncompressedSize: number;
20
+ /** Offset of the entry's LOCAL header, which is not where its data begins. */
21
+ localHeaderOffset: number;
22
+ }
23
+ interface CentralDirectoryLocation {
24
+ offset: number;
25
+ size: number;
26
+ }
27
+ /**
28
+ * Where the central directory is, given the tail of the file.
29
+ *
30
+ * `tailStart` is that tail's offset in the whole archive, so a caller that
31
+ * fetched only the last few kilobytes still gets absolute offsets back.
32
+ */
33
+ export declare function findCentralDirectory(tail: Buffer, tailStart?: number): CentralDirectoryLocation;
34
+ /**
35
+ * The entries a central directory declares.
36
+ *
37
+ * Sizes come from HERE and never from a local header: every Go module zip sets
38
+ * the data-descriptor flag, which zeroes the size and CRC fields in the local
39
+ * header and moves the real values to a trailer after the data.
40
+ */
41
+ export declare function parseCentralDirectory(cd: Buffer): ZipEntry[];
42
+ /**
43
+ * Where an entry's compressed bytes start, read from its LOCAL header.
44
+ *
45
+ * The local header's name and extra lengths are its own and may differ from the
46
+ * central directory's — deriving this from the central record produces an offset
47
+ * that is silently a few bytes wrong.
48
+ */
49
+ export declare function dataOffset(local: Buffer, at: number): number;
50
+ /** Decompress one entry's raw bytes. */
51
+ export declare function inflateEntry(raw: Buffer, method: number): Buffer;
52
+ /** Every entry of an archive held whole in memory. */
53
+ export declare function readZip(archive: Buffer): ZipEntry[];
54
+ /** One entry's decompressed content, from an archive held whole in memory. */
55
+ export declare function readEntry(archive: Buffer, entry: ZipEntry): Buffer;
56
+ /**
57
+ * True for an entry path that would write outside its destination directory.
58
+ * A module zip has never carried one; a malicious archive is the reason to look.
59
+ */
60
+ export declare function isUnsafeEntryName(name: string): boolean;
61
+ export interface ExtractOptions {
62
+ /** Which entries to write. Directory entries are absent from a module zip. */
63
+ filter?: (entry: ZipEntry) => boolean;
64
+ /** Path segments to drop from the front of every name, for a prefixed archive. */
65
+ strip?: number;
66
+ }
67
+ /** Write an in-memory archive's entries under `dest`. Returns what was written. */
68
+ export declare function extractZip(archive: Buffer, dest: string, options?: ExtractOptions): string[];
69
+ /** Fetch `[start, end]` inclusive, the way an HTTP Range request is written. */
70
+ export type RangeFetch = (start: number, end: number) => Promise<Buffer>;
71
+ /**
72
+ * The entries of a remote archive, read from its tail alone.
73
+ *
74
+ * Two requests: the tail, then the central directory. `size` is the archive's
75
+ * total length, which a HEAD or the first ranged response reports.
76
+ */
77
+ export declare function readRemoteZip(size: number, fetchRange: RangeFetch): Promise<ZipEntry[]>;
78
+ /**
79
+ * Fetch some entries of a remote archive, coalescing neighbours into one request.
80
+ *
81
+ * Zip entries are laid out in central-directory order, and the entries of one
82
+ * directory are almost always adjacent, so a package's files come back as a
83
+ * single contiguous read.
84
+ */
85
+ export declare function readRemoteEntries(entries: readonly ZipEntry[], fetchRange: RangeFetch): Promise<Map<string, Buffer>>;
86
+ export {};
@@ -0,0 +1,251 @@
1
+ /**
2
+ * zip — reading a zip archive in-process, whole or a slice at a time.
3
+ *
4
+ * Go module zips are why this exists. `unzip` is absent from a default Windows
5
+ * install and GNU tar cannot read zip at all, so shelling out behaves
6
+ * differently on every machine the agent runs on.
7
+ *
8
+ * The slice half is what makes the Go standard library affordable. It ships
9
+ * inside an 83 MB toolchain archive of which any one package is under a
10
+ * megabyte, and a reader that can pull the central directory and then only the
11
+ * entries it wants leaves the other 82 MB on the server.
12
+ */
13
+ import { inflateRawSync } from 'node:zlib';
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ const EOCD_SIG = 0x06054b50;
17
+ const ZIP64_EOCD_SIG = 0x06064b50;
18
+ const ZIP64_LOCATOR_SIG = 0x07064b50;
19
+ const CDFH_SIG = 0x02014b50;
20
+ const EOCD_SIZE = 22;
21
+ const ZIP64_LOCATOR_SIZE = 20;
22
+ const LOCAL_HEADER_FIXED = 30;
23
+ /** A 32-bit field carrying this asks to be read from the zip64 extra field. */
24
+ const ZIP64_SENTINEL = 0xffffffff;
25
+ /** The comment is a 16-bit length, so the EOCD starts no further back than this. */
26
+ export const EOCD_SEARCH_SPAN = 0xffff + EOCD_SIZE;
27
+ const STORED = 0;
28
+ const DEFLATED = 8;
29
+ /**
30
+ * Where the central directory is, given the tail of the file.
31
+ *
32
+ * `tailStart` is that tail's offset in the whole archive, so a caller that
33
+ * fetched only the last few kilobytes still gets absolute offsets back.
34
+ */
35
+ export function findCentralDirectory(tail, tailStart = 0) {
36
+ const eocd = lastIndexOfSignature(tail, EOCD_SIG);
37
+ if (eocd < 0)
38
+ throw new Error('not a zip archive: no end-of-central-directory record');
39
+ const size = tail.readUInt32LE(eocd + 12);
40
+ const offset = tail.readUInt32LE(eocd + 16);
41
+ if (size !== ZIP64_SENTINEL && offset !== ZIP64_SENTINEL)
42
+ return { offset, size };
43
+ const locator = eocd - ZIP64_LOCATOR_SIZE;
44
+ if (locator < 0 || tail.readUInt32LE(locator) !== ZIP64_LOCATOR_SIG) {
45
+ throw new Error('zip64 archive without a zip64 locator');
46
+ }
47
+ const zip64Eocd = Number(tail.readBigUInt64LE(locator + 8)) - tailStart;
48
+ if (zip64Eocd < 0 || tail.readUInt32LE(zip64Eocd) !== ZIP64_EOCD_SIG) {
49
+ throw new Error('zip64 end-of-central-directory record is outside the read span');
50
+ }
51
+ return {
52
+ size: Number(tail.readBigUInt64LE(zip64Eocd + 40)),
53
+ offset: Number(tail.readBigUInt64LE(zip64Eocd + 48))
54
+ };
55
+ }
56
+ function lastIndexOfSignature(buf, sig) {
57
+ for (let i = buf.length - 4; i >= 0; i--) {
58
+ if (buf.readUInt32LE(i) === sig)
59
+ return i;
60
+ }
61
+ return -1;
62
+ }
63
+ /**
64
+ * The entries a central directory declares.
65
+ *
66
+ * Sizes come from HERE and never from a local header: every Go module zip sets
67
+ * the data-descriptor flag, which zeroes the size and CRC fields in the local
68
+ * header and moves the real values to a trailer after the data.
69
+ */
70
+ export function parseCentralDirectory(cd) {
71
+ const entries = [];
72
+ let at = 0;
73
+ while (at + 46 <= cd.length && cd.readUInt32LE(at) === CDFH_SIG) {
74
+ const nameLen = cd.readUInt16LE(at + 28);
75
+ const extraLen = cd.readUInt16LE(at + 30);
76
+ const commentLen = cd.readUInt16LE(at + 32);
77
+ const entry = {
78
+ name: cd.toString('utf8', at + 46, at + 46 + nameLen),
79
+ method: cd.readUInt16LE(at + 10),
80
+ compressedSize: cd.readUInt32LE(at + 20),
81
+ uncompressedSize: cd.readUInt32LE(at + 24),
82
+ localHeaderOffset: cd.readUInt32LE(at + 42)
83
+ };
84
+ applyZip64Extra(entry, cd.subarray(at + 46 + nameLen, at + 46 + nameLen + extraLen));
85
+ entries.push(entry);
86
+ at += 46 + nameLen + extraLen + commentLen;
87
+ }
88
+ return entries;
89
+ }
90
+ /**
91
+ * Zip64 widens only the fields that overflowed, and packs them in that order.
92
+ * Reading a fixed layout would misplace every value on an entry that overflowed
93
+ * one field but not the one before it.
94
+ */
95
+ function applyZip64Extra(entry, extra) {
96
+ let at = 0;
97
+ while (at + 4 <= extra.length) {
98
+ const id = extra.readUInt16LE(at);
99
+ const size = extra.readUInt16LE(at + 2);
100
+ if (id !== 0x0001) {
101
+ at += 4 + size;
102
+ continue;
103
+ }
104
+ let field = at + 4;
105
+ const next = () => {
106
+ const v = Number(extra.readBigUInt64LE(field));
107
+ field += 8;
108
+ return v;
109
+ };
110
+ if (entry.uncompressedSize === ZIP64_SENTINEL)
111
+ entry.uncompressedSize = next();
112
+ if (entry.compressedSize === ZIP64_SENTINEL)
113
+ entry.compressedSize = next();
114
+ if (entry.localHeaderOffset === ZIP64_SENTINEL)
115
+ entry.localHeaderOffset = next();
116
+ return;
117
+ }
118
+ }
119
+ /**
120
+ * Where an entry's compressed bytes start, read from its LOCAL header.
121
+ *
122
+ * The local header's name and extra lengths are its own and may differ from the
123
+ * central directory's — deriving this from the central record produces an offset
124
+ * that is silently a few bytes wrong.
125
+ */
126
+ export function dataOffset(local, at) {
127
+ return at + LOCAL_HEADER_FIXED + local.readUInt16LE(at + 26) + local.readUInt16LE(at + 28);
128
+ }
129
+ /** Decompress one entry's raw bytes. */
130
+ export function inflateEntry(raw, method) {
131
+ if (method === STORED)
132
+ return raw;
133
+ if (method === DEFLATED)
134
+ return inflateRawSync(raw);
135
+ throw new Error(`unsupported zip compression method ${method}`);
136
+ }
137
+ /** Every entry of an archive held whole in memory. */
138
+ export function readZip(archive) {
139
+ const tailStart = Math.max(0, archive.length - EOCD_SEARCH_SPAN);
140
+ const { offset, size } = findCentralDirectory(archive.subarray(tailStart), tailStart);
141
+ return parseCentralDirectory(archive.subarray(offset, offset + size));
142
+ }
143
+ /** One entry's decompressed content, from an archive held whole in memory. */
144
+ export function readEntry(archive, entry) {
145
+ const start = dataOffset(archive, entry.localHeaderOffset);
146
+ return inflateEntry(archive.subarray(start, start + entry.compressedSize), entry.method);
147
+ }
148
+ /**
149
+ * True for an entry path that would write outside its destination directory.
150
+ * A module zip has never carried one; a malicious archive is the reason to look.
151
+ */
152
+ export function isUnsafeEntryName(name) {
153
+ if (name === '' || path.isAbsolute(name) || /^[A-Za-z]:/.test(name))
154
+ return true;
155
+ return name.split(/[/\\]/).some(seg => seg === '..');
156
+ }
157
+ /** Write an in-memory archive's entries under `dest`. Returns what was written. */
158
+ export function extractZip(archive, dest, options = {}) {
159
+ const written = [];
160
+ for (const entry of readZip(archive)) {
161
+ if (entry.name.endsWith('/'))
162
+ continue;
163
+ if (options.filter && !options.filter(entry))
164
+ continue;
165
+ if (isUnsafeEntryName(entry.name))
166
+ continue;
167
+ const rel = stripSegments(entry.name, options.strip ?? 0);
168
+ if (rel === null)
169
+ continue;
170
+ const target = path.join(dest, rel);
171
+ fs.mkdirSync(path.dirname(target), { recursive: true });
172
+ fs.writeFileSync(target, readEntry(archive, entry));
173
+ written.push(target);
174
+ }
175
+ return written;
176
+ }
177
+ function stripSegments(name, count) {
178
+ if (count === 0)
179
+ return name;
180
+ const parts = name.split('/');
181
+ return parts.length > count ? parts.slice(count).join('/') : null;
182
+ }
183
+ /**
184
+ * The entries of a remote archive, read from its tail alone.
185
+ *
186
+ * Two requests: the tail, then the central directory. `size` is the archive's
187
+ * total length, which a HEAD or the first ranged response reports.
188
+ */
189
+ export async function readRemoteZip(size, fetchRange) {
190
+ const tailStart = Math.max(0, size - EOCD_SEARCH_SPAN);
191
+ const tail = await fetchRange(tailStart, size - 1);
192
+ const cd = findCentralDirectory(tail, tailStart);
193
+ const held = cd.offset - tailStart;
194
+ const bytes = held >= 0 && held + cd.size <= tail.length ?
195
+ tail.subarray(held, held + cd.size)
196
+ : await fetchRange(cd.offset, cd.offset + cd.size - 1);
197
+ return parseCentralDirectory(bytes);
198
+ }
199
+ /**
200
+ * Room for a local header whose name and extra fields we have not read yet.
201
+ * Go's zip writer emits no local extra field, so this is slack, not a guess at a
202
+ * real value — an entry that overruns it is re-fetched exactly.
203
+ */
204
+ const LOCAL_HEADER_SLACK = 4096;
205
+ /**
206
+ * A gap worth paying for rather than opening a second connection. Well under the
207
+ * bytes a TCP slow-start ramp wastes on an extra round trip.
208
+ */
209
+ const COALESCE_GAP = 64 * 1024;
210
+ /**
211
+ * Fetch some entries of a remote archive, coalescing neighbours into one request.
212
+ *
213
+ * Zip entries are laid out in central-directory order, and the entries of one
214
+ * directory are almost always adjacent, so a package's files come back as a
215
+ * single contiguous read.
216
+ */
217
+ export async function readRemoteEntries(entries, fetchRange) {
218
+ const out = new Map();
219
+ const ordered = [...entries].sort((a, b) => a.localHeaderOffset - b.localHeaderOffset);
220
+ for (const span of coalesce(ordered)) {
221
+ const buf = await fetchRange(span.start, span.end);
222
+ for (const entry of span.entries) {
223
+ const at = entry.localHeaderOffset - span.start;
224
+ const from = dataOffset(buf, at);
225
+ const to = from + entry.compressedSize;
226
+ const raw = to <= buf.length ? buf.subarray(from, to) : await refetch(entry, fetchRange);
227
+ out.set(entry.name, inflateEntry(raw, entry.method));
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+ async function refetch(entry, fetchRange) {
233
+ const head = await fetchRange(entry.localHeaderOffset, entry.localHeaderOffset + LOCAL_HEADER_FIXED - 1);
234
+ const start = dataOffset(head, 0) + entry.localHeaderOffset;
235
+ return fetchRange(start, start + entry.compressedSize - 1);
236
+ }
237
+ function coalesce(ordered) {
238
+ const spans = [];
239
+ for (const entry of ordered) {
240
+ const start = entry.localHeaderOffset;
241
+ const end = start + LOCAL_HEADER_FIXED + LOCAL_HEADER_SLACK + entry.compressedSize - 1;
242
+ const last = spans[spans.length - 1];
243
+ if (last && start - last.end <= COALESCE_GAP) {
244
+ last.end = Math.max(last.end, end);
245
+ last.entries.push(entry);
246
+ continue;
247
+ }
248
+ spans.push({ start, end, entries: [entry] });
249
+ }
250
+ return spans;
251
+ }
@@ -283,7 +283,7 @@ export async function acquirePackage(input) {
283
283
  const pin = declaredRange ?
284
284
  { source: 'declared-range', range: declaredRange, asked }
285
285
  : { source: 'npm-latest', asked };
286
- const install = await profile.acquire(asked, declaredRange, io);
286
+ const install = await profile.acquire(asked, declaredRange, cwd, io);
287
287
  if (!install.success) {
288
288
  return { ok: false, stage: 'install', stderr: install.stderr, pin, asked };
289
289
  }
@@ -18,7 +18,7 @@ import { resolvePackage, type ResolvedPackage } from './docs-resolve.js';
18
18
  import { npmVersionLookup, type NpmVersionInfo } from './npm-version.js';
19
19
  import { type SpawnFn } from '../shared/child-process.js';
20
20
  import type { ExportGap } from './export-gap.js';
21
- export type EcosystemId = 'npm' | 'cargo' | 'hackage';
21
+ export type EcosystemId = 'npm' | 'cargo' | 'hackage' | 'go';
22
22
  /**
23
23
  * Every filesystem, process and network reach a row is allowed. Rows read no
24
24
  * environment and call no global directly, so a test injects a fake registry and
@@ -33,6 +33,10 @@ export interface EcosystemIo {
33
33
  cargoHome: string;
34
34
  /** Every directory cabal may have filed a downloaded tarball under. */
35
35
  cabalPackageDirs: readonly string[];
36
+ /** Root of the Go module cache — `GOMODCACHE`, or its default. */
37
+ goModCache: string;
38
+ /** A local Go installation, whose `src/` is the standard library. */
39
+ goroot: string | undefined;
36
40
  signal?: AbortSignal | undefined;
37
41
  }
38
42
  /**
@@ -63,8 +67,14 @@ export interface EcosystemProfile {
63
67
  resolve: (name: string, cwd: string, io: EcosystemIo) => ResolvedPackage;
64
68
  /** The version range the project pins this package to, if it pins one. */
65
69
  declaredRange: (name: string, cwd: string) => string | null;
66
- /** Fetch a package that is not on disk into `io.modulesDir`. */
67
- acquire: (name: string, range: string | null, io: EcosystemIo) => Promise<AcquireResult>;
70
+ /**
71
+ * Fetch a package that is not on disk into `io.modulesDir`.
72
+ *
73
+ * `cwd` is the project, not the destination: Go reads its `go` directive to
74
+ * pick which toolchain's standard library an answer should describe, and to
75
+ * tell `net/http` from a dotless module's own `myapp/internal/db`.
76
+ */
77
+ acquire: (name: string, range: string | null, cwd: string, io: EcosystemIo) => Promise<AcquireResult>;
68
78
  /**
69
79
  * A second resolution hop, for ecosystems where the package that ships the
70
80
  * documented surface is not the one that was asked for.
@@ -122,6 +132,15 @@ export interface EcosystemProfile {
122
132
  commentPrefix: string;
123
133
  /** Directories the surface walk never descends into: tests, build output. */
124
134
  skipDirs: readonly string[];
135
+ /**
136
+ * Narrow the walked files to one copy of each declaration.
137
+ *
138
+ * Two ecosystems ship the same API twice in one package: npm as `.d.ts` and
139
+ * `.d.cts` twins, Go as build-tag variants of one file. Both cost the
140
+ * retrieval budget for text the reader already has, and neither is
141
+ * separable downstream — same identifiers, same package, same version.
142
+ */
143
+ selectFiles?: (files: readonly string[], pkg: ResolvedPackage) => string[];
125
144
  /** What this ecosystem's packages ship, for a "there is nothing to read" answer. */
126
145
  surfaceLabel: string;
127
146
  /**
@@ -167,6 +186,7 @@ export declare const ECOSYSTEMS: {
167
186
  readonly npm: EcosystemProfile;
168
187
  readonly cargo: EcosystemProfile;
169
188
  readonly hackage: EcosystemProfile;
189
+ readonly go: EcosystemProfile;
170
190
  };
171
191
  /** Which ecosystems `cwd` looks like a project of, in roster order. */
172
192
  export declare function detectEcosystems(cwd: string, roster?: readonly EcosystemProfile[]): EcosystemId[];
@@ -20,9 +20,12 @@ import * as path from 'node:path';
20
20
  import { runAutoInstall, findDeclaredRange, extractParentPackage, resolveTypeSourceForDocs, getDocsModulesDir } from './docs-core.js';
21
21
  import { resolvePackage, isDtsFile, isValidModuleName } from './docs-resolve.js';
22
22
  import { DECL_SPLIT_RE, MEMBER_SPLIT_RE } from './docs-chunk.js';
23
+ import { dropParallelDeclarations } from './docs-index.js';
23
24
  import { npmVersionLookup } from './npm-version.js';
24
25
  import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, manifestCrates, cargoExportGap, cargoContentFingerprint, cargoSupplementCandidates, CARGO_DECL_SPLIT_RE, CARGO_MEMBER_SPLIT_RE } from './eco-cargo.js';
25
26
  import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, hackageExportGap, hackageContentFingerprint, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS, HACKAGE_MEMBER_SPLIT_RE } from './eco-hackage.js';
27
+ import { detectGo, isValidImportPath, resolveGoPackage, acquireGoModule, goDeclaredVersion, goLatest, goProjectName, goDeclaredDeps, goManifestDeps, isGoFile, selectBuildVariants, selectOwnPackage, defaultGoModCache, goContentFingerprintParts } from './eco-go.js';
28
+ import { goSurface, goContentFingerprint, GO_DECL_SPLIT_RE, GO_MEMBER_SPLIT_RE } from './go-surface.js';
26
29
  import { runChild } from '../shared/child-process.js';
27
30
  /**
28
31
  * Is any of `names` present at `cwd` or above it?
@@ -76,6 +79,8 @@ export function defaultEcosystemIo(overrides = {}) {
76
79
  modulesDir: getDocsModulesDir(),
77
80
  cargoHome: process.env.CARGO_HOME?.trim() || path.join(os.homedir(), '.cargo'),
78
81
  cabalPackageDirs: defaultCabalPackageDirs(),
82
+ goModCache: defaultGoModCache(),
83
+ goroot: process.env.GOROOT?.trim() || undefined,
79
84
  ...overrides
80
85
  };
81
86
  }
@@ -122,7 +127,7 @@ export function npmProfile(hooks = {}) {
122
127
  parentPackage: extractParentPackage,
123
128
  resolve: (name, cwd) => resolve(name, cwd),
124
129
  declaredRange: findDeclaredRange,
125
- acquire: (name, range, io) => runAutoInstall(io.spawn, name, {
130
+ acquire: (name, range, _cwd, io) => runAutoInstall(io.spawn, name, {
126
131
  signal: io.signal,
127
132
  versionRange: range ?? undefined
128
133
  }),
@@ -139,6 +144,7 @@ export function npmProfile(hooks = {}) {
139
144
  commentPrefix: '//',
140
145
  // A nested node_modules is another package's surface, never this one's.
141
146
  skipDirs: ['node_modules'],
147
+ selectFiles: dropParallelDeclarations,
142
148
  surfaceLabel: '.d.ts files or README',
143
149
  packageSubject: 'an npm package',
144
150
  projectGlobs: ['*.ts', '*.tsx'],
@@ -259,7 +265,7 @@ const cargoProfile = {
259
265
  resolve: (name, cwd, io) => resolveCrate(name, cwd, { cargoHome: io.cargoHome, modulesDir: io.modulesDir }),
260
266
  // Cargo has already resolved every version; the lock IS the pin.
261
267
  declaredRange: (name, cwd) => lockedVersion(name, cwd),
262
- acquire: async (name, range, io) => {
268
+ acquire: async (name, range, _cwd, io) => {
263
269
  // Asked even when the range is known: the download host wants the name as
264
270
  // PUBLISHED, and only the API knows whether that is `tokio-util` or
265
271
  // `tokio_util`. A null answer falls back to the caller's spelling.
@@ -293,7 +299,7 @@ const cargoProfile = {
293
299
  // Not unpacked here. `acquire` reads crates.io for the published
294
300
  // spelling, so `tokio-util` and `tokio_util` both land.
295
301
  }
296
- const got = await cargoProfile.acquire(c.name, c.version, io);
302
+ const got = await cargoProfile.acquire(c.name, c.version, cwd, io);
297
303
  if (!got.success)
298
304
  continue;
299
305
  try {
@@ -359,7 +365,7 @@ const hackageProfile = {
359
365
  parentPackage: name => name,
360
366
  resolve: (name, cwd, io) => resolveHackage(name, cwd, { modulesDir: io.modulesDir }),
361
367
  declaredRange: (name, cwd) => hackageVersion(name, cwd),
362
- acquire: async (name, range, io) => {
368
+ acquire: async (name, range, _cwd, io) => {
363
369
  const dir = hackageExtractDir(io.modulesDir);
364
370
  const version = range
365
371
  ?? cachedVersions(name, io.cabalPackageDirs).pop()
@@ -413,7 +419,7 @@ const hackageProfile = {
413
419
  // Not unpacked yet. `acquire` prefers the tarball cabal already
414
420
  // downloaded as a dependency, so this is local work, not a fetch.
415
421
  }
416
- const got = await hackageProfile.acquire(c.name, c.version, io);
422
+ const got = await hackageProfile.acquire(c.name, c.version, cwd, io);
417
423
  if (!got.success)
418
424
  continue;
419
425
  try {
@@ -459,10 +465,57 @@ function hasCabalManifest(cwd) {
459
465
  return false;
460
466
  }
461
467
  }
468
+ /**
469
+ * A Go package's surface is cut out of `.go` source, and the row keeps `internal`
470
+ * out of it: Go's compiler enforces that no package outside the module may
471
+ * import one, so an `internal` tree is not API however exported its names are.
472
+ */
473
+ const goProfile = {
474
+ id: 'go',
475
+ why: 'A Go IMPORT PATH is not a module — gin/binding is served by gin, while '
476
+ + 'aws-sdk-go-v2/service/s3 is its own module — so the boundary is found by '
477
+ + 'asking the proxy for the longest prefix that resolves, longest first. '
478
+ + 'Versions come from go.mod, which since Go 1.17 IS the lockfile: its '
479
+ + 'indirect block is the resolved closure, so nothing walks a graph and '
480
+ + 'go.sum is never read. The standard library is not on the proxy at all, '
481
+ + 'and is read from a local GOROOT or sliced out of the toolchain archive.',
482
+ registryLabel: 'proxy.golang.org',
483
+ manifestLabel: 'go.mod',
484
+ detect: detectGo,
485
+ isValidName: isValidImportPath,
486
+ // Identity, because the module a path belongs to cannot be found without the
487
+ // network. Everything that needs the real module — resolve, acquire, latest —
488
+ // is handed `io` and works it out there.
489
+ parentPackage: name => name,
490
+ resolve: (name, cwd, io) => resolveGoPackage(name, cwd, {
491
+ goModCache: io.goModCache,
492
+ modulesDir: io.modulesDir,
493
+ goroot: io.goroot
494
+ }),
495
+ declaredRange: goDeclaredVersion,
496
+ acquire: (name, range, cwd, io) => acquireGoModule(name, range, cwd, { goModCache: io.goModCache, modulesDir: io.modulesDir, goroot: io.goroot }, io.fetch, io.signal),
497
+ latest: (name, io) => goLatest(name, io.fetch, io.signal),
498
+ isSurfaceFile: isGoFile,
499
+ surface: content => goSurface(content),
500
+ contentFingerprint: () => [goContentFingerprint(), ...goContentFingerprintParts()].join('\u0000'),
501
+ declSplitRe: GO_DECL_SPLIT_RE,
502
+ memberSplitRe: GO_MEMBER_SPLIT_RE,
503
+ typeKeywords: ['type', 'struct', 'interface', 'func'],
504
+ commentPrefix: '//',
505
+ skipDirs: ['testdata', 'examples', 'vendor', 'internal', '.git'],
506
+ selectFiles: (files, pkg) => selectBuildVariants(selectOwnPackage(files, pkg.root, pkg.name, pkg.version)),
507
+ surfaceLabel: '.go source or README',
508
+ packageSubject: 'a Go package',
509
+ projectGlobs: ['*.go'],
510
+ projectName: goProjectName,
511
+ declaredDeps: goDeclaredDeps,
512
+ manifestDeps: goManifestDeps
513
+ };
462
514
  export const ECOSYSTEMS = {
463
515
  npm: npmProfile(),
464
516
  cargo: cargoProfile,
465
- hackage: hackageProfile
517
+ hackage: hackageProfile,
518
+ go: goProfile
466
519
  };
467
520
  /** Which ecosystems `cwd` looks like a project of, in roster order. */
468
521
  export function detectEcosystems(cwd, roster = Object.values(ECOSYSTEMS)) {
@@ -35,4 +35,17 @@ export interface IndexResult {
35
35
  * have said so.
36
36
  */
37
37
  export declare function chunkerFingerprint(): string;
38
+ /**
39
+ * Drop a `.d.cts` / `.d.mts` that sits beside a `.d.ts` of the same name.
40
+ *
41
+ * Modern npm packages ship parallel declarations for ESM and CJS: the same API
42
+ * written twice. zod 4.5.4 indexed to 2565 chunks over 1215 distinct bodies,
43
+ * 1280 of them from `.d.cts`; hono, which ships none, had 704 distinct of 708.
44
+ * The cost is the eight-chunk retrieval budget — half of it can go to text the
45
+ * reader already has.
46
+ *
47
+ * The sibling test, not a blanket ban on the extensions: a package shipping only
48
+ * `.d.cts` still has to be readable, and all 123 of zod's had a `.d.ts` twin.
49
+ */
50
+ export declare function dropParallelDeclarations(files: readonly string[]): string[];
38
51
  export declare function ensureIndexed(cache: CacheHandle, pkg: ResolvedPackage, profile?: EcosystemProfile, supplements?: readonly ResolvedPackage[]): IndexResult;
@@ -59,7 +59,7 @@ function computeContentHash(pkg, profile, supplements = []) {
59
59
  hash.update(ZERO_SEP);
60
60
  // Source text, the same trick as `declSplitRe.source`: the fingerprint moves
61
61
  // whenever the selection rule does, with nothing to remember to bump.
62
- hash.update(Buffer.from(`${String(profile.isSurfaceFile)}\u0000${String(dropParallelDeclarations)}`
62
+ hash.update(Buffer.from(`${String(profile.isSurfaceFile)}\u0000${String(profile.selectFiles)}`
63
63
  + `\u0000${String(dropDeadMajors)}`, 'utf8'));
64
64
  hash.update(ZERO_SEP);
65
65
  // The extractor and the writer, by source. Surfacing only `pkg.entry` below
@@ -144,7 +144,7 @@ function walkSurface(root, profile) {
144
144
  * The sibling test, not a blanket ban on the extensions: a package shipping only
145
145
  * `.d.cts` still has to be readable, and all 123 of zod's had a `.d.ts` twin.
146
146
  */
147
- function dropParallelDeclarations(files) {
147
+ export function dropParallelDeclarations(files) {
148
148
  const esm = new Set(files.filter(f => f.endsWith('.d.ts')).map(f => f.slice(0, -'.d.ts'.length)));
149
149
  return files.filter(f => {
150
150
  const base = /\.d\.[cm]ts$/.exec(f) ? f.slice(0, -'.d.cts'.length) : null;
@@ -181,7 +181,7 @@ function collectFiles(pkg, profile) {
181
181
  const walked = walkSurface(pkg.root, profile);
182
182
  const surface = dropDeadMajors(walked, pkg.root, pkg.version);
183
183
  return {
184
- surface: profile.id === 'npm' ? dropParallelDeclarations(surface) : surface,
184
+ surface: profile.selectFiles ? profile.selectFiles(surface, pkg) : surface,
185
185
  readme: pkg.readme
186
186
  };
187
187
  }