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,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 };