upstreamfix 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UpstreamFix contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # UpstreamFix
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ > **Experimental (0.1.0).** 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
+
7
+ ## Demo
8
+
9
+ ![Illustrative terminal walkthrough of listing, scanning, and previewing a migration](docs/upstreamfix-demo.gif)
10
+
11
+ The frames show the intended command flow. Output varies with the project and migration findings.
12
+
13
+ ## Quick start
14
+
15
+ Once a release is available on npm, start with `npx upstreamfix@latest list`. To inspect a project without changing its files, run:
16
+
17
+ ```sh
18
+ npx --yes upstreamfix@latest list
19
+ npx --yes upstreamfix@latest scan --cwd /path/to/your/project
20
+ npx --yes upstreamfix@latest migrate firebase-admin@14 --cwd /path/to/your/project --report /tmp/firebase-admin-v14.md
21
+ ```
22
+
23
+ The report path is explicit; preview commands do not create report files unless `--report` is supplied. The Firebase Admin migration currently targets projects using the namespaced API and proposes modular API changes. Review the diff and manual findings against the [official migration guide](https://firebase.google.com/docs/admin/migrate-node-v10).
24
+
25
+ ## Supported migrations
26
+
27
+ | Migration | What it previews | Status |
28
+ |---|---|---|
29
+ | `firebase-admin@14` | Firebase Admin SDK v13 namespaced API to v14 modular API | Benchmarked on 12 valid public repositories; 316 of 340 detected change groups migrated automatically. Manual findings and runtime prerequisites still require review. |
30
+ | `stripe@22` | Stripe Node SDK v21 to v22 | Benchmarked on 6 installable public repositories; payment-sensitive cases remain manual. See [Stripe's v22 guide](https://github.com/stripe/stripe-node/wiki/Migration-guide-for-v22). |
31
+ | `stripe@23` | Stripe Node SDK v22 to v23 | Benchmarked on 4 public repositories; a removed payment parameter was identified for manual review. See [Stripe's v23 guide](https://github.com/stripe/stripe-node/wiki/Migration-guide-for-v23). |
32
+
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
+
35
+ ## What is not supported
36
+
37
+ - 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.
39
+ - Only migrations shown by `upstreamfix list` are supported. Unsupported, ambiguous, or semantic API changes require manual work.
40
+ - 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
+
42
+ ## Safety and data
43
+
44
+ UpstreamFix runs locally. It reads source files, package manifests, and supported lockfiles in the selected project. It does not upload project source to an UpstreamFix service. A preview can invoke npm, pnpm, or Yarn to regenerate a supported lockfile; this may contact the package registry, but the updater uses a fresh temporary home and does not pass your environment tokens or package-manager config. Preview does not write project files unless you explicitly request a report with `--report`.
45
+
46
+ Optional `--verify` requires a Git working tree and copies tracked or non-ignored files into a size-limited temporary directory. It excludes common credential files, package-manager configuration, and symlinks, then installs dependencies with scripts disabled. Configured typecheck and test scripts still execute on your machine with a restricted environment; they are project code, can run arbitrary commands, and can access the network. Only verify projects and scripts you trust, and avoid projects whose tests can access production services or perform external side effects. Dependency installation can contact the package registry. The original project directory is left unchanged.
47
+
48
+ After reviewing a preview, `--write` verifies the migration in an isolated Git worktree and commits it on a new `upstreamfix/<migration>` branch. It refuses non-Git projects, dirty trees (unless `--allow-dirty` is supplied), manual findings, missing or failed verification checks, and source changes whose containing manifest does not declare the target SDK major. It never switches or edits your current working tree. Verification still runs project scripts on your machine, so use it only with projects you trust. No branch is merged or pushed automatically.
49
+
50
+ Firebase Admin v14 requires Node.js 22 or newer. If your project omits `engines.node`, the preview explains why the dependency cannot yet be upgraded. Once you have confirmed your deployment runtime, add `"engines": { "node": ">=22" }` yourself or pass `--node-runtime 22` to `migrate firebase-admin@14` (including with `--write`). That option proposes the Node range alongside the SDK update. It never overrides an existing incompatible `engines.node` range.
51
+
52
+ ## Contributing
53
+
54
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, test guidance, and how to add a migration. Security reports belong in the private reporting process described in [SECURITY.md](SECURITY.md). UpstreamFix is licensed under the [MIT License](LICENSE).
55
+
56
+ ## Development
57
+
58
+ Requires Node.js 22.12 or newer.
59
+
60
+ ```sh
61
+ npm ci
62
+ npm run typecheck
63
+ npm test
64
+ ```
65
+
66
+ To build and run the CLI from this checkout:
67
+
68
+ ```sh
69
+ npm run build
70
+ node dist/cli.js list
71
+ node dist/cli.js migrate firebase-admin@14 --cwd /path/to/fixture
72
+ ```
73
+
74
+ Add a migration scaffold with `npm run new-migration -- <npm-package> <target-major>`. Its real-SDK compatibility test is opt-in because it downloads SDK versions: set `UPSTREAMFIX_RUN_REAL_SDK_TESTS=1` when running that generated test.
package/dist/cli.js ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { renderMarkdownReport, renderTerminalReport } from './core/report.js';
7
+ import { runMigration } from './core/runner.js';
8
+ import { verifyProposedMigration } from './core/verify.js';
9
+ import { writeMigrationOnBranch } from './core/write-branch.js';
10
+ import { assertWritesEnabled } from './core/safety.js';
11
+ import { findApplicableMigrations, migrations } from './migrations/index.js';
12
+ function existingMigration(value) {
13
+ if (!migrations.some((migration) => migration.id === value)) {
14
+ throw new InvalidArgumentError(`Unknown migration "${value}". Available: ${migrations.map((migration) => migration.id).join(', ')}`);
15
+ }
16
+ return value;
17
+ }
18
+ export function createProgram() {
19
+ const program = new Command();
20
+ program
21
+ .name('upstreamfix')
22
+ .description('Prepare and review automated code migrations for breaking SDK changes.')
23
+ .showHelpAfterError()
24
+ .exitOverride();
25
+ program
26
+ .command('list')
27
+ .description('List the migrations included in this UpstreamFix release.')
28
+ .action(() => {
29
+ if (migrations.length === 0) {
30
+ console.log('No migrations are available in this release.');
31
+ return;
32
+ }
33
+ console.log('| Migration | Package | From → To | Released | Automated rules |');
34
+ console.log('|---|---|---:|---|---|');
35
+ for (const migration of migrations) {
36
+ console.log(`| ${migration.id} | ${migration.packageName} | ${migration.fromMajor} → ${migration.toMajor} | ${migration.releasedAt} | ${migration.rules.join(', ')} |`);
37
+ }
38
+ });
39
+ program
40
+ .command('migrate')
41
+ .description('Run a supported migration against a project.')
42
+ .argument('<migration>', 'migration identifier, for example firebase-admin@14', existingMigration)
43
+ .option('-C, --cwd <directory>', 'project directory to migrate', process.cwd())
44
+ .option('--write', 'apply verified changes in a new Git branch and create a commit')
45
+ .option('--allow-dirty', 'allow a dirty working tree; the migration branch is based on committed HEAD')
46
+ .option('--verify', 'verify proposed changes in a temporary project copy')
47
+ .option('--node-runtime <major>', 'confirm Firebase Admin v14 will run on Node.js 22+ and add engines.node when absent', parseNodeRuntime)
48
+ .option('--verify-timeout <seconds>', 'maximum time per verification command', parsePositiveInteger)
49
+ .option('--report <file>', 'write the Markdown report to this explicit path (relative paths use --cwd)')
50
+ .action(async (migrationId, options) => {
51
+ const migration = migrations.find((candidate) => candidate.id === migrationId);
52
+ if (options.nodeRuntime && migration.id !== 'firebase-admin@14')
53
+ throw new Error('--node-runtime 22 is supported only for firebase-admin@14.');
54
+ const cwd = path.resolve(options.cwd);
55
+ if (options.allowDirty && !options.write)
56
+ throw new Error('--allow-dirty can only be used together with --write.');
57
+ let writeResult;
58
+ if (options.write) {
59
+ assertWritesEnabled(false);
60
+ writeResult = await writeMigrationOnBranch(migration, {
61
+ cwd,
62
+ allowDirty: options.allowDirty,
63
+ nodeRuntime: options.nodeRuntime,
64
+ timeoutMs: (options.verifyTimeout ?? 300) * 1000,
65
+ });
66
+ }
67
+ const outcome = writeResult?.outcome ?? await runMigration(migration, { cwd, dryRun: true, nodeRuntime: options.nodeRuntime });
68
+ const verification = writeResult?.verification ?? (options.verify
69
+ ? await verifyProposedMigration(cwd, outcome.proposedDiff, { packageName: migration.packageName, fileChanges: outcome.proposedFiles, timeoutMs: (options.verifyTimeout ?? 300) * 1000 })
70
+ : undefined);
71
+ const report = renderMarkdownReport(migration, outcome, !options.write, verification);
72
+ console.log(renderTerminalReport(report));
73
+ if (options.report) {
74
+ const reportPath = path.resolve(cwd, options.report);
75
+ await mkdir(path.dirname(reportPath), { recursive: true });
76
+ await writeFile(reportPath, report, 'utf8');
77
+ console.log(`Report saved to ${path.relative(cwd, reportPath) || path.basename(reportPath)}`);
78
+ }
79
+ if (writeResult)
80
+ console.log(`Committed on branch ${writeResult.branch}: ${writeResult.commit}`);
81
+ if (verification?.status === 'failed')
82
+ process.exitCode = 1;
83
+ if (!options.write)
84
+ console.log('Preview only. No project files were written.');
85
+ });
86
+ program
87
+ .command('scan')
88
+ .description('Run supported migrations that match dependencies declared in a project.')
89
+ .option('-C, --cwd <directory>', 'project directory to scan', process.cwd())
90
+ .option('--write', 'apply each verified migration in a new Git branch and create a commit')
91
+ .option('--allow-dirty', 'allow a dirty working tree; migration branches use committed HEAD')
92
+ .option('--verify', 'verify each proposed migration in a temporary project copy')
93
+ .option('--node-runtime <major>', 'confirm Firebase Admin v14 will run on Node.js 22+ and add engines.node when absent', parseNodeRuntime)
94
+ .option('--verify-timeout <seconds>', 'maximum time per verification command', parsePositiveInteger)
95
+ .option('--report <file>', 'write Markdown reports to this explicit path (relative paths use --cwd)')
96
+ .action(async (options) => {
97
+ const cwd = path.resolve(options.cwd);
98
+ if (options.allowDirty && !options.write)
99
+ throw new Error('--allow-dirty can only be used together with --write.');
100
+ const reports = [];
101
+ const commits = [];
102
+ let verificationFailed = false;
103
+ const applicable = await findApplicableMigrations(cwd);
104
+ if (applicable.length === 0) {
105
+ console.log('No supported migration applies to the dependencies declared in this project.');
106
+ return;
107
+ }
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 });
119
+ }
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
+ }
128
+ const report = reports.join('\n---\n\n');
129
+ console.log(renderTerminalReport(report));
130
+ if (options.report) {
131
+ const reportPath = path.resolve(cwd, options.report);
132
+ await mkdir(path.dirname(reportPath), { recursive: true });
133
+ await writeFile(reportPath, report, 'utf8');
134
+ console.log(`Report saved to ${path.relative(cwd, reportPath) || path.basename(reportPath)}`);
135
+ }
136
+ for (const commit of commits)
137
+ console.log(`Committed on branch ${commit.branch}: ${commit.commit}`);
138
+ if (verificationFailed)
139
+ process.exitCode = 1;
140
+ if (!options.write)
141
+ console.log('Preview only. No project files were written.');
142
+ });
143
+ return program;
144
+ }
145
+ function parsePositiveInteger(value) {
146
+ const parsed = Number(value);
147
+ if (!Number.isInteger(parsed) || parsed < 1)
148
+ throw new Error('Timeout must be a positive integer number of seconds.');
149
+ return parsed;
150
+ }
151
+ function parseNodeRuntime(value) {
152
+ if (value !== '22')
153
+ throw new InvalidArgumentError('Only --node-runtime 22 is supported for firebase-admin@14.');
154
+ return 22;
155
+ }
156
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
157
+ if (invokedPath === fileURLToPath(import.meta.url)) {
158
+ createProgram().parseAsync().catch((error) => {
159
+ if (error instanceof CommanderError && error.exitCode === 0) {
160
+ process.exitCode = 0;
161
+ return;
162
+ }
163
+ const message = error instanceof Error ? error.message : String(error);
164
+ console.error(`upstreamfix: ${message}`);
165
+ process.exitCode = 1;
166
+ });
167
+ }
@@ -0,0 +1,41 @@
1
+ /** Parse the ordinary, non-pretty `tsc` output without executing project code. */
2
+ export function parseTypeScriptDiagnostics(output) {
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(/^\.\//, '');
9
+ diagnostics.push({
10
+ file,
11
+ line: Number(match[2]),
12
+ column: Number(match[3]),
13
+ code: Number(match[4]),
14
+ message: match[5].trim().replace(/\s+/g, ' '),
15
+ });
16
+ }
17
+ return diagnostics;
18
+ }
19
+ /**
20
+ * Compare diagnostic multisets by file, error code and message. Positions are
21
+ * intentionally excluded: a harmless preceding edit must not create a finding.
22
+ * These are candidates only; a changed SDK is not necessarily the cause.
23
+ */
24
+ export function newTypeScriptDiagnostics(before, after) {
25
+ const existing = new Map();
26
+ for (const diagnostic of before) {
27
+ const key = diagnosticKey(diagnostic);
28
+ existing.set(key, (existing.get(key) ?? 0) + 1);
29
+ }
30
+ return after.filter((diagnostic) => {
31
+ const key = diagnosticKey(diagnostic);
32
+ const previous = existing.get(key) ?? 0;
33
+ if (previous === 0)
34
+ return true;
35
+ existing.set(key, previous - 1);
36
+ return false;
37
+ });
38
+ }
39
+ function diagnosticKey(diagnostic) {
40
+ return `${diagnostic.file}\0${diagnostic.code}\0${diagnostic.message}`;
41
+ }
@@ -0,0 +1,77 @@
1
+ const processGroups = new Set();
2
+ const temporaryCleanups = new Set();
3
+ let handlersInstalled = false;
4
+ let shutdownStarted = false;
5
+ export function trackProcessGroup(pid) {
6
+ processGroups.add(pid);
7
+ installSignalHandlers();
8
+ return () => {
9
+ processGroups.delete(pid);
10
+ uninstallSignalHandlersIfIdle();
11
+ };
12
+ }
13
+ export function registerTemporaryCleanup(cleanup) {
14
+ temporaryCleanups.add(cleanup);
15
+ installSignalHandlers();
16
+ return () => {
17
+ temporaryCleanups.delete(cleanup);
18
+ uninstallSignalHandlersIfIdle();
19
+ };
20
+ }
21
+ export async function terminateProcessGroup(pid, graceMs = 100) {
22
+ signalGroup(pid, 'SIGTERM');
23
+ if (graceMs > 0)
24
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
25
+ signalGroup(pid, 'SIGKILL');
26
+ processGroups.delete(pid);
27
+ }
28
+ function signalGroup(pid, signal) {
29
+ try {
30
+ if (process.platform === 'win32')
31
+ process.kill(pid, signal);
32
+ else
33
+ process.kill(-pid, signal);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'ESRCH') {
37
+ try {
38
+ process.kill(pid, signal);
39
+ }
40
+ catch { /* The process already exited. */ }
41
+ }
42
+ }
43
+ }
44
+ function installSignalHandlers() {
45
+ if (handlersInstalled)
46
+ return;
47
+ process.on('SIGINT', handleSigint);
48
+ process.on('SIGTERM', handleSigterm);
49
+ handlersInstalled = true;
50
+ }
51
+ function uninstallSignalHandlersIfIdle() {
52
+ if (!handlersInstalled || processGroups.size > 0 || temporaryCleanups.size > 0)
53
+ return;
54
+ process.off('SIGINT', handleSigint);
55
+ process.off('SIGTERM', handleSigterm);
56
+ handlersInstalled = false;
57
+ }
58
+ function handleSigint() {
59
+ void shutdown('SIGINT');
60
+ }
61
+ function handleSigterm() {
62
+ void shutdown('SIGTERM');
63
+ }
64
+ async function shutdown(signal) {
65
+ if (shutdownStarted)
66
+ return;
67
+ shutdownStarted = true;
68
+ const pids = [...processGroups];
69
+ for (const pid of pids)
70
+ signalGroup(pid, 'SIGTERM');
71
+ await new Promise((resolve) => setTimeout(resolve, 150));
72
+ for (const pid of pids)
73
+ signalGroup(pid, 'SIGKILL');
74
+ processGroups.clear();
75
+ await Promise.allSettled([...temporaryCleanups].map((cleanup) => cleanup()));
76
+ process.exit(signal === 'SIGINT' ? 130 : 143);
77
+ }
@@ -0,0 +1,131 @@
1
+ export function renderMarkdownReport(migration, outcome, dryRun, verification) {
2
+ const lines = [];
3
+ const total = outcome.changes.length + outcome.manual.length;
4
+ lines.push(`# Migration report: ${migration.title}`);
5
+ lines.push('');
6
+ if (dryRun)
7
+ lines.push('> Dry run: no file was written.\n');
8
+ lines.push(`- Findings: **${total}**`);
9
+ lines.push(`- ${dryRun ? 'Proposed transformations' : 'Applied transformations'}: **${outcome.changes.length}**`);
10
+ lines.push(`- Need your attention: **${outcome.manual.length}**`);
11
+ lines.push(`- ${dryRun ? 'Files with proposed changes' : 'Files changed'}: **${outcome.filesChanged.length}**`);
12
+ lines.push(`- Official guide: ${migration.guideUrl}`);
13
+ lines.push('');
14
+ if (total === 0) {
15
+ lines.push('No supported Firebase Admin namespace usages were found in the scanned files.');
16
+ lines.push('');
17
+ }
18
+ if (outcome.manual.length > 0) {
19
+ lines.push('## Needs your attention');
20
+ lines.push('');
21
+ for (const item of outcome.manual) {
22
+ lines.push(`- ${inlineCode(`${item.file}:${item.line}`)} ${escapeMarkdownText(item.reason).replace(/\r?\n/g, ' ')}`);
23
+ lines.push(` ${inlineCode(item.snippet)}`);
24
+ }
25
+ lines.push('');
26
+ }
27
+ if (outcome.manifestNotes.length > 0) {
28
+ lines.push('## package.json');
29
+ lines.push('');
30
+ for (const note of outcome.manifestNotes)
31
+ lines.push(`- ${escapeMarkdownText(note).replace(/\r?\n/g, ' ')}`);
32
+ lines.push('');
33
+ }
34
+ if (verification) {
35
+ lines.push('## Verification');
36
+ lines.push('');
37
+ lines.push(`- Result: **${verification.status}**`);
38
+ lines.push('- Scope: temporary project copy; the original project was not changed.');
39
+ lines.push('- Verification commands run on this machine with a restricted environment, without copied credentials or package install scripts; project test code is still executable code and is not network-isolated.');
40
+ if (verification.note)
41
+ lines.push(`- Note: ${verification.note}`);
42
+ if (verification.checks.length > 0) {
43
+ lines.push('');
44
+ lines.push('| Check | Command | Result | Duration | Output |');
45
+ lines.push('|---|---|---|---:|---|');
46
+ for (const check of verification.checks) {
47
+ lines.push(`| ${markdownCell(check.name)} | ${inlineCode(check.command)} | ${check.status} | ${(check.durationMs / 1000).toFixed(2)}s | ${markdownCell(check.output || '—')} |`);
48
+ }
49
+ }
50
+ lines.push('');
51
+ }
52
+ if (outcome.changes.length > 0) {
53
+ lines.push(dryRun ? '## Proposed transformations' : '## Applied transformations');
54
+ lines.push('');
55
+ lines.push('| Location | Rule | Before | After |');
56
+ lines.push('|---|---|---|---|');
57
+ for (const c of outcome.changes) {
58
+ lines.push(`| ${inlineCode(`${c.file}:${c.line}`)} | ${markdownCell(c.rule)} | ${markdownCell(c.before)} | ${markdownCell(c.after)} |`);
59
+ }
60
+ lines.push('');
61
+ }
62
+ if (outcome.proposedDiff.length > 0) {
63
+ lines.push(dryRun ? '## Proposed diff (not applied)' : '## Applied diff');
64
+ lines.push('');
65
+ for (const diff of outcome.proposedDiff) {
66
+ const longestBacktickRun = Math.max(0, ...[...diff.matchAll(/`+/g)].map(([run]) => run.length));
67
+ const fence = '`'.repeat(Math.max(4, longestBacktickRun + 1));
68
+ lines.push(`${fence}diff`);
69
+ lines.push(diff);
70
+ lines.push(fence);
71
+ lines.push('');
72
+ }
73
+ }
74
+ lines.push('## Next steps');
75
+ lines.push('');
76
+ if (dryRun) {
77
+ lines.push('1. Review the preview and the official migration guide before applying changes to a repository.');
78
+ lines.push('2. After review, use --write to verify and commit supported changes on a new Git branch. The command refuses manual findings and failed or missing checks.');
79
+ lines.push('3. Use the official migration guide and handle the items listed under "Needs your attention" manually.');
80
+ }
81
+ else {
82
+ lines.push('1. Review the applied changes (`git diff`).');
83
+ lines.push('2. Reinstall dependencies and run your type checker and tests.');
84
+ lines.push('3. Handle the items listed under "Needs your attention".');
85
+ }
86
+ lines.push('');
87
+ const raw = lines.join('\n');
88
+ const sanitized = maskSecrets(raw);
89
+ return sanitized === raw ? raw : `${sanitized}\n> Warning: possible credentials were detected in the report and redacted. Review the source before sharing this report.`;
90
+ }
91
+ /** Redact common provider credentials and private-key material from every report field. */
92
+ export function maskSecrets(value) {
93
+ return value
94
+ .replace(/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g, '[PRIVATE KEY REDACTED]')
95
+ .replace(/((?:"|\b)private_key(?:"|\b)\s*:\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^,\r\n}]+)/gi, '$1"[REDACTED]"')
96
+ .replace(/\b(?:sk|rk|pk)\\?_(?:live|test)\\?_(?:[A-Za-z0-9-]|\\?_){8,}\b/gi, '[API key redacted]')
97
+ .replace(/\bwhsec\\?_(?:[A-Za-z0-9-]|\\?_){8,}\b/gi, '[webhook secret redacted]')
98
+ .replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[API key redacted]');
99
+ }
100
+ /** Keep the Markdown artifact intact while making prose readable in a terminal. */
101
+ export function renderTerminalReport(markdown) {
102
+ let inFence = false;
103
+ const readable = markdown.split('\n').map((line) => {
104
+ if (/^`{4,}/.test(line)) {
105
+ inFence = !inFence;
106
+ return line;
107
+ }
108
+ if (inFence || !line.startsWith('- '))
109
+ return line;
110
+ return line
111
+ .replace(/&lt;/g, '<')
112
+ .replace(/&gt;/g, '>')
113
+ .replace(/&amp;/g, '&')
114
+ .replace(/\\([\\`*_{}\[\]()#+.!])/g, '$1');
115
+ }).join('\n');
116
+ return maskSecrets(readable);
117
+ }
118
+ function markdownCell(value) {
119
+ return escapeMarkdownText(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
120
+ }
121
+ function escapeMarkdownText(value) {
122
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
123
+ .replace(/([\\`*_{}\[\]()#+.!])/g, '\\$1');
124
+ }
125
+ function inlineCode(value) {
126
+ const normalized = value.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
127
+ const runs = [...normalized.matchAll(/`+/g)].map(([run]) => run.length);
128
+ const delimiter = '`'.repeat(Math.max(1, ...runs) + 1);
129
+ const needsPadding = normalized.startsWith('`') || normalized.endsWith('`') || /^\s|\s$/.test(normalized);
130
+ return `${delimiter}${needsPadding ? ' ' : ''}${normalized}${needsPadding ? ' ' : ''}${delimiter}`;
131
+ }