viberails 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -56,50 +56,115 @@ async function promptInitDecision() {
56
56
  assertNotCancelled(result);
57
57
  return result;
58
58
  }
59
- async function promptRuleCustomization(defaults) {
60
- const maxFileLinesResult = await clack.text({
61
- message: "Maximum lines per source file?",
62
- placeholder: String(defaults.maxFileLines),
63
- initialValue: String(defaults.maxFileLines),
64
- validate: (v) => {
65
- const n = Number.parseInt(v, 10);
66
- if (Number.isNaN(n) || n < 1) return "Enter a positive number";
67
- }
68
- });
69
- assertNotCancelled(maxFileLinesResult);
70
- const requireTestsResult = await clack.confirm({
71
- message: "Require matching test files for source files?",
72
- initialValue: defaults.requireTests
73
- });
74
- assertNotCancelled(requireTestsResult);
75
- const namingLabel = defaults.fileNamingValue ? `Enforce file naming? (detected: ${defaults.fileNamingValue})` : "Enforce file naming?";
76
- const enforceNamingResult = await clack.confirm({
77
- message: namingLabel,
78
- initialValue: defaults.enforceNaming
79
- });
80
- assertNotCancelled(enforceNamingResult);
81
- const enforcementResult = await clack.select({
82
- message: "Enforcement mode",
83
- options: [
59
+ async function promptRuleMenu(defaults) {
60
+ const state = { ...defaults };
61
+ while (true) {
62
+ const namingHint = state.enforceNaming ? `yes${state.fileNamingValue ? ` (${state.fileNamingValue})` : ""}` : "no";
63
+ const enforcementHint = state.enforcement === "warn" ? "warn \u2014 violations shown but commits allowed" : "enforce \u2014 commits blocked on violation";
64
+ const options = [
65
+ { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
84
66
  {
85
- value: "warn",
86
- label: "warn",
87
- hint: "show violations but don't block commits (recommended)"
67
+ value: "requireTests",
68
+ label: "Require test files",
69
+ hint: state.requireTests ? "yes" : "no"
88
70
  },
89
- {
90
- value: "enforce",
91
- label: "enforce",
92
- hint: "block commits with violations"
93
- }
94
- ],
95
- initialValue: defaults.enforcement
96
- });
97
- assertNotCancelled(enforcementResult);
71
+ { value: "enforceNaming", label: "Enforce file naming", hint: namingHint },
72
+ { value: "enforcement", label: "Enforcement mode", hint: enforcementHint }
73
+ ];
74
+ if (state.packageOverrides && state.packageOverrides.length > 0) {
75
+ const count = state.packageOverrides.length;
76
+ options.push({
77
+ value: "packageOverrides",
78
+ label: "Per-package overrides",
79
+ hint: `${count} package${count > 1 ? "s" : ""} differ (view)`
80
+ });
81
+ }
82
+ options.push({ value: "done", label: "Done" });
83
+ const choice = await clack.select({
84
+ message: "Customize rules",
85
+ options
86
+ });
87
+ assertNotCancelled(choice);
88
+ if (choice === "done") break;
89
+ if (choice === "packageOverrides" && state.packageOverrides) {
90
+ const lines = state.packageOverrides.map((pkg) => {
91
+ const diffs = [];
92
+ if (pkg.conventions) {
93
+ for (const [key, val] of Object.entries(pkg.conventions)) {
94
+ const v = typeof val === "string" ? val : val?.value;
95
+ if (v) diffs.push(`${key}: ${v}`);
96
+ }
97
+ }
98
+ if (pkg.stack) {
99
+ for (const [key, val] of Object.entries(pkg.stack)) {
100
+ if (val) diffs.push(`${key}: ${val}`);
101
+ }
102
+ }
103
+ return `${pkg.path}
104
+ ${diffs.join(", ") || "minor differences"}`;
105
+ });
106
+ clack.note(
107
+ `${lines.join("\n\n")}
108
+
109
+ Edit the "packages" section in viberails.config.json to adjust.`,
110
+ "Per-package overrides"
111
+ );
112
+ continue;
113
+ }
114
+ if (choice === "maxFileLines") {
115
+ const result = await clack.text({
116
+ message: "Maximum lines per source file?",
117
+ initialValue: String(state.maxFileLines),
118
+ validate: (v) => {
119
+ const n = Number.parseInt(v, 10);
120
+ if (Number.isNaN(n) || n < 1) return "Enter a positive number";
121
+ }
122
+ });
123
+ assertNotCancelled(result);
124
+ state.maxFileLines = Number.parseInt(result, 10);
125
+ }
126
+ if (choice === "requireTests") {
127
+ const result = await clack.confirm({
128
+ message: "Require matching test files for source files?",
129
+ initialValue: state.requireTests
130
+ });
131
+ assertNotCancelled(result);
132
+ state.requireTests = result;
133
+ }
134
+ if (choice === "enforceNaming") {
135
+ const result = await clack.confirm({
136
+ message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
137
+ initialValue: state.enforceNaming
138
+ });
139
+ assertNotCancelled(result);
140
+ state.enforceNaming = result;
141
+ }
142
+ if (choice === "enforcement") {
143
+ const result = await clack.select({
144
+ message: "Enforcement mode",
145
+ options: [
146
+ {
147
+ value: "warn",
148
+ label: "warn",
149
+ hint: "show violations but don't block commits (recommended)"
150
+ },
151
+ {
152
+ value: "enforce",
153
+ label: "enforce",
154
+ hint: "block commits with violations"
155
+ }
156
+ ],
157
+ initialValue: state.enforcement
158
+ });
159
+ assertNotCancelled(result);
160
+ state.enforcement = result;
161
+ }
162
+ }
98
163
  return {
99
- maxFileLines: Number.parseInt(maxFileLinesResult, 10),
100
- requireTests: requireTestsResult,
101
- enforceNaming: enforceNamingResult,
102
- enforcement: enforcementResult
164
+ maxFileLines: state.maxFileLines,
165
+ requireTests: state.requireTests,
166
+ enforceNaming: state.enforceNaming,
167
+ enforcement: state.enforcement
103
168
  };
104
169
  }
105
170
  async function promptIntegrations(hookManager) {
@@ -116,15 +181,21 @@ async function promptIntegrations(hookManager) {
116
181
  value: "claude",
117
182
  label: "Claude Code hook",
118
183
  hint: "checks files when Claude edits them"
184
+ },
185
+ {
186
+ value: "claudeMd",
187
+ label: "CLAUDE.md reference",
188
+ hint: "appends @.viberails/context.md so Claude loads rules automatically"
119
189
  }
120
190
  ],
121
- initialValues: ["preCommit", "claude"],
191
+ initialValues: ["preCommit", "claude", "claudeMd"],
122
192
  required: false
123
193
  });
124
194
  assertNotCancelled(result);
125
195
  return {
126
196
  preCommitHook: result.includes("preCommit"),
127
- claudeCodeHook: result.includes("claude")
197
+ claudeCodeHook: result.includes("claude"),
198
+ claudeMdRef: result.includes("claudeMd")
128
199
  };
129
200
  }
130
201
 
@@ -182,22 +253,21 @@ async function boundariesCommand(options, cwd) {
182
253
  displayRules(config);
183
254
  }
184
255
  function displayRules(config) {
185
- if (!config.boundaries || config.boundaries.length === 0) {
256
+ if (!config.boundaries || Object.keys(config.boundaries.deny).length === 0) {
186
257
  console.log(chalk.yellow("No boundary rules configured."));
187
258
  console.log(`Run ${chalk.cyan("viberails boundaries --infer")} to generate rules.`);
188
259
  return;
189
260
  }
190
- const allowRules = config.boundaries.filter((r) => r.allow);
191
- const denyRules = config.boundaries.filter((r) => !r.allow);
261
+ const { deny } = config.boundaries;
262
+ const sources = Object.keys(deny).filter((k) => deny[k].length > 0);
263
+ const totalRules = sources.reduce((sum, k) => sum + deny[k].length, 0);
192
264
  console.log(`
193
- ${chalk.bold(`Boundary rules (${config.boundaries.length} rules):`)}
265
+ ${chalk.bold(`Boundary rules (${totalRules} deny rules):`)}
194
266
  `);
195
- for (const r of allowRules) {
196
- console.log(` ${chalk.green("\u2713")} ${r.from} \u2192 ${r.to}`);
197
- }
198
- for (const r of denyRules) {
199
- const reason = r.reason ? chalk.dim(` (${r.reason})`) : "";
200
- console.log(` ${chalk.red("\u2717")} ${r.from} \u2192 ${r.to}${reason}`);
267
+ for (const source of sources) {
268
+ for (const target of deny[source]) {
269
+ console.log(` ${chalk.red("\u2717")} ${source} \u2192 ${target}`);
270
+ }
201
271
  }
202
272
  console.log(
203
273
  `
@@ -214,24 +284,22 @@ async function inferAndDisplay(projectRoot, config, configPath) {
214
284
  });
215
285
  console.log(chalk.dim(`${graph.nodes.length} files, ${graph.edges.length} edges`));
216
286
  const inferred = inferBoundaries(graph);
217
- if (inferred.length === 0) {
287
+ const sources = Object.keys(inferred.deny).filter((k) => inferred.deny[k].length > 0);
288
+ const totalRules = sources.reduce((sum, k) => sum + inferred.deny[k].length, 0);
289
+ if (totalRules === 0) {
218
290
  console.log(chalk.yellow("No boundary rules could be inferred."));
219
291
  return;
220
292
  }
221
- const allow = inferred.filter((r) => r.allow);
222
- const deny = inferred.filter((r) => !r.allow);
223
293
  console.log(`
224
294
  ${chalk.bold("Inferred boundary rules:")}
225
295
  `);
226
- for (const r of allow) {
227
- console.log(` ${chalk.green("\u2713")} ${r.from} \u2192 ${r.to}`);
228
- }
229
- for (const r of deny) {
230
- const reason = r.reason ? chalk.dim(` (${r.reason})`) : "";
231
- console.log(` ${chalk.red("\u2717")} ${r.from} \u2192 ${r.to}${reason}`);
296
+ for (const source of sources) {
297
+ for (const target of inferred.deny[source]) {
298
+ console.log(` ${chalk.red("\u2717")} ${source} \u2192 ${target}`);
299
+ }
232
300
  }
233
301
  console.log(`
234
- ${allow.length} allowed, ${deny.length} denied`);
302
+ ${totalRules} denied`);
235
303
  console.log("");
236
304
  const shouldSave = await confirm2("Save to viberails.config.json?");
237
305
  if (shouldSave) {
@@ -239,7 +307,7 @@ ${chalk.bold("Inferred boundary rules:")}
239
307
  config.rules.enforceBoundaries = true;
240
308
  fs3.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
241
309
  `);
242
- console.log(`${chalk.green("\u2713")} Saved ${inferred.length} rules`);
310
+ console.log(`${chalk.green("\u2713")} Saved ${totalRules} rules`);
243
311
  }
244
312
  }
245
313
  async function showGraph(projectRoot, config) {
@@ -557,7 +625,13 @@ async function checkCommand(options, cwd) {
557
625
  filesToCheck = getAllSourceFiles(projectRoot, config);
558
626
  }
559
627
  if (filesToCheck.length === 0) {
560
- console.log(`${chalk2.green("\u2713")} No files to check.`);
628
+ if (options.format === "json") {
629
+ console.log(
630
+ JSON.stringify({ violations: [], checkedFiles: 0, enforcement: config.enforcement })
631
+ );
632
+ } else {
633
+ console.log(`${chalk2.green("\u2713")} No files to check.`);
634
+ }
561
635
  return 0;
562
636
  }
563
637
  const violations = [];
@@ -598,7 +672,7 @@ async function checkCommand(options, cwd) {
598
672
  const testViolations = checkMissingTests(projectRoot, config, severity);
599
673
  violations.push(...testViolations);
600
674
  }
601
- if (config.rules.enforceBoundaries && config.boundaries && config.boundaries.length > 0 && !options.noBoundaries) {
675
+ if (config.rules.enforceBoundaries && config.boundaries && Object.keys(config.boundaries.deny).length > 0 && !options.noBoundaries) {
602
676
  const startTime = Date.now();
603
677
  const { buildImportGraph, checkBoundaries } = await import("@viberails/graph");
604
678
  const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
@@ -614,12 +688,14 @@ async function checkCommand(options, cwd) {
614
688
  violations.push({
615
689
  file: relFile,
616
690
  rule: "boundary-violation",
617
- message: `Imports "${bv.specifier}" violating boundary: ${bv.rule.from} \u2192 ${bv.rule.to}${bv.rule.reason ? ` (${bv.rule.reason})` : ""}`,
691
+ message: `Imports "${bv.specifier}" violating boundary: ${bv.rule.from} \u2192 ${bv.rule.to}`,
618
692
  severity
619
693
  });
620
694
  }
621
695
  const elapsed = Date.now() - startTime;
622
- console.log(chalk2.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
696
+ if (options.format !== "json") {
697
+ console.log(chalk2.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
698
+ }
623
699
  }
624
700
  if (options.format === "json") {
625
701
  console.log(
@@ -646,8 +722,54 @@ async function checkCommand(options, cwd) {
646
722
  return 0;
647
723
  }
648
724
 
725
+ // src/commands/check-hook.ts
726
+ import * as fs7 from "fs";
727
+ function parseHookFilePath(input) {
728
+ try {
729
+ if (!input.trim()) return void 0;
730
+ const parsed = JSON.parse(input);
731
+ return parsed?.tool_input?.file_path ?? void 0;
732
+ } catch {
733
+ return void 0;
734
+ }
735
+ }
736
+ function readStdin() {
737
+ try {
738
+ return fs7.readFileSync(0, "utf-8");
739
+ } catch {
740
+ return "";
741
+ }
742
+ }
743
+ async function hookCheckCommand(cwd) {
744
+ try {
745
+ const filePath = parseHookFilePath(readStdin());
746
+ if (!filePath) return 0;
747
+ const originalWrite = process.stdout.write.bind(process.stdout);
748
+ let captured = "";
749
+ process.stdout.write = (chunk) => {
750
+ captured += typeof chunk === "string" ? chunk : chunk.toString();
751
+ return true;
752
+ };
753
+ try {
754
+ await checkCommand({ files: [filePath], format: "json" }, cwd);
755
+ } finally {
756
+ process.stdout.write = originalWrite;
757
+ }
758
+ if (!captured.trim()) return 0;
759
+ const result = JSON.parse(captured);
760
+ if (result.violations?.length > 0) {
761
+ process.stderr.write(`${captured.trim()}
762
+ `);
763
+ return 2;
764
+ }
765
+ return 0;
766
+ } catch {
767
+ return 0;
768
+ }
769
+ }
770
+
649
771
  // src/commands/fix.ts
650
- import * as fs9 from "fs";
772
+ import * as fs10 from "fs";
651
773
  import * as path10 from "path";
652
774
  import { loadConfig as loadConfig3 } from "@viberails/config";
653
775
  import chalk4 from "chalk";
@@ -790,7 +912,7 @@ function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
790
912
  }
791
913
 
792
914
  // src/commands/fix-naming.ts
793
- import * as fs7 from "fs";
915
+ import * as fs8 from "fs";
794
916
  import * as path8 from "path";
795
917
 
796
918
  // src/commands/convert-name.ts
@@ -852,12 +974,12 @@ function computeRename(relPath, targetConvention, projectRoot) {
852
974
  const newRelPath = path8.join(dir, newFilename);
853
975
  const oldAbsPath = path8.join(projectRoot, relPath);
854
976
  const newAbsPath = path8.join(projectRoot, newRelPath);
855
- if (fs7.existsSync(newAbsPath)) return null;
977
+ if (fs8.existsSync(newAbsPath)) return null;
856
978
  return { oldPath: relPath, newPath: newRelPath, oldAbsPath, newAbsPath };
857
979
  }
858
980
  function executeRename(rename) {
859
- if (fs7.existsSync(rename.newAbsPath)) return false;
860
- fs7.renameSync(rename.oldAbsPath, rename.newAbsPath);
981
+ if (fs8.existsSync(rename.newAbsPath)) return false;
982
+ fs8.renameSync(rename.oldAbsPath, rename.newAbsPath);
861
983
  return true;
862
984
  }
863
985
  function deduplicateRenames(renames) {
@@ -872,7 +994,7 @@ function deduplicateRenames(renames) {
872
994
  }
873
995
 
874
996
  // src/commands/fix-tests.ts
875
- import * as fs8 from "fs";
997
+ import * as fs9 from "fs";
876
998
  import * as path9 from "path";
877
999
  function generateTestStub(sourceRelPath, config, projectRoot) {
878
1000
  const { testPattern } = config.structure;
@@ -883,7 +1005,7 @@ function generateTestStub(sourceRelPath, config, projectRoot) {
883
1005
  const testFilename = `${stem}${testSuffix}`;
884
1006
  const dir = path9.dirname(path9.join(projectRoot, sourceRelPath));
885
1007
  const testAbsPath = path9.join(dir, testFilename);
886
- if (fs8.existsSync(testAbsPath)) return null;
1008
+ if (fs9.existsSync(testAbsPath)) return null;
887
1009
  return {
888
1010
  path: path9.relative(projectRoot, testAbsPath),
889
1011
  absPath: testAbsPath,
@@ -897,8 +1019,8 @@ function writeTestStub(stub, config) {
897
1019
  it.todo('add tests');
898
1020
  });
899
1021
  `;
900
- fs8.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
901
- fs8.writeFileSync(stub.absPath, content);
1022
+ fs9.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
1023
+ fs9.writeFileSync(stub.absPath, content);
902
1024
  }
903
1025
 
904
1026
  // src/commands/fix.ts
@@ -911,7 +1033,7 @@ async function fixCommand(options, cwd) {
911
1033
  return 1;
912
1034
  }
913
1035
  const configPath = path10.join(projectRoot, CONFIG_FILE3);
914
- if (!fs9.existsSync(configPath)) {
1036
+ if (!fs10.existsSync(configPath)) {
915
1037
  console.error(
916
1038
  `${chalk4.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
917
1039
  );
@@ -975,13 +1097,13 @@ async function fixCommand(options, cwd) {
975
1097
  }
976
1098
  let importUpdateCount = 0;
977
1099
  if (renameCount > 0) {
978
- const appliedRenames = dedupedRenames.filter((r) => fs9.existsSync(r.newAbsPath));
1100
+ const appliedRenames = dedupedRenames.filter((r) => fs10.existsSync(r.newAbsPath));
979
1101
  const updates = await updateImportsAfterRenames(appliedRenames, projectRoot);
980
1102
  importUpdateCount = updates.length;
981
1103
  }
982
1104
  let stubCount = 0;
983
1105
  for (const stub of testStubs) {
984
- if (!fs9.existsSync(stub.absPath)) {
1106
+ if (!fs10.existsSync(stub.absPath)) {
985
1107
  writeTestStub(stub, config);
986
1108
  stubCount++;
987
1109
  }
@@ -1002,16 +1124,21 @@ async function fixCommand(options, cwd) {
1002
1124
  }
1003
1125
 
1004
1126
  // src/commands/init.ts
1005
- import * as fs12 from "fs";
1127
+ import * as fs13 from "fs";
1006
1128
  import * as path13 from "path";
1007
1129
  import * as clack2 from "@clack/prompts";
1008
1130
  import { generateConfig } from "@viberails/config";
1009
1131
  import { scan } from "@viberails/scanner";
1010
1132
  import chalk8 from "chalk";
1011
1133
 
1012
- // src/display.ts
1013
- import { FRAMEWORK_NAMES as FRAMEWORK_NAMES2, LIBRARY_NAMES, ORM_NAMES, STYLING_NAMES as STYLING_NAMES2 } from "@viberails/types";
1014
- import chalk6 from "chalk";
1134
+ // src/display-text.ts
1135
+ import {
1136
+ CONVENTION_LABELS as CONVENTION_LABELS2,
1137
+ FRAMEWORK_NAMES as FRAMEWORK_NAMES3,
1138
+ LIBRARY_NAMES as LIBRARY_NAMES2,
1139
+ ORM_NAMES as ORM_NAMES2,
1140
+ STYLING_NAMES as STYLING_NAMES3
1141
+ } from "@viberails/types";
1015
1142
 
1016
1143
  // src/display-helpers.ts
1017
1144
  import { ROLE_DESCRIPTIONS } from "@viberails/types";
@@ -1062,6 +1189,16 @@ function formatRoleGroup(group) {
1062
1189
  return `${group.label} \u2014 ${dirs} (${files})`;
1063
1190
  }
1064
1191
 
1192
+ // src/display.ts
1193
+ import {
1194
+ CONVENTION_LABELS,
1195
+ FRAMEWORK_NAMES as FRAMEWORK_NAMES2,
1196
+ LIBRARY_NAMES,
1197
+ ORM_NAMES,
1198
+ STYLING_NAMES as STYLING_NAMES2
1199
+ } from "@viberails/types";
1200
+ import chalk6 from "chalk";
1201
+
1065
1202
  // src/display-monorepo.ts
1066
1203
  import { FRAMEWORK_NAMES, STYLING_NAMES } from "@viberails/types";
1067
1204
  import chalk5 from "chalk";
@@ -1171,12 +1308,6 @@ function formatMonorepoResultsText(scanResult, config) {
1171
1308
  }
1172
1309
 
1173
1310
  // src/display.ts
1174
- var CONVENTION_LABELS = {
1175
- fileNaming: "File naming",
1176
- componentNaming: "Component naming",
1177
- hookNaming: "Hook naming",
1178
- importAlias: "Import alias"
1179
- };
1180
1311
  function formatItem(item, nameMap) {
1181
1312
  const name = nameMap?.[item.name] ?? item.name;
1182
1313
  return item.version ? `${name} ${item.version}` : name;
@@ -1312,6 +1443,11 @@ function displayRulesPreview(config) {
1312
1443
  }
1313
1444
  console.log("");
1314
1445
  }
1446
+
1447
+ // src/display-text.ts
1448
+ function getConventionStr2(cv) {
1449
+ return typeof cv === "string" ? cv : cv.value;
1450
+ }
1315
1451
  function plainConfidenceLabel(convention) {
1316
1452
  const pct = Math.round(convention.consistency);
1317
1453
  if (convention.confidence === "high") {
@@ -1327,7 +1463,7 @@ function formatConventionsText(scanResult) {
1327
1463
  lines.push("Conventions:");
1328
1464
  for (const [key, convention] of conventionEntries) {
1329
1465
  if (convention.confidence === "low") continue;
1330
- const label = CONVENTION_LABELS[key] ?? key;
1466
+ const label = CONVENTION_LABELS2[key] ?? key;
1331
1467
  if (scanResult.packages.length > 1) {
1332
1468
  const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1333
1469
  const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
@@ -1361,7 +1497,7 @@ function formatRulesText(config) {
1361
1497
  lines.push(" \u2022 Require test files: no");
1362
1498
  }
1363
1499
  if (config.rules.enforceNaming && config.conventions.fileNaming) {
1364
- lines.push(` \u2022 Enforce file naming: ${getConventionStr(config.conventions.fileNaming)}`);
1500
+ lines.push(` \u2022 Enforce file naming: ${getConventionStr2(config.conventions.fileNaming)}`);
1365
1501
  } else {
1366
1502
  lines.push(" \u2022 Enforce file naming: no");
1367
1503
  }
@@ -1376,17 +1512,17 @@ function formatScanResultsText(scanResult, config) {
1376
1512
  const { stack } = scanResult;
1377
1513
  lines.push("Detected:");
1378
1514
  if (stack.framework) {
1379
- lines.push(` \u2713 ${formatItem(stack.framework, FRAMEWORK_NAMES2)}`);
1515
+ lines.push(` \u2713 ${formatItem(stack.framework, FRAMEWORK_NAMES3)}`);
1380
1516
  }
1381
1517
  lines.push(` \u2713 ${formatItem(stack.language)}`);
1382
1518
  if (stack.styling) {
1383
- lines.push(` \u2713 ${formatItem(stack.styling, STYLING_NAMES2)}`);
1519
+ lines.push(` \u2713 ${formatItem(stack.styling, STYLING_NAMES3)}`);
1384
1520
  }
1385
1521
  if (stack.backend) {
1386
- lines.push(` \u2713 ${formatItem(stack.backend, FRAMEWORK_NAMES2)}`);
1522
+ lines.push(` \u2713 ${formatItem(stack.backend, FRAMEWORK_NAMES3)}`);
1387
1523
  }
1388
1524
  if (stack.orm) {
1389
- lines.push(` \u2713 ${formatItem(stack.orm, ORM_NAMES)}`);
1525
+ lines.push(` \u2713 ${formatItem(stack.orm, ORM_NAMES2)}`);
1390
1526
  }
1391
1527
  const secondaryParts = [];
1392
1528
  if (stack.packageManager) secondaryParts.push(formatItem(stack.packageManager));
@@ -1398,7 +1534,7 @@ function formatScanResultsText(scanResult, config) {
1398
1534
  }
1399
1535
  if (stack.libraries.length > 0) {
1400
1536
  for (const lib of stack.libraries) {
1401
- lines.push(` \u2713 ${formatItem(lib, LIBRARY_NAMES)}`);
1537
+ lines.push(` \u2713 ${formatItem(lib, LIBRARY_NAMES2)}`);
1402
1538
  }
1403
1539
  }
1404
1540
  const groups = groupByRole(scanResult.structure.directories);
@@ -1422,7 +1558,7 @@ function formatScanResultsText(scanResult, config) {
1422
1558
  }
1423
1559
 
1424
1560
  // src/utils/write-generated-files.ts
1425
- import * as fs10 from "fs";
1561
+ import * as fs11 from "fs";
1426
1562
  import * as path11 from "path";
1427
1563
  import { generateContext } from "@viberails/context";
1428
1564
  var CONTEXT_DIR = ".viberails";
@@ -1431,12 +1567,12 @@ var SCAN_RESULT_FILE = "scan-result.json";
1431
1567
  function writeGeneratedFiles(projectRoot, config, scanResult) {
1432
1568
  const contextDir = path11.join(projectRoot, CONTEXT_DIR);
1433
1569
  try {
1434
- if (!fs10.existsSync(contextDir)) {
1435
- fs10.mkdirSync(contextDir, { recursive: true });
1570
+ if (!fs11.existsSync(contextDir)) {
1571
+ fs11.mkdirSync(contextDir, { recursive: true });
1436
1572
  }
1437
1573
  const context = generateContext(config);
1438
- fs10.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1439
- fs10.writeFileSync(
1574
+ fs11.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1575
+ fs11.writeFileSync(
1440
1576
  path11.join(contextDir, SCAN_RESULT_FILE),
1441
1577
  `${JSON.stringify(scanResult, null, 2)}
1442
1578
  `
@@ -1448,27 +1584,28 @@ function writeGeneratedFiles(projectRoot, config, scanResult) {
1448
1584
  }
1449
1585
 
1450
1586
  // src/commands/init-hooks.ts
1451
- import * as fs11 from "fs";
1587
+ import * as fs12 from "fs";
1452
1588
  import * as path12 from "path";
1453
1589
  import chalk7 from "chalk";
1590
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1454
1591
  function setupPreCommitHook(projectRoot) {
1455
1592
  const lefthookPath = path12.join(projectRoot, "lefthook.yml");
1456
- if (fs11.existsSync(lefthookPath)) {
1593
+ if (fs12.existsSync(lefthookPath)) {
1457
1594
  addLefthookPreCommit(lefthookPath);
1458
1595
  console.log(` ${chalk7.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
1459
1596
  return;
1460
1597
  }
1461
1598
  const huskyDir = path12.join(projectRoot, ".husky");
1462
- if (fs11.existsSync(huskyDir)) {
1599
+ if (fs12.existsSync(huskyDir)) {
1463
1600
  writeHuskyPreCommit(huskyDir);
1464
1601
  console.log(` ${chalk7.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
1465
1602
  return;
1466
1603
  }
1467
1604
  const gitDir = path12.join(projectRoot, ".git");
1468
- if (fs11.existsSync(gitDir)) {
1605
+ if (fs12.existsSync(gitDir)) {
1469
1606
  const hooksDir = path12.join(gitDir, "hooks");
1470
- if (!fs11.existsSync(hooksDir)) {
1471
- fs11.mkdirSync(hooksDir, { recursive: true });
1607
+ if (!fs12.existsSync(hooksDir)) {
1608
+ fs12.mkdirSync(hooksDir, { recursive: true });
1472
1609
  }
1473
1610
  writeGitHookPreCommit(hooksDir);
1474
1611
  console.log(` ${chalk7.green("\u2713")} .git/hooks/pre-commit`);
@@ -1476,10 +1613,10 @@ function setupPreCommitHook(projectRoot) {
1476
1613
  }
1477
1614
  function writeGitHookPreCommit(hooksDir) {
1478
1615
  const hookPath = path12.join(hooksDir, "pre-commit");
1479
- if (fs11.existsSync(hookPath)) {
1480
- const existing = fs11.readFileSync(hookPath, "utf-8");
1616
+ if (fs12.existsSync(hookPath)) {
1617
+ const existing = fs12.readFileSync(hookPath, "utf-8");
1481
1618
  if (existing.includes("viberails")) return;
1482
- fs11.writeFileSync(
1619
+ fs12.writeFileSync(
1483
1620
  hookPath,
1484
1621
  `${existing.trimEnd()}
1485
1622
 
@@ -1496,61 +1633,51 @@ npx viberails check --staged
1496
1633
  "npx viberails check --staged",
1497
1634
  ""
1498
1635
  ].join("\n");
1499
- fs11.writeFileSync(hookPath, script, { mode: 493 });
1636
+ fs12.writeFileSync(hookPath, script, { mode: 493 });
1500
1637
  }
1501
1638
  function addLefthookPreCommit(lefthookPath) {
1502
- const content = fs11.readFileSync(lefthookPath, "utf-8");
1639
+ const content = fs12.readFileSync(lefthookPath, "utf-8");
1503
1640
  if (content.includes("viberails")) return;
1504
- const hasPreCommit = /^pre-commit:/m.test(content);
1505
- if (hasPreCommit) {
1506
- const commandBlock = ["", " viberails:", " run: npx viberails check --staged"].join(
1507
- "\n"
1508
- );
1509
- const updated = `${content.trimEnd()}
1510
- ${commandBlock}
1511
- `;
1512
- fs11.writeFileSync(lefthookPath, updated);
1513
- } else {
1514
- const section = [
1515
- "",
1516
- "pre-commit:",
1517
- " commands:",
1518
- " viberails:",
1519
- " run: npx viberails check --staged"
1520
- ].join("\n");
1521
- fs11.writeFileSync(lefthookPath, `${content.trimEnd()}
1522
- ${section}
1523
- `);
1641
+ const doc = parseYaml(content) ?? {};
1642
+ if (!doc["pre-commit"]) {
1643
+ doc["pre-commit"] = { commands: {} };
1524
1644
  }
1645
+ if (!doc["pre-commit"].commands) {
1646
+ doc["pre-commit"].commands = {};
1647
+ }
1648
+ doc["pre-commit"].commands.viberails = {
1649
+ run: "npx viberails check --staged"
1650
+ };
1651
+ fs12.writeFileSync(lefthookPath, stringifyYaml(doc));
1525
1652
  }
1526
1653
  function detectHookManager(projectRoot) {
1527
- if (fs11.existsSync(path12.join(projectRoot, "lefthook.yml"))) return "Lefthook";
1528
- if (fs11.existsSync(path12.join(projectRoot, ".husky"))) return "Husky";
1529
- if (fs11.existsSync(path12.join(projectRoot, ".git"))) return "git hook";
1654
+ if (fs12.existsSync(path12.join(projectRoot, "lefthook.yml"))) return "Lefthook";
1655
+ if (fs12.existsSync(path12.join(projectRoot, ".husky"))) return "Husky";
1656
+ if (fs12.existsSync(path12.join(projectRoot, ".git"))) return "git hook";
1530
1657
  return void 0;
1531
1658
  }
1532
1659
  function setupClaudeCodeHook(projectRoot) {
1533
1660
  const claudeDir = path12.join(projectRoot, ".claude");
1534
- if (!fs11.existsSync(claudeDir)) {
1535
- fs11.mkdirSync(claudeDir, { recursive: true });
1661
+ if (!fs12.existsSync(claudeDir)) {
1662
+ fs12.mkdirSync(claudeDir, { recursive: true });
1536
1663
  }
1537
1664
  const settingsPath = path12.join(claudeDir, "settings.json");
1538
1665
  let settings = {};
1539
- if (fs11.existsSync(settingsPath)) {
1666
+ if (fs12.existsSync(settingsPath)) {
1540
1667
  try {
1541
- settings = JSON.parse(fs11.readFileSync(settingsPath, "utf-8"));
1668
+ settings = JSON.parse(fs12.readFileSync(settingsPath, "utf-8"));
1542
1669
  } catch {
1543
1670
  console.warn(
1544
- ` ${chalk7.yellow("!")} .claude/settings.json contains invalid JSON \u2014 resetting to add hook`
1671
+ ` ${chalk7.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
1545
1672
  );
1546
- settings = {};
1673
+ console.warn(` Fix the JSON manually, then re-run ${chalk7.cyan("viberails init --force")}`);
1674
+ return;
1547
1675
  }
1548
1676
  }
1549
1677
  const hooks = settings.hooks ?? {};
1550
1678
  const existing = hooks.PostToolUse ?? [];
1551
1679
  if (existing.some((h) => JSON.stringify(h).includes("viberails"))) return;
1552
- const extractFile = `node -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).tool_input?.file_path??'')}catch{}"`;
1553
- const hookCommand = `FILE=$(${extractFile}) && [ -n "$FILE" ] && npx viberails check --files "$FILE" --format json; exit 0`;
1680
+ const hookCommand = "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --hook; else npx viberails check --hook; fi";
1554
1681
  hooks.PostToolUse = [
1555
1682
  ...existing,
1556
1683
  {
@@ -1564,22 +1691,34 @@ function setupClaudeCodeHook(projectRoot) {
1564
1691
  }
1565
1692
  ];
1566
1693
  settings.hooks = hooks;
1567
- fs11.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1694
+ fs12.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1568
1695
  `);
1569
1696
  console.log(` ${chalk7.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
1570
1697
  }
1698
+ function setupClaudeMdReference(projectRoot) {
1699
+ const claudeMdPath = path12.join(projectRoot, "CLAUDE.md");
1700
+ let content = "";
1701
+ if (fs12.existsSync(claudeMdPath)) {
1702
+ content = fs12.readFileSync(claudeMdPath, "utf-8");
1703
+ }
1704
+ if (content.includes("@.viberails/context.md")) return;
1705
+ const ref = "\n@.viberails/context.md\n";
1706
+ const prefix = content.length === 0 ? "" : content.trimEnd();
1707
+ fs12.writeFileSync(claudeMdPath, prefix + ref);
1708
+ console.log(` ${chalk7.green("\u2713")} CLAUDE.md \u2014 added @.viberails/context.md reference`);
1709
+ }
1571
1710
  function writeHuskyPreCommit(huskyDir) {
1572
1711
  const hookPath = path12.join(huskyDir, "pre-commit");
1573
- if (fs11.existsSync(hookPath)) {
1574
- const existing = fs11.readFileSync(hookPath, "utf-8");
1712
+ if (fs12.existsSync(hookPath)) {
1713
+ const existing = fs12.readFileSync(hookPath, "utf-8");
1575
1714
  if (!existing.includes("viberails")) {
1576
- fs11.writeFileSync(hookPath, `${existing.trimEnd()}
1715
+ fs12.writeFileSync(hookPath, `${existing.trimEnd()}
1577
1716
  npx viberails check --staged
1578
1717
  `);
1579
1718
  }
1580
1719
  return;
1581
1720
  }
1582
- fs11.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1721
+ fs12.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1583
1722
  }
1584
1723
 
1585
1724
  // src/commands/init.ts
@@ -1596,14 +1735,10 @@ function filterHighConfidence(conventions) {
1596
1735
  }
1597
1736
  return filtered;
1598
1737
  }
1599
- function getConventionStr2(cv) {
1738
+ function getConventionStr3(cv) {
1600
1739
  if (!cv) return void 0;
1601
1740
  return typeof cv === "string" ? cv : cv.value;
1602
1741
  }
1603
- function hasConventionOverrides(config) {
1604
- if (!config.packages || config.packages.length === 0) return false;
1605
- return config.packages.some((pkg) => pkg.conventions && Object.keys(pkg.conventions).length > 0);
1606
- }
1607
1742
  async function initCommand(options, cwd) {
1608
1743
  const startDir = cwd ?? process.cwd();
1609
1744
  const projectRoot = findProjectRoot(startDir);
@@ -1613,10 +1748,10 @@ async function initCommand(options, cwd) {
1613
1748
  );
1614
1749
  }
1615
1750
  const configPath = path13.join(projectRoot, CONFIG_FILE4);
1616
- if (fs12.existsSync(configPath)) {
1751
+ if (fs13.existsSync(configPath) && !options.force) {
1617
1752
  console.log(
1618
1753
  `${chalk8.yellow("!")} viberails is already initialized.
1619
- Run ${chalk8.cyan("viberails sync")} to update, or delete viberails.config.json to start fresh.`
1754
+ Run ${chalk8.cyan("viberails sync")} to update, or ${chalk8.cyan("viberails init --force")} to start fresh.`
1620
1755
  );
1621
1756
  return;
1622
1757
  }
@@ -1636,16 +1771,18 @@ async function initCommand(options, cwd) {
1636
1771
  ignore: config2.ignore
1637
1772
  });
1638
1773
  const inferred = inferBoundaries(graph);
1639
- if (inferred.length > 0) {
1774
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1775
+ if (denyCount > 0) {
1640
1776
  config2.boundaries = inferred;
1641
1777
  config2.rules.enforceBoundaries = true;
1642
- console.log(` Inferred ${inferred.length} boundary rules`);
1778
+ console.log(` Inferred ${denyCount} boundary rules`);
1643
1779
  }
1644
1780
  }
1645
- fs12.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
1781
+ fs13.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
1646
1782
  `);
1647
1783
  writeGeneratedFiles(projectRoot, config2, scanResult2);
1648
1784
  updateGitignore(projectRoot);
1785
+ setupClaudeMdReference(projectRoot);
1649
1786
  console.log(`
1650
1787
  Created:`);
1651
1788
  console.log(` ${chalk8.green("\u2713")} ${CONFIG_FILE4}`);
@@ -1668,27 +1805,18 @@ Created:`);
1668
1805
  clack2.note(resultsText, "Scan results");
1669
1806
  const decision = await promptInitDecision();
1670
1807
  if (decision === "customize") {
1671
- clack2.note(
1672
- "Rules control what viberails checks for.\nYou can change these later in viberails.config.json.",
1673
- "Rules"
1674
- );
1675
- const overrides = await promptRuleCustomization({
1808
+ const overrides = await promptRuleMenu({
1676
1809
  maxFileLines: config.rules.maxFileLines,
1677
1810
  requireTests: config.rules.requireTests,
1678
1811
  enforceNaming: config.rules.enforceNaming,
1679
1812
  enforcement: config.enforcement,
1680
- fileNamingValue: getConventionStr2(config.conventions.fileNaming)
1813
+ fileNamingValue: getConventionStr3(config.conventions.fileNaming),
1814
+ packageOverrides: config.packages
1681
1815
  });
1682
1816
  config.rules.maxFileLines = overrides.maxFileLines;
1683
1817
  config.rules.requireTests = overrides.requireTests;
1684
1818
  config.rules.enforceNaming = overrides.enforceNaming;
1685
1819
  config.enforcement = overrides.enforcement;
1686
- if (config.workspace?.packages && config.workspace.packages.length > 0) {
1687
- clack2.note(
1688
- 'These rules apply globally. To customize per package,\nedit the "packages" section in viberails.config.json.',
1689
- "Per-package overrides"
1690
- );
1691
- }
1692
1820
  }
1693
1821
  if (config.workspace?.packages && config.workspace.packages.length > 0) {
1694
1822
  clack2.note(
@@ -1706,10 +1834,11 @@ Created:`);
1706
1834
  ignore: config.ignore
1707
1835
  });
1708
1836
  const inferred = inferBoundaries(graph);
1709
- if (inferred.length > 0) {
1837
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1838
+ if (denyCount > 0) {
1710
1839
  config.boundaries = inferred;
1711
1840
  config.rules.enforceBoundaries = true;
1712
- bs.stop(`Inferred ${inferred.length} boundary rules`);
1841
+ bs.stop(`Inferred ${denyCount} boundary rules`);
1713
1842
  } else {
1714
1843
  bs.stop("No boundary rules inferred");
1715
1844
  }
@@ -1717,13 +1846,7 @@ Created:`);
1717
1846
  }
1718
1847
  const hookManager = detectHookManager(projectRoot);
1719
1848
  const integrations = await promptIntegrations(hookManager);
1720
- if (hasConventionOverrides(config)) {
1721
- clack2.note(
1722
- "Some packages use different conventions. Per-package\noverrides have been saved in viberails.config.json \u2014\nreview and adjust as needed.",
1723
- "Per-package conventions"
1724
- );
1725
- }
1726
- fs12.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1849
+ fs13.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1727
1850
  `);
1728
1851
  writeGeneratedFiles(projectRoot, config, scanResult);
1729
1852
  updateGitignore(projectRoot);
@@ -1734,8 +1857,7 @@ Created:`);
1734
1857
  ];
1735
1858
  if (integrations.preCommitHook) {
1736
1859
  setupPreCommitHook(projectRoot);
1737
- const hookMgr = detectHookManager(projectRoot);
1738
- if (hookMgr) {
1860
+ if (hookManager === "Lefthook") {
1739
1861
  createdFiles.push(`lefthook.yml \u2014 added viberails pre-commit`);
1740
1862
  }
1741
1863
  }
@@ -1743,6 +1865,10 @@ Created:`);
1743
1865
  setupClaudeCodeHook(projectRoot);
1744
1866
  createdFiles.push(".claude/settings.json \u2014 added viberails hook");
1745
1867
  }
1868
+ if (integrations.claudeMdRef) {
1869
+ setupClaudeMdReference(projectRoot);
1870
+ createdFiles.push("CLAUDE.md \u2014 added @.viberails/context.md reference");
1871
+ }
1746
1872
  clack2.log.success(`Created:
1747
1873
  ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1748
1874
  clack2.outro("Done! Next: review viberails.config.json, then run viberails check");
@@ -1750,24 +1876,162 @@ ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1750
1876
  function updateGitignore(projectRoot) {
1751
1877
  const gitignorePath = path13.join(projectRoot, ".gitignore");
1752
1878
  let content = "";
1753
- if (fs12.existsSync(gitignorePath)) {
1754
- content = fs12.readFileSync(gitignorePath, "utf-8");
1879
+ if (fs13.existsSync(gitignorePath)) {
1880
+ content = fs13.readFileSync(gitignorePath, "utf-8");
1755
1881
  }
1756
1882
  if (!content.includes(".viberails/scan-result.json")) {
1757
1883
  const block = "\n# viberails\n.viberails/scan-result.json\n";
1758
1884
  const prefix = content.length === 0 ? "" : `${content.trimEnd()}
1759
1885
  `;
1760
- fs12.writeFileSync(gitignorePath, `${prefix}${block}`);
1886
+ fs13.writeFileSync(gitignorePath, `${prefix}${block}`);
1761
1887
  }
1762
1888
  }
1763
1889
 
1764
1890
  // src/commands/sync.ts
1765
- import * as fs13 from "fs";
1891
+ import * as fs14 from "fs";
1766
1892
  import * as path14 from "path";
1767
1893
  import { loadConfig as loadConfig4, mergeConfig } from "@viberails/config";
1768
1894
  import { scan as scan2 } from "@viberails/scanner";
1769
1895
  import chalk9 from "chalk";
1896
+
1897
+ // src/utils/diff-configs.ts
1898
+ import { CONVENTION_LABELS as CONVENTION_LABELS3, FRAMEWORK_NAMES as FRAMEWORK_NAMES4, ORM_NAMES as ORM_NAMES3, STYLING_NAMES as STYLING_NAMES4 } from "@viberails/types";
1899
+ function parseStackString(s) {
1900
+ const atIdx = s.indexOf("@");
1901
+ if (atIdx > 0) {
1902
+ return { name: s.slice(0, atIdx), version: s.slice(atIdx + 1) };
1903
+ }
1904
+ return { name: s };
1905
+ }
1906
+ function displayStackName(s) {
1907
+ const { name, version } = parseStackString(s);
1908
+ const allMaps = {
1909
+ ...FRAMEWORK_NAMES4,
1910
+ ...STYLING_NAMES4,
1911
+ ...ORM_NAMES3
1912
+ };
1913
+ const display = allMaps[name] ?? name;
1914
+ return version ? `${display} ${version}` : display;
1915
+ }
1916
+ function conventionStr(cv) {
1917
+ return typeof cv === "string" ? cv : cv.value;
1918
+ }
1919
+ function isDetected(cv) {
1920
+ return typeof cv !== "string" && cv._detected === true;
1921
+ }
1922
+ var STACK_FIELDS = [
1923
+ "framework",
1924
+ "styling",
1925
+ "backend",
1926
+ "orm",
1927
+ "linter",
1928
+ "formatter",
1929
+ "testRunner"
1930
+ ];
1931
+ var CONVENTION_KEYS = [
1932
+ "fileNaming",
1933
+ "componentNaming",
1934
+ "hookNaming",
1935
+ "importAlias"
1936
+ ];
1937
+ var STRUCTURE_FIELDS = [
1938
+ { key: "srcDir", label: "source directory" },
1939
+ { key: "pages", label: "pages directory" },
1940
+ { key: "components", label: "components directory" },
1941
+ { key: "hooks", label: "hooks directory" },
1942
+ { key: "utils", label: "utilities directory" },
1943
+ { key: "types", label: "types directory" },
1944
+ { key: "tests", label: "tests directory" },
1945
+ { key: "testPattern", label: "test pattern" }
1946
+ ];
1947
+ function diffConfigs(existing, merged) {
1948
+ const changes = [];
1949
+ for (const field of STACK_FIELDS) {
1950
+ const oldVal = existing.stack[field];
1951
+ const newVal = merged.stack[field];
1952
+ if (!oldVal && newVal) {
1953
+ changes.push({ type: "added", description: `Stack: added ${displayStackName(newVal)}` });
1954
+ } else if (oldVal && newVal && oldVal !== newVal) {
1955
+ changes.push({
1956
+ type: "changed",
1957
+ description: `Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
1958
+ });
1959
+ }
1960
+ }
1961
+ for (const key of CONVENTION_KEYS) {
1962
+ const oldVal = existing.conventions[key];
1963
+ const newVal = merged.conventions[key];
1964
+ const label = CONVENTION_LABELS3[key] ?? key;
1965
+ if (!oldVal && newVal) {
1966
+ changes.push({
1967
+ type: "added",
1968
+ description: `New convention: ${label} (${conventionStr(newVal)})`
1969
+ });
1970
+ } else if (oldVal && newVal && isDetected(newVal)) {
1971
+ changes.push({
1972
+ type: "changed",
1973
+ description: `Convention updated: ${label} (${conventionStr(newVal)})`
1974
+ });
1975
+ }
1976
+ }
1977
+ for (const { key, label } of STRUCTURE_FIELDS) {
1978
+ const oldVal = existing.structure[key];
1979
+ const newVal = merged.structure[key];
1980
+ if (!oldVal && newVal) {
1981
+ changes.push({ type: "added", description: `Structure: detected ${label} (${newVal})` });
1982
+ }
1983
+ }
1984
+ const existingPaths = new Set((existing.packages ?? []).map((p) => p.path));
1985
+ for (const pkg of merged.packages ?? []) {
1986
+ if (!existingPaths.has(pkg.path)) {
1987
+ changes.push({ type: "added", description: `New package: ${pkg.path}` });
1988
+ }
1989
+ }
1990
+ const existingWsPkgs = new Set(existing.workspace?.packages ?? []);
1991
+ const mergedWsPkgs = new Set(merged.workspace?.packages ?? []);
1992
+ for (const pkg of mergedWsPkgs) {
1993
+ if (!existingWsPkgs.has(pkg)) {
1994
+ changes.push({ type: "added", description: `Workspace: added ${pkg}` });
1995
+ }
1996
+ }
1997
+ for (const pkg of existingWsPkgs) {
1998
+ if (!mergedWsPkgs.has(pkg)) {
1999
+ changes.push({ type: "removed", description: `Workspace: removed ${pkg}` });
2000
+ }
2001
+ }
2002
+ return changes;
2003
+ }
2004
+ function formatStatsDelta(oldStats, newStats) {
2005
+ const fileDelta = newStats.totalFiles - oldStats.totalFiles;
2006
+ const lineDelta = newStats.totalLines - oldStats.totalLines;
2007
+ if (fileDelta === 0 && lineDelta === 0) return void 0;
2008
+ const parts = [];
2009
+ if (fileDelta !== 0) {
2010
+ const sign = fileDelta > 0 ? "+" : "";
2011
+ parts.push(`${sign}${fileDelta.toLocaleString()} files`);
2012
+ }
2013
+ if (lineDelta !== 0) {
2014
+ const sign = lineDelta > 0 ? "+" : "";
2015
+ parts.push(`${sign}${lineDelta.toLocaleString()} lines`);
2016
+ }
2017
+ return `${parts.join(", ")} since last sync`;
2018
+ }
2019
+
2020
+ // src/commands/sync.ts
1770
2021
  var CONFIG_FILE5 = "viberails.config.json";
2022
+ var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
2023
+ function loadPreviousStats(projectRoot) {
2024
+ const scanResultPath = path14.join(projectRoot, SCAN_RESULT_FILE2);
2025
+ try {
2026
+ const raw = fs14.readFileSync(scanResultPath, "utf-8");
2027
+ const parsed = JSON.parse(raw);
2028
+ if (parsed?.statistics?.totalFiles !== void 0) {
2029
+ return parsed.statistics;
2030
+ }
2031
+ } catch {
2032
+ }
2033
+ return void 0;
2034
+ }
1771
2035
  async function syncCommand(cwd) {
1772
2036
  const startDir = cwd ?? process.cwd();
1773
2037
  const projectRoot = findProjectRoot(startDir);
@@ -1778,18 +2042,27 @@ async function syncCommand(cwd) {
1778
2042
  }
1779
2043
  const configPath = path14.join(projectRoot, CONFIG_FILE5);
1780
2044
  const existing = await loadConfig4(configPath);
2045
+ const previousStats = loadPreviousStats(projectRoot);
1781
2046
  console.log(chalk9.dim("Scanning project..."));
1782
2047
  const scanResult = await scan2(projectRoot);
1783
2048
  const merged = mergeConfig(existing, scanResult);
1784
2049
  const existingJson = JSON.stringify(existing, null, 2);
1785
2050
  const mergedJson = JSON.stringify(merged, null, 2);
1786
2051
  const configChanged = existingJson !== mergedJson;
1787
- if (configChanged) {
1788
- console.log(
1789
- ` ${chalk9.yellow("!")} Config updated \u2014 review ${chalk9.cyan(CONFIG_FILE5)} for changes`
1790
- );
2052
+ const changes = configChanged ? diffConfigs(existing, merged) : [];
2053
+ const statsDelta = previousStats ? formatStatsDelta(previousStats, scanResult.statistics) : void 0;
2054
+ if (changes.length > 0 || statsDelta) {
2055
+ console.log(`
2056
+ ${chalk9.bold("Changes:")}`);
2057
+ for (const change of changes) {
2058
+ const icon = change.type === "removed" ? chalk9.red("-") : chalk9.green("+");
2059
+ console.log(` ${icon} ${change.description}`);
2060
+ }
2061
+ if (statsDelta) {
2062
+ console.log(` ${chalk9.dim(statsDelta)}`);
2063
+ }
1791
2064
  }
1792
- fs13.writeFileSync(configPath, `${mergedJson}
2065
+ fs14.writeFileSync(configPath, `${mergedJson}
1793
2066
  `);
1794
2067
  writeGeneratedFiles(projectRoot, merged, scanResult);
1795
2068
  console.log(`
@@ -1804,10 +2077,10 @@ ${chalk9.bold("Synced:")}`);
1804
2077
  }
1805
2078
 
1806
2079
  // src/index.ts
1807
- var VERSION = "0.3.2";
2080
+ var VERSION = "0.4.0";
1808
2081
  var program = new Command();
1809
2082
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
1810
- 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) => {
2083
+ 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)").option("-f, --force", "Re-initialize, replacing existing config").action(async (options) => {
1811
2084
  try {
1812
2085
  await initCommand(options);
1813
2086
  } catch (err) {
@@ -1825,9 +2098,13 @@ program.command("sync").description("Re-scan and update generated files").action
1825
2098
  process.exit(1);
1826
2099
  }
1827
2100
  });
1828
- program.command("check").description("Check files against enforced rules").option("--staged", "Check only staged files (for pre-commit hooks)").option("--files <files...>", "Check specific files").option("--no-boundaries", "Skip boundary checking").option("--quiet", "Show only summary counts, not individual violations").option("--limit <n>", "Maximum number of violations to display", Number.parseInt).option("--format <format>", "Output format: text (default) or json").action(
2101
+ program.command("check").description("Check files against enforced rules").option("--staged", "Check only staged files (for pre-commit hooks)").option("--files <files...>", "Check specific files").option("--no-boundaries", "Skip boundary checking").option("--quiet", "Show only summary counts, not individual violations").option("--limit <n>", "Maximum number of violations to display", Number.parseInt).option("--format <format>", "Output format: text (default) or json").option("--hook", "Claude Code hook mode: read file from stdin, output to stderr").action(
1829
2102
  async (options) => {
1830
2103
  try {
2104
+ if (options.hook) {
2105
+ const exitCode2 = await hookCheckCommand();
2106
+ process.exit(exitCode2);
2107
+ }
1831
2108
  const exitCode = await checkCommand({
1832
2109
  ...options,
1833
2110
  noBoundaries: options.boundaries === false,