aztrx-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "path";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
3
5
  import pc from "picocolors";
4
6
  import { program } from "commander";
7
+ import { opt, formatHelp } from "./cli/help.js";
5
8
  import { run } from "./core/orchestrator.js";
6
9
  import { EventBus } from "./core/eventBus.js";
7
10
  import { renderTui } from "./ui/app.js";
@@ -11,10 +14,41 @@ import { writePrComment } from "./core/pr.js";
11
14
  import { writeBadge } from "./core/badge.js";
12
15
  import { flushTelemetry } from "./core/telemetry/index.js";
13
16
  import { flushCloud } from "./core/cloud/index.js";
17
+ import { summarizeFindings } from "./core/summarize.js";
18
+ import { applyVerifiedPatches } from "./core/heal/apply.js";
19
+ import { promptYesNo } from "./core/prompt.js";
20
+ import { modernizeFile } from "./core/modernize.js";
14
21
  function collect(value, prev) {
15
22
  prev.push(value);
16
23
  return prev;
17
24
  }
25
+ /** Auto-size the swarm to the machine's CPU cores, capped so we never oversubscribe. */
26
+ function autoWorkers() {
27
+ const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
28
+ return Math.max(1, Math.min(n, 8));
29
+ }
30
+ /** Print one low-key "next flag" hint after a run, so users learn the advanced
31
+ * flags on demand instead of memorizing the whole surface. Fires only in the
32
+ * plain-log path when there's a finding worth acting on. */
33
+ function suggestNext(findings, opts) {
34
+ if (opts.dryRun || opts.crashTest || findings.length === 0)
35
+ return;
36
+ const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
37
+ const alreadyFixing = opts.heal || opts.fix || opts.magicFix;
38
+ if (crashOrError && !alreadyFixing) {
39
+ if (opts.repro) {
40
+ console.log(pc.dim("Tip: run with --fix to attempt a closed-loop fix"));
41
+ }
42
+ else {
43
+ console.log(pc.dim("Tip: run with --repro to prove these with a runnable spec, or --fix to fix them end-to-end"));
44
+ }
45
+ return;
46
+ }
47
+ const serverError = findings.some((f) => f.type === "network_5xx");
48
+ if (serverError && !opts.httpFuzz) {
49
+ console.log(pc.dim("Tip: run with --http-fuzz to map the server-side attack surface"));
50
+ }
51
+ }
18
52
  program
19
53
  .name("aztrx-cli")
20
54
  .description("Runtime stress-testing for web apps — detect bugs, prove them with a repro")
@@ -43,42 +77,90 @@ program
43
77
  .action((opts) => {
44
78
  startStudio({ repoRoot: path.resolve(program.opts().repo), port: parseInt(opts.port, 10) });
45
79
  });
80
+ program
81
+ .command("modernize")
82
+ .description("rewrite a legacy JS/TS file into modern idiomatic syntax (LLM)")
83
+ .argument("<file>", "path to the file to modernize")
84
+ .option("-y, --yes", "apply without prompting")
85
+ .action(async (file, opts) => {
86
+ const repoRoot = path.resolve(program.opts().repo);
87
+ const rel = path.relative(repoRoot, path.resolve(file));
88
+ const res = await modernizeFile(repoRoot, file);
89
+ if (!res.ok) {
90
+ console.error(pc.red("modernize failed:") + ` ${res.error}`);
91
+ process.exit(1);
92
+ }
93
+ for (const c of res.changes)
94
+ console.log(pc.green(" ✓") + ` ${c}`);
95
+ const doApply = await promptYesNo(`Apply modernized version to ${rel}? (y/N)`, { yes: opts.yes });
96
+ if (doApply) {
97
+ fs.writeFileSync(path.resolve(file), res.modernized, "utf-8");
98
+ console.log(pc.dim(` Applied → review with: git diff ${rel}`));
99
+ }
100
+ else {
101
+ console.log(pc.dim(" Not applied."));
102
+ }
103
+ });
46
104
  program
47
105
  .command("run", { isDefault: true })
48
106
  .description("inspect a running app and prove its bugs with an executable repro")
49
107
  .argument("<url>", "dev server to inspect, e.g. http://localhost:3000")
50
- .option("--repo <path>", "project root to inspect/watch (default: cwd)")
51
- .option("--max-actions <n>", "max actions per pass", "100")
52
- .option("--dry-run", "report what would be clicked without clicking")
53
- .option("--crash-test", "throw a deliberate error to verify capture")
54
- .option("--fail-on", "exit 1 if any crash/error finding is present")
55
- .option("--fuzz", "chaos fuzzing instead of the deterministic walk (F5)")
56
- .option("--http-fuzz", "HTTP-layer mutation fuzzing hostile requests against the target origin (F5-http)")
57
- .option("--http-fuzz-mutations", "with --http-fuzz: also send POST/PUT body mutations (default: GET-only)")
58
- .option("--seed <n>", "RNG seed for fuzz", "42")
59
- .option("--repro", "minimize + compile + validate each finding (F7-F9)")
60
- .option("--repro-runs <n>", "replay iterations for the flake-rate gate", "3")
61
- .option("--heal", "closed-loop healing for crash/error findings (implies --repro)")
62
- .option("--heal-model <model>", "LLM model for healing the fallback tier (default claude-sonnet-5)")
63
- .option("--heal-fast-model <model>", "fast/cheap first tier for the smart router (default claude-haiku-4-5)")
64
- .option("--test-command <cmd>", "test command run against a healed patch (default: npm test, auto-detected)")
65
- .option("--test-timeout <ms>", "timeout for the heal test gate, ms", "300000")
66
- .option("--no-test", "skip the test gate during healing")
67
- .option("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)")
68
- .option("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)")
69
- .option("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)")
70
- .option("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)")
71
- .option("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint")
72
- .option("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)")
73
- .option("--api-key <key>", "API key for --upload / --share-data (defaults to $AZTRX_API_KEY)")
74
- .option("--cloud-url <url>", "override the cloud ingest base URL (default https://api.aztrx.app)")
75
- .option("--allow-host <host>", "add a host to the network allow-list (repeatable)", collect, [])
76
- .option("--storage-state <path>", "path to a Playwright storage-state JSON (cookies/localStorage) for authenticated pages")
77
- .option("--auth <path>", "alias for --storage-state")
78
- .option("--plain", "disable the live terminal UI, print plain logs (default when piped)")
79
- .option("--ui", "force the live terminal UI even when stdout is not a TTY")
108
+ .configureHelp({ formatHelp })
109
+ .addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
110
+ .addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
111
+ .addOption(opt("--dry-run", "report what would be clicked without clicking", "detect"))
112
+ .addOption(opt("--crash-test", "throw a deliberate error to verify capture", "advanced"))
113
+ .addOption(opt("--fail-on", "exit 1 if any crash/error finding is present", "ship"))
114
+ .addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk (F5)", "detect"))
115
+ .addOption(opt("--http-fuzz", "HTTP-layer mutation fuzzing hostile requests against the target origin (F5-http)", "detect"))
116
+ .addOption(opt("--http-fuzz-mutations", "with --http-fuzz: also send POST/PUT body mutations (default: GET-only)", "advanced"))
117
+ .addOption(opt("--seed <n>", "RNG seed for fuzz", "advanced").default("42"))
118
+ .addOption(opt("--workers <n>", "number of parallel detection workers (default 1)", "detect"))
119
+ .addOption(opt("--swarm", "auto-size the swarm to the machine's CPU cores (alias: --workers auto)").hideHelp())
120
+ .addOption(opt("--repro", "minimize + compile + validate each finding (F7-F9)", "prove"))
121
+ .addOption(opt("--repro-runs <n>", "replay iterations for the flake-rate gate", "advanced").default("3"))
122
+ .addOption(opt("--fix", "find explain heal apply: one-command fix", "fix"))
123
+ .addOption(opt("--heal", "closed-loop healing for crash/error findings (implies --repro)", "fix"))
124
+ .addOption(opt("--magic-fix", "alias for --fix (deprecated)").hideHelp())
125
+ .addOption(opt("--heal-model <model>", "LLM model for healing the fallback tier (default claude-sonnet-5)", "advanced"))
126
+ .addOption(opt("--heal-fast-model <model>", "fast/cheap first tier for the smart router (default claude-haiku-4-5)", "advanced"))
127
+ .addOption(opt("--test-command <cmd>", "test command run against a healed patch (default: npm test, auto-detected)", "advanced"))
128
+ .addOption(opt("--test-timeout <ms>", "timeout for the heal test gate, ms", "advanced").default("300000"))
129
+ .addOption(opt("--no-test", "skip the test gate during healing", "advanced"))
130
+ .addOption(opt("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)", "advanced"))
131
+ .addOption(opt("--explain", "print a human-language summary of the findings (no healing)", "fix"))
132
+ .addOption(opt("-y, --yes", "auto-apply verified fixes without prompting (with --fix)", "fix"))
133
+ .addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
134
+ .addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
135
+ .addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
136
+ .addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
137
+ .addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
138
+ .addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
139
+ .addOption(opt("--api-key <key>", "API key for --upload / --share-data (defaults to $AZTRX_API_KEY)", "advanced"))
140
+ .addOption(opt("--cloud-url <url>", "override the cloud ingest base URL (default https://api.aztrx.app)", "advanced"))
141
+ .addOption(opt("--allow-host <host>", "add a host to the network allow-list (repeatable)", "advanced").argParser(collect).default([]))
142
+ .addOption(opt("--storage-state <path>", "path to a Playwright storage-state JSON (cookies/localStorage) for authenticated pages", "auth"))
143
+ .addOption(opt("--auth <path>", "alias for --storage-state").hideHelp())
144
+ .addOption(opt("--login", "auto-login before the pass (needs AZTRX_AUTH_EMAIL/AZTRX_AUTH_PASSWORD env)", "auth"))
145
+ .addOption(opt("--login-email <email>", "email for --login (default: $AZTRX_AUTH_EMAIL)").hideHelp())
146
+ .addOption(opt("--login-password <pass>", "password for --login (default: $AZTRX_AUTH_PASSWORD)").hideHelp())
147
+ .addOption(opt("--login-url <url>", "explicit login page URL for --login (default: current page)").hideHelp())
148
+ .addOption(opt("--plain", "disable the live terminal UI, print plain logs (default when piped)", "advanced"))
149
+ .addOption(opt("--ui", "force the live terminal UI even when stdout is not a TTY", "advanced"))
80
150
  .action(async (url, opts) => {
151
+ // `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
152
+ const magicFix = opts.magicFix || opts.fix;
81
153
  const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
154
+ const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
155
+ const mode = (workers ?? 1) > 1 || opts.httpFuzz
156
+ ? `swarm (${workers ?? 1} worker${(workers ?? 1) === 1 ? "" : "s"})`
157
+ : opts.fuzz
158
+ ? `fuzz (seed ${opts.seed})`
159
+ : opts.heal
160
+ ? "repro → heal"
161
+ : opts.repro
162
+ ? "repro"
163
+ : "deterministic walk";
82
164
  const runOpts = {
83
165
  url,
84
166
  repoRoot,
@@ -88,11 +170,12 @@ program
88
170
  fuzz: opts.fuzz,
89
171
  httpFuzz: opts.httpFuzz,
90
172
  httpFuzzMutations: opts.httpFuzzMutations,
91
- repro: opts.repro || opts.heal,
173
+ repro: opts.repro || opts.heal || magicFix,
92
174
  seed: parseInt(opts.seed, 10),
175
+ workers,
93
176
  allowHosts: opts.allowHost ?? [],
94
177
  reproRuns: parseInt(opts.reproRuns, 10),
95
- heal: opts.heal,
178
+ heal: opts.heal || magicFix,
96
179
  healModel: opts.healModel,
97
180
  healFastModel: opts.healFastModel,
98
181
  testCommand: opts.testCommand,
@@ -105,6 +188,10 @@ program
105
188
  apiKey: opts.apiKey,
106
189
  cloudUrl: opts.cloudUrl,
107
190
  storageState: opts.storageState ?? opts.auth,
191
+ login: opts.login,
192
+ loginEmail: opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL,
193
+ loginPassword: opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD,
194
+ loginUrl: opts.loginUrl,
108
195
  };
109
196
  const failOn = Boolean(opts.failOn);
110
197
  const useUi = !opts.plain && (process.stdout.isTTY === true || opts.ui === true);
@@ -117,7 +204,7 @@ program
117
204
  done: runPromise,
118
205
  targetUrl: url,
119
206
  repoRoot,
120
- mode: opts.fuzz ? `fuzz (seed ${opts.seed})` : opts.httpFuzz ? "http fuzz" : opts.heal ? "repro → heal" : opts.repro ? "repro" : "deterministic walk",
207
+ mode,
121
208
  });
122
209
  try {
123
210
  findings = await runPromise;
@@ -144,6 +231,36 @@ program
144
231
  writeBadge(repoRoot, findings, badgePath);
145
232
  console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
146
233
  }
234
+ // F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
235
+ // The run already printed its structured output; this layer explains it and,
236
+ // under `--fix`, offers to apply the verified patches so `git diff`
237
+ // shows the result. Never commits.
238
+ if (magicFix || opts.explain) {
239
+ const summary = await summarizeFindings(findings, { lang: opts.lang });
240
+ console.log("\n" + summary);
241
+ }
242
+ if (magicFix) {
243
+ const healed = findings.filter((f) => f.heal?.status === "healed");
244
+ if (healed.length > 0) {
245
+ const doApply = await promptYesNo(`Apply ${healed.length} verified fix${healed.length === 1 ? "" : "es"} to the working tree? (y/N)`, { yes: opts.yes });
246
+ if (doApply) {
247
+ const result = applyVerifiedPatches(repoRoot, findings);
248
+ for (const a of result.applied) {
249
+ console.log(pc.green(" ✓ applied") + ` ${a.filePath} (${a.hunkCount} edit${a.hunkCount === 1 ? "" : "s"})`);
250
+ }
251
+ for (const c of result.conflicts) {
252
+ console.log(pc.yellow(" ◐ skipped") + ` ${c.filePath}: ${c.error}`);
253
+ }
254
+ console.log(pc.dim(" Review with: git diff"));
255
+ }
256
+ else {
257
+ console.log(pc.dim(" Not applied — review the .patch files under .aztrx/heal/."));
258
+ }
259
+ }
260
+ }
261
+ if (!useUi) {
262
+ suggestNext(findings, opts);
263
+ }
147
264
  // Drain any in-flight telemetry uploads (each bounded) before exit, so a
148
265
  // pending `--share-data` dispatch isn't killed mid-flight. Never affects
149
266
  // the exit code.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * F-auth — auto-login. Locates a login form and drives it with the user's
3
+ * credentials, so the rest of the run (and its repros) exercise the app
4
+ * *authenticated*. Best-effort by design: a login that can't be found or
5
+ * doesn't complete is reported, never fatal — the run continues unauth.
6
+ *
7
+ * The password input is the reliable signal for a login form; the email/username
8
+ * field and submit button are resolved within its nearest `<form>` (or the page
9
+ * body when the app doesn't use a `<form>` element).
10
+ */
11
+ /** Locate a login form on the current page, or `null` if none is present. */
12
+ export async function findLoginForm(page) {
13
+ const password = page.locator('input[type="password"]').first();
14
+ if ((await password.count()) === 0)
15
+ return null;
16
+ const form = password.locator("xpath=ancestor::form[1]");
17
+ const scope = (await form.count()) > 0 ? form : page.locator("body");
18
+ const emailCandidates = [
19
+ scope.locator('input[type="email"]'),
20
+ scope.locator('input[autocomplete="username"]'),
21
+ scope.locator('input[name*="email" i]'),
22
+ scope.locator('input[name*="user" i]'),
23
+ scope.locator('input[name*="login" i]'),
24
+ scope.locator('input[type="text"]'),
25
+ ];
26
+ let email = null;
27
+ for (const c of emailCandidates) {
28
+ if ((await c.count()) > 0) {
29
+ email = c.first();
30
+ break;
31
+ }
32
+ }
33
+ if (!email)
34
+ return null;
35
+ const submitCandidates = [
36
+ scope.locator('button[type="submit"]'),
37
+ scope.locator('input[type="submit"]'),
38
+ ];
39
+ let submit = null;
40
+ for (const c of submitCandidates) {
41
+ if ((await c.count()) > 0) {
42
+ submit = c.first();
43
+ break;
44
+ }
45
+ }
46
+ return { email, password, submit };
47
+ }
48
+ /**
49
+ * Drive the login form. Returns whether it appeared to complete: the password
50
+ * field vanished, or the URL changed. Never throws — every step degrades.
51
+ */
52
+ export async function establishLogin(page, opts) {
53
+ if (opts.loginUrl) {
54
+ await page.goto(opts.loginUrl, { waitUntil: "load", timeout: 30000 }).catch(() => { });
55
+ await page.waitForTimeout(1000);
56
+ }
57
+ const form = await findLoginForm(page);
58
+ if (!form)
59
+ return { ok: false, reason: "no login form detected" };
60
+ await form.email.fill(opts.email).catch(() => { });
61
+ await form.password.fill(opts.password).catch(() => { });
62
+ const urlBefore = page.url();
63
+ if (form.submit) {
64
+ await form.submit.click({ timeout: 3000 }).catch(() => { });
65
+ }
66
+ else {
67
+ await form.password.press("Enter").catch(() => { });
68
+ }
69
+ await page.waitForLoadState("domcontentloaded").catch(() => { });
70
+ await page.waitForTimeout(1500);
71
+ const stillHasPassword = (await page.locator('input[type="password"]').count()) > 0;
72
+ const urlChanged = page.url() !== urlBefore;
73
+ if (!stillHasPassword || urlChanged)
74
+ return { ok: true };
75
+ return { ok: false, reason: "login did not appear to complete" };
76
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * F13 — the only place Aztrx writes a verified patch into the user's *working
3
+ * tree*. Healing itself stays sandboxed (see `index.ts`); this module is the
4
+ * explicit, opt-in "apply" step behind `--fix`, so the recipe's promised
5
+ * `git diff` shows real changes.
6
+ *
7
+ * Rules that keep this safe:
8
+ * - Only findings whose heal result is `healed` (verified: bug gone) are applied.
9
+ * - The file is re-read *per finding* (several findings can touch one file),
10
+ * and each patch is applied with exact-match hunks. A mismatch is a conflict
11
+ * and is skipped — its `.patch` artifact stays for manual review.
12
+ * - Paths are confined to `repoRoot`; anything escaping it is refused.
13
+ * - Aztrx never commits. This writes working-tree files only.
14
+ */
15
+ import * as fs from "fs";
16
+ import * as path from "path";
17
+ import { applyHunks } from "./sandbox.js";
18
+ export function applyVerifiedPatches(repoRoot, findings) {
19
+ const applied = [];
20
+ const conflicts = [];
21
+ const root = path.resolve(repoRoot);
22
+ for (const f of findings) {
23
+ if (f.heal?.status !== "healed")
24
+ continue;
25
+ const filePath = f.heal.filePath;
26
+ if (!filePath) {
27
+ conflicts.push({ filePath, error: "no source file recorded" });
28
+ continue;
29
+ }
30
+ const abs = path.resolve(root, filePath);
31
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
32
+ conflicts.push({ filePath, error: `refusing to write outside repo: ${filePath}` });
33
+ continue;
34
+ }
35
+ let current;
36
+ try {
37
+ current = fs.readFileSync(abs, "utf-8");
38
+ }
39
+ catch (e) {
40
+ conflicts.push({ filePath, error: `cannot read: ${e.message}` });
41
+ continue;
42
+ }
43
+ const result = applyHunks(current, f.heal.hunks);
44
+ if (!result.ok) {
45
+ conflicts.push({ filePath, error: result.errors.join("; ") });
46
+ continue;
47
+ }
48
+ fs.writeFileSync(abs, result.patched, "utf-8");
49
+ applied.push({ filePath, hunkCount: result.applied });
50
+ }
51
+ return { applied, conflicts };
52
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * F-modernize — the "code translator". Rewrites a legacy JS/TS file into modern
3
+ * idiomatic form (const/let over var, async/await over callbacks and promise
4
+ * chains, arrow functions, optional chaining) while preserving behavior. This is
5
+ * a *static* transform, unlike the rest of Aztrx's runtime detection, so it's its
6
+ * own command rather than a `run` flag.
7
+ *
8
+ * Safety model: the model's output is gated by a re-parse (`ts.transpileModule`
9
+ * reports syntax errors without running a full tsc), and the caller applies it to
10
+ * the working tree only after the user confirms — never automatically.
11
+ */
12
+ import * as fs from "fs";
13
+ import * as path from "path";
14
+ import * as ts from "typescript";
15
+ const MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
16
+ const API_URL = "https://api.anthropic.com/v1/messages";
17
+ export function detectLang(filePath) {
18
+ const ext = path.extname(filePath).toLowerCase();
19
+ if ([".ts", ".tsx", ".mts", ".cts"].includes(ext))
20
+ return "ts";
21
+ if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext))
22
+ return "js";
23
+ return null;
24
+ }
25
+ /** Syntax gate: does the output still parse? In-process (no tsc subprocess). */
26
+ export function parseGate(source, lang) {
27
+ const result = ts.transpileModule(source, {
28
+ compilerOptions: {
29
+ target: ts.ScriptTarget.ES2022,
30
+ module: ts.ModuleKind.ESNext,
31
+ allowJs: true,
32
+ jsx: ts.JsxEmit.Preserve,
33
+ },
34
+ reportDiagnostics: true,
35
+ });
36
+ const errors = (result.diagnostics ?? [])
37
+ .filter((d) => d.category === ts.DiagnosticCategory.Error)
38
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
39
+ return { ok: errors.length === 0, errors };
40
+ }
41
+ const SYSTEM = "You are a careful code-modernization engineer. You rewrite legacy JavaScript/TypeScript into modern idiomatic form while preserving behavior exactly. You never change logic, control flow, or behavior — only syntax and idioms.";
42
+ function buildPrompt(source, lang) {
43
+ const language = lang === "ts" ? "TypeScript" : "JavaScript";
44
+ return [
45
+ `Rewrite the following ${language} file into modern idiomatic form:`,
46
+ `- prefer const/let over var`,
47
+ `- prefer async/await over callbacks and promise .then chains`,
48
+ `- prefer arrow functions, optional chaining, and nullish coalescing where they do not change behavior`,
49
+ `- do NOT change any logic, control flow, or behavior — only modernize syntax and idioms`,
50
+ `- do NOT add imports; only remove an import if it is genuinely unused`,
51
+ ``,
52
+ `Return ONLY a JSON object, no markdown fences, no prose. Shape:`,
53
+ `{ "modernized": "<the full modernized file content>", "changes": ["short human-readable change", "..."] }`,
54
+ ``,
55
+ `--- file (${language}) ---`,
56
+ source,
57
+ `--- end file ---`,
58
+ ].join("\n");
59
+ }
60
+ function parseReply(raw) {
61
+ let text = raw.trim();
62
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
63
+ if (fence)
64
+ text = fence[1].trim();
65
+ const start = text.indexOf("{");
66
+ const end = text.lastIndexOf("}");
67
+ if (start >= 0 && end > start)
68
+ text = text.slice(start, end + 1);
69
+ const data = JSON.parse(text);
70
+ const modernized = typeof data.modernized === "string" ? data.modernized : "";
71
+ const changes = Array.isArray(data.changes)
72
+ ? data.changes.filter((c) => typeof c === "string").slice(0, 20)
73
+ : [];
74
+ return { modernized, changes };
75
+ }
76
+ export async function modernizeFile(repoRoot, filePath) {
77
+ const lang = detectLang(filePath);
78
+ if (!lang) {
79
+ return { ok: false, original: "", changes: [], error: `unsupported file type (only JS/TS): ${filePath}` };
80
+ }
81
+ const abs = path.resolve(repoRoot, filePath);
82
+ let original;
83
+ try {
84
+ original = fs.readFileSync(abs, "utf-8");
85
+ }
86
+ catch (e) {
87
+ return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
88
+ }
89
+ const key = process.env.ANTHROPIC_API_KEY;
90
+ if (!key) {
91
+ return { ok: false, original, changes: [], lang, error: "ANTHROPIC_API_KEY is not set" };
92
+ }
93
+ let reply;
94
+ try {
95
+ const res = await fetch(API_URL, {
96
+ method: "POST",
97
+ headers: {
98
+ "content-type": "application/json",
99
+ "x-api-key": key,
100
+ "anthropic-version": "2023-06-01",
101
+ },
102
+ body: JSON.stringify({
103
+ model: MODEL,
104
+ max_tokens: 8192,
105
+ temperature: 0,
106
+ system: SYSTEM,
107
+ messages: [{ role: "user", content: buildPrompt(original, lang) }],
108
+ }),
109
+ });
110
+ if (!res.ok) {
111
+ const body = await res.text().catch(() => "");
112
+ return { ok: false, original, changes: [], lang, error: `LLM request failed (${res.status}): ${body.slice(0, 300)}` };
113
+ }
114
+ const data = (await res.json());
115
+ reply = (data.content ?? [])
116
+ .filter((c) => c.type === "text")
117
+ .map((c) => c.text ?? "")
118
+ .join("\n");
119
+ }
120
+ catch (e) {
121
+ return { ok: false, original, changes: [], lang, error: `LLM request failed: ${e.message}` };
122
+ }
123
+ let parsed;
124
+ try {
125
+ parsed = parseReply(reply);
126
+ }
127
+ catch {
128
+ return { ok: false, original, changes: [], lang, error: "could not parse the model reply" };
129
+ }
130
+ if (!parsed.modernized.trim()) {
131
+ return { ok: false, original, changes: [], lang, error: "model returned an empty file" };
132
+ }
133
+ const gate = parseGate(parsed.modernized, lang);
134
+ if (!gate.ok) {
135
+ return {
136
+ ok: false,
137
+ original,
138
+ changes: parsed.changes,
139
+ lang,
140
+ error: `modernized output does not parse: ${gate.errors[0] ?? "syntax error"}`,
141
+ };
142
+ }
143
+ return { ok: true, original, modernized: parsed.modernized, changes: parsed.changes, lang };
144
+ }