upstreamfix 0.1.2 → 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 CHANGED
@@ -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
- - UpstreamFix does not watch package registries, discover new API changes, or invent migrations. It only previews migration rules already included in the release.
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
- for (const migration of applicable) {
109
- let writeResult;
110
- if (options.write) {
111
- assertWritesEnabled(false);
112
- writeResult = await writeMigrationOnBranch(migration, {
113
- cwd,
114
- allowDirty: options.allowDirty,
115
- nodeRuntime: migration.id === 'firebase-admin@14' ? options.nodeRuntime : undefined,
116
- timeoutMs: (options.verifyTimeout ?? 300) * 1000,
117
- });
118
- commits.push({ branch: writeResult.branch, commit: writeResult.commit });
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
- const outcome = writeResult?.outcome ?? await runMigration(migration, { cwd, dryRun: true, nodeRuntime: migration.id === 'firebase-admin@14' ? options.nodeRuntime : undefined });
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.isInteger(parsed) || parsed < 1)
148
- throw new Error('Timeout must be a positive integer number of seconds.');
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
- const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
157
- if (invokedPath === fileURLToPath(import.meta.url)) {
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
- const message = error instanceof Error ? error.message : String(error);
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
- // A failed command can prepend text on the same line as the first tsc
5
- // diagnostic; begin at the source path, never at the command wrapper.
6
- const pattern = /(?:^|\s)([^\s():]+\.(?:[cm]?tsx?|jsx?))\((\d+),(\d+)\): error TS(\d+): ([^\r\n]+)/g;
7
- for (const match of output.matchAll(pattern)) {
8
- const file = match[1].replaceAll('\\', '/').replace(/^\/workspace\//, '').replace(/^\.\//, '');
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[4]),
14
- message: match[5].trim().replace(/\s+/g, ' '),
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,4 @@
1
+ /** Parse a JSON package manifest while accepting the UTF-8 BOM used by some editors. */
2
+ export function parseManifest(contents) {
3
+ return JSON.parse(contents.replace(/^\uFEFF/, ''));
4
+ }
@@ -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
+ }