upstreamfix 0.1.0 → 0.1.3
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 +21 -4
- package/dist/cli.js +109 -29
- package/dist/core/diagnostic-diff.js +12 -9
- package/dist/core/diff-api.js +211 -0
- package/dist/core/docker-mount.js +6 -0
- package/dist/core/impact.js +72 -0
- package/dist/core/manifest.js +4 -0
- package/dist/core/node-range.js +19 -0
- package/dist/core/probe.js +155 -0
- package/dist/core/project-discovery.js +10 -0
- package/dist/core/report.js +70 -10
- package/dist/core/runner.js +136 -19
- package/dist/core/verify.js +110 -78
- package/dist/core/watch.js +81 -0
- package/dist/core/write-branch.js +45 -27
- package/dist/migrations/firebase-admin-v14/index.js +122 -10
- package/dist/migrations/index.js +3 -7
- package/dist/migrations/stripe-shared.js +75 -9
- package/dist/migrations/stripe-v23/index.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**UpstreamFix previews code changes for supported breaking SDK upgrades, reports cases that need a developer, and can commit verified changes on a dedicated Git branch.**
|
|
4
4
|
|
|
5
|
-
> **Experimental (0.
|
|
5
|
+
> **Experimental (0.x).** Review every proposed change and test your application before using a migration in production. The source code is available in the [public GitHub repository](https://github.com/ivanDev237/upstreamfix).
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[View the illustrative terminal walkthrough](https://github.com/ivanDev237/upstreamfix/blob/main/docs/upstreamfix-demo.gif) of listing, scanning, and previewing a migration.
|
|
10
10
|
|
|
11
11
|
The frames show the intended command flow. Output varies with the project and migration findings.
|
|
12
12
|
|
|
13
13
|
## Quick start
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Start with `npx upstreamfix@latest list`. To inspect a project without changing its files, run:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
18
|
npx --yes upstreamfix@latest list
|
|
@@ -32,10 +32,27 @@ The report path is explicit; preview commands do not create report files unless
|
|
|
32
32
|
|
|
33
33
|
Run `upstreamfix list` for the catalog included in the installed version. Run `upstreamfix scan --cwd <project>` to select migrations from package versions declared in root or nested `package.json` files.
|
|
34
34
|
|
|
35
|
+
## Detecting SDK changes
|
|
36
|
+
|
|
37
|
+
These commands report evidence for review. They do not create migration rules or pull requests.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
upstreamfix watch --state /path/to/upstreamfix-watch.json
|
|
41
|
+
upstreamfix diff-api firebase-admin 13.10.0 14.0.0
|
|
42
|
+
upstreamfix impact --cwd /path/to/project
|
|
43
|
+
upstreamfix probe stripe@23.0.0 --cwd /path/to/project
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`watch` checks the npm `latest` tags of catalog packages. The first run records a baseline; later runs record newly observed **major** versions in the chosen JSON file. It makes small registry requests and updates the file atomically. `diff-api` downloads two exact npm tarballs, verifies their published integrity values, and compares public TypeScript declarations. Removed or changed declarations are clues; apparent renames are explicitly hypotheses. The archive size is capped and no package scripts run.
|
|
47
|
+
|
|
48
|
+
`impact` reads declared versions in root and nested manifests, looks up each catalog package's latest npm version, and estimates automatic versus manual source findings using the catalog's current rules. It does not install dependencies or modify the project. Its counts are estimates, not a compatibility guarantee.
|
|
49
|
+
|
|
50
|
+
`probe` needs a Git project with a root npm lockfile and TypeScript. It shows an estimated cold download first and exits without downloading. Add `--yes` after reviewing that estimate. It copies the project, installs the locked baseline and an exact target SDK version with lifecycle scripts disabled, and runs both compiler checks in a pinned Docker image with no network and a read-only project mount. Existing compiler errors are separated from new candidates. A new error is **not proof** that the SDK caused it. The original files and lockfile remain untouched. pnpm/Yarn projects and projects without a readable TypeScript check currently return "measurement unavailable". Docker and registry access are required only for the confirmed probe.
|
|
51
|
+
|
|
35
52
|
## What is not supported
|
|
36
53
|
|
|
37
54
|
- The GitHub Action exits without changing a repository; automatic pull requests are not available.
|
|
38
|
-
-
|
|
55
|
+
- `watch`, `diff-api`, and `probe` can identify new version and type-change candidates. They do not generate or approve safe migration rules automatically.
|
|
39
56
|
- Only migrations shown by `upstreamfix list` are supported. Unsupported, ambiguous, or semantic API changes require manual work.
|
|
40
57
|
- Lockfile previews cover standalone root projects using npm v3, pnpm, or Yarn Classic lockfiles. Regenerating a lockfile may contact the package registry; pnpm or Yarn must already be installed and match any `packageManager` pin. Workspaces, Yarn Berry, shrinkwrap, project package-manager configuration files, and local or non-registry dependencies are left for manual review.
|
|
41
58
|
|
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError, InvalidArgumentError } from 'commander';
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { renderMarkdownReport, renderTerminalReport } from './core/report.js';
|
|
7
|
+
import { renderMarkdownReport, renderTerminalReport, sanitizeTerminalText } from './core/report.js';
|
|
7
8
|
import { runMigration } from './core/runner.js';
|
|
8
9
|
import { verifyProposedMigration } from './core/verify.js';
|
|
9
10
|
import { writeMigrationOnBranch } from './core/write-branch.js';
|
|
10
11
|
import { assertWritesEnabled } from './core/safety.js';
|
|
11
12
|
import { findApplicableMigrations, migrations } from './migrations/index.js';
|
|
13
|
+
import { watchPackages } from './core/watch.js';
|
|
14
|
+
import { diffPublishedApi } from './core/diff-api.js';
|
|
15
|
+
import { analyzeImpact } from './core/impact.js';
|
|
16
|
+
import { probeProject } from './core/probe.js';
|
|
12
17
|
function existingMigration(value) {
|
|
13
18
|
if (!migrations.some((migration) => migration.id === value)) {
|
|
14
19
|
throw new InvalidArgumentError(`Unknown migration "${value}". Available: ${migrations.map((migration) => migration.id).join(', ')}`);
|
|
@@ -17,11 +22,56 @@ function existingMigration(value) {
|
|
|
17
22
|
}
|
|
18
23
|
export function createProgram() {
|
|
19
24
|
const program = new Command();
|
|
25
|
+
const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
20
26
|
program
|
|
21
27
|
.name('upstreamfix')
|
|
28
|
+
.version(packageVersion)
|
|
22
29
|
.description('Prepare and review automated code migrations for breaking SDK changes.')
|
|
23
30
|
.showHelpAfterError()
|
|
24
31
|
.exitOverride();
|
|
32
|
+
program
|
|
33
|
+
.command('probe')
|
|
34
|
+
.description('Compare TypeScript diagnostics before and after an exact SDK upgrade in a temporary copy.')
|
|
35
|
+
.argument('<package@version>', 'exact target, for example stripe@23.0.0')
|
|
36
|
+
.option('-C, --cwd <directory>', 'Git project directory to probe', process.cwd())
|
|
37
|
+
.option('--timeout <seconds>', 'maximum time per probe command', parsePositiveInteger)
|
|
38
|
+
.option('--yes', 'confirm dependency and Docker image downloads after reviewing the estimate')
|
|
39
|
+
.action(async (target, options) => {
|
|
40
|
+
const result = await probeProject(options.cwd, target, { confirmed: options.yes, ...(options.timeout ? { timeoutMs: options.timeout * 1000 } : {}) });
|
|
41
|
+
console.log(result.estimatedDownload);
|
|
42
|
+
if (result.status === 'confirmation-required') {
|
|
43
|
+
console.log('No downloads started. Re-run with --yes to perform the probe.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log(JSON.stringify(result, null, 2));
|
|
47
|
+
if (result.status === 'unavailable')
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
50
|
+
program
|
|
51
|
+
.command('impact')
|
|
52
|
+
.description('Estimate affected usages of catalog SDK upgrades in a project without writing files.')
|
|
53
|
+
.option('-C, --cwd <directory>', 'project directory to analyze', process.cwd())
|
|
54
|
+
.action(async (options) => {
|
|
55
|
+
console.log(JSON.stringify(await analyzeImpact(options.cwd), null, 2));
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command('diff-api')
|
|
59
|
+
.description('Compare published TypeScript declarations of two exact npm package versions.')
|
|
60
|
+
.argument('<package>', 'npm package name')
|
|
61
|
+
.argument('<old>', 'exact old version')
|
|
62
|
+
.argument('<new>', 'exact new version')
|
|
63
|
+
.action(async (packageName, oldVersion, newVersion) => {
|
|
64
|
+
const report = await diffPublishedApi(packageName, oldVersion, newVersion);
|
|
65
|
+
console.log(JSON.stringify(report, null, 2));
|
|
66
|
+
});
|
|
67
|
+
program
|
|
68
|
+
.command('watch')
|
|
69
|
+
.description('Check npm for new major versions of catalog packages and update a JSON state file.')
|
|
70
|
+
.requiredOption('--state <file>', 'JSON state file to update atomically')
|
|
71
|
+
.action(async (options) => {
|
|
72
|
+
const result = await watchPackages(options.state, migrations.map((migration) => migration.packageName));
|
|
73
|
+
console.log(result.events.length ? JSON.stringify(result.events, null, 2) : 'No new major versions since the previous check.');
|
|
74
|
+
});
|
|
25
75
|
program
|
|
26
76
|
.command('list')
|
|
27
77
|
.description('List the migrations included in this UpstreamFix release.')
|
|
@@ -46,12 +96,15 @@ export function createProgram() {
|
|
|
46
96
|
.option('--verify', 'verify proposed changes in a temporary project copy')
|
|
47
97
|
.option('--node-runtime <major>', 'confirm Firebase Admin v14 will run on Node.js 22+ and add engines.node when absent', parseNodeRuntime)
|
|
48
98
|
.option('--verify-timeout <seconds>', 'maximum time per verification command', parsePositiveInteger)
|
|
99
|
+
.option('--allow-yarn-lock-install', 'allow a full Yarn Classic installation to regenerate yarn.lock (estimated 50–400 MB)')
|
|
49
100
|
.option('--report <file>', 'write the Markdown report to this explicit path (relative paths use --cwd)')
|
|
50
101
|
.action(async (migrationId, options) => {
|
|
51
102
|
const migration = migrations.find((candidate) => candidate.id === migrationId);
|
|
52
103
|
if (options.nodeRuntime && migration.id !== 'firebase-admin@14')
|
|
53
104
|
throw new Error('--node-runtime 22 is supported only for firebase-admin@14.');
|
|
54
105
|
const cwd = path.resolve(options.cwd);
|
|
106
|
+
if (options.allowYarnLockInstall)
|
|
107
|
+
console.log('Yarn Classic lockfile regeneration may download about 50–400 MB without a warm cache.');
|
|
55
108
|
if (options.allowDirty && !options.write)
|
|
56
109
|
throw new Error('--allow-dirty can only be used together with --write.');
|
|
57
110
|
let writeResult;
|
|
@@ -61,10 +114,11 @@ export function createProgram() {
|
|
|
61
114
|
cwd,
|
|
62
115
|
allowDirty: options.allowDirty,
|
|
63
116
|
nodeRuntime: options.nodeRuntime,
|
|
117
|
+
allowYarnLockInstall: options.allowYarnLockInstall,
|
|
64
118
|
timeoutMs: (options.verifyTimeout ?? 300) * 1000,
|
|
65
119
|
});
|
|
66
120
|
}
|
|
67
|
-
const outcome = writeResult?.outcome ?? await runMigration(migration, { cwd, dryRun: true, nodeRuntime: options.nodeRuntime });
|
|
121
|
+
const outcome = writeResult?.outcome ?? await runMigration(migration, { cwd, dryRun: true, nodeRuntime: options.nodeRuntime, allowYarnLockInstall: options.allowYarnLockInstall });
|
|
68
122
|
const verification = writeResult?.verification ?? (options.verify
|
|
69
123
|
? await verifyProposedMigration(cwd, outcome.proposedDiff, { packageName: migration.packageName, fileChanges: outcome.proposedFiles, timeoutMs: (options.verifyTimeout ?? 300) * 1000 })
|
|
70
124
|
: undefined);
|
|
@@ -81,7 +135,7 @@ export function createProgram() {
|
|
|
81
135
|
if (verification?.status === 'failed')
|
|
82
136
|
process.exitCode = 1;
|
|
83
137
|
if (!options.write)
|
|
84
|
-
console.log('Preview only. No project files were written.');
|
|
138
|
+
console.log(options.report ? 'Preview only. Only the requested report file was written.' : 'Preview only. No project files were written.');
|
|
85
139
|
});
|
|
86
140
|
program
|
|
87
141
|
.command('scan')
|
|
@@ -92,9 +146,12 @@ export function createProgram() {
|
|
|
92
146
|
.option('--verify', 'verify each proposed migration in a temporary project copy')
|
|
93
147
|
.option('--node-runtime <major>', 'confirm Firebase Admin v14 will run on Node.js 22+ and add engines.node when absent', parseNodeRuntime)
|
|
94
148
|
.option('--verify-timeout <seconds>', 'maximum time per verification command', parsePositiveInteger)
|
|
149
|
+
.option('--allow-yarn-lock-install', 'allow a full Yarn Classic installation to regenerate yarn.lock (estimated 50–400 MB)')
|
|
95
150
|
.option('--report <file>', 'write Markdown reports to this explicit path (relative paths use --cwd)')
|
|
96
151
|
.action(async (options) => {
|
|
97
152
|
const cwd = path.resolve(options.cwd);
|
|
153
|
+
if (options.allowYarnLockInstall)
|
|
154
|
+
console.log('Yarn Classic lockfile regeneration may download about 50–400 MB without a warm cache.');
|
|
98
155
|
if (options.allowDirty && !options.write)
|
|
99
156
|
throw new Error('--allow-dirty can only be used together with --write.');
|
|
100
157
|
const reports = [];
|
|
@@ -105,26 +162,29 @@ export function createProgram() {
|
|
|
105
162
|
console.log('No supported migration applies to the dependencies declared in this project.');
|
|
106
163
|
return;
|
|
107
164
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
165
|
+
await withCompletedBranchNotice(async (recordCommit) => {
|
|
166
|
+
for (const migration of applicable) {
|
|
167
|
+
let writeResult;
|
|
168
|
+
if (options.write) {
|
|
169
|
+
assertWritesEnabled(false);
|
|
170
|
+
writeResult = await writeMigrationOnBranch(migration, {
|
|
171
|
+
cwd,
|
|
172
|
+
allowDirty: options.allowDirty,
|
|
173
|
+
nodeRuntime: migration.id === 'firebase-admin@14' ? options.nodeRuntime : undefined,
|
|
174
|
+
allowYarnLockInstall: options.allowYarnLockInstall,
|
|
175
|
+
timeoutMs: (options.verifyTimeout ?? 300) * 1000,
|
|
176
|
+
});
|
|
177
|
+
recordCommit({ branch: writeResult.branch, commit: writeResult.commit });
|
|
178
|
+
}
|
|
179
|
+
const outcome = writeResult?.outcome ?? await runMigration(migration, { cwd, dryRun: true, nodeRuntime: migration.id === 'firebase-admin@14' ? options.nodeRuntime : undefined, allowYarnLockInstall: options.allowYarnLockInstall });
|
|
180
|
+
const verification = writeResult?.verification ?? (options.verify
|
|
181
|
+
? await verifyProposedMigration(cwd, outcome.proposedDiff, { packageName: migration.packageName, fileChanges: outcome.proposedFiles, timeoutMs: (options.verifyTimeout ?? 300) * 1000 })
|
|
182
|
+
: undefined);
|
|
183
|
+
if (verification?.status === 'failed')
|
|
184
|
+
verificationFailed = true;
|
|
185
|
+
reports.push(renderMarkdownReport(migration, outcome, !options.write, verification));
|
|
119
186
|
}
|
|
120
|
-
|
|
121
|
-
const verification = writeResult?.verification ?? (options.verify
|
|
122
|
-
? await verifyProposedMigration(cwd, outcome.proposedDiff, { packageName: migration.packageName, fileChanges: outcome.proposedFiles, timeoutMs: (options.verifyTimeout ?? 300) * 1000 })
|
|
123
|
-
: undefined);
|
|
124
|
-
if (verification?.status === 'failed')
|
|
125
|
-
verificationFailed = true;
|
|
126
|
-
reports.push(renderMarkdownReport(migration, outcome, !options.write, verification));
|
|
127
|
-
}
|
|
187
|
+
}, commits);
|
|
128
188
|
const report = reports.join('\n---\n\n');
|
|
129
189
|
console.log(renderTerminalReport(report));
|
|
130
190
|
if (options.report) {
|
|
@@ -138,14 +198,28 @@ export function createProgram() {
|
|
|
138
198
|
if (verificationFailed)
|
|
139
199
|
process.exitCode = 1;
|
|
140
200
|
if (!options.write)
|
|
141
|
-
console.log('Preview only. No project files were written.');
|
|
201
|
+
console.log(options.report ? 'Preview only. Only the requested report file was written.' : 'Preview only. No project files were written.');
|
|
142
202
|
});
|
|
143
203
|
return program;
|
|
144
204
|
}
|
|
205
|
+
export function completedBranchNotice(commits) {
|
|
206
|
+
return `The scan stopped after creating these branches:\n${commits.map((item) => `- ${item.branch}: ${item.commit}`).join('\n')}`;
|
|
207
|
+
}
|
|
208
|
+
export async function withCompletedBranchNotice(work, commits = []) {
|
|
209
|
+
try {
|
|
210
|
+
return await work((commit) => commits.push(commit));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (commits.length > 0)
|
|
214
|
+
console.error(completedBranchNotice(commits));
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
145
218
|
function parsePositiveInteger(value) {
|
|
146
219
|
const parsed = Number(value);
|
|
147
|
-
if (!Number.
|
|
148
|
-
throw new
|
|
220
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 86_400) {
|
|
221
|
+
throw new InvalidArgumentError('Verification timeout must be an integer from 1 to at most 86400 seconds (24 hours).');
|
|
222
|
+
}
|
|
149
223
|
return parsed;
|
|
150
224
|
}
|
|
151
225
|
function parseNodeRuntime(value) {
|
|
@@ -153,15 +227,21 @@ function parseNodeRuntime(value) {
|
|
|
153
227
|
throw new InvalidArgumentError('Only --node-runtime 22 is supported for firebase-admin@14.');
|
|
154
228
|
return 22;
|
|
155
229
|
}
|
|
156
|
-
|
|
157
|
-
|
|
230
|
+
let invokedPath = '';
|
|
231
|
+
export function safeCliError(error) {
|
|
232
|
+
return `upstreamfix: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}`;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
236
|
+
}
|
|
237
|
+
catch { /* An unresolved argv path is not this CLI. */ }
|
|
238
|
+
if (invokedPath === realpathSync(fileURLToPath(import.meta.url))) {
|
|
158
239
|
createProgram().parseAsync().catch((error) => {
|
|
159
240
|
if (error instanceof CommanderError && error.exitCode === 0) {
|
|
160
241
|
process.exitCode = 0;
|
|
161
242
|
return;
|
|
162
243
|
}
|
|
163
|
-
|
|
164
|
-
console.error(`upstreamfix: ${message}`);
|
|
244
|
+
console.error(safeCliError(error));
|
|
165
245
|
process.exitCode = 1;
|
|
166
246
|
});
|
|
167
247
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/** Parse the ordinary, non-pretty `tsc` output without executing project code. */
|
|
2
2
|
export function parseTypeScriptDiagnostics(output) {
|
|
3
3
|
const diagnostics = [];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const clean = output.replace(/\u001b\[[0-9;]*m/g, '');
|
|
5
|
+
const pattern = /^(.+?\.(?:[cm]?tsx?|jsx?))(?:\((\d+),(\d+)\):|:(\d+):(\d+)\s+-) error TS(\d+): (.+)$/;
|
|
6
|
+
for (const line of clean.split(/\r?\n/)) {
|
|
7
|
+
const match = pattern.exec(line);
|
|
8
|
+
if (!match)
|
|
9
|
+
continue;
|
|
10
|
+
const rawPath = match[1].trim().replace(/^npm error\s+/, '');
|
|
11
|
+
const file = rawPath.replaceAll('\\', '/').replace(/^\/workspace\//, '').replace(/^\.\//, '');
|
|
9
12
|
diagnostics.push({
|
|
10
13
|
file,
|
|
11
|
-
line: Number(match[2]),
|
|
12
|
-
column: Number(match[3]),
|
|
13
|
-
code: Number(match[
|
|
14
|
-
message: match[
|
|
14
|
+
line: Number(match[2] ?? match[4]),
|
|
15
|
+
column: Number(match[3] ?? match[5]),
|
|
16
|
+
code: Number(match[6]),
|
|
17
|
+
message: match[7].trim().replace(/\s+/g, ' '),
|
|
15
18
|
});
|
|
16
19
|
}
|
|
17
20
|
return diagnostics;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gunzipSync } from 'node:zlib';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
5
|
+
import { readBoundedJson } from './watch.js';
|
|
6
|
+
const maxArchiveBytes = 15 * 1024 * 1024;
|
|
7
|
+
const maxUnpackedBytes = 40 * 1024 * 1024;
|
|
8
|
+
export function validateExactVersion(version) {
|
|
9
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(version)) {
|
|
10
|
+
throw new Error(`An exact npm version is required; received ${version}.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function tarString(buffer, start, length) {
|
|
14
|
+
return buffer.subarray(start, start + length).toString('utf8').split('\0')[0].trim();
|
|
15
|
+
}
|
|
16
|
+
export function unpackDeclarationFiles(archive, integrity) {
|
|
17
|
+
if (archive.length > maxArchiveBytes)
|
|
18
|
+
throw new Error('Package archive exceeds the size limit.');
|
|
19
|
+
if (integrity) {
|
|
20
|
+
const match = /^(sha512|sha1)-([A-Za-z0-9+/=]+)$/.exec(integrity);
|
|
21
|
+
if (!match || createHash(match[1]).update(archive).digest('base64') !== match[2])
|
|
22
|
+
throw new Error('Package archive integrity mismatch.');
|
|
23
|
+
}
|
|
24
|
+
const unpacked = gunzipSync(archive, { maxOutputLength: maxUnpackedBytes });
|
|
25
|
+
const files = new Map();
|
|
26
|
+
let nextPath;
|
|
27
|
+
for (let offset = 0; offset + 512 <= unpacked.length;) {
|
|
28
|
+
const header = unpacked.subarray(offset, offset + 512);
|
|
29
|
+
if (header.every((byte) => byte === 0))
|
|
30
|
+
break;
|
|
31
|
+
const headerName = [tarString(header, 345, 155), tarString(header, 0, 100)].filter(Boolean).join('/');
|
|
32
|
+
const size = Number.parseInt(tarString(header, 124, 12), 8);
|
|
33
|
+
const kind = tarString(header, 156, 1) || '0';
|
|
34
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > unpacked.length)
|
|
35
|
+
throw new Error('Invalid package archive entry size.');
|
|
36
|
+
const body = unpacked.subarray(offset + 512, offset + 512 + size);
|
|
37
|
+
if (kind === 'L') {
|
|
38
|
+
nextPath = body.toString('utf8').split('\0')[0];
|
|
39
|
+
}
|
|
40
|
+
else if (kind === 'x') {
|
|
41
|
+
const records = body.toString('utf8');
|
|
42
|
+
const pathRecord = /(?:^|\n)\d+ path=([^\n]+)/.exec(records);
|
|
43
|
+
if (pathRecord)
|
|
44
|
+
nextPath = pathRecord[1];
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const name = nextPath ?? headerName;
|
|
48
|
+
nextPath = undefined;
|
|
49
|
+
const parts = name.split('/');
|
|
50
|
+
if (parts.some((part) => part === '..' || part === '.') || name.startsWith('/') || name.includes('\\') || /^[A-Za-z]:/.test(name)) {
|
|
51
|
+
throw new Error(`Package archive has an unsafe path: ${name}`);
|
|
52
|
+
}
|
|
53
|
+
if (!['0', '5'].includes(kind))
|
|
54
|
+
throw new Error(`Package archive contains an unsupported link or entry: ${name}`);
|
|
55
|
+
const relative = name.startsWith('package/') ? name.slice('package/'.length) : name;
|
|
56
|
+
if (kind === '0' && (/\.d\.(?:ts|mts|cts)$/.test(relative) || relative === 'package.json')) {
|
|
57
|
+
files.set(relative, body.toString('utf8'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
61
|
+
}
|
|
62
|
+
if (!files.has('package.json'))
|
|
63
|
+
throw new Error('Package archive has no package.json.');
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
function declaredEntries(files) {
|
|
67
|
+
const manifest = JSON.parse(files.get('package.json'));
|
|
68
|
+
const candidates = new Set();
|
|
69
|
+
if (typeof manifest.types === 'string')
|
|
70
|
+
candidates.add(manifest.types);
|
|
71
|
+
if (typeof manifest.typings === 'string')
|
|
72
|
+
candidates.add(manifest.typings);
|
|
73
|
+
if (typeof manifest.main === 'string')
|
|
74
|
+
candidates.add(manifest.main);
|
|
75
|
+
function visit(value) {
|
|
76
|
+
if (!value || typeof value !== 'object')
|
|
77
|
+
return;
|
|
78
|
+
for (const [key, item] of Object.entries(value)) {
|
|
79
|
+
if (key === 'types' && typeof item === 'string')
|
|
80
|
+
candidates.add(item);
|
|
81
|
+
else
|
|
82
|
+
visit(item);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
visit(manifest.exports);
|
|
86
|
+
visit(manifest.typesVersions);
|
|
87
|
+
if (candidates.size === 0 && files.has('index.d.ts'))
|
|
88
|
+
candidates.add('index.d.ts');
|
|
89
|
+
const resolved = new Set();
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
const entry = path.posix.normalize(candidate.replace(/^\.\//, ''));
|
|
92
|
+
if (entry.startsWith('../') || path.posix.isAbsolute(entry))
|
|
93
|
+
continue;
|
|
94
|
+
for (const form of [entry, `${entry}.d.ts`, `${entry}.d.mts`, `${entry}.d.cts`, path.posix.join(entry, 'index.d.ts'), entry.replace(/\.[cm]?js$/, '.d.ts')]) {
|
|
95
|
+
if (files.has(form))
|
|
96
|
+
resolved.add(form);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...resolved];
|
|
100
|
+
}
|
|
101
|
+
function declarations(files) {
|
|
102
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, compilerOptions: { noResolve: false } });
|
|
103
|
+
for (const [name, content] of files) {
|
|
104
|
+
if (/\.d\.(?:ts|mts|cts)$/.test(name))
|
|
105
|
+
project.createSourceFile(`/package/${name}`, content, { overwrite: true });
|
|
106
|
+
}
|
|
107
|
+
const symbols = new Map();
|
|
108
|
+
for (const entry of declaredEntries(files)) {
|
|
109
|
+
const source = project.getSourceFile(`/package/${entry}`);
|
|
110
|
+
if (!source)
|
|
111
|
+
continue;
|
|
112
|
+
const prefix = entry === 'index.d.ts' ? '' : `${entry}:`;
|
|
113
|
+
const collect = (declarations, scope) => {
|
|
114
|
+
for (const [name, nodes] of declarations) {
|
|
115
|
+
if (nodes.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
const key = `${scope}${name}`;
|
|
118
|
+
symbols.set(key, { symbol: key, after: nodes.map((node) => node.getText().replace(/\s+/g, ' ').trim()).join(' | '), source: entry });
|
|
119
|
+
const members = new Map();
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
if (!Node.isInterfaceDeclaration(node) && !Node.isClassDeclaration(node))
|
|
122
|
+
continue;
|
|
123
|
+
for (const member of node.getMembers()) {
|
|
124
|
+
const memberName = member.getName?.();
|
|
125
|
+
if (!memberName)
|
|
126
|
+
continue;
|
|
127
|
+
members.set(memberName, [...(members.get(memberName) ?? []), member.getText().replace(/\s+/g, ' ').trim()]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const [memberName, texts] of members) {
|
|
131
|
+
const memberKey = `${key}.${memberName}`;
|
|
132
|
+
symbols.set(memberKey, { symbol: memberKey, after: texts.join(' | '), source: entry });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
collect(source.getExportedDeclarations(), prefix);
|
|
137
|
+
for (const module of source.getDescendantsOfKind(SyntaxKind.ModuleDeclaration)) {
|
|
138
|
+
collect(module.getExportedDeclarations(), `${prefix}${module.getName()}.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return symbols;
|
|
142
|
+
}
|
|
143
|
+
export function compareApiFromTarballs(packageName, oldVersion, newVersion, oldArchive, newArchive, oldIntegrity, newIntegrity) {
|
|
144
|
+
const before = declarations(unpackDeclarationFiles(oldArchive, oldIntegrity));
|
|
145
|
+
const after = declarations(unpackDeclarationFiles(newArchive, newIntegrity));
|
|
146
|
+
if (before.size === 0 || after.size === 0)
|
|
147
|
+
throw new Error('No exported type symbols were resolved from one or both package archives; the API comparison is incomplete.');
|
|
148
|
+
const removed = [];
|
|
149
|
+
const changed = [];
|
|
150
|
+
const added = [];
|
|
151
|
+
for (const [symbol, previous] of before) {
|
|
152
|
+
const current = after.get(symbol);
|
|
153
|
+
if (!current)
|
|
154
|
+
removed.push({ ...previous, before: previous.after, after: undefined });
|
|
155
|
+
else if (previous.after !== current.after)
|
|
156
|
+
changed.push({ symbol, before: previous.after, after: current.after, source: current.source });
|
|
157
|
+
}
|
|
158
|
+
for (const [symbol, current] of after)
|
|
159
|
+
if (!before.has(symbol))
|
|
160
|
+
added.push(current);
|
|
161
|
+
const normalize = (finding, declaration) => declaration?.replace(finding.symbol.split('.').at(-1), '') ?? '';
|
|
162
|
+
const addedByText = new Map();
|
|
163
|
+
for (const candidate of added) {
|
|
164
|
+
const key = normalize(candidate, candidate.after);
|
|
165
|
+
addedByText.set(key, [...(addedByText.get(key) ?? []), candidate]);
|
|
166
|
+
}
|
|
167
|
+
const renameHypotheses = [];
|
|
168
|
+
for (const old of removed) {
|
|
169
|
+
for (const candidate of addedByText.get(normalize(old, old.before)) ?? []) {
|
|
170
|
+
renameHypotheses.push({ from: old.symbol, to: candidate.symbol, reason: 'Similar declaration text; hypothesis only, verify against vendor guidance.' });
|
|
171
|
+
if (renameHypotheses.length >= 1000)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
if (renameHypotheses.length >= 1000)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
return { package: packageName, oldVersion, newVersion, removed, changed, added, renameHypotheses };
|
|
178
|
+
}
|
|
179
|
+
async function boundedArchive(url, fetcher) {
|
|
180
|
+
const parsed = new URL(url);
|
|
181
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== 'registry.npmjs.org' || parsed.port || parsed.username || parsed.password) {
|
|
182
|
+
throw new Error('The package tarball must be served directly by the npm registry over HTTPS.');
|
|
183
|
+
}
|
|
184
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(20_000), redirect: 'error' });
|
|
185
|
+
if (!response.ok)
|
|
186
|
+
throw new Error(`Package download failed (${response.status}).`);
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let bytes = 0;
|
|
189
|
+
for await (const chunk of response.body ?? []) {
|
|
190
|
+
bytes += chunk.byteLength;
|
|
191
|
+
if (bytes > maxArchiveBytes)
|
|
192
|
+
throw new Error('Package archive exceeds the size limit.');
|
|
193
|
+
chunks.push(Buffer.from(chunk));
|
|
194
|
+
}
|
|
195
|
+
return Buffer.concat(chunks);
|
|
196
|
+
}
|
|
197
|
+
export async function diffPublishedApi(packageName, oldVersion, newVersion, fetcher = fetch) {
|
|
198
|
+
if (!/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(packageName))
|
|
199
|
+
throw new Error('Invalid npm package name.');
|
|
200
|
+
validateExactVersion(oldVersion);
|
|
201
|
+
validateExactVersion(newVersion);
|
|
202
|
+
const load = async (version) => {
|
|
203
|
+
const metadata = await readBoundedJson(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${version}`, undefined, fetcher);
|
|
204
|
+
if (!metadata.dist?.tarball || !metadata.dist.integrity)
|
|
205
|
+
throw new Error(`Missing tarball integrity for ${packageName}@${version}.`);
|
|
206
|
+
return { archive: await boundedArchive(metadata.dist.tarball, fetcher), integrity: metadata.dist.integrity };
|
|
207
|
+
};
|
|
208
|
+
const old = await load(oldVersion);
|
|
209
|
+
const next = await load(newVersion);
|
|
210
|
+
return compareApiFromTarballs(packageName, oldVersion, newVersion, old.archive, next.archive, old.integrity, next.integrity);
|
|
211
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Docker's --mount uses comma-separated fields; --volume keeps commas in host paths literal. */
|
|
2
|
+
export function dockerBindArgs(source, target, readonly = false) {
|
|
3
|
+
if (source.includes(','))
|
|
4
|
+
return ['--volume', `${source}:${target}${readonly ? ':ro' : ''}`];
|
|
5
|
+
return ['--mount', `type=bind,source=${source},target=${target}${readonly ? ',readonly' : ''}`];
|
|
6
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { IGNORED_PROJECT_DIRECTORIES, isSupportedSourceMajor } from './project-discovery.js';
|
|
4
|
+
import { runMigration } from './runner.js';
|
|
5
|
+
import { npmLatestVersion } from './watch.js';
|
|
6
|
+
import { migrations } from '../migrations/index.js';
|
|
7
|
+
import { parseManifest } from './manifest.js';
|
|
8
|
+
async function declaredVersions(root) {
|
|
9
|
+
const found = new Map();
|
|
10
|
+
const warnings = [];
|
|
11
|
+
async function visit(directory) {
|
|
12
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13
|
+
if (entry.isSymbolicLink())
|
|
14
|
+
continue;
|
|
15
|
+
const absolute = path.join(directory, entry.name);
|
|
16
|
+
if (entry.isDirectory() && !IGNORED_PROJECT_DIRECTORIES.has(entry.name))
|
|
17
|
+
await visit(absolute);
|
|
18
|
+
if (entry.isFile() && entry.name === 'package.json') {
|
|
19
|
+
let manifest;
|
|
20
|
+
try {
|
|
21
|
+
manifest = parseManifest(await readFile(absolute, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
warnings.push(`Invalid manifest: ${path.relative(root, absolute)}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
28
|
+
for (const [name, version] of Object.entries(manifest[section] ?? {})) {
|
|
29
|
+
if (typeof version !== 'string')
|
|
30
|
+
continue;
|
|
31
|
+
if (section === 'peerDependencies' && ['dependencies', 'devDependencies', 'optionalDependencies'].some((runtime) => typeof manifest[runtime]?.[name] === 'string'))
|
|
32
|
+
continue;
|
|
33
|
+
const versions = found.get(name) ?? new Set();
|
|
34
|
+
versions.add(version);
|
|
35
|
+
found.set(name, versions);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await visit(root);
|
|
42
|
+
return { found, warnings };
|
|
43
|
+
}
|
|
44
|
+
export async function analyzeImpact(cwd, lookup = npmLatestVersion) {
|
|
45
|
+
const { found: versions, warnings } = await declaredVersions(path.resolve(cwd));
|
|
46
|
+
const packages = [...new Set(migrations.map((migration) => migration.packageName))].filter((name) => versions.has(name)).sort();
|
|
47
|
+
const results = [];
|
|
48
|
+
for (const packageName of packages) {
|
|
49
|
+
const declared = [...versions.get(packageName)].sort();
|
|
50
|
+
const declaredVersion = declared.join(', ');
|
|
51
|
+
const latestVersion = await lookup(packageName);
|
|
52
|
+
if (!/^\d+\.\d+\.\d+/.test(latestVersion))
|
|
53
|
+
throw new Error(`Invalid registry version for ${packageName}.`);
|
|
54
|
+
const latestMajor = Number(latestVersion.split('.')[0]);
|
|
55
|
+
if (declared.length > 1) {
|
|
56
|
+
results.push({ package: packageName, declaredVersion, latestVersion, latestMajor, affected: 0, automatic: 0, manual: 0, automaticShare: null,
|
|
57
|
+
note: `Multiple declared versions need separate review; no migration rate was inferred. ${warnings.join('; ')}` });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const migration = migrations.find((candidate) => candidate.packageName === packageName && isSupportedSourceMajor(declared[0], candidate.fromMajor));
|
|
61
|
+
if (!migration) {
|
|
62
|
+
results.push({ package: packageName, declaredVersion, latestVersion, latestMajor, affected: 0, automatic: 0, manual: 0, automaticShare: null, note: `No catalog migration matches this declared version; affected usages are unknown. ${warnings.join('; ')}`.trim() });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const outcome = await runMigration(migration, { cwd, dryRun: true, analysisOnly: true });
|
|
66
|
+
const automatic = outcome.changes.length;
|
|
67
|
+
const manual = outcome.manual.length;
|
|
68
|
+
const affected = automatic + manual;
|
|
69
|
+
results.push({ package: packageName, declaredVersion, latestVersion, latestMajor, migration: migration.id, affected, automatic, manual, automaticShare: affected ? automatic / affected : null, ...(warnings.length ? { note: warnings.join('; ') } : {}) });
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Conservative check that every supported arm of an engines.node range starts at or above a required major. */
|
|
2
|
+
export function guaranteesNodeMajor(range, minimum) {
|
|
3
|
+
const arms = range.trim().split(/\s*\|\|\s*/);
|
|
4
|
+
return arms.length > 0 && arms.every((arm) => {
|
|
5
|
+
const parts = arm.trim().replace(/^(>=|>|\^|~)\s+(?=\d)/, '$1').split(/\s+/);
|
|
6
|
+
const first = /^(>=|>|\^|~)?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/.exec(parts[0] ?? '');
|
|
7
|
+
if (!first)
|
|
8
|
+
return false;
|
|
9
|
+
const major = Number(first[2]);
|
|
10
|
+
if (major < minimum)
|
|
11
|
+
return false;
|
|
12
|
+
if (parts.length === 1)
|
|
13
|
+
return true;
|
|
14
|
+
return parts.slice(1).every((part) => {
|
|
15
|
+
const bound = /^(<|<=)(\d+)(?:\.\d+){0,2}$/.exec(part);
|
|
16
|
+
return Boolean(bound && Number(bound[2]) > minimum);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|