@danypops/lector 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +9 -7
  2. package/package.json +5 -1
  3. package/src/adapters/fallback-code-intelligence-index.ts +73 -0
  4. package/src/adapters/find-source-files.ts +1 -1
  5. package/src/adapters/git-repo-fetcher.ts +154 -37
  6. package/src/adapters/lsp/json-rpc-stream.ts +52 -2
  7. package/src/adapters/lsp/language-server-process.ts +54 -10
  8. package/src/adapters/lsp/lsp-symbol-index.ts +133 -32
  9. package/src/adapters/normalize-npm-repository.ts +77 -0
  10. package/src/adapters/npm-lockfile-version-resolver.ts +519 -0
  11. package/src/adapters/npm-package-source-resolver.ts +322 -0
  12. package/src/adapters/npm-registry-client.ts +304 -0
  13. package/src/adapters/sqlite-symbol-graph.ts +47 -3
  14. package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +61 -11
  15. package/src/adapters/typescript-compiler-symbol-index.ts +129 -0
  16. package/src/cli.ts +87 -23
  17. package/src/daemon.ts +4 -0
  18. package/src/domain/find-workspace-symbols.ts +4 -3
  19. package/src/domain/installed-package-version.ts +66 -0
  20. package/src/domain/intelligence-provenance.ts +19 -0
  21. package/src/domain/language-server-descriptor.ts +18 -0
  22. package/src/domain/npm-package-metadata.ts +26 -0
  23. package/src/domain/package-source.ts +143 -0
  24. package/src/domain/repo-fetch-result.ts +33 -1
  25. package/src/domain/resolve-package-source.ts +204 -0
  26. package/src/domain/symbol-graph-generation.ts +3 -0
  27. package/src/domain/symbol-query.ts +16 -0
  28. package/src/domain/workspace-symbol.ts +8 -0
  29. package/src/index.ts +61 -4
  30. package/src/ports/installed-package-version-resolver-port.ts +5 -0
  31. package/src/ports/npm-registry-port.ts +5 -0
  32. package/src/ports/package-source-resolver-port.ts +5 -0
  33. package/src/ports/repo-fetcher-port.ts +2 -2
  34. package/src/ports/symbol-index-port.ts +4 -2
  35. package/src/service.ts +89 -25
@@ -0,0 +1,519 @@
1
+ import { closeSync, existsSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { parseSyml } from "@yarnpkg/parsers";
4
+ import { type ParseError, parse as parseJsonc } from "jsonc-parser";
5
+ import { parseDocument } from "yaml";
6
+ import type {
7
+ InstalledPackageEvidence,
8
+ InstalledPackageVersionBounds,
9
+ InstalledPackageVersionCandidate,
10
+ InstalledPackageVersionOutcome,
11
+ InstalledPackageVersionRequest,
12
+ JavaScriptPackageManager,
13
+ OversizedInstalledPackageVersion,
14
+ } from "../domain/installed-package-version.ts";
15
+ import type { InstalledPackageVersionResolverPort } from "../ports/installed-package-version-resolver-port.ts";
16
+
17
+ interface ParsedEvidence {
18
+ readonly version: string;
19
+ readonly evidence: InstalledPackageEvidence;
20
+ }
21
+
22
+ type ManifestSyntax = "json" | "yaml";
23
+ type LimitedResource = OversizedInstalledPackageVersion["resource"];
24
+
25
+ const HARD_MAX_MANIFEST_BYTES = 256 * 1024 * 1024;
26
+ const HARD_MAX_MANIFEST_ENTRIES = 5_000_000;
27
+ const HARD_MAX_MANIFEST_NESTING = 512;
28
+ const HARD_MAX_WORKSPACES = 100_000;
29
+ const HARD_MAX_DIAGNOSTICS = 10_000;
30
+ const HARD_MAX_CANDIDATES = 100_000;
31
+ const HARD_MAX_EVIDENCE_PER_VERSION = 100_000;
32
+ const READ_CHUNK_BYTES = 64 * 1024;
33
+
34
+ class ManifestResourceLimitExceeded extends Error {
35
+ readonly resource: LimitedResource;
36
+
37
+ constructor(resource: LimitedResource) {
38
+ super(resource);
39
+ this.resource = resource;
40
+ }
41
+ }
42
+
43
+ class UnsupportedLockfile extends Error {}
44
+
45
+ export class InvalidInstalledPackageVersionRequest extends Error {
46
+ constructor(field: string) {
47
+ super(`invalid installed-package version request: ${field}`);
48
+ this.name = "InvalidInstalledPackageVersionRequest";
49
+ }
50
+ }
51
+
52
+ function assertText(value: string, field: string, maxLength: number): void {
53
+ if (value.length === 0 || value.length > maxLength) throw new InvalidInstalledPackageVersionRequest(field);
54
+ for (let index = 0; index < value.length; index++) {
55
+ const code = value.charCodeAt(index);
56
+ if (code <= 31 || code === 127) throw new InvalidInstalledPackageVersionRequest(field);
57
+ }
58
+ }
59
+
60
+ function assertBound(value: number, field: string, hardMaximum: number): void {
61
+ if (!Number.isSafeInteger(value) || value < 1 || value > hardMaximum) throw new InvalidInstalledPackageVersionRequest(field);
62
+ }
63
+
64
+ function validateInput(request: InstalledPackageVersionRequest, bounds: InstalledPackageVersionBounds): void {
65
+ assertText(request.projectRoot, "projectRoot", 4096);
66
+ assertText(request.packageName, "packageName", 512);
67
+ if (request.requestedVersion !== null) assertText(request.requestedVersion, "requestedVersion", 256);
68
+ assertBound(bounds.maxManifestBytes, "maxManifestBytes", HARD_MAX_MANIFEST_BYTES);
69
+ assertBound(bounds.maxManifestEntries, "maxManifestEntries", HARD_MAX_MANIFEST_ENTRIES);
70
+ assertBound(bounds.maxManifestNesting, "maxManifestNesting", HARD_MAX_MANIFEST_NESTING);
71
+ assertBound(bounds.maxWorkspaces, "maxWorkspaces", HARD_MAX_WORKSPACES);
72
+ assertBound(bounds.maxDiagnostics, "maxDiagnostics", HARD_MAX_DIAGNOSTICS);
73
+ assertBound(bounds.maxCandidates, "maxCandidates", HARD_MAX_CANDIDATES);
74
+ assertBound(bounds.maxEvidencePerVersion, "maxEvidencePerVersion", HARD_MAX_EVIDENCE_PER_VERSION);
75
+ }
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return typeof value === "object" && value !== null && !Array.isArray(value);
79
+ }
80
+
81
+ function stringField(record: Record<string, unknown>, key: string): string | null {
82
+ const value = record[key];
83
+ return typeof value === "string" && value.length > 0 ? value : null;
84
+ }
85
+
86
+ function numericVersion(value: unknown): number | null {
87
+ if (typeof value === "number" && Number.isFinite(value)) return value;
88
+ if (typeof value !== "string" || value.trim().length === 0) return null;
89
+ const parsed = Number(value);
90
+ return Number.isFinite(parsed) ? parsed : null;
91
+ }
92
+
93
+ function versionFromLocator(locator: string, packageName: string): string | null {
94
+ const normalized = locator.startsWith("/") ? locator.slice(1) : locator;
95
+ const modernPrefix = `${packageName}@`;
96
+ if (normalized.startsWith(modernPrefix)) {
97
+ let version = normalized.slice(modernPrefix.length).split("(", 1)[0] ?? "";
98
+ if (version.startsWith("npm:")) version = version.slice("npm:".length);
99
+ return version.length > 0 ? version : null;
100
+ }
101
+ const legacyPrefix = `${packageName}/`;
102
+ if (!normalized.startsWith(legacyPrefix)) return null;
103
+ const version = normalized.slice(legacyPrefix.length).split("_", 1)[0] ?? "";
104
+ return version.length > 0 ? version : null;
105
+ }
106
+
107
+ function workspaceLocator(selector: string): { name: string; path: string } | null {
108
+ const marker = "@workspace:";
109
+ const markerIndex = selector.indexOf(marker);
110
+ if (markerIndex < 1) return null;
111
+ const name = selector.slice(0, markerIndex);
112
+ const path = selector.slice(markerIndex + marker.length).split("(", 1)[0] ?? "";
113
+ return name.length > 0 && path.length > 0 ? { name, path } : null;
114
+ }
115
+
116
+ function evidence(
117
+ manager: JavaScriptPackageManager,
118
+ lockfile: string,
119
+ locator: string,
120
+ integrity: string | null,
121
+ workspace: boolean,
122
+ ): InstalledPackageEvidence {
123
+ return { manager, lockfile, locator, integrity, workspace };
124
+ }
125
+
126
+ function jsonNesting(text: string): number {
127
+ let depth = 0;
128
+ let maximum = 0;
129
+ let quote: '"' | "'" | null = null;
130
+ let escaped = false;
131
+ let lineComment = false;
132
+ let blockComment = false;
133
+ for (let index = 0; index < text.length; index++) {
134
+ const character = text[index] ?? "";
135
+ const next = text[index + 1] ?? "";
136
+ if (lineComment) {
137
+ if (character === "\n") lineComment = false;
138
+ continue;
139
+ }
140
+ if (blockComment) {
141
+ if (character === "*" && next === "/") {
142
+ blockComment = false;
143
+ index++;
144
+ }
145
+ continue;
146
+ }
147
+ if (quote !== null) {
148
+ if (escaped) escaped = false;
149
+ else if (character === "\\") escaped = true;
150
+ else if (character === quote) quote = null;
151
+ continue;
152
+ }
153
+ if (character === "/" && next === "/") {
154
+ lineComment = true;
155
+ index++;
156
+ continue;
157
+ }
158
+ if (character === "/" && next === "*") {
159
+ blockComment = true;
160
+ index++;
161
+ continue;
162
+ }
163
+ if (character === '"' || character === "'") {
164
+ quote = character;
165
+ continue;
166
+ }
167
+ if (character === "{" || character === "[") {
168
+ depth++;
169
+ maximum = Math.max(maximum, depth);
170
+ } else if (character === "}" || character === "]") {
171
+ depth = Math.max(0, depth - 1);
172
+ }
173
+ }
174
+ return maximum;
175
+ }
176
+
177
+ function yamlNesting(text: string): number {
178
+ const indentation: number[] = [];
179
+ let maximum = 0;
180
+ for (const line of text.split(/\r?\n/)) {
181
+ if (/^\s*(?:#.*)?$/.test(line)) continue;
182
+ const width = line.match(/^[ \t]*/)?.[0].replaceAll("\t", " ").length ?? 0;
183
+ while (indentation.length > 0 && width <= (indentation.at(-1) ?? 0)) indentation.pop();
184
+ indentation.push(width);
185
+ maximum = Math.max(maximum, indentation.length + jsonNesting(line));
186
+ }
187
+ return maximum;
188
+ }
189
+
190
+ class ResolutionContext {
191
+ private bytesRead = 0;
192
+ private entriesRead = 0;
193
+ private diagnosticsRead = 0;
194
+ private readonly workspaces = new Set<string>();
195
+ private readonly root: string;
196
+ readonly bounds: InstalledPackageVersionBounds;
197
+
198
+ constructor(projectRoot: string, bounds: InstalledPackageVersionBounds) {
199
+ this.root = realpathSync(resolve(projectRoot));
200
+ this.bounds = bounds;
201
+ }
202
+
203
+ touchEntry(): void {
204
+ this.entriesRead++;
205
+ if (this.entriesRead > this.bounds.maxManifestEntries) throw new ManifestResourceLimitExceeded("manifest-entries");
206
+ }
207
+
208
+ reportDiagnostics(count: number): void {
209
+ this.diagnosticsRead += count;
210
+ if (this.diagnosticsRead > this.bounds.maxDiagnostics) throw new ManifestResourceLimitExceeded("diagnostics");
211
+ }
212
+
213
+ touchWorkspace(workspacePath: string): void {
214
+ const normalized = workspacePath === "" ? "." : workspacePath.replaceAll("\\", "/");
215
+ if (this.workspaces.has(normalized)) return;
216
+ this.workspaces.add(normalized);
217
+ if (this.workspaces.size > this.bounds.maxWorkspaces) throw new ManifestResourceLimitExceeded("workspaces");
218
+ }
219
+
220
+ private projectPath(relativePath: string): string {
221
+ if (isAbsolute(relativePath)) throw new Error("manifest path is absolute");
222
+ const absolutePath = resolve(this.root, relativePath);
223
+ const relativePathFromRoot = relative(this.root, absolutePath);
224
+ if (relativePathFromRoot === ".." || relativePathFromRoot.startsWith(`..${sep}`) || isAbsolute(relativePathFromRoot)) {
225
+ throw new Error("manifest path escapes project root");
226
+ }
227
+ if (!existsSync(absolutePath)) return absolutePath;
228
+ const realPath = realpathSync(absolutePath);
229
+ const realPathFromRoot = relative(this.root, realPath);
230
+ if (realPathFromRoot === ".." || realPathFromRoot.startsWith(`..${sep}`) || isAbsolute(realPathFromRoot)) {
231
+ throw new Error("manifest symlink escapes project root");
232
+ }
233
+ return realPath;
234
+ }
235
+
236
+ readProjectFile(relativePath: string, syntax: ManifestSyntax): string {
237
+ const descriptor = openSync(this.projectPath(relativePath), "r");
238
+ try {
239
+ const stat = fstatSync(descriptor);
240
+ if (!stat.isFile()) throw new Error("manifest is not a regular file");
241
+ const remaining = this.bounds.maxManifestBytes - this.bytesRead;
242
+ if (stat.size > remaining) throw new ManifestResourceLimitExceeded("manifest-bytes");
243
+ const chunks: Buffer[] = [];
244
+ let total = 0;
245
+ let bytesRead: number;
246
+ do {
247
+ const capacity = Math.min(READ_CHUNK_BYTES, remaining - total + 1);
248
+ const chunk = Buffer.allocUnsafe(capacity);
249
+ bytesRead = readSync(descriptor, chunk, 0, capacity, null);
250
+ total += bytesRead;
251
+ if (total > remaining) throw new ManifestResourceLimitExceeded("manifest-bytes");
252
+ if (bytesRead > 0) chunks.push(chunk.subarray(0, bytesRead));
253
+ } while (bytesRead > 0);
254
+ this.bytesRead += total;
255
+ const text = Buffer.concat(chunks, total).toString("utf8");
256
+ const nesting = syntax === "json" ? jsonNesting(text) : yamlNesting(text);
257
+ if (nesting > this.bounds.maxManifestNesting) throw new ManifestResourceLimitExceeded("manifest-nesting");
258
+ return text;
259
+ } finally {
260
+ closeSync(descriptor);
261
+ }
262
+ }
263
+
264
+ workspaceVersion(workspacePath: string, packageName: string, lockfile: string, manager: JavaScriptPackageManager): ParsedEvidence | null {
265
+ this.touchWorkspace(workspacePath);
266
+ const normalized = workspacePath === "." || workspacePath === "" ? "package.json" : join(workspacePath, "package.json");
267
+ const manifestPath = this.projectPath(normalized);
268
+ if (!existsSync(manifestPath)) return null;
269
+ const text = this.readProjectFile(normalized, "json");
270
+ let parsed: unknown;
271
+ try {
272
+ parsed = JSON.parse(text);
273
+ } catch {
274
+ this.reportDiagnostics(1);
275
+ throw new Error("invalid workspace package manifest");
276
+ }
277
+ this.touchEntry();
278
+ if (!isRecord(parsed) || stringField(parsed, "name") !== packageName) return null;
279
+ const version = stringField(parsed, "version");
280
+ if (version === null) return null;
281
+ return { version, evidence: evidence(manager, lockfile, workspacePath, null, true) };
282
+ }
283
+ }
284
+
285
+ function parseJson(text: string, context: ResolutionContext): unknown {
286
+ try {
287
+ return JSON.parse(text);
288
+ } catch {
289
+ context.reportDiagnostics(1);
290
+ throw new Error("invalid JSON lockfile");
291
+ }
292
+ }
293
+
294
+ function parseNpmLock(text: string, lockfile: string, packageName: string, context: ResolutionContext): ParsedEvidence[] {
295
+ const parsed = parseJson(text, context);
296
+ if (!isRecord(parsed)) throw new Error("lockfile root is not an object");
297
+ const lockfileVersion = parsed.lockfileVersion;
298
+ if (lockfileVersion !== 2 && lockfileVersion !== 3) throw new UnsupportedLockfile();
299
+ if (!isRecord(parsed.packages)) throw new Error("packages is missing");
300
+ const results: ParsedEvidence[] = [];
301
+ for (const [locator, rawEntry] of Object.entries(parsed.packages)) {
302
+ context.touchEntry();
303
+ if (!isRecord(rawEntry)) continue;
304
+ const workspace = !locator.includes("node_modules");
305
+ if (workspace) context.touchWorkspace(locator);
306
+ const declaredName = stringField(rawEntry, "name");
307
+ const isRequestedWorkspace = workspace && declaredName === packageName;
308
+ const isInstalledEntry = locator === `node_modules/${packageName}` || locator.endsWith(`/node_modules/${packageName}`);
309
+ if (!isRequestedWorkspace && !isInstalledEntry) continue;
310
+ let version = stringField(rawEntry, "version");
311
+ if (version === null && rawEntry.link === true) {
312
+ const target = stringField(rawEntry, "resolved");
313
+ const targetEntry = target ? parsed.packages[target] : undefined;
314
+ if (isRecord(targetEntry)) version = stringField(targetEntry, "version");
315
+ }
316
+ if (version === null) continue;
317
+ results.push({
318
+ version,
319
+ evidence: evidence("npm", lockfile, locator, stringField(rawEntry, "integrity"), workspace || rawEntry.link === true),
320
+ });
321
+ }
322
+ return results;
323
+ }
324
+
325
+ function parsePnpmLock(text: string, lockfile: string, packageName: string, context: ResolutionContext): ParsedEvidence[] {
326
+ const document = parseDocument(text);
327
+ context.reportDiagnostics(document.errors.length + document.warnings.length);
328
+ if (document.errors.length > 0) throw new Error("invalid pnpm YAML");
329
+ const parsed: unknown = document.toJS({ maxAliasCount: Math.min(context.bounds.maxManifestEntries, 1_000) });
330
+ if (!isRecord(parsed)) throw new Error("lockfile root is not an object");
331
+ const lockfileVersion = numericVersion(parsed.lockfileVersion);
332
+ if (lockfileVersion === null || lockfileVersion < 5.3 || lockfileVersion >= 10) throw new UnsupportedLockfile();
333
+ if (!isRecord(parsed.packages) && !isRecord(parsed.importers)) throw new Error("packages and importers are missing");
334
+ const results: ParsedEvidence[] = [];
335
+ if (isRecord(parsed.importers)) {
336
+ for (const workspacePath of Object.keys(parsed.importers)) {
337
+ context.touchEntry();
338
+ const workspace = context.workspaceVersion(workspacePath, packageName, lockfile, "pnpm");
339
+ if (workspace !== null) results.push(workspace);
340
+ }
341
+ }
342
+ for (const [locator, rawEntry] of Object.entries(isRecord(parsed.packages) ? parsed.packages : {})) {
343
+ context.touchEntry();
344
+ const version = versionFromLocator(locator, packageName);
345
+ if (version === null) continue;
346
+ const integrity = isRecord(rawEntry) && isRecord(rawEntry.resolution) ? stringField(rawEntry.resolution, "integrity") : null;
347
+ results.push({ version, evidence: evidence("pnpm", lockfile, locator, integrity, false) });
348
+ }
349
+ return results;
350
+ }
351
+
352
+ function parseYarnLock(text: string, lockfile: string, packageName: string, context: ResolutionContext): ParsedEvidence[] {
353
+ let parsed: unknown;
354
+ try {
355
+ parsed = parseSyml(text);
356
+ } catch {
357
+ context.reportDiagnostics(1);
358
+ throw new Error("invalid Yarn lockfile");
359
+ }
360
+ if (!isRecord(parsed)) throw new Error("lockfile root is not an object");
361
+ const metadata = parsed.__metadata;
362
+ if (metadata !== undefined) {
363
+ if (!isRecord(metadata)) throw new Error("invalid Yarn metadata");
364
+ const version = numericVersion(metadata.version);
365
+ if (version === null || version < 4 || version > 8) throw new UnsupportedLockfile();
366
+ }
367
+ const results: ParsedEvidence[] = [];
368
+ for (const [locator, rawEntry] of Object.entries(parsed)) {
369
+ if (locator === "__metadata") continue;
370
+ context.touchEntry();
371
+ if (!isRecord(rawEntry)) continue;
372
+ const selectors = locator.split(",").map((selector) => selector.trim());
373
+ for (const selector of selectors) {
374
+ const workspace = workspaceLocator(selector);
375
+ if (workspace === null) continue;
376
+ context.touchWorkspace(workspace.path);
377
+ if (workspace.name !== packageName) continue;
378
+ const candidate = context.workspaceVersion(workspace.path, packageName, lockfile, "yarn");
379
+ if (candidate !== null) results.push(candidate);
380
+ }
381
+ if (!selectors.some((selector) => versionFromLocator(selector, packageName) !== null)) continue;
382
+ const version = stringField(rawEntry, "version");
383
+ if (version === null || selectors.some((selector) => workspaceLocator(selector)?.name === packageName)) continue;
384
+ results.push({
385
+ version,
386
+ evidence: evidence("yarn", lockfile, locator, stringField(rawEntry, "integrity") ?? stringField(rawEntry, "checksum"), false),
387
+ });
388
+ }
389
+ return results;
390
+ }
391
+
392
+ function parseBunLock(text: string, lockfile: string, packageName: string, context: ResolutionContext): ParsedEvidence[] {
393
+ const errors: ParseError[] = [];
394
+ const parsed: unknown = parseJsonc(text, errors, { allowTrailingComma: true, disallowComments: false });
395
+ context.reportDiagnostics(errors.length);
396
+ if (errors.length > 0 || !isRecord(parsed)) throw new Error("invalid Bun lockfile");
397
+ if (parsed.lockfileVersion !== 1) throw new UnsupportedLockfile();
398
+ if (!isRecord(parsed.packages)) throw new Error("packages is missing");
399
+ const results: ParsedEvidence[] = [];
400
+ if (isRecord(parsed.workspaces)) {
401
+ for (const [workspacePath, rawWorkspace] of Object.entries(parsed.workspaces)) {
402
+ context.touchEntry();
403
+ context.touchWorkspace(workspacePath);
404
+ if (isRecord(rawWorkspace) && stringField(rawWorkspace, "name") === packageName) {
405
+ const version = stringField(rawWorkspace, "version");
406
+ if (version !== null) {
407
+ results.push({ version, evidence: evidence("bun", lockfile, workspacePath, null, true) });
408
+ continue;
409
+ }
410
+ }
411
+ const workspace = context.workspaceVersion(workspacePath, packageName, lockfile, "bun");
412
+ if (workspace !== null) results.push(workspace);
413
+ }
414
+ }
415
+ for (const [locator, rawEntry] of Object.entries(parsed.packages)) {
416
+ context.touchEntry();
417
+ if (!Array.isArray(rawEntry) || typeof rawEntry[0] !== "string") continue;
418
+ const version = versionFromLocator(rawEntry[0], packageName);
419
+ if (version === null) continue;
420
+ const integrity = typeof rawEntry[3] === "string" && rawEntry[3].length > 0 ? rawEntry[3] : null;
421
+ results.push({ version, evidence: evidence("bun", lockfile, locator, integrity, false) });
422
+ }
423
+ return results;
424
+ }
425
+
426
+ function mergeCandidates(parsed: readonly ParsedEvidence[], maxEvidencePerVersion: number): InstalledPackageVersionCandidate[] {
427
+ const byVersion = new Map<string, InstalledPackageEvidence[]>();
428
+ const truncatedVersions = new Set<string>();
429
+ for (const entry of parsed) {
430
+ const evidenceList = byVersion.get(entry.version) ?? [];
431
+ if (
432
+ !evidenceList.some(
433
+ (item) => item.manager === entry.evidence.manager && item.lockfile === entry.evidence.lockfile && item.locator === entry.evidence.locator,
434
+ )
435
+ ) {
436
+ if (evidenceList.length < maxEvidencePerVersion) evidenceList.push(entry.evidence);
437
+ else truncatedVersions.add(entry.version);
438
+ }
439
+ byVersion.set(entry.version, evidenceList);
440
+ }
441
+ return Array.from(byVersion, ([version, evidenceList]) => ({
442
+ version,
443
+ evidence: evidenceList,
444
+ evidenceTruncated: truncatedVersions.has(version),
445
+ })).sort((left, right) => left.version.localeCompare(right.version));
446
+ }
447
+
448
+ function resourceLimitOutcome(error: ManifestResourceLimitExceeded, bounds: InstalledPackageVersionBounds): InstalledPackageVersionOutcome {
449
+ const limits: Record<LimitedResource, number> = {
450
+ "manifest-bytes": bounds.maxManifestBytes,
451
+ "manifest-entries": bounds.maxManifestEntries,
452
+ "manifest-nesting": bounds.maxManifestNesting,
453
+ workspaces: bounds.maxWorkspaces,
454
+ diagnostics: bounds.maxDiagnostics,
455
+ };
456
+ return { status: "oversized", resource: error.resource, limit: limits[error.resource] };
457
+ }
458
+
459
+ export class NpmLockfileVersionResolver implements InstalledPackageVersionResolverPort {
460
+ resolve(request: InstalledPackageVersionRequest, bounds: InstalledPackageVersionBounds): Promise<InstalledPackageVersionOutcome> {
461
+ validateInput(request, bounds);
462
+ const npmLock = existsSync(join(request.projectRoot, "npm-shrinkwrap.json")) ? "npm-shrinkwrap.json" : "package-lock.json";
463
+ const lockfiles = [npmLock, "pnpm-lock.yaml", "yarn.lock", "bun.lock"].filter((name) => existsSync(join(request.projectRoot, name)));
464
+ if (lockfiles.length === 0) {
465
+ const binaryBunLock = "bun.lockb";
466
+ return Promise.resolve(
467
+ existsSync(join(request.projectRoot, binaryBunLock))
468
+ ? { status: "unavailable", code: "unsupported-lockfile", lockfile: binaryBunLock }
469
+ : { status: "unavailable", code: "lockfile-not-found" },
470
+ );
471
+ }
472
+
473
+ const context = new ResolutionContext(request.projectRoot, bounds);
474
+ const parsed: ParsedEvidence[] = [];
475
+ for (const lockfile of lockfiles) {
476
+ try {
477
+ const syntax: ManifestSyntax = lockfile === "package-lock.json" || lockfile === "npm-shrinkwrap.json" || lockfile === "bun.lock" ? "json" : "yaml";
478
+ const text = context.readProjectFile(lockfile, syntax);
479
+ if (lockfile === "package-lock.json" || lockfile === "npm-shrinkwrap.json") parsed.push(...parseNpmLock(text, lockfile, request.packageName, context));
480
+ else if (lockfile === "pnpm-lock.yaml") parsed.push(...parsePnpmLock(text, lockfile, request.packageName, context));
481
+ else if (lockfile === "yarn.lock") parsed.push(...parseYarnLock(text, lockfile, request.packageName, context));
482
+ else parsed.push(...parseBunLock(text, lockfile, request.packageName, context));
483
+ } catch (error) {
484
+ if (error instanceof ManifestResourceLimitExceeded) return Promise.resolve(resourceLimitOutcome(error, bounds));
485
+ return Promise.resolve({
486
+ status: "unavailable",
487
+ code: error instanceof UnsupportedLockfile ? "unsupported-lockfile" : "corrupt-lockfile",
488
+ lockfile,
489
+ });
490
+ }
491
+ }
492
+
493
+ let candidates = mergeCandidates(parsed, bounds.maxEvidencePerVersion);
494
+ if (request.requestedVersion !== null) candidates = candidates.filter(({ version }) => version === request.requestedVersion);
495
+ if (candidates.length === 0) {
496
+ return Promise.resolve({ status: "unavailable", code: request.requestedVersion === null ? "package-not-found" : "version-not-found" });
497
+ }
498
+ if (candidates.length === 1) {
499
+ const candidate = candidates[0];
500
+ if (!candidate) return Promise.resolve({ status: "unavailable", code: "package-not-found" });
501
+ return Promise.resolve({
502
+ status: "resolved",
503
+ packageName: request.packageName,
504
+ requestedVersion: request.requestedVersion,
505
+ version: candidate.version,
506
+ evidence: candidate.evidence,
507
+ evidenceTruncated: candidate.evidenceTruncated,
508
+ });
509
+ }
510
+ const truncated = candidates.length > bounds.maxCandidates;
511
+ return Promise.resolve({
512
+ status: "ambiguous",
513
+ packageName: request.packageName,
514
+ requestedVersion: null,
515
+ candidates: candidates.slice(0, bounds.maxCandidates),
516
+ truncated,
517
+ });
518
+ }
519
+ }