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,109 @@
1
+ 'use strict';
2
+
3
+ const { c, write } = require('../utils/log');
4
+
5
+ const CAT_LABEL = {
6
+ ok: '🟒 OK', upgrade: '🟑 Upgrade', partial: '🟑 Partial', blocker: 'πŸ”΄ Blocker',
7
+ removed: 'πŸ”΅ Removed', abandoned: '⚫ Abandoned', agnostic: '🟒 Agnostic', unknown: 'βšͺ Unknown',
8
+ };
9
+
10
+ function renderTerminal(report) {
11
+ const { snapshot, versionGap, deps, verdict, census, breakingChanges } = report;
12
+
13
+ write('');
14
+ write(c.bold(c.cyan(' ng-upgrade-doctor')) + c.dim(' Β· Angular upgrade readiness report'));
15
+ write(c.dim(' ' + '─'.repeat(56)));
16
+ write('');
17
+
18
+ // Headline
19
+ const scoreColor = verdict.score >= 80 ? c.green : verdict.score >= 60 ? c.yellow : c.red;
20
+ write(' ' + c.bold('Project: ') + snapshot.name);
21
+ write(' ' + c.bold('Angular: ') + `${snapshot.angularVersion || '?'} ` +
22
+ c.dim(`(${versionGap.known ? versionGap.majorsBehind + ' major(s) behind ' + versionGap.latestStable : 'version unknown'})`));
23
+ write(' ' + c.bold('Readiness: ') + scoreColor(c.bold(`${verdict.score}/100`)) + ' ' + c.dim(verdict.bandLabel));
24
+ write(' ' + c.bold('Effort est.: ') + `${verdict.effort.lowDays}–${verdict.effort.highDays} person-days` +
25
+ c.dim(` (${versionGap.hopCount || 0} hop${versionGap.hopCount === 1 ? '' : 's'})`));
26
+ write('');
27
+
28
+ // Inventory
29
+ write(c.bold(' Inventory'));
30
+ line('Dependencies', `${snapshot.totalDependencyCount} (${snapshot.prodDependencyCount} prod / ${snapshot.devDependencyCount} dev)`);
31
+ if (snapshot.transitiveDependencyCount != null) line('Transitive (lockfile)', String(snapshot.transitiveDependencyCount));
32
+ line('Angular packages', String(snapshot.angularPackageCount));
33
+ line('3rd-party Angular libs', String(snapshot.thirdPartyAngularCount));
34
+ line('Package manager', `${snapshot.packageManager}${snapshot.lockfile ? ' (' + snapshot.lockfile + ')' : ''}`);
35
+ line('Workspace', `${snapshot.workspaceType}${snapshot.builder ? ' Β· ' + shortBuilder(snapshot.builder) : ''}`);
36
+ line('TypeScript', snapshot.tsVersion || 'unknown');
37
+ line('Test / e2e', `${snapshot.testRunner} / ${snapshot.e2e}`);
38
+ if (census) {
39
+ line('Components', `${census.counts.components} ` + c.dim(`(${census.counts.standaloneComponents} standalone${census.standaloneRatio != null ? ', ' + census.standaloneRatio + '%' : ''})`));
40
+ line('NgModules / Services', `${census.counts.modules} / ${census.counts.services}`);
41
+ line('Source files', `${census.fileCounts.tsFiles} ts Β· ${census.fileCounts.htmlTemplates} html Β· ${census.fileCounts.specFiles} specs`);
42
+ }
43
+ write('');
44
+
45
+ // Dependency compatibility
46
+ const s = deps.summary;
47
+ write(c.bold(` Dependency compatibility `) + c.dim(`(target: Angular ${deps.targetMajor})`));
48
+ line('Compatible', c.green(`${s.ok + s.agnostic}`), true);
49
+ line('Needs upgrade / partial', c.yellow(`${s.upgrade + s.partial}`), true);
50
+ line('Blockers (no compatible release)', c.red(`${s.blocker}`), true);
51
+ line('Removed / discontinued', c.blue(`${s.removed}`), true);
52
+ line('Abandoned / unmaintained', c.gray(`${s.abandoned}`), true);
53
+ if (s.unknown) line('Unknown (offline/not found)', c.dim(`${s.unknown}`), true);
54
+ write('');
55
+
56
+ // Code breaking changes (AST scan)
57
+ if (breakingChanges && breakingChanges.summary.totalHits > 0) {
58
+ const b = breakingChanges.summary;
59
+ write(c.bold(' Code breaking changes ') + c.dim(`(${breakingChanges.astAvailable ? 'AST' : 'regex fallback'} Β· ${b.filesAffected} files)`));
60
+ line('Removed APIs in use', c.red(`${b.removed}`), true);
61
+ line('Deprecated APIs in use', c.yellow(`${b.deprecated}`), true);
62
+ line('Modernization opportunities', c.blue(`${b.opportunity}`), true);
63
+ breakingChanges.rules.slice(0, 6).forEach((r) => {
64
+ const tag = r.severity === 'removed' ? c.red('removed') : r.severity === 'deprecated' ? c.yellow('deprec.') : c.blue('opportun');
65
+ write(' ' + tag + ' ' + c.bold(r.title) + c.dim(` Γ—${r.count} in ${r.fileCount} file(s)`));
66
+ });
67
+ write('');
68
+ }
69
+
70
+ // Top blockers
71
+ if (verdict.blockers.length) {
72
+ write(c.bold(' Must-fix before upgrading'));
73
+ verdict.blockers.slice(0, 12).forEach((b) => {
74
+ write(' ' + c.red('βœ— ') + c.bold(b.name) + c.dim(` β€” ${b.reason}`));
75
+ if (b.replacement) write(' ' + c.dim('β†’ ' + b.replacement));
76
+ });
77
+ if (verdict.blockers.length > 12) write(c.dim(` …and ${verdict.blockers.length - 12} more (see full report)`));
78
+ write('');
79
+ }
80
+
81
+ // Path
82
+ if (versionGap.known && versionGap.hops.length) {
83
+ write(c.bold(' Recommended upgrade path'));
84
+ write(' ' + versionGap.hops.map((h) => c.cyan('v' + h.major)).join(c.dim(' β†’ ')));
85
+ write('');
86
+ }
87
+
88
+ // Quick wins
89
+ if (verdict.quickWins.length) {
90
+ write(c.bold(' Quick wins'));
91
+ verdict.quickWins.forEach((w) => write(' ' + c.green('β€’') + ' ' + w));
92
+ write('');
93
+ }
94
+
95
+ write(c.dim(' ' + '─'.repeat(56)));
96
+ write(' ' + c.dim('Full HTML & JSON reports written alongside this run. Re-run with --help for options.'));
97
+ write('');
98
+ }
99
+
100
+ function line(label, value, indent) {
101
+ const pad = indent ? ' ' : ' ';
102
+ process.stdout.write(pad + c.dim((label + ':').padEnd(28)) + ' ' + value + '\n');
103
+ }
104
+
105
+ function shortBuilder(b) {
106
+ return b.replace('@angular-devkit/build-angular:', 'devkit:').replace('@angular/build:', 'ng-build:');
107
+ }
108
+
109
+ module.exports = { renderTerminal, CAT_LABEL };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function readJson(filePath) {
7
+ try {
8
+ const raw = fs.readFileSync(filePath, 'utf8');
9
+ return JSON.parse(raw);
10
+ } catch (_) {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function exists(filePath) {
16
+ try {
17
+ fs.accessSync(filePath);
18
+ return true;
19
+ } catch (_) {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ const IGNORE_DIRS = new Set([
25
+ 'node_modules', 'dist', '.git', '.angular', 'coverage', '.nx', 'tmp',
26
+ 'out-tsc', '.cache', 'e2e-results', 'playwright-report', 'test-results',
27
+ ]);
28
+
29
+ /**
30
+ * Recursively collect files matching one of `extensions` under `root`.
31
+ * Bounded by `maxFiles` so it stays fast on huge repos.
32
+ */
33
+ function walkFiles(root, extensions, maxFiles = 20000) {
34
+ const results = [];
35
+ const stack = [root];
36
+ while (stack.length && results.length < maxFiles) {
37
+ const dir = stack.pop();
38
+ let entries;
39
+ try {
40
+ entries = fs.readdirSync(dir, { withFileTypes: true });
41
+ } catch (_) {
42
+ continue;
43
+ }
44
+ for (const entry of entries) {
45
+ if (results.length >= maxFiles) break;
46
+ const full = path.join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
49
+ stack.push(full);
50
+ }
51
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
52
+ results.push(full);
53
+ }
54
+ }
55
+ }
56
+ return results;
57
+ }
58
+
59
+ function safeReadFile(filePath) {
60
+ try {
61
+ return fs.readFileSync(filePath, 'utf8');
62
+ } catch (_) {
63
+ return '';
64
+ }
65
+ }
66
+
67
+ module.exports = { readJson, exists, walkFiles, safeReadFile };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tiny zero-dependency ANSI logging helper. Colors auto-disable when the
5
+ * output is not a TTY or when NO_COLOR is set.
6
+ */
7
+
8
+ const enabled = process.stdout.isTTY && !process.env.NO_COLOR;
9
+
10
+ const codes = {
11
+ reset: 0,
12
+ bold: 1,
13
+ dim: 2,
14
+ red: 31,
15
+ green: 32,
16
+ yellow: 33,
17
+ blue: 34,
18
+ magenta: 35,
19
+ cyan: 36,
20
+ gray: 90,
21
+ };
22
+
23
+ function wrap(name) {
24
+ return (str) => (enabled ? `[${codes[name]}m${str}` : String(str));
25
+ }
26
+
27
+ const c = {};
28
+ Object.keys(codes).forEach((k) => (c[k] = wrap(k)));
29
+
30
+ function write(str = '') {
31
+ process.stdout.write(str + '\n');
32
+ }
33
+
34
+ module.exports = { c, write, enabled };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal, zero-dependency semver subset.
5
+ *
6
+ * Supports the range syntax that actually shows up in npm peerDependencies:
7
+ * exact (1.2.3), x-ranges (1.x, 1.2.x, *), caret (^1.2.3), tilde (~1.2.3),
8
+ * comparators (>=, >, <=, <, =), hyphen ranges (1.2.3 - 2.3.4),
9
+ * AND (space separated) and OR (||) combinations.
10
+ *
11
+ * Prerelease handling is intentionally simplified: a prerelease tag makes a
12
+ * version sort just below its release, which is good enough for classifying
13
+ * Angular peer-dependency support.
14
+ */
15
+
16
+ function parse(version) {
17
+ if (version == null) return null;
18
+ const clean = String(version).trim().replace(/^v/, '');
19
+ const m = clean.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?/);
20
+ if (!m) return null;
21
+ return {
22
+ major: Number(m[1]),
23
+ minor: Number(m[2]),
24
+ patch: Number(m[3]),
25
+ prerelease: m[4] || '',
26
+ raw: clean,
27
+ };
28
+ }
29
+
30
+ /** Pull the first x.y.z out of an arbitrary string (e.g. "~4.9.5" -> 4.9.5). */
31
+ function coerce(str) {
32
+ if (str == null) return null;
33
+ const m = String(str).match(/(\d+)\.(\d+)\.(\d+)/) || String(str).match(/(\d+)\.(\d+)/) || String(str).match(/(\d+)/);
34
+ if (!m) return null;
35
+ const major = Number(m[1] || 0);
36
+ const minor = Number(m[2] || 0);
37
+ const patch = Number(m[3] || 0);
38
+ return parse(`${major}.${minor}.${patch}`);
39
+ }
40
+
41
+ function major(version) {
42
+ const p = parse(version) || coerce(version);
43
+ return p ? p.major : null;
44
+ }
45
+
46
+ function compare(a, b) {
47
+ const pa = parse(a);
48
+ const pb = parse(b);
49
+ if (!pa || !pb) return 0;
50
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
51
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
52
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
53
+ // A prerelease is lower than its corresponding release.
54
+ if (pa.prerelease && !pb.prerelease) return -1;
55
+ if (!pa.prerelease && pb.prerelease) return 1;
56
+ if (pa.prerelease === pb.prerelease) return 0;
57
+ return pa.prerelease < pb.prerelease ? -1 : 1;
58
+ }
59
+
60
+ const gt = (a, b) => compare(a, b) > 0;
61
+ const gte = (a, b) => compare(a, b) >= 0;
62
+ const lt = (a, b) => compare(a, b) < 0;
63
+ const lte = (a, b) => compare(a, b) <= 0;
64
+ const eq = (a, b) => compare(a, b) === 0;
65
+
66
+ /** Compare two version strings, treating null/invalid as lowest. Useful for max-by. */
67
+ function maxVersion(list) {
68
+ return list.filter(Boolean).reduce((best, v) => {
69
+ if (!best) return v;
70
+ return gt(v, best) ? v : best;
71
+ }, null);
72
+ }
73
+
74
+ // ---- Range parsing -------------------------------------------------------
75
+
76
+ function xToZero(part) {
77
+ return part === undefined || part === '' || part === 'x' || part === 'X' || part === '*'
78
+ ? 0
79
+ : Number(part);
80
+ }
81
+
82
+ /** Expand a single simple range token into a list of {op, version} comparators. */
83
+ function expandToken(token) {
84
+ token = token.trim();
85
+ if (token === '' || token === '*' || token === 'x' || token === 'X' || token === 'latest') {
86
+ return [{ op: '>=', version: '0.0.0' }];
87
+ }
88
+
89
+ // Caret: ^1.2.3
90
+ if (token.startsWith('^')) {
91
+ const [maj, min, pat] = token.slice(1).split('.');
92
+ const M = xToZero(maj), m = xToZero(min), p = xToZero(pat);
93
+ let upper;
94
+ if (M > 0 || maj === undefined) upper = `${M + 1}.0.0`;
95
+ else if (m > 0 || min === undefined || min === 'x') upper = `0.${m + 1}.0`;
96
+ else upper = `0.0.${p + 1}`;
97
+ return [{ op: '>=', version: `${M}.${m}.${p}` }, { op: '<', version: upper }];
98
+ }
99
+
100
+ // Tilde: ~1.2.3
101
+ if (token.startsWith('~')) {
102
+ const [maj, min, pat] = token.slice(1).split('.');
103
+ const M = xToZero(maj), m = xToZero(min), p = xToZero(pat);
104
+ const hasMinor = min !== undefined && min !== '' && min !== 'x' && min !== 'X';
105
+ const upper = hasMinor ? `${M}.${m + 1}.0` : `${M + 1}.0.0`;
106
+ return [{ op: '>=', version: `${M}.${m}.${p}` }, { op: '<', version: upper }];
107
+ }
108
+
109
+ // Comparators: >=, >, <=, <, =
110
+ const cmp = token.match(/^(>=|<=|>|<|=)\s*(.+)$/);
111
+ if (cmp) {
112
+ const c = coerce(cmp[2]);
113
+ return [{ op: cmp[1], version: c ? c.raw : '0.0.0' }];
114
+ }
115
+
116
+ // X-range or exact: 1, 1.2, 1.2.3, 1.x, 1.2.x
117
+ const parts = token.split('.');
118
+ const majX = parts[0];
119
+ const minX = parts[1];
120
+ const patX = parts[2];
121
+ const majWild = majX === undefined || majX === 'x' || majX === 'X' || majX === '*';
122
+ const minWild = minX === undefined || minX === 'x' || minX === 'X' || minX === '*';
123
+ const patWild = patX === undefined || patX === 'x' || patX === 'X' || patX === '*';
124
+
125
+ if (majWild) return [{ op: '>=', version: '0.0.0' }];
126
+ const M = Number(majX);
127
+ if (minWild) {
128
+ return [{ op: '>=', version: `${M}.0.0` }, { op: '<', version: `${M + 1}.0.0` }];
129
+ }
130
+ const m = Number(minX);
131
+ if (patWild) {
132
+ return [{ op: '>=', version: `${M}.${m}.0` }, { op: '<', version: `${M}.${m + 1}.0` }];
133
+ }
134
+ return [{ op: '=', version: `${M}.${m}.${Number(patX)}` }];
135
+ }
136
+
137
+ function testComparator(version, { op, version: target }) {
138
+ switch (op) {
139
+ case '>=': return gte(version, target);
140
+ case '>': return gt(version, target);
141
+ case '<=': return lte(version, target);
142
+ case '<': return lt(version, target);
143
+ case '=': return eq(version, target);
144
+ default: return false;
145
+ }
146
+ }
147
+
148
+ /** Split an AND-group into comparators, handling hyphen ranges. */
149
+ function groupComparators(group) {
150
+ const tokens = group.trim().split(/\s+/).filter(Boolean);
151
+ // Hyphen range: a - b
152
+ const hyphenIdx = tokens.indexOf('-');
153
+ if (hyphenIdx > 0 && tokens[hyphenIdx + 1]) {
154
+ const lower = coerce(tokens[hyphenIdx - 1]);
155
+ const upperToken = tokens[hyphenIdx + 1];
156
+ const up = upperToken.split('.');
157
+ let upperCmp;
158
+ if (up[1] === undefined) upperCmp = { op: '<', version: `${Number(up[0]) + 1}.0.0` };
159
+ else if (up[2] === undefined) upperCmp = { op: '<', version: `${Number(up[0])}.${Number(up[1]) + 1}.0` };
160
+ else upperCmp = { op: '<=', version: coerce(upperToken).raw };
161
+ return [{ op: '>=', version: lower ? lower.raw : '0.0.0' }, upperCmp];
162
+ }
163
+ return tokens.flatMap(expandToken);
164
+ }
165
+
166
+ /** Does `version` satisfy `range`? */
167
+ function satisfies(version, range) {
168
+ const v = parse(version);
169
+ if (!v) return false;
170
+ if (range == null || range === '' || range === '*' || range === 'latest') return true;
171
+ const orGroups = String(range).split('||');
172
+ return orGroups.some((group) => {
173
+ const comparators = groupComparators(group);
174
+ return comparators.every((c) => testComparator(v.raw, c));
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Given a peerDependency range on @angular/core (e.g. "^15.0.0 || ^16.0.0"),
180
+ * return the highest Angular major that it accepts, scanning a sane window.
181
+ */
182
+ function highestSupportedMajor(range, minMajor = 2, maxMajor = 40) {
183
+ if (!range) return null;
184
+ let best = null;
185
+ for (let M = minMajor; M <= maxMajor; M++) {
186
+ // Probe both the low and high end of the major so ranges like "^15.2.0"
187
+ // (which excludes 15.0.0) and "<15.2.0" are both handled.
188
+ if (satisfies(`${M}.0.0`, range) || satisfies(`${M}.99.99`, range)) {
189
+ best = M;
190
+ }
191
+ }
192
+ return best;
193
+ }
194
+
195
+ module.exports = {
196
+ parse,
197
+ coerce,
198
+ major,
199
+ compare,
200
+ gt,
201
+ gte,
202
+ lt,
203
+ lte,
204
+ eq,
205
+ maxVersion,
206
+ satisfies,
207
+ highestSupportedMajor,
208
+ };