angular-compat-matrix 0.0.0-placeholder → 0.1.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/README.md +127 -1
- package/dist/app.js +90 -0
- package/dist/build.js +46 -0
- package/dist/cli.js +210 -0
- package/dist/exec.js +110 -0
- package/dist/install.js +131 -0
- package/dist/library.js +184 -0
- package/dist/model.js +12 -0
- package/dist/partial.js +90 -0
- package/dist/peers.js +79 -0
- package/dist/registry.js +85 -0
- package/dist/report.js +233 -0
- package/dist/run.js +200 -0
- package/dist/smoke.js +524 -0
- package/dist/tests.js +100 -0
- package/dist/tools.js +86 -0
- package/dist/versions.js +138 -0
- package/package.json +45 -6
package/README.md
CHANGED
|
@@ -1 +1,127 @@
|
|
|
1
|
-
|
|
1
|
+
# angular-compat-matrix
|
|
2
|
+
|
|
3
|
+
Which Angular versions does your published library really work with?
|
|
4
|
+
|
|
5
|
+
Run it with `npx angular-compat-matrix`, or install the package and run `ng-matrix`: both names start the same command.
|
|
6
|
+
|
|
7
|
+
ng-matrix builds and packs your Angular library, installs the tarball the way a user would into a fresh app for each Angular major (made with that major's own CLI), builds it, and reports a matrix. It tests the package your users get, not the source you see.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ npx angular-compat-matrix
|
|
11
|
+
|
|
12
|
+
my-dropdown@3.0.0 (compiled with Angular 21.2.0; highest minVersion 20.0.0)
|
|
13
|
+
|
|
14
|
+
peer range install partial AOT build strict types
|
|
15
|
+
Angular 19 ✓ ✓ ✗ — —
|
|
16
|
+
Angular 20 ✓ ✓ ✓ ✓ ✗
|
|
17
|
+
Angular 21 ✓ ✓ ✓ ✓ ✓
|
|
18
|
+
Angular 22 ✗ ✓ ✓ ✓ ✓
|
|
19
|
+
|
|
20
|
+
✗ 19: partial: Needs Angular 20.0.0 or later: highest minVersion 20.0.0, in
|
|
21
|
+
MyDropdownComponent. Build the library with the oldest Angular you support, or take
|
|
22
|
+
this version out of the peer range.
|
|
23
|
+
✗ 20: strict types: A template using the package type-checks only loosely…
|
|
24
|
+
! 22: works, but peerDependencies doesn't allow it (@angular/core "^19.0.0 || ^20.0.0 ||
|
|
25
|
+
^21.0.0"). Widen the range to include it.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Run it in your library's workspace: an Angular CLI workspace with a library project, or a folder with `ng-package.json`.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx angular-compat-matrix # supported majors plus every major your peer range claims
|
|
34
|
+
npx angular-compat-matrix --versions 18-22 # an explicit range (or 22, or 20,22)
|
|
35
|
+
npx angular-compat-matrix --project my-lib # one library in a workspace with several
|
|
36
|
+
npx angular-compat-matrix --consumer ./example # build your own example app instead of a generated one
|
|
37
|
+
npx angular-compat-matrix --tests # also run your library's tests on each version
|
|
38
|
+
npx angular-compat-matrix --pm pnpm # npm (default) or pnpm
|
|
39
|
+
npx angular-compat-matrix --format markdown # a table for your README; also json
|
|
40
|
+
npx angular-compat-matrix --package some-lib@3 # check a published package instead of building
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`npx angular-compat-matrix --help` lists every option.
|
|
44
|
+
|
|
45
|
+
**Exit code:** 1 when a version inside your peer range fails, so CI goes red only for broken promises. A version outside the range that fails is a note. 2 means ng-matrix couldn't check a version at all.
|
|
46
|
+
|
|
47
|
+
**Node:** ng-matrix runs every Angular version on the Node it runs on, and stops early if that Node is too old for one of them. Node 24.15 or later covers Angular 17 to 22. Angular's `engines` ranges only set minimums; [Angular's own support table](https://angular.dev/reference/versions) is narrower.
|
|
48
|
+
|
|
49
|
+
## What it checks
|
|
50
|
+
|
|
51
|
+
For each Angular major, in order. A failure marks the later checks `—`, so the first real failure is the one reported. The peer range is reported but never stops the other checks.
|
|
52
|
+
|
|
53
|
+
| Check | Question | How |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| Peer range | Does the package claim this version? | Every `@angular/*` peer is compared, not just `core`: a CDK range can leave a version out too |
|
|
56
|
+
| Install | Does it install in a fresh app without peer conflicts? | npm: `--legacy-peer-deps` with the peers added explicitly, then `npm ls` reports every invalid peer. pnpm: `strict-peer-dependencies`. For a version the range doesn't claim, only that expected conflict is ignored |
|
|
57
|
+
| Partial | Can this version's linker load the compiled code? | The highest `minVersion` in the package's `ɵɵngDeclare…` calls. ✗ is proof; ✓ is only advisory (`✓?`) when the package was compiled with a newer major, because `minVersion` has been too low before |
|
|
58
|
+
| AOT build | Does a production build of an app using it succeed? | `ng build` of a smoke app that uses every public export, with `strictTemplates: false` and no size budgets |
|
|
59
|
+
| Strict types | Do templates using it type-check? | The same build with `strictTemplates: true` and `skipLibCheck: false`, which also catches typings that use types an older `@angular/core` lacks |
|
|
60
|
+
| Tests | Do your own tests pass on this version? | With `--tests`: your test command in a copy of your workspace, with Angular swapped to this version |
|
|
61
|
+
|
|
62
|
+
### Why build, when there's minVersion?
|
|
63
|
+
|
|
64
|
+
The Angular compiler writes a `minVersion` into each compiled declaration: the oldest Angular whose linker can handle the features it uses. It's set per feature, not from the Angular that compiled the code, and it has been too low more than once. Angular 16.1's input transforms broke 16.0 apps ([angular/angular#51413](https://github.com/angular/angular/pull/51413)). In Angular 22, factory declarations claim `minVersion: "12.0.0"` while using `ɵɵFactoryTarget.Service`, which only 22 has; an Angular 21 linker crashes on them with `Unsupported enum value`. So `minVersion` can prove a version fails, never that it works.
|
|
65
|
+
|
|
66
|
+
### The smoke app
|
|
67
|
+
|
|
68
|
+
ng-matrix reads your package's typings, where Angular records each declaration's selector, inputs and standalone flag. It writes a small app that:
|
|
69
|
+
|
|
70
|
+
- imports every entry point in your `exports` map, and installs your optional peers too, since some entry point may need them
|
|
71
|
+
- uses each component, directive and pipe once, each in a component of its own (as an app would), with a correctly typed value for every required input
|
|
72
|
+
- writes attribute selectors that take a boolean or string bare (`<div myFlag>`), the way users do, and binds the rest
|
|
73
|
+
- imports each NgModule and uses what it exports, and injects your services
|
|
74
|
+
|
|
75
|
+
If your library needs more setup than that (providers, content, a parent component), give ng-matrix an example app with `--consumer`. It's copied, its Angular dependencies are moved to each version, and your package is installed from the tarball.
|
|
76
|
+
|
|
77
|
+
## In CI
|
|
78
|
+
|
|
79
|
+
A GitHub Actions job that checks the supported majors on every push:
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
jobs:
|
|
83
|
+
angular-matrix:
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v7
|
|
87
|
+
- uses: actions/setup-node@v7
|
|
88
|
+
with:
|
|
89
|
+
node-version: 24
|
|
90
|
+
- run: npm ci
|
|
91
|
+
- run: npx angular-compat-matrix --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
(`ng-matrix ci github`, which writes a workflow with one job per Angular version, is coming in v0.2.)
|
|
95
|
+
|
|
96
|
+
## Where version data comes from
|
|
97
|
+
|
|
98
|
+
Nothing about Angular's versions is hard-coded. It all comes from the npm registry when ng-matrix runs, and is cached for a day (`--offline` reuses the cache):
|
|
99
|
+
|
|
100
|
+
- **Majors:** the published `@angular/core` versions, latest patch of each. A major counts as supported for 18 months after its x.0.0 release. `--next` adds whatever the `next` tag points to, which may be a minor pre-release.
|
|
101
|
+
- **Node:** the `engines` of that version's `@angular/core` and `@angular/cli`.
|
|
102
|
+
- **TypeScript** (for `--tests`): `@angular/compiler-cli`'s peer range. **RxJS and zone.js:** `@angular/core`'s.
|
|
103
|
+
- **Your other peers** (Material, CDK…): the newest version whose own peer ranges accept that Angular.
|
|
104
|
+
|
|
105
|
+
## Scope of v0.1
|
|
106
|
+
|
|
107
|
+
In: Angular CLI workspaces and standalone ng-packagr libraries; npm 11 and a pinned pnpm (12.15.0); the six checks; terminal, Markdown and JSON output.
|
|
108
|
+
|
|
109
|
+
Coming in v0.2: Nx workspaces (for now, run ng-matrix in the library folder with `--build "npx nx build my-lib"`), `ng-matrix ci github`, and SSR and with-zone.js columns.
|
|
110
|
+
|
|
111
|
+
Not planned: Yarn and Bun (the JSON output makes them easy to add), checking apps for upgrade readiness, and anything that only shows up at runtime in a browser.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npm install
|
|
117
|
+
npm test # unit tests, against recorded registry answers
|
|
118
|
+
npm run build
|
|
119
|
+
npm ci --prefix fixtures # the Angular 22 toolchain the fixture libraries build with
|
|
120
|
+
node test/fixtures/run.ts # every fixture case (or name one, e.g. healthy-npm)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`fixtures/libs` holds small libraries that each fail exactly one check: a feature newer than an older claimed major, a `minVersion` that's too low, peer ranges narrower and wider than reality, a CDK peer that leaves a version out, a type error only strict templates catch, typings only `skipLibCheck: false` catches, a kit over the default size budget, full compilation output, and tests that use a newer API. `fixtures/workspaces/cli-workspace` is an Angular CLI workspace, and `fixtures/consumers/healthy-example` an example app for `--consumer`. The suite also checks published packages: Angular Material 22, angular2-multiselect-dropdown 10, ngx-toastr and angular-calendar. [docs/real-world-round.md](docs/real-world-round.md) has the results of a run against 15 popular libraries.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT © 2026 Nizamudeen S
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { errorLines, exec, quietEnv } from "./exec.js";
|
|
4
|
+
import { binOf, ensureTool } from "./tools.js";
|
|
5
|
+
/** One flag set that works, without prompts, for every CLI from 17 to 22. */
|
|
6
|
+
export function newAppArgs(pm) {
|
|
7
|
+
return [
|
|
8
|
+
'new',
|
|
9
|
+
'smoke',
|
|
10
|
+
'--minimal',
|
|
11
|
+
'--skip-git',
|
|
12
|
+
'--skip-install',
|
|
13
|
+
'--defaults',
|
|
14
|
+
'--interactive=false',
|
|
15
|
+
'--ssr=false',
|
|
16
|
+
`--package-manager=${pm}`,
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
export async function generateApp(angular, pm, parentDir, cacheDir, offline) {
|
|
20
|
+
const cli = await ensureTool(cacheDir, '@angular/cli', angular.cliVersion, offline);
|
|
21
|
+
const result = await exec(process.execPath, [binOf(cli, 'ng'), ...newAppArgs(pm)], {
|
|
22
|
+
cwd: parentDir,
|
|
23
|
+
env: quietEnv({ CI: '1' }),
|
|
24
|
+
timeoutMs: 10 * 60_000,
|
|
25
|
+
});
|
|
26
|
+
const appDir = join(parentDir, 'smoke');
|
|
27
|
+
if (result.code !== 0 || !existsSync(join(appDir, 'angular.json'))) {
|
|
28
|
+
throw new Error(`ng new (Angular CLI ${angular.cliVersion}) failed:\n${errorLines(result.output).join('\n')}`);
|
|
29
|
+
}
|
|
30
|
+
return appDir;
|
|
31
|
+
}
|
|
32
|
+
export function readJson(path) {
|
|
33
|
+
// Generated tsconfig files start with /* comments */; JSON.parse can't take them.
|
|
34
|
+
const text = readFileSync(path, 'utf8').replace(/^\s*\/\*[\s\S]*?\*\/\s*/gm, '').replace(/^\s*\/\/.*$/gm, '');
|
|
35
|
+
return JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
export function writeJson(path, value) {
|
|
38
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
39
|
+
}
|
|
40
|
+
/** The app project in angular.json: the generated one, or the first application of a --consumer app. */
|
|
41
|
+
export function appProject(appDir) {
|
|
42
|
+
const workspace = readJson(join(appDir, 'angular.json'));
|
|
43
|
+
const entry = Object.entries(workspace.projects).find(([, p]) => (p.projectType ?? 'application') === 'application');
|
|
44
|
+
if (!entry)
|
|
45
|
+
throw new Error(`${appDir}/angular.json has no application project.`);
|
|
46
|
+
const [name, project] = entry;
|
|
47
|
+
const tsConfig = String(project.architect?.build?.options?.tsConfig ?? 'tsconfig.app.json');
|
|
48
|
+
return { name, tsConfig };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Points the build at ng-matrix's own tsconfig (and, for a generated app, its own main file),
|
|
52
|
+
* and removes the production budgets so a large component kit doesn't fail the build on size.
|
|
53
|
+
*/
|
|
54
|
+
export function configureBuild(appDir, mainFile) {
|
|
55
|
+
const path = join(appDir, 'angular.json');
|
|
56
|
+
const workspace = readJson(path);
|
|
57
|
+
const project = appProject(appDir);
|
|
58
|
+
const build = workspace.projects[project.name].architect.build;
|
|
59
|
+
build.options ??= {};
|
|
60
|
+
if (mainFile) {
|
|
61
|
+
// `browser` for the application builder (17+), `main` for the older webpack one.
|
|
62
|
+
if ('main' in build.options && !('browser' in build.options))
|
|
63
|
+
build.options.main = mainFile;
|
|
64
|
+
else
|
|
65
|
+
build.options.browser = mainFile;
|
|
66
|
+
}
|
|
67
|
+
build.options.tsConfig = 'tsconfig.ngm.json';
|
|
68
|
+
for (const configuration of Object.values(build.configurations ?? {})) {
|
|
69
|
+
if ('budgets' in configuration)
|
|
70
|
+
configuration.budgets = [];
|
|
71
|
+
}
|
|
72
|
+
writeJson(path, workspace);
|
|
73
|
+
return project;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Writes tsconfig.ngm.json extending the app's own tsconfig, with the template strictness set
|
|
77
|
+
* explicitly: Angular 17 to 21 apps write strictTemplates: true, and 22 makes it the default.
|
|
78
|
+
*/
|
|
79
|
+
export function writeTsconfig(appDir, baseTsConfig, mode, files) {
|
|
80
|
+
const config = {
|
|
81
|
+
extends: `./${baseTsConfig.replace(/^\.\//, '')}`,
|
|
82
|
+
compilerOptions: { skipLibCheck: mode.skipLibCheck },
|
|
83
|
+
angularCompilerOptions: { strictTemplates: mode.strictTemplates },
|
|
84
|
+
};
|
|
85
|
+
if (files) {
|
|
86
|
+
config.files = files;
|
|
87
|
+
config.include = [];
|
|
88
|
+
}
|
|
89
|
+
writeJson(join(appDir, 'tsconfig.ngm.json'), config);
|
|
90
|
+
}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { writeTsconfig } from "./app.js";
|
|
4
|
+
import { errorLines, exec, quietEnv } from "./exec.js";
|
|
5
|
+
export async function ngBuild(appDir, baseTsConfig, mode, files) {
|
|
6
|
+
writeTsconfig(appDir, baseTsConfig, mode, files);
|
|
7
|
+
const require = createRequire(join(appDir, 'package.json'));
|
|
8
|
+
const cliManifest = require.resolve('@angular/cli/package.json');
|
|
9
|
+
const ng = join(dirname(cliManifest), 'bin', 'ng.js');
|
|
10
|
+
const result = await exec(process.execPath, [ng, 'build', '--configuration', 'production'], {
|
|
11
|
+
cwd: appDir,
|
|
12
|
+
env: quietEnv({ CI: '1' }),
|
|
13
|
+
timeoutMs: 20 * 60_000,
|
|
14
|
+
});
|
|
15
|
+
if (result.code === 0)
|
|
16
|
+
return { pass: true, errorLines: [], durationMs: result.durationMs };
|
|
17
|
+
const lines = result.timedOut ? ['The build timed out after 20 minutes.'] : errorLines(result.output);
|
|
18
|
+
return { pass: false, errorLines: lines, hint: hintFor(result.output), durationMs: result.durationMs };
|
|
19
|
+
}
|
|
20
|
+
/** Plain-language explanations for build failures whose messages don't say what's wrong. */
|
|
21
|
+
export function hintFor(output) {
|
|
22
|
+
if (/Unsupported enum value|Cannot create property 'message' on string/.test(output)) {
|
|
23
|
+
return 'The compiled code uses features newer than this Angular, though its minVersion says otherwise: the linker crashed on it. Build the library with the oldest Angular you support.';
|
|
24
|
+
}
|
|
25
|
+
const linker = /depends upon a library published using Angular version ([\d.]+), which requires Angular version ([\d.]+) or newer/.exec(output);
|
|
26
|
+
if (linker) {
|
|
27
|
+
return `The package was compiled with Angular ${linker[1]} and needs Angular ${linker[2]} or newer. Build it with the oldest Angular you support.`;
|
|
28
|
+
}
|
|
29
|
+
const missing = /No matching export in "[^"]*node_modules[\\/](@angular[\\/][\w-]+)[^"]*" for import "([\w$ɵ]+)"/.exec(output);
|
|
30
|
+
if (missing) {
|
|
31
|
+
return `The package imports ${missing[2]} from ${missing[1].replace('\\', '/')}, which this Angular version doesn't have.`;
|
|
32
|
+
}
|
|
33
|
+
// TS2305 inside a .d.ts: `Module '"@angular/core"' has no exported member 'X'`, with the file on the next line.
|
|
34
|
+
const member = /Module '"([^"]+)"' has no exported member(?: named)? '([\w$ɵ]+)'[\s\S]{0,300}?node_modules[\\/](?:\.pnpm[\\/]\S*?[\\/]node_modules[\\/])?((?:@[\w.-]+[\\/])?[\w.-]+)[\\/]\S*\.d\.ts/.exec(output);
|
|
35
|
+
if (member) {
|
|
36
|
+
const pkg = member[3].replace('\\', '/');
|
|
37
|
+
return `${pkg}'s typings use ${member[2]}, which this version's ${member[1]} doesn't export. Apps with skipLibCheck: false, or that use the type, fail to compile.`;
|
|
38
|
+
}
|
|
39
|
+
if (/has no exported member(?: named)? '([\w$ɵ]+)'/.test(output) && /\.d\.ts/.test(output)) {
|
|
40
|
+
return "The package's typings use a type this Angular doesn't export. Apps with skipLibCheck: false fail to compile.";
|
|
41
|
+
}
|
|
42
|
+
if (/error NG8002|error NG8008|is not assignable to type|TS2322/.test(output)) {
|
|
43
|
+
return 'A template using the package type-checks only loosely: with strict templates, Angular reports the error above. If it comes from a bare attribute like <div myDirective>, give that boolean input a booleanAttribute transform.';
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import semver from 'semver';
|
|
8
|
+
import { LibraryError, prepareLibrary } from "./library.js";
|
|
9
|
+
import { angularImports, scanPackage, summarize } from "./partial.js";
|
|
10
|
+
import { Registry } from "./registry.js";
|
|
11
|
+
import { exitCode, findings, renderJson, renderMarkdown, renderTerminal } from "./report.js";
|
|
12
|
+
import { defaultConcurrency, runAll } from "./run.js";
|
|
13
|
+
import { defaultCacheDir } from "./tools.js";
|
|
14
|
+
import { defaultMajors, describeMajor, loadVersionSources, majorsOf, nextVersion, nodeSatisfies, parseVersionList, } from "./versions.js";
|
|
15
|
+
const HELP = `ng-matrix: which Angular versions does your published library really work with?
|
|
16
|
+
|
|
17
|
+
It builds and packs your library, installs the tarball into a fresh app for each Angular
|
|
18
|
+
major (made with that major's own CLI), builds it, and reports a matrix.
|
|
19
|
+
|
|
20
|
+
Usage
|
|
21
|
+
npx angular-compat-matrix [options]
|
|
22
|
+
ng-matrix [options] (once installed)
|
|
23
|
+
|
|
24
|
+
Options
|
|
25
|
+
--versions <list> Angular majors to check: 22, 18-22, 20,22. Default: the supported
|
|
26
|
+
majors, every major the peer range claims (from 17), and the newest
|
|
27
|
+
--next Also check whatever the \`next\` tag points to
|
|
28
|
+
--project <name> The library project, in a workspace with several
|
|
29
|
+
--consumer <dir> Build your own example app instead of a generated smoke app
|
|
30
|
+
--tests Also run the library's tests on each version (slow)
|
|
31
|
+
--test-command <cmd> The test command for --tests (default: <pm> test)
|
|
32
|
+
--format <format> terminal (default), markdown or json
|
|
33
|
+
--pm <npm|pnpm> Package manager for the apps (default: pnpm if there's a
|
|
34
|
+
pnpm-lock.yaml, else npm)
|
|
35
|
+
--build <cmd> Build the library with this command instead of the detected one
|
|
36
|
+
--skip-build Pack the existing build output as is
|
|
37
|
+
--tarball <file> Check this packed .tgz instead of building
|
|
38
|
+
--package <spec> Check a published package, e.g. some-lib@3.1.0
|
|
39
|
+
--concurrency <n> Versions checked at once (default: as many as free memory allows)
|
|
40
|
+
--keep Keep the temporary apps, for debugging
|
|
41
|
+
--offline Use only cached registry answers and packages
|
|
42
|
+
--cwd <dir> The library's workspace (default: the current folder)
|
|
43
|
+
--verbose Show every error line, not just the first few
|
|
44
|
+
-h, --help This help
|
|
45
|
+
-v, --version ng-matrix's version
|
|
46
|
+
|
|
47
|
+
Exit code: 1 when a version inside the peer range fails, 2 when a version couldn't be
|
|
48
|
+
checked at all, 0 otherwise. A version outside the range that fails is a note, not an error.
|
|
49
|
+
`;
|
|
50
|
+
function toolVersion() {
|
|
51
|
+
const manifest = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
52
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).version;
|
|
53
|
+
}
|
|
54
|
+
/** A problem ng-matrix explains and stops on, with exit code 2. */
|
|
55
|
+
class Stop extends Error {
|
|
56
|
+
lines;
|
|
57
|
+
constructor(message, lines = []) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.lines = lines;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function fail(message, lines = []) {
|
|
63
|
+
throw new Stop(message, lines);
|
|
64
|
+
}
|
|
65
|
+
function explain(error) {
|
|
66
|
+
if (error instanceof Stop || error instanceof LibraryError) {
|
|
67
|
+
process.stderr.write(`ng-matrix: ${error.message}\n${error.lines.map((l) => ` ${l}\n`).join('')}`);
|
|
68
|
+
}
|
|
69
|
+
else if (String(error.code).startsWith('ERR_PARSE_ARGS')) {
|
|
70
|
+
process.stderr.write(`ng-matrix: ${error.message}. See --help.\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
process.stderr.write(`ng-matrix: unexpected error\n${error.stack ?? String(error)}\n`);
|
|
74
|
+
}
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
export async function main(argv) {
|
|
78
|
+
const { values, positionals } = parseArgs({
|
|
79
|
+
args: argv,
|
|
80
|
+
allowPositionals: true,
|
|
81
|
+
options: {
|
|
82
|
+
versions: { type: 'string' },
|
|
83
|
+
next: { type: 'boolean', default: false },
|
|
84
|
+
project: { type: 'string' },
|
|
85
|
+
consumer: { type: 'string' },
|
|
86
|
+
tests: { type: 'boolean', default: false },
|
|
87
|
+
'test-command': { type: 'string' },
|
|
88
|
+
format: { type: 'string', default: 'terminal' },
|
|
89
|
+
pm: { type: 'string' },
|
|
90
|
+
build: { type: 'string' },
|
|
91
|
+
'skip-build': { type: 'boolean', default: false },
|
|
92
|
+
tarball: { type: 'string' },
|
|
93
|
+
package: { type: 'string' },
|
|
94
|
+
concurrency: { type: 'string' },
|
|
95
|
+
keep: { type: 'boolean', default: false },
|
|
96
|
+
offline: { type: 'boolean', default: false },
|
|
97
|
+
cwd: { type: 'string' },
|
|
98
|
+
verbose: { type: 'boolean', default: false },
|
|
99
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
100
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (values.help) {
|
|
104
|
+
process.stdout.write(HELP);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (values.version) {
|
|
108
|
+
process.stdout.write(`${toolVersion()}\n`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (positionals[0] === 'ci')
|
|
112
|
+
fail('`ng-matrix ci github` is planned for v0.2.');
|
|
113
|
+
if (positionals.length)
|
|
114
|
+
fail(`Unknown argument "${positionals[0]}". See --help.`);
|
|
115
|
+
const format = values.format;
|
|
116
|
+
if (!['terminal', 'markdown', 'json'].includes(format))
|
|
117
|
+
fail(`--format must be terminal, markdown or json, not "${format}".`);
|
|
118
|
+
const cwd = resolve(values.cwd ?? process.cwd());
|
|
119
|
+
const pm = (values.pm ?? (existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm'));
|
|
120
|
+
if (pm !== 'npm' && pm !== 'pnpm')
|
|
121
|
+
fail(`--pm must be npm or pnpm (yarn and bun come later), not "${pm}".`);
|
|
122
|
+
const consumer = values.consumer ? resolve(cwd, values.consumer) : undefined;
|
|
123
|
+
const log = (line) => process.stderr.write(`[ng-matrix] ${line}\n`);
|
|
124
|
+
const cacheDir = defaultCacheDir();
|
|
125
|
+
const registry = new Registry({
|
|
126
|
+
cacheDir,
|
|
127
|
+
offline: values.offline,
|
|
128
|
+
recordedDir: process.env.NG_MATRIX_RECORDED_REGISTRY,
|
|
129
|
+
});
|
|
130
|
+
const workDir = mkdtempSync(join(tmpdir(), 'ng-matrix-'));
|
|
131
|
+
if (values.keep)
|
|
132
|
+
log(`Working in ${workDir} (--keep)`);
|
|
133
|
+
try {
|
|
134
|
+
const library = await prepareLibrary({
|
|
135
|
+
cwd,
|
|
136
|
+
project: values.project,
|
|
137
|
+
build: values.build,
|
|
138
|
+
skipBuild: values['skip-build'],
|
|
139
|
+
tarball: values.tarball,
|
|
140
|
+
packageSpec: values.package,
|
|
141
|
+
}, workDir, log);
|
|
142
|
+
const summary = summarize(scanPackage(library.dir));
|
|
143
|
+
const now = new Date();
|
|
144
|
+
const sources = await loadVersionSources(registry);
|
|
145
|
+
const majors = values.versions
|
|
146
|
+
? parseVersionList(values.versions)
|
|
147
|
+
: defaultMajors(sources.core, library.manifest.peerDependencies?.['@angular/core'], now);
|
|
148
|
+
const published = new Set(majorsOf(sources.core));
|
|
149
|
+
for (const major of majors)
|
|
150
|
+
if (!published.has(major))
|
|
151
|
+
fail(`Angular ${major} isn't published on npm.`);
|
|
152
|
+
const angular = majors.map((major) => describeMajor(sources, major, now));
|
|
153
|
+
const next = values.next ? nextVersion(sources.core) : undefined;
|
|
154
|
+
if (values.next && !next)
|
|
155
|
+
log('The `next` tag is not ahead of `latest` right now; nothing extra to check.');
|
|
156
|
+
if (next)
|
|
157
|
+
angular.push(describeMajor(sources, semver.major(next), now, next));
|
|
158
|
+
// One Node for every version: Angular's engines ranges only set minimums.
|
|
159
|
+
const tooOld = angular.filter((a) => !nodeSatisfies(process.version, a.nodeRanges));
|
|
160
|
+
if (tooOld.length) {
|
|
161
|
+
fail(`This Node (${process.version}) can't run ${tooOld.map((a) => `Angular ${a.major}`).join(', ')}.`, tooOld.map((a) => `Angular ${a.major} needs Node ${a.nodeRanges.join(' and ')}`).concat('Use a Node that satisfies every version you check (24.15 or later covers 17 to 22), or check fewer versions.'));
|
|
162
|
+
}
|
|
163
|
+
const concurrency = values.concurrency ? Math.max(1, Number(values.concurrency)) : defaultConcurrency(angular.length);
|
|
164
|
+
log(`Checking ${library.manifest.name}@${library.manifest.version} on ${angular.map((a) => a.version).join(', ')} with ${pm}, ${concurrency} at a time`);
|
|
165
|
+
const versions = await runAll({
|
|
166
|
+
library,
|
|
167
|
+
summary,
|
|
168
|
+
pm,
|
|
169
|
+
registry,
|
|
170
|
+
cacheDir,
|
|
171
|
+
offline: values.offline,
|
|
172
|
+
workDir,
|
|
173
|
+
workspace: values.tarball || values.package ? undefined : cwd,
|
|
174
|
+
tests: values.tests,
|
|
175
|
+
testCommand: values['test-command'],
|
|
176
|
+
consumer,
|
|
177
|
+
keep: values.keep,
|
|
178
|
+
log,
|
|
179
|
+
}, angular, concurrency);
|
|
180
|
+
const report = {
|
|
181
|
+
tool: { name: 'angular-compat-matrix', version: toolVersion() },
|
|
182
|
+
checkedAt: now.toISOString(),
|
|
183
|
+
library: {
|
|
184
|
+
name: library.manifest.name,
|
|
185
|
+
version: library.manifest.version,
|
|
186
|
+
source: library.source,
|
|
187
|
+
compiledWith: summary.compiledWith,
|
|
188
|
+
highestMinVersion: summary.highest?.minVersion,
|
|
189
|
+
highestMinVersionIn: summary.highest ? `${summary.highest.type} (${summary.highest.file})` : undefined,
|
|
190
|
+
declarations: summary.declarations,
|
|
191
|
+
},
|
|
192
|
+
packageManager: pm,
|
|
193
|
+
node: process.version,
|
|
194
|
+
versions,
|
|
195
|
+
findings: findings(versions, library.manifest, angularImports(library.dir)),
|
|
196
|
+
exitCode: exitCode(versions),
|
|
197
|
+
};
|
|
198
|
+
const render = format === 'json' ? renderJson : format === 'markdown' ? renderMarkdown : (r) => renderTerminal(r, values.verbose);
|
|
199
|
+
process.stdout.write((format === 'terminal' ? '\n' : '') + render(report));
|
|
200
|
+
return report.exitCode;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return explain(error);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (!values.keep)
|
|
207
|
+
rmSync(workDir, { recursive: true, force: true, maxRetries: 3 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => process.exit(explain(error)));
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const ANSI = /\u001b\[[0-9;?]*[A-Za-z]|\u001b\][^\u0007]*\u0007/g;
|
|
5
|
+
export function stripAnsi(text) {
|
|
6
|
+
return text.replace(ANSI, '');
|
|
7
|
+
}
|
|
8
|
+
/** Environment every child gets: no prompts, no analytics, no colour codes to strip later. */
|
|
9
|
+
export function quietEnv(extra = {}) {
|
|
10
|
+
return {
|
|
11
|
+
...process.env,
|
|
12
|
+
NG_CLI_ANALYTICS: 'false',
|
|
13
|
+
NG_DISABLE_VERSION_CHECK: '1',
|
|
14
|
+
NO_UPDATE_NOTIFIER: '1',
|
|
15
|
+
NPM_CONFIG_UPDATE_NOTIFIER: 'false',
|
|
16
|
+
NPM_CONFIG_FUND: 'false',
|
|
17
|
+
NPM_CONFIG_AUDIT: 'false',
|
|
18
|
+
FORCE_COLOR: '0',
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function exec(command, args, options) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = options.shell
|
|
26
|
+
? spawn([command, ...args].join(' '), { cwd: options.cwd, env: options.env ?? quietEnv(), shell: true })
|
|
27
|
+
: spawn(command, args, { cwd: options.cwd, env: options.env ?? quietEnv() });
|
|
28
|
+
let output = '';
|
|
29
|
+
let timedOut = false;
|
|
30
|
+
const append = (chunk) => (output += chunk.toString('utf8'));
|
|
31
|
+
child.stdout?.on('data', append);
|
|
32
|
+
child.stderr?.on('data', append);
|
|
33
|
+
const timer = options.timeoutMs
|
|
34
|
+
? setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
killTree(child.pid);
|
|
37
|
+
}, options.timeoutMs)
|
|
38
|
+
: undefined;
|
|
39
|
+
const finish = (code) => {
|
|
40
|
+
if (timer)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve({ code, output: stripAnsi(output), durationMs: Date.now() - started, timedOut });
|
|
43
|
+
};
|
|
44
|
+
child.on('error', (error) => {
|
|
45
|
+
output += `\n${error.message}`;
|
|
46
|
+
finish(127);
|
|
47
|
+
});
|
|
48
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function killTree(pid) {
|
|
52
|
+
if (!pid)
|
|
53
|
+
return;
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 'SIGKILL');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// already gone
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* npm's own CLI script, so npm runs as `node npm-cli.js` without a shell
|
|
68
|
+
* (spawning `npm.cmd` on Windows needs one, and then every argument needs quoting).
|
|
69
|
+
*/
|
|
70
|
+
export function npmCli() {
|
|
71
|
+
const candidates = [
|
|
72
|
+
process.env.npm_execpath,
|
|
73
|
+
join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
74
|
+
join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
75
|
+
];
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (candidate && /npm-cli\.js$/.test(candidate) && existsSync(candidate))
|
|
78
|
+
return [process.execPath, candidate];
|
|
79
|
+
}
|
|
80
|
+
throw new Error("Couldn't find npm next to this Node. ng-matrix needs the npm that ships with Node.");
|
|
81
|
+
}
|
|
82
|
+
export function npm(args, options) {
|
|
83
|
+
const [node, cli] = npmCli();
|
|
84
|
+
return exec(node, [cli, ...args], options);
|
|
85
|
+
}
|
|
86
|
+
/** The first lines that look like errors, with a little context, for reports. */
|
|
87
|
+
export function errorLines(output, max = 12) {
|
|
88
|
+
const lines = output.split(/\r?\n/).map((line) => line.trimEnd());
|
|
89
|
+
// Errors first; warnings (Angular prints NG8113 "not used" ones, say) only when there's nothing else.
|
|
90
|
+
const errors = /(\[ERROR\]|\berror TS\d+|\bERR_PNPM_|npm error|^\s*Error:|✘|✕|^\s*X |\berror NG\d{4}\b|ERESOLVE|unmet peer|invalid: )/i;
|
|
91
|
+
const anything = /(\[WARNING\]|\bNG\d{4}\b|\bwarn)/i;
|
|
92
|
+
for (const marker of [errors, anything]) {
|
|
93
|
+
const picked = [];
|
|
94
|
+
for (let i = 0; i < lines.length && picked.length < max; i++) {
|
|
95
|
+
if (!marker.test(lines[i]))
|
|
96
|
+
continue;
|
|
97
|
+
for (let j = i; j < Math.min(lines.length, i + 6) && picked.length < max; j++) {
|
|
98
|
+
const line = lines[j];
|
|
99
|
+
if (j > i && (errors.test(line) || anything.test(line)) && !marker.test(line))
|
|
100
|
+
break;
|
|
101
|
+
if (line.trim() && !picked.includes(line))
|
|
102
|
+
picked.push(line);
|
|
103
|
+
}
|
|
104
|
+
i += 5;
|
|
105
|
+
}
|
|
106
|
+
if (picked.length)
|
|
107
|
+
return picked;
|
|
108
|
+
}
|
|
109
|
+
return lines.filter((line) => line.trim()).slice(-max);
|
|
110
|
+
}
|