ng-upgrade-doctor 0.2.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.
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Versioned rulebook of Angular / RxJS breaking changes and migration
5
+ * opportunities, detectable from source code.
6
+ *
7
+ * Each rule:
8
+ * id - stable slug
9
+ * title - short human label
10
+ * severity - 'removed' (hard break) | 'deprecated' (soft) | 'opportunity' (modernization)
11
+ * since - Angular major that introduced the change (informational)
12
+ * scope - 'ts' (TypeScript AST) | 'template' (HTML)
13
+ * match - matcher spec for the AST scanner (ts scope) — see ts-scanner.js
14
+ * regex - fallback line regex used when the AST parser isn't available,
15
+ * and the primary matcher for 'template' scope
16
+ * message - what/why
17
+ * fix - recommended migration
18
+ * docs - reference URL
19
+ */
20
+
21
+ const TS_RULES = [
22
+ {
23
+ id: 'entry-components',
24
+ title: 'entryComponents removed',
25
+ severity: 'removed', since: 13, scope: 'ts',
26
+ match: { type: 'property', name: 'entryComponents' },
27
+ regex: /\bentryComponents\s*:/,
28
+ message: 'entryComponents is obsolete under Ivy and was removed from @NgModule.',
29
+ fix: 'Delete entryComponents arrays; components can be created dynamically without them.',
30
+ docs: 'https://angular.dev/reference/migrations',
31
+ },
32
+ {
33
+ id: 'angular-http',
34
+ title: '@angular/http removed',
35
+ severity: 'removed', since: 8, scope: 'ts',
36
+ match: { type: 'import', module: '@angular/http' },
37
+ regex: /from\s+['"]@angular\/http['"]/,
38
+ message: 'The legacy @angular/http package was removed.',
39
+ fix: 'Use HttpClient from @angular/common/http.',
40
+ docs: 'https://angular.dev/guide/http',
41
+ },
42
+ {
43
+ id: 'rxjs-add-imports',
44
+ title: 'RxJS patch imports (rxjs/add) removed',
45
+ severity: 'removed', since: 15, scope: 'ts',
46
+ match: { type: 'import', module: /^rxjs\/add\// },
47
+ regex: /from\s+['"]rxjs\/add\//,
48
+ message: 'RxJS 5 “patch” imports (rxjs/add/operator/*) were removed in RxJS 6+.',
49
+ fix: 'Import operators from "rxjs" / "rxjs/operators" and use pipe().',
50
+ docs: 'https://rxjs.dev/deprecations/breaking-changes',
51
+ },
52
+ {
53
+ id: 'rxjs-deep-imports',
54
+ title: 'RxJS deep type imports removed',
55
+ severity: 'removed', since: 15, scope: 'ts',
56
+ match: { type: 'import', module: /^rxjs\/(Observable|Subject|BehaviorSubject|ReplaySubject|Subscription|Observer)$/ },
57
+ regex: /from\s+['"]rxjs\/(Observable|Subject|BehaviorSubject|ReplaySubject|Subscription|Observer)['"]/,
58
+ message: 'RxJS 5 deep imports (e.g. rxjs/Observable) were removed.',
59
+ fix: 'Import from the "rxjs" root barrel instead.',
60
+ docs: 'https://rxjs.dev/deprecations/breaking-changes',
61
+ },
62
+ {
63
+ id: 'rxjs-compat-import',
64
+ title: 'rxjs-compat still imported',
65
+ severity: 'removed', since: 15, scope: 'ts',
66
+ match: { type: 'import', module: /^rxjs-compat/ },
67
+ regex: /from\s+['"]rxjs-compat/,
68
+ message: 'rxjs-compat is a temporary migration shim and cannot remain.',
69
+ fix: 'Finish the RxJS 7 migration and remove rxjs-compat.',
70
+ docs: 'https://rxjs.dev/deprecations/breaking-changes',
71
+ },
72
+ {
73
+ id: 'material-barrel-import',
74
+ title: '@angular/material barrel import removed',
75
+ severity: 'removed', since: 9, scope: 'ts',
76
+ match: { type: 'import', module: '@angular/material' },
77
+ regex: /from\s+['"]@angular\/material['"]/,
78
+ message: 'Importing from the @angular/material barrel was removed; use secondary entry points.',
79
+ fix: 'Import from the specific entry point, e.g. @angular/material/button.',
80
+ docs: 'https://material.angular.io',
81
+ },
82
+ {
83
+ id: 'renderer-v1',
84
+ title: 'Renderer (v1) removed',
85
+ severity: 'removed', since: 9, scope: 'ts',
86
+ match: { type: 'import', module: '@angular/core', named: ['Renderer'] },
87
+ regex: /\bRenderer\b(?!2)/,
88
+ message: 'The original Renderer was removed.',
89
+ fix: 'Use Renderer2 from @angular/core.',
90
+ docs: 'https://angular.dev/api/core/Renderer2',
91
+ },
92
+ {
93
+ id: 'view-encapsulation-native',
94
+ title: 'ViewEncapsulation.Native removed',
95
+ severity: 'removed', since: 9, scope: 'ts',
96
+ match: { type: 'propertyAccess', object: 'ViewEncapsulation', name: 'Native' },
97
+ regex: /ViewEncapsulation\s*\.\s*Native\b/,
98
+ message: 'ViewEncapsulation.Native was removed.',
99
+ fix: 'Use ViewEncapsulation.ShadowDom.',
100
+ docs: 'https://angular.dev/api/core/ViewEncapsulation',
101
+ },
102
+ {
103
+ id: 'reflective-injector',
104
+ title: 'ReflectiveInjector removed',
105
+ severity: 'removed', since: 13, scope: 'ts',
106
+ match: { type: 'identifier', name: 'ReflectiveInjector' },
107
+ regex: /\bReflectiveInjector\b/,
108
+ message: 'ReflectiveInjector was removed.',
109
+ fix: 'Use Injector.create({ providers }).',
110
+ docs: 'https://angular.dev/api/core/Injector',
111
+ },
112
+ {
113
+ id: 'wrapped-value',
114
+ title: 'WrappedValue removed',
115
+ severity: 'removed', since: 13, scope: 'ts',
116
+ match: { type: 'identifier', name: 'WrappedValue' },
117
+ regex: /\bWrappedValue\b/,
118
+ message: 'WrappedValue was removed; pipes should return new references to signal change.',
119
+ fix: 'Return a new object/array reference instead of wrapping values.',
120
+ docs: 'https://angular.dev/reference/migrations',
121
+ },
122
+ {
123
+ id: 'router-preserve-query-params',
124
+ title: 'preserveQueryParams removed',
125
+ severity: 'removed', since: 7, scope: 'ts',
126
+ match: { type: 'property', name: 'preserveQueryParams' },
127
+ regex: /\bpreserveQueryParams\s*:/,
128
+ message: 'Router option preserveQueryParams was removed.',
129
+ fix: "Use queryParamsHandling: 'preserve'.",
130
+ docs: 'https://angular.dev/api/router/NavigationExtras',
131
+ },
132
+ {
133
+ id: 'loadchildren-string',
134
+ title: 'String loadChildren removed',
135
+ severity: 'removed', since: 9, scope: 'ts',
136
+ match: { type: 'property', name: 'loadChildren', valueContains: '#' },
137
+ regex: /loadChildren\s*:\s*['"][^'"]*#[^'"]*['"]/,
138
+ message: 'The "path#ModuleName" string form of loadChildren was removed.',
139
+ fix: 'Use a dynamic import: loadChildren: () => import("./x").then(m => m.XModule).',
140
+ docs: 'https://angular.dev/guide/routing/common-router-tasks',
141
+ },
142
+ {
143
+ id: 'testbed-get',
144
+ title: 'TestBed.get() deprecated',
145
+ severity: 'deprecated', since: 9, scope: 'ts',
146
+ match: { type: 'call', object: 'TestBed', callee: 'get' },
147
+ regex: /TestBed\s*\.\s*get\s*\(/,
148
+ message: 'TestBed.get() is deprecated and type-unsafe.',
149
+ fix: 'Use TestBed.inject().',
150
+ docs: 'https://angular.dev/api/core/testing/TestBed',
151
+ },
152
+ {
153
+ id: 'rxjs-to-promise',
154
+ title: '.toPromise() deprecated',
155
+ severity: 'deprecated', since: 15, scope: 'ts',
156
+ match: { type: 'call', callee: 'toPromise' },
157
+ regex: /\.\s*toPromise\s*\(/,
158
+ message: 'Observable.toPromise() is deprecated in RxJS 7 and removed in RxJS 8.',
159
+ fix: 'Use firstValueFrom() or lastValueFrom().',
160
+ docs: 'https://rxjs.dev/deprecations/to-promise',
161
+ },
162
+ {
163
+ id: 'test-async-helper',
164
+ title: 'async() test helper renamed',
165
+ severity: 'deprecated', since: 10, scope: 'ts',
166
+ match: { type: 'call', callee: 'async', firstArgFunction: true },
167
+ regex: /(?<![.\w])async\s*\(\s*(?:async\s*)?(?:\([^)]*\)|function)/,
168
+ message: 'The async() testing helper was renamed to waitForAsync().',
169
+ fix: 'Use waitForAsync() from @angular/core/testing.',
170
+ docs: 'https://angular.dev/api/core/testing/waitForAsync',
171
+ },
172
+ {
173
+ id: 'canload-guard',
174
+ title: 'CanLoad guard deprecated',
175
+ severity: 'deprecated', since: 15, scope: 'ts',
176
+ match: { type: 'identifier', name: 'CanLoad' },
177
+ regex: /\bCanLoad\b/,
178
+ message: 'The CanLoad route guard interface is deprecated.',
179
+ fix: 'Migrate to CanMatch (and prefer functional guards).',
180
+ docs: 'https://angular.dev/api/router/CanMatch',
181
+ },
182
+ ];
183
+
184
+ const TEMPLATE_RULES = [
185
+ {
186
+ id: 'cf-ngif',
187
+ title: '*ngIf → @if opportunity',
188
+ severity: 'opportunity', since: 17, scope: 'template',
189
+ regex: /\*ngIf\b/g,
190
+ message: 'Built-in control flow @if is the modern replacement for *ngIf.',
191
+ fix: 'Run `ng generate @angular/core:control-flow` after upgrading to v17+.',
192
+ docs: 'https://angular.dev/guide/templates/control-flow',
193
+ },
194
+ {
195
+ id: 'cf-ngfor',
196
+ title: '*ngFor → @for opportunity',
197
+ severity: 'opportunity', since: 17, scope: 'template',
198
+ regex: /\*ngFor\b/g,
199
+ message: 'Built-in control flow @for replaces *ngFor and improves performance.',
200
+ fix: 'Run the control-flow migration schematic after upgrading to v17+.',
201
+ docs: 'https://angular.dev/guide/templates/control-flow',
202
+ },
203
+ {
204
+ id: 'cf-ngswitch',
205
+ title: '*ngSwitch → @switch opportunity',
206
+ severity: 'opportunity', since: 17, scope: 'template',
207
+ regex: /\[ngSwitch\]|\*ngSwitchCase|\*ngSwitchDefault/g,
208
+ message: 'Built-in control flow @switch replaces the ngSwitch directives.',
209
+ fix: 'Run the control-flow migration schematic after upgrading to v17+.',
210
+ docs: 'https://angular.dev/guide/templates/control-flow',
211
+ },
212
+ {
213
+ id: 'flex-layout-directives',
214
+ title: 'flex-layout directives (fx*) in templates',
215
+ severity: 'removed', since: 16, scope: 'template',
216
+ regex: /\sfx(Layout|Flex|LayoutAlign|LayoutGap|Show|Hide|Fill)\b/g,
217
+ message: '@angular/flex-layout is discontinued; its fx* directives have no supported replacement.',
218
+ fix: 'Migrate to CSS Flexbox/Grid or Angular CDK Layout.',
219
+ docs: 'https://github.com/angular/flex-layout',
220
+ },
221
+ ];
222
+
223
+ module.exports = { TS_RULES, TEMPLATE_RULES };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Curated knowledge base of packages that need special handling during an
5
+ * Angular upgrade — things a registry lookup alone can't tell you:
6
+ * officially removed tooling, discontinued libraries, and recommended
7
+ * replacements. This is data, not logic, so it can grow with community PRs.
8
+ *
9
+ * status:
10
+ * 'removed' - removed from Angular / no longer part of the toolchain
11
+ * 'discontinued' - officially discontinued by its maintainers
12
+ * 'abandoned' - Angular-2/legacy era, effectively unmaintained
13
+ * 'transitional' - a shim that should be removed after migration
14
+ * 'maintenance' - still works but recommended to move off
15
+ */
16
+
17
+ const KNOWN = {
18
+ tslint: {
19
+ status: 'removed',
20
+ reason: 'TSLint is deprecated and unsupported. Angular linting moved to ESLint.',
21
+ replacement: 'angular-eslint (@angular-eslint/schematics)',
22
+ },
23
+ codelyzer: {
24
+ status: 'removed',
25
+ reason: 'Codelyzer targets TSLint, which is deprecated.',
26
+ replacement: 'angular-eslint',
27
+ },
28
+ 'rxjs-tslint': {
29
+ status: 'removed',
30
+ reason: 'TSLint-based RxJS migration tool; obsolete after moving to RxJS 7 + ESLint.',
31
+ replacement: 'rxjs 7 + eslint-plugin-rxjs (or drop entirely)',
32
+ },
33
+ protractor: {
34
+ status: 'removed',
35
+ reason: 'Protractor is deprecated and was dropped from the Angular CLI.',
36
+ replacement: 'Playwright or Cypress',
37
+ },
38
+ '@angular/flex-layout': {
39
+ status: 'discontinued',
40
+ reason: 'Officially discontinued by the Angular team; no releases past v15.',
41
+ replacement: 'Angular CDK Layout + native CSS Flexbox/Grid',
42
+ },
43
+ 'rxjs-compat': {
44
+ status: 'transitional',
45
+ reason: 'Compatibility shim for the RxJS 5 -> 6 migration. Should not survive an upgrade.',
46
+ replacement: 'Complete RxJS 7 migration and remove',
47
+ },
48
+ 'ngrx-store-freeze': {
49
+ status: 'abandoned',
50
+ reason: 'Deprecated; NgRx has built-in runtime checks for state immutability.',
51
+ replacement: 'NgRx runtimeChecks (strictStateImmutability)',
52
+ },
53
+ moment: {
54
+ status: 'maintenance',
55
+ reason: 'Moment.js is in maintenance mode and is large; the team recommends alternatives.',
56
+ replacement: 'date-fns, Luxon, Day.js, or native Temporal',
57
+ },
58
+ 'moment-timezone': {
59
+ status: 'maintenance',
60
+ reason: 'Tied to Moment.js which is in maintenance mode.',
61
+ replacement: 'Luxon or @js-temporal/polyfill',
62
+ },
63
+ jquery: {
64
+ status: 'maintenance',
65
+ reason: 'Direct DOM libraries fight Angular change detection; usually avoidable.',
66
+ replacement: 'Angular APIs / Renderer2 / signals',
67
+ },
68
+ 'angular2-modal': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'Angular CDK Overlay or @angular/material dialog' },
69
+ 'angular2-multiselect-dropdown': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'Angular Material / CDK-based multiselect' },
70
+ 'ng2-select': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'ng-select or Angular Material' },
71
+ 'ng2-dnd': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: '@angular/cdk/drag-drop' },
72
+ 'ng2-cache': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'Custom service or a maintained cache lib' },
73
+ 'ng2-truncate': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'CSS line-clamp or a custom pipe' },
74
+ 'ng2-search-filter': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: 'Custom pipe / RxJS filtering' },
75
+ 'ng2-material-dropdown': { status: 'abandoned', reason: 'Angular 2-era, unmaintained.', replacement: '@angular/material menu/select' },
76
+ 'ngx-modialog': { status: 'abandoned', reason: 'Unmaintained modal library.', replacement: '@angular/cdk/overlay or @angular/material dialog' },
77
+ mydaterangepicker: { status: 'abandoned', reason: 'Unmaintained date picker.', replacement: '@angular/material datepicker or ngx-daterangepicker-material' },
78
+ 'angular-wizard-form': { status: 'abandoned', reason: 'Unmaintained; low activity.', replacement: '@angular/cdk/stepper or @angular/material stepper' },
79
+ };
80
+
81
+ /** Package name prefixes that strongly imply an Angular-coupled library. */
82
+ const ANGULAR_LIB_PREFIXES = ['@angular/', '@ngrx/', '@ngtools/', 'ng2-', 'ngx-', 'angular2-', 'angularx-', '@angular-eslint/', '@nguniversal/', '@ng-'];
83
+
84
+ function lookup(name) {
85
+ return KNOWN[name] || null;
86
+ }
87
+
88
+ function looksAngularCoupled(name) {
89
+ return ANGULAR_LIB_PREFIXES.some((p) => name.startsWith(p)) || /(^|[-/])ng(x|2)?[-/]/.test(name);
90
+ }
91
+
92
+ module.exports = { KNOWN, lookup, looksAngularCoupled, ANGULAR_LIB_PREFIXES };
package/src/index.js ADDED
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { analyzeSnapshot } = require('./analyzers/project-snapshot');
6
+ const { analyzeCensus } = require('./analyzers/code-census');
7
+ const { analyzeVersionGap } = require('./analyzers/version-gap');
8
+ const { analyzeDependencies } = require('./analyzers/dependency-compat');
9
+ const { analyzeBreakingChanges } = require('./analyzers/breaking-changes');
10
+ const { computeVerdict } = require('./analyzers/verdict');
11
+ const { renderTerminal } = require('./report/terminal');
12
+ const { renderHtml } = require('./report/html');
13
+ const { renderJson } = require('./report/json');
14
+ const { LATEST_STABLE } = require('./data/angular-versions');
15
+ const { c, write } = require('./utils/log');
16
+
17
+ const TOOL_VERSION = require('../package.json').version;
18
+
19
+ async function run(options) {
20
+ const projectDir = path.resolve(options.projectDir || process.cwd());
21
+
22
+ if (!fs.existsSync(path.join(projectDir, 'package.json'))) {
23
+ write(c.red(`✗ No package.json found in ${projectDir}`));
24
+ process.exitCode = 2;
25
+ return;
26
+ }
27
+
28
+ write(c.dim(' Reading project…'));
29
+ const snapshot = analyzeSnapshot(projectDir);
30
+
31
+ if (!snapshot.angularMajor) {
32
+ write(c.yellow(' ⚠ Could not detect @angular/core — is this an Angular project?'));
33
+ }
34
+
35
+ const targetMajor = options.target || LATEST_STABLE;
36
+ const versionGap = analyzeVersionGap(snapshot.angularMajor, targetMajor);
37
+
38
+ let census = null;
39
+ let breakingChanges = null;
40
+ if (!options.noCode) {
41
+ write(c.dim(' Scanning source tree…'));
42
+ census = analyzeCensus(projectDir);
43
+ breakingChanges = analyzeBreakingChanges(projectDir);
44
+ if (!breakingChanges.astAvailable) {
45
+ write(c.dim(' (TypeScript compiler not found in project — used regex fallback for code scan)'));
46
+ }
47
+ }
48
+
49
+ write(c.dim(` Analyzing ${snapshot.totalDependencyCount} dependencies${options.offline ? ' (offline)' : ' against the npm registry'}…`));
50
+ const deps = await analyzeDependencies(snapshot, targetMajor, {
51
+ offline: options.offline,
52
+ concurrency: options.concurrency || 8,
53
+ onProgress: options.quiet ? null : throttleProgress(snapshot.totalDependencyCount),
54
+ });
55
+ deps.offline = Boolean(options.offline);
56
+
57
+ const verdict = computeVerdict({ snapshot, versionGap, deps, census, breakingChanges });
58
+
59
+ const report = {
60
+ snapshot,
61
+ versionGap,
62
+ deps,
63
+ census,
64
+ breakingChanges,
65
+ verdict,
66
+ meta: { toolVersion: TOOL_VERSION, generatedAt: options.now || '', projectDir },
67
+ };
68
+
69
+ // Terminal output
70
+ renderTerminal(report);
71
+
72
+ // Write artifacts
73
+ const outDir = path.resolve(options.out || projectDir);
74
+ const htmlPath = path.join(outDir, 'ng-upgrade-report.html');
75
+ const jsonPath = path.join(outDir, 'ng-upgrade-report.json');
76
+ try {
77
+ fs.mkdirSync(outDir, { recursive: true });
78
+ fs.writeFileSync(htmlPath, renderHtml(report), 'utf8');
79
+ fs.writeFileSync(jsonPath, renderJson(report), 'utf8');
80
+ write(' ' + c.green('✓') + ' HTML report: ' + c.cyan(htmlPath));
81
+ write(' ' + c.green('✓') + ' JSON report: ' + c.cyan(jsonPath));
82
+ write('');
83
+ } catch (e) {
84
+ write(c.red(' ✗ Could not write reports: ' + e.message));
85
+ }
86
+
87
+ // CI gate
88
+ if (options.ci) {
89
+ const fail = deps.summary.blocker + deps.summary.removed > 0 || verdict.score < (options.minScore || 0);
90
+ process.exitCode = fail ? 1 : 0;
91
+ }
92
+
93
+ return report;
94
+ }
95
+
96
+ function throttleProgress(total) {
97
+ let last = 0;
98
+ return (done) => {
99
+ const pct = Math.floor((done / total) * 100);
100
+ if (pct >= last + 10 || done === total) {
101
+ last = pct;
102
+ if (process.stdout.isTTY) process.stdout.write(`\r …resolved ${done}/${total} packages `);
103
+ if (done === total && process.stdout.isTTY) process.stdout.write('\r' + ' '.repeat(40) + '\r');
104
+ }
105
+ };
106
+ }
107
+
108
+ module.exports = { run, TOOL_VERSION };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Zero-dependency npm registry client built on Node's global fetch (Node 18+).
5
+ * Fetches lightweight "packuments" and extracts only the fields we need.
6
+ * Everything degrades gracefully: on any network failure we return null and
7
+ * the analysis falls back to the local knowledge base.
8
+ */
9
+
10
+ const https = require('https');
11
+ const { URL } = require('url');
12
+
13
+ const REGISTRY = process.env.NPM_REGISTRY || 'https://registry.npmjs.org';
14
+ const ACCEPT = 'application/vnd.npm.install-v1+json, application/json';
15
+
16
+ function encodeName(name) {
17
+ // Scoped package @scope/name -> @scope%2Fname
18
+ return name.startsWith('@') ? '@' + encodeURIComponent(name.slice(1)) : encodeURIComponent(name);
19
+ }
20
+
21
+ /** True when Node's global fetch is available (Node 18+). */
22
+ const hasGlobalFetch = typeof fetch === 'function';
23
+
24
+ async function fetchPackument(name, timeoutMs = 8000) {
25
+ const url = `${REGISTRY}/${encodeName(name)}`;
26
+ // The "abbreviated" packument is much smaller and still carries peerDeps.
27
+ const json = hasGlobalFetch
28
+ ? await fetchViaGlobal(url, timeoutMs)
29
+ : await fetchViaHttps(url, timeoutMs);
30
+ return json;
31
+ }
32
+
33
+ async function fetchViaGlobal(url, timeoutMs) {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ const res = await fetch(url, { headers: { Accept: ACCEPT }, signal: controller.signal });
38
+ if (!res.ok) return null;
39
+ return await res.json();
40
+ } catch (_) {
41
+ return null;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ /** Fallback for Node <18 (no global fetch). Requests identity encoding so we
48
+ * don't have to decompress gzip ourselves. */
49
+ function fetchViaHttps(url, timeoutMs) {
50
+ return new Promise((resolve) => {
51
+ let settled = false;
52
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
53
+ try {
54
+ const u = new URL(url);
55
+ const req = https.get(
56
+ {
57
+ hostname: u.hostname,
58
+ path: u.pathname + u.search,
59
+ headers: { Accept: ACCEPT, 'Accept-Encoding': 'identity', 'User-Agent': 'ng-upgrade-doctor' },
60
+ },
61
+ (res) => {
62
+ if (res.statusCode !== 200) { res.resume(); return done(null); }
63
+ let data = '';
64
+ res.setEncoding('utf8');
65
+ res.on('data', (chunk) => { data += chunk; });
66
+ res.on('end', () => { try { done(JSON.parse(data)); } catch (_) { done(null); } });
67
+ }
68
+ );
69
+ req.on('error', () => done(null));
70
+ req.setTimeout(timeoutMs, () => { req.destroy(); done(null); });
71
+ } catch (_) {
72
+ done(null);
73
+ }
74
+ });
75
+ }
76
+
77
+ /** Run async `worker` over `items` with a bounded concurrency pool. */
78
+ async function mapPool(items, concurrency, worker) {
79
+ const results = new Array(items.length);
80
+ let cursor = 0;
81
+ const runners = new Array(Math.min(concurrency, items.length)).fill(0).map(async () => {
82
+ while (cursor < items.length) {
83
+ const idx = cursor++;
84
+ try {
85
+ results[idx] = await worker(items[idx], idx);
86
+ } catch (_) {
87
+ results[idx] = null;
88
+ }
89
+ }
90
+ });
91
+ await Promise.all(runners);
92
+ return results;
93
+ }
94
+
95
+ module.exports = { fetchPackument, mapPool, REGISTRY };