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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammed S Hirani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # ng-upgrade-doctor
2
+
3
+ > Zero-dependency Angular upgrade readiness & blast-radius diagnostic.
4
+
5
+ Run one command in any Angular project and get the whole picture: what's installed, what's compatible with the next Angular versions, what will block the upgrade, what's abandoned, and roughly how much effort it will take — as a shareable HTML report, machine-readable JSON, and a terminal summary.
6
+
7
+ `ng-upgrade-doctor` is a **diagnostician, not an upgrader**. It tells you what you're walking into *before* you run `ng update`. It never modifies your code — it's completely read-only.
8
+
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npx ng-upgrade-doctor
13
+ ```
14
+
15
+ That's it. No install, no config, no `ng add`. It writes `ng-upgrade-report.html` and `ng-upgrade-report.json` into your project.
16
+
17
+ ## Why "zero dependency"?
18
+
19
+ The published package has an empty `dependencies` map. It relies only on the Node standard library:
20
+
21
+ - **Reading `package.json` / lockfiles** — `fs` + `JSON.parse`
22
+ - **Querying the npm registry** — global `fetch` (Node 18+) with an automatic `https` fallback for Node 14–16
23
+ - **Parsing the source tree** — plain regex census (no AST parser bundled)
24
+ - **Rendering the report** — template strings → one self-contained HTML file
25
+
26
+ This keeps installs instant, supply-chain risk near zero, and makes it safe to run in CI.
27
+
28
+ ## What's in the report
29
+
30
+ | Section | Highlights |
31
+ |---|---|
32
+ | **Project inventory** | dependency counts (prod/dev/transitive), Angular packages, 3rd-party Angular libs, components, NgModules, services, standalone ratio, file counts, LOC, build system, test/e2e stack |
33
+ | **Version gap** | how many majors behind, support/EOL status, hop-by-hop upgrade path with per-version Node/TS requirements |
34
+ | **Dependency compatibility** | every dependency classified: compatible / needs upgrade / partial / blocker / removed / abandoned, with the highest Angular major each supports (resolved from live npm peer dependencies) and recommended replacements |
35
+ | **Readiness score** | a transparent 0–100 score with every deduction itemized, plus an effort band in person-days |
36
+ | **Must-fix + quick wins** | the ordered blocker list and the fastest things to clear first |
37
+
38
+ ## Usage
39
+
40
+ ```
41
+ npx ng-upgrade-doctor [path] [options]
42
+
43
+ Options:
44
+ --target <major> Target Angular major to assess against (default: latest known)
45
+ --offline Skip the registry; classify from the local knowledge base only
46
+ --no-code Skip the source-tree census (deps only, faster)
47
+ --out <dir> Where to write reports (default: project dir)
48
+ --concurrency <n> Parallel registry lookups (default: 8)
49
+ --ci Exit 1 if hard blockers exist or score < --min-score
50
+ --min-score <n> Minimum readiness score for --ci to pass
51
+ --quiet Suppress progress output
52
+ -h, --help / -v, --version
53
+ ```
54
+
55
+ ### CI gate example
56
+
57
+ ```bash
58
+ npx ng-upgrade-doctor --ci --min-score 60
59
+ ```
60
+
61
+ Fails the build if there are hard blockers or the readiness score drops below 60 — a way to stop upgrade debt from piling up.
62
+
63
+ ## How compatibility is determined
64
+
65
+ For each dependency, the tool fetches the npm packument and finds the **highest Angular major** that any published version's `peerDependencies["@angular/core"]` accepts. Compared against your target, that yields the classification. A curated knowledge base (`src/data/known-packages.js`) overlays truths a registry lookup can't know — officially removed tooling (`tslint`, `protractor`), discontinued libraries (`@angular/flex-layout`), and recommended replacements.
66
+
67
+ > Compatibility is *inferred*. Always confirm against the official [Angular Update Guide](https://angular.dev/update-guide) before upgrading.
68
+
69
+ ## Code breaking-change scan (AST)
70
+
71
+ For `.ts` files the tool borrows the target project's own **TypeScript compiler**
72
+ and walks the real AST, matching against a versioned rulebook
73
+ (`src/data/breaking-changes.js`) of removed/deprecated APIs — e.g. `entryComponents`,
74
+ legacy `rxjs/add` and `rxjs-compat` imports, `TestBed.get()`, `.toPromise()`, the
75
+ `async()` test helper, `@angular/material` barrel imports, and more. Templates are
76
+ scanned for modernization opportunities (`*ngIf`/`*ngFor`/`*ngSwitch` → built-in
77
+ control flow) and discontinued `fx*` flex-layout directives. Every hit is reported
78
+ as `file:line:col`. If the compiler can't be resolved, it falls back to a regex scan
79
+ so the section still has value.
80
+
81
+ ## Roadmap
82
+
83
+ - **v0.1** — inventory, version gap, dependency compatibility matrix, readiness score, HTML/JSON/terminal reports ✅
84
+ - **v0.2** — AST-based breaking-change scanner (removed/deprecated API usage + modernization opportunities with `file:line:col`) ✅
85
+ - **v0.3** — staged migration planner with per-hop effort and modernization guidance (standalone, signals, zoneless)
86
+ - **v0.4** — Nx/monorepo awareness, richer CI output (SARIF/markdown)
87
+ - **v1.0** — community-maintained rules database
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { main } = require('../src/cli');
5
+ main();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "ng-upgrade-doctor",
3
+ "version": "0.2.0",
4
+ "description": "Zero-dependency Angular upgrade readiness & blast-radius diagnostic. Run npx ng-upgrade-doctor in any Angular project to get a full picture of what needs upgrading, what will break, and how hard it will be.",
5
+ "keywords": [
6
+ "angular",
7
+ "upgrade",
8
+ "migration",
9
+ "ng-update",
10
+ "audit",
11
+ "dependencies",
12
+ "readiness",
13
+ "diagnostic"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Mohammed S Hirani <mohammadhirani-cmpn@atharvacoe.ac.in>",
17
+ "homepage": "https://github.com/mohd-hirani/ng-upgrade-doctor#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/mohd-hirani/ng-upgrade-doctor.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/mohd-hirani/ng-upgrade-doctor/issues"
24
+ },
25
+ "type": "commonjs",
26
+ "bin": {
27
+ "ng-upgrade-doctor": "bin/ng-upgrade-doctor.js"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "start": "node bin/ng-upgrade-doctor.js",
36
+ "test": "node test/run.js"
37
+ },
38
+ "engines": {
39
+ "node": ">=14.0.0"
40
+ },
41
+ "dependencies": {},
42
+ "devDependencies": {},
43
+ "private": false
44
+ }
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { walkFiles } = require('../utils/fs-helpers');
5
+ const { loadTypeScript } = require('../ast/loader');
6
+ const { scanTsFiles } = require('../ast/ts-scanner');
7
+ const { scanWithRegex } = require('../ast/regex-scanner');
8
+ const { TS_RULES, TEMPLATE_RULES } = require('../data/breaking-changes');
9
+
10
+ const RULE_INDEX = {};
11
+ [...TS_RULES, ...TEMPLATE_RULES].forEach((r) => (RULE_INDEX[r.id] = r));
12
+
13
+ const SAMPLE_CAP = 8; // locations retained per rule for the report
14
+
15
+ /**
16
+ * Scans the source tree for removed/deprecated API usage and modernization
17
+ * opportunities. Uses the target project's TypeScript compiler for .ts files
18
+ * (AST) and regex for .html templates; falls back to regex for .ts when the
19
+ * compiler can't be borrowed.
20
+ */
21
+ function analyzeBreakingChanges(projectDir, options = {}) {
22
+ const { onProgress } = options;
23
+ const srcRoot = requireSrc(projectDir);
24
+
25
+ const tsFiles = walkFiles(srcRoot, ['.ts'], 15000).filter((f) => !f.endsWith('.d.ts'));
26
+ const htmlFiles = walkFiles(srcRoot, ['.html'], 15000);
27
+
28
+ const { ts, source } = loadTypeScript(projectDir);
29
+ const astAvailable = Boolean(ts);
30
+
31
+ let tsFindings;
32
+ if (astAvailable) {
33
+ tsFindings = scanTsFiles(ts, tsFiles, TS_RULES, projectDir, progress(onProgress, 'ts', tsFiles.length));
34
+ } else {
35
+ // Degrade to regex over TS files.
36
+ tsFindings = scanWithRegex(tsFiles, TS_RULES, projectDir, progress(onProgress, 'ts', tsFiles.length));
37
+ }
38
+
39
+ const templateFindings = scanWithRegex(htmlFiles, TEMPLATE_RULES, projectDir, progress(onProgress, 'html', htmlFiles.length));
40
+
41
+ const findings = tsFindings.concat(templateFindings);
42
+ return aggregate(findings, {
43
+ astAvailable,
44
+ tsCompilerSource: source ? path.relative(projectDir, source) || source : null,
45
+ filesScanned: { ts: tsFiles.length, html: htmlFiles.length },
46
+ });
47
+ }
48
+
49
+ function aggregate(findings, extra) {
50
+ const perRule = {};
51
+ const affectedFiles = new Set();
52
+ const severityCounts = { removed: 0, deprecated: 0, opportunity: 0 };
53
+
54
+ for (const f of findings) {
55
+ const rule = RULE_INDEX[f.ruleId];
56
+ if (!rule) continue;
57
+ affectedFiles.add(f.file);
58
+ severityCounts[rule.severity] = (severityCounts[rule.severity] || 0) + 1;
59
+
60
+ if (!perRule[f.ruleId]) {
61
+ perRule[f.ruleId] = {
62
+ id: rule.id,
63
+ title: rule.title,
64
+ severity: rule.severity,
65
+ since: rule.since,
66
+ scope: rule.scope,
67
+ message: rule.message,
68
+ fix: rule.fix,
69
+ docs: rule.docs,
70
+ count: 0,
71
+ files: new Set(),
72
+ samples: [],
73
+ };
74
+ }
75
+ const entry = perRule[f.ruleId];
76
+ entry.count += 1;
77
+ entry.files.add(f.file);
78
+ if (entry.samples.length < SAMPLE_CAP) {
79
+ entry.samples.push(`${f.file}:${f.line}:${f.col}`);
80
+ }
81
+ }
82
+
83
+ const SEV_ORDER = { removed: 0, deprecated: 1, opportunity: 2 };
84
+ const rules = Object.values(perRule)
85
+ .map((r) => ({ ...r, fileCount: r.files.size, files: undefined }))
86
+ .sort((a, b) => (SEV_ORDER[a.severity] - SEV_ORDER[b.severity]) || (b.count - a.count));
87
+
88
+ return {
89
+ astAvailable: extra.astAvailable,
90
+ tsCompilerSource: extra.tsCompilerSource,
91
+ filesScanned: extra.filesScanned,
92
+ summary: {
93
+ totalHits: findings.length,
94
+ removed: severityCounts.removed,
95
+ deprecated: severityCounts.deprecated,
96
+ opportunity: severityCounts.opportunity,
97
+ rulesTriggered: rules.length,
98
+ filesAffected: affectedFiles.size,
99
+ },
100
+ rules,
101
+ };
102
+ }
103
+
104
+ function requireSrc(projectDir) {
105
+ const fs = require('fs');
106
+ const src = path.join(projectDir, 'src');
107
+ try {
108
+ if (fs.existsSync(src)) return src;
109
+ } catch (_) { /* ignore */ }
110
+ return projectDir;
111
+ }
112
+
113
+ function progress(onProgress, phase, total) {
114
+ if (!onProgress) return null;
115
+ return (done) => onProgress(phase, done, total);
116
+ }
117
+
118
+ module.exports = { analyzeBreakingChanges };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { walkFiles, safeReadFile, exists } = require('../utils/fs-helpers');
5
+
6
+ /**
7
+ * Lightweight, regex-based census of the source tree. No AST, no dependency —
8
+ * just counts the building blocks so the report can say "N components,
9
+ * M NgModules, standalone ratio", plus a few cheap upgrade signals.
10
+ *
11
+ * These are heuristics (comments/strings can inflate counts slightly), and
12
+ * the report labels them as approximate.
13
+ */
14
+ function analyzeCensus(projectDir) {
15
+ const srcRoot = exists(path.join(projectDir, 'src')) ? path.join(projectDir, 'src') : projectDir;
16
+ const tsFiles = walkFiles(srcRoot, ['.ts'], 15000).filter((f) => !f.endsWith('.d.ts'));
17
+ const htmlFiles = walkFiles(srcRoot, ['.html'], 15000);
18
+ const styleFiles = walkFiles(srcRoot, ['.scss', '.css', '.sass', '.less'], 15000);
19
+ const specFiles = tsFiles.filter((f) => f.endsWith('.spec.ts'));
20
+
21
+ const counts = {
22
+ components: 0,
23
+ standaloneComponents: 0,
24
+ modules: 0,
25
+ services: 0,
26
+ directives: 0,
27
+ pipes: 0,
28
+ totalLoc: 0,
29
+ };
30
+
31
+ // Upgrade-relevant signals (cheap, high-signal patterns)
32
+ const signals = {
33
+ rxjsCompatImports: 0,
34
+ deepRxjsOperatorImports: 0, // rxjs/operators - fine, but rxjs/add/... is legacy
35
+ legacyRxjsAddImports: 0,
36
+ entryComponents: 0,
37
+ viewChildStatic: 0,
38
+ moduleWithProvidersNoGeneric: 0,
39
+ ngModuleFiles: 0,
40
+ filesWithHits: new Set(),
41
+ };
42
+
43
+ for (const file of tsFiles) {
44
+ const content = safeReadFile(file);
45
+ if (!content) continue;
46
+ counts.totalLoc += content.split('\n').length;
47
+
48
+ counts.components += countMatches(content, /@Component\s*\(/g);
49
+ counts.modules += countMatches(content, /@NgModule\s*\(/g);
50
+ counts.services += countMatches(content, /@Injectable\s*\(/g);
51
+ counts.directives += countMatches(content, /@Directive\s*\(/g);
52
+ counts.pipes += countMatches(content, /@Pipe\s*\(/g);
53
+ counts.standaloneComponents += countMatches(content, /standalone\s*:\s*true/g);
54
+
55
+ if (/@NgModule\s*\(/.test(content)) signals.ngModuleFiles += 1;
56
+
57
+ const localHits = [];
58
+ localHits.push(['rxjsCompatImports', countMatches(content, /from\s+['"]rxjs-compat/g)]);
59
+ localHits.push(['legacyRxjsAddImports', countMatches(content, /from\s+['"]rxjs\/add\//g)]);
60
+ localHits.push(['entryComponents', countMatches(content, /entryComponents\s*:/g)]);
61
+ localHits.push(['viewChildStatic', countMatches(content, /@ViewChild\([^)]*static\s*:/g)]);
62
+ localHits.push(['moduleWithProvidersNoGeneric', countMatches(content, /:\s*ModuleWithProviders\s*[;,)]/g)]);
63
+
64
+ let fileHit = false;
65
+ for (const [key, n] of localHits) {
66
+ if (n > 0) {
67
+ signals[key] += n;
68
+ fileHit = true;
69
+ }
70
+ }
71
+ if (fileHit) signals.filesWithHits.add(file);
72
+ }
73
+
74
+ return {
75
+ fileCounts: {
76
+ tsFiles: tsFiles.length,
77
+ htmlTemplates: htmlFiles.length,
78
+ styleFiles: styleFiles.length,
79
+ specFiles: specFiles.length,
80
+ },
81
+ counts,
82
+ standaloneRatio:
83
+ counts.components > 0 ? Math.round((counts.standaloneComponents / counts.components) * 100) : null,
84
+ signals: {
85
+ rxjsCompatImports: signals.rxjsCompatImports,
86
+ legacyRxjsAddImports: signals.legacyRxjsAddImports,
87
+ entryComponents: signals.entryComponents,
88
+ viewChildStatic: signals.viewChildStatic,
89
+ moduleWithProvidersNoGeneric: signals.moduleWithProvidersNoGeneric,
90
+ ngModuleFiles: signals.ngModuleFiles,
91
+ filesWithBreakingSignals: signals.filesWithHits.size,
92
+ },
93
+ };
94
+ }
95
+
96
+ function countMatches(str, regex) {
97
+ const m = str.match(regex);
98
+ return m ? m.length : 0;
99
+ }
100
+
101
+ module.exports = { analyzeCensus };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const semver = require('../utils/semver');
4
+ const { fetchPackument, mapPool } = require('../registry');
5
+ const knownPackages = require('../data/known-packages');
6
+
7
+ /**
8
+ * Classification buckets:
9
+ * ok 🟢 compatible with the target Angular (a supporting version exists)
10
+ * upgrade 🟡 compatible but you're behind the latest
11
+ * partial 🟡 supports some hops but not all the way to target
12
+ * blocker 🔴 no published version supports the target Angular major
13
+ * removed 🔵 removed from Angular / discontinued (from knowledge base)
14
+ * abandoned ⚫ unmaintained (old last-publish or npm-deprecated)
15
+ * agnostic 🟢 not Angular-coupled; version-independent
16
+ * unknown ⚪ could not resolve (offline / not found)
17
+ */
18
+
19
+ const STALE_YEARS = 2;
20
+
21
+ async function analyzeDependencies(snapshot, targetMajor, options = {}) {
22
+ const { offline = false, concurrency = 8, onProgress } = options;
23
+ const currentMajor = snapshot.angularMajor || targetMajor;
24
+ const entries = Object.entries(snapshot.allDeps);
25
+ const isDev = (name) => name in (snapshot.devDependencies || {});
26
+
27
+ let done = 0;
28
+ const results = await mapPool(entries, concurrency, async ([name, range]) => {
29
+ const res = await classifyPackage(name, range, targetMajor, { offline, isDev: isDev(name), currentMajor });
30
+ done += 1;
31
+ if (onProgress) onProgress(done, entries.length, name);
32
+ return res;
33
+ });
34
+
35
+ const summary = tally(results);
36
+ return { packages: results.sort(sortBySeverity), summary, targetMajor };
37
+ }
38
+
39
+ async function classifyPackage(name, declaredRange, targetMajor, { offline, isDev, currentMajor }) {
40
+ const known = knownPackages.lookup(name);
41
+ const base = {
42
+ name,
43
+ declaredRange,
44
+ dev: isDev,
45
+ angularCoupled: knownPackages.looksAngularCoupled(name),
46
+ latestVersion: null,
47
+ lastPublish: null,
48
+ maxAngularSupported: null,
49
+ deprecated: false,
50
+ replacement: known ? known.replacement : null,
51
+ };
52
+
53
+ // Knowledge-base overrides come first — these are curated truths that hold
54
+ // regardless of what the registry says.
55
+ if (known && (known.status === 'removed' || known.status === 'discontinued')) {
56
+ return { ...base, category: 'removed', reason: known.reason, kbStatus: known.status };
57
+ }
58
+ if (known && known.status === 'transitional') {
59
+ // e.g. rxjs-compat — a migration shim that must not survive an upgrade.
60
+ return { ...base, category: 'blocker', reason: known.reason, kbStatus: known.status };
61
+ }
62
+
63
+ if (offline) {
64
+ return offlineClassify(base, known, targetMajor);
65
+ }
66
+
67
+ const packument = await fetchPackument(name);
68
+ if (!packument) {
69
+ return offlineClassify(base, known, targetMajor, 'Registry lookup failed (offline or not found).');
70
+ }
71
+
72
+ const latest = (packument['dist-tags'] && packument['dist-tags'].latest) || null;
73
+ base.latestVersion = latest;
74
+ base.deprecated = Boolean(
75
+ (packument.versions && latest && packument.versions[latest] && packument.versions[latest].deprecated) ||
76
+ packument.deprecated
77
+ );
78
+ if (packument.time && latest && packument.time[latest]) {
79
+ base.lastPublish = packument.time[latest];
80
+ }
81
+
82
+ // Determine the highest Angular major supported across published versions.
83
+ let maxSupported = null;
84
+ if (packument.versions) {
85
+ for (const [ver, meta] of Object.entries(packument.versions)) {
86
+ const peer = meta && meta.peerDependencies && meta.peerDependencies['@angular/core'];
87
+ if (peer) {
88
+ const hi = semver.highestSupportedMajor(peer);
89
+ if (hi != null && (maxSupported == null || hi > maxSupported)) maxSupported = hi;
90
+ }
91
+ }
92
+ }
93
+ base.maxAngularSupported = maxSupported;
94
+
95
+ const stale = isStale(base.lastPublish);
96
+
97
+ // Non-Angular-coupled library: generally version-independent.
98
+ if (maxSupported == null && !base.angularCoupled) {
99
+ if (base.deprecated) return { ...base, category: 'abandoned', reason: 'Marked deprecated on npm.' };
100
+ if (stale && known && known.status) return { ...base, category: 'abandoned', reason: known.reason };
101
+ if (stale) return { ...base, category: 'abandoned', reason: `No release in over ${STALE_YEARS} years.` };
102
+ if (known && known.status === 'maintenance') return { ...base, category: 'upgrade', reason: known.reason };
103
+ return { ...base, category: 'agnostic', reason: 'Framework-agnostic; not tied to an Angular version.' };
104
+ }
105
+
106
+ // Angular-coupled library — judge by max supported major.
107
+ if (maxSupported == null) {
108
+ // Coupled by name but no peer dep info; lean on KB / staleness.
109
+ if (known && known.status === 'abandoned') return { ...base, category: 'abandoned', reason: known.reason };
110
+ if (stale) return { ...base, category: 'abandoned', reason: `No release in over ${STALE_YEARS} years.` };
111
+ return { ...base, category: 'unknown', reason: 'Angular-coupled but peer dependency on @angular/core not declared.' };
112
+ }
113
+
114
+ if (maxSupported >= targetMajor) {
115
+ return { ...base, category: 'ok', reason: `Supports Angular ${maxSupported} (>= target ${targetMajor}).` };
116
+ }
117
+ if (maxSupported >= currentMajor) {
118
+ // Works at your current version (or beyond) but can't reach the target yet.
119
+ return { ...base, category: 'partial', reason: `Supports up to Angular ${maxSupported}; blocks the final jump to ${targetMajor}.` };
120
+ }
121
+ return {
122
+ ...base,
123
+ category: 'blocker',
124
+ reason: `Highest supported is Angular ${maxSupported} — behind even your current Angular ${currentMajor}.`,
125
+ };
126
+ }
127
+
128
+ function offlineClassify(base, known, targetMajor, note) {
129
+ if (known) {
130
+ if (known.status === 'abandoned') return { ...base, category: 'abandoned', reason: known.reason };
131
+ if (known.status === 'transitional') return { ...base, category: 'blocker', reason: known.reason };
132
+ if (known.status === 'maintenance') return { ...base, category: 'upgrade', reason: known.reason };
133
+ }
134
+ return {
135
+ ...base,
136
+ category: base.angularCoupled ? 'unknown' : 'agnostic',
137
+ reason: note || 'Offline mode: classified from local knowledge base only.',
138
+ };
139
+ }
140
+
141
+ function isStale(dateStr) {
142
+ if (!dateStr) return false;
143
+ // Compare year lexically to avoid Date.now(); "2022" vs "2024" style.
144
+ const year = Number(String(dateStr).slice(0, 4));
145
+ if (!year) return false;
146
+ // Reference year derived from the newest Angular EOL data (kept conservative).
147
+ const referenceYear = 2026;
148
+ return referenceYear - year >= STALE_YEARS;
149
+ }
150
+
151
+ function tally(results) {
152
+ const s = {
153
+ total: results.length,
154
+ ok: 0, upgrade: 0, partial: 0, blocker: 0, removed: 0, abandoned: 0, agnostic: 0, unknown: 0,
155
+ deprecated: 0, stale: 0,
156
+ };
157
+ for (const r of results) {
158
+ s[r.category] = (s[r.category] || 0) + 1;
159
+ if (r.deprecated) s.deprecated += 1;
160
+ if (isStale(r.lastPublish)) s.stale += 1;
161
+ }
162
+ s.hardBlockers = s.blocker + s.removed;
163
+ s.needsAttention = s.blocker + s.removed + s.partial + s.abandoned;
164
+ return s;
165
+ }
166
+
167
+ const SEVERITY = { removed: 0, blocker: 1, abandoned: 2, partial: 3, unknown: 4, upgrade: 5, agnostic: 6, ok: 7 };
168
+ function sortBySeverity(a, b) {
169
+ const d = (SEVERITY[a.category] ?? 9) - (SEVERITY[b.category] ?? 9);
170
+ if (d !== 0) return d;
171
+ return a.name.localeCompare(b.name);
172
+ }
173
+
174
+ module.exports = { analyzeDependencies, classifyPackage };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { readJson, exists, safeReadFile } = require('../utils/fs-helpers');
5
+ const semver = require('../utils/semver');
6
+
7
+ /**
8
+ * Builds the "what's present" inventory from package.json, the lockfile and
9
+ * angular.json — no network required.
10
+ */
11
+ function analyzeSnapshot(projectDir) {
12
+ const pkg = readJson(path.join(projectDir, 'package.json')) || {};
13
+ const deps = pkg.dependencies || {};
14
+ const devDeps = pkg.devDependencies || {};
15
+ const allDeps = { ...deps, ...devDeps };
16
+ const names = Object.keys(allDeps);
17
+
18
+ const angularPackages = names
19
+ .filter((n) => n.startsWith('@angular/') || n === '@angular-devkit/build-angular')
20
+ .sort();
21
+
22
+ const thirdPartyAngular = names.filter(
23
+ (n) => !n.startsWith('@angular/') && /(^@ngrx\/|^ngx-|^ng2-|^angular2?-|^angularx-|-ng2?$)/.test(n)
24
+ );
25
+
26
+ // Package manager + lockfile detection
27
+ let packageManager = 'unknown';
28
+ let lockfile = null;
29
+ let transitiveCount = null;
30
+ if (exists(path.join(projectDir, 'package-lock.json'))) {
31
+ packageManager = 'npm';
32
+ lockfile = 'package-lock.json';
33
+ const lock = readJson(path.join(projectDir, 'package-lock.json'));
34
+ if (lock && lock.packages) {
35
+ transitiveCount = Math.max(0, Object.keys(lock.packages).length - 1);
36
+ } else if (lock && lock.dependencies) {
37
+ transitiveCount = Object.keys(lock.dependencies).length;
38
+ }
39
+ } else if (exists(path.join(projectDir, 'yarn.lock'))) {
40
+ packageManager = 'yarn';
41
+ lockfile = 'yarn.lock';
42
+ const raw = safeReadFile(path.join(projectDir, 'yarn.lock'));
43
+ const m = raw.match(/^\S+@/gm);
44
+ if (m) transitiveCount = m.length;
45
+ } else if (exists(path.join(projectDir, 'pnpm-lock.yaml'))) {
46
+ packageManager = 'pnpm';
47
+ lockfile = 'pnpm-lock.yaml';
48
+ }
49
+
50
+ // Angular version (resolved from lockfile if possible, else from range)
51
+ const declaredAngular = allDeps['@angular/core'] || null;
52
+ const angularVersion = resolveInstalledVersion(projectDir, '@angular/core') || (declaredAngular ? semver.coerce(declaredAngular)?.raw : null);
53
+ const angularMajor = angularVersion ? semver.major(angularVersion) : null;
54
+
55
+ const tsVersion = resolveInstalledVersion(projectDir, 'typescript') || (allDeps['typescript'] ? semver.coerce(allDeps['typescript'])?.raw : null);
56
+
57
+ // angular.json shape
58
+ const angularJson = readJson(path.join(projectDir, 'angular.json'));
59
+ let projectCount = null;
60
+ let builder = null;
61
+ let workspaceType = 'single project';
62
+ if (angularJson && angularJson.projects) {
63
+ const projNames = Object.keys(angularJson.projects);
64
+ projectCount = projNames.length;
65
+ if (projectCount > 1) workspaceType = 'multi-project workspace';
66
+ const first = angularJson.projects[projNames[0]];
67
+ builder = first?.architect?.build?.builder || first?.targets?.build?.builder || null;
68
+ }
69
+ if (exists(path.join(projectDir, 'nx.json'))) workspaceType = 'Nx monorepo';
70
+
71
+ // Test / e2e stack
72
+ const testRunner = allDeps['jest'] ? 'Jest'
73
+ : allDeps['karma'] ? 'Karma + Jasmine'
74
+ : allDeps['@web/test-runner'] ? 'Web Test Runner'
75
+ : 'unknown';
76
+ const e2e = allDeps['@playwright/test'] || allDeps['playwright'] ? 'Playwright'
77
+ : allDeps['cypress'] ? 'Cypress'
78
+ : allDeps['protractor'] ? 'Protractor (deprecated)'
79
+ : 'none detected';
80
+
81
+ return {
82
+ name: pkg.name || path.basename(projectDir),
83
+ prodDependencyCount: Object.keys(deps).length,
84
+ devDependencyCount: Object.keys(devDeps).length,
85
+ totalDependencyCount: names.length,
86
+ transitiveDependencyCount: transitiveCount,
87
+ angularPackageCount: angularPackages.length,
88
+ angularPackages,
89
+ thirdPartyAngularCount: thirdPartyAngular.length,
90
+ thirdPartyAngular,
91
+ packageManager,
92
+ lockfile,
93
+ angularVersion,
94
+ angularMajor,
95
+ declaredAngular,
96
+ tsVersion,
97
+ workspaceType,
98
+ projectCount,
99
+ builder,
100
+ testRunner,
101
+ e2e,
102
+ allDeps,
103
+ dependencies: deps,
104
+ devDependencies: devDeps,
105
+ };
106
+ }
107
+
108
+ /** Read the concrete installed version from node_modules or lockfile. */
109
+ function resolveInstalledVersion(projectDir, name) {
110
+ const pkgJson = readJson(path.join(projectDir, 'node_modules', name, 'package.json'));
111
+ if (pkgJson && pkgJson.version) return pkgJson.version;
112
+ const lock = readJson(path.join(projectDir, 'package-lock.json'));
113
+ if (lock) {
114
+ if (lock.packages && lock.packages[`node_modules/${name}`]) {
115
+ return lock.packages[`node_modules/${name}`].version;
116
+ }
117
+ if (lock.dependencies && lock.dependencies[name]) {
118
+ return lock.dependencies[name].version;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ module.exports = { analyzeSnapshot, resolveInstalledVersion };