@mjasnikovs/pi-task 0.40.28 → 0.40.29
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 +6 -3
- package/dist/shared/zip.d.ts +86 -0
- package/dist/shared/zip.js +251 -0
- package/dist/workers/docs-core.js +1 -1
- package/dist/workers/docs-ecosystems.d.ts +23 -3
- package/dist/workers/docs-ecosystems.js +59 -6
- package/dist/workers/docs-index.d.ts +13 -0
- package/dist/workers/docs-index.js +3 -3
- package/dist/workers/eco-go.d.ts +181 -0
- package/dist/workers/eco-go.js +659 -0
- package/dist/workers/go-stdlib.d.ts +72 -0
- package/dist/workers/go-stdlib.js +210 -0
- package/dist/workers/go-surface.d.ts +75 -0
- package/dist/workers/go-surface.js +571 -0
- package/dist/workers/pi-worker-docs.js +9 -2
- package/package.json +1 -1
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eco-go — reading Go packages for the docs Worker tool.
|
|
3
|
+
*
|
|
4
|
+
* Three things separate this row from the others.
|
|
5
|
+
*
|
|
6
|
+
* A Go IMPORT PATH is not a module. `github.com/gin-gonic/gin/binding` is served
|
|
7
|
+
* by the `gin` module, and `github.com/aws/aws-sdk-go-v2/service/s3` is its own
|
|
8
|
+
* module despite looking like a subdirectory of one. No syntactic rule tells
|
|
9
|
+
* them apart, so the module boundary is found by asking the proxy for the
|
|
10
|
+
* longest prefix that resolves — longest FIRST, because shorter prefixes answer
|
|
11
|
+
* too: `github.com/go-redis/redis` is a real, ancient, different module from
|
|
12
|
+
* `github.com/go-redis/redis/v8`.
|
|
13
|
+
*
|
|
14
|
+
* The go.mod IS the lockfile. From Go 1.17 the indirect block is the complete
|
|
15
|
+
* pruned closure with resolved versions, so nothing here walks a dependency
|
|
16
|
+
* graph and `go.sum` is never read: it carries integrity hashes for versions
|
|
17
|
+
* that were considered and rejected, so using it would over-report.
|
|
18
|
+
*
|
|
19
|
+
* And Go has no re-export syntax at all — no `pub use`, no export list — so this
|
|
20
|
+
* row sets neither `supplements` nor `exportGap`. Every name a package exports
|
|
21
|
+
* is declared in that package's own files.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { ResolveError } from './docs-resolve.js';
|
|
27
|
+
import { findAtOrAbove } from './eco-cargo.js';
|
|
28
|
+
import { buildConstraint } from './go-surface.js';
|
|
29
|
+
import { readZip, readEntry, isUnsafeEntryName } from '../shared/zip.js';
|
|
30
|
+
import { acquireStdlibPackage, findInGoroot, findSliced } from './go-stdlib.js';
|
|
31
|
+
const PROXY = 'https://proxy.golang.org';
|
|
32
|
+
const USER_AGENT = 'pi-task (github.com/mjasnikovs/pi-task)';
|
|
33
|
+
/**
|
|
34
|
+
* Hosts that never serve a module at `<host>/<one segment>`, so the prefix walk
|
|
35
|
+
* has a floor and does not spend two requests proving `github.com/gin-gonic` is
|
|
36
|
+
* not a module.
|
|
37
|
+
*/
|
|
38
|
+
const TWO_SEGMENT_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org']);
|
|
39
|
+
export function isValidImportPath(name) {
|
|
40
|
+
if (!name || name.startsWith('/') || name.endsWith('/'))
|
|
41
|
+
return false;
|
|
42
|
+
if (name.includes('..') || name.includes('//'))
|
|
43
|
+
return false;
|
|
44
|
+
return /^[A-Za-z0-9][A-Za-z0-9._~+-]*(?:\/[A-Za-z0-9._~+-]+)*$/.test(name);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The proxy and the module cache both spell an uppercase letter as `!` plus its
|
|
48
|
+
* lowercase form, so that a case-insensitive filesystem cannot collide two
|
|
49
|
+
* modules whose paths differ only in case.
|
|
50
|
+
*/
|
|
51
|
+
export function escapeModulePath(name) {
|
|
52
|
+
return name.replace(/[A-Z]/g, c => `!${c.toLowerCase()}`);
|
|
53
|
+
}
|
|
54
|
+
export function unescapeModulePath(name) {
|
|
55
|
+
return name.replace(/!([a-z])/g, (_, c) => c.toUpperCase());
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Go's own rule, from `cmd/go`'s `IsStandardImportPath`: the first path element
|
|
59
|
+
* of a standard-library import contains no dot. `net/http` is stdlib,
|
|
60
|
+
* `go.uber.org/zap` is not.
|
|
61
|
+
*/
|
|
62
|
+
export function isStdlibImport(importPath) {
|
|
63
|
+
const first = importPath.split('/')[0];
|
|
64
|
+
return first !== '' && !first.includes('.');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Is this import path the standard library, in THIS project?
|
|
68
|
+
*
|
|
69
|
+
* Go's rule is purely lexical, so a project whose own module path has no dot —
|
|
70
|
+
* `module myapp` — makes `myapp/internal/db` look like stdlib. The project's own
|
|
71
|
+
* module is stripped first, which is what `cmd/go` effectively does too.
|
|
72
|
+
*/
|
|
73
|
+
export function isProjectStdlib(importPath, cwd) {
|
|
74
|
+
if (!isStdlibImport(importPath))
|
|
75
|
+
return false;
|
|
76
|
+
const own = goProjectName(cwd);
|
|
77
|
+
return own === null || !prefixes(own, importPath);
|
|
78
|
+
}
|
|
79
|
+
export function isGoFile(name) {
|
|
80
|
+
// On the suffix, never the word "test": `test_helpers.go` holds
|
|
81
|
+
// `gin.CreateTestContext`, which is documented public API.
|
|
82
|
+
return name.endsWith('.go') && !name.endsWith('_test.go');
|
|
83
|
+
}
|
|
84
|
+
function safeRead(file) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.readFileSync(file, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const REQUIRE_LINE_RE = /^([^\s()]+)\s+(\S+)(.*)$/;
|
|
93
|
+
/**
|
|
94
|
+
* Parse a `go.mod`.
|
|
95
|
+
*
|
|
96
|
+
* Written against what real files contain rather than the grammar's happy path:
|
|
97
|
+
* gin has THREE `require` blocks, single-line and block forms mix freely, and
|
|
98
|
+
* the direct/indirect split is the `// indirect` comment and never the block a
|
|
99
|
+
* line happens to sit in.
|
|
100
|
+
*/
|
|
101
|
+
export function parseGoMod(text) {
|
|
102
|
+
const out = {
|
|
103
|
+
module: null,
|
|
104
|
+
goVersion: null,
|
|
105
|
+
toolchain: null,
|
|
106
|
+
requires: [],
|
|
107
|
+
replaces: new Map()
|
|
108
|
+
};
|
|
109
|
+
let block = null;
|
|
110
|
+
for (const raw of text.split('\n')) {
|
|
111
|
+
const line = stripLineComment(raw).trim();
|
|
112
|
+
if (line === '')
|
|
113
|
+
continue;
|
|
114
|
+
if (block !== null) {
|
|
115
|
+
if (line === ')')
|
|
116
|
+
block = null;
|
|
117
|
+
else if (block === 'require')
|
|
118
|
+
addRequire(out, line, raw);
|
|
119
|
+
else if (block === 'replace')
|
|
120
|
+
addReplace(out, line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const open = /^(require|replace|exclude|retract)\s*\($/.exec(line);
|
|
124
|
+
if (open) {
|
|
125
|
+
block = open[1];
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const single = /^(module|go|toolchain|require|replace)\s+(.*)$/.exec(line);
|
|
129
|
+
if (!single)
|
|
130
|
+
continue;
|
|
131
|
+
const [, directive, rest] = single;
|
|
132
|
+
if (directive === 'module')
|
|
133
|
+
out.module = rest.trim();
|
|
134
|
+
else if (directive === 'go')
|
|
135
|
+
out.goVersion = rest.trim();
|
|
136
|
+
else if (directive === 'toolchain')
|
|
137
|
+
out.toolchain = rest.trim();
|
|
138
|
+
else if (directive === 'require')
|
|
139
|
+
addRequire(out, rest, raw);
|
|
140
|
+
else
|
|
141
|
+
addReplace(out, rest);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** Strip a `//` comment, keeping enough of it to see an `// indirect` marker. */
|
|
146
|
+
function stripLineComment(line) {
|
|
147
|
+
const at = line.indexOf('//');
|
|
148
|
+
return at < 0 ? line : line.slice(0, at);
|
|
149
|
+
}
|
|
150
|
+
function addRequire(out, line, raw) {
|
|
151
|
+
const match = REQUIRE_LINE_RE.exec(line.trim());
|
|
152
|
+
if (!match)
|
|
153
|
+
return;
|
|
154
|
+
out.requires.push({
|
|
155
|
+
module: match[1],
|
|
156
|
+
version: match[2],
|
|
157
|
+
indirect: /\/\/\s*indirect/.test(raw)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function addReplace(out, line) {
|
|
161
|
+
const match = /^(\S+)(?:\s+\S+)?\s*=>\s*(\S+)/.exec(line.trim());
|
|
162
|
+
if (match)
|
|
163
|
+
out.replaces.set(match[1], match[2]);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A `replace` target that is a directory rather than a module path. Its source
|
|
167
|
+
* is in the working tree, so the proxy has nothing to serve for it — and the
|
|
168
|
+
* placeholder version such an entry carries (`k8s.io/api v0.0.0`) would 404.
|
|
169
|
+
*/
|
|
170
|
+
function isLocalReplacement(target) {
|
|
171
|
+
return target.startsWith('.') || target.startsWith('/') || /^[A-Za-z]:[\\/]/.test(target);
|
|
172
|
+
}
|
|
173
|
+
/** The `use` directories a `go.work` names, resolved against its own location. */
|
|
174
|
+
export function parseGoWork(text, dir) {
|
|
175
|
+
const dirs = [];
|
|
176
|
+
let inBlock = false;
|
|
177
|
+
for (const raw of text.split('\n')) {
|
|
178
|
+
const line = stripLineComment(raw).trim();
|
|
179
|
+
if (line === '')
|
|
180
|
+
continue;
|
|
181
|
+
if (inBlock) {
|
|
182
|
+
if (line === ')')
|
|
183
|
+
inBlock = false;
|
|
184
|
+
else
|
|
185
|
+
dirs.push(path.resolve(dir, line));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (/^use\s*\($/.test(line))
|
|
189
|
+
inBlock = true;
|
|
190
|
+
else {
|
|
191
|
+
const single = /^use\s+(\S+)$/.exec(line);
|
|
192
|
+
if (single)
|
|
193
|
+
dirs.push(path.resolve(dir, single[1]));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return dirs;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Every `go.mod` that governs `cwd`.
|
|
200
|
+
*
|
|
201
|
+
* A `go.work` wins where there is one: each of its `use` directories is a
|
|
202
|
+
* first-class module, and taking only the root would miss every dependency the
|
|
203
|
+
* members declare.
|
|
204
|
+
*/
|
|
205
|
+
export function goManifests(cwd) {
|
|
206
|
+
const work = findAtOrAbove(cwd, 'go.work');
|
|
207
|
+
if (work) {
|
|
208
|
+
const text = safeRead(work);
|
|
209
|
+
if (text !== null) {
|
|
210
|
+
const mods = parseGoWork(text, path.dirname(work))
|
|
211
|
+
.map(d => path.join(d, 'go.mod'))
|
|
212
|
+
.filter(f => fs.existsSync(f));
|
|
213
|
+
if (mods.length > 0)
|
|
214
|
+
return mods;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const mod = findAtOrAbove(cwd, 'go.mod');
|
|
218
|
+
return mod ? [mod] : [];
|
|
219
|
+
}
|
|
220
|
+
export function detectGo(cwd) {
|
|
221
|
+
return findAtOrAbove(cwd, 'go.mod') !== null || findAtOrAbove(cwd, 'go.work') !== null;
|
|
222
|
+
}
|
|
223
|
+
/** The project's own module path, from the nearest manifest. */
|
|
224
|
+
export function goProjectName(cwd) {
|
|
225
|
+
const manifests = goManifests(cwd);
|
|
226
|
+
if (manifests.length === 0)
|
|
227
|
+
return null;
|
|
228
|
+
const text = safeRead(manifests[0]);
|
|
229
|
+
return text === null ? null : parseGoMod(text).module;
|
|
230
|
+
}
|
|
231
|
+
function readManifests(cwd) {
|
|
232
|
+
const files = goManifests(cwd);
|
|
233
|
+
if (files.length === 0)
|
|
234
|
+
return undefined;
|
|
235
|
+
const parsed = files
|
|
236
|
+
.map(safeRead)
|
|
237
|
+
.filter((t) => t !== null)
|
|
238
|
+
.map(parseGoMod);
|
|
239
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Every module version the project resolves, direct and indirect.
|
|
243
|
+
*
|
|
244
|
+
* A `replace` onto a local directory is dropped: nothing can be fetched for it,
|
|
245
|
+
* and its recorded version is a placeholder rather than a fact about a release.
|
|
246
|
+
*/
|
|
247
|
+
export function goDeclaredDeps(cwd) {
|
|
248
|
+
const mods = readManifests(cwd);
|
|
249
|
+
if (!mods)
|
|
250
|
+
return undefined;
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const mod of mods) {
|
|
253
|
+
for (const req of mod.requires) {
|
|
254
|
+
const replacement = mod.replaces.get(req.module);
|
|
255
|
+
if (replacement !== undefined && isLocalReplacement(replacement))
|
|
256
|
+
continue;
|
|
257
|
+
if (!(req.module in out))
|
|
258
|
+
out[req.module] = req.version;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The modules the project may import directly — the requires with no
|
|
265
|
+
* `// indirect` marker, less the workspace's own members, which resolve from the
|
|
266
|
+
* working tree rather than from a registry.
|
|
267
|
+
*/
|
|
268
|
+
export function goManifestDeps(cwd) {
|
|
269
|
+
const mods = readManifests(cwd);
|
|
270
|
+
if (!mods)
|
|
271
|
+
return undefined;
|
|
272
|
+
const members = new Set(mods.map(m => m.module).filter((m) => m !== null));
|
|
273
|
+
const out = new Set();
|
|
274
|
+
for (const mod of mods) {
|
|
275
|
+
for (const req of mod.requires) {
|
|
276
|
+
if (!req.indirect && !members.has(req.module))
|
|
277
|
+
out.add(req.module);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The version the project pins the module serving `importPath` to.
|
|
284
|
+
*
|
|
285
|
+
* The longest require whose path prefixes the import wins, so
|
|
286
|
+
* `github.com/aws/aws-sdk-go-v2/service/s3` takes the s3 submodule's own pin and
|
|
287
|
+
* not the SDK core's.
|
|
288
|
+
*/
|
|
289
|
+
export function goDeclaredVersion(importPath, cwd) {
|
|
290
|
+
const deps = goDeclaredDeps(cwd);
|
|
291
|
+
if (!deps)
|
|
292
|
+
return null;
|
|
293
|
+
let best = null;
|
|
294
|
+
let longest = -1;
|
|
295
|
+
for (const [module, version] of Object.entries(deps)) {
|
|
296
|
+
if (!prefixes(module, importPath) || module.length <= longest)
|
|
297
|
+
continue;
|
|
298
|
+
longest = module.length;
|
|
299
|
+
best = version;
|
|
300
|
+
}
|
|
301
|
+
return best;
|
|
302
|
+
}
|
|
303
|
+
function prefixes(module, importPath) {
|
|
304
|
+
return importPath === module || importPath.startsWith(`${module}/`);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The package-to-module map `vendor/modules.txt` states outright.
|
|
308
|
+
*
|
|
309
|
+
* Exact and free: no prefix walk, no request, and replaces already applied. Its
|
|
310
|
+
* one limit is that `go mod vendor` copies only the packages the project
|
|
311
|
+
* imports, so a question about an untouched corner of a dependency still needs
|
|
312
|
+
* the module zip.
|
|
313
|
+
*/
|
|
314
|
+
export function parseVendorModules(text) {
|
|
315
|
+
const out = new Map();
|
|
316
|
+
let current = null;
|
|
317
|
+
for (const raw of text.split('\n')) {
|
|
318
|
+
const line = raw.trim();
|
|
319
|
+
if (line === '')
|
|
320
|
+
continue;
|
|
321
|
+
if (line.startsWith('##'))
|
|
322
|
+
continue;
|
|
323
|
+
if (line.startsWith('# ')) {
|
|
324
|
+
const match = /^#\s+(\S+)\s+(\S+)/.exec(line);
|
|
325
|
+
current = match ? { module: match[1], version: match[2] } : null;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (line.startsWith('#'))
|
|
329
|
+
continue;
|
|
330
|
+
if (current)
|
|
331
|
+
out.set(line, current);
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
export function defaultGoModCache() {
|
|
336
|
+
const configured = process.env.GOMODCACHE?.trim();
|
|
337
|
+
if (configured)
|
|
338
|
+
return configured;
|
|
339
|
+
const gopath = process.env.GOPATH?.trim() || path.join(os.homedir(), 'go');
|
|
340
|
+
return path.join(gopath, 'pkg', 'mod');
|
|
341
|
+
}
|
|
342
|
+
/** Extracted module trees, newest source of truth last so a fetch wins a tie. */
|
|
343
|
+
function moduleRoots(dirs) {
|
|
344
|
+
return [dirs.goModCache, path.join(dirs.modulesDir, 'go')];
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Find the directory serving `importPath` under one extracted-modules root.
|
|
348
|
+
*
|
|
349
|
+
* The module boundary is read off the DIRECTORY NAME — every extracted tree is
|
|
350
|
+
* `<escaped module>@<version>` — so no manifest is parsed and no network is
|
|
351
|
+
* touched. The longest matching prefix wins, for the same reason the proxy walk
|
|
352
|
+
* runs longest-first.
|
|
353
|
+
*/
|
|
354
|
+
function findInRoot(root, importPath, pinned) {
|
|
355
|
+
for (const module of modulePrefixes(importPath)) {
|
|
356
|
+
const parent = path.join(root, ...escapeModulePath(module).split('/').slice(0, -1));
|
|
357
|
+
const leaf = escapeModulePath(module).split('/').pop();
|
|
358
|
+
let entries;
|
|
359
|
+
try {
|
|
360
|
+
entries = fs.readdirSync(parent);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const versions = entries
|
|
366
|
+
.filter(e => e.startsWith(`${leaf}@`))
|
|
367
|
+
.map(e => ({ dir: path.join(parent, e), version: e.slice(leaf.length + 1) }));
|
|
368
|
+
const wanted = pinned ? versions.filter(v => v.version === pinned) : versions;
|
|
369
|
+
if (wanted.length === 0)
|
|
370
|
+
continue;
|
|
371
|
+
const chosen = wanted.sort((a, b) => a.version.localeCompare(b.version)).pop();
|
|
372
|
+
const sub = importPath.slice(module.length).replace(/^\//, '');
|
|
373
|
+
const dir = sub === '' ? chosen.dir : path.join(chosen.dir, ...sub.split('/'));
|
|
374
|
+
if (!fs.existsSync(dir))
|
|
375
|
+
continue;
|
|
376
|
+
return { module, version: chosen.version, dir };
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
/** Candidate module paths for an import, longest first. */
|
|
381
|
+
export function modulePrefixes(importPath) {
|
|
382
|
+
const parts = importPath.split('/');
|
|
383
|
+
const floor = TWO_SEGMENT_HOSTS.has(parts[0]) ? 3 : 2;
|
|
384
|
+
const out = [];
|
|
385
|
+
for (let n = parts.length; n >= Math.min(floor, parts.length); n--) {
|
|
386
|
+
out.push(parts.slice(0, n).join('/'));
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
function readmeIn(root) {
|
|
391
|
+
for (const name of ['README.md', 'readme.md', 'README.markdown', 'README']) {
|
|
392
|
+
const abs = path.join(root, name);
|
|
393
|
+
if (fs.existsSync(abs))
|
|
394
|
+
return abs;
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
/** The file most likely to carry the package's headline documentation. */
|
|
399
|
+
function entryIn(root, importPath) {
|
|
400
|
+
const leaf = importPath.split('/').pop() ?? '';
|
|
401
|
+
for (const name of ['doc.go', `${leaf}.go`]) {
|
|
402
|
+
const abs = path.join(root, name);
|
|
403
|
+
if (fs.existsSync(abs))
|
|
404
|
+
return abs;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const first = fs.readdirSync(root).filter(isGoFile).sort()[0];
|
|
408
|
+
return first === undefined ? null : path.join(root, first);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Find a Go package's source on disk.
|
|
416
|
+
*
|
|
417
|
+
* Vendored source first: it is exact, already extracted, and needs nothing from
|
|
418
|
+
* the network. Then the module cache, then whatever this tool fetched earlier.
|
|
419
|
+
*/
|
|
420
|
+
export function resolveGoPackage(importPath, cwd, dirs) {
|
|
421
|
+
if (!isValidImportPath(importPath)) {
|
|
422
|
+
throw new ResolveError('invalid_name', `Invalid Go import path: "${importPath}"`);
|
|
423
|
+
}
|
|
424
|
+
const found = isProjectStdlib(importPath, cwd) ?
|
|
425
|
+
resolveStdlib(importPath, dirs)
|
|
426
|
+
: (resolveVendored(importPath, cwd) ?? resolveExtracted(importPath, cwd, dirs));
|
|
427
|
+
if (!found) {
|
|
428
|
+
throw new ResolveError('not_installed', `Go package "${importPath}" has no source under ${dirs.goModCache}, `
|
|
429
|
+
+ `${path.join(dirs.modulesDir, 'go')} or a vendor directory.`);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ecosystem: 'go',
|
|
433
|
+
name: importPath,
|
|
434
|
+
version: found.version,
|
|
435
|
+
root: found.dir,
|
|
436
|
+
entry: entryIn(found.dir, importPath),
|
|
437
|
+
readme: readmeIn(found.dir)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** A local toolchain first: it costs nothing and matches the project's build. */
|
|
441
|
+
function resolveStdlib(importPath, dirs) {
|
|
442
|
+
const found = findInGoroot(importPath, dirs.goroot) ?? findSliced(importPath, moduleRoots(dirs));
|
|
443
|
+
return found ? { module: 'std', version: found.version, dir: found.dir } : null;
|
|
444
|
+
}
|
|
445
|
+
function resolveVendored(importPath, cwd) {
|
|
446
|
+
const manifest = findAtOrAbove(cwd, 'vendor', 'modules.txt');
|
|
447
|
+
if (!manifest)
|
|
448
|
+
return null;
|
|
449
|
+
const text = safeRead(manifest);
|
|
450
|
+
if (text === null)
|
|
451
|
+
return null;
|
|
452
|
+
const entry = parseVendorModules(text).get(importPath);
|
|
453
|
+
if (!entry)
|
|
454
|
+
return null;
|
|
455
|
+
const dir = path.join(path.dirname(manifest), ...importPath.split('/'));
|
|
456
|
+
return fs.existsSync(dir) ? { module: entry.module, version: entry.version, dir } : null;
|
|
457
|
+
}
|
|
458
|
+
function resolveExtracted(importPath, cwd, dirs) {
|
|
459
|
+
// A pin that is not on disk is not_installed, never an invitation to answer
|
|
460
|
+
// from whatever version another checkout left behind: nothing downstream
|
|
461
|
+
// marks the substitution.
|
|
462
|
+
const pinned = goDeclaredVersion(importPath, cwd);
|
|
463
|
+
for (const root of moduleRoots(dirs)) {
|
|
464
|
+
const found = findInRoot(root, importPath, pinned);
|
|
465
|
+
if (found)
|
|
466
|
+
return found;
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* One `@latest` or `.info` lookup.
|
|
472
|
+
*
|
|
473
|
+
* A 200 is not enough. `github.com/aws/aws-sdk-go-v2/service/@latest` answered
|
|
474
|
+
* `200` with an empty body on one probe and `404` on the next, so a walk that
|
|
475
|
+
* stops on `res.ok` lands on a path that is not a module at all.
|
|
476
|
+
*/
|
|
477
|
+
async function proxyInfo(module, fetchFn, signal) {
|
|
478
|
+
try {
|
|
479
|
+
const response = await fetchFn(`${PROXY}/${escapeModulePath(module)}/@latest`, {
|
|
480
|
+
headers: { 'user-agent': USER_AGENT, accept: 'application/json' },
|
|
481
|
+
...(signal ? { signal } : {})
|
|
482
|
+
});
|
|
483
|
+
if (!response.ok)
|
|
484
|
+
return null;
|
|
485
|
+
const body = (await response.json());
|
|
486
|
+
return typeof body?.Version === 'string' && body.Version !== '' ? body : null;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Which module serves `importPath`, by asking the proxy about the longest
|
|
494
|
+
* prefix first.
|
|
495
|
+
*
|
|
496
|
+
* Longest first is not an optimisation. `github.com/go-redis/redis/v8` and
|
|
497
|
+
* `github.com/go-redis/redis` both resolve, to different modules a major apart,
|
|
498
|
+
* and a walk that grows from the left returns the wrong one every time.
|
|
499
|
+
*/
|
|
500
|
+
export async function resolveModulePath(importPath, fetchFn, signal) {
|
|
501
|
+
for (const candidate of modulePrefixes(importPath)) {
|
|
502
|
+
const info = await proxyInfo(candidate, fetchFn, signal);
|
|
503
|
+
if (info)
|
|
504
|
+
return { module: candidate, version: info.Version };
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
/** The newest published version of the module serving `importPath`. */
|
|
509
|
+
export async function goLatest(importPath, fetchFn, signal) {
|
|
510
|
+
if (isStdlibImport(importPath))
|
|
511
|
+
return null;
|
|
512
|
+
for (const candidate of modulePrefixes(importPath)) {
|
|
513
|
+
const info = await proxyInfo(candidate, fetchFn, signal);
|
|
514
|
+
if (!info)
|
|
515
|
+
continue;
|
|
516
|
+
return {
|
|
517
|
+
pkg: candidate,
|
|
518
|
+
latest: info.Version,
|
|
519
|
+
recent: [],
|
|
520
|
+
...(typeof info.Time === 'string' ? { publishedAt: info.Time } : {})
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
export function moduleZipUrl(module, version) {
|
|
526
|
+
return `${PROXY}/${escapeModulePath(module)}/@v/${escapeModulePath(version)}.zip`;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Fetch a module and extract its Go source under `<modulesDir>/go`.
|
|
530
|
+
*
|
|
531
|
+
* Entries are laid out exactly as the module cache lays them out —
|
|
532
|
+
* `<module>@<version>/…`, in the module's original case — so what lands on disk
|
|
533
|
+
* is indistinguishable from a tree `go mod download` would have written, and
|
|
534
|
+
* `findInRoot` reads both with one rule.
|
|
535
|
+
*/
|
|
536
|
+
export async function acquireGoModule(importPath, pinned, cwd, dirs, fetchFn, signal) {
|
|
537
|
+
const installDir = path.join(dirs.modulesDir, 'go');
|
|
538
|
+
if (isProjectStdlib(importPath, cwd)) {
|
|
539
|
+
return acquireStdlibPackage({
|
|
540
|
+
importPath,
|
|
541
|
+
goDirective: goDirectiveOf(cwd),
|
|
542
|
+
installDir,
|
|
543
|
+
fetchFn,
|
|
544
|
+
signal
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const resolved = await resolveModulePath(importPath, fetchFn, signal);
|
|
548
|
+
if (!resolved) {
|
|
549
|
+
return {
|
|
550
|
+
success: false,
|
|
551
|
+
installDir,
|
|
552
|
+
stderr: `No module on ${PROXY} serves the import path "${importPath}".`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const version = pinned ?? resolved.version;
|
|
556
|
+
try {
|
|
557
|
+
const response = await fetchFn(moduleZipUrl(resolved.module, version), {
|
|
558
|
+
headers: { 'user-agent': USER_AGENT },
|
|
559
|
+
redirect: 'follow',
|
|
560
|
+
...(signal ? { signal } : {})
|
|
561
|
+
});
|
|
562
|
+
if (!response.ok) {
|
|
563
|
+
return {
|
|
564
|
+
success: false,
|
|
565
|
+
installDir,
|
|
566
|
+
stderr: `${resolved.module}@${version}: proxy returned ${response.status}.`
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
writeModule(Buffer.from(await response.arrayBuffer()), installDir, resolved.module, version);
|
|
570
|
+
return { success: true, installDir, stderr: '' };
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
return {
|
|
574
|
+
success: false,
|
|
575
|
+
installDir,
|
|
576
|
+
stderr: err instanceof Error ? err.message : String(err)
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** The language version the project targets, for picking a toolchain to read. */
|
|
581
|
+
function goDirectiveOf(cwd) {
|
|
582
|
+
const manifests = goManifests(cwd);
|
|
583
|
+
if (manifests.length === 0)
|
|
584
|
+
return null;
|
|
585
|
+
const text = safeRead(manifests[0]);
|
|
586
|
+
return text === null ? null : parseGoMod(text).goVersion;
|
|
587
|
+
}
|
|
588
|
+
/** Only the files a reader can use: Go source, documentation, and the manifest. */
|
|
589
|
+
function isWantedEntry(name) {
|
|
590
|
+
const leaf = name.split('/').pop() ?? '';
|
|
591
|
+
return isGoFile(leaf) || leaf === 'go.mod' || /^readme(\.md|\.markdown)?$/i.test(leaf);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The zip spells the module path in its ORIGINAL case; the module cache spells it
|
|
595
|
+
* escaped. Writing the entries verbatim leaves `github.com/BurntSushi/toml`
|
|
596
|
+
* beside a resolver looking for `github.com/!burnt!sushi/toml`, and every
|
|
597
|
+
* uppercase module resolves as not-installed straight after a successful fetch.
|
|
598
|
+
*/
|
|
599
|
+
function writeModule(archive, installDir, module, version) {
|
|
600
|
+
const from = `${module}@${version}/`;
|
|
601
|
+
const to = `${escapeModulePath(module)}@${escapeModulePath(version)}/`;
|
|
602
|
+
for (const entry of readZip(archive)) {
|
|
603
|
+
if (!isWantedEntry(entry.name) || isUnsafeEntryName(entry.name))
|
|
604
|
+
continue;
|
|
605
|
+
const rel = entry.name.startsWith(from) ? to + entry.name.slice(from.length) : entry.name;
|
|
606
|
+
const target = path.join(installDir, ...rel.split('/'));
|
|
607
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
608
|
+
fs.writeFileSync(target, readEntry(archive, entry));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Keep one file per set of build-tag variants.
|
|
613
|
+
*
|
|
614
|
+
* Go does what npm's `.d.ts`/`.d.cts` twins do: `binding.go` and
|
|
615
|
+
* `binding_nomsgpack.go` declare the same twelve names under opposite
|
|
616
|
+
* constraints, and `internal/json` declares the same five names four times. All
|
|
617
|
+
* of them in one index is duplicate text competing for the retrieval budget with
|
|
618
|
+
* nothing to tell the copies apart.
|
|
619
|
+
*
|
|
620
|
+
* The default build decides, which is what a reader gets by running `go build`
|
|
621
|
+
* with no tags: a bare negation holds, a bare tag does not.
|
|
622
|
+
*/
|
|
623
|
+
export function selectBuildVariants(files) {
|
|
624
|
+
const byDir = new Map();
|
|
625
|
+
for (const file of files) {
|
|
626
|
+
const dir = path.dirname(file);
|
|
627
|
+
byDir.set(dir, [...(byDir.get(dir) ?? []), file]);
|
|
628
|
+
}
|
|
629
|
+
const kept = [];
|
|
630
|
+
for (const group of byDir.values()) {
|
|
631
|
+
const defaults = group.filter(f => holdsByDefault(safeRead(f) ?? ''));
|
|
632
|
+
// A directory whose every file is tag-guarded still has to be readable.
|
|
633
|
+
kept.push(...(defaults.length > 0 ? defaults : group));
|
|
634
|
+
}
|
|
635
|
+
return kept.sort();
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Does this file compile with no `-tags` argument?
|
|
639
|
+
*
|
|
640
|
+
* Only the shapes that actually occur: a conjunction of bare tags and bare
|
|
641
|
+
* negations. Anything with a parenthesis or a version tag is kept, because
|
|
642
|
+
* guessing wrong drops real API and keeping a duplicate only costs budget.
|
|
643
|
+
*/
|
|
644
|
+
function holdsByDefault(src) {
|
|
645
|
+
const constraint = buildConstraint(src);
|
|
646
|
+
if (constraint === null)
|
|
647
|
+
return true;
|
|
648
|
+
const expr = constraint.replace(/^\/\/go:build\s*/, '').trim();
|
|
649
|
+
if (/[()|]/.test(expr) || /\bgo1\./.test(expr))
|
|
650
|
+
return true;
|
|
651
|
+
return expr.split(/\s*&&\s*/).every(term => term.startsWith('!'));
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Everything below `goSurface` and the file-selection rule that feeds it, by
|
|
655
|
+
* source, so a fix to either re-indexes rather than being masked by a cache hit.
|
|
656
|
+
*/
|
|
657
|
+
export function goContentFingerprintParts() {
|
|
658
|
+
return [String(selectBuildVariants), String(holdsByDefault), String(isWantedEntry)];
|
|
659
|
+
}
|