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