@seorii/libcollect 0.1.0 → 1.0.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/include-deps.mjs CHANGED
@@ -6,10 +6,12 @@ import process from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import semver from "semver";
8
8
 
9
- const DEFAULT_CONCURRENCY = 10;
9
+ const DEFAULT_CONCURRENCY = 50;
10
10
  const DEFAULT_SOURCE_SECTIONS = ["dependencies", "optionalDependencies", "peerDependencies"];
11
11
  const DEFAULT_INCLUDE_TYPES = ["peerDependency", "optionalDependency", "bundledDependency"];
12
12
  const REGISTRY_BASE = "https://registry.npmjs.org";
13
+ const FETCH_RETRY_COUNT = 3;
14
+ const FETCH_RETRY_BASE_DELAY_MS = 250;
13
15
 
14
16
  function createLimiter(maxConcurrency) {
15
17
  let activeCount = 0;
@@ -38,7 +40,13 @@ function createLimiter(maxConcurrency) {
38
40
  };
39
41
  }
40
42
 
41
- function createRegistryContext(concurrency = DEFAULT_CONCURRENCY) {
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => {
45
+ setTimeout(resolve, ms);
46
+ });
47
+ }
48
+
49
+ function createRegistryContext(concurrency = DEFAULT_CONCURRENCY, logger = null) {
42
50
  const safeConcurrency =
43
51
  Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : DEFAULT_CONCURRENCY;
44
52
 
@@ -47,20 +55,42 @@ function createRegistryContext(concurrency = DEFAULT_CONCURRENCY) {
47
55
  fetchLimit: createLimiter(safeConcurrency),
48
56
  metaCache: new Map(),
49
57
  resolvedCache: new Map(),
58
+ warnings: [],
59
+ logger,
50
60
  };
51
61
  }
52
62
 
53
63
  async function fetchJson(url, registryContext) {
54
64
  return registryContext.fetchLimit(async () => {
55
- const res = await fetch(url, {
56
- headers: { accept: "application/json" },
57
- });
65
+ for (let attempt = 0; attempt <= FETCH_RETRY_COUNT; attempt += 1) {
66
+ let failureMessage = null;
58
67
 
59
- if (!res.ok) {
60
- throw new Error(`HTTP ${res.status} for ${url}`);
61
- }
68
+ try {
69
+ const res = await fetch(url, {
70
+ headers: { accept: "application/json" },
71
+ });
62
72
 
63
- return res.json();
73
+ if (res.ok) {
74
+ return res.json();
75
+ }
76
+
77
+ failureMessage = `HTTP ${res.status} for ${url}`;
78
+ } catch (error) {
79
+ failureMessage = error instanceof Error ? error.message : String(error);
80
+ }
81
+
82
+ if (attempt === FETCH_RETRY_COUNT) {
83
+ throw new Error(failureMessage);
84
+ }
85
+
86
+ const delayMs = FETCH_RETRY_BASE_DELAY_MS * 2 ** attempt;
87
+ if (typeof registryContext.logger === "function") {
88
+ registryContext.logger(
89
+ `[libcollect] registry fetch failed, retrying ${attempt + 1}/${FETCH_RETRY_COUNT} in ${delayMs}ms: ${failureMessage}`,
90
+ );
91
+ }
92
+ await sleep(delayMs);
93
+ }
64
94
  });
65
95
  }
66
96
 
@@ -99,12 +129,87 @@ function rangeMentionsPrerelease(range) {
99
129
  return /-\w/.test(String(range || ""));
100
130
  }
101
131
 
132
+ function splitDisjunctiveRange(range) {
133
+ return String(range || "")
134
+ .split("||")
135
+ .map((part) => part.trim())
136
+ .filter(Boolean);
137
+ }
138
+
102
139
  function getAllVersions(meta) {
103
140
  return Object.keys(meta.versions || {})
104
141
  .filter((version) => !!semver.valid(version))
105
142
  .sort(semver.rcompare);
106
143
  }
107
144
 
145
+ function getPrereleaseChannel(version) {
146
+ const prerelease = semver.prerelease(version);
147
+ if (!prerelease || prerelease.length === 0) {
148
+ return null;
149
+ }
150
+
151
+ return String(prerelease[0] || "")
152
+ .split("-")[0]
153
+ .trim() || null;
154
+ }
155
+
156
+ function findFallbackVersionFromMeta(meta, wanted) {
157
+ const target = String(wanted || "").trim();
158
+ if (!semver.valid(target)) {
159
+ return null;
160
+ }
161
+
162
+ const targetChannel = getPrereleaseChannel(target);
163
+ if (!targetChannel) {
164
+ return null;
165
+ }
166
+
167
+ return (
168
+ getAllVersions(meta).find((version) => {
169
+ if (!semver.prerelease(version)) {
170
+ return false;
171
+ }
172
+
173
+ return (
174
+ semver.major(version) === semver.major(target) &&
175
+ semver.minor(version) === semver.minor(target) &&
176
+ semver.patch(version) === semver.patch(target) &&
177
+ getPrereleaseChannel(version) === targetChannel
178
+ );
179
+ }) || null
180
+ );
181
+ }
182
+
183
+ function versionSatisfiesWanted(version, wanted, meta) {
184
+ const target = String(wanted || "latest").trim() || "latest";
185
+ const distTags = meta["dist-tags"] || {};
186
+
187
+ if (target === "latest" || target === "*") {
188
+ return true;
189
+ }
190
+
191
+ const parts = splitDisjunctiveRange(target);
192
+ if (parts.length > 1) {
193
+ return parts.some((part) => versionSatisfiesWanted(version, part, meta));
194
+ }
195
+
196
+ if (distTags[target]) {
197
+ return version === distTags[target];
198
+ }
199
+
200
+ if (meta.versions?.[target]) {
201
+ return version === target;
202
+ }
203
+
204
+ if (!semver.validRange(target)) {
205
+ return false;
206
+ }
207
+
208
+ return semver.satisfies(version, target, {
209
+ includePrerelease: rangeMentionsPrerelease(target),
210
+ });
211
+ }
212
+
108
213
  function pickVersionFromMeta(meta, wanted) {
109
214
  const versions = getAllVersions(meta);
110
215
  const distTags = meta["dist-tags"] || {};
@@ -122,13 +227,7 @@ function pickVersionFromMeta(meta, wanted) {
122
227
  return target;
123
228
  }
124
229
 
125
- if (!semver.validRange(target)) {
126
- return null;
127
- }
128
-
129
- return semver.maxSatisfying(versions, target, {
130
- includePrerelease: rangeMentionsPrerelease(target),
131
- });
230
+ return versions.find((version) => versionSatisfiesWanted(version, target, meta)) || null;
132
231
  }
133
232
 
134
233
  async function resolveVersion(name, wanted, registryContext) {
@@ -157,6 +256,14 @@ async function resolveVersion(name, wanted, registryContext) {
157
256
  const resolvedVersion = pickVersionFromMeta(meta, wanted);
158
257
 
159
258
  if (!resolvedVersion) {
259
+ const fallbackVersion = findFallbackVersionFromMeta(meta, wanted);
260
+ if (fallbackVersion) {
261
+ registryContext.warnings.push(
262
+ `${name}: requested ${wanted} is unavailable in the registry, using nearest published ${fallbackVersion} instead.`,
263
+ );
264
+ return fallbackVersion;
265
+ }
266
+
160
267
  throw new Error(`Cannot resolve version: ${name}@${wanted || "latest"}`);
161
268
  }
162
269
 
@@ -280,36 +387,44 @@ async function walkNode(name, wanted, edgeType, parent, registryContext, visited
280
387
  return makeSkippedNode(name, wanted, edgeType, parent, "non-registry spec");
281
388
  }
282
389
 
283
- const alias = parseAliasSpec(wanted);
284
- if (alias) {
285
- const resolvedAlias = await resolveVersion(name, wanted, registryContext);
390
+ try {
391
+ const alias = parseAliasSpec(wanted);
392
+ if (alias) {
393
+ const resolvedAlias = await resolveVersion(name, wanted, registryContext);
394
+ return buildNodeFromPackage(
395
+ resolvedAlias.actualName,
396
+ name,
397
+ wanted,
398
+ resolvedAlias.version,
399
+ edgeType,
400
+ parent,
401
+ registryContext,
402
+ visited,
403
+ {
404
+ actualName: resolvedAlias.actualName,
405
+ aliased: true,
406
+ },
407
+ );
408
+ }
409
+
410
+ const resolvedVersion = await resolveVersion(name, wanted, registryContext);
286
411
  return buildNodeFromPackage(
287
- resolvedAlias.actualName,
412
+ name,
288
413
  name,
289
414
  wanted,
290
- resolvedAlias.version,
415
+ resolvedVersion,
291
416
  edgeType,
292
417
  parent,
293
418
  registryContext,
294
419
  visited,
295
- {
296
- actualName: resolvedAlias.actualName,
297
- aliased: true,
298
- },
299
420
  );
300
- }
421
+ } catch (error) {
422
+ if (edgeType !== "root" && isVersionResolutionError(error)) {
423
+ return makeSkippedNode(name, wanted, edgeType, parent, "unresolvable version");
424
+ }
301
425
 
302
- const resolvedVersion = await resolveVersion(name, wanted, registryContext);
303
- return buildNodeFromPackage(
304
- name,
305
- name,
306
- wanted,
307
- resolvedVersion,
308
- edgeType,
309
- parent,
310
- registryContext,
311
- visited,
312
- );
426
+ throw error;
427
+ }
313
428
  }
314
429
 
315
430
  function flattenTree(tree) {
@@ -354,7 +469,8 @@ function flattenTree(tree) {
354
469
  }
355
470
 
356
471
  async function collectNpmDeps(rootName, rootVersion = "latest", options = {}) {
357
- const registryContext = options.registryContext || createRegistryContext(options.concurrency);
472
+ const registryContext =
473
+ options.registryContext || createRegistryContext(options.concurrency, options.logger);
358
474
  const visited = new Set();
359
475
  const tree = await walkNode(rootName, rootVersion, "root", null, registryContext, visited);
360
476
  const flat = flattenTree(tree);
@@ -375,6 +491,26 @@ function dedupe(items) {
375
491
  return [...new Set(items.filter(Boolean))];
376
492
  }
377
493
 
494
+ function logProgress(logger, message) {
495
+ if (typeof logger !== "function") {
496
+ return;
497
+ }
498
+
499
+ logger(`[libcollect] ${message}`);
500
+ }
501
+
502
+ function shouldLogStep(index, total, step = 25) {
503
+ return index === 1 || index === total || index % step === 0;
504
+ }
505
+
506
+ function isVersionResolutionError(error) {
507
+ return (
508
+ error instanceof Error &&
509
+ (error.message.startsWith("Cannot resolve version:") ||
510
+ error.message.startsWith("Missing version metadata:"))
511
+ );
512
+ }
513
+
378
514
  function getLibcollectConfig(pkg) {
379
515
  const raw = pkg.libcollect && typeof pkg.libcollect === "object" ? pkg.libcollect : {};
380
516
 
@@ -475,34 +611,57 @@ function addCandidate(candidates, node, extra = {}) {
475
611
  if (node.aliased || extra.aliased) candidate.aliased = true;
476
612
  }
477
613
 
478
- async function collectCandidates(pkg, config, registryContext) {
614
+ async function collectCandidates(pkg, config, registryContext, logger) {
479
615
  const { roots, manualDependencies } = buildSourceRoots(pkg, config);
480
616
  const candidates = new Map();
481
617
  const warnings = [];
482
618
  const includeTypeSet = new Set(config.includeDependencyTypes);
483
619
 
484
- for (const root of roots) {
620
+ logProgress(
621
+ logger,
622
+ `collecting dependency graph from ${roots.length} root(s) with concurrency ${registryContext.concurrency}`,
623
+ );
624
+
625
+ for (const [index, root] of roots.entries()) {
626
+ const rootProgress = `${index + 1}/${roots.length}`;
627
+ logProgress(logger, `root ${rootProgress}: ${root.name}@${root.range} (${root.sourceSection})`);
628
+
485
629
  if (root.sourceSection !== "dependencies") {
486
630
  if (isNonRegistrySpec(root.range)) {
487
631
  warnings.push(
488
632
  `${root.name}: ${root.sourceSection}에 있는 non-registry spec(${root.range})은 dependencies로 자동 승격하지 않았습니다.`,
489
633
  );
490
634
  } else {
491
- const resolvedRoot = await resolveVersion(root.name, root.range, registryContext);
492
- addCandidate(
493
- candidates,
494
- {
495
- name: root.name,
496
- requested: root.range,
497
- version: typeof resolvedRoot === "string" ? resolvedRoot : resolvedRoot.version,
498
- actualName: typeof resolvedRoot === "string" ? null : resolvedRoot.actualName || null,
499
- dependencyType: "sourceSection",
500
- },
501
- {
502
- sourceSection: root.sourceSection,
503
- aliased: !!(typeof resolvedRoot !== "string" && resolvedRoot.actualName),
504
- },
505
- );
635
+ try {
636
+ const resolvedRoot = await resolveVersion(root.name, root.range, registryContext);
637
+ addCandidate(
638
+ candidates,
639
+ {
640
+ name: root.name,
641
+ requested: root.range,
642
+ version: typeof resolvedRoot === "string" ? resolvedRoot : resolvedRoot.version,
643
+ actualName: typeof resolvedRoot === "string" ? null : resolvedRoot.actualName || null,
644
+ dependencyType: "sourceSection",
645
+ },
646
+ {
647
+ sourceSection: root.sourceSection,
648
+ aliased: !!(typeof resolvedRoot !== "string" && resolvedRoot.actualName),
649
+ },
650
+ );
651
+ } catch (error) {
652
+ if (!isVersionResolutionError(error)) {
653
+ throw error;
654
+ }
655
+
656
+ warnings.push(
657
+ `${root.name}: ${root.sourceSection}에 있는 spec(${root.range})을 registry에서 찾지 못해 자동 승격하지 않았습니다.`,
658
+ );
659
+ logProgress(
660
+ logger,
661
+ `root ${rootProgress} skipped source-section promotion: unresolved version ${root.range}`,
662
+ );
663
+ continue;
664
+ }
506
665
  }
507
666
  }
508
667
 
@@ -510,13 +669,30 @@ async function collectCandidates(pkg, config, registryContext) {
510
669
  warnings.push(
511
670
  `${root.name}: root spec(${root.range})는 registry 기반이 아니어서 하위 dep 탐색을 건너뛰었습니다.`,
512
671
  );
672
+ logProgress(logger, `root ${rootProgress} skipped graph walk: non-registry spec ${root.range}`);
513
673
  continue;
514
674
  }
515
675
 
516
- const result = await collectNpmDeps(root.name, root.range, {
517
- registryContext,
518
- concurrency: config.concurrency,
519
- });
676
+ let result;
677
+
678
+ try {
679
+ result = await collectNpmDeps(root.name, root.range, {
680
+ registryContext,
681
+ concurrency: config.concurrency,
682
+ });
683
+ } catch (error) {
684
+ if (!isVersionResolutionError(error)) {
685
+ throw error;
686
+ }
687
+
688
+ warnings.push(
689
+ `${root.name}: root spec(${root.range})을 registry에서 찾지 못해 하위 dep 탐색을 건너뛰었습니다.`,
690
+ );
691
+ logProgress(logger, `root ${rootProgress} skipped graph walk: unresolved version ${root.range}`);
692
+ continue;
693
+ }
694
+
695
+ const candidateCountBefore = candidates.size;
520
696
 
521
697
  for (const node of result.flat) {
522
698
  if (node.skipped) {
@@ -532,6 +708,13 @@ async function collectCandidates(pkg, config, registryContext) {
532
708
 
533
709
  addCandidate(candidates, node);
534
710
  }
711
+
712
+ logProgress(
713
+ logger,
714
+ `root ${rootProgress} done: walked ${result.flat.length} node(s), ${
715
+ candidates.size - candidateCountBefore
716
+ } new candidate(s)`,
717
+ );
535
718
  }
536
719
 
537
720
  return { candidates, warnings, manualDependencies, roots };
@@ -548,14 +731,11 @@ async function resolvePreferredSpec(name, candidate, pkg, config, registryContex
548
731
 
549
732
  const meta = await getPackageMeta(targetName, registryContext);
550
733
  const versions = getAllVersions(meta);
551
- const includePrerelease = normalizedRanges.some(rangeMentionsPrerelease);
552
734
 
553
735
  const commonVersion = versions.find((version) =>
554
736
  normalizedRanges.every((range) => {
555
737
  if (range === "latest" || range === "*") return true;
556
- if (meta.versions?.[range]) return version === range;
557
- if (!semver.validRange(range)) return false;
558
- return semver.satisfies(version, range, { includePrerelease });
738
+ return versionSatisfiesWanted(version, range, meta);
559
739
  }),
560
740
  );
561
741
 
@@ -594,27 +774,46 @@ function diffKeys(beforeObj, afterObj) {
594
774
 
595
775
  export async function includeDependencies(
596
776
  packageJsonPath = path.resolve(process.cwd(), "package.json"),
777
+ options = {},
597
778
  ) {
779
+ const logger = typeof options.logger === "function" ? options.logger : null;
780
+ logProgress(logger, `reading ${packageJsonPath}`);
781
+
598
782
  const rawText = await fs.readFile(packageJsonPath, "utf8");
599
783
  const pkg = JSON.parse(rawText);
600
784
  const config = getLibcollectConfig(pkg);
601
- const registryContext = createRegistryContext(config.concurrency);
785
+ const registryContext = createRegistryContext(config.concurrency, logger);
602
786
  const beforeDependencies = { ...pkg.dependencies };
603
787
 
788
+ logProgress(
789
+ logger,
790
+ `config loaded: source sections [${config.sourceSections.join(", ")}], include types [${config.includeDependencyTypes.join(", ")}]`,
791
+ );
792
+
604
793
  const { candidates, warnings, manualDependencies, roots } = await collectCandidates(
605
794
  pkg,
606
795
  config,
607
796
  registryContext,
797
+ logger,
608
798
  );
609
799
 
610
800
  const autoIncludedDependencies = {};
611
801
  const versionConflictWarnings = [];
802
+ const pendingCandidates = [...candidates.entries()]
803
+ .sort(([a], [b]) => a.localeCompare(b))
804
+ .filter(([name]) => !manualDependencies[name]);
612
805
 
613
- for (const [name, candidate] of [...candidates.entries()].sort(([a], [b]) =>
614
- a.localeCompare(b),
615
- )) {
616
- if (manualDependencies[name]) {
617
- continue;
806
+ logProgress(
807
+ logger,
808
+ `selecting preferred specs for ${pendingCandidates.length} auto-include candidate(s)`,
809
+ );
810
+
811
+ for (const [index, [name, candidate]] of pendingCandidates.entries()) {
812
+ if (shouldLogStep(index + 1, pendingCandidates.length)) {
813
+ logProgress(
814
+ logger,
815
+ `spec selection ${index + 1}/${pendingCandidates.length}: ${name}`,
816
+ );
618
817
  }
619
818
 
620
819
  const spec = await resolvePreferredSpec(name, candidate, pkg, config, registryContext);
@@ -643,7 +842,9 @@ export async function includeDependencies(
643
842
  exactVersions: config.exactVersions,
644
843
  };
645
844
 
845
+ logProgress(logger, `writing backup to ${packageJsonPath}.bak`);
646
846
  await fs.writeFile(`${packageJsonPath}.bak`, rawText, "utf8");
847
+ logProgress(logger, `writing updated package.json`);
647
848
  await fs.writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
648
849
 
649
850
  const { added, removed } = diffKeys(beforeDependencies, nextDependencies);
@@ -654,7 +855,7 @@ export async function includeDependencies(
654
855
  roots,
655
856
  added,
656
857
  removed,
657
- warnings: [...warnings, ...versionConflictWarnings],
858
+ warnings: [...warnings, ...versionConflictWarnings, ...registryContext.warnings],
658
859
  autoIncludedCount: Object.keys(autoIncludedDependencies).length,
659
860
  dependencyCount: Object.keys(nextDependencies).length,
660
861
  };
@@ -684,7 +885,9 @@ async function runCli() {
684
885
  }
685
886
 
686
887
  try {
687
- const result = await includeDependencies();
888
+ const result = await includeDependencies(undefined, {
889
+ logger: (message) => console.log(message),
890
+ });
688
891
  console.log(
689
892
  `[libcollect] roots: ${result.roots.map((x) => `${x.name}@${x.range}`).join(", ")}`,
690
893
  );