afterbefore 0.1.19 → 0.2.1

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 DELETED
@@ -1,1233 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { Command } from "commander";
5
- import chalk3 from "chalk";
6
-
7
- // src/logger.ts
8
- import { writeFileSync } from "fs";
9
- import chalk from "chalk";
10
- import ora from "ora";
11
- var BAR_WIDTH = 20;
12
- var BAR_SPINNER = { interval: 80, frames: [" "] };
13
- var Logger = class {
14
- spinner = null;
15
- pipelineTotal = 0;
16
- lastStep = 0;
17
- lastLabel = "";
18
- pipelineActive = false;
19
- logBuffer = [];
20
- isTTY = !!process.stderr.isTTY;
21
- log(level, message) {
22
- const ts = (/* @__PURE__ */ new Date()).toISOString();
23
- this.logBuffer.push(`${ts} [${level}] ${message}`);
24
- }
25
- info(message) {
26
- this.log("info", message);
27
- if (this.pipelineActive) return;
28
- if (this.spinner) {
29
- this.spinner.clear();
30
- console.log(chalk.blue("\u2139"), message);
31
- this.spinner.render();
32
- } else {
33
- console.log(chalk.blue("\u2139"), message);
34
- }
35
- }
36
- success(message) {
37
- this.log("ok", message);
38
- if (this.pipelineActive) return;
39
- if (this.spinner) {
40
- this.spinner.clear();
41
- console.log(chalk.green("\u2714"), message);
42
- this.spinner.render();
43
- } else {
44
- console.log(chalk.green("\u2714"), message);
45
- }
46
- }
47
- warn(message) {
48
- this.log("warn", message);
49
- if (this.spinner) {
50
- this.spinner.clear();
51
- console.log(chalk.yellow("\u26A0"), message);
52
- this.spinner.render();
53
- } else {
54
- console.log(chalk.yellow("\u26A0"), message);
55
- }
56
- }
57
- error(message) {
58
- this.log("error", message);
59
- if (this.spinner) {
60
- this.spinner.clear();
61
- console.error(chalk.red("\u2716"), message);
62
- this.spinner.render();
63
- } else {
64
- console.error(chalk.red("\u2716"), message);
65
- }
66
- }
67
- dim(message) {
68
- this.log("debug", message);
69
- if (this.pipelineActive) return;
70
- if (this.spinner) {
71
- this.spinner.clear();
72
- console.log(chalk.dim(message));
73
- this.spinner.render();
74
- } else {
75
- console.log(chalk.dim(message));
76
- }
77
- }
78
- spin(message) {
79
- this.log("info", message);
80
- this.clearSpinner();
81
- this.spinner = ora(message).start();
82
- return this.spinner;
83
- }
84
- stopSpinner() {
85
- this.clearSpinner();
86
- }
87
- startPipeline(total) {
88
- this.pipelineTotal = total;
89
- this.lastStep = 0;
90
- this.lastLabel = "";
91
- this.pipelineActive = true;
92
- this.clearSpinner();
93
- this.log("info", `Pipeline started (${total} steps)`);
94
- const text = this.renderPipeline(0, "Starting...");
95
- if (this.isTTY) {
96
- this.spinner = ora({
97
- text: chalk.dim(text),
98
- spinner: BAR_SPINNER
99
- }).start();
100
- } else {
101
- console.error(chalk.dim(text));
102
- }
103
- }
104
- pipeline(step, label) {
105
- if (step === this.lastStep && label === this.lastLabel) return;
106
- this.lastStep = step;
107
- this.lastLabel = label;
108
- this.log("step", `${step}/${this.pipelineTotal} ${label}`);
109
- const text = chalk.dim(this.renderPipeline(step, label));
110
- if (!this.isTTY) {
111
- return;
112
- }
113
- if (!this.spinner) {
114
- this.spinner = ora({
115
- text,
116
- spinner: BAR_SPINNER
117
- }).start();
118
- return;
119
- }
120
- this.spinner.text = text;
121
- this.spinner.render();
122
- }
123
- stageComplete(name, detail, durationMs) {
124
- const duration = durationMs < 1e3 ? `${Math.round(durationMs)}ms` : `${(durationMs / 1e3).toFixed(1)}s`;
125
- const line = ` ${chalk.green("\u2713")} ${name.padEnd(12)} ${chalk.dim(detail.padEnd(50))} ${chalk.dim(duration)}`;
126
- this.log("stage", `${name}: ${detail} (${duration})`);
127
- if (this.isTTY && this.spinner) {
128
- this.spinner.clear();
129
- console.log(line);
130
- this.spinner.render();
131
- } else {
132
- console.log(line);
133
- }
134
- }
135
- completePipeline(finished = false) {
136
- this.pipelineActive = false;
137
- this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
138
- if (this.spinner) {
139
- if (finished) {
140
- const bar = "\u2588".repeat(BAR_WIDTH);
141
- this.spinner.succeed(` ${bar}`);
142
- } else {
143
- this.spinner.stop();
144
- }
145
- this.spinner = null;
146
- } else if (finished) {
147
- const bar = "\u2588".repeat(BAR_WIDTH);
148
- console.error(`${chalk.green("\u2714")} ${bar}`);
149
- }
150
- this.pipelineTotal = 0;
151
- this.lastStep = 0;
152
- this.lastLabel = "";
153
- }
154
- writeLogFile(filePath) {
155
- if (this.logBuffer.length === 0) return;
156
- writeFileSync(filePath, this.logBuffer.join("\n") + "\n", "utf-8");
157
- }
158
- renderPipeline(step, label) {
159
- const total = this.pipelineTotal || 1;
160
- const clampedStep = Math.max(0, Math.min(step, total));
161
- const filled = Math.round(clampedStep / total * BAR_WIDTH);
162
- const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
163
- return ` ${bar} ${clampedStep}/${total} ${label}`;
164
- }
165
- clearSpinner() {
166
- if (this.spinner) {
167
- this.spinner.stop();
168
- this.spinner = null;
169
- }
170
- }
171
- };
172
- var logger = new Logger();
173
-
174
- // src/errors.ts
175
- var AfterbeforeError = class extends Error {
176
- constructor(message, suggestion) {
177
- super(message);
178
- this.suggestion = suggestion;
179
- this.name = "AfterbeforeError";
180
- }
181
- };
182
-
183
- // src/cleanup.ts
184
- var CleanupRegistry = class {
185
- cleanups = [];
186
- registered = false;
187
- register(fn) {
188
- this.cleanups.push(fn);
189
- this.ensureSignalHandlers();
190
- }
191
- async runAll() {
192
- const fns = [...this.cleanups].reverse();
193
- this.cleanups = [];
194
- for (const fn of fns) {
195
- try {
196
- await fn();
197
- } catch (err) {
198
- logger.warn(
199
- `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`
200
- );
201
- }
202
- }
203
- }
204
- ensureSignalHandlers() {
205
- if (this.registered) return;
206
- this.registered = true;
207
- const handler = async (signal) => {
208
- logger.dim(`
209
- Received ${signal}, cleaning up...`);
210
- await this.runAll();
211
- process.exit(signal === "SIGINT" ? 130 : 143);
212
- };
213
- process.on("SIGINT", () => handler("SIGINT"));
214
- process.on("SIGTERM", () => handler("SIGTERM"));
215
- process.on("exit", () => {
216
- if (this.cleanups.length > 0) {
217
- for (const fn of [...this.cleanups].reverse()) {
218
- try {
219
- fn();
220
- } catch {
221
- }
222
- }
223
- this.cleanups = [];
224
- }
225
- });
226
- }
227
- };
228
- var cleanupRegistry = new CleanupRegistry();
229
-
230
- // src/utils/git.ts
231
- import { execSync } from "child_process";
232
- function git(args, cwd) {
233
- try {
234
- return execSync(`git ${args}`, {
235
- cwd,
236
- encoding: "utf-8",
237
- stdio: ["pipe", "pipe", "pipe"]
238
- }).trim();
239
- } catch (err) {
240
- const message = err instanceof Error ? err.message : String(err);
241
- if (message.includes("ENOENT") || message.includes("not found")) {
242
- throw new AfterbeforeError(
243
- "Git is not installed or not in PATH.",
244
- "Install git: https://git-scm.com/downloads"
245
- );
246
- }
247
- throw err;
248
- }
249
- }
250
- function isGitRepo(cwd) {
251
- try {
252
- git("rev-parse --is-inside-work-tree", cwd);
253
- return true;
254
- } catch {
255
- return false;
256
- }
257
- }
258
- function getMergeBase(base, cwd) {
259
- try {
260
- return git(`merge-base ${base} HEAD`, cwd);
261
- } catch {
262
- throw new AfterbeforeError(
263
- `Could not find merge base for "${base}". The branch or ref may not exist.`,
264
- `Run "git branch -a" to see available branches. Did you mean "master" instead of "main"?`
265
- );
266
- }
267
- }
268
- function getDiffNameStatus(base, cwd) {
269
- const mergeBase = getMergeBase(base, cwd);
270
- return git(`diff --name-status ${mergeBase}`, cwd);
271
- }
272
- function getCurrentBranch(cwd) {
273
- return git("rev-parse --abbrev-ref HEAD", cwd);
274
- }
275
-
276
- // src/pipeline.ts
277
- import { resolve as resolve3, basename } from "path";
278
- import chalk2 from "chalk";
279
-
280
- // src/config.ts
281
- import { resolve } from "path";
282
- import { existsSync, readFileSync } from "fs";
283
- import { pathToFileURL } from "url";
284
- var CONFIG_FILES = [
285
- "afterbefore.config.json",
286
- "afterbefore.config.js",
287
- "afterbefore.config.mjs"
288
- ];
289
- async function loadConfig(cwd) {
290
- for (const name of CONFIG_FILES) {
291
- const filePath = resolve(cwd, name);
292
- if (!existsSync(filePath)) continue;
293
- logger.dim(` Config: ${name}`);
294
- if (name.endsWith(".json")) {
295
- const raw = readFileSync(filePath, "utf-8");
296
- return JSON.parse(raw);
297
- }
298
- const mod = await import(pathToFileURL(filePath).href);
299
- return mod.default ?? mod;
300
- }
301
- return null;
302
- }
303
-
304
- // src/utils/fs.ts
305
- import { mkdir } from "fs/promises";
306
- async function ensureDir(dir) {
307
- await mkdir(dir, { recursive: true });
308
- }
309
-
310
- // src/utils/port.ts
311
- import { createServer } from "net";
312
- function findPort() {
313
- return new Promise((resolve4, reject) => {
314
- const server = createServer();
315
- server.listen(0, () => {
316
- const addr = server.address();
317
- if (!addr || typeof addr === "string") {
318
- server.close();
319
- reject(new Error("Failed to get port"));
320
- return;
321
- }
322
- const port = addr.port;
323
- server.close(() => resolve4(port));
324
- });
325
- server.on("error", reject);
326
- });
327
- }
328
- async function findAvailablePort(exclude) {
329
- for (let i = 0; i < 5; i++) {
330
- const port = await findPort();
331
- if (!exclude || !exclude.has(port)) return port;
332
- }
333
- throw new Error("Failed to find available port after 5 attempts");
334
- }
335
-
336
- // src/stages/diff.ts
337
- var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
338
- function parseDiffOutput(raw) {
339
- if (!raw.trim()) return [];
340
- const results = [];
341
- for (const line of raw.trim().split("\n")) {
342
- const parts = line.split(" ");
343
- const statusRaw = parts[0].charAt(0);
344
- if (!VALID_STATUSES.has(statusRaw)) continue;
345
- const status = statusRaw;
346
- if (status === "R" || status === "C") {
347
- results.push({ status, oldPath: parts[1], path: parts[2] });
348
- } else {
349
- results.push({ status, path: parts[1] });
350
- }
351
- }
352
- return results;
353
- }
354
- function getChangedFiles(base, cwd) {
355
- const raw = getDiffNameStatus(base, cwd);
356
- return parseDiffOutput(raw);
357
- }
358
-
359
- // src/stages/classify.ts
360
- var GLOBAL_FILES = /* @__PURE__ */ new Set([
361
- "tailwind.config.ts",
362
- "tailwind.config.js",
363
- "postcss.config.js",
364
- "postcss.config.mjs",
365
- "postcss.config.cjs"
366
- ]);
367
- function isGlobalVisualFile(filePath) {
368
- const p = filePath.replace(/^src\//, "");
369
- return GLOBAL_FILES.has(p) || /globals?\.(css|scss)$/.test(p);
370
- }
371
- function classifyFile(filePath) {
372
- const p = filePath.replace(/^src\//, "");
373
- if (/\.(test|spec)\.[tj]sx?$/.test(p) || /^tests?\//.test(p) || /\/__tests__\//.test(p) || p.includes(".test.") || p.includes(".spec.")) {
374
- return "test";
375
- }
376
- if (/^(tsconfig|next\.config|tailwind\.config|postcss\.config|\.eslint|\.prettier|vitest\.config|jest\.config)/.test(
377
- p
378
- ) || p === "package.json" || p === "package-lock.json" || p.endsWith(".config.ts") || p.endsWith(".config.js") || p.endsWith(".config.mjs")) {
379
- return "config";
380
- }
381
- if (/\.(css|scss|sass|less)$/.test(p) || p === "tailwind.config.ts") {
382
- return "style";
383
- }
384
- if (/\/layout\.[tj]sx?$/.test(p) || /^app\/layout\.[tj]sx?$/.test(p)) {
385
- return "layout";
386
- }
387
- if (/\/page\.[tj]sx?$/.test(p) || /^app\/page\.[tj]sx?$/.test(p)) {
388
- return "page";
389
- }
390
- if (/^(components|app)\//.test(p) && /\.[tj]sx?$/.test(p)) {
391
- return "component";
392
- }
393
- if (/^(lib|utils|hooks|helpers|services|api)\//.test(p) && /\.[tj]sx?$/.test(p)) {
394
- return "utility";
395
- }
396
- if (/\.[tj]sx?$/.test(p)) {
397
- return "component";
398
- }
399
- return "other";
400
- }
401
- function classifyFiles(files) {
402
- return files.map((f) => ({
403
- ...f,
404
- category: classifyFile(f.path)
405
- }));
406
- }
407
-
408
- // src/stages/graph.ts
409
- import { readdirSync, readFileSync as readFileSync3 } from "fs";
410
- import { join as join2, relative } from "path";
411
- import { init, parse } from "es-module-lexer";
412
-
413
- // src/stages/resolve.ts
414
- import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
415
- import { resolve as resolve2, dirname, join } from "path";
416
- var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
417
- function createResolver(projectRoot) {
418
- const mappings = loadPathMappings(projectRoot);
419
- const existsCache = /* @__PURE__ */ new Map();
420
- function cachedExists(p) {
421
- const cached = existsCache.get(p);
422
- if (cached !== void 0) return cached;
423
- const result = existsSync2(p);
424
- existsCache.set(p, result);
425
- return result;
426
- }
427
- function tryResolve(candidate) {
428
- if (cachedExists(candidate) && !isDirectory(candidate)) return candidate;
429
- for (const ext of EXTENSIONS) {
430
- const withExt = candidate + ext;
431
- if (cachedExists(withExt)) return withExt;
432
- }
433
- for (const ext of EXTENSIONS) {
434
- const indexFile = join(candidate, `index${ext}`);
435
- if (cachedExists(indexFile)) return indexFile;
436
- }
437
- return null;
438
- }
439
- function isDirectory(p) {
440
- try {
441
- return statSync(p).isDirectory();
442
- } catch {
443
- return false;
444
- }
445
- }
446
- return (specifier, fromFile) => {
447
- if (specifier.startsWith(".")) {
448
- const dir = dirname(fromFile);
449
- const candidate = resolve2(dir, specifier);
450
- return tryResolve(candidate);
451
- }
452
- for (const mapping of mappings) {
453
- if (!specifier.startsWith(mapping.prefix)) continue;
454
- const rest = specifier.slice(mapping.prefix.length);
455
- for (const target of mapping.targets) {
456
- const candidate = resolve2(projectRoot, target + rest);
457
- const result = tryResolve(candidate);
458
- if (result) return result;
459
- }
460
- }
461
- return null;
462
- };
463
- }
464
- function loadPathMappings(projectRoot) {
465
- const tsconfigPath = join(projectRoot, "tsconfig.json");
466
- if (!existsSync2(tsconfigPath)) {
467
- logger.dim("No tsconfig.json found, skipping path alias resolution");
468
- return [];
469
- }
470
- try {
471
- const raw = readFileSync2(tsconfigPath, "utf-8");
472
- const cleaned = stripJsonComments(raw);
473
- const config = JSON.parse(cleaned);
474
- const paths = config?.compilerOptions?.paths;
475
- if (!paths) return [];
476
- const baseUrl = config?.compilerOptions?.baseUrl || ".";
477
- const mappings = [];
478
- for (const [pattern, targets] of Object.entries(paths)) {
479
- const prefix = pattern.replace(/\*$/, "");
480
- const resolvedTargets = targets.map(
481
- (t) => t.replace(/\*$/, "")
482
- );
483
- const absoluteTargets = resolvedTargets.map(
484
- (t) => join(baseUrl, t)
485
- );
486
- mappings.push({ prefix, targets: absoluteTargets });
487
- }
488
- return mappings;
489
- } catch (e) {
490
- logger.warn(`Failed to parse tsconfig.json: ${e}`);
491
- return [];
492
- }
493
- }
494
- function stripJsonComments(input) {
495
- let result = "";
496
- let i = 0;
497
- const len = input.length;
498
- while (i < len) {
499
- const ch = input[i];
500
- if (ch === '"') {
501
- let j = i + 1;
502
- while (j < len) {
503
- if (input[j] === "\\") {
504
- j += 2;
505
- } else if (input[j] === '"') {
506
- j++;
507
- break;
508
- } else {
509
- j++;
510
- }
511
- }
512
- result += input.slice(i, j);
513
- i = j;
514
- continue;
515
- }
516
- if (ch === "/" && input[i + 1] === "/") {
517
- i += 2;
518
- while (i < len && input[i] !== "\n") i++;
519
- continue;
520
- }
521
- if (ch === "/" && input[i + 1] === "*") {
522
- i += 2;
523
- while (i < len && !(input[i] === "*" && input[i + 1] === "/")) i++;
524
- i += 2;
525
- continue;
526
- }
527
- if (ch === ",") {
528
- let j = i + 1;
529
- while (j < len && /\s/.test(input[j])) j++;
530
- if (input[j] === "}" || input[j] === "]") {
531
- i = j;
532
- continue;
533
- }
534
- }
535
- result += ch;
536
- i++;
537
- }
538
- return result;
539
- }
540
-
541
- // src/stages/graph.ts
542
- var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
543
- var SOURCE_DIRS = ["app", "src", "components", "lib"];
544
- function collectFiles(dir) {
545
- const results = [];
546
- let entries;
547
- try {
548
- entries = readdirSync(dir, { withFileTypes: true });
549
- } catch {
550
- return results;
551
- }
552
- for (const entry of entries) {
553
- const fullPath = join2(dir, entry.name);
554
- if (entry.isDirectory()) {
555
- if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
556
- continue;
557
- }
558
- results.push(...collectFiles(fullPath));
559
- } else if (entry.isFile()) {
560
- const ext = entry.name.slice(entry.name.lastIndexOf("."));
561
- if (SOURCE_EXTENSIONS.has(ext)) {
562
- results.push(fullPath);
563
- }
564
- }
565
- }
566
- return results;
567
- }
568
- function parseImports(filePath) {
569
- let source;
570
- try {
571
- source = readFileSync3(filePath, "utf-8");
572
- } catch {
573
- return [];
574
- }
575
- try {
576
- const cleaned = source.replace(/import\s+type\s+/g, "import ").replace(/export\s+type\s+/g, "export ");
577
- const [imports] = parse(cleaned);
578
- return imports.map((imp) => imp.n).filter((n) => n !== void 0 && n !== "");
579
- } catch {
580
- const specifiers = [];
581
- const importRegex = /(?:import|from)\s+["']([^"']+)["']/g;
582
- let match;
583
- while ((match = importRegex.exec(source)) !== null) {
584
- specifiers.push(match[1]);
585
- }
586
- return specifiers;
587
- }
588
- }
589
- async function buildImportGraph(projectRoot) {
590
- await init;
591
- const resolve4 = createResolver(projectRoot);
592
- const allFiles = [];
593
- for (const dir of SOURCE_DIRS) {
594
- const fullDir = join2(projectRoot, dir);
595
- allFiles.push(...collectFiles(fullDir));
596
- }
597
- const fileSet = new Set(allFiles);
598
- const forward = /* @__PURE__ */ new Map();
599
- const reverse = /* @__PURE__ */ new Map();
600
- for (const filePath of fileSet) {
601
- const relPath = relative(projectRoot, filePath);
602
- const specifiers = parseImports(filePath);
603
- const deps = /* @__PURE__ */ new Set();
604
- for (const spec of specifiers) {
605
- const resolved = resolve4(spec, filePath);
606
- if (!resolved) continue;
607
- const relResolved = relative(projectRoot, resolved);
608
- deps.add(relResolved);
609
- if (!reverse.has(relResolved)) {
610
- reverse.set(relResolved, /* @__PURE__ */ new Set());
611
- }
612
- reverse.get(relResolved).add(relPath);
613
- }
614
- forward.set(relPath, deps);
615
- }
616
- logger.dim(`Import graph: ${fileSet.size} files, ${countEdges(forward)} edges`);
617
- return { forward, reverse };
618
- }
619
- function countEdges(forward) {
620
- let count = 0;
621
- for (const deps of forward.values()) {
622
- count += deps.size;
623
- }
624
- return count;
625
- }
626
-
627
- // src/utils/nextjs.ts
628
- function pagePathToRoute(pagePath) {
629
- let p = pagePath.replace(/^src\//, "");
630
- const match = p.match(/^app\/(.*)\/page\.[tj]sx?$/);
631
- if (!match) {
632
- if (/^app\/page\.[tj]sx?$/.test(p)) return "/";
633
- return null;
634
- }
635
- let route = match[1];
636
- if (/\[.*\]/.test(route)) {
637
- logger.warn(`Skipping dynamic route: /${route} (dynamic segments not supported)`);
638
- return null;
639
- }
640
- route = route.split("/").filter((seg) => !seg.startsWith("(")).join("/");
641
- return `/${route}` || "/";
642
- }
643
- function isPageFile(filePath) {
644
- const p = filePath.replace(/^src\//, "");
645
- return /^app\/(.+\/)?page\.[tj]sx?$/.test(p);
646
- }
647
- function isLayoutFile(filePath) {
648
- const p = filePath.replace(/^src\//, "");
649
- return /^app\/(.+\/)?layout\.[tj]sx?$/.test(p);
650
- }
651
- function getLayoutDir(filePath) {
652
- const p = filePath.replace(/^src\//, "");
653
- const match = p.match(/^(app\/.*\/)layout\.[tj]sx?$/);
654
- if (!match) return "app/";
655
- return match[1];
656
- }
657
-
658
- // src/stages/impact.ts
659
- var DEFAULT_MAX_DEPTH = 10;
660
- function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0, maxDepth = DEFAULT_MAX_DEPTH) {
661
- const routeMap = /* @__PURE__ */ new Map();
662
- for (const file of changedFiles) {
663
- const visited = /* @__PURE__ */ new Set();
664
- const queue = [
665
- { path: file, depth: 0, chain: [file] }
666
- ];
667
- visited.add(file);
668
- while (queue.length > 0) {
669
- const { path, depth, chain } = queue.shift();
670
- if (isPageFile(path)) {
671
- const route = pagePathToRoute(path);
672
- if (route !== null && !routeMap.has(route)) {
673
- routeMap.set(route, {
674
- pagePath: path,
675
- route,
676
- reason: depth === 0 ? "direct" : "transitive",
677
- depth,
678
- triggerChain: chain
679
- });
680
- }
681
- }
682
- if (depth >= maxDepth) continue;
683
- const importers = graph.reverse.get(path);
684
- if (!importers) continue;
685
- for (const importer of importers) {
686
- if (visited.has(importer)) continue;
687
- visited.add(importer);
688
- queue.push({ path: importer, depth: depth + 1, chain: [...chain, importer] });
689
- }
690
- }
691
- }
692
- for (const file of changedFiles) {
693
- if (!isLayoutFile(file)) continue;
694
- const layoutDir = getLayoutDir(file);
695
- for (const knownFile of graph.forward.keys()) {
696
- if (knownFile.startsWith(layoutDir) && isPageFile(knownFile)) {
697
- const route = pagePathToRoute(knownFile);
698
- if (route !== null && !routeMap.has(route)) {
699
- routeMap.set(route, {
700
- pagePath: knownFile,
701
- route,
702
- reason: "transitive",
703
- depth: 1,
704
- triggerChain: [file, knownFile]
705
- });
706
- }
707
- }
708
- }
709
- }
710
- const routes = Array.from(routeMap.values()).sort(
711
- (a, b) => a.depth - b.depth
712
- );
713
- if (maxRoutes > 0 && routes.length > maxRoutes) {
714
- const skipped = routes.length - maxRoutes;
715
- logger.warn(
716
- `Limiting to ${maxRoutes} route(s), skipping ${skipped} deeper route(s). Use --max-routes 0 for unlimited.`
717
- );
718
- const limited = routes.slice(0, maxRoutes);
719
- logger.dim(`Found ${limited.length} affected route(s) (of ${routes.length} total)`);
720
- return limited;
721
- }
722
- logger.dim(`Found ${routes.length} affected route(s)`);
723
- return routes;
724
- }
725
-
726
- // src/stages/worktree.ts
727
- import { exec as execCb, execSync as execSync2 } from "child_process";
728
- import { promisify } from "util";
729
- import { rm, mkdtemp } from "fs/promises";
730
- import { join as join4 } from "path";
731
- import { tmpdir } from "os";
732
-
733
- // src/utils/pm.ts
734
- import { existsSync as existsSync3 } from "fs";
735
- import { join as join3 } from "path";
736
- function detectPackageManager(dir) {
737
- if (existsSync3(join3(dir, "bun.lockb")) || existsSync3(join3(dir, "bun.lock")))
738
- return "bun";
739
- if (existsSync3(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
740
- if (existsSync3(join3(dir, "yarn.lock"))) return "yarn";
741
- return "npm";
742
- }
743
- function pmExec(pm) {
744
- switch (pm) {
745
- case "bun":
746
- return "bunx";
747
- case "pnpm":
748
- return "pnpm exec";
749
- case "yarn":
750
- return "yarn";
751
- default:
752
- return "npx";
753
- }
754
- }
755
-
756
- // src/stages/worktree.ts
757
- var exec = promisify(execCb);
758
- async function createWorktree(base, cwd) {
759
- const worktreeDir = await mkdtemp(join4(tmpdir(), "afterbefore-wt-"));
760
- logger.dim(`Creating worktree for ${base} at ${worktreeDir}`);
761
- try {
762
- await exec(`git worktree add "${worktreeDir}" "${base}"`, { cwd });
763
- } catch (err) {
764
- throw new AfterbeforeError(
765
- `Failed to create worktree for ref "${base}".`,
766
- `Make sure the branch/ref "${base}" exists. Run "git branch -a" to see available refs.`
767
- );
768
- }
769
- const pm = detectPackageManager(cwd);
770
- logger.dim(`Installing dependencies with ${pm}`);
771
- await exec(`${pm} install`, { cwd: worktreeDir });
772
- const cleanup = async () => {
773
- logger.dim(`Cleaning up worktree at ${worktreeDir}`);
774
- try {
775
- execSync2(`git worktree remove --force "${worktreeDir}"`, {
776
- cwd,
777
- stdio: ["pipe", "pipe", "pipe"]
778
- });
779
- } catch {
780
- try {
781
- await rm(worktreeDir, { recursive: true, force: true });
782
- execSync2("git worktree prune", {
783
- cwd,
784
- stdio: ["pipe", "pipe", "pipe"]
785
- });
786
- } catch {
787
- logger.warn(`Failed to clean up worktree at ${worktreeDir}`);
788
- }
789
- }
790
- };
791
- cleanupRegistry.register(cleanup);
792
- return { path: worktreeDir, ref: base, cleanup };
793
- }
794
-
795
- // src/stages/server.ts
796
- import { spawn } from "child_process";
797
- import { existsSync as existsSync4 } from "fs";
798
- import { join as join5 } from "path";
799
- function waitForServer(url, timeoutMs) {
800
- const start = Date.now();
801
- return new Promise((resolve4, reject) => {
802
- const poll = async () => {
803
- if (Date.now() - start > timeoutMs) {
804
- reject(
805
- new AfterbeforeError(
806
- `Server at ${url} did not respond within ${timeoutMs / 1e3}s.`,
807
- `Try running "next dev" manually to check for errors. Also check if port ${url.split(":").pop()} is already in use.`
808
- )
809
- );
810
- return;
811
- }
812
- try {
813
- await fetch(url);
814
- resolve4();
815
- } catch {
816
- setTimeout(poll, 150);
817
- }
818
- };
819
- poll();
820
- });
821
- }
822
- async function startServer(projectDir, port) {
823
- const url = `http://localhost:${port}`;
824
- const pm = detectPackageManager(projectDir);
825
- const exec2 = pmExec(pm);
826
- const [cmd, ...baseArgs] = exec2.split(" ");
827
- const lockFile = join5(projectDir, ".next", "dev", "lock");
828
- if (existsSync4(lockFile)) {
829
- throw new AfterbeforeError(
830
- `Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
831
- `Stop the other dev server first, or delete .next/dev/lock if it's stale.`
832
- );
833
- }
834
- logger.info(`Starting Next.js dev server on ${url} (using ${pm})`);
835
- const child = spawn(cmd, [...baseArgs, "next", "dev", "-p", String(port)], {
836
- cwd: projectDir,
837
- stdio: ["pipe", "pipe", "pipe"],
838
- detached: true
839
- });
840
- child.stderr?.on("data", (data) => {
841
- const msg = data.toString().trim();
842
- if (msg) logger.dim(`[next:${port}] ${msg}`);
843
- });
844
- await waitForServer(url, 6e4);
845
- logger.success(`Server ready at ${url}`);
846
- return { port, process: child, url };
847
- }
848
- async function stopServer(server) {
849
- logger.dim(`Stopping server on port ${server.port}`);
850
- const pid = server.process.pid;
851
- try {
852
- process.kill(-pid, "SIGTERM");
853
- } catch {
854
- }
855
- await new Promise((resolve4) => {
856
- const timeout = setTimeout(() => {
857
- try {
858
- process.kill(-pid, "SIGKILL");
859
- } catch {
860
- }
861
- resolve4();
862
- }, 5e3);
863
- server.process.on("exit", () => {
864
- clearTimeout(timeout);
865
- resolve4();
866
- });
867
- });
868
- }
869
-
870
- // src/stages/capture.ts
871
- import { join as join6 } from "path";
872
- import { execSync as execSync3 } from "child_process";
873
- import { chromium } from "playwright";
874
- async function launchBrowser() {
875
- try {
876
- return await chromium.launch();
877
- } catch {
878
- logger.dim("Chromium not found, installing...");
879
- try {
880
- execSync3("npx playwright install chromium", {
881
- stdio: ["pipe", "pipe", "pipe"]
882
- });
883
- return await chromium.launch();
884
- } catch {
885
- throw new AfterbeforeError(
886
- "Could not install or launch Playwright Chromium.",
887
- 'Run "npx playwright install chromium" manually, then try again.'
888
- );
889
- }
890
- }
891
- }
892
- async function captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options) {
893
- const results = [];
894
- const [beforePage, afterPage] = await Promise.all([
895
- beforeCtx.newPage(),
896
- afterCtx.newPage()
897
- ]);
898
- try {
899
- const beforePath = join6(outputDir, `${task.prefix}-before.png`);
900
- const afterPath = join6(outputDir, `${task.prefix}-after.png`);
901
- const settle = async (page) => {
902
- await page.evaluate("document.fonts.ready");
903
- if (options.delay > 0) {
904
- await page.waitForTimeout(options.delay);
905
- }
906
- };
907
- const performActions = async (page, actions) => {
908
- for (const action of actions) {
909
- if (action.click) {
910
- await page.locator(action.click).first().click();
911
- }
912
- if (action.scroll) {
913
- await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
914
- }
915
- if (action.wait && action.wait > 0) {
916
- await page.waitForTimeout(action.wait);
917
- }
918
- await settle(page);
919
- }
920
- };
921
- const screenshot = async (page, path) => {
922
- await page.screenshot({ path, fullPage: true });
923
- };
924
- await Promise.all([
925
- beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
926
- afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
927
- ]);
928
- results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
929
- } finally {
930
- await Promise.all([beforePage.close(), afterPage.close()]);
931
- }
932
- return results;
933
- }
934
- var BATCH_SIZE = 3;
935
- async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
936
- const browser = options.browser ?? await launchBrowser();
937
- const ownsBrowser = !options.browser;
938
- const results = [];
939
- try {
940
- const contextOpts = {
941
- viewport: { width: options.width, height: options.height },
942
- deviceScaleFactor: 2
943
- };
944
- const [beforeCtx, afterCtx] = await Promise.all([
945
- browser.newContext(contextOpts),
946
- browser.newContext(contextOpts)
947
- ]);
948
- for (let batchStart = 0; batchStart < tasks.length; batchStart += BATCH_SIZE) {
949
- const batch = tasks.slice(batchStart, batchStart + BATCH_SIZE);
950
- const batchResults = await Promise.allSettled(
951
- batch.map((task, idx) => {
952
- options.onProgress?.(batchStart + idx + 1, task.label);
953
- return captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options);
954
- })
955
- );
956
- for (const result of batchResults) {
957
- if (result.status === "fulfilled") {
958
- results.push(...result.value);
959
- } else {
960
- logger.dim(`Capture failed: ${result.reason}`);
961
- }
962
- }
963
- }
964
- await Promise.all([beforeCtx.close(), afterCtx.close()]);
965
- } finally {
966
- if (ownsBrowser) {
967
- await browser.close();
968
- }
969
- }
970
- return results;
971
- }
972
-
973
- // src/pipeline.ts
974
- function generateSessionName(cwd) {
975
- const branch = getCurrentBranch(cwd);
976
- const name = branch.replace(/^(feat|fix|perf|chore|refactor|docs|style|test|ci|build)\//, "");
977
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
978
- return `${name}_${date}`;
979
- }
980
- function routeToPrefix(route) {
981
- if (route === "/") return "_root";
982
- return route.replace(/^\//, "").replace(/\//g, "-");
983
- }
984
- function expandRoutes(routes, config) {
985
- const tasks = [];
986
- for (const r of routes) {
987
- const scenarios = config?.scenarios?.[r.route] ?? [];
988
- tasks.push({
989
- route: r.route,
990
- label: r.route,
991
- prefix: routeToPrefix(r.route)
992
- });
993
- for (const s of scenarios) {
994
- tasks.push({
995
- route: r.route,
996
- label: `${r.route} [${s.name}]`,
997
- prefix: `${routeToPrefix(r.route)}~${s.name}`,
998
- actions: s.actions
999
- });
1000
- }
1001
- }
1002
- return tasks;
1003
- }
1004
- function applyConfigDefaults(options, config) {
1005
- if (!config?.defaults) return;
1006
- const defaults = config.defaults;
1007
- const cliDefaults = {
1008
- base: "main",
1009
- output: ".afterbefore",
1010
- maxRoutes: 6,
1011
- width: 1280,
1012
- height: 720,
1013
- delay: 0,
1014
- maxDepth: 10,
1015
- dryRun: false,
1016
- verbose: false
1017
- };
1018
- const opts = options;
1019
- for (const [key, value] of Object.entries(defaults)) {
1020
- if (key === "cwd" || value === void 0) continue;
1021
- if (key in cliDefaults && opts[key] === cliDefaults[key]) {
1022
- opts[key] = value;
1023
- }
1024
- }
1025
- }
1026
- async function runPipeline(options) {
1027
- const { base, output, cwd } = options;
1028
- const sessionName = generateSessionName(cwd);
1029
- const outputDir = resolve3(cwd, output, sessionName);
1030
- const startTime = Date.now();
1031
- try {
1032
- const version = true ? "0.1.19" : "dev";
1033
- const mode = options.dryRun ? "Dry run" : "Capturing";
1034
- console.log(`
1035
- afterbefore v${version} \xB7 ${mode} against ${base}
1036
- `);
1037
- const config = await loadConfig(cwd);
1038
- applyConfigDefaults(options, config);
1039
- logger.startPipeline(options.dryRun ? 3 : 6);
1040
- const t1 = Date.now();
1041
- logger.pipeline(1, "Analyzing diff...");
1042
- const diffFiles = getChangedFiles(base, cwd);
1043
- logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
1044
- if (diffFiles.length === 0) {
1045
- logger.completePipeline();
1046
- logger.success("No changed files detected. Nothing to do.");
1047
- return;
1048
- }
1049
- const t2 = Date.now();
1050
- const classified = classifyFiles(diffFiles);
1051
- const impactfulFiles = classified.filter(
1052
- (f) => f.category !== "test" && f.category !== "other"
1053
- );
1054
- if (impactfulFiles.length === 0) {
1055
- logger.completePipeline();
1056
- logger.success(
1057
- "No visually relevant changes detected (only test/other files changed)."
1058
- );
1059
- return;
1060
- }
1061
- logger.pipeline(2, "Building import graph...");
1062
- const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
1063
- const graph = await buildImportGraph(cwd);
1064
- const graphEdges = Array.from(graph.forward.values()).reduce((sum, deps) => sum + deps.size, 0);
1065
- logger.stageComplete("Graph", `${graph.forward.size} modules, ${graphEdges} edges`, Date.now() - t2);
1066
- const t3 = Date.now();
1067
- logger.pipeline(3, "Finding affected routes...");
1068
- const changedPaths = impactfulFiles.map((f) => f.path);
1069
- let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
1070
- if (affectedRoutes.length === 0) {
1071
- const hasGlobalChanges = impactfulFiles.some((f) => isGlobalVisualFile(f.path));
1072
- if (hasGlobalChanges) {
1073
- const allRoutes = [];
1074
- for (const file of graph.forward.keys()) {
1075
- if (!isPageFile(file)) continue;
1076
- const route = pagePathToRoute(file);
1077
- if (route === null) continue;
1078
- allRoutes.push({
1079
- pagePath: file,
1080
- route,
1081
- reason: "transitive",
1082
- depth: 0,
1083
- triggerChain: [file]
1084
- });
1085
- }
1086
- allRoutes.sort((a, b) => a.route.localeCompare(b.route));
1087
- if (options.maxRoutes > 0 && allRoutes.length > options.maxRoutes) {
1088
- affectedRoutes = allRoutes.slice(0, options.maxRoutes);
1089
- } else {
1090
- affectedRoutes = allRoutes;
1091
- }
1092
- }
1093
- }
1094
- const directCount = affectedRoutes.filter((r) => r.reason === "direct").length;
1095
- const transitiveCount = affectedRoutes.length - directCount;
1096
- const impactDetail = directCount > 0 && transitiveCount > 0 ? `${affectedRoutes.length} routes (${directCount} direct, ${transitiveCount} transitive)` : `${affectedRoutes.length} routes`;
1097
- logger.stageComplete("Impact", impactDetail, Date.now() - t3);
1098
- if (options.verbose) {
1099
- console.log("");
1100
- for (const r of affectedRoutes) {
1101
- const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
1102
- const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
1103
- console.log(chalk2.dim(` ${r.route.padEnd(24)} ${depthLabel.padEnd(10)} ${chain}`));
1104
- }
1105
- console.log("");
1106
- }
1107
- if (affectedRoutes.length === 0) {
1108
- worktreePromise?.then((w) => w.cleanup()).catch(() => {
1109
- });
1110
- logger.completePipeline();
1111
- logger.success(
1112
- "No affected routes found. Changed files don't impact any pages."
1113
- );
1114
- return;
1115
- }
1116
- if (options.dryRun) {
1117
- worktreePromise?.then((w) => w.cleanup()).catch(() => {
1118
- });
1119
- logger.completePipeline();
1120
- console.log(`
1121
- ${affectedRoutes.length} route(s) would be captured:
1122
- `);
1123
- for (const r of affectedRoutes) {
1124
- const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
1125
- const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
1126
- console.log(` ${r.route.padEnd(24)} ${chalk2.dim(depthLabel.padEnd(10))} ${chalk2.dim(chain)}`);
1127
- }
1128
- const elapsed2 = ((Date.now() - startTime) / 1e3).toFixed(1);
1129
- console.log(chalk2.dim(`
1130
- Completed in ${elapsed2}s (dry run \u2014 no screenshots captured)
1131
- `));
1132
- return;
1133
- }
1134
- const t4 = Date.now();
1135
- logger.pipeline(4, "Setting up worktree...");
1136
- const worktree = await worktreePromise;
1137
- logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
1138
- const t5 = Date.now();
1139
- logger.pipeline(5, "Starting servers...");
1140
- await ensureDir(outputDir);
1141
- const beforePort = await findAvailablePort();
1142
- const afterPort = await findAvailablePort(/* @__PURE__ */ new Set([beforePort]));
1143
- const [beforeServer, afterServer, browser] = await Promise.all([
1144
- startServer(worktree.path, beforePort),
1145
- startServer(cwd, afterPort),
1146
- launchBrowser()
1147
- ]);
1148
- cleanupRegistry.register(() => stopServer(beforeServer));
1149
- cleanupRegistry.register(() => stopServer(afterServer));
1150
- cleanupRegistry.register(() => browser.close());
1151
- logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
1152
- const t6 = Date.now();
1153
- logger.pipeline(6, "Capturing screenshots...");
1154
- const tasks = expandRoutes(affectedRoutes, config);
1155
- const captures = await captureRoutes(
1156
- tasks,
1157
- beforeServer.url,
1158
- afterServer.url,
1159
- outputDir,
1160
- {
1161
- browser,
1162
- width: options.width,
1163
- height: options.height,
1164
- delay: options.delay,
1165
- onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
1166
- }
1167
- );
1168
- logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
1169
- logger.completePipeline(true);
1170
- console.log(`
1171
- ${captures.length} screenshot pair(s) captured:
1172
- `);
1173
- for (const c of captures) {
1174
- console.log(` ${c.route}`);
1175
- console.log(chalk2.dim(` before: ${c.beforePath.replace(cwd + "/", "")}`));
1176
- console.log(chalk2.dim(` after: ${c.afterPath.replace(cwd + "/", "")}`));
1177
- }
1178
- const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
1179
- logger.success(`Done in ${elapsed}s \u2014 ${captures.length} route(s) captured`);
1180
- } finally {
1181
- try {
1182
- logger.writeLogFile(resolve3(outputDir, "debug.log"));
1183
- } catch {
1184
- }
1185
- await cleanupRegistry.runAll();
1186
- }
1187
- }
1188
-
1189
- // src/cli.ts
1190
- var program = new Command();
1191
- program.name("afterbefore").description(
1192
- "Automatic before/after screenshot capture for PRs. Git diff is the config."
1193
- ).version("0.1.19").option("--base <ref>", "Base branch or ref to compare against", "main").option("--output <dir>", "Output directory for screenshots", ".afterbefore").option(
1194
- "--max-routes <count>",
1195
- "Maximum routes to capture (0 = unlimited)",
1196
- "6"
1197
- ).option("--width <pixels>", "Viewport width", "1280").option("--height <pixels>", "Viewport height", "720").option("--delay <ms>", "Extra wait time (ms) after page load", "0").option("--max-depth <n>", "Max import graph traversal depth", "10").option("--dry-run", "Show affected routes without capturing", false).option("--verbose", "Show detailed import graph traversal", false).action(async (opts) => {
1198
- const cwd = process.cwd();
1199
- if (!isGitRepo(cwd)) {
1200
- logger.error("Not a git repository. Run this from inside a git repo.");
1201
- process.exit(1);
1202
- }
1203
- const options = {
1204
- base: opts.base,
1205
- output: opts.output,
1206
- maxRoutes: parseInt(opts.maxRoutes, 10),
1207
- width: parseInt(opts.width, 10),
1208
- height: parseInt(opts.height, 10),
1209
- delay: parseInt(opts.delay, 10),
1210
- maxDepth: parseInt(opts.maxDepth, 10),
1211
- dryRun: opts.dryRun,
1212
- verbose: opts.verbose,
1213
- cwd
1214
- };
1215
- try {
1216
- await runPipeline(options);
1217
- } catch (err) {
1218
- if (err instanceof AfterbeforeError) {
1219
- logger.error(err.message);
1220
- logger.dim(` Suggestion: ${err.suggestion}`);
1221
- } else {
1222
- logger.error(
1223
- err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
1224
- );
1225
- console.error(chalk3.dim("\n Help us fix this: https://github.com/kairevicius/afterbefore/issues/new"));
1226
- console.error(chalk3.dim(" Include the debug.log file from your output directory.\n"));
1227
- }
1228
- await cleanupRegistry.runAll();
1229
- process.exit(1);
1230
- }
1231
- });
1232
- program.parse();
1233
- //# sourceMappingURL=cli.js.map