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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/ng-upgrade-doctor.js +5 -0
- package/package.json +44 -0
- package/src/analyzers/breaking-changes.js +118 -0
- package/src/analyzers/code-census.js +101 -0
- package/src/analyzers/dependency-compat.js +174 -0
- package/src/analyzers/project-snapshot.js +124 -0
- package/src/analyzers/verdict.js +118 -0
- package/src/analyzers/version-gap.js +38 -0
- package/src/ast/loader.js +38 -0
- package/src/ast/regex-scanner.js +47 -0
- package/src/ast/ts-scanner.js +154 -0
- package/src/cli.js +73 -0
- package/src/data/angular-versions.js +83 -0
- package/src/data/breaking-changes.js +223 -0
- package/src/data/known-packages.js +92 -0
- package/src/index.js +108 -0
- package/src/registry.js +95 -0
- package/src/report/html.js +539 -0
- package/src/report/json.js +60 -0
- package/src/report/terminal.js +109 -0
- package/src/utils/fs-helpers.js +67 -0
- package/src/utils/log.js +34 -0
- package/src/utils/semver.js +208 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blends the analyses into a single 0–100 Upgrade Readiness Score, an effort
|
|
5
|
+
* band, and a prioritized, deduped action list. The score is intentionally
|
|
6
|
+
* transparent: every deduction is itemized so the number is auditable.
|
|
7
|
+
*/
|
|
8
|
+
function computeVerdict({ snapshot, versionGap, deps, census, breakingChanges }) {
|
|
9
|
+
const deductions = [];
|
|
10
|
+
let score = 100;
|
|
11
|
+
|
|
12
|
+
const add = (points, label) => {
|
|
13
|
+
if (points <= 0) return;
|
|
14
|
+
score -= points;
|
|
15
|
+
deductions.push({ points, label });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Version distance
|
|
19
|
+
if (versionGap.known && versionGap.majorsBehind > 0) {
|
|
20
|
+
add(Math.min(40, versionGap.majorsBehind * 8), `${versionGap.majorsBehind} major version(s) behind latest`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// EOL / unsupported current version
|
|
24
|
+
if (versionGap.known && versionGap.eol && versionGap.eol < '2026-07') {
|
|
25
|
+
add(12, `Angular ${versionGap.currentMajor} is past its support window (~${versionGap.eol})`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Dependency risk
|
|
29
|
+
const s = deps.summary;
|
|
30
|
+
add(Math.min(30, s.blocker * 5), `${s.blocker} dependency blocker(s) with no compatible release`);
|
|
31
|
+
add(Math.min(20, s.removed * 4), `${s.removed} package(s) removed/discontinued from Angular`);
|
|
32
|
+
add(Math.min(12, s.abandoned * 2), `${s.abandoned} abandoned/unmaintained package(s)`);
|
|
33
|
+
add(Math.min(8, s.partial * 1), `${s.partial} package(s) only partially compatible`);
|
|
34
|
+
add(Math.min(6, s.deprecated * 1), `${s.deprecated} package(s) marked deprecated on npm`);
|
|
35
|
+
|
|
36
|
+
// Code signals — prefer the richer AST breaking-change scan when present.
|
|
37
|
+
if (breakingChanges && breakingChanges.summary) {
|
|
38
|
+
const bc = breakingChanges.summary;
|
|
39
|
+
add(Math.min(14, bc.removed * 1), `${bc.removed} removed-API usage(s) in source (${breakingChanges.summary.filesAffected} files affected)`);
|
|
40
|
+
add(Math.min(6, bc.deprecated * 0.5), `${bc.deprecated} deprecated-API usage(s) in source`);
|
|
41
|
+
} else {
|
|
42
|
+
const sig = census ? census.signals : {};
|
|
43
|
+
add(Math.min(6, (sig.rxjsCompatImports || 0) * 2), `rxjs-compat still imported in ${sig.rxjsCompatImports || 0} place(s)`);
|
|
44
|
+
add(Math.min(4, (sig.entryComponents || 0) * 1), `entryComponents used in ${sig.entryComponents || 0} place(s) (removed API)`);
|
|
45
|
+
add(Math.min(3, (sig.legacyRxjsAddImports || 0) * 1), `legacy rxjs/add imports in ${sig.legacyRxjsAddImports || 0} place(s)`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Floor at 5 rather than 0 so the number stays informative even for repos
|
|
49
|
+
// that are very far behind (a hard 0 can't distinguish "bad" from "worse").
|
|
50
|
+
const SCORE_FLOOR = 5;
|
|
51
|
+
score = Math.max(SCORE_FLOOR, Math.round(score));
|
|
52
|
+
|
|
53
|
+
let band, bandLabel;
|
|
54
|
+
if (score >= 80) { band = 'healthy'; bandLabel = 'Healthy — low-risk upgrade'; }
|
|
55
|
+
else if (score >= 60) { band = 'moderate'; bandLabel = 'Moderate — plan a focused effort'; }
|
|
56
|
+
else if (score >= 40) { band = 'significant'; bandLabel = 'Significant — clear blockers first'; }
|
|
57
|
+
else { band = 'major'; bandLabel = 'Major — treat as a dedicated project'; }
|
|
58
|
+
|
|
59
|
+
// Rough effort band (person-days), deliberately a range not a false-precise number.
|
|
60
|
+
const hops = versionGap.known ? versionGap.hopCount : 0;
|
|
61
|
+
const effortLow = hops * 2 + s.blocker * 1 + s.removed * 1 + s.partial * 0.5;
|
|
62
|
+
const effortHigh = hops * 4 + s.blocker * 3 + s.removed * 2 + s.partial * 1 + s.abandoned * 1;
|
|
63
|
+
const effort = {
|
|
64
|
+
lowDays: Math.max(1, Math.round(effortLow)),
|
|
65
|
+
highDays: Math.max(2, Math.round(effortHigh)),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
score,
|
|
70
|
+
band,
|
|
71
|
+
bandLabel,
|
|
72
|
+
deductions: deductions.sort((a, b) => b.points - a.points),
|
|
73
|
+
effort,
|
|
74
|
+
blockers: buildBlockerList(deps),
|
|
75
|
+
quickWins: buildQuickWins(deps, census, versionGap, breakingChanges),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildBlockerList(deps) {
|
|
80
|
+
return deps.packages
|
|
81
|
+
.filter((p) => p.category === 'blocker' || p.category === 'removed')
|
|
82
|
+
.map((p) => ({
|
|
83
|
+
name: p.name,
|
|
84
|
+
category: p.category,
|
|
85
|
+
reason: p.reason,
|
|
86
|
+
replacement: p.replacement,
|
|
87
|
+
supports: p.maxAngularSupported,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildQuickWins(deps, census, versionGap, breakingChanges) {
|
|
92
|
+
const wins = [];
|
|
93
|
+
const removedTooling = deps.packages.filter((p) => p.category === 'removed');
|
|
94
|
+
if (removedTooling.length) {
|
|
95
|
+
wins.push(`Replace removed tooling first: ${removedTooling.map((p) => p.name).join(', ')}.`);
|
|
96
|
+
}
|
|
97
|
+
// Auto-fixable code migrations worth calling out.
|
|
98
|
+
if (breakingChanges && breakingChanges.rules) {
|
|
99
|
+
const byId = Object.fromEntries(breakingChanges.rules.map((r) => [r.id, r]));
|
|
100
|
+
if (byId['entry-components']) wins.push(`Remove entryComponents (${byId['entry-components'].count} usage(s)) — no longer needed under Ivy.`);
|
|
101
|
+
if (byId['rxjs-compat-import']) wins.push('Finish the RxJS 7 migration and drop rxjs-compat.');
|
|
102
|
+
if (byId['testbed-get']) wins.push(`Swap TestBed.get() → TestBed.inject() (${byId['testbed-get'].count} usage(s)).`);
|
|
103
|
+
const cf = ['cf-ngif', 'cf-ngfor', 'cf-ngswitch'].reduce((n, id) => n + (byId[id] ? byId[id].count : 0), 0);
|
|
104
|
+
if (cf > 0) wins.push(`After reaching v17+, run the control-flow schematic to modernize ${cf} *ngIf/*ngFor/*ngSwitch usage(s).`);
|
|
105
|
+
} else if (census && census.signals.rxjsCompatImports > 0) {
|
|
106
|
+
wins.push('Finish the RxJS 7 migration and drop rxjs-compat.');
|
|
107
|
+
}
|
|
108
|
+
const abandoned = deps.packages.filter((p) => p.category === 'abandoned');
|
|
109
|
+
if (abandoned.length) {
|
|
110
|
+
wins.push(`Audit ${abandoned.length} unmaintained package(s) for replacement before upgrading.`);
|
|
111
|
+
}
|
|
112
|
+
if (versionGap.known && versionGap.hopCount > 1) {
|
|
113
|
+
wins.push(`Upgrade one major at a time via ng update (${versionGap.hopCount} hops), running tests between each.`);
|
|
114
|
+
}
|
|
115
|
+
return wins;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { computeVerdict };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LATEST_STABLE, getVersionInfo, upgradePath } = require('../data/angular-versions');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the project stands relative to the latest Angular, the support status
|
|
7
|
+
* of the current version, and the hop-by-hop path to the target.
|
|
8
|
+
*/
|
|
9
|
+
function analyzeVersionGap(currentMajor, targetMajor = LATEST_STABLE) {
|
|
10
|
+
if (!currentMajor) {
|
|
11
|
+
return { known: false, message: 'Could not determine the current Angular version.' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const majorsBehind = Math.max(0, targetMajor - currentMajor);
|
|
15
|
+
const currentInfo = getVersionInfo(currentMajor);
|
|
16
|
+
const targetInfo = getVersionInfo(targetMajor);
|
|
17
|
+
const path = upgradePath(currentMajor, targetMajor);
|
|
18
|
+
|
|
19
|
+
// Rough EOL assessment against a reference "now". We can't call Date.now()
|
|
20
|
+
// in some sandboxes, but comparing YYYY-MM strings lexically is fine.
|
|
21
|
+
const eol = currentInfo ? currentInfo.eol : null;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
known: true,
|
|
25
|
+
currentMajor,
|
|
26
|
+
targetMajor,
|
|
27
|
+
majorsBehind,
|
|
28
|
+
latestStable: LATEST_STABLE,
|
|
29
|
+
isLatest: majorsBehind === 0,
|
|
30
|
+
currentInfo,
|
|
31
|
+
targetInfo,
|
|
32
|
+
eol,
|
|
33
|
+
hops: path,
|
|
34
|
+
hopCount: path.length,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { analyzeVersionGap };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zero-dependency trick: the tool ships no parser. Instead we borrow the
|
|
7
|
+
* TypeScript compiler (and, when present, @angular/compiler) from the *target*
|
|
8
|
+
* project's own node_modules — every Angular repo already has them. If they
|
|
9
|
+
* can't be resolved, callers fall back to a regex scan.
|
|
10
|
+
*/
|
|
11
|
+
function loadTypeScript(projectDir) {
|
|
12
|
+
const candidates = [projectDir, path.join(projectDir, 'node_modules')];
|
|
13
|
+
// 1) Resolve relative to the project (handles hoisting, workspaces, pnpm).
|
|
14
|
+
try {
|
|
15
|
+
const resolved = require.resolve('typescript', { paths: candidates });
|
|
16
|
+
return { ts: require(resolved), source: resolved };
|
|
17
|
+
} catch (_) {
|
|
18
|
+
/* fall through */
|
|
19
|
+
}
|
|
20
|
+
// 2) Last resort: whatever TypeScript the tool itself might see (usually none).
|
|
21
|
+
try {
|
|
22
|
+
return { ts: require('typescript'), source: 'tool' };
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return { ts: null, source: null };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadAngularCompiler(projectDir) {
|
|
29
|
+
const candidates = [projectDir, path.join(projectDir, 'node_modules')];
|
|
30
|
+
try {
|
|
31
|
+
const resolved = require.resolve('@angular/compiler', { paths: candidates });
|
|
32
|
+
return { compiler: require(resolved), source: resolved };
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return { compiler: null, source: null };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { loadTypeScript, loadAngularCompiler };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { safeReadFile } = require('../utils/fs-helpers');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Line-by-line regex scanner. Used for:
|
|
8
|
+
* - HTML template rules (no AST available for templates in this MVP)
|
|
9
|
+
* - a graceful fallback for TS rules when the TypeScript compiler can't be
|
|
10
|
+
* borrowed from the target project.
|
|
11
|
+
*
|
|
12
|
+
* Each rule must carry a `regex`. Findings: { ruleId, file, line, col }.
|
|
13
|
+
*/
|
|
14
|
+
function scanWithRegex(files, rules, projectDir, onProgress) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
files.forEach((file, i) => {
|
|
17
|
+
const content = safeReadFile(file);
|
|
18
|
+
if (content) {
|
|
19
|
+
const rel = path.relative(projectDir, file);
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
for (const rule of rules) {
|
|
22
|
+
if (!rule.regex) continue;
|
|
23
|
+
// Use a fresh, global-flagged clone so we can find every hit per line.
|
|
24
|
+
const re = new RegExp(rule.regex.source, rule.regex.flags.includes('g') ? rule.regex.flags : rule.regex.flags + 'g');
|
|
25
|
+
for (let ln = 0; ln < lines.length; ln++) {
|
|
26
|
+
const line = lines[ln];
|
|
27
|
+
if (isLikelyComment(line)) continue;
|
|
28
|
+
re.lastIndex = 0;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = re.exec(line)) !== null) {
|
|
31
|
+
findings.push({ ruleId: rule.id, file: rel, line: ln + 1, col: m.index + 1 });
|
|
32
|
+
if (m.index === re.lastIndex) re.lastIndex++; // avoid zero-width loop
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (onProgress) onProgress(i + 1, files.length);
|
|
38
|
+
});
|
|
39
|
+
return findings;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLikelyComment(line) {
|
|
43
|
+
const t = line.trim();
|
|
44
|
+
return t.startsWith('//') || t.startsWith('*') || t.startsWith('<!--');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { scanWithRegex };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { safeReadFile } = require('../utils/fs-helpers');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk each TypeScript file's AST and match nodes against the rulebook.
|
|
8
|
+
* Matcher types supported: import, property, propertyAccess, call, identifier.
|
|
9
|
+
* Returns a flat list of findings: { ruleId, file, line, col }.
|
|
10
|
+
*/
|
|
11
|
+
function scanTsFiles(ts, files, rules, projectDir, onProgress) {
|
|
12
|
+
const byType = groupBy(rules, (r) => r.match.type);
|
|
13
|
+
const findings = [];
|
|
14
|
+
|
|
15
|
+
files.forEach((file, i) => {
|
|
16
|
+
const content = safeReadFile(file);
|
|
17
|
+
if (content) {
|
|
18
|
+
let sf;
|
|
19
|
+
try {
|
|
20
|
+
sf = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, /*setParentNodes*/ true);
|
|
21
|
+
} catch (_) {
|
|
22
|
+
sf = null;
|
|
23
|
+
}
|
|
24
|
+
if (sf) scanSourceFile(ts, sf, file, byType, projectDir, findings);
|
|
25
|
+
}
|
|
26
|
+
if (onProgress) onProgress(i + 1, files.length);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return findings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scanSourceFile(ts, sf, file, byType, projectDir, findings) {
|
|
33
|
+
const rel = path.relative(projectDir, file);
|
|
34
|
+
const record = (rule, node) => {
|
|
35
|
+
let pos;
|
|
36
|
+
try {
|
|
37
|
+
pos = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
38
|
+
} catch (_) {
|
|
39
|
+
pos = { line: 0, character: 0 };
|
|
40
|
+
}
|
|
41
|
+
findings.push({ ruleId: rule.id, file: rel, line: pos.line + 1, col: pos.character + 1 });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const importRules = byType.import || [];
|
|
45
|
+
const propRules = byType.property || [];
|
|
46
|
+
const paRules = byType.propertyAccess || [];
|
|
47
|
+
const callRules = byType.call || [];
|
|
48
|
+
const idRules = byType.identifier || [];
|
|
49
|
+
|
|
50
|
+
const visit = (node) => {
|
|
51
|
+
// import ... from 'module'
|
|
52
|
+
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
53
|
+
const mod = node.moduleSpecifier.text;
|
|
54
|
+
const named = getNamedImports(ts, node);
|
|
55
|
+
for (const r of importRules) {
|
|
56
|
+
if (!moduleMatches(mod, r.match.module)) continue;
|
|
57
|
+
if (r.match.named && !r.match.named.some((n) => named.includes(n))) continue;
|
|
58
|
+
record(r, node);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// { key: value } object literal property
|
|
63
|
+
if (ts.isPropertyAssignment(node) && node.name) {
|
|
64
|
+
const name = safeText(ts, node.name, sf);
|
|
65
|
+
for (const r of propRules) {
|
|
66
|
+
if (name !== r.match.name) continue;
|
|
67
|
+
if (r.match.valueContains) {
|
|
68
|
+
const init = node.initializer ? safeText(ts, node.initializer, sf) : '';
|
|
69
|
+
if (!init.includes(r.match.valueContains)) continue;
|
|
70
|
+
}
|
|
71
|
+
record(r, node);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Foo.Bar member access
|
|
76
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
77
|
+
const object = safeText(ts, node.expression, sf);
|
|
78
|
+
const name = safeText(ts, node.name, sf);
|
|
79
|
+
for (const r of paRules) {
|
|
80
|
+
if (r.match.object === object && r.match.name === name) record(r, node);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// callee(...) or object.callee(...)
|
|
85
|
+
if (ts.isCallExpression(node)) {
|
|
86
|
+
const expr = node.expression;
|
|
87
|
+
let callee = null;
|
|
88
|
+
let object = null;
|
|
89
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
90
|
+
callee = safeText(ts, expr.name, sf);
|
|
91
|
+
object = safeText(ts, expr.expression, sf);
|
|
92
|
+
} else if (ts.isIdentifier(expr)) {
|
|
93
|
+
callee = safeText(ts, expr, sf);
|
|
94
|
+
}
|
|
95
|
+
for (const r of callRules) {
|
|
96
|
+
if (r.match.callee !== callee) continue;
|
|
97
|
+
if (r.match.object && r.match.object !== object) continue;
|
|
98
|
+
if (r.match.firstArgFunction) {
|
|
99
|
+
const arg0 = node.arguments && node.arguments[0];
|
|
100
|
+
if (!arg0 || !(ts.isArrowFunction(arg0) || ts.isFunctionExpression(arg0))) continue;
|
|
101
|
+
}
|
|
102
|
+
record(r, node);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// bare identifier usage (distinctive names only — see rulebook)
|
|
107
|
+
if (idRules.length && ts.isIdentifier(node)) {
|
|
108
|
+
const name = node.escapedText ? String(node.escapedText) : safeText(ts, node, sf);
|
|
109
|
+
for (const r of idRules) {
|
|
110
|
+
if (name === r.match.name) record(r, node);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
visit(sf);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getNamedImports(ts, importDecl) {
|
|
121
|
+
const names = [];
|
|
122
|
+
const clause = importDecl.importClause;
|
|
123
|
+
if (!clause) return names;
|
|
124
|
+
if (clause.name) names.push(String(clause.name.escapedText || ''));
|
|
125
|
+
const bindings = clause.namedBindings;
|
|
126
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
127
|
+
bindings.elements.forEach((el) => names.push(String((el.name && el.name.escapedText) || '')));
|
|
128
|
+
}
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function moduleMatches(mod, matcher) {
|
|
133
|
+
if (matcher instanceof RegExp) return matcher.test(mod);
|
|
134
|
+
return mod === matcher;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function safeText(ts, node, sf) {
|
|
138
|
+
try {
|
|
139
|
+
return node.getText(sf);
|
|
140
|
+
} catch (_) {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function groupBy(arr, keyFn) {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const item of arr) {
|
|
148
|
+
const k = keyFn(item);
|
|
149
|
+
(out[k] = out[k] || []).push(item);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { scanTsFiles };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { run, TOOL_VERSION } = require('./index');
|
|
4
|
+
const { write, c } = require('./utils/log');
|
|
5
|
+
|
|
6
|
+
const HELP = `
|
|
7
|
+
ng-upgrade-doctor v${TOOL_VERSION}
|
|
8
|
+
Zero-dependency Angular upgrade readiness & blast-radius diagnostic.
|
|
9
|
+
|
|
10
|
+
USAGE
|
|
11
|
+
npx ng-upgrade-doctor [path] [options]
|
|
12
|
+
|
|
13
|
+
ARGUMENTS
|
|
14
|
+
path Path to the Angular project (default: current directory)
|
|
15
|
+
|
|
16
|
+
OPTIONS
|
|
17
|
+
--target <major> Target Angular major to assess against (default: latest known)
|
|
18
|
+
--offline Skip the npm registry; classify from the local knowledge base only
|
|
19
|
+
--no-code Skip the source-tree census (faster; deps only)
|
|
20
|
+
--out <dir> Directory to write HTML/JSON reports (default: project dir)
|
|
21
|
+
--concurrency <n> Parallel registry lookups (default: 8)
|
|
22
|
+
--ci CI mode: exit 1 if hard blockers exist or score < --min-score
|
|
23
|
+
--min-score <n> Minimum readiness score for --ci to pass (default: 0)
|
|
24
|
+
--quiet Suppress progress output
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
-v, --version Show version
|
|
27
|
+
|
|
28
|
+
EXAMPLES
|
|
29
|
+
npx ng-upgrade-doctor
|
|
30
|
+
npx ng-upgrade-doctor ./apps/web --target 18
|
|
31
|
+
npx ng-upgrade-doctor --ci --min-score 60
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const opts = {};
|
|
36
|
+
const positional = [];
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
switch (a) {
|
|
40
|
+
case '-h': case '--help': opts.help = true; break;
|
|
41
|
+
case '-v': case '--version': opts.version = true; break;
|
|
42
|
+
case '--offline': opts.offline = true; break;
|
|
43
|
+
case '--no-code': opts.noCode = true; break;
|
|
44
|
+
case '--ci': opts.ci = true; break;
|
|
45
|
+
case '--quiet': opts.quiet = true; break;
|
|
46
|
+
case '--target': opts.target = Number(argv[++i]); break;
|
|
47
|
+
case '--out': opts.out = argv[++i]; break;
|
|
48
|
+
case '--concurrency': opts.concurrency = Number(argv[++i]); break;
|
|
49
|
+
case '--min-score': opts.minScore = Number(argv[++i]); break;
|
|
50
|
+
default:
|
|
51
|
+
if (a.startsWith('--target=')) opts.target = Number(a.split('=')[1]);
|
|
52
|
+
else if (a.startsWith('--out=')) opts.out = a.split('=')[1];
|
|
53
|
+
else if (a.startsWith('-')) { write(c.yellow(` Unknown option: ${a}`)); }
|
|
54
|
+
else positional.push(a);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (positional[0]) opts.projectDir = positional[0];
|
|
58
|
+
return opts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
63
|
+
if (opts.help) { write(HELP); return; }
|
|
64
|
+
if (opts.version) { write(TOOL_VERSION); return; }
|
|
65
|
+
try {
|
|
66
|
+
await run(opts);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
write(c.red(' ✗ ng-upgrade-doctor failed: ' + (e && e.stack ? e.stack : e)));
|
|
69
|
+
process.exitCode = 2;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { main, parseArgs };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Angular release reference data — the maintainable "rulebook".
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately plain data so it can be updated each Angular release
|
|
7
|
+
* without touching engine code. `eol` dates are approximate (Angular gives
|
|
8
|
+
* each major ~6 months active + 12 months LTS). `latestStable` should be
|
|
9
|
+
* bumped when a new major ships.
|
|
10
|
+
*
|
|
11
|
+
* Fields per major:
|
|
12
|
+
* node - supported Node.js ranges (human readable)
|
|
13
|
+
* ts - supported TypeScript range
|
|
14
|
+
* rxjs - supported RxJS range
|
|
15
|
+
* zone - zone.js range (null once zoneless is the norm for that line)
|
|
16
|
+
* eol - approximate end-of-life (YYYY-MM) for the LTS window
|
|
17
|
+
* notes - headline breaking changes / themes for the release
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LATEST_STABLE = 20;
|
|
21
|
+
|
|
22
|
+
const VERSIONS = {
|
|
23
|
+
12: {
|
|
24
|
+
node: '12.14 / 14.15+', ts: '4.2 – 4.3', rxjs: '6.5 / 7.x', zone: '~0.11',
|
|
25
|
+
eol: '2022-11',
|
|
26
|
+
notes: ['Ivy-only libraries', 'IE11 deprecated', 'Nullish coalescing in templates'],
|
|
27
|
+
},
|
|
28
|
+
13: {
|
|
29
|
+
node: '12.20 / 14.15 / 16.10', ts: '4.4 – 4.5', rxjs: '6.5 / 7.4+', zone: '~0.11',
|
|
30
|
+
eol: '2023-05',
|
|
31
|
+
notes: ['View Engine removed (Ivy only)', 'IE11 support removed', 'Dynamic component API simplified'],
|
|
32
|
+
},
|
|
33
|
+
14: {
|
|
34
|
+
node: '14.15 / 16.10', ts: '4.6 – 4.8', rxjs: '6.5 / 7.x', zone: '~0.11',
|
|
35
|
+
eol: '2023-11',
|
|
36
|
+
notes: ['Standalone components (preview)', 'Typed reactive forms', 'CLI autocompletion'],
|
|
37
|
+
},
|
|
38
|
+
15: {
|
|
39
|
+
node: '14.20 / 16.13 / 18.10', ts: '4.8 – 4.9', rxjs: '6.5 / 7.x', zone: '~0.11',
|
|
40
|
+
eol: '2024-05',
|
|
41
|
+
notes: ['Standalone APIs stable', 'MDC-based Angular Material', 'Directive composition API', 'enableIvy removed'],
|
|
42
|
+
},
|
|
43
|
+
16: {
|
|
44
|
+
node: '16.14 / 18.10', ts: '>=4.9.3 <5.2', rxjs: '6.x / 7.x', zone: '~0.13',
|
|
45
|
+
eol: '2024-11',
|
|
46
|
+
notes: ['Signals (developer preview)', 'Required inputs', 'esbuild dev-server (preview)', 'entryComponents removed', 'Standalone ng new'],
|
|
47
|
+
},
|
|
48
|
+
17: {
|
|
49
|
+
node: '18.13 / 20.9', ts: '>=5.2 <5.5', rxjs: '6.x / 7.x', zone: '~0.14',
|
|
50
|
+
eol: '2025-05',
|
|
51
|
+
notes: ['New control flow @if/@for/@switch', 'Deferrable views', 'esbuild + Vite default build', 'New brand / angular.dev', 'View transitions'],
|
|
52
|
+
},
|
|
53
|
+
18: {
|
|
54
|
+
node: '18.19 / 20.11 / 22', ts: '>=5.4 <5.6', rxjs: '6.x / 7.x', zone: '~0.14 (optional)',
|
|
55
|
+
eol: '2025-11',
|
|
56
|
+
notes: ['Zoneless change detection (preview)', 'Control flow stable', 'Material 3 stable', 'Event replay for SSR'],
|
|
57
|
+
},
|
|
58
|
+
19: {
|
|
59
|
+
node: '18.19 / 20.11 / 22', ts: '>=5.5 <5.7', rxjs: '6.x / 7.x', zone: '~0.15 (optional)',
|
|
60
|
+
eol: '2026-05',
|
|
61
|
+
notes: ['Standalone by default', 'Incremental hydration', 'linkedSignal & resource APIs', 'Hot module replacement'],
|
|
62
|
+
},
|
|
63
|
+
20: {
|
|
64
|
+
node: '20.11 / 22 / 24', ts: '>=5.8 <5.9', rxjs: '7.x', zone: 'optional (zoneless stable path)',
|
|
65
|
+
eol: '2026-11',
|
|
66
|
+
notes: ['Signals stable', 'Zoneless developer preview → stable path', 'Several long-deprecated APIs removed', 'Style/template diagnostics'],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function getVersionInfo(major) {
|
|
71
|
+
return VERSIONS[major] || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the ordered hop path from `from` to `to` (exclusive of `from`). */
|
|
75
|
+
function upgradePath(from, to) {
|
|
76
|
+
const path = [];
|
|
77
|
+
for (let v = from + 1; v <= to; v++) {
|
|
78
|
+
if (VERSIONS[v]) path.push({ major: v, ...VERSIONS[v] });
|
|
79
|
+
}
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { LATEST_STABLE, VERSIONS, getVersionInfo, upgradePath };
|