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.cjs CHANGED
@@ -89,50 +89,115 @@ async function promptInitDecision() {
89
89
  assertNotCancelled(result);
90
90
  return result;
91
91
  }
92
- async function promptRuleCustomization(defaults) {
93
- const maxFileLinesResult = await clack.text({
94
- message: "Maximum lines per source file?",
95
- placeholder: String(defaults.maxFileLines),
96
- initialValue: String(defaults.maxFileLines),
97
- validate: (v) => {
98
- const n = Number.parseInt(v, 10);
99
- if (Number.isNaN(n) || n < 1) return "Enter a positive number";
100
- }
101
- });
102
- assertNotCancelled(maxFileLinesResult);
103
- const requireTestsResult = await clack.confirm({
104
- message: "Require matching test files for source files?",
105
- initialValue: defaults.requireTests
106
- });
107
- assertNotCancelled(requireTestsResult);
108
- const namingLabel = defaults.fileNamingValue ? `Enforce file naming? (detected: ${defaults.fileNamingValue})` : "Enforce file naming?";
109
- const enforceNamingResult = await clack.confirm({
110
- message: namingLabel,
111
- initialValue: defaults.enforceNaming
112
- });
113
- assertNotCancelled(enforceNamingResult);
114
- const enforcementResult = await clack.select({
115
- message: "Enforcement mode",
116
- options: [
92
+ async function promptRuleMenu(defaults) {
93
+ const state = { ...defaults };
94
+ while (true) {
95
+ const namingHint = state.enforceNaming ? `yes${state.fileNamingValue ? ` (${state.fileNamingValue})` : ""}` : "no";
96
+ const enforcementHint = state.enforcement === "warn" ? "warn \u2014 violations shown but commits allowed" : "enforce \u2014 commits blocked on violation";
97
+ const options = [
98
+ { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
117
99
  {
118
- value: "warn",
119
- label: "warn",
120
- hint: "show violations but don't block commits (recommended)"
100
+ value: "requireTests",
101
+ label: "Require test files",
102
+ hint: state.requireTests ? "yes" : "no"
121
103
  },
122
- {
123
- value: "enforce",
124
- label: "enforce",
125
- hint: "block commits with violations"
126
- }
127
- ],
128
- initialValue: defaults.enforcement
129
- });
130
- assertNotCancelled(enforcementResult);
104
+ { value: "enforceNaming", label: "Enforce file naming", hint: namingHint },
105
+ { value: "enforcement", label: "Enforcement mode", hint: enforcementHint }
106
+ ];
107
+ if (state.packageOverrides && state.packageOverrides.length > 0) {
108
+ const count = state.packageOverrides.length;
109
+ options.push({
110
+ value: "packageOverrides",
111
+ label: "Per-package overrides",
112
+ hint: `${count} package${count > 1 ? "s" : ""} differ (view)`
113
+ });
114
+ }
115
+ options.push({ value: "done", label: "Done" });
116
+ const choice = await clack.select({
117
+ message: "Customize rules",
118
+ options
119
+ });
120
+ assertNotCancelled(choice);
121
+ if (choice === "done") break;
122
+ if (choice === "packageOverrides" && state.packageOverrides) {
123
+ const lines = state.packageOverrides.map((pkg) => {
124
+ const diffs = [];
125
+ if (pkg.conventions) {
126
+ for (const [key, val] of Object.entries(pkg.conventions)) {
127
+ const v = typeof val === "string" ? val : val?.value;
128
+ if (v) diffs.push(`${key}: ${v}`);
129
+ }
130
+ }
131
+ if (pkg.stack) {
132
+ for (const [key, val] of Object.entries(pkg.stack)) {
133
+ if (val) diffs.push(`${key}: ${val}`);
134
+ }
135
+ }
136
+ return `${pkg.path}
137
+ ${diffs.join(", ") || "minor differences"}`;
138
+ });
139
+ clack.note(
140
+ `${lines.join("\n\n")}
141
+
142
+ Edit the "packages" section in viberails.config.json to adjust.`,
143
+ "Per-package overrides"
144
+ );
145
+ continue;
146
+ }
147
+ if (choice === "maxFileLines") {
148
+ const result = await clack.text({
149
+ message: "Maximum lines per source file?",
150
+ initialValue: String(state.maxFileLines),
151
+ validate: (v) => {
152
+ const n = Number.parseInt(v, 10);
153
+ if (Number.isNaN(n) || n < 1) return "Enter a positive number";
154
+ }
155
+ });
156
+ assertNotCancelled(result);
157
+ state.maxFileLines = Number.parseInt(result, 10);
158
+ }
159
+ if (choice === "requireTests") {
160
+ const result = await clack.confirm({
161
+ message: "Require matching test files for source files?",
162
+ initialValue: state.requireTests
163
+ });
164
+ assertNotCancelled(result);
165
+ state.requireTests = result;
166
+ }
167
+ if (choice === "enforceNaming") {
168
+ const result = await clack.confirm({
169
+ message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
170
+ initialValue: state.enforceNaming
171
+ });
172
+ assertNotCancelled(result);
173
+ state.enforceNaming = result;
174
+ }
175
+ if (choice === "enforcement") {
176
+ const result = await clack.select({
177
+ message: "Enforcement mode",
178
+ options: [
179
+ {
180
+ value: "warn",
181
+ label: "warn",
182
+ hint: "show violations but don't block commits (recommended)"
183
+ },
184
+ {
185
+ value: "enforce",
186
+ label: "enforce",
187
+ hint: "block commits with violations"
188
+ }
189
+ ],
190
+ initialValue: state.enforcement
191
+ });
192
+ assertNotCancelled(result);
193
+ state.enforcement = result;
194
+ }
195
+ }
131
196
  return {
132
- maxFileLines: Number.parseInt(maxFileLinesResult, 10),
133
- requireTests: requireTestsResult,
134
- enforceNaming: enforceNamingResult,
135
- enforcement: enforcementResult
197
+ maxFileLines: state.maxFileLines,
198
+ requireTests: state.requireTests,
199
+ enforceNaming: state.enforceNaming,
200
+ enforcement: state.enforcement
136
201
  };
137
202
  }
138
203
  async function promptIntegrations(hookManager) {
@@ -149,15 +214,21 @@ async function promptIntegrations(hookManager) {
149
214
  value: "claude",
150
215
  label: "Claude Code hook",
151
216
  hint: "checks files when Claude edits them"
217
+ },
218
+ {
219
+ value: "claudeMd",
220
+ label: "CLAUDE.md reference",
221
+ hint: "appends @.viberails/context.md so Claude loads rules automatically"
152
222
  }
153
223
  ],
154
- initialValues: ["preCommit", "claude"],
224
+ initialValues: ["preCommit", "claude", "claudeMd"],
155
225
  required: false
156
226
  });
157
227
  assertNotCancelled(result);
158
228
  return {
159
229
  preCommitHook: result.includes("preCommit"),
160
- claudeCodeHook: result.includes("claude")
230
+ claudeCodeHook: result.includes("claude"),
231
+ claudeMdRef: result.includes("claudeMd")
161
232
  };
162
233
  }
163
234
 
@@ -215,22 +286,21 @@ async function boundariesCommand(options, cwd) {
215
286
  displayRules(config);
216
287
  }
217
288
  function displayRules(config) {
218
- if (!config.boundaries || config.boundaries.length === 0) {
289
+ if (!config.boundaries || Object.keys(config.boundaries.deny).length === 0) {
219
290
  console.log(import_chalk.default.yellow("No boundary rules configured."));
220
291
  console.log(`Run ${import_chalk.default.cyan("viberails boundaries --infer")} to generate rules.`);
221
292
  return;
222
293
  }
223
- const allowRules = config.boundaries.filter((r) => r.allow);
224
- const denyRules = config.boundaries.filter((r) => !r.allow);
294
+ const { deny } = config.boundaries;
295
+ const sources = Object.keys(deny).filter((k) => deny[k].length > 0);
296
+ const totalRules = sources.reduce((sum, k) => sum + deny[k].length, 0);
225
297
  console.log(`
226
- ${import_chalk.default.bold(`Boundary rules (${config.boundaries.length} rules):`)}
298
+ ${import_chalk.default.bold(`Boundary rules (${totalRules} deny rules):`)}
227
299
  `);
228
- for (const r of allowRules) {
229
- console.log(` ${import_chalk.default.green("\u2713")} ${r.from} \u2192 ${r.to}`);
230
- }
231
- for (const r of denyRules) {
232
- const reason = r.reason ? import_chalk.default.dim(` (${r.reason})`) : "";
233
- console.log(` ${import_chalk.default.red("\u2717")} ${r.from} \u2192 ${r.to}${reason}`);
300
+ for (const source of sources) {
301
+ for (const target of deny[source]) {
302
+ console.log(` ${import_chalk.default.red("\u2717")} ${source} \u2192 ${target}`);
303
+ }
234
304
  }
235
305
  console.log(
236
306
  `
@@ -247,24 +317,22 @@ async function inferAndDisplay(projectRoot, config, configPath) {
247
317
  });
248
318
  console.log(import_chalk.default.dim(`${graph.nodes.length} files, ${graph.edges.length} edges`));
249
319
  const inferred = inferBoundaries(graph);
250
- if (inferred.length === 0) {
320
+ const sources = Object.keys(inferred.deny).filter((k) => inferred.deny[k].length > 0);
321
+ const totalRules = sources.reduce((sum, k) => sum + inferred.deny[k].length, 0);
322
+ if (totalRules === 0) {
251
323
  console.log(import_chalk.default.yellow("No boundary rules could be inferred."));
252
324
  return;
253
325
  }
254
- const allow = inferred.filter((r) => r.allow);
255
- const deny = inferred.filter((r) => !r.allow);
256
326
  console.log(`
257
327
  ${import_chalk.default.bold("Inferred boundary rules:")}
258
328
  `);
259
- for (const r of allow) {
260
- console.log(` ${import_chalk.default.green("\u2713")} ${r.from} \u2192 ${r.to}`);
261
- }
262
- for (const r of deny) {
263
- const reason = r.reason ? import_chalk.default.dim(` (${r.reason})`) : "";
264
- console.log(` ${import_chalk.default.red("\u2717")} ${r.from} \u2192 ${r.to}${reason}`);
329
+ for (const source of sources) {
330
+ for (const target of inferred.deny[source]) {
331
+ console.log(` ${import_chalk.default.red("\u2717")} ${source} \u2192 ${target}`);
332
+ }
265
333
  }
266
334
  console.log(`
267
- ${allow.length} allowed, ${deny.length} denied`);
335
+ ${totalRules} denied`);
268
336
  console.log("");
269
337
  const shouldSave = await confirm2("Save to viberails.config.json?");
270
338
  if (shouldSave) {
@@ -272,7 +340,7 @@ ${import_chalk.default.bold("Inferred boundary rules:")}
272
340
  config.rules.enforceBoundaries = true;
273
341
  fs3.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
274
342
  `);
275
- console.log(`${import_chalk.default.green("\u2713")} Saved ${inferred.length} rules`);
343
+ console.log(`${import_chalk.default.green("\u2713")} Saved ${totalRules} rules`);
276
344
  }
277
345
  }
278
346
  async function showGraph(projectRoot, config) {
@@ -590,7 +658,13 @@ async function checkCommand(options, cwd) {
590
658
  filesToCheck = getAllSourceFiles(projectRoot, config);
591
659
  }
592
660
  if (filesToCheck.length === 0) {
593
- console.log(`${import_chalk2.default.green("\u2713")} No files to check.`);
661
+ if (options.format === "json") {
662
+ console.log(
663
+ JSON.stringify({ violations: [], checkedFiles: 0, enforcement: config.enforcement })
664
+ );
665
+ } else {
666
+ console.log(`${import_chalk2.default.green("\u2713")} No files to check.`);
667
+ }
594
668
  return 0;
595
669
  }
596
670
  const violations = [];
@@ -631,7 +705,7 @@ async function checkCommand(options, cwd) {
631
705
  const testViolations = checkMissingTests(projectRoot, config, severity);
632
706
  violations.push(...testViolations);
633
707
  }
634
- if (config.rules.enforceBoundaries && config.boundaries && config.boundaries.length > 0 && !options.noBoundaries) {
708
+ if (config.rules.enforceBoundaries && config.boundaries && Object.keys(config.boundaries.deny).length > 0 && !options.noBoundaries) {
635
709
  const startTime = Date.now();
636
710
  const { buildImportGraph, checkBoundaries } = await import("@viberails/graph");
637
711
  const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
@@ -647,12 +721,14 @@ async function checkCommand(options, cwd) {
647
721
  violations.push({
648
722
  file: relFile,
649
723
  rule: "boundary-violation",
650
- message: `Imports "${bv.specifier}" violating boundary: ${bv.rule.from} \u2192 ${bv.rule.to}${bv.rule.reason ? ` (${bv.rule.reason})` : ""}`,
724
+ message: `Imports "${bv.specifier}" violating boundary: ${bv.rule.from} \u2192 ${bv.rule.to}`,
651
725
  severity
652
726
  });
653
727
  }
654
728
  const elapsed = Date.now() - startTime;
655
- console.log(import_chalk2.default.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
729
+ if (options.format !== "json") {
730
+ console.log(import_chalk2.default.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
731
+ }
656
732
  }
657
733
  if (options.format === "json") {
658
734
  console.log(
@@ -679,8 +755,54 @@ async function checkCommand(options, cwd) {
679
755
  return 0;
680
756
  }
681
757
 
758
+ // src/commands/check-hook.ts
759
+ var fs7 = __toESM(require("fs"), 1);
760
+ function parseHookFilePath(input) {
761
+ try {
762
+ if (!input.trim()) return void 0;
763
+ const parsed = JSON.parse(input);
764
+ return parsed?.tool_input?.file_path ?? void 0;
765
+ } catch {
766
+ return void 0;
767
+ }
768
+ }
769
+ function readStdin() {
770
+ try {
771
+ return fs7.readFileSync(0, "utf-8");
772
+ } catch {
773
+ return "";
774
+ }
775
+ }
776
+ async function hookCheckCommand(cwd) {
777
+ try {
778
+ const filePath = parseHookFilePath(readStdin());
779
+ if (!filePath) return 0;
780
+ const originalWrite = process.stdout.write.bind(process.stdout);
781
+ let captured = "";
782
+ process.stdout.write = (chunk) => {
783
+ captured += typeof chunk === "string" ? chunk : chunk.toString();
784
+ return true;
785
+ };
786
+ try {
787
+ await checkCommand({ files: [filePath], format: "json" }, cwd);
788
+ } finally {
789
+ process.stdout.write = originalWrite;
790
+ }
791
+ if (!captured.trim()) return 0;
792
+ const result = JSON.parse(captured);
793
+ if (result.violations?.length > 0) {
794
+ process.stderr.write(`${captured.trim()}
795
+ `);
796
+ return 2;
797
+ }
798
+ return 0;
799
+ } catch {
800
+ return 0;
801
+ }
802
+ }
803
+
682
804
  // src/commands/fix.ts
683
- var fs9 = __toESM(require("fs"), 1);
805
+ var fs10 = __toESM(require("fs"), 1);
684
806
  var path10 = __toESM(require("path"), 1);
685
807
  var import_config3 = require("@viberails/config");
686
808
  var import_chalk4 = __toESM(require("chalk"), 1);
@@ -823,7 +945,7 @@ function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
823
945
  }
824
946
 
825
947
  // src/commands/fix-naming.ts
826
- var fs7 = __toESM(require("fs"), 1);
948
+ var fs8 = __toESM(require("fs"), 1);
827
949
  var path8 = __toESM(require("path"), 1);
828
950
 
829
951
  // src/commands/convert-name.ts
@@ -885,12 +1007,12 @@ function computeRename(relPath, targetConvention, projectRoot) {
885
1007
  const newRelPath = path8.join(dir, newFilename);
886
1008
  const oldAbsPath = path8.join(projectRoot, relPath);
887
1009
  const newAbsPath = path8.join(projectRoot, newRelPath);
888
- if (fs7.existsSync(newAbsPath)) return null;
1010
+ if (fs8.existsSync(newAbsPath)) return null;
889
1011
  return { oldPath: relPath, newPath: newRelPath, oldAbsPath, newAbsPath };
890
1012
  }
891
1013
  function executeRename(rename) {
892
- if (fs7.existsSync(rename.newAbsPath)) return false;
893
- fs7.renameSync(rename.oldAbsPath, rename.newAbsPath);
1014
+ if (fs8.existsSync(rename.newAbsPath)) return false;
1015
+ fs8.renameSync(rename.oldAbsPath, rename.newAbsPath);
894
1016
  return true;
895
1017
  }
896
1018
  function deduplicateRenames(renames) {
@@ -905,7 +1027,7 @@ function deduplicateRenames(renames) {
905
1027
  }
906
1028
 
907
1029
  // src/commands/fix-tests.ts
908
- var fs8 = __toESM(require("fs"), 1);
1030
+ var fs9 = __toESM(require("fs"), 1);
909
1031
  var path9 = __toESM(require("path"), 1);
910
1032
  function generateTestStub(sourceRelPath, config, projectRoot) {
911
1033
  const { testPattern } = config.structure;
@@ -916,7 +1038,7 @@ function generateTestStub(sourceRelPath, config, projectRoot) {
916
1038
  const testFilename = `${stem}${testSuffix}`;
917
1039
  const dir = path9.dirname(path9.join(projectRoot, sourceRelPath));
918
1040
  const testAbsPath = path9.join(dir, testFilename);
919
- if (fs8.existsSync(testAbsPath)) return null;
1041
+ if (fs9.existsSync(testAbsPath)) return null;
920
1042
  return {
921
1043
  path: path9.relative(projectRoot, testAbsPath),
922
1044
  absPath: testAbsPath,
@@ -930,8 +1052,8 @@ function writeTestStub(stub, config) {
930
1052
  it.todo('add tests');
931
1053
  });
932
1054
  `;
933
- fs8.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
934
- fs8.writeFileSync(stub.absPath, content);
1055
+ fs9.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
1056
+ fs9.writeFileSync(stub.absPath, content);
935
1057
  }
936
1058
 
937
1059
  // src/commands/fix.ts
@@ -944,7 +1066,7 @@ async function fixCommand(options, cwd) {
944
1066
  return 1;
945
1067
  }
946
1068
  const configPath = path10.join(projectRoot, CONFIG_FILE3);
947
- if (!fs9.existsSync(configPath)) {
1069
+ if (!fs10.existsSync(configPath)) {
948
1070
  console.error(
949
1071
  `${import_chalk4.default.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
950
1072
  );
@@ -1008,13 +1130,13 @@ async function fixCommand(options, cwd) {
1008
1130
  }
1009
1131
  let importUpdateCount = 0;
1010
1132
  if (renameCount > 0) {
1011
- const appliedRenames = dedupedRenames.filter((r) => fs9.existsSync(r.newAbsPath));
1133
+ const appliedRenames = dedupedRenames.filter((r) => fs10.existsSync(r.newAbsPath));
1012
1134
  const updates = await updateImportsAfterRenames(appliedRenames, projectRoot);
1013
1135
  importUpdateCount = updates.length;
1014
1136
  }
1015
1137
  let stubCount = 0;
1016
1138
  for (const stub of testStubs) {
1017
- if (!fs9.existsSync(stub.absPath)) {
1139
+ if (!fs10.existsSync(stub.absPath)) {
1018
1140
  writeTestStub(stub, config);
1019
1141
  stubCount++;
1020
1142
  }
@@ -1035,16 +1157,15 @@ async function fixCommand(options, cwd) {
1035
1157
  }
1036
1158
 
1037
1159
  // src/commands/init.ts
1038
- var fs12 = __toESM(require("fs"), 1);
1160
+ var fs13 = __toESM(require("fs"), 1);
1039
1161
  var path13 = __toESM(require("path"), 1);
1040
1162
  var clack2 = __toESM(require("@clack/prompts"), 1);
1041
1163
  var import_config4 = require("@viberails/config");
1042
1164
  var import_scanner = require("@viberails/scanner");
1043
1165
  var import_chalk8 = __toESM(require("chalk"), 1);
1044
1166
 
1045
- // src/display.ts
1046
- var import_types3 = require("@viberails/types");
1047
- var import_chalk6 = __toESM(require("chalk"), 1);
1167
+ // src/display-text.ts
1168
+ var import_types4 = require("@viberails/types");
1048
1169
 
1049
1170
  // src/display-helpers.ts
1050
1171
  var import_types = require("@viberails/types");
@@ -1095,6 +1216,10 @@ function formatRoleGroup(group) {
1095
1216
  return `${group.label} \u2014 ${dirs} (${files})`;
1096
1217
  }
1097
1218
 
1219
+ // src/display.ts
1220
+ var import_types3 = require("@viberails/types");
1221
+ var import_chalk6 = __toESM(require("chalk"), 1);
1222
+
1098
1223
  // src/display-monorepo.ts
1099
1224
  var import_types2 = require("@viberails/types");
1100
1225
  var import_chalk5 = __toESM(require("chalk"), 1);
@@ -1204,12 +1329,6 @@ function formatMonorepoResultsText(scanResult, config) {
1204
1329
  }
1205
1330
 
1206
1331
  // src/display.ts
1207
- var CONVENTION_LABELS = {
1208
- fileNaming: "File naming",
1209
- componentNaming: "Component naming",
1210
- hookNaming: "Hook naming",
1211
- importAlias: "Import alias"
1212
- };
1213
1332
  function formatItem(item, nameMap) {
1214
1333
  const name = nameMap?.[item.name] ?? item.name;
1215
1334
  return item.version ? `${name} ${item.version}` : name;
@@ -1228,7 +1347,7 @@ function displayConventions(scanResult) {
1228
1347
  ${import_chalk6.default.bold("Conventions:")}`);
1229
1348
  for (const [key, convention] of conventionEntries) {
1230
1349
  if (convention.confidence === "low") continue;
1231
- const label = CONVENTION_LABELS[key] ?? key;
1350
+ const label = import_types3.CONVENTION_LABELS[key] ?? key;
1232
1351
  if (scanResult.packages.length > 1) {
1233
1352
  const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1234
1353
  const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
@@ -1345,6 +1464,11 @@ function displayRulesPreview(config) {
1345
1464
  }
1346
1465
  console.log("");
1347
1466
  }
1467
+
1468
+ // src/display-text.ts
1469
+ function getConventionStr2(cv) {
1470
+ return typeof cv === "string" ? cv : cv.value;
1471
+ }
1348
1472
  function plainConfidenceLabel(convention) {
1349
1473
  const pct = Math.round(convention.consistency);
1350
1474
  if (convention.confidence === "high") {
@@ -1360,7 +1484,7 @@ function formatConventionsText(scanResult) {
1360
1484
  lines.push("Conventions:");
1361
1485
  for (const [key, convention] of conventionEntries) {
1362
1486
  if (convention.confidence === "low") continue;
1363
- const label = CONVENTION_LABELS[key] ?? key;
1487
+ const label = import_types4.CONVENTION_LABELS[key] ?? key;
1364
1488
  if (scanResult.packages.length > 1) {
1365
1489
  const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1366
1490
  const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
@@ -1394,7 +1518,7 @@ function formatRulesText(config) {
1394
1518
  lines.push(" \u2022 Require test files: no");
1395
1519
  }
1396
1520
  if (config.rules.enforceNaming && config.conventions.fileNaming) {
1397
- lines.push(` \u2022 Enforce file naming: ${getConventionStr(config.conventions.fileNaming)}`);
1521
+ lines.push(` \u2022 Enforce file naming: ${getConventionStr2(config.conventions.fileNaming)}`);
1398
1522
  } else {
1399
1523
  lines.push(" \u2022 Enforce file naming: no");
1400
1524
  }
@@ -1409,17 +1533,17 @@ function formatScanResultsText(scanResult, config) {
1409
1533
  const { stack } = scanResult;
1410
1534
  lines.push("Detected:");
1411
1535
  if (stack.framework) {
1412
- lines.push(` \u2713 ${formatItem(stack.framework, import_types3.FRAMEWORK_NAMES)}`);
1536
+ lines.push(` \u2713 ${formatItem(stack.framework, import_types4.FRAMEWORK_NAMES)}`);
1413
1537
  }
1414
1538
  lines.push(` \u2713 ${formatItem(stack.language)}`);
1415
1539
  if (stack.styling) {
1416
- lines.push(` \u2713 ${formatItem(stack.styling, import_types3.STYLING_NAMES)}`);
1540
+ lines.push(` \u2713 ${formatItem(stack.styling, import_types4.STYLING_NAMES)}`);
1417
1541
  }
1418
1542
  if (stack.backend) {
1419
- lines.push(` \u2713 ${formatItem(stack.backend, import_types3.FRAMEWORK_NAMES)}`);
1543
+ lines.push(` \u2713 ${formatItem(stack.backend, import_types4.FRAMEWORK_NAMES)}`);
1420
1544
  }
1421
1545
  if (stack.orm) {
1422
- lines.push(` \u2713 ${formatItem(stack.orm, import_types3.ORM_NAMES)}`);
1546
+ lines.push(` \u2713 ${formatItem(stack.orm, import_types4.ORM_NAMES)}`);
1423
1547
  }
1424
1548
  const secondaryParts = [];
1425
1549
  if (stack.packageManager) secondaryParts.push(formatItem(stack.packageManager));
@@ -1431,7 +1555,7 @@ function formatScanResultsText(scanResult, config) {
1431
1555
  }
1432
1556
  if (stack.libraries.length > 0) {
1433
1557
  for (const lib of stack.libraries) {
1434
- lines.push(` \u2713 ${formatItem(lib, import_types3.LIBRARY_NAMES)}`);
1558
+ lines.push(` \u2713 ${formatItem(lib, import_types4.LIBRARY_NAMES)}`);
1435
1559
  }
1436
1560
  }
1437
1561
  const groups = groupByRole(scanResult.structure.directories);
@@ -1455,7 +1579,7 @@ function formatScanResultsText(scanResult, config) {
1455
1579
  }
1456
1580
 
1457
1581
  // src/utils/write-generated-files.ts
1458
- var fs10 = __toESM(require("fs"), 1);
1582
+ var fs11 = __toESM(require("fs"), 1);
1459
1583
  var path11 = __toESM(require("path"), 1);
1460
1584
  var import_context = require("@viberails/context");
1461
1585
  var CONTEXT_DIR = ".viberails";
@@ -1464,12 +1588,12 @@ var SCAN_RESULT_FILE = "scan-result.json";
1464
1588
  function writeGeneratedFiles(projectRoot, config, scanResult) {
1465
1589
  const contextDir = path11.join(projectRoot, CONTEXT_DIR);
1466
1590
  try {
1467
- if (!fs10.existsSync(contextDir)) {
1468
- fs10.mkdirSync(contextDir, { recursive: true });
1591
+ if (!fs11.existsSync(contextDir)) {
1592
+ fs11.mkdirSync(contextDir, { recursive: true });
1469
1593
  }
1470
1594
  const context = (0, import_context.generateContext)(config);
1471
- fs10.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1472
- fs10.writeFileSync(
1595
+ fs11.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1596
+ fs11.writeFileSync(
1473
1597
  path11.join(contextDir, SCAN_RESULT_FILE),
1474
1598
  `${JSON.stringify(scanResult, null, 2)}
1475
1599
  `
@@ -1481,27 +1605,28 @@ function writeGeneratedFiles(projectRoot, config, scanResult) {
1481
1605
  }
1482
1606
 
1483
1607
  // src/commands/init-hooks.ts
1484
- var fs11 = __toESM(require("fs"), 1);
1608
+ var fs12 = __toESM(require("fs"), 1);
1485
1609
  var path12 = __toESM(require("path"), 1);
1486
1610
  var import_chalk7 = __toESM(require("chalk"), 1);
1611
+ var import_yaml = require("yaml");
1487
1612
  function setupPreCommitHook(projectRoot) {
1488
1613
  const lefthookPath = path12.join(projectRoot, "lefthook.yml");
1489
- if (fs11.existsSync(lefthookPath)) {
1614
+ if (fs12.existsSync(lefthookPath)) {
1490
1615
  addLefthookPreCommit(lefthookPath);
1491
1616
  console.log(` ${import_chalk7.default.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
1492
1617
  return;
1493
1618
  }
1494
1619
  const huskyDir = path12.join(projectRoot, ".husky");
1495
- if (fs11.existsSync(huskyDir)) {
1620
+ if (fs12.existsSync(huskyDir)) {
1496
1621
  writeHuskyPreCommit(huskyDir);
1497
1622
  console.log(` ${import_chalk7.default.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
1498
1623
  return;
1499
1624
  }
1500
1625
  const gitDir = path12.join(projectRoot, ".git");
1501
- if (fs11.existsSync(gitDir)) {
1626
+ if (fs12.existsSync(gitDir)) {
1502
1627
  const hooksDir = path12.join(gitDir, "hooks");
1503
- if (!fs11.existsSync(hooksDir)) {
1504
- fs11.mkdirSync(hooksDir, { recursive: true });
1628
+ if (!fs12.existsSync(hooksDir)) {
1629
+ fs12.mkdirSync(hooksDir, { recursive: true });
1505
1630
  }
1506
1631
  writeGitHookPreCommit(hooksDir);
1507
1632
  console.log(` ${import_chalk7.default.green("\u2713")} .git/hooks/pre-commit`);
@@ -1509,10 +1634,10 @@ function setupPreCommitHook(projectRoot) {
1509
1634
  }
1510
1635
  function writeGitHookPreCommit(hooksDir) {
1511
1636
  const hookPath = path12.join(hooksDir, "pre-commit");
1512
- if (fs11.existsSync(hookPath)) {
1513
- const existing = fs11.readFileSync(hookPath, "utf-8");
1637
+ if (fs12.existsSync(hookPath)) {
1638
+ const existing = fs12.readFileSync(hookPath, "utf-8");
1514
1639
  if (existing.includes("viberails")) return;
1515
- fs11.writeFileSync(
1640
+ fs12.writeFileSync(
1516
1641
  hookPath,
1517
1642
  `${existing.trimEnd()}
1518
1643
 
@@ -1529,61 +1654,51 @@ npx viberails check --staged
1529
1654
  "npx viberails check --staged",
1530
1655
  ""
1531
1656
  ].join("\n");
1532
- fs11.writeFileSync(hookPath, script, { mode: 493 });
1657
+ fs12.writeFileSync(hookPath, script, { mode: 493 });
1533
1658
  }
1534
1659
  function addLefthookPreCommit(lefthookPath) {
1535
- const content = fs11.readFileSync(lefthookPath, "utf-8");
1660
+ const content = fs12.readFileSync(lefthookPath, "utf-8");
1536
1661
  if (content.includes("viberails")) return;
1537
- const hasPreCommit = /^pre-commit:/m.test(content);
1538
- if (hasPreCommit) {
1539
- const commandBlock = ["", " viberails:", " run: npx viberails check --staged"].join(
1540
- "\n"
1541
- );
1542
- const updated = `${content.trimEnd()}
1543
- ${commandBlock}
1544
- `;
1545
- fs11.writeFileSync(lefthookPath, updated);
1546
- } else {
1547
- const section = [
1548
- "",
1549
- "pre-commit:",
1550
- " commands:",
1551
- " viberails:",
1552
- " run: npx viberails check --staged"
1553
- ].join("\n");
1554
- fs11.writeFileSync(lefthookPath, `${content.trimEnd()}
1555
- ${section}
1556
- `);
1662
+ const doc = (0, import_yaml.parse)(content) ?? {};
1663
+ if (!doc["pre-commit"]) {
1664
+ doc["pre-commit"] = { commands: {} };
1665
+ }
1666
+ if (!doc["pre-commit"].commands) {
1667
+ doc["pre-commit"].commands = {};
1557
1668
  }
1669
+ doc["pre-commit"].commands.viberails = {
1670
+ run: "npx viberails check --staged"
1671
+ };
1672
+ fs12.writeFileSync(lefthookPath, (0, import_yaml.stringify)(doc));
1558
1673
  }
1559
1674
  function detectHookManager(projectRoot) {
1560
- if (fs11.existsSync(path12.join(projectRoot, "lefthook.yml"))) return "Lefthook";
1561
- if (fs11.existsSync(path12.join(projectRoot, ".husky"))) return "Husky";
1562
- if (fs11.existsSync(path12.join(projectRoot, ".git"))) return "git hook";
1675
+ if (fs12.existsSync(path12.join(projectRoot, "lefthook.yml"))) return "Lefthook";
1676
+ if (fs12.existsSync(path12.join(projectRoot, ".husky"))) return "Husky";
1677
+ if (fs12.existsSync(path12.join(projectRoot, ".git"))) return "git hook";
1563
1678
  return void 0;
1564
1679
  }
1565
1680
  function setupClaudeCodeHook(projectRoot) {
1566
1681
  const claudeDir = path12.join(projectRoot, ".claude");
1567
- if (!fs11.existsSync(claudeDir)) {
1568
- fs11.mkdirSync(claudeDir, { recursive: true });
1682
+ if (!fs12.existsSync(claudeDir)) {
1683
+ fs12.mkdirSync(claudeDir, { recursive: true });
1569
1684
  }
1570
1685
  const settingsPath = path12.join(claudeDir, "settings.json");
1571
1686
  let settings = {};
1572
- if (fs11.existsSync(settingsPath)) {
1687
+ if (fs12.existsSync(settingsPath)) {
1573
1688
  try {
1574
- settings = JSON.parse(fs11.readFileSync(settingsPath, "utf-8"));
1689
+ settings = JSON.parse(fs12.readFileSync(settingsPath, "utf-8"));
1575
1690
  } catch {
1576
1691
  console.warn(
1577
- ` ${import_chalk7.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 resetting to add hook`
1692
+ ` ${import_chalk7.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
1578
1693
  );
1579
- settings = {};
1694
+ console.warn(` Fix the JSON manually, then re-run ${import_chalk7.default.cyan("viberails init --force")}`);
1695
+ return;
1580
1696
  }
1581
1697
  }
1582
1698
  const hooks = settings.hooks ?? {};
1583
1699
  const existing = hooks.PostToolUse ?? [];
1584
1700
  if (existing.some((h) => JSON.stringify(h).includes("viberails"))) return;
1585
- const extractFile = `node -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).tool_input?.file_path??'')}catch{}"`;
1586
- const hookCommand = `FILE=$(${extractFile}) && [ -n "$FILE" ] && npx viberails check --files "$FILE" --format json; exit 0`;
1701
+ const hookCommand = "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --hook; else npx viberails check --hook; fi";
1587
1702
  hooks.PostToolUse = [
1588
1703
  ...existing,
1589
1704
  {
@@ -1597,22 +1712,34 @@ function setupClaudeCodeHook(projectRoot) {
1597
1712
  }
1598
1713
  ];
1599
1714
  settings.hooks = hooks;
1600
- fs11.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1715
+ fs12.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1601
1716
  `);
1602
1717
  console.log(` ${import_chalk7.default.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
1603
1718
  }
1719
+ function setupClaudeMdReference(projectRoot) {
1720
+ const claudeMdPath = path12.join(projectRoot, "CLAUDE.md");
1721
+ let content = "";
1722
+ if (fs12.existsSync(claudeMdPath)) {
1723
+ content = fs12.readFileSync(claudeMdPath, "utf-8");
1724
+ }
1725
+ if (content.includes("@.viberails/context.md")) return;
1726
+ const ref = "\n@.viberails/context.md\n";
1727
+ const prefix = content.length === 0 ? "" : content.trimEnd();
1728
+ fs12.writeFileSync(claudeMdPath, prefix + ref);
1729
+ console.log(` ${import_chalk7.default.green("\u2713")} CLAUDE.md \u2014 added @.viberails/context.md reference`);
1730
+ }
1604
1731
  function writeHuskyPreCommit(huskyDir) {
1605
1732
  const hookPath = path12.join(huskyDir, "pre-commit");
1606
- if (fs11.existsSync(hookPath)) {
1607
- const existing = fs11.readFileSync(hookPath, "utf-8");
1733
+ if (fs12.existsSync(hookPath)) {
1734
+ const existing = fs12.readFileSync(hookPath, "utf-8");
1608
1735
  if (!existing.includes("viberails")) {
1609
- fs11.writeFileSync(hookPath, `${existing.trimEnd()}
1736
+ fs12.writeFileSync(hookPath, `${existing.trimEnd()}
1610
1737
  npx viberails check --staged
1611
1738
  `);
1612
1739
  }
1613
1740
  return;
1614
1741
  }
1615
- fs11.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1742
+ fs12.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1616
1743
  }
1617
1744
 
1618
1745
  // src/commands/init.ts
@@ -1629,14 +1756,10 @@ function filterHighConfidence(conventions) {
1629
1756
  }
1630
1757
  return filtered;
1631
1758
  }
1632
- function getConventionStr2(cv) {
1759
+ function getConventionStr3(cv) {
1633
1760
  if (!cv) return void 0;
1634
1761
  return typeof cv === "string" ? cv : cv.value;
1635
1762
  }
1636
- function hasConventionOverrides(config) {
1637
- if (!config.packages || config.packages.length === 0) return false;
1638
- return config.packages.some((pkg) => pkg.conventions && Object.keys(pkg.conventions).length > 0);
1639
- }
1640
1763
  async function initCommand(options, cwd) {
1641
1764
  const startDir = cwd ?? process.cwd();
1642
1765
  const projectRoot = findProjectRoot(startDir);
@@ -1646,10 +1769,10 @@ async function initCommand(options, cwd) {
1646
1769
  );
1647
1770
  }
1648
1771
  const configPath = path13.join(projectRoot, CONFIG_FILE4);
1649
- if (fs12.existsSync(configPath)) {
1772
+ if (fs13.existsSync(configPath) && !options.force) {
1650
1773
  console.log(
1651
1774
  `${import_chalk8.default.yellow("!")} viberails is already initialized.
1652
- Run ${import_chalk8.default.cyan("viberails sync")} to update, or delete viberails.config.json to start fresh.`
1775
+ Run ${import_chalk8.default.cyan("viberails sync")} to update, or ${import_chalk8.default.cyan("viberails init --force")} to start fresh.`
1653
1776
  );
1654
1777
  return;
1655
1778
  }
@@ -1669,16 +1792,18 @@ async function initCommand(options, cwd) {
1669
1792
  ignore: config2.ignore
1670
1793
  });
1671
1794
  const inferred = inferBoundaries(graph);
1672
- if (inferred.length > 0) {
1795
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1796
+ if (denyCount > 0) {
1673
1797
  config2.boundaries = inferred;
1674
1798
  config2.rules.enforceBoundaries = true;
1675
- console.log(` Inferred ${inferred.length} boundary rules`);
1799
+ console.log(` Inferred ${denyCount} boundary rules`);
1676
1800
  }
1677
1801
  }
1678
- fs12.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
1802
+ fs13.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
1679
1803
  `);
1680
1804
  writeGeneratedFiles(projectRoot, config2, scanResult2);
1681
1805
  updateGitignore(projectRoot);
1806
+ setupClaudeMdReference(projectRoot);
1682
1807
  console.log(`
1683
1808
  Created:`);
1684
1809
  console.log(` ${import_chalk8.default.green("\u2713")} ${CONFIG_FILE4}`);
@@ -1701,27 +1826,18 @@ Created:`);
1701
1826
  clack2.note(resultsText, "Scan results");
1702
1827
  const decision = await promptInitDecision();
1703
1828
  if (decision === "customize") {
1704
- clack2.note(
1705
- "Rules control what viberails checks for.\nYou can change these later in viberails.config.json.",
1706
- "Rules"
1707
- );
1708
- const overrides = await promptRuleCustomization({
1829
+ const overrides = await promptRuleMenu({
1709
1830
  maxFileLines: config.rules.maxFileLines,
1710
1831
  requireTests: config.rules.requireTests,
1711
1832
  enforceNaming: config.rules.enforceNaming,
1712
1833
  enforcement: config.enforcement,
1713
- fileNamingValue: getConventionStr2(config.conventions.fileNaming)
1834
+ fileNamingValue: getConventionStr3(config.conventions.fileNaming),
1835
+ packageOverrides: config.packages
1714
1836
  });
1715
1837
  config.rules.maxFileLines = overrides.maxFileLines;
1716
1838
  config.rules.requireTests = overrides.requireTests;
1717
1839
  config.rules.enforceNaming = overrides.enforceNaming;
1718
1840
  config.enforcement = overrides.enforcement;
1719
- if (config.workspace?.packages && config.workspace.packages.length > 0) {
1720
- clack2.note(
1721
- 'These rules apply globally. To customize per package,\nedit the "packages" section in viberails.config.json.',
1722
- "Per-package overrides"
1723
- );
1724
- }
1725
1841
  }
1726
1842
  if (config.workspace?.packages && config.workspace.packages.length > 0) {
1727
1843
  clack2.note(
@@ -1739,10 +1855,11 @@ Created:`);
1739
1855
  ignore: config.ignore
1740
1856
  });
1741
1857
  const inferred = inferBoundaries(graph);
1742
- if (inferred.length > 0) {
1858
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1859
+ if (denyCount > 0) {
1743
1860
  config.boundaries = inferred;
1744
1861
  config.rules.enforceBoundaries = true;
1745
- bs.stop(`Inferred ${inferred.length} boundary rules`);
1862
+ bs.stop(`Inferred ${denyCount} boundary rules`);
1746
1863
  } else {
1747
1864
  bs.stop("No boundary rules inferred");
1748
1865
  }
@@ -1750,13 +1867,7 @@ Created:`);
1750
1867
  }
1751
1868
  const hookManager = detectHookManager(projectRoot);
1752
1869
  const integrations = await promptIntegrations(hookManager);
1753
- if (hasConventionOverrides(config)) {
1754
- clack2.note(
1755
- "Some packages use different conventions. Per-package\noverrides have been saved in viberails.config.json \u2014\nreview and adjust as needed.",
1756
- "Per-package conventions"
1757
- );
1758
- }
1759
- fs12.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1870
+ fs13.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1760
1871
  `);
1761
1872
  writeGeneratedFiles(projectRoot, config, scanResult);
1762
1873
  updateGitignore(projectRoot);
@@ -1767,8 +1878,7 @@ Created:`);
1767
1878
  ];
1768
1879
  if (integrations.preCommitHook) {
1769
1880
  setupPreCommitHook(projectRoot);
1770
- const hookMgr = detectHookManager(projectRoot);
1771
- if (hookMgr) {
1881
+ if (hookManager === "Lefthook") {
1772
1882
  createdFiles.push(`lefthook.yml \u2014 added viberails pre-commit`);
1773
1883
  }
1774
1884
  }
@@ -1776,6 +1886,10 @@ Created:`);
1776
1886
  setupClaudeCodeHook(projectRoot);
1777
1887
  createdFiles.push(".claude/settings.json \u2014 added viberails hook");
1778
1888
  }
1889
+ if (integrations.claudeMdRef) {
1890
+ setupClaudeMdReference(projectRoot);
1891
+ createdFiles.push("CLAUDE.md \u2014 added @.viberails/context.md reference");
1892
+ }
1779
1893
  clack2.log.success(`Created:
1780
1894
  ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1781
1895
  clack2.outro("Done! Next: review viberails.config.json, then run viberails check");
@@ -1783,24 +1897,162 @@ ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1783
1897
  function updateGitignore(projectRoot) {
1784
1898
  const gitignorePath = path13.join(projectRoot, ".gitignore");
1785
1899
  let content = "";
1786
- if (fs12.existsSync(gitignorePath)) {
1787
- content = fs12.readFileSync(gitignorePath, "utf-8");
1900
+ if (fs13.existsSync(gitignorePath)) {
1901
+ content = fs13.readFileSync(gitignorePath, "utf-8");
1788
1902
  }
1789
1903
  if (!content.includes(".viberails/scan-result.json")) {
1790
1904
  const block = "\n# viberails\n.viberails/scan-result.json\n";
1791
1905
  const prefix = content.length === 0 ? "" : `${content.trimEnd()}
1792
1906
  `;
1793
- fs12.writeFileSync(gitignorePath, `${prefix}${block}`);
1907
+ fs13.writeFileSync(gitignorePath, `${prefix}${block}`);
1794
1908
  }
1795
1909
  }
1796
1910
 
1797
1911
  // src/commands/sync.ts
1798
- var fs13 = __toESM(require("fs"), 1);
1912
+ var fs14 = __toESM(require("fs"), 1);
1799
1913
  var path14 = __toESM(require("path"), 1);
1800
1914
  var import_config5 = require("@viberails/config");
1801
1915
  var import_scanner2 = require("@viberails/scanner");
1802
1916
  var import_chalk9 = __toESM(require("chalk"), 1);
1917
+
1918
+ // src/utils/diff-configs.ts
1919
+ var import_types5 = require("@viberails/types");
1920
+ function parseStackString(s) {
1921
+ const atIdx = s.indexOf("@");
1922
+ if (atIdx > 0) {
1923
+ return { name: s.slice(0, atIdx), version: s.slice(atIdx + 1) };
1924
+ }
1925
+ return { name: s };
1926
+ }
1927
+ function displayStackName(s) {
1928
+ const { name, version } = parseStackString(s);
1929
+ const allMaps = {
1930
+ ...import_types5.FRAMEWORK_NAMES,
1931
+ ...import_types5.STYLING_NAMES,
1932
+ ...import_types5.ORM_NAMES
1933
+ };
1934
+ const display = allMaps[name] ?? name;
1935
+ return version ? `${display} ${version}` : display;
1936
+ }
1937
+ function conventionStr(cv) {
1938
+ return typeof cv === "string" ? cv : cv.value;
1939
+ }
1940
+ function isDetected(cv) {
1941
+ return typeof cv !== "string" && cv._detected === true;
1942
+ }
1943
+ var STACK_FIELDS = [
1944
+ "framework",
1945
+ "styling",
1946
+ "backend",
1947
+ "orm",
1948
+ "linter",
1949
+ "formatter",
1950
+ "testRunner"
1951
+ ];
1952
+ var CONVENTION_KEYS = [
1953
+ "fileNaming",
1954
+ "componentNaming",
1955
+ "hookNaming",
1956
+ "importAlias"
1957
+ ];
1958
+ var STRUCTURE_FIELDS = [
1959
+ { key: "srcDir", label: "source directory" },
1960
+ { key: "pages", label: "pages directory" },
1961
+ { key: "components", label: "components directory" },
1962
+ { key: "hooks", label: "hooks directory" },
1963
+ { key: "utils", label: "utilities directory" },
1964
+ { key: "types", label: "types directory" },
1965
+ { key: "tests", label: "tests directory" },
1966
+ { key: "testPattern", label: "test pattern" }
1967
+ ];
1968
+ function diffConfigs(existing, merged) {
1969
+ const changes = [];
1970
+ for (const field of STACK_FIELDS) {
1971
+ const oldVal = existing.stack[field];
1972
+ const newVal = merged.stack[field];
1973
+ if (!oldVal && newVal) {
1974
+ changes.push({ type: "added", description: `Stack: added ${displayStackName(newVal)}` });
1975
+ } else if (oldVal && newVal && oldVal !== newVal) {
1976
+ changes.push({
1977
+ type: "changed",
1978
+ description: `Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
1979
+ });
1980
+ }
1981
+ }
1982
+ for (const key of CONVENTION_KEYS) {
1983
+ const oldVal = existing.conventions[key];
1984
+ const newVal = merged.conventions[key];
1985
+ const label = import_types5.CONVENTION_LABELS[key] ?? key;
1986
+ if (!oldVal && newVal) {
1987
+ changes.push({
1988
+ type: "added",
1989
+ description: `New convention: ${label} (${conventionStr(newVal)})`
1990
+ });
1991
+ } else if (oldVal && newVal && isDetected(newVal)) {
1992
+ changes.push({
1993
+ type: "changed",
1994
+ description: `Convention updated: ${label} (${conventionStr(newVal)})`
1995
+ });
1996
+ }
1997
+ }
1998
+ for (const { key, label } of STRUCTURE_FIELDS) {
1999
+ const oldVal = existing.structure[key];
2000
+ const newVal = merged.structure[key];
2001
+ if (!oldVal && newVal) {
2002
+ changes.push({ type: "added", description: `Structure: detected ${label} (${newVal})` });
2003
+ }
2004
+ }
2005
+ const existingPaths = new Set((existing.packages ?? []).map((p) => p.path));
2006
+ for (const pkg of merged.packages ?? []) {
2007
+ if (!existingPaths.has(pkg.path)) {
2008
+ changes.push({ type: "added", description: `New package: ${pkg.path}` });
2009
+ }
2010
+ }
2011
+ const existingWsPkgs = new Set(existing.workspace?.packages ?? []);
2012
+ const mergedWsPkgs = new Set(merged.workspace?.packages ?? []);
2013
+ for (const pkg of mergedWsPkgs) {
2014
+ if (!existingWsPkgs.has(pkg)) {
2015
+ changes.push({ type: "added", description: `Workspace: added ${pkg}` });
2016
+ }
2017
+ }
2018
+ for (const pkg of existingWsPkgs) {
2019
+ if (!mergedWsPkgs.has(pkg)) {
2020
+ changes.push({ type: "removed", description: `Workspace: removed ${pkg}` });
2021
+ }
2022
+ }
2023
+ return changes;
2024
+ }
2025
+ function formatStatsDelta(oldStats, newStats) {
2026
+ const fileDelta = newStats.totalFiles - oldStats.totalFiles;
2027
+ const lineDelta = newStats.totalLines - oldStats.totalLines;
2028
+ if (fileDelta === 0 && lineDelta === 0) return void 0;
2029
+ const parts = [];
2030
+ if (fileDelta !== 0) {
2031
+ const sign = fileDelta > 0 ? "+" : "";
2032
+ parts.push(`${sign}${fileDelta.toLocaleString()} files`);
2033
+ }
2034
+ if (lineDelta !== 0) {
2035
+ const sign = lineDelta > 0 ? "+" : "";
2036
+ parts.push(`${sign}${lineDelta.toLocaleString()} lines`);
2037
+ }
2038
+ return `${parts.join(", ")} since last sync`;
2039
+ }
2040
+
2041
+ // src/commands/sync.ts
1803
2042
  var CONFIG_FILE5 = "viberails.config.json";
2043
+ var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
2044
+ function loadPreviousStats(projectRoot) {
2045
+ const scanResultPath = path14.join(projectRoot, SCAN_RESULT_FILE2);
2046
+ try {
2047
+ const raw = fs14.readFileSync(scanResultPath, "utf-8");
2048
+ const parsed = JSON.parse(raw);
2049
+ if (parsed?.statistics?.totalFiles !== void 0) {
2050
+ return parsed.statistics;
2051
+ }
2052
+ } catch {
2053
+ }
2054
+ return void 0;
2055
+ }
1804
2056
  async function syncCommand(cwd) {
1805
2057
  const startDir = cwd ?? process.cwd();
1806
2058
  const projectRoot = findProjectRoot(startDir);
@@ -1811,18 +2063,27 @@ async function syncCommand(cwd) {
1811
2063
  }
1812
2064
  const configPath = path14.join(projectRoot, CONFIG_FILE5);
1813
2065
  const existing = await (0, import_config5.loadConfig)(configPath);
2066
+ const previousStats = loadPreviousStats(projectRoot);
1814
2067
  console.log(import_chalk9.default.dim("Scanning project..."));
1815
2068
  const scanResult = await (0, import_scanner2.scan)(projectRoot);
1816
2069
  const merged = (0, import_config5.mergeConfig)(existing, scanResult);
1817
2070
  const existingJson = JSON.stringify(existing, null, 2);
1818
2071
  const mergedJson = JSON.stringify(merged, null, 2);
1819
2072
  const configChanged = existingJson !== mergedJson;
1820
- if (configChanged) {
1821
- console.log(
1822
- ` ${import_chalk9.default.yellow("!")} Config updated \u2014 review ${import_chalk9.default.cyan(CONFIG_FILE5)} for changes`
1823
- );
2073
+ const changes = configChanged ? diffConfigs(existing, merged) : [];
2074
+ const statsDelta = previousStats ? formatStatsDelta(previousStats, scanResult.statistics) : void 0;
2075
+ if (changes.length > 0 || statsDelta) {
2076
+ console.log(`
2077
+ ${import_chalk9.default.bold("Changes:")}`);
2078
+ for (const change of changes) {
2079
+ const icon = change.type === "removed" ? import_chalk9.default.red("-") : import_chalk9.default.green("+");
2080
+ console.log(` ${icon} ${change.description}`);
2081
+ }
2082
+ if (statsDelta) {
2083
+ console.log(` ${import_chalk9.default.dim(statsDelta)}`);
2084
+ }
1824
2085
  }
1825
- fs13.writeFileSync(configPath, `${mergedJson}
2086
+ fs14.writeFileSync(configPath, `${mergedJson}
1826
2087
  `);
1827
2088
  writeGeneratedFiles(projectRoot, merged, scanResult);
1828
2089
  console.log(`
@@ -1837,10 +2098,10 @@ ${import_chalk9.default.bold("Synced:")}`);
1837
2098
  }
1838
2099
 
1839
2100
  // src/index.ts
1840
- var VERSION = "0.3.2";
2101
+ var VERSION = "0.4.0";
1841
2102
  var program = new import_commander.Command();
1842
2103
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
1843
- 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) => {
2104
+ 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) => {
1844
2105
  try {
1845
2106
  await initCommand(options);
1846
2107
  } catch (err) {
@@ -1858,9 +2119,13 @@ program.command("sync").description("Re-scan and update generated files").action
1858
2119
  process.exit(1);
1859
2120
  }
1860
2121
  });
1861
- 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(
2122
+ 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(
1862
2123
  async (options) => {
1863
2124
  try {
2125
+ if (options.hook) {
2126
+ const exitCode2 = await hookCheckCommand();
2127
+ process.exit(exitCode2);
2128
+ }
1864
2129
  const exitCode = await checkCommand({
1865
2130
  ...options,
1866
2131
  noBoundaries: options.boundaries === false,