viberails 0.1.0 → 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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import chalk6 from "chalk";
4
+ import chalk10 from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/boundaries.ts
@@ -34,11 +34,11 @@ async function confirm(message) {
34
34
  input: process.stdin,
35
35
  output: process.stdout
36
36
  });
37
- return new Promise((resolve3) => {
37
+ return new Promise((resolve4) => {
38
38
  rl.question(`${message} (Y/n) `, (answer) => {
39
39
  rl.close();
40
40
  const trimmed = answer.trim().toLowerCase();
41
- resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
41
+ resolve4(trimmed === "" || trimmed === "y" || trimmed === "yes");
42
42
  });
43
43
  });
44
44
  }
@@ -187,12 +187,42 @@ ${chalk.yellow("Cycles detected:")}`);
187
187
  }
188
188
 
189
189
  // src/commands/check.ts
190
+ import * as fs6 from "fs";
191
+ import * as path6 from "path";
192
+ import { loadConfig as loadConfig2 } from "@viberails/config";
193
+ import chalk2 from "chalk";
194
+
195
+ // src/commands/check-config.ts
196
+ function resolveConfigForFile(relPath, config) {
197
+ if (!config.packages || config.packages.length === 0) {
198
+ return { rules: config.rules, conventions: config.conventions };
199
+ }
200
+ const sortedPackages = [...config.packages].sort((a, b) => b.path.length - a.path.length);
201
+ for (const pkg of sortedPackages) {
202
+ if (relPath.startsWith(`${pkg.path}/`) || relPath === pkg.path) {
203
+ return {
204
+ rules: { ...config.rules, ...pkg.rules },
205
+ conventions: { ...config.conventions, ...pkg.conventions }
206
+ };
207
+ }
208
+ }
209
+ return { rules: config.rules, conventions: config.conventions };
210
+ }
211
+ function resolveIgnoreForFile(relPath, config) {
212
+ const globalIgnore = config.ignore;
213
+ if (!config.packages) return globalIgnore;
214
+ for (const pkg of config.packages) {
215
+ if (pkg.ignore && relPath.startsWith(`${pkg.path}/`)) {
216
+ return [...globalIgnore, ...pkg.ignore];
217
+ }
218
+ }
219
+ return globalIgnore;
220
+ }
221
+
222
+ // src/commands/check-files.ts
190
223
  import { execSync } from "child_process";
191
224
  import * as fs4 from "fs";
192
225
  import * as path4 from "path";
193
- import { loadConfig as loadConfig2 } from "@viberails/config";
194
- import chalk2 from "chalk";
195
- var CONFIG_FILE2 = "viberails.config.json";
196
226
  var SOURCE_EXTS = /* @__PURE__ */ new Set([
197
227
  ".ts",
198
228
  ".tsx",
@@ -210,6 +240,160 @@ var NAMING_PATTERNS = {
210
240
  PascalCase: /^[A-Z][a-zA-Z0-9]*$/,
211
241
  snake_case: /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
212
242
  };
243
+ function isIgnored(relPath, ignorePatterns) {
244
+ for (const pattern of ignorePatterns) {
245
+ if (pattern.endsWith("/**")) {
246
+ const prefix = pattern.slice(0, -3);
247
+ if (relPath.startsWith(`${prefix}/`) || relPath === prefix) return true;
248
+ } else if (pattern.startsWith("**/")) {
249
+ const suffix = pattern.slice(3);
250
+ if (relPath.endsWith(suffix)) return true;
251
+ } else if (relPath === pattern || relPath.startsWith(`${pattern}/`)) {
252
+ return true;
253
+ }
254
+ }
255
+ return false;
256
+ }
257
+ function countFileLines(filePath) {
258
+ try {
259
+ const content = fs4.readFileSync(filePath, "utf-8");
260
+ if (content.length === 0) return 0;
261
+ let count = 1;
262
+ for (let i = 0; i < content.length; i++) {
263
+ if (content.charCodeAt(i) === 10) count++;
264
+ }
265
+ return count;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+ function checkNaming(relPath, conventions) {
271
+ const filename = path4.basename(relPath);
272
+ const ext = path4.extname(filename);
273
+ if (!SOURCE_EXTS.has(ext)) return void 0;
274
+ if (filename.startsWith("index.") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith(".")) {
275
+ return void 0;
276
+ }
277
+ const bare = filename.slice(0, filename.indexOf("."));
278
+ const convention = typeof conventions.fileNaming === "string" ? conventions.fileNaming : conventions.fileNaming?.value;
279
+ if (!convention) return void 0;
280
+ const pattern = NAMING_PATTERNS[convention];
281
+ if (!pattern || pattern.test(bare)) return void 0;
282
+ return `File name "${filename}" does not follow ${convention} convention.`;
283
+ }
284
+ function getStagedFiles(projectRoot) {
285
+ try {
286
+ const output = execSync("git diff --cached --name-only --diff-filter=ACM", {
287
+ cwd: projectRoot,
288
+ encoding: "utf-8"
289
+ });
290
+ return output.trim().split("\n").filter(Boolean);
291
+ } catch {
292
+ return [];
293
+ }
294
+ }
295
+ function getAllSourceFiles(projectRoot, config) {
296
+ const files = [];
297
+ const walk = (dir) => {
298
+ let entries;
299
+ try {
300
+ entries = fs4.readdirSync(dir, { withFileTypes: true });
301
+ } catch {
302
+ return;
303
+ }
304
+ for (const entry of entries) {
305
+ const rel = path4.relative(projectRoot, path4.join(dir, entry.name));
306
+ if (entry.isDirectory()) {
307
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
308
+ continue;
309
+ }
310
+ if (isIgnored(rel, config.ignore)) continue;
311
+ walk(path4.join(dir, entry.name));
312
+ } else if (entry.isFile()) {
313
+ const ext = path4.extname(entry.name);
314
+ if (SOURCE_EXTS.has(ext) && !isIgnored(rel, config.ignore)) {
315
+ files.push(rel);
316
+ }
317
+ }
318
+ }
319
+ };
320
+ walk(projectRoot);
321
+ return files;
322
+ }
323
+ function collectSourceFiles(dir, projectRoot) {
324
+ const files = [];
325
+ const walk = (d) => {
326
+ let entries;
327
+ try {
328
+ entries = fs4.readdirSync(d, { withFileTypes: true });
329
+ } catch {
330
+ return;
331
+ }
332
+ for (const entry of entries) {
333
+ if (entry.isDirectory()) {
334
+ if (entry.name === "node_modules") continue;
335
+ walk(path4.join(d, entry.name));
336
+ } else if (entry.isFile()) {
337
+ files.push(path4.relative(projectRoot, path4.join(d, entry.name)));
338
+ }
339
+ }
340
+ };
341
+ walk(dir);
342
+ return files;
343
+ }
344
+
345
+ // src/commands/check-tests.ts
346
+ import * as fs5 from "fs";
347
+ import * as path5 from "path";
348
+ var SOURCE_EXTS2 = /* @__PURE__ */ new Set([
349
+ ".ts",
350
+ ".tsx",
351
+ ".js",
352
+ ".jsx",
353
+ ".mjs",
354
+ ".cjs",
355
+ ".vue",
356
+ ".svelte",
357
+ ".astro"
358
+ ]);
359
+ function checkMissingTests(projectRoot, config, severity) {
360
+ const violations = [];
361
+ const { testPattern } = config.structure;
362
+ if (!testPattern) return violations;
363
+ const srcDir = config.structure.srcDir;
364
+ if (!srcDir) return violations;
365
+ const srcPath = path5.join(projectRoot, srcDir);
366
+ if (!fs5.existsSync(srcPath)) return violations;
367
+ const testSuffix = testPattern.replace("*", "");
368
+ const sourceFiles = collectSourceFiles(srcPath, projectRoot);
369
+ for (const relFile of sourceFiles) {
370
+ const basename6 = path5.basename(relFile);
371
+ if (basename6.includes(".test.") || basename6.includes(".spec.") || basename6.startsWith("index.") || basename6.endsWith(".d.ts")) {
372
+ continue;
373
+ }
374
+ const ext = path5.extname(basename6);
375
+ if (!SOURCE_EXTS2.has(ext)) continue;
376
+ const stem = basename6.slice(0, basename6.indexOf("."));
377
+ const expectedTestFile = `${stem}${testSuffix}`;
378
+ const dir = path5.dirname(path5.join(projectRoot, relFile));
379
+ const colocatedTest = path5.join(dir, expectedTestFile);
380
+ const testsDir = config.structure.tests;
381
+ const dedicatedTest = testsDir ? path5.join(projectRoot, testsDir, expectedTestFile) : null;
382
+ const hasTest = fs5.existsSync(colocatedTest) || dedicatedTest !== null && fs5.existsSync(dedicatedTest);
383
+ if (!hasTest) {
384
+ violations.push({
385
+ file: relFile,
386
+ rule: "missing-test",
387
+ message: `No test file found. Expected \`${expectedTestFile}\`.`,
388
+ severity
389
+ });
390
+ }
391
+ }
392
+ return violations;
393
+ }
394
+
395
+ // src/commands/check.ts
396
+ var CONFIG_FILE2 = "viberails.config.json";
213
397
  async function checkCommand(options, cwd) {
214
398
  const startDir = cwd ?? process.cwd();
215
399
  const projectRoot = findProjectRoot(startDir);
@@ -217,8 +401,8 @@ async function checkCommand(options, cwd) {
217
401
  console.error(`${chalk2.red("Error:")} No package.json found. Are you in a JS/TS project?`);
218
402
  return 1;
219
403
  }
220
- const configPath = path4.join(projectRoot, CONFIG_FILE2);
221
- if (!fs4.existsSync(configPath)) {
404
+ const configPath = path6.join(projectRoot, CONFIG_FILE2);
405
+ if (!fs6.existsSync(configPath)) {
222
406
  console.error(
223
407
  `${chalk2.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
224
408
  );
@@ -240,23 +424,25 @@ async function checkCommand(options, cwd) {
240
424
  const violations = [];
241
425
  const severity = config.enforcement === "enforce" ? "error" : "warn";
242
426
  for (const file of filesToCheck) {
243
- const absPath = path4.isAbsolute(file) ? file : path4.join(projectRoot, file);
244
- const relPath = path4.relative(projectRoot, absPath);
245
- if (isIgnored(relPath, config.ignore)) continue;
246
- if (!fs4.existsSync(absPath)) continue;
247
- if (config.rules.maxFileLines > 0) {
427
+ const absPath = path6.isAbsolute(file) ? file : path6.join(projectRoot, file);
428
+ const relPath = path6.relative(projectRoot, absPath);
429
+ const effectiveIgnore = resolveIgnoreForFile(relPath, config);
430
+ if (isIgnored(relPath, effectiveIgnore)) continue;
431
+ if (!fs6.existsSync(absPath)) continue;
432
+ const resolved = resolveConfigForFile(relPath, config);
433
+ if (resolved.rules.maxFileLines > 0) {
248
434
  const lines = countFileLines(absPath);
249
- if (lines !== null && lines > config.rules.maxFileLines) {
435
+ if (lines !== null && lines > resolved.rules.maxFileLines) {
250
436
  violations.push({
251
437
  file: relPath,
252
438
  rule: "file-size",
253
- message: `${lines} lines (max ${config.rules.maxFileLines}). Split into focused modules.`,
439
+ message: `${lines} lines (max ${resolved.rules.maxFileLines}). Split into focused modules.`,
254
440
  severity
255
441
  });
256
442
  }
257
443
  }
258
- if (config.rules.enforceNaming && config.conventions.fileNaming) {
259
- const namingViolation = checkNaming(relPath, config);
444
+ if (resolved.rules.enforceNaming && resolved.conventions.fileNaming) {
445
+ const namingViolation = checkNaming(relPath, resolved.conventions);
260
446
  if (namingViolation) {
261
447
  violations.push({
262
448
  file: relPath,
@@ -280,10 +466,10 @@ async function checkCommand(options, cwd) {
280
466
  ignore: config.ignore
281
467
  });
282
468
  const boundaryViolations = checkBoundaries(graph, config.boundaries);
283
- const filterSet = options.staged || options.files ? new Set(filesToCheck.map((f) => path4.resolve(projectRoot, f))) : null;
469
+ const filterSet = options.staged || options.files ? new Set(filesToCheck.map((f) => path6.resolve(projectRoot, f))) : null;
284
470
  for (const bv of boundaryViolations) {
285
471
  if (filterSet && !filterSet.has(bv.file)) continue;
286
- const relFile = path4.relative(projectRoot, bv.file);
472
+ const relFile = path6.relative(projectRoot, bv.file);
287
473
  violations.push({
288
474
  file: relFile,
289
475
  rule: "boundary-violation",
@@ -311,153 +497,488 @@ ${violations.length} ${word} found.`);
311
497
  }
312
498
  return 0;
313
499
  }
314
- function countFileLines(filePath) {
315
- try {
316
- const content = fs4.readFileSync(filePath, "utf-8");
317
- if (content.length === 0) return 0;
318
- let count = 1;
319
- for (let i = 0; i < content.length; i++) {
320
- if (content.charCodeAt(i) === 10) count++;
500
+
501
+ // src/commands/fix.ts
502
+ import * as fs9 from "fs";
503
+ import * as path10 from "path";
504
+ import { loadConfig as loadConfig3 } from "@viberails/config";
505
+ import chalk4 from "chalk";
506
+
507
+ // src/commands/fix-helpers.ts
508
+ import { execSync as execSync2 } from "child_process";
509
+ import { createInterface as createInterface2 } from "readline";
510
+ import chalk3 from "chalk";
511
+ function printPlan(renames, stubs) {
512
+ if (renames.length > 0) {
513
+ console.log(chalk3.bold("\nFile renames:"));
514
+ for (const r of renames) {
515
+ console.log(` ${chalk3.red(r.oldPath)} \u2192 ${chalk3.green(r.newPath)}`);
321
516
  }
322
- return count;
323
- } catch {
324
- return null;
325
517
  }
326
- }
327
- function checkNaming(relPath, config) {
328
- const filename = path4.basename(relPath);
329
- const ext = path4.extname(filename);
330
- if (!SOURCE_EXTS.has(ext)) return void 0;
331
- if (filename.startsWith("index.") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith(".")) {
332
- return void 0;
333
- }
334
- const bare = filename.slice(0, filename.indexOf("."));
335
- const convention = typeof config.conventions.fileNaming === "string" ? config.conventions.fileNaming : config.conventions.fileNaming?.value;
336
- if (!convention) return void 0;
337
- const pattern = NAMING_PATTERNS[convention];
338
- if (!pattern || pattern.test(bare)) return void 0;
339
- return `File name "${filename}" does not follow ${convention} convention.`;
340
- }
341
- function checkMissingTests(projectRoot, config, severity) {
342
- const violations = [];
343
- const { testPattern } = config.structure;
344
- if (!testPattern) return violations;
345
- const srcDir = config.structure.srcDir;
346
- if (!srcDir) return violations;
347
- const srcPath = path4.join(projectRoot, srcDir);
348
- if (!fs4.existsSync(srcPath)) return violations;
349
- const testSuffix = testPattern.replace("*", "");
350
- const sourceFiles = collectSourceFiles(srcPath, projectRoot);
351
- for (const relFile of sourceFiles) {
352
- const basename2 = path4.basename(relFile);
353
- if (basename2.includes(".test.") || basename2.includes(".spec.") || basename2.startsWith("index.") || basename2.endsWith(".d.ts")) {
354
- continue;
355
- }
356
- const ext = path4.extname(basename2);
357
- if (!SOURCE_EXTS.has(ext)) continue;
358
- const stem = basename2.slice(0, basename2.indexOf("."));
359
- const expectedTestFile = `${stem}${testSuffix}`;
360
- const dir = path4.dirname(path4.join(projectRoot, relFile));
361
- const colocatedTest = path4.join(dir, expectedTestFile);
362
- const testsDir = config.structure.tests;
363
- const dedicatedTest = testsDir ? path4.join(projectRoot, testsDir, expectedTestFile) : null;
364
- const hasTest = fs4.existsSync(colocatedTest) || dedicatedTest !== null && fs4.existsSync(dedicatedTest);
365
- if (!hasTest) {
366
- violations.push({
367
- file: relFile,
368
- rule: "missing-test",
369
- message: `No test file found. Expected \`${expectedTestFile}\`.`,
370
- severity
371
- });
518
+ if (stubs.length > 0) {
519
+ console.log(chalk3.bold("\nTest stubs to create:"));
520
+ for (const s of stubs) {
521
+ console.log(` ${chalk3.green("+")} ${s.path}`);
372
522
  }
373
523
  }
374
- return violations;
375
524
  }
376
- function getStagedFiles(projectRoot) {
525
+ function checkGitDirty(projectRoot) {
377
526
  try {
378
- const output = execSync("git diff --cached --name-only --diff-filter=ACM", {
527
+ const output = execSync2("git status --porcelain", {
379
528
  cwd: projectRoot,
380
529
  encoding: "utf-8"
381
530
  });
382
- return output.trim().split("\n").filter(Boolean);
531
+ return output.trim().length > 0;
383
532
  } catch {
384
- return [];
533
+ return false;
385
534
  }
386
535
  }
387
- function getAllSourceFiles(projectRoot, config) {
388
- const files = [];
389
- const walk = (dir) => {
390
- let entries;
391
- try {
392
- entries = fs4.readdirSync(dir, { withFileTypes: true });
393
- } catch {
394
- return;
536
+ function getConventionValue(convention) {
537
+ if (typeof convention === "string") return convention;
538
+ if (convention && typeof convention === "object" && "value" in convention) {
539
+ return convention.value;
540
+ }
541
+ return void 0;
542
+ }
543
+ function promptConfirm(question) {
544
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
545
+ return new Promise((resolve4) => {
546
+ rl.question(`${question} (y/N) `, (answer) => {
547
+ rl.close();
548
+ resolve4(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
549
+ });
550
+ });
551
+ }
552
+
553
+ // src/commands/fix-imports.ts
554
+ import * as path7 from "path";
555
+ function stripExtension(filePath) {
556
+ return filePath.replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
557
+ }
558
+ function computeNewSpecifier(oldSpecifier, newBare) {
559
+ const hasJsExt = oldSpecifier.endsWith(".js");
560
+ const base = hasJsExt ? oldSpecifier.slice(0, -3) : oldSpecifier;
561
+ const dir = base.lastIndexOf("/");
562
+ const prefix = dir >= 0 ? base.slice(0, dir + 1) : "";
563
+ const newSpec = prefix + newBare;
564
+ return hasJsExt ? `${newSpec}.js` : newSpec;
565
+ }
566
+ async function updateImportsAfterRenames(renames, projectRoot) {
567
+ if (renames.length === 0) return [];
568
+ const { Project, SyntaxKind } = await import("ts-morph");
569
+ const renameMap = /* @__PURE__ */ new Map();
570
+ for (const r of renames) {
571
+ const oldStripped = stripExtension(r.oldAbsPath);
572
+ const newFilename = path7.basename(r.newPath);
573
+ const newName = newFilename.slice(0, newFilename.indexOf("."));
574
+ renameMap.set(oldStripped, { newBare: newName });
575
+ }
576
+ const project = new Project({
577
+ tsConfigFilePath: void 0,
578
+ skipAddingFilesFromTsConfig: true
579
+ });
580
+ project.addSourceFilesAtPaths(path7.join(projectRoot, "**/*.{ts,tsx,js,jsx,mjs,cjs}"));
581
+ const updates = [];
582
+ const extensions = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
583
+ for (const sourceFile of project.getSourceFiles()) {
584
+ const filePath = sourceFile.getFilePath();
585
+ if (filePath.includes("/node_modules/") || filePath.includes("/dist/")) continue;
586
+ const fileDir = path7.dirname(filePath);
587
+ for (const decl of sourceFile.getImportDeclarations()) {
588
+ const specifier = decl.getModuleSpecifierValue();
589
+ if (!specifier.startsWith(".")) continue;
590
+ const match = resolveToRenamedFile(specifier, fileDir, renameMap, extensions);
591
+ if (!match) continue;
592
+ const newSpec = computeNewSpecifier(specifier, match.newBare);
593
+ updates.push({
594
+ file: filePath,
595
+ oldSpecifier: specifier,
596
+ newSpecifier: newSpec,
597
+ line: decl.getStartLineNumber()
598
+ });
599
+ decl.setModuleSpecifier(newSpec);
395
600
  }
396
- for (const entry of entries) {
397
- const rel = path4.relative(projectRoot, path4.join(dir, entry.name));
398
- if (entry.isDirectory()) {
399
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
400
- continue;
401
- }
402
- if (isIgnored(rel, config.ignore)) continue;
403
- walk(path4.join(dir, entry.name));
404
- } else if (entry.isFile()) {
405
- const ext = path4.extname(entry.name);
406
- if (SOURCE_EXTS.has(ext) && !isIgnored(rel, config.ignore)) {
407
- files.push(rel);
601
+ for (const decl of sourceFile.getExportDeclarations()) {
602
+ const specifier = decl.getModuleSpecifierValue();
603
+ if (!specifier || !specifier.startsWith(".")) continue;
604
+ const match = resolveToRenamedFile(specifier, fileDir, renameMap, extensions);
605
+ if (!match) continue;
606
+ const newSpec = computeNewSpecifier(specifier, match.newBare);
607
+ updates.push({
608
+ file: filePath,
609
+ oldSpecifier: specifier,
610
+ newSpecifier: newSpec,
611
+ line: decl.getStartLineNumber()
612
+ });
613
+ decl.setModuleSpecifier(newSpec);
614
+ }
615
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
616
+ if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
617
+ const args = call.getArguments();
618
+ if (args.length === 0) continue;
619
+ const arg = args[0];
620
+ if (arg.getKind() !== SyntaxKind.StringLiteral) continue;
621
+ const specifier = arg.getText().slice(1, -1);
622
+ if (!specifier.startsWith(".")) continue;
623
+ const match = resolveToRenamedFile(specifier, fileDir, renameMap, extensions);
624
+ if (!match) continue;
625
+ const newSpec = computeNewSpecifier(specifier, match.newBare);
626
+ updates.push({
627
+ file: filePath,
628
+ oldSpecifier: specifier,
629
+ newSpecifier: newSpec,
630
+ line: call.getStartLineNumber()
631
+ });
632
+ const quote = arg.getText()[0];
633
+ arg.replaceWithText(`${quote}${newSpec}${quote}`);
634
+ }
635
+ }
636
+ if (updates.length > 0) {
637
+ await project.save();
638
+ }
639
+ return updates;
640
+ }
641
+ function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
642
+ const cleanSpec = specifier.endsWith(".js") ? specifier.slice(0, -3) : specifier;
643
+ const resolved = path7.resolve(fromDir, cleanSpec);
644
+ for (const ext of extensions) {
645
+ const candidate = resolved + ext;
646
+ const stripped = stripExtension(candidate);
647
+ const match = renameMap.get(stripped);
648
+ if (match) return match;
649
+ }
650
+ return void 0;
651
+ }
652
+
653
+ // src/commands/fix-naming.ts
654
+ import * as fs7 from "fs";
655
+ import * as path8 from "path";
656
+
657
+ // src/commands/convert-name.ts
658
+ function splitIntoWords(name) {
659
+ const parts = name.split(/[-_]/);
660
+ const words = [];
661
+ for (const part of parts) {
662
+ if (part === "") continue;
663
+ let current = "";
664
+ for (let i = 0; i < part.length; i++) {
665
+ const ch = part[i];
666
+ const isUpper = ch >= "A" && ch <= "Z";
667
+ if (isUpper && current.length > 0) {
668
+ const prevIsUpper = current[current.length - 1] >= "A" && current[current.length - 1] <= "Z";
669
+ const nextIsLower = i + 1 < part.length && part[i + 1] >= "a" && part[i + 1] <= "z";
670
+ if (!prevIsUpper || nextIsLower) {
671
+ words.push(current.toLowerCase());
672
+ current = "";
408
673
  }
409
674
  }
675
+ current += ch;
410
676
  }
677
+ if (current) words.push(current.toLowerCase());
678
+ }
679
+ return words;
680
+ }
681
+ function convertName(bare, target) {
682
+ const words = splitIntoWords(bare);
683
+ if (words.length === 0) return bare;
684
+ switch (target) {
685
+ case "kebab-case":
686
+ return words.join("-");
687
+ case "camelCase":
688
+ return words[0] + words.slice(1).map(capitalize).join("");
689
+ case "PascalCase":
690
+ return words.map(capitalize).join("");
691
+ case "snake_case":
692
+ return words.join("_");
693
+ default:
694
+ return bare;
695
+ }
696
+ }
697
+ function capitalize(word) {
698
+ if (word.length === 0) return word;
699
+ return word[0].toUpperCase() + word.slice(1);
700
+ }
701
+
702
+ // src/commands/fix-naming.ts
703
+ function computeRename(relPath, targetConvention, projectRoot) {
704
+ const filename = path8.basename(relPath);
705
+ const dir = path8.dirname(relPath);
706
+ const dotIndex = filename.indexOf(".");
707
+ if (dotIndex === -1) return null;
708
+ const bare = filename.slice(0, dotIndex);
709
+ const suffix = filename.slice(dotIndex);
710
+ const newBare = convertName(bare, targetConvention);
711
+ if (newBare === bare) return null;
712
+ const newFilename = newBare + suffix;
713
+ const newRelPath = path8.join(dir, newFilename);
714
+ const oldAbsPath = path8.join(projectRoot, relPath);
715
+ const newAbsPath = path8.join(projectRoot, newRelPath);
716
+ if (fs7.existsSync(newAbsPath)) return null;
717
+ return { oldPath: relPath, newPath: newRelPath, oldAbsPath, newAbsPath };
718
+ }
719
+ function executeRename(rename) {
720
+ if (fs7.existsSync(rename.newAbsPath)) return false;
721
+ fs7.renameSync(rename.oldAbsPath, rename.newAbsPath);
722
+ return true;
723
+ }
724
+ function deduplicateRenames(renames) {
725
+ const seen = /* @__PURE__ */ new Set();
726
+ const result = [];
727
+ for (const r of renames) {
728
+ if (seen.has(r.newAbsPath)) continue;
729
+ seen.add(r.newAbsPath);
730
+ result.push(r);
731
+ }
732
+ return result;
733
+ }
734
+
735
+ // src/commands/fix-tests.ts
736
+ import * as fs8 from "fs";
737
+ import * as path9 from "path";
738
+ function generateTestStub(sourceRelPath, config, projectRoot) {
739
+ const { testPattern } = config.structure;
740
+ if (!testPattern) return null;
741
+ const basename6 = path9.basename(sourceRelPath);
742
+ const stem = basename6.slice(0, basename6.indexOf("."));
743
+ const testSuffix = testPattern.replace("*", "");
744
+ const testFilename = `${stem}${testSuffix}`;
745
+ const dir = path9.dirname(path9.join(projectRoot, sourceRelPath));
746
+ const testAbsPath = path9.join(dir, testFilename);
747
+ if (fs8.existsSync(testAbsPath)) return null;
748
+ return {
749
+ path: path9.relative(projectRoot, testAbsPath),
750
+ absPath: testAbsPath,
751
+ moduleName: stem
411
752
  };
412
- walk(projectRoot);
413
- return files;
414
753
  }
415
- function collectSourceFiles(dir, projectRoot) {
416
- const files = [];
417
- const walk = (d) => {
418
- let entries;
419
- try {
420
- entries = fs4.readdirSync(d, { withFileTypes: true });
421
- } catch {
422
- return;
754
+ function writeTestStub(stub, config) {
755
+ const runner = config.stack.testRunner === "jest" ? "jest" : "vitest";
756
+ const importLine = runner === "jest" ? "" : "import { describe, it, expect } from 'vitest';\n\n";
757
+ const content = `${importLine}describe('${stub.moduleName}', () => {
758
+ it.todo('add tests');
759
+ });
760
+ `;
761
+ fs8.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
762
+ fs8.writeFileSync(stub.absPath, content);
763
+ }
764
+
765
+ // src/commands/fix.ts
766
+ var CONFIG_FILE3 = "viberails.config.json";
767
+ async function fixCommand(options, cwd) {
768
+ const startDir = cwd ?? process.cwd();
769
+ const projectRoot = findProjectRoot(startDir);
770
+ if (!projectRoot) {
771
+ console.error(`${chalk4.red("Error:")} No package.json found. Are you in a JS/TS project?`);
772
+ return 1;
773
+ }
774
+ const configPath = path10.join(projectRoot, CONFIG_FILE3);
775
+ if (!fs9.existsSync(configPath)) {
776
+ console.error(
777
+ `${chalk4.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
778
+ );
779
+ return 1;
780
+ }
781
+ const config = await loadConfig3(configPath);
782
+ if (!options.yes && !options.dryRun) {
783
+ const isDirty = checkGitDirty(projectRoot);
784
+ if (isDirty) {
785
+ console.log(
786
+ chalk4.yellow("Warning: You have uncommitted changes. Consider committing first.")
787
+ );
423
788
  }
424
- for (const entry of entries) {
425
- if (entry.isDirectory()) {
426
- if (entry.name === "node_modules") continue;
427
- walk(path4.join(d, entry.name));
428
- } else if (entry.isFile()) {
429
- files.push(path4.relative(projectRoot, path4.join(d, entry.name)));
430
- }
789
+ }
790
+ const shouldFixNaming = !options.rule || options.rule.includes("file-naming");
791
+ const shouldFixTests = !options.rule || options.rule.includes("missing-test");
792
+ const allFiles = getAllSourceFiles(projectRoot, config);
793
+ const renames = [];
794
+ if (shouldFixNaming) {
795
+ for (const file of allFiles) {
796
+ const resolved = resolveConfigForFile(file, config);
797
+ if (!resolved.rules.enforceNaming || !resolved.conventions.fileNaming) continue;
798
+ const violation = checkNaming(file, resolved.conventions);
799
+ if (!violation) continue;
800
+ const convention = getConventionValue(resolved.conventions.fileNaming);
801
+ if (!convention) continue;
802
+ const rename = computeRename(file, convention, projectRoot);
803
+ if (rename) renames.push(rename);
431
804
  }
432
- };
433
- walk(dir);
434
- return files;
435
- }
436
- function isIgnored(relPath, ignorePatterns) {
437
- for (const pattern of ignorePatterns) {
438
- if (pattern.endsWith("/**")) {
439
- const prefix = pattern.slice(0, -3);
440
- if (relPath.startsWith(`${prefix}/`) || relPath === prefix) return true;
441
- } else if (pattern.startsWith("**/")) {
442
- const suffix = pattern.slice(3);
443
- if (relPath.endsWith(suffix)) return true;
444
- } else if (relPath === pattern || relPath.startsWith(`${pattern}/`)) {
445
- return true;
805
+ }
806
+ const dedupedRenames = deduplicateRenames(renames);
807
+ const testStubs = [];
808
+ if (shouldFixTests && config.rules.requireTests) {
809
+ const testViolations = checkMissingTests(projectRoot, config, "warn");
810
+ for (const v of testViolations) {
811
+ const stub = generateTestStub(v.file, config, projectRoot);
812
+ if (stub) testStubs.push(stub);
446
813
  }
447
814
  }
448
- return false;
815
+ if (dedupedRenames.length === 0 && testStubs.length === 0) {
816
+ console.log(`${chalk4.green("\u2713")} No fixable violations found.`);
817
+ return 0;
818
+ }
819
+ printPlan(dedupedRenames, testStubs);
820
+ if (options.dryRun) {
821
+ console.log(chalk4.dim("\nDry run \u2014 no changes applied."));
822
+ return 0;
823
+ }
824
+ if (!options.yes) {
825
+ const confirmed = await promptConfirm("Apply these fixes?");
826
+ if (!confirmed) {
827
+ console.log("Aborted.");
828
+ return 0;
829
+ }
830
+ }
831
+ let renameCount = 0;
832
+ for (const rename of dedupedRenames) {
833
+ if (executeRename(rename)) {
834
+ renameCount++;
835
+ }
836
+ }
837
+ let importUpdateCount = 0;
838
+ if (renameCount > 0) {
839
+ const appliedRenames = dedupedRenames.filter((r) => fs9.existsSync(r.newAbsPath));
840
+ const updates = await updateImportsAfterRenames(appliedRenames, projectRoot);
841
+ importUpdateCount = updates.length;
842
+ }
843
+ let stubCount = 0;
844
+ for (const stub of testStubs) {
845
+ if (!fs9.existsSync(stub.absPath)) {
846
+ writeTestStub(stub, config);
847
+ stubCount++;
848
+ }
849
+ }
850
+ console.log("");
851
+ if (renameCount > 0) {
852
+ console.log(`${chalk4.green("\u2713")} Renamed ${renameCount} file${renameCount > 1 ? "s" : ""}`);
853
+ }
854
+ if (importUpdateCount > 0) {
855
+ console.log(
856
+ `${chalk4.green("\u2713")} Updated ${importUpdateCount} import${importUpdateCount > 1 ? "s" : ""}`
857
+ );
858
+ }
859
+ if (stubCount > 0) {
860
+ console.log(`${chalk4.green("\u2713")} Generated ${stubCount} test stub${stubCount > 1 ? "s" : ""}`);
861
+ }
862
+ return 0;
449
863
  }
450
864
 
451
865
  // src/commands/init.ts
452
- import * as fs6 from "fs";
453
- import * as path6 from "path";
866
+ import * as fs12 from "fs";
867
+ import * as path13 from "path";
454
868
  import { generateConfig } from "@viberails/config";
455
869
  import { scan } from "@viberails/scanner";
456
- import chalk4 from "chalk";
870
+ import chalk8 from "chalk";
871
+
872
+ // src/display.ts
873
+ import { FRAMEWORK_NAMES as FRAMEWORK_NAMES2, LIBRARY_NAMES, STYLING_NAMES as STYLING_NAMES2 } from "@viberails/types";
874
+ import chalk6 from "chalk";
875
+
876
+ // src/display-helpers.ts
877
+ import { ROLE_DESCRIPTIONS } from "@viberails/types";
878
+ function groupByRole(directories) {
879
+ const map = /* @__PURE__ */ new Map();
880
+ for (const dir of directories) {
881
+ if (dir.role === "unknown") continue;
882
+ const existing = map.get(dir.role);
883
+ if (existing) {
884
+ existing.dirs.push(dir);
885
+ } else {
886
+ map.set(dir.role, { dirs: [dir] });
887
+ }
888
+ }
889
+ const groups = [];
890
+ for (const [role, { dirs }] of map) {
891
+ const label = ROLE_DESCRIPTIONS[role] ?? role;
892
+ const totalFiles = dirs.reduce((sum, d) => sum + d.fileCount, 0);
893
+ groups.push({
894
+ role,
895
+ label,
896
+ dirCount: dirs.length,
897
+ totalFiles,
898
+ singlePath: dirs.length === 1 ? dirs[0].path : void 0
899
+ });
900
+ }
901
+ return groups;
902
+ }
903
+ function formatSummary(stats, packageCount) {
904
+ const parts = [];
905
+ if (packageCount && packageCount > 1) {
906
+ parts.push(`${packageCount} packages`);
907
+ }
908
+ parts.push(`${stats.totalFiles.toLocaleString()} source files`);
909
+ parts.push(`${stats.totalLines.toLocaleString()} lines`);
910
+ parts.push(`avg ${Math.round(stats.averageFileLines)} lines/file`);
911
+ return parts.join(" \xB7 ");
912
+ }
913
+ function formatExtensions(filesByExtension, maxEntries = 4) {
914
+ return Object.entries(filesByExtension).sort(([, a], [, b]) => b - a).slice(0, maxEntries).map(([ext, count]) => `${ext} ${count}`).join(" \xB7 ");
915
+ }
916
+ function formatRoleGroup(group) {
917
+ const files = group.totalFiles === 1 ? "1 file" : `${group.totalFiles} files`;
918
+ if (group.singlePath) {
919
+ return `${group.label} \u2014 ${group.singlePath} (${files})`;
920
+ }
921
+ const dirs = group.dirCount === 1 ? "1 dir" : `${group.dirCount} dirs`;
922
+ return `${group.label} \u2014 ${dirs} (${files})`;
923
+ }
924
+
925
+ // src/display-monorepo.ts
926
+ import { FRAMEWORK_NAMES, STYLING_NAMES } from "@viberails/types";
927
+ import chalk5 from "chalk";
928
+ function formatPackageSummary(pkg) {
929
+ const parts = [];
930
+ if (pkg.stack.framework) {
931
+ parts.push(formatItem(pkg.stack.framework, FRAMEWORK_NAMES));
932
+ }
933
+ if (pkg.stack.styling) {
934
+ parts.push(formatItem(pkg.stack.styling, STYLING_NAMES));
935
+ }
936
+ const files = `${pkg.statistics.totalFiles} files`;
937
+ const detail = parts.length > 0 ? `${parts.join(", ")} (${files})` : `(${files})`;
938
+ return ` ${pkg.relativePath} \u2014 ${detail}`;
939
+ }
940
+ function displayMonorepoResults(scanResult) {
941
+ const { stack, packages } = scanResult;
942
+ console.log(`
943
+ ${chalk5.bold(`Detected: (monorepo, ${packages.length} packages)`)}`);
944
+ console.log(` ${chalk5.green("\u2713")} ${formatItem(stack.language)}`);
945
+ if (stack.packageManager) {
946
+ console.log(` ${chalk5.green("\u2713")} ${formatItem(stack.packageManager)}`);
947
+ }
948
+ if (stack.linter) {
949
+ console.log(` ${chalk5.green("\u2713")} ${formatItem(stack.linter)}`);
950
+ }
951
+ if (stack.formatter) {
952
+ console.log(` ${chalk5.green("\u2713")} ${formatItem(stack.formatter)}`);
953
+ }
954
+ if (stack.testRunner) {
955
+ console.log(` ${chalk5.green("\u2713")} ${formatItem(stack.testRunner)}`);
956
+ }
957
+ console.log("");
958
+ for (const pkg of packages) {
959
+ console.log(formatPackageSummary(pkg));
960
+ }
961
+ const packagesWithDirs = packages.filter(
962
+ (pkg) => pkg.structure.directories.some((d) => d.role !== "unknown")
963
+ );
964
+ if (packagesWithDirs.length > 0) {
965
+ console.log(`
966
+ ${chalk5.bold("Structure:")}`);
967
+ for (const pkg of packagesWithDirs) {
968
+ const groups = groupByRole(pkg.structure.directories);
969
+ if (groups.length === 0) continue;
970
+ console.log(` ${pkg.relativePath}:`);
971
+ for (const group of groups) {
972
+ console.log(` ${chalk5.green("\u2713")} ${formatRoleGroup(group)}`);
973
+ }
974
+ }
975
+ }
976
+ displayConventions(scanResult);
977
+ displaySummarySection(scanResult);
978
+ console.log("");
979
+ }
457
980
 
458
981
  // src/display.ts
459
- import { FRAMEWORK_NAMES, LIBRARY_NAMES, ROLE_DESCRIPTIONS, STYLING_NAMES } from "@viberails/types";
460
- import chalk3 from "chalk";
461
982
  var CONVENTION_LABELS = {
462
983
  fileNaming: "File naming",
463
984
  componentNaming: "Component naming",
@@ -475,82 +996,194 @@ function confidenceLabel(convention) {
475
996
  }
476
997
  return `${pct}% \u2014 medium confidence, suggested only`;
477
998
  }
999
+ function displayConventions(scanResult) {
1000
+ const conventionEntries = Object.entries(scanResult.conventions);
1001
+ if (conventionEntries.length === 0) return;
1002
+ console.log(`
1003
+ ${chalk6.bold("Conventions:")}`);
1004
+ for (const [key, convention] of conventionEntries) {
1005
+ if (convention.confidence === "low") continue;
1006
+ const label = CONVENTION_LABELS[key] ?? key;
1007
+ if (scanResult.packages.length > 1) {
1008
+ const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1009
+ const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
1010
+ if (allSame || pkgValues.length <= 1) {
1011
+ const ind = convention.confidence === "high" ? chalk6.green("\u2713") : chalk6.yellow("~");
1012
+ const detail = chalk6.dim(`(${confidenceLabel(convention)})`);
1013
+ console.log(` ${ind} ${label}: ${convention.value} ${detail}`);
1014
+ } else {
1015
+ console.log(` ${chalk6.yellow("~")} ${label}: varies by package`);
1016
+ for (const pv of pkgValues) {
1017
+ const pct = Math.round(pv.convention.consistency);
1018
+ console.log(` ${pv.relativePath}: ${pv.convention.value} (${pct}%)`);
1019
+ }
1020
+ }
1021
+ } else {
1022
+ const ind = convention.confidence === "high" ? chalk6.green("\u2713") : chalk6.yellow("~");
1023
+ const detail = chalk6.dim(`(${confidenceLabel(convention)})`);
1024
+ console.log(` ${ind} ${label}: ${convention.value} ${detail}`);
1025
+ }
1026
+ }
1027
+ }
1028
+ function displaySummarySection(scanResult) {
1029
+ const pkgCount = scanResult.packages.length > 1 ? scanResult.packages.length : void 0;
1030
+ console.log(`
1031
+ ${chalk6.bold("Summary:")}`);
1032
+ console.log(` ${formatSummary(scanResult.statistics, pkgCount)}`);
1033
+ const ext = formatExtensions(scanResult.statistics.filesByExtension);
1034
+ if (ext) {
1035
+ console.log(` ${ext}`);
1036
+ }
1037
+ }
478
1038
  function displayScanResults(scanResult) {
479
- const { stack, conventions } = scanResult;
1039
+ if (scanResult.packages.length > 1) {
1040
+ displayMonorepoResults(scanResult);
1041
+ return;
1042
+ }
1043
+ const { stack } = scanResult;
480
1044
  console.log(`
481
- ${chalk3.bold("Detected:")}`);
1045
+ ${chalk6.bold("Detected:")}`);
482
1046
  if (stack.framework) {
483
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.framework, FRAMEWORK_NAMES)}`);
1047
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.framework, FRAMEWORK_NAMES2)}`);
484
1048
  }
485
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.language)}`);
1049
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.language)}`);
486
1050
  if (stack.styling) {
487
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.styling, STYLING_NAMES)}`);
1051
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.styling, STYLING_NAMES2)}`);
488
1052
  }
489
1053
  if (stack.backend) {
490
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.backend, FRAMEWORK_NAMES)}`);
1054
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.backend, FRAMEWORK_NAMES2)}`);
491
1055
  }
492
1056
  if (stack.linter) {
493
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.linter)}`);
1057
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.linter)}`);
1058
+ }
1059
+ if (stack.formatter) {
1060
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.formatter)}`);
494
1061
  }
495
1062
  if (stack.testRunner) {
496
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.testRunner)}`);
1063
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.testRunner)}`);
497
1064
  }
498
1065
  if (stack.packageManager) {
499
- console.log(` ${chalk3.green("\u2713")} ${formatItem(stack.packageManager)}`);
1066
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(stack.packageManager)}`);
500
1067
  }
501
1068
  if (stack.libraries.length > 0) {
502
1069
  for (const lib of stack.libraries) {
503
- console.log(` ${chalk3.green("\u2713")} ${formatItem(lib, LIBRARY_NAMES)}`);
504
- }
505
- }
506
- const meaningfulDirs = scanResult.structure.directories.filter((d) => d.role !== "unknown");
507
- if (meaningfulDirs.length > 0) {
508
- console.log(`
509
- ${chalk3.bold("Structure:")}`);
510
- for (const dir of meaningfulDirs) {
511
- const label = ROLE_DESCRIPTIONS[dir.role] ?? dir.role;
512
- const files = dir.fileCount === 1 ? "1 file" : `${dir.fileCount} files`;
513
- console.log(` ${chalk3.green("\u2713")} ${dir.path} \u2014 ${label} (${files})`);
1070
+ console.log(` ${chalk6.green("\u2713")} ${formatItem(lib, LIBRARY_NAMES)}`);
514
1071
  }
515
1072
  }
516
- const conventionEntries = Object.entries(conventions);
517
- if (conventionEntries.length > 0) {
1073
+ const groups = groupByRole(scanResult.structure.directories);
1074
+ if (groups.length > 0) {
518
1075
  console.log(`
519
- ${chalk3.bold("Conventions:")}`);
520
- for (const [key, convention] of conventionEntries) {
521
- if (convention.confidence === "low") continue;
522
- const label = CONVENTION_LABELS[key] ?? key;
523
- const ind = convention.confidence === "high" ? chalk3.green("\u2713") : chalk3.yellow("~");
524
- const detail = chalk3.dim(`(${confidenceLabel(convention)})`);
525
- console.log(` ${ind} ${label}: ${convention.value} ${detail}`);
1076
+ ${chalk6.bold("Structure:")}`);
1077
+ for (const group of groups) {
1078
+ console.log(` ${chalk6.green("\u2713")} ${formatRoleGroup(group)}`);
526
1079
  }
527
1080
  }
1081
+ displayConventions(scanResult);
1082
+ displaySummarySection(scanResult);
528
1083
  console.log("");
529
1084
  }
530
1085
 
531
1086
  // src/utils/write-generated-files.ts
532
- import * as fs5 from "fs";
533
- import * as path5 from "path";
1087
+ import * as fs10 from "fs";
1088
+ import * as path11 from "path";
534
1089
  import { generateContext } from "@viberails/context";
535
1090
  var CONTEXT_DIR = ".viberails";
536
1091
  var CONTEXT_FILE = "context.md";
537
1092
  var SCAN_RESULT_FILE = "scan-result.json";
538
1093
  function writeGeneratedFiles(projectRoot, config, scanResult) {
539
- const contextDir = path5.join(projectRoot, CONTEXT_DIR);
540
- if (!fs5.existsSync(contextDir)) {
541
- fs5.mkdirSync(contextDir, { recursive: true });
542
- }
543
- const context = generateContext(config);
544
- fs5.writeFileSync(path5.join(contextDir, CONTEXT_FILE), context);
545
- fs5.writeFileSync(
546
- path5.join(contextDir, SCAN_RESULT_FILE),
547
- `${JSON.stringify(scanResult, null, 2)}
1094
+ const contextDir = path11.join(projectRoot, CONTEXT_DIR);
1095
+ try {
1096
+ if (!fs10.existsSync(contextDir)) {
1097
+ fs10.mkdirSync(contextDir, { recursive: true });
1098
+ }
1099
+ const context = generateContext(config);
1100
+ fs10.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1101
+ fs10.writeFileSync(
1102
+ path11.join(contextDir, SCAN_RESULT_FILE),
1103
+ `${JSON.stringify(scanResult, null, 2)}
548
1104
  `
549
- );
1105
+ );
1106
+ } catch (err) {
1107
+ const message = err instanceof Error ? err.message : String(err);
1108
+ throw new Error(`Failed to write generated files to ${contextDir}: ${message}`);
1109
+ }
1110
+ }
1111
+
1112
+ // src/commands/init-hooks.ts
1113
+ import * as fs11 from "fs";
1114
+ import * as path12 from "path";
1115
+ import chalk7 from "chalk";
1116
+ function setupPreCommitHook(projectRoot) {
1117
+ const lefthookPath = path12.join(projectRoot, "lefthook.yml");
1118
+ if (fs11.existsSync(lefthookPath)) {
1119
+ addLefthookPreCommit(lefthookPath);
1120
+ console.log(` ${chalk7.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
1121
+ return;
1122
+ }
1123
+ const huskyDir = path12.join(projectRoot, ".husky");
1124
+ if (fs11.existsSync(huskyDir)) {
1125
+ writeHuskyPreCommit(huskyDir);
1126
+ console.log(` ${chalk7.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
1127
+ return;
1128
+ }
1129
+ const gitDir = path12.join(projectRoot, ".git");
1130
+ if (fs11.existsSync(gitDir)) {
1131
+ const hooksDir = path12.join(gitDir, "hooks");
1132
+ if (!fs11.existsSync(hooksDir)) {
1133
+ fs11.mkdirSync(hooksDir, { recursive: true });
1134
+ }
1135
+ writeGitHookPreCommit(hooksDir);
1136
+ console.log(` ${chalk7.green("\u2713")} .git/hooks/pre-commit`);
1137
+ }
1138
+ }
1139
+ function writeGitHookPreCommit(hooksDir) {
1140
+ const hookPath = path12.join(hooksDir, "pre-commit");
1141
+ if (fs11.existsSync(hookPath)) {
1142
+ const existing = fs11.readFileSync(hookPath, "utf-8");
1143
+ if (existing.includes("viberails")) return;
1144
+ fs11.writeFileSync(
1145
+ hookPath,
1146
+ `${existing.trimEnd()}
1147
+
1148
+ # viberails check
1149
+ npx viberails check --staged
1150
+ `
1151
+ );
1152
+ return;
1153
+ }
1154
+ const script = [
1155
+ "#!/bin/sh",
1156
+ "# Generated by viberails \u2014 https://viberails.sh",
1157
+ "",
1158
+ "npx viberails check --staged",
1159
+ ""
1160
+ ].join("\n");
1161
+ fs11.writeFileSync(hookPath, script, { mode: 493 });
1162
+ }
1163
+ function addLefthookPreCommit(lefthookPath) {
1164
+ const content = fs11.readFileSync(lefthookPath, "utf-8");
1165
+ if (content.includes("viberails")) return;
1166
+ const addition = ["", " viberails:", " run: npx viberails check --staged"].join("\n");
1167
+ fs11.writeFileSync(lefthookPath, `${content.trimEnd()}
1168
+ ${addition}
1169
+ `);
1170
+ }
1171
+ function writeHuskyPreCommit(huskyDir) {
1172
+ const hookPath = path12.join(huskyDir, "pre-commit");
1173
+ if (fs11.existsSync(hookPath)) {
1174
+ const existing = fs11.readFileSync(hookPath, "utf-8");
1175
+ if (!existing.includes("viberails")) {
1176
+ fs11.writeFileSync(hookPath, `${existing.trimEnd()}
1177
+ npx viberails check --staged
1178
+ `);
1179
+ }
1180
+ return;
1181
+ }
1182
+ fs11.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
550
1183
  }
551
1184
 
552
1185
  // src/commands/init.ts
553
- var CONFIG_FILE3 = "viberails.config.json";
1186
+ var CONFIG_FILE4 = "viberails.config.json";
554
1187
  function filterHighConfidence(conventions) {
555
1188
  const filtered = {};
556
1189
  for (const [key, value] of Object.entries(conventions)) {
@@ -571,19 +1204,19 @@ async function initCommand(options, cwd) {
571
1204
  "No package.json found in this directory or any parent.\n\nMake sure you are inside a JavaScript or TypeScript project, then run:\n npx viberails"
572
1205
  );
573
1206
  }
574
- const configPath = path6.join(projectRoot, CONFIG_FILE3);
575
- if (fs6.existsSync(configPath)) {
1207
+ const configPath = path13.join(projectRoot, CONFIG_FILE4);
1208
+ if (fs12.existsSync(configPath)) {
576
1209
  console.log(
577
- chalk4.yellow("!") + " viberails is already initialized in this project.\n Run " + chalk4.cyan("viberails sync") + " to update the generated files."
1210
+ chalk8.yellow("!") + " viberails is already initialized in this project.\n Run " + chalk8.cyan("viberails sync") + " to update the generated files."
578
1211
  );
579
1212
  return;
580
1213
  }
581
- console.log(chalk4.dim("Scanning project..."));
1214
+ console.log(chalk8.dim("Scanning project..."));
582
1215
  const scanResult = await scan(projectRoot);
583
1216
  displayScanResults(scanResult);
584
1217
  if (scanResult.statistics.totalFiles === 0) {
585
1218
  console.log(
586
- chalk4.yellow("!") + " No source files detected. viberails will generate context with minimal content.\n Run " + chalk4.cyan("viberails sync") + " after adding source files.\n"
1219
+ chalk8.yellow("!") + " No source files detected. viberails will generate context with minimal content.\n Run " + chalk8.cyan("viberails sync") + " after adding source files.\n"
587
1220
  );
588
1221
  }
589
1222
  if (!options.yes) {
@@ -603,7 +1236,7 @@ async function initCommand(options, cwd) {
603
1236
  shouldInfer = await confirm("Infer boundary rules from import patterns?");
604
1237
  }
605
1238
  if (shouldInfer) {
606
- console.log(chalk4.dim("Building import graph..."));
1239
+ console.log(chalk8.dim("Building import graph..."));
607
1240
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
608
1241
  const packages = resolveWorkspacePackages(projectRoot, config.workspace);
609
1242
  const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
@@ -611,116 +1244,48 @@ async function initCommand(options, cwd) {
611
1244
  if (inferred.length > 0) {
612
1245
  config.boundaries = inferred;
613
1246
  config.rules.enforceBoundaries = true;
614
- console.log(` ${chalk4.green("\u2713")} Inferred ${inferred.length} boundary rules`);
1247
+ console.log(` ${chalk8.green("\u2713")} Inferred ${inferred.length} boundary rules`);
615
1248
  }
616
1249
  }
617
1250
  }
618
- fs6.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1251
+ fs12.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
619
1252
  `);
620
1253
  writeGeneratedFiles(projectRoot, config, scanResult);
621
1254
  updateGitignore(projectRoot);
622
1255
  setupPreCommitHook(projectRoot);
623
1256
  console.log(`
624
- ${chalk4.bold("Created:")}`);
625
- console.log(` ${chalk4.green("\u2713")} ${CONFIG_FILE3}`);
626
- console.log(` ${chalk4.green("\u2713")} .viberails/context.md`);
627
- console.log(` ${chalk4.green("\u2713")} .viberails/scan-result.json`);
1257
+ ${chalk8.bold("Created:")}`);
1258
+ console.log(` ${chalk8.green("\u2713")} ${CONFIG_FILE4}`);
1259
+ console.log(` ${chalk8.green("\u2713")} .viberails/context.md`);
1260
+ console.log(` ${chalk8.green("\u2713")} .viberails/scan-result.json`);
628
1261
  console.log(`
629
- ${chalk4.bold("Next steps:")}`);
630
- console.log(` 1. Review ${chalk4.cyan("viberails.config.json")} and adjust rules`);
1262
+ ${chalk8.bold("Next steps:")}`);
1263
+ console.log(` 1. Review ${chalk8.cyan("viberails.config.json")} and adjust rules`);
631
1264
  console.log(
632
- ` 2. Commit ${chalk4.cyan("viberails.config.json")} and ${chalk4.cyan(".viberails/context.md")}`
1265
+ ` 2. Commit ${chalk8.cyan("viberails.config.json")} and ${chalk8.cyan(".viberails/context.md")}`
633
1266
  );
634
- console.log(` 3. Run ${chalk4.cyan("viberails check")} to verify your project passes`);
1267
+ console.log(` 3. Run ${chalk8.cyan("viberails check")} to verify your project passes`);
635
1268
  }
636
1269
  function updateGitignore(projectRoot) {
637
- const gitignorePath = path6.join(projectRoot, ".gitignore");
1270
+ const gitignorePath = path13.join(projectRoot, ".gitignore");
638
1271
  let content = "";
639
- if (fs6.existsSync(gitignorePath)) {
640
- content = fs6.readFileSync(gitignorePath, "utf-8");
1272
+ if (fs12.existsSync(gitignorePath)) {
1273
+ content = fs12.readFileSync(gitignorePath, "utf-8");
641
1274
  }
642
1275
  if (!content.includes(".viberails/scan-result.json")) {
643
1276
  const block = "\n# viberails\n.viberails/scan-result.json\n";
644
- fs6.writeFileSync(gitignorePath, `${content.trimEnd()}
1277
+ fs12.writeFileSync(gitignorePath, `${content.trimEnd()}
645
1278
  ${block}`);
646
1279
  }
647
1280
  }
648
- function setupPreCommitHook(projectRoot) {
649
- const lefthookPath = path6.join(projectRoot, "lefthook.yml");
650
- if (fs6.existsSync(lefthookPath)) {
651
- addLefthookPreCommit(lefthookPath);
652
- console.log(` ${chalk4.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
653
- return;
654
- }
655
- const huskyDir = path6.join(projectRoot, ".husky");
656
- if (fs6.existsSync(huskyDir)) {
657
- writeHuskyPreCommit(huskyDir);
658
- console.log(` ${chalk4.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
659
- return;
660
- }
661
- const gitDir = path6.join(projectRoot, ".git");
662
- if (fs6.existsSync(gitDir)) {
663
- const hooksDir = path6.join(gitDir, "hooks");
664
- if (!fs6.existsSync(hooksDir)) {
665
- fs6.mkdirSync(hooksDir, { recursive: true });
666
- }
667
- writeGitHookPreCommit(hooksDir);
668
- console.log(` ${chalk4.green("\u2713")} .git/hooks/pre-commit`);
669
- }
670
- }
671
- function writeGitHookPreCommit(hooksDir) {
672
- const hookPath = path6.join(hooksDir, "pre-commit");
673
- if (fs6.existsSync(hookPath)) {
674
- const existing = fs6.readFileSync(hookPath, "utf-8");
675
- if (existing.includes("viberails")) return;
676
- fs6.writeFileSync(
677
- hookPath,
678
- `${existing.trimEnd()}
679
-
680
- # viberails check
681
- npx viberails check --staged
682
- `
683
- );
684
- return;
685
- }
686
- const script = [
687
- "#!/bin/sh",
688
- "# Generated by viberails \u2014 https://viberails.sh",
689
- "",
690
- "npx viberails check --staged",
691
- ""
692
- ].join("\n");
693
- fs6.writeFileSync(hookPath, script, { mode: 493 });
694
- }
695
- function addLefthookPreCommit(lefthookPath) {
696
- const content = fs6.readFileSync(lefthookPath, "utf-8");
697
- if (content.includes("viberails")) return;
698
- const addition = ["", " viberails:", " run: npx viberails check --staged"].join("\n");
699
- fs6.writeFileSync(lefthookPath, `${content.trimEnd()}
700
- ${addition}
701
- `);
702
- }
703
- function writeHuskyPreCommit(huskyDir) {
704
- const hookPath = path6.join(huskyDir, "pre-commit");
705
- if (fs6.existsSync(hookPath)) {
706
- const existing = fs6.readFileSync(hookPath, "utf-8");
707
- if (!existing.includes("viberails")) {
708
- fs6.writeFileSync(hookPath, `${existing.trimEnd()}
709
- npx viberails check --staged
710
- `);
711
- }
712
- return;
713
- }
714
- fs6.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
715
- }
716
1281
 
717
1282
  // src/commands/sync.ts
718
- import * as fs7 from "fs";
719
- import * as path7 from "path";
720
- import { loadConfig as loadConfig3, mergeConfig } from "@viberails/config";
1283
+ import * as fs13 from "fs";
1284
+ import * as path14 from "path";
1285
+ import { loadConfig as loadConfig4, mergeConfig } from "@viberails/config";
721
1286
  import { scan as scan2 } from "@viberails/scanner";
722
- import chalk5 from "chalk";
723
- var CONFIG_FILE4 = "viberails.config.json";
1287
+ import chalk9 from "chalk";
1288
+ var CONFIG_FILE5 = "viberails.config.json";
724
1289
  async function syncCommand(cwd) {
725
1290
  const startDir = cwd ?? process.cwd();
726
1291
  const projectRoot = findProjectRoot(startDir);
@@ -729,23 +1294,23 @@ async function syncCommand(cwd) {
729
1294
  "No package.json found in this directory or any parent.\n\nMake sure you are inside a JavaScript or TypeScript project, then run:\n npx viberails"
730
1295
  );
731
1296
  }
732
- const configPath = path7.join(projectRoot, CONFIG_FILE4);
733
- const existing = await loadConfig3(configPath);
734
- console.log(chalk5.dim("Scanning project..."));
1297
+ const configPath = path14.join(projectRoot, CONFIG_FILE5);
1298
+ const existing = await loadConfig4(configPath);
1299
+ console.log(chalk9.dim("Scanning project..."));
735
1300
  const scanResult = await scan2(projectRoot);
736
1301
  const merged = mergeConfig(existing, scanResult);
737
- fs7.writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}
1302
+ fs13.writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}
738
1303
  `);
739
1304
  writeGeneratedFiles(projectRoot, merged, scanResult);
740
1305
  console.log(`
741
- ${chalk5.bold("Synced:")}`);
742
- console.log(` ${chalk5.green("\u2713")} ${CONFIG_FILE4} \u2014 updated`);
743
- console.log(` ${chalk5.green("\u2713")} .viberails/context.md \u2014 regenerated`);
744
- console.log(` ${chalk5.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
1306
+ ${chalk9.bold("Synced:")}`);
1307
+ console.log(` ${chalk9.green("\u2713")} ${CONFIG_FILE5} \u2014 updated`);
1308
+ console.log(` ${chalk9.green("\u2713")} .viberails/context.md \u2014 regenerated`);
1309
+ console.log(` ${chalk9.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
745
1310
  }
746
1311
 
747
1312
  // src/index.ts
748
- var VERSION = "0.1.0";
1313
+ var VERSION = "0.2.1";
749
1314
  var program = new Command();
750
1315
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
751
1316
  program.command("init", { isDefault: true }).description("Scan your project and set up enforcement guardrails").option("-y, --yes", "Non-interactive mode (use defaults, high-confidence only)").action(async (options) => {
@@ -753,7 +1318,7 @@ program.command("init", { isDefault: true }).description("Scan your project and
753
1318
  await initCommand(options);
754
1319
  } catch (err) {
755
1320
  const message = err instanceof Error ? err.message : String(err);
756
- console.error(`${chalk6.red("Error:")} ${message}`);
1321
+ console.error(`${chalk10.red("Error:")} ${message}`);
757
1322
  process.exit(1);
758
1323
  }
759
1324
  });
@@ -762,7 +1327,7 @@ program.command("sync").description("Re-scan and update generated files").action
762
1327
  await syncCommand();
763
1328
  } catch (err) {
764
1329
  const message = err instanceof Error ? err.message : String(err);
765
- console.error(`${chalk6.red("Error:")} ${message}`);
1330
+ console.error(`${chalk10.red("Error:")} ${message}`);
766
1331
  process.exit(1);
767
1332
  }
768
1333
  });
@@ -775,7 +1340,17 @@ program.command("check").description("Check files against enforced rules").optio
775
1340
  process.exit(exitCode);
776
1341
  } catch (err) {
777
1342
  const message = err instanceof Error ? err.message : String(err);
778
- console.error(`${chalk6.red("Error:")} ${message}`);
1343
+ console.error(`${chalk10.red("Error:")} ${message}`);
1344
+ process.exit(1);
1345
+ }
1346
+ });
1347
+ program.command("fix").description("Auto-fix file naming violations and generate missing test stubs").option("--dry-run", "Show planned fixes without applying them").option("--rule <rules...>", "Fix only specific rules (file-naming, missing-test)").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
1348
+ try {
1349
+ const exitCode = await fixCommand(options);
1350
+ process.exit(exitCode);
1351
+ } catch (err) {
1352
+ const message = err instanceof Error ? err.message : String(err);
1353
+ console.error(`${chalk10.red("Error:")} ${message}`);
779
1354
  process.exit(1);
780
1355
  }
781
1356
  });
@@ -784,7 +1359,7 @@ program.command("boundaries").description("Display, infer, or inspect import bou
784
1359
  await boundariesCommand(options);
785
1360
  } catch (err) {
786
1361
  const message = err instanceof Error ? err.message : String(err);
787
- console.error(`${chalk6.red("Error:")} ${message}`);
1362
+ console.error(`${chalk10.red("Error:")} ${message}`);
788
1363
  process.exit(1);
789
1364
  }
790
1365
  });