viberails 0.4.0 → 0.5.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
@@ -34,7 +34,7 @@ __export(index_exports, {
34
34
  VERSION: () => VERSION
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
- var import_chalk10 = __toESM(require("chalk"), 1);
37
+ var import_chalk12 = __toESM(require("chalk"), 1);
38
38
  var import_commander = require("commander");
39
39
 
40
40
  // src/commands/boundaries.ts
@@ -61,183 +61,485 @@ function findProjectRoot(startDir) {
61
61
  }
62
62
 
63
63
  // src/utils/prompt.ts
64
+ var clack5 = __toESM(require("@clack/prompts"), 1);
65
+
66
+ // src/utils/prompt-integrations.ts
64
67
  var clack = __toESM(require("@clack/prompts"), 1);
65
- function assertNotCancelled(value) {
66
- if (clack.isCancel(value)) {
67
- clack.cancel("Setup cancelled.");
68
- process.exit(0);
68
+ async function promptIntegrations(hookManager, tools) {
69
+ const hookLabel = hookManager ? `Pre-commit hook (${hookManager})` : "Pre-commit hook (git hook)";
70
+ const options = [
71
+ {
72
+ value: "preCommit",
73
+ label: hookLabel,
74
+ hint: "runs viberails checks when you commit"
75
+ }
76
+ ];
77
+ if (tools?.isTypeScript) {
78
+ options.push({
79
+ value: "typecheck",
80
+ label: "Typecheck (tsc --noEmit)",
81
+ hint: "catches type errors before commit"
82
+ });
69
83
  }
70
- }
71
- async function confirm2(message) {
72
- const result = await clack.confirm({ message, initialValue: true });
73
- assertNotCancelled(result);
74
- return result;
75
- }
76
- async function confirmDangerous(message) {
77
- const result = await clack.confirm({ message, initialValue: false });
78
- assertNotCancelled(result);
79
- return result;
80
- }
81
- async function promptInitDecision() {
82
- const result = await clack.select({
83
- message: "Accept these settings?",
84
- options: [
85
- { value: "accept", label: "Yes, looks good", hint: "recommended" },
86
- { value: "customize", label: "Let me customize" }
87
- ]
84
+ if (tools?.linter) {
85
+ const linterName = tools.linter === "biome" ? "Biome" : "ESLint";
86
+ options.push({
87
+ value: "lint",
88
+ label: `Lint check (${linterName})`,
89
+ hint: "runs linter on staged files before commit"
90
+ });
91
+ }
92
+ options.push(
93
+ {
94
+ value: "claude",
95
+ label: "Claude Code hook",
96
+ hint: "checks files when Claude edits them"
97
+ },
98
+ {
99
+ value: "claudeMd",
100
+ label: "CLAUDE.md reference",
101
+ hint: "appends @.viberails/context.md so Claude loads rules automatically"
102
+ },
103
+ {
104
+ value: "githubAction",
105
+ label: "GitHub Actions workflow",
106
+ hint: "blocks PRs that fail viberails check"
107
+ }
108
+ );
109
+ const initialValues = options.map((o) => o.value);
110
+ const result = await clack.multiselect({
111
+ message: "Set up integrations?",
112
+ options,
113
+ initialValues,
114
+ required: false
88
115
  });
89
116
  assertNotCancelled(result);
90
- return result;
117
+ return {
118
+ preCommitHook: result.includes("preCommit"),
119
+ claudeCodeHook: result.includes("claude"),
120
+ claudeMdRef: result.includes("claudeMd"),
121
+ githubAction: result.includes("githubAction"),
122
+ typecheckHook: result.includes("typecheck"),
123
+ lintHook: result.includes("lint")
124
+ };
91
125
  }
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) },
99
- {
100
- value: "requireTests",
101
- label: "Require test files",
102
- hint: state.requireTests ? "yes" : "no"
103
- },
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
- });
126
+
127
+ // src/utils/prompt-rules.ts
128
+ var clack4 = __toESM(require("@clack/prompts"), 1);
129
+
130
+ // src/utils/prompt-menu-handlers.ts
131
+ var clack3 = __toESM(require("@clack/prompts"), 1);
132
+
133
+ // src/utils/prompt-package-overrides.ts
134
+ var clack2 = __toESM(require("@clack/prompts"), 1);
135
+ function normalizePackageOverrides(packages) {
136
+ for (const pkg of packages) {
137
+ if (pkg.rules && Object.keys(pkg.rules).length === 0) {
138
+ delete pkg.rules;
114
139
  }
115
- options.push({ value: "done", label: "Done" });
116
- const choice = await clack.select({
117
- message: "Customize rules",
118
- options
140
+ if (pkg.coverage && Object.keys(pkg.coverage).length === 0) {
141
+ delete pkg.coverage;
142
+ }
143
+ }
144
+ return packages;
145
+ }
146
+ function packageCoverageHint(pkg, defaults) {
147
+ const coverage = pkg.rules?.testCoverage ?? defaults.testCoverage;
148
+ const isExempt = coverage === 0;
149
+ const hasSummaryOverride = pkg.coverage?.summaryPath !== void 0 && pkg.coverage.summaryPath !== defaults.coverageSummaryPath;
150
+ const defaultCommand = defaults.coverageCommand ?? "";
151
+ const hasCommandOverride = pkg.coverage?.command !== void 0 && pkg.coverage.command !== defaultCommand;
152
+ const tags = [];
153
+ const nameSegments = pkg.name.replace(/^@[^/]+\//, "").split(/[-/]/);
154
+ const isTypesOnly = isExempt && nameSegments.some((s) => s === "types");
155
+ tags.push(isExempt ? isTypesOnly ? "exempt (types-only)" : "exempt" : `${coverage}%`);
156
+ if (hasSummaryOverride) tags.push("summary override");
157
+ if (hasCommandOverride) tags.push("command override");
158
+ return tags.join(", ");
159
+ }
160
+ async function promptPackageCoverageOverrides(packages, defaults) {
161
+ const editablePackages = packages.filter((pkg) => pkg.path !== ".");
162
+ if (editablePackages.length === 0) return packages;
163
+ while (true) {
164
+ const selectedPath = await clack2.select({
165
+ message: "Select package to edit coverage overrides",
166
+ options: [
167
+ ...editablePackages.map((pkg) => ({
168
+ value: pkg.path,
169
+ label: `${pkg.path} (${pkg.name})`,
170
+ hint: packageCoverageHint(pkg, defaults)
171
+ })),
172
+ { value: "__done__", label: "Done" }
173
+ ]
119
174
  });
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}`);
175
+ assertNotCancelled(selectedPath);
176
+ if (selectedPath === "__done__") break;
177
+ const target = editablePackages.find((pkg) => pkg.path === selectedPath);
178
+ if (!target) continue;
179
+ while (true) {
180
+ const effectiveCoverage = target.rules?.testCoverage ?? defaults.testCoverage;
181
+ const effectiveSummary = target.coverage?.summaryPath ?? defaults.coverageSummaryPath;
182
+ const effectiveCommand = target.coverage?.command ?? defaults.coverageCommand ?? "(auto-detect)";
183
+ const choice = await clack2.select({
184
+ message: `Edit coverage overrides for ${target.path}`,
185
+ options: [
186
+ { value: "testCoverage", label: "testCoverage", hint: String(effectiveCoverage) },
187
+ { value: "summaryPath", label: "coverage.summaryPath", hint: effectiveSummary },
188
+ { value: "command", label: "coverage.command", hint: effectiveCommand },
189
+ { value: "reset", label: "Reset this package to inherit defaults" },
190
+ { value: "back", label: "Back to package list" }
191
+ ]
192
+ });
193
+ assertNotCancelled(choice);
194
+ if (choice === "back") break;
195
+ if (choice === "testCoverage") {
196
+ const result = await clack2.text({
197
+ message: "Package testCoverage (0 to exempt package)?",
198
+ initialValue: String(effectiveCoverage),
199
+ validate: (v) => {
200
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
201
+ const n = Number.parseInt(v, 10);
202
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
203
+ }
204
+ });
205
+ assertNotCancelled(result);
206
+ const nextCoverage = Number.parseInt(result, 10);
207
+ if (nextCoverage === defaults.testCoverage) {
208
+ if (target.rules) {
209
+ delete target.rules.testCoverage;
129
210
  }
211
+ } else {
212
+ target.rules = { ...target.rules ?? {}, testCoverage: nextCoverage };
130
213
  }
131
- if (pkg.stack) {
132
- for (const [key, val] of Object.entries(pkg.stack)) {
133
- if (val) diffs.push(`${key}: ${val}`);
214
+ }
215
+ if (choice === "summaryPath") {
216
+ const result = await clack2.text({
217
+ message: "Package coverage.summaryPath (blank to inherit default)?",
218
+ initialValue: target.coverage?.summaryPath !== void 0 ? target.coverage.summaryPath : "",
219
+ placeholder: defaults.coverageSummaryPath
220
+ });
221
+ assertNotCancelled(result);
222
+ const value = result.trim();
223
+ if (value.length === 0 || value === defaults.coverageSummaryPath) {
224
+ if (target.coverage) {
225
+ delete target.coverage.summaryPath;
134
226
  }
227
+ } else {
228
+ target.coverage = { ...target.coverage ?? {}, summaryPath: value };
135
229
  }
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";
230
+ }
231
+ if (choice === "command") {
232
+ const result = await clack2.text({
233
+ message: "Package coverage.command (blank to inherit default/auto)?",
234
+ initialValue: target.coverage?.command !== void 0 ? target.coverage.command : "",
235
+ placeholder: defaults.coverageCommand ?? "(auto-detect from package.json test runner)"
236
+ });
237
+ assertNotCancelled(result);
238
+ const value = result.trim();
239
+ const defaultCommand = defaults.coverageCommand ?? "";
240
+ if (value.length === 0 || value === defaultCommand) {
241
+ if (target.coverage) {
242
+ delete target.coverage.command;
243
+ }
244
+ } else {
245
+ target.coverage = { ...target.coverage ?? {}, command: value };
154
246
  }
155
- });
156
- assertNotCancelled(result);
157
- state.maxFileLines = Number.parseInt(result, 10);
247
+ }
248
+ if (choice === "reset") {
249
+ if (target.rules) {
250
+ delete target.rules.testCoverage;
251
+ }
252
+ delete target.coverage;
253
+ }
254
+ normalizePackageOverrides(editablePackages);
158
255
  }
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;
256
+ }
257
+ return normalizePackageOverrides(packages);
258
+ }
259
+
260
+ // src/utils/prompt-menu-handlers.ts
261
+ function getPackageDiffs(pkg, root) {
262
+ const diffs = [];
263
+ const convKeys = ["fileNaming", "componentNaming", "hookNaming", "importAlias"];
264
+ for (const key of convKeys) {
265
+ if (pkg.conventions?.[key] && pkg.conventions[key] !== root.conventions?.[key]) {
266
+ diffs.push(`${key}: ${pkg.conventions[key]}`);
267
+ }
268
+ }
269
+ const stackKeys = [
270
+ "framework",
271
+ "language",
272
+ "styling",
273
+ "backend",
274
+ "orm",
275
+ "linter",
276
+ "formatter",
277
+ "testRunner",
278
+ "packageManager"
279
+ ];
280
+ for (const key of stackKeys) {
281
+ if (pkg.stack?.[key] && pkg.stack[key] !== root.stack?.[key]) {
282
+ diffs.push(`${key}: ${pkg.stack[key]}`);
166
283
  }
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
284
+ }
285
+ if (pkg.rules?.maxFileLines !== void 0 && pkg.rules.maxFileLines !== root.rules?.maxFileLines && pkg.rules.maxFileLines > 0) {
286
+ diffs.push(`maxFileLines: ${pkg.rules.maxFileLines}`);
287
+ }
288
+ if (pkg.rules?.testCoverage !== void 0 && pkg.rules.testCoverage !== root.rules?.testCoverage && pkg.rules.testCoverage >= 0) {
289
+ diffs.push(`testCoverage: ${pkg.rules.testCoverage}`);
290
+ }
291
+ if (pkg.coverage?.summaryPath && pkg.coverage.summaryPath !== root.coverage?.summaryPath) {
292
+ diffs.push(`coverage.summaryPath: ${pkg.coverage.summaryPath}`);
293
+ }
294
+ if (pkg.coverage?.command && pkg.coverage.command !== root.coverage?.command) {
295
+ diffs.push("coverage.command: (override)");
296
+ }
297
+ return diffs;
298
+ }
299
+ function buildMenuOptions(state, packageCount) {
300
+ const namingHint = state.enforceNaming ? `yes${state.fileNamingValue ? ` (${state.fileNamingValue})` : ""}` : "no";
301
+ const options = [
302
+ { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
303
+ { value: "enforceNaming", label: "Enforce file naming", hint: namingHint }
304
+ ];
305
+ if (state.fileNamingValue) {
306
+ options.push({
307
+ value: "fileNaming",
308
+ label: "File naming convention",
309
+ hint: state.fileNamingValue
310
+ });
311
+ }
312
+ options.push({
313
+ value: "testCoverage",
314
+ label: "Test coverage target",
315
+ hint: state.testCoverage === 0 ? "0 (disabled)" : `${state.testCoverage}%`
316
+ });
317
+ options.push({
318
+ value: "enforceMissingTests",
319
+ label: "Enforce missing tests",
320
+ hint: state.enforceMissingTests ? "yes" : "no"
321
+ });
322
+ if (state.testCoverage > 0) {
323
+ options.push(
324
+ {
325
+ value: "coverageSummaryPath",
326
+ label: "Coverage summary path",
327
+ hint: state.coverageSummaryPath
328
+ },
329
+ {
330
+ value: "coverageCommand",
331
+ label: "Coverage command",
332
+ hint: state.coverageCommand ?? "auto-detect from package.json test runner"
333
+ }
334
+ );
335
+ if (packageCount > 0) {
336
+ options.push({
337
+ value: "packageOverrides",
338
+ label: "Per-package coverage overrides",
339
+ hint: `${packageCount} package${packageCount > 1 ? "s" : ""} configurable`
171
340
  });
172
- assertNotCancelled(result);
173
- state.enforceNaming = result;
174
341
  }
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
342
+ }
343
+ options.push(
344
+ { value: "reset", label: "Reset all to detected defaults" },
345
+ { value: "done", label: "Done" }
346
+ );
347
+ return options;
348
+ }
349
+ function clonePackages(packages) {
350
+ return packages?.map((pkg) => ({
351
+ ...pkg,
352
+ stack: pkg.stack ? { ...pkg.stack } : void 0,
353
+ structure: pkg.structure ? { ...pkg.structure } : void 0,
354
+ conventions: pkg.conventions ? { ...pkg.conventions } : void 0,
355
+ rules: pkg.rules ? { ...pkg.rules } : void 0,
356
+ coverage: pkg.coverage ? { ...pkg.coverage } : void 0,
357
+ ignore: pkg.ignore ? [...pkg.ignore] : void 0,
358
+ boundaries: pkg.boundaries ? {
359
+ deny: [...pkg.boundaries.deny],
360
+ ignore: pkg.boundaries.ignore ? [...pkg.boundaries.ignore] : void 0
361
+ } : void 0
362
+ }));
363
+ }
364
+ async function handleMenuChoice(choice, state, defaults, root) {
365
+ if (choice === "reset") {
366
+ state.maxFileLines = defaults.maxFileLines;
367
+ state.testCoverage = defaults.testCoverage;
368
+ state.enforceMissingTests = defaults.enforceMissingTests;
369
+ state.enforceNaming = defaults.enforceNaming;
370
+ state.fileNamingValue = defaults.fileNamingValue;
371
+ state.coverageSummaryPath = defaults.coverageSummaryPath;
372
+ state.coverageCommand = defaults.coverageCommand;
373
+ state.packageOverrides = clonePackages(defaults.packageOverrides);
374
+ clack3.log.info("Reset all rules to detected defaults.");
375
+ return;
376
+ }
377
+ if (choice === "packageOverrides") {
378
+ if (state.packageOverrides) {
379
+ const packageDiffs = root ? state.packageOverrides.filter((pkg) => pkg.path !== root.path).map((pkg) => ({ pkg, diffs: getPackageDiffs(pkg, root) })).filter((entry) => entry.diffs.length > 0) : [];
380
+ state.packageOverrides = await promptPackageCoverageOverrides(state.packageOverrides, {
381
+ testCoverage: state.testCoverage,
382
+ coverageSummaryPath: state.coverageSummaryPath,
383
+ coverageCommand: state.coverageCommand
191
384
  });
192
- assertNotCancelled(result);
193
- state.enforcement = result;
385
+ const lines = packageDiffs.map((entry) => `${entry.pkg.path}
386
+ ${entry.diffs.join(", ")}`);
387
+ if (lines.length > 0) {
388
+ clack3.note(lines.join("\n\n"), "Existing package differences");
389
+ }
194
390
  }
391
+ return;
392
+ }
393
+ if (choice === "maxFileLines") {
394
+ const result = await clack3.text({
395
+ message: "Maximum lines per source file?",
396
+ initialValue: String(state.maxFileLines),
397
+ validate: (v) => {
398
+ if (typeof v !== "string") return "Enter a positive number";
399
+ const n = Number.parseInt(v, 10);
400
+ if (Number.isNaN(n) || n < 1) return "Enter a positive number";
401
+ }
402
+ });
403
+ assertNotCancelled(result);
404
+ state.maxFileLines = Number.parseInt(result, 10);
405
+ }
406
+ if (choice === "enforceMissingTests") {
407
+ const result = await clack3.confirm({
408
+ message: "Require every source file to have a corresponding test file?",
409
+ initialValue: state.enforceMissingTests
410
+ });
411
+ assertNotCancelled(result);
412
+ state.enforceMissingTests = result;
413
+ }
414
+ if (choice === "testCoverage") {
415
+ const result = await clack3.text({
416
+ message: "Test coverage target (0 disables coverage checks)?",
417
+ initialValue: String(state.testCoverage),
418
+ validate: (v) => {
419
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
420
+ const n = Number.parseInt(v, 10);
421
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
422
+ }
423
+ });
424
+ assertNotCancelled(result);
425
+ state.testCoverage = Number.parseInt(result, 10);
426
+ }
427
+ if (choice === "coverageSummaryPath") {
428
+ const result = await clack3.text({
429
+ message: "Coverage summary path (relative to package root)?",
430
+ initialValue: state.coverageSummaryPath,
431
+ validate: (v) => {
432
+ if (typeof v !== "string" || v.trim().length === 0) return "Path cannot be empty";
433
+ }
434
+ });
435
+ assertNotCancelled(result);
436
+ state.coverageSummaryPath = result.trim();
437
+ }
438
+ if (choice === "coverageCommand") {
439
+ const result = await clack3.text({
440
+ message: "Coverage command (blank to auto-detect from package.json)?",
441
+ initialValue: state.coverageCommand ?? "",
442
+ placeholder: "(auto-detect from package.json test runner)"
443
+ });
444
+ assertNotCancelled(result);
445
+ const trimmed = result.trim();
446
+ state.coverageCommand = trimmed.length > 0 ? trimmed : void 0;
447
+ }
448
+ if (choice === "enforceNaming") {
449
+ const result = await clack3.confirm({
450
+ message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
451
+ initialValue: state.enforceNaming
452
+ });
453
+ assertNotCancelled(result);
454
+ state.enforceNaming = result;
455
+ }
456
+ if (choice === "fileNaming") {
457
+ const selected = await clack3.select({
458
+ message: "Which file naming convention should be enforced?",
459
+ options: [
460
+ { value: "kebab-case", label: "kebab-case" },
461
+ { value: "camelCase", label: "camelCase" },
462
+ { value: "PascalCase", label: "PascalCase" },
463
+ { value: "snake_case", label: "snake_case" }
464
+ ],
465
+ initialValue: state.fileNamingValue
466
+ });
467
+ assertNotCancelled(selected);
468
+ state.fileNamingValue = selected;
469
+ }
470
+ }
471
+
472
+ // src/utils/prompt-rules.ts
473
+ function getRootPackage(packages) {
474
+ return packages.find((pkg) => pkg.path === ".") ?? packages[0];
475
+ }
476
+ async function promptRuleMenu(defaults) {
477
+ const state = {
478
+ ...defaults,
479
+ packageOverrides: clonePackages(defaults.packageOverrides)
480
+ };
481
+ const root = state.packageOverrides && state.packageOverrides.length > 0 ? getRootPackage(state.packageOverrides) : void 0;
482
+ const packageCount = state.packageOverrides?.filter((pkg) => pkg.path !== ".").length ?? 0;
483
+ while (true) {
484
+ const options = buildMenuOptions(state, packageCount);
485
+ const choice = await clack4.select({ message: "Customize rules", options });
486
+ assertNotCancelled(choice);
487
+ if (choice === "done") break;
488
+ await handleMenuChoice(choice, state, defaults, root);
195
489
  }
196
490
  return {
197
491
  maxFileLines: state.maxFileLines,
198
- requireTests: state.requireTests,
492
+ testCoverage: state.testCoverage,
493
+ enforceMissingTests: state.enforceMissingTests,
199
494
  enforceNaming: state.enforceNaming,
200
- enforcement: state.enforcement
495
+ fileNamingValue: state.fileNamingValue,
496
+ coverageSummaryPath: state.coverageSummaryPath,
497
+ coverageCommand: state.coverageCommand,
498
+ packageOverrides: state.packageOverrides
201
499
  };
202
500
  }
203
- async function promptIntegrations(hookManager) {
204
- const hookLabel = hookManager ? `Pre-commit hook (${hookManager})` : "Pre-commit hook (git hook)";
205
- const result = await clack.multiselect({
206
- message: "Set up integrations?",
501
+
502
+ // src/utils/prompt.ts
503
+ function assertNotCancelled(value) {
504
+ if (clack5.isCancel(value)) {
505
+ clack5.cancel("Setup cancelled.");
506
+ process.exit(0);
507
+ }
508
+ }
509
+ async function confirm3(message) {
510
+ const result = await clack5.confirm({ message, initialValue: true });
511
+ assertNotCancelled(result);
512
+ return result;
513
+ }
514
+ async function confirmDangerous(message) {
515
+ const result = await clack5.confirm({ message, initialValue: false });
516
+ assertNotCancelled(result);
517
+ return result;
518
+ }
519
+ async function promptInitDecision() {
520
+ const result = await clack5.select({
521
+ message: "Accept these rules?",
207
522
  options: [
208
523
  {
209
- value: "preCommit",
210
- label: hookLabel,
211
- hint: "runs checks when you commit"
212
- },
213
- {
214
- value: "claude",
215
- label: "Claude Code hook",
216
- hint: "checks files when Claude edits them"
524
+ value: "accept",
525
+ label: "Yes, looks good",
526
+ hint: "warns on violation; use --enforce in CI to block"
217
527
  },
218
- {
219
- value: "claudeMd",
220
- label: "CLAUDE.md reference",
221
- hint: "appends @.viberails/context.md so Claude loads rules automatically"
222
- }
223
- ],
224
- initialValues: ["preCommit", "claude", "claudeMd"],
225
- required: false
528
+ { value: "customize", label: "Let me customize rules" }
529
+ ]
226
530
  });
227
531
  assertNotCancelled(result);
228
- return {
229
- preCommitHook: result.includes("preCommit"),
230
- claudeCodeHook: result.includes("claude"),
231
- claudeMdRef: result.includes("claudeMd")
232
- };
532
+ return result;
233
533
  }
234
534
 
235
535
  // src/utils/resolve-workspace-packages.ts
236
536
  var fs2 = __toESM(require("fs"), 1);
237
537
  var path2 = __toESM(require("path"), 1);
238
- function resolveWorkspacePackages(projectRoot, workspace) {
239
- const packages = [];
240
- for (const relativePath of workspace.packages) {
538
+ function resolveWorkspacePackages(projectRoot, packages) {
539
+ const resolved = [];
540
+ for (const pkgConfig of packages) {
541
+ if (pkgConfig.path === ".") continue;
542
+ const relativePath = pkgConfig.path;
241
543
  const absPath = path2.join(projectRoot, relativePath);
242
544
  const pkgJsonPath = path2.join(absPath, "package.json");
243
545
  if (!fs2.existsSync(pkgJsonPath)) continue;
@@ -253,13 +555,13 @@ function resolveWorkspacePackages(projectRoot, workspace) {
253
555
  ...Object.keys(pkg.dependencies ?? {}),
254
556
  ...Object.keys(pkg.devDependencies ?? {})
255
557
  ];
256
- packages.push({ name, path: absPath, relativePath, internalDeps: allDeps });
558
+ resolved.push({ name, path: absPath, relativePath, internalDeps: allDeps });
257
559
  }
258
- const packageNames = new Set(packages.map((p) => p.name));
259
- for (const pkg of packages) {
560
+ const packageNames = new Set(resolved.map((p) => p.name));
561
+ for (const pkg of resolved) {
260
562
  pkg.internalDeps = pkg.internalDeps.filter((dep) => packageNames.has(dep));
261
563
  }
262
- return packages;
564
+ return resolved;
263
565
  }
264
566
 
265
567
  // src/commands/boundaries.ts
@@ -310,7 +612,7 @@ Enforcement: ${config.rules.enforceBoundaries ? import_chalk.default.green("on")
310
612
  async function inferAndDisplay(projectRoot, config, configPath) {
311
613
  console.log(import_chalk.default.dim("Analyzing imports..."));
312
614
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
313
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
615
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
314
616
  const graph = await buildImportGraph(projectRoot, {
315
617
  packages,
316
618
  ignore: config.ignore
@@ -334,11 +636,11 @@ ${import_chalk.default.bold("Inferred boundary rules:")}
334
636
  console.log(`
335
637
  ${totalRules} denied`);
336
638
  console.log("");
337
- const shouldSave = await confirm2("Save to viberails.config.json?");
639
+ const shouldSave = await confirm3("Save to viberails.config.json?");
338
640
  if (shouldSave) {
339
641
  config.boundaries = inferred;
340
642
  config.rules.enforceBoundaries = true;
341
- fs3.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
643
+ fs3.writeFileSync(configPath, `${JSON.stringify((0, import_config.compactConfig)(config), null, 2)}
342
644
  `);
343
645
  console.log(`${import_chalk.default.green("\u2713")} Saved ${totalRules} rules`);
344
646
  }
@@ -346,7 +648,7 @@ ${import_chalk.default.bold("Inferred boundary rules:")}
346
648
  async function showGraph(projectRoot, config) {
347
649
  console.log(import_chalk.default.dim("Building import graph..."));
348
650
  const { buildImportGraph } = await import("@viberails/graph");
349
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
651
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
350
652
  const graph = await buildImportGraph(projectRoot, {
351
653
  packages,
352
654
  ignore: config.ignore
@@ -374,42 +676,191 @@ ${import_chalk.default.yellow("Cycles detected:")}`);
374
676
  }
375
677
 
376
678
  // src/commands/check.ts
377
- var fs6 = __toESM(require("fs"), 1);
378
- var path6 = __toESM(require("path"), 1);
379
- var import_config2 = require("@viberails/config");
679
+ var fs7 = __toESM(require("fs"), 1);
680
+ var path7 = __toESM(require("path"), 1);
681
+ var import_config4 = require("@viberails/config");
380
682
  var import_chalk2 = __toESM(require("chalk"), 1);
381
683
 
382
684
  // src/commands/check-config.ts
685
+ var import_config2 = require("@viberails/config");
383
686
  function resolveConfigForFile(relPath, config) {
384
- if (!config.packages || config.packages.length === 0) {
385
- return { rules: config.rules, conventions: config.conventions };
386
- }
387
687
  const sortedPackages = [...config.packages].sort((a, b) => b.path.length - a.path.length);
388
688
  for (const pkg of sortedPackages) {
689
+ if (pkg.path === ".") continue;
389
690
  if (relPath.startsWith(`${pkg.path}/`) || relPath === pkg.path) {
390
691
  return {
391
692
  rules: { ...config.rules, ...pkg.rules },
392
- conventions: { ...config.conventions, ...pkg.conventions }
693
+ conventions: pkg.conventions ?? {},
694
+ coverage: {
695
+ ...config.defaults?.coverage ?? {},
696
+ ...pkg.coverage ?? {}
697
+ }
393
698
  };
394
699
  }
395
700
  }
396
- return { rules: config.rules, conventions: config.conventions };
701
+ const root = config.packages.find((p) => p.path === ".") ?? config.packages[0];
702
+ return {
703
+ rules: { ...config.rules, ...root.rules },
704
+ conventions: root.conventions ?? {},
705
+ coverage: {
706
+ ...config.defaults?.coverage ?? {},
707
+ ...root.coverage ?? {}
708
+ }
709
+ };
710
+ }
711
+ function getEffectiveIgnore(config) {
712
+ return [...import_config2.BUILTIN_IGNORE, ...config.ignore ?? []];
397
713
  }
398
714
  function resolveIgnoreForFile(relPath, config) {
399
- const globalIgnore = config.ignore;
400
- if (!config.packages) return globalIgnore;
401
- for (const pkg of config.packages) {
402
- if (pkg.ignore && relPath.startsWith(`${pkg.path}/`)) {
403
- return [...globalIgnore, ...pkg.ignore];
404
- }
715
+ const base = getEffectiveIgnore(config);
716
+ const root = config.packages.find((p) => p.path === ".");
717
+ const withRoot = root?.ignore ? [...base, ...root.ignore] : base;
718
+ const matched = [...config.packages].filter((p) => p.path !== ".").sort((a, b) => b.path.length - a.path.length).find((p) => relPath.startsWith(`${p.path}/`) || relPath === p.path);
719
+ if (matched?.ignore) {
720
+ return [...withRoot, ...matched.ignore];
405
721
  }
406
- return globalIgnore;
722
+ return withRoot;
407
723
  }
408
724
 
409
- // src/commands/check-files.ts
725
+ // src/commands/check-coverage.ts
410
726
  var import_node_child_process = require("child_process");
411
727
  var fs4 = __toESM(require("fs"), 1);
412
728
  var path4 = __toESM(require("path"), 1);
729
+ var DEFAULT_SUMMARY_PATH = "coverage/coverage-summary.json";
730
+ function packageRoot(projectRoot, pkg) {
731
+ return pkg.path === "." ? projectRoot : path4.join(projectRoot, pkg.path);
732
+ }
733
+ function resolveForPackage(config, pkg) {
734
+ return {
735
+ pkg,
736
+ rules: { ...config.rules, ...pkg.rules },
737
+ coverage: {
738
+ ...config.defaults?.coverage ?? {},
739
+ ...pkg.coverage ?? {}
740
+ }
741
+ };
742
+ }
743
+ function resolveCoveragePackages(projectRoot, config, filesToCheck, staged) {
744
+ if (!staged) {
745
+ return config.packages.map((pkg) => resolveForPackage(config, pkg));
746
+ }
747
+ const matched = /* @__PURE__ */ new Map();
748
+ for (const raw of filesToCheck) {
749
+ const relPath = path4.isAbsolute(raw) ? path4.relative(projectRoot, raw) : raw;
750
+ const sorted = [...config.packages].filter((pkg2) => pkg2.path !== ".").sort((a, b) => b.path.length - a.path.length);
751
+ const pkg = sorted.find(
752
+ (candidate) => relPath.startsWith(`${candidate.path}/`) || relPath === candidate.path
753
+ ) ?? config.packages.find((candidate) => candidate.path === ".") ?? config.packages[0];
754
+ matched.set(pkg.path, resolveForPackage(config, pkg));
755
+ }
756
+ return [...matched.values()];
757
+ }
758
+ function readCoveragePercentage(summaryPath) {
759
+ try {
760
+ const parsed = JSON.parse(fs4.readFileSync(summaryPath, "utf-8"));
761
+ const pct = parsed.total?.lines?.pct;
762
+ return typeof pct === "number" ? pct : void 0;
763
+ } catch {
764
+ return void 0;
765
+ }
766
+ }
767
+ function runCoverageCommand(pkgRoot, command) {
768
+ const result = (0, import_node_child_process.spawnSync)(command, {
769
+ cwd: pkgRoot,
770
+ shell: true,
771
+ encoding: "utf-8",
772
+ stdio: "pipe"
773
+ });
774
+ if (result.status === 0) return { ok: true };
775
+ const stderr = result.stderr?.trim() ?? "";
776
+ const stdout = result.stdout?.trim() ?? "";
777
+ const raw = stderr || stdout || `exit code ${result.status ?? 1}`;
778
+ if (raw.includes("coverage-v8") || raw.includes("coverage-istanbul") || raw.includes("MISSING DEP")) {
779
+ return {
780
+ ok: false,
781
+ detail: "Missing coverage provider. Install with: npm install -D @vitest/coverage-v8"
782
+ };
783
+ }
784
+ const detail = raw.replace(/\x1B\[[0-9;]*m/g, "");
785
+ return { ok: false, detail };
786
+ }
787
+ function violationFilePath(projectRoot, pkgRoot, summaryPath) {
788
+ return path4.relative(projectRoot, path4.join(pkgRoot, summaryPath));
789
+ }
790
+ function pushViolation(violations, file, message, severity) {
791
+ violations.push({
792
+ file,
793
+ rule: "test-coverage",
794
+ message,
795
+ severity
796
+ });
797
+ }
798
+ function checkCoverage(projectRoot, config, filesToCheck, options) {
799
+ const severity = options.enforce ? "error" : "warn";
800
+ const targets = resolveCoveragePackages(
801
+ projectRoot,
802
+ config,
803
+ filesToCheck,
804
+ options.staged === true
805
+ );
806
+ const violations = [];
807
+ for (const target of targets) {
808
+ if (target.rules.testCoverage <= 0) continue;
809
+ const pkgRoot = packageRoot(projectRoot, target.pkg);
810
+ const summaryPath = target.coverage.summaryPath ?? DEFAULT_SUMMARY_PATH;
811
+ const summaryAbs = path4.join(pkgRoot, summaryPath);
812
+ const summaryRel = violationFilePath(projectRoot, pkgRoot, summaryPath);
813
+ let pct = readCoveragePercentage(summaryAbs);
814
+ if (pct === void 0 && !options.staged) {
815
+ const command = target.coverage.command;
816
+ if (!command) {
817
+ const pkgLabel = target.pkg.path === "." ? "root package" : target.pkg.path;
818
+ pushViolation(
819
+ violations,
820
+ summaryRel,
821
+ `No coverage summary found for "${pkgLabel}". Run your test suite with coverage enabled, or set defaults.coverage.command in viberails.config.json.`,
822
+ severity
823
+ );
824
+ continue;
825
+ }
826
+ const run = runCoverageCommand(pkgRoot, command);
827
+ if (!run.ok) {
828
+ pushViolation(
829
+ violations,
830
+ summaryRel,
831
+ `Failed to run coverage command: ${run.detail}.`,
832
+ severity
833
+ );
834
+ continue;
835
+ }
836
+ pct = readCoveragePercentage(summaryAbs);
837
+ }
838
+ if (pct === void 0) {
839
+ pushViolation(
840
+ violations,
841
+ summaryRel,
842
+ `Coverage summary not found or invalid at \`${summaryPath}\`.`,
843
+ severity
844
+ );
845
+ continue;
846
+ }
847
+ if (pct < target.rules.testCoverage) {
848
+ pushViolation(
849
+ violations,
850
+ summaryRel,
851
+ `Line coverage ${pct.toFixed(1)}% is below required ${target.rules.testCoverage}%.`,
852
+ severity
853
+ );
854
+ }
855
+ }
856
+ return violations;
857
+ }
858
+
859
+ // src/commands/check-files.ts
860
+ var import_node_child_process2 = require("child_process");
861
+ var fs5 = __toESM(require("fs"), 1);
862
+ var path5 = __toESM(require("path"), 1);
863
+ var import_config3 = require("@viberails/config");
413
864
  var import_picomatch = __toESM(require("picomatch"), 1);
414
865
  var ALWAYS_SKIP_DIRS = /* @__PURE__ */ new Set([
415
866
  "node_modules",
@@ -452,7 +903,7 @@ function isIgnored(relPath, ignorePatterns) {
452
903
  }
453
904
  function countFileLines(filePath) {
454
905
  try {
455
- const content = fs4.readFileSync(filePath, "utf-8");
906
+ const content = fs5.readFileSync(filePath, "utf-8");
456
907
  if (content.length === 0) return 0;
457
908
  let count = 1;
458
909
  for (let i = 0; i < content.length; i++) {
@@ -464,14 +915,14 @@ function countFileLines(filePath) {
464
915
  }
465
916
  }
466
917
  function checkNaming(relPath, conventions) {
467
- const filename = path4.basename(relPath);
468
- const ext = path4.extname(filename);
918
+ const filename = path5.basename(relPath);
919
+ const ext = path5.extname(filename);
469
920
  if (!SOURCE_EXTS.has(ext)) return void 0;
470
921
  if (filename.startsWith("index.") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith(".") || filename.startsWith("_") || filename.startsWith("+") || filename.startsWith("$") || filename.startsWith("[")) {
471
922
  return void 0;
472
923
  }
473
924
  const bare = filename.slice(0, filename.indexOf("."));
474
- const convention = typeof conventions.fileNaming === "string" ? conventions.fileNaming : conventions.fileNaming?.value;
925
+ const convention = conventions.fileNaming;
475
926
  if (!convention) return void 0;
476
927
  const pattern = NAMING_PATTERNS[convention];
477
928
  if (!pattern || pattern.test(bare)) return void 0;
@@ -479,35 +930,57 @@ function checkNaming(relPath, conventions) {
479
930
  }
480
931
  function getStagedFiles(projectRoot) {
481
932
  try {
482
- const output = (0, import_node_child_process.execSync)("git diff --cached --name-only --diff-filter=ACM", {
933
+ const output = (0, import_node_child_process2.execSync)("git diff --cached --name-only --diff-filter=ACM", {
483
934
  cwd: projectRoot,
484
- encoding: "utf-8"
935
+ encoding: "utf-8",
936
+ stdio: ["ignore", "pipe", "ignore"]
485
937
  });
486
938
  return output.trim().split("\n").filter(Boolean);
487
939
  } catch {
488
940
  return [];
489
941
  }
490
942
  }
943
+ function getDiffFiles(projectRoot, base) {
944
+ try {
945
+ const allOutput = (0, import_node_child_process2.execSync)(`git diff --name-only --diff-filter=ACMR ${base}...HEAD`, {
946
+ cwd: projectRoot,
947
+ encoding: "utf-8",
948
+ stdio: ["ignore", "pipe", "ignore"]
949
+ });
950
+ const addedOutput = (0, import_node_child_process2.execSync)(`git diff --name-only --diff-filter=A ${base}...HEAD`, {
951
+ cwd: projectRoot,
952
+ encoding: "utf-8",
953
+ stdio: ["ignore", "pipe", "ignore"]
954
+ });
955
+ return {
956
+ all: allOutput.trim().split("\n").filter(Boolean),
957
+ added: addedOutput.trim().split("\n").filter(Boolean)
958
+ };
959
+ } catch {
960
+ return { all: [], added: [] };
961
+ }
962
+ }
491
963
  function getAllSourceFiles(projectRoot, config) {
964
+ const effectiveIgnore = [...import_config3.BUILTIN_IGNORE, ...config.ignore ?? []];
492
965
  const files = [];
493
966
  const walk = (dir) => {
494
967
  let entries;
495
968
  try {
496
- entries = fs4.readdirSync(dir, { withFileTypes: true });
969
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
497
970
  } catch {
498
971
  return;
499
972
  }
500
973
  for (const entry of entries) {
501
- const rel = path4.relative(projectRoot, path4.join(dir, entry.name));
974
+ const rel = path5.relative(projectRoot, path5.join(dir, entry.name));
502
975
  if (entry.isDirectory()) {
503
976
  if (ALWAYS_SKIP_DIRS.has(entry.name)) {
504
977
  continue;
505
978
  }
506
- if (isIgnored(rel, config.ignore)) continue;
507
- walk(path4.join(dir, entry.name));
979
+ if (isIgnored(rel, effectiveIgnore)) continue;
980
+ walk(path5.join(dir, entry.name));
508
981
  } else if (entry.isFile()) {
509
- const ext = path4.extname(entry.name);
510
- if (SOURCE_EXTS.has(ext) && !isIgnored(rel, config.ignore)) {
982
+ const ext = path5.extname(entry.name);
983
+ if (SOURCE_EXTS.has(ext) && !isIgnored(rel, effectiveIgnore)) {
511
984
  files.push(rel);
512
985
  }
513
986
  }
@@ -521,16 +994,16 @@ function collectSourceFiles(dir, projectRoot) {
521
994
  const walk = (d) => {
522
995
  let entries;
523
996
  try {
524
- entries = fs4.readdirSync(d, { withFileTypes: true });
997
+ entries = fs5.readdirSync(d, { withFileTypes: true });
525
998
  } catch {
526
999
  return;
527
1000
  }
528
1001
  for (const entry of entries) {
529
1002
  if (entry.isDirectory()) {
530
1003
  if (entry.name === "node_modules") continue;
531
- walk(path4.join(d, entry.name));
1004
+ walk(path5.join(d, entry.name));
532
1005
  } else if (entry.isFile()) {
533
- files.push(path4.relative(projectRoot, path4.join(d, entry.name)));
1006
+ files.push(path5.relative(projectRoot, path5.join(d, entry.name)));
534
1007
  }
535
1008
  }
536
1009
  };
@@ -539,8 +1012,8 @@ function collectSourceFiles(dir, projectRoot) {
539
1012
  }
540
1013
 
541
1014
  // src/commands/check-tests.ts
542
- var fs5 = __toESM(require("fs"), 1);
543
- var path5 = __toESM(require("path"), 1);
1015
+ var fs6 = __toESM(require("fs"), 1);
1016
+ var path6 = __toESM(require("path"), 1);
544
1017
  var SOURCE_EXTS2 = /* @__PURE__ */ new Set([
545
1018
  ".ts",
546
1019
  ".tsx",
@@ -554,44 +1027,58 @@ var SOURCE_EXTS2 = /* @__PURE__ */ new Set([
554
1027
  ]);
555
1028
  function checkMissingTests(projectRoot, config, severity) {
556
1029
  const violations = [];
557
- const { testPattern } = config.structure;
558
- if (!testPattern) return violations;
559
- const srcDir = config.structure.srcDir;
560
- if (!srcDir) return violations;
561
- const srcPath = path5.join(projectRoot, srcDir);
562
- if (!fs5.existsSync(srcPath)) return violations;
563
- const testSuffix = testPattern.replace("*", "");
564
- const sourceFiles = collectSourceFiles(srcPath, projectRoot);
565
- for (const relFile of sourceFiles) {
566
- const basename7 = path5.basename(relFile);
567
- if (basename7.includes(".test.") || basename7.includes(".spec.") || basename7.startsWith("index.") || basename7.endsWith(".d.ts")) {
568
- continue;
569
- }
570
- const ext = path5.extname(basename7);
571
- if (!SOURCE_EXTS2.has(ext)) continue;
572
- const stem = basename7.slice(0, basename7.indexOf("."));
573
- const expectedTestFile = `${stem}${testSuffix}`;
574
- const dir = path5.dirname(path5.join(projectRoot, relFile));
575
- const colocatedTest = path5.join(dir, expectedTestFile);
576
- const testsDir = config.structure.tests;
577
- const dedicatedTest = testsDir ? path5.join(projectRoot, testsDir, expectedTestFile) : null;
578
- const hasTest = fs5.existsSync(colocatedTest) || dedicatedTest !== null && fs5.existsSync(dedicatedTest);
579
- if (!hasTest) {
580
- violations.push({
581
- file: relFile,
582
- rule: "missing-test",
583
- message: `No test file found. Expected \`${expectedTestFile}\`.`,
584
- severity
585
- });
1030
+ for (const pkg of config.packages) {
1031
+ const effectiveRules = { ...config.rules, ...pkg.rules };
1032
+ const enforceMissing = effectiveRules.enforceMissingTests ?? effectiveRules.testCoverage > 0;
1033
+ if (!enforceMissing) continue;
1034
+ const testPattern = pkg.structure?.testPattern;
1035
+ const srcDir = pkg.structure?.srcDir;
1036
+ if (!testPattern || !srcDir) continue;
1037
+ const packageRoot2 = pkg.path === "." ? projectRoot : path6.join(projectRoot, pkg.path);
1038
+ const srcPath = path6.join(packageRoot2, srcDir);
1039
+ if (!fs6.existsSync(srcPath)) continue;
1040
+ const testSuffix = testPattern.replace("*", "");
1041
+ const sourceFiles = collectSourceFiles(srcPath, projectRoot);
1042
+ for (const relFile of sourceFiles) {
1043
+ const basename8 = path6.basename(relFile);
1044
+ if (basename8.includes(".test.") || basename8.includes(".spec.") || basename8.startsWith("index.") || basename8.endsWith(".d.ts")) {
1045
+ continue;
1046
+ }
1047
+ const ext = path6.extname(basename8);
1048
+ if (!SOURCE_EXTS2.has(ext)) continue;
1049
+ const stem = basename8.slice(0, -ext.length);
1050
+ const expectedTestFile = `${stem}${testSuffix}`;
1051
+ const dir = path6.dirname(path6.join(projectRoot, relFile));
1052
+ const colocatedTest = path6.join(dir, expectedTestFile);
1053
+ const testsDir = pkg.structure?.tests;
1054
+ const dedicatedTest = testsDir ? path6.join(packageRoot2, testsDir, expectedTestFile) : null;
1055
+ const hasTest = fs6.existsSync(colocatedTest) || dedicatedTest !== null && fs6.existsSync(dedicatedTest);
1056
+ if (!hasTest) {
1057
+ violations.push({
1058
+ file: relFile,
1059
+ rule: "missing-test",
1060
+ message: `No test file found. Expected \`${expectedTestFile}\`.`,
1061
+ severity
1062
+ });
1063
+ }
586
1064
  }
587
1065
  }
588
1066
  return violations;
589
1067
  }
1068
+ function resolvePackageForFile(sourceRelPath, config) {
1069
+ const sorted = [...config.packages].filter((p) => p.path !== ".").sort((a, b) => b.path.length - a.path.length);
1070
+ for (const pkg of sorted) {
1071
+ if (sourceRelPath.startsWith(`${pkg.path}/`) || sourceRelPath === pkg.path) {
1072
+ return pkg;
1073
+ }
1074
+ }
1075
+ return config.packages.find((p) => p.path === ".") ?? config.packages[0];
1076
+ }
590
1077
 
591
1078
  // src/commands/check.ts
592
1079
  var CONFIG_FILE2 = "viberails.config.json";
593
1080
  function isTestFile(relPath) {
594
- const filename = path6.basename(relPath);
1081
+ const filename = path7.basename(relPath);
595
1082
  return filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith("test.") || filename.startsWith("spec.") || relPath.includes("__tests__/") || relPath.includes("__test__/");
596
1083
  }
597
1084
  function printGroupedViolations(violations, limit) {
@@ -601,7 +1088,13 @@ function printGroupedViolations(violations, limit) {
601
1088
  existing.push(v);
602
1089
  groups.set(v.rule, existing);
603
1090
  }
604
- const ruleOrder = ["file-size", "file-naming", "missing-test", "boundary-violation"];
1091
+ const ruleOrder = [
1092
+ "file-size",
1093
+ "file-naming",
1094
+ "missing-test",
1095
+ "test-coverage",
1096
+ "boundary-violation"
1097
+ ];
605
1098
  const sortedKeys = [...groups.keys()].sort(
606
1099
  (a, b) => (ruleOrder.indexOf(a) === -1 ? 99 : ruleOrder.indexOf(a)) - (ruleOrder.indexOf(b) === -1 ? 99 : ruleOrder.indexOf(b))
607
1100
  );
@@ -641,17 +1134,22 @@ async function checkCommand(options, cwd) {
641
1134
  console.error(`${import_chalk2.default.red("Error:")} No package.json found. Are you in a JS/TS project?`);
642
1135
  return 1;
643
1136
  }
644
- const configPath = path6.join(projectRoot, CONFIG_FILE2);
645
- if (!fs6.existsSync(configPath)) {
1137
+ const configPath = path7.join(projectRoot, CONFIG_FILE2);
1138
+ if (!fs7.existsSync(configPath)) {
646
1139
  console.error(
647
1140
  `${import_chalk2.default.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
648
1141
  );
649
1142
  return 1;
650
1143
  }
651
- const config = await (0, import_config2.loadConfig)(configPath);
1144
+ const config = await (0, import_config4.loadConfig)(configPath);
652
1145
  let filesToCheck;
1146
+ let diffAddedFiles = null;
653
1147
  if (options.staged) {
654
1148
  filesToCheck = getStagedFiles(projectRoot);
1149
+ } else if (options.diffBase) {
1150
+ const diff = getDiffFiles(projectRoot, options.diffBase);
1151
+ filesToCheck = diff.all.filter((f) => SOURCE_EXTS.has(path7.extname(f)));
1152
+ diffAddedFiles = new Set(diff.added);
655
1153
  } else if (options.files && options.files.length > 0) {
656
1154
  filesToCheck = options.files;
657
1155
  } else {
@@ -659,22 +1157,20 @@ async function checkCommand(options, cwd) {
659
1157
  }
660
1158
  if (filesToCheck.length === 0) {
661
1159
  if (options.format === "json") {
662
- console.log(
663
- JSON.stringify({ violations: [], checkedFiles: 0, enforcement: config.enforcement })
664
- );
1160
+ console.log(JSON.stringify({ violations: [], checkedFiles: 0 }));
665
1161
  } else {
666
1162
  console.log(`${import_chalk2.default.green("\u2713")} No files to check.`);
667
1163
  }
668
1164
  return 0;
669
1165
  }
670
1166
  const violations = [];
671
- const severity = config.enforcement === "enforce" ? "error" : "warn";
1167
+ const severity = options.enforce ? "error" : "warn";
672
1168
  for (const file of filesToCheck) {
673
- const absPath = path6.isAbsolute(file) ? file : path6.join(projectRoot, file);
674
- const relPath = path6.relative(projectRoot, absPath);
1169
+ const absPath = path7.isAbsolute(file) ? file : path7.join(projectRoot, file);
1170
+ const relPath = path7.relative(projectRoot, absPath);
675
1171
  const effectiveIgnore = resolveIgnoreForFile(relPath, config);
676
1172
  if (isIgnored(relPath, effectiveIgnore)) continue;
677
- if (!fs6.existsSync(absPath)) continue;
1173
+ if (!fs7.existsSync(absPath)) continue;
678
1174
  const resolved = resolveConfigForFile(relPath, config);
679
1175
  const testFile = isTestFile(relPath);
680
1176
  const maxLines = testFile ? resolved.rules.maxTestFileLines : resolved.rules.maxFileLines;
@@ -701,23 +1197,34 @@ async function checkCommand(options, cwd) {
701
1197
  }
702
1198
  }
703
1199
  }
704
- if (config.rules.requireTests && !options.staged && !options.files) {
1200
+ if (!options.staged && !options.files) {
705
1201
  const testViolations = checkMissingTests(projectRoot, config, severity);
706
- violations.push(...testViolations);
1202
+ if (diffAddedFiles) {
1203
+ violations.push(...testViolations.filter((v) => diffAddedFiles.has(v.file)));
1204
+ } else {
1205
+ violations.push(...testViolations);
1206
+ }
1207
+ }
1208
+ if (!options.files && !options.staged && !options.diffBase) {
1209
+ const coverageViolations = checkCoverage(projectRoot, config, filesToCheck, {
1210
+ staged: options.staged,
1211
+ enforce: options.enforce
1212
+ });
1213
+ violations.push(...coverageViolations);
707
1214
  }
708
1215
  if (config.rules.enforceBoundaries && config.boundaries && Object.keys(config.boundaries.deny).length > 0 && !options.noBoundaries) {
709
1216
  const startTime = Date.now();
710
1217
  const { buildImportGraph, checkBoundaries } = await import("@viberails/graph");
711
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
1218
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
712
1219
  const graph = await buildImportGraph(projectRoot, {
713
1220
  packages,
714
1221
  ignore: config.ignore
715
1222
  });
716
1223
  const boundaryViolations = checkBoundaries(graph, config.boundaries);
717
- const filterSet = options.staged || options.files ? new Set(filesToCheck.map((f) => path6.resolve(projectRoot, f))) : null;
1224
+ const filterSet = options.staged || options.files || options.diffBase ? new Set(filesToCheck.map((f) => path7.resolve(projectRoot, f))) : null;
718
1225
  for (const bv of boundaryViolations) {
719
1226
  if (filterSet && !filterSet.has(bv.file)) continue;
720
- const relFile = path6.relative(projectRoot, bv.file);
1227
+ const relFile = path7.relative(projectRoot, bv.file);
721
1228
  violations.push({
722
1229
  file: relFile,
723
1230
  rule: "boundary-violation",
@@ -734,11 +1241,10 @@ async function checkCommand(options, cwd) {
734
1241
  console.log(
735
1242
  JSON.stringify({
736
1243
  violations,
737
- checkedFiles: filesToCheck.length,
738
- enforcement: config.enforcement
1244
+ checkedFiles: filesToCheck.length
739
1245
  })
740
1246
  );
741
- return config.enforcement === "enforce" && violations.length > 0 ? 1 : 0;
1247
+ return options.enforce && violations.length > 0 ? 1 : 0;
742
1248
  }
743
1249
  if (violations.length === 0) {
744
1250
  console.log(`${import_chalk2.default.green("\u2713")} ${filesToCheck.length} files checked \u2014 no violations`);
@@ -748,7 +1254,7 @@ async function checkCommand(options, cwd) {
748
1254
  printGroupedViolations(violations, options.limit);
749
1255
  }
750
1256
  printSummary(violations);
751
- if (config.enforcement === "enforce") {
1257
+ if (options.enforce) {
752
1258
  console.log(import_chalk2.default.red("Fix violations before committing."));
753
1259
  return 1;
754
1260
  }
@@ -756,7 +1262,7 @@ async function checkCommand(options, cwd) {
756
1262
  }
757
1263
 
758
1264
  // src/commands/check-hook.ts
759
- var fs7 = __toESM(require("fs"), 1);
1265
+ var fs8 = __toESM(require("fs"), 1);
760
1266
  function parseHookFilePath(input) {
761
1267
  try {
762
1268
  if (!input.trim()) return void 0;
@@ -768,7 +1274,7 @@ function parseHookFilePath(input) {
768
1274
  }
769
1275
  function readStdin() {
770
1276
  try {
771
- return fs7.readFileSync(0, "utf-8");
1277
+ return fs8.readFileSync(0, "utf-8");
772
1278
  } catch {
773
1279
  return "";
774
1280
  }
@@ -802,13 +1308,13 @@ async function hookCheckCommand(cwd) {
802
1308
  }
803
1309
 
804
1310
  // src/commands/fix.ts
805
- var fs10 = __toESM(require("fs"), 1);
806
- var path10 = __toESM(require("path"), 1);
807
- var import_config3 = require("@viberails/config");
1311
+ var fs11 = __toESM(require("fs"), 1);
1312
+ var path11 = __toESM(require("path"), 1);
1313
+ var import_config5 = require("@viberails/config");
808
1314
  var import_chalk4 = __toESM(require("chalk"), 1);
809
1315
 
810
1316
  // src/commands/fix-helpers.ts
811
- var import_node_child_process2 = require("child_process");
1317
+ var import_node_child_process3 = require("child_process");
812
1318
  var import_chalk3 = __toESM(require("chalk"), 1);
813
1319
  function printPlan(renames, stubs) {
814
1320
  if (renames.length > 0) {
@@ -826,9 +1332,10 @@ function printPlan(renames, stubs) {
826
1332
  }
827
1333
  function checkGitDirty(projectRoot) {
828
1334
  try {
829
- const output = (0, import_node_child_process2.execSync)("git status --porcelain", {
1335
+ const output = (0, import_node_child_process3.execSync)("git status --porcelain", {
830
1336
  cwd: projectRoot,
831
- encoding: "utf-8"
1337
+ encoding: "utf-8",
1338
+ stdio: ["ignore", "pipe", "ignore"]
832
1339
  });
833
1340
  return output.trim().length > 0;
834
1341
  } catch {
@@ -837,14 +1344,11 @@ function checkGitDirty(projectRoot) {
837
1344
  }
838
1345
  function getConventionValue(convention) {
839
1346
  if (typeof convention === "string") return convention;
840
- if (convention && typeof convention === "object" && "value" in convention) {
841
- return convention.value;
842
- }
843
1347
  return void 0;
844
1348
  }
845
1349
 
846
1350
  // src/commands/fix-imports.ts
847
- var path7 = __toESM(require("path"), 1);
1351
+ var path8 = __toESM(require("path"), 1);
848
1352
  function stripExtension(filePath) {
849
1353
  return filePath.replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
850
1354
  }
@@ -862,7 +1366,7 @@ async function updateImportsAfterRenames(renames, projectRoot) {
862
1366
  const renameMap = /* @__PURE__ */ new Map();
863
1367
  for (const r of renames) {
864
1368
  const oldStripped = stripExtension(r.oldAbsPath);
865
- const newFilename = path7.basename(r.newPath);
1369
+ const newFilename = path8.basename(r.newPath);
866
1370
  const newName = newFilename.slice(0, newFilename.indexOf("."));
867
1371
  renameMap.set(oldStripped, { newBare: newName });
868
1372
  }
@@ -870,14 +1374,14 @@ async function updateImportsAfterRenames(renames, projectRoot) {
870
1374
  tsConfigFilePath: void 0,
871
1375
  skipAddingFilesFromTsConfig: true
872
1376
  });
873
- project.addSourceFilesAtPaths(path7.join(projectRoot, "**/*.{ts,tsx,js,jsx,mjs,cjs}"));
1377
+ project.addSourceFilesAtPaths(path8.join(projectRoot, "**/*.{ts,tsx,js,jsx,mjs,cjs}"));
874
1378
  const updates = [];
875
1379
  const extensions = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
876
1380
  for (const sourceFile of project.getSourceFiles()) {
877
1381
  const filePath = sourceFile.getFilePath();
878
- const segments = filePath.split(path7.sep);
1382
+ const segments = filePath.split(path8.sep);
879
1383
  if (segments.includes("node_modules") || segments.includes("dist")) continue;
880
- const fileDir = path7.dirname(filePath);
1384
+ const fileDir = path8.dirname(filePath);
881
1385
  for (const decl of sourceFile.getImportDeclarations()) {
882
1386
  const specifier = decl.getModuleSpecifierValue();
883
1387
  if (!specifier.startsWith(".")) continue;
@@ -934,7 +1438,7 @@ async function updateImportsAfterRenames(renames, projectRoot) {
934
1438
  }
935
1439
  function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
936
1440
  const cleanSpec = specifier.endsWith(".js") ? specifier.slice(0, -3) : specifier;
937
- const resolved = path7.resolve(fromDir, cleanSpec);
1441
+ const resolved = path8.resolve(fromDir, cleanSpec);
938
1442
  for (const ext of extensions) {
939
1443
  const candidate = resolved + ext;
940
1444
  const stripped = stripExtension(candidate);
@@ -945,8 +1449,8 @@ function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
945
1449
  }
946
1450
 
947
1451
  // src/commands/fix-naming.ts
948
- var fs8 = __toESM(require("fs"), 1);
949
- var path8 = __toESM(require("path"), 1);
1452
+ var fs9 = __toESM(require("fs"), 1);
1453
+ var path9 = __toESM(require("path"), 1);
950
1454
 
951
1455
  // src/commands/convert-name.ts
952
1456
  function splitIntoWords(name) {
@@ -995,8 +1499,8 @@ function capitalize(word) {
995
1499
 
996
1500
  // src/commands/fix-naming.ts
997
1501
  function computeRename(relPath, targetConvention, projectRoot) {
998
- const filename = path8.basename(relPath);
999
- const dir = path8.dirname(relPath);
1502
+ const filename = path9.basename(relPath);
1503
+ const dir = path9.dirname(relPath);
1000
1504
  const dotIndex = filename.indexOf(".");
1001
1505
  if (dotIndex === -1) return null;
1002
1506
  const bare = filename.slice(0, dotIndex);
@@ -1004,15 +1508,15 @@ function computeRename(relPath, targetConvention, projectRoot) {
1004
1508
  const newBare = convertName(bare, targetConvention);
1005
1509
  if (newBare === bare) return null;
1006
1510
  const newFilename = newBare + suffix;
1007
- const newRelPath = path8.join(dir, newFilename);
1008
- const oldAbsPath = path8.join(projectRoot, relPath);
1009
- const newAbsPath = path8.join(projectRoot, newRelPath);
1010
- if (fs8.existsSync(newAbsPath)) return null;
1511
+ const newRelPath = path9.join(dir, newFilename);
1512
+ const oldAbsPath = path9.join(projectRoot, relPath);
1513
+ const newAbsPath = path9.join(projectRoot, newRelPath);
1514
+ if (fs9.existsSync(newAbsPath)) return null;
1011
1515
  return { oldPath: relPath, newPath: newRelPath, oldAbsPath, newAbsPath };
1012
1516
  }
1013
1517
  function executeRename(rename) {
1014
- if (fs8.existsSync(rename.newAbsPath)) return false;
1015
- fs8.renameSync(rename.oldAbsPath, rename.newAbsPath);
1518
+ if (fs9.existsSync(rename.newAbsPath)) return false;
1519
+ fs9.renameSync(rename.oldAbsPath, rename.newAbsPath);
1016
1520
  return true;
1017
1521
  }
1018
1522
  function deduplicateRenames(renames) {
@@ -1027,33 +1531,38 @@ function deduplicateRenames(renames) {
1027
1531
  }
1028
1532
 
1029
1533
  // src/commands/fix-tests.ts
1030
- var fs9 = __toESM(require("fs"), 1);
1031
- var path9 = __toESM(require("path"), 1);
1534
+ var fs10 = __toESM(require("fs"), 1);
1535
+ var path10 = __toESM(require("path"), 1);
1032
1536
  function generateTestStub(sourceRelPath, config, projectRoot) {
1033
- const { testPattern } = config.structure;
1537
+ const pkg = resolvePackageForFile(sourceRelPath, config);
1538
+ const testPattern = pkg?.structure?.testPattern;
1034
1539
  if (!testPattern) return null;
1035
- const basename7 = path9.basename(sourceRelPath);
1036
- const stem = basename7.slice(0, basename7.indexOf("."));
1540
+ const basename8 = path10.basename(sourceRelPath);
1541
+ const ext = path10.extname(basename8);
1542
+ if (!ext) return null;
1543
+ const stem = basename8.slice(0, -ext.length);
1037
1544
  const testSuffix = testPattern.replace("*", "");
1038
1545
  const testFilename = `${stem}${testSuffix}`;
1039
- const dir = path9.dirname(path9.join(projectRoot, sourceRelPath));
1040
- const testAbsPath = path9.join(dir, testFilename);
1041
- if (fs9.existsSync(testAbsPath)) return null;
1546
+ const dir = path10.dirname(path10.join(projectRoot, sourceRelPath));
1547
+ const testAbsPath = path10.join(dir, testFilename);
1548
+ if (fs10.existsSync(testAbsPath)) return null;
1042
1549
  return {
1043
- path: path9.relative(projectRoot, testAbsPath),
1550
+ path: path10.relative(projectRoot, testAbsPath),
1044
1551
  absPath: testAbsPath,
1045
1552
  moduleName: stem
1046
1553
  };
1047
1554
  }
1048
1555
  function writeTestStub(stub, config) {
1049
- const runner = config.stack.testRunner === "jest" ? "jest" : "vitest";
1556
+ const pkg = resolvePackageForFile(stub.path, config);
1557
+ const testRunner = pkg?.stack?.testRunner ?? "";
1558
+ const runner = testRunner.startsWith("jest") ? "jest" : "vitest";
1050
1559
  const importLine = runner === "jest" ? "" : "import { describe, it, expect } from 'vitest';\n\n";
1051
1560
  const content = `${importLine}describe('${stub.moduleName}', () => {
1052
1561
  it.todo('add tests');
1053
1562
  });
1054
1563
  `;
1055
- fs9.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
1056
- fs9.writeFileSync(stub.absPath, content);
1564
+ fs10.mkdirSync(path10.dirname(stub.absPath), { recursive: true });
1565
+ fs10.writeFileSync(stub.absPath, content);
1057
1566
  }
1058
1567
 
1059
1568
  // src/commands/fix.ts
@@ -1065,14 +1574,14 @@ async function fixCommand(options, cwd) {
1065
1574
  console.error(`${import_chalk4.default.red("Error:")} No package.json found. Are you in a JS/TS project?`);
1066
1575
  return 1;
1067
1576
  }
1068
- const configPath = path10.join(projectRoot, CONFIG_FILE3);
1069
- if (!fs10.existsSync(configPath)) {
1577
+ const configPath = path11.join(projectRoot, CONFIG_FILE3);
1578
+ if (!fs11.existsSync(configPath)) {
1070
1579
  console.error(
1071
1580
  `${import_chalk4.default.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
1072
1581
  );
1073
1582
  return 1;
1074
1583
  }
1075
- const config = await (0, import_config3.loadConfig)(configPath);
1584
+ const config = await (0, import_config5.loadConfig)(configPath);
1076
1585
  if (!options.dryRun) {
1077
1586
  const isDirty = checkGitDirty(projectRoot);
1078
1587
  if (isDirty) {
@@ -1099,7 +1608,7 @@ async function fixCommand(options, cwd) {
1099
1608
  }
1100
1609
  const dedupedRenames = deduplicateRenames(renames);
1101
1610
  const testStubs = [];
1102
- if (shouldFixTests && config.rules.requireTests) {
1611
+ if (shouldFixTests) {
1103
1612
  const testViolations = checkMissingTests(projectRoot, config, "warn");
1104
1613
  for (const v of testViolations) {
1105
1614
  const stub = generateTestStub(v.file, config, projectRoot);
@@ -1130,13 +1639,13 @@ async function fixCommand(options, cwd) {
1130
1639
  }
1131
1640
  let importUpdateCount = 0;
1132
1641
  if (renameCount > 0) {
1133
- const appliedRenames = dedupedRenames.filter((r) => fs10.existsSync(r.newAbsPath));
1642
+ const appliedRenames = dedupedRenames.filter((r) => fs11.existsSync(r.newAbsPath));
1134
1643
  const updates = await updateImportsAfterRenames(appliedRenames, projectRoot);
1135
1644
  importUpdateCount = updates.length;
1136
1645
  }
1137
1646
  let stubCount = 0;
1138
1647
  for (const stub of testStubs) {
1139
- if (!fs10.existsSync(stub.absPath)) {
1648
+ if (!fs11.existsSync(stub.absPath)) {
1140
1649
  writeTestStub(stub, config);
1141
1650
  stubCount++;
1142
1651
  }
@@ -1157,15 +1666,16 @@ async function fixCommand(options, cwd) {
1157
1666
  }
1158
1667
 
1159
1668
  // src/commands/init.ts
1160
- var fs13 = __toESM(require("fs"), 1);
1161
- var path13 = __toESM(require("path"), 1);
1162
- var clack2 = __toESM(require("@clack/prompts"), 1);
1163
- var import_config4 = require("@viberails/config");
1669
+ var fs17 = __toESM(require("fs"), 1);
1670
+ var path17 = __toESM(require("path"), 1);
1671
+ var clack7 = __toESM(require("@clack/prompts"), 1);
1672
+ var import_config6 = require("@viberails/config");
1164
1673
  var import_scanner = require("@viberails/scanner");
1165
- var import_chalk8 = __toESM(require("chalk"), 1);
1674
+ var import_chalk10 = __toESM(require("chalk"), 1);
1166
1675
 
1167
- // src/display-text.ts
1676
+ // src/display.ts
1168
1677
  var import_types4 = require("@viberails/types");
1678
+ var import_chalk6 = __toESM(require("chalk"), 1);
1169
1679
 
1170
1680
  // src/display-helpers.ts
1171
1681
  var import_types = require("@viberails/types");
@@ -1216,20 +1726,130 @@ function formatRoleGroup(group) {
1216
1726
  return `${group.label} \u2014 ${dirs} (${files})`;
1217
1727
  }
1218
1728
 
1219
- // src/display.ts
1729
+ // src/display-monorepo.ts
1220
1730
  var import_types3 = require("@viberails/types");
1221
- var import_chalk6 = __toESM(require("chalk"), 1);
1731
+ var import_chalk5 = __toESM(require("chalk"), 1);
1222
1732
 
1223
- // src/display-monorepo.ts
1733
+ // src/display-text.ts
1224
1734
  var import_types2 = require("@viberails/types");
1225
- var import_chalk5 = __toESM(require("chalk"), 1);
1735
+ function plainConfidenceLabel(convention) {
1736
+ const pct = Math.round(convention.consistency);
1737
+ if (convention.confidence === "high") {
1738
+ return `${pct}%`;
1739
+ }
1740
+ return `${pct}%, suggested only`;
1741
+ }
1742
+ function formatConventionsText(scanResult) {
1743
+ const lines = [];
1744
+ const conventionEntries = Object.entries(scanResult.conventions);
1745
+ if (conventionEntries.length === 0) return lines;
1746
+ lines.push("");
1747
+ lines.push("Conventions:");
1748
+ for (const [key, convention] of conventionEntries) {
1749
+ if (convention.confidence === "low") continue;
1750
+ const label = import_types2.CONVENTION_LABELS[key] ?? key;
1751
+ if (scanResult.packages.length > 1) {
1752
+ const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1753
+ const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
1754
+ if (allSame || pkgValues.length <= 1) {
1755
+ const ind = convention.confidence === "high" ? "\u2713" : "~";
1756
+ lines.push(` ${ind} ${label}: ${convention.value} (${plainConfidenceLabel(convention)})`);
1757
+ } else {
1758
+ lines.push(` ~ ${label}: varies by package`);
1759
+ for (const pv of pkgValues) {
1760
+ const pct = Math.round(pv.convention.consistency);
1761
+ lines.push(` ${pv.relativePath}: ${pv.convention.value} (${pct}%)`);
1762
+ }
1763
+ }
1764
+ } else {
1765
+ const ind = convention.confidence === "high" ? "\u2713" : "~";
1766
+ lines.push(` ${ind} ${label}: ${convention.value} (${plainConfidenceLabel(convention)})`);
1767
+ }
1768
+ }
1769
+ return lines;
1770
+ }
1771
+ function formatRulesText(config) {
1772
+ const root = config.packages.find((p) => p.path === ".") ?? config.packages[0];
1773
+ const lines = [];
1774
+ lines.push(`Max file size: ${config.rules.maxFileLines} lines`);
1775
+ if (config.rules.testCoverage > 0) {
1776
+ lines.push(`Test coverage target: ${config.rules.testCoverage}%`);
1777
+ } else {
1778
+ lines.push("Test coverage target: disabled");
1779
+ }
1780
+ const enforceMissing = config.rules.enforceMissingTests ?? config.rules.testCoverage > 0;
1781
+ if (enforceMissing && root?.structure?.testPattern) {
1782
+ lines.push(`Enforce missing tests: yes (${root.structure.testPattern})`);
1783
+ } else {
1784
+ lines.push("Enforce missing tests: no");
1785
+ }
1786
+ if (config.rules.enforceNaming && root?.conventions?.fileNaming) {
1787
+ lines.push(`Enforce file naming: ${root.conventions.fileNaming}`);
1788
+ } else {
1789
+ lines.push("Enforce file naming: no");
1790
+ }
1791
+ return lines;
1792
+ }
1793
+ function formatScanResultsText(scanResult) {
1794
+ if (scanResult.packages.length > 1) {
1795
+ return formatMonorepoResultsText(scanResult);
1796
+ }
1797
+ const lines = [];
1798
+ const { stack } = scanResult;
1799
+ lines.push("Detected:");
1800
+ if (stack.framework) {
1801
+ lines.push(` \u2713 ${formatItem(stack.framework, import_types2.FRAMEWORK_NAMES)}`);
1802
+ }
1803
+ lines.push(` \u2713 ${formatItem(stack.language)}`);
1804
+ if (stack.styling) {
1805
+ lines.push(` \u2713 ${formatItem(stack.styling, import_types2.STYLING_NAMES)}`);
1806
+ }
1807
+ if (stack.backend) {
1808
+ lines.push(` \u2713 ${formatItem(stack.backend, import_types2.FRAMEWORK_NAMES)}`);
1809
+ }
1810
+ if (stack.orm) {
1811
+ lines.push(` \u2713 ${formatItem(stack.orm, import_types2.ORM_NAMES)}`);
1812
+ }
1813
+ const secondaryParts = [];
1814
+ if (stack.packageManager) secondaryParts.push(formatItem(stack.packageManager));
1815
+ if (stack.linter) secondaryParts.push(formatItem(stack.linter));
1816
+ if (stack.formatter) secondaryParts.push(formatItem(stack.formatter));
1817
+ if (stack.testRunner) secondaryParts.push(formatItem(stack.testRunner));
1818
+ if (secondaryParts.length > 0) {
1819
+ lines.push(` \u2713 ${secondaryParts.join(" \xB7 ")}`);
1820
+ }
1821
+ if (stack.libraries.length > 0) {
1822
+ for (const lib of stack.libraries) {
1823
+ lines.push(` \u2713 ${formatItem(lib, import_types2.LIBRARY_NAMES)}`);
1824
+ }
1825
+ }
1826
+ const groups = groupByRole(scanResult.structure.directories);
1827
+ if (groups.length > 0) {
1828
+ lines.push("");
1829
+ lines.push("Structure:");
1830
+ for (const group of groups) {
1831
+ lines.push(` \u2713 ${formatRoleGroup(group)}`);
1832
+ }
1833
+ }
1834
+ lines.push(...formatConventionsText(scanResult));
1835
+ const pkgCount = scanResult.packages.length > 1 ? scanResult.packages.length : void 0;
1836
+ lines.push("");
1837
+ lines.push(formatSummary(scanResult.statistics, pkgCount));
1838
+ const ext = formatExtensions(scanResult.statistics.filesByExtension);
1839
+ if (ext) {
1840
+ lines.push(ext);
1841
+ }
1842
+ return lines.join("\n");
1843
+ }
1844
+
1845
+ // src/display-monorepo.ts
1226
1846
  function formatPackageSummary(pkg) {
1227
1847
  const parts = [];
1228
1848
  if (pkg.stack.framework) {
1229
- parts.push(formatItem(pkg.stack.framework, import_types2.FRAMEWORK_NAMES));
1849
+ parts.push(formatItem(pkg.stack.framework, import_types3.FRAMEWORK_NAMES));
1230
1850
  }
1231
1851
  if (pkg.stack.styling) {
1232
- parts.push(formatItem(pkg.stack.styling, import_types2.STYLING_NAMES));
1852
+ parts.push(formatItem(pkg.stack.styling, import_types3.STYLING_NAMES));
1233
1853
  }
1234
1854
  const files = `${pkg.statistics.totalFiles} files`;
1235
1855
  const detail = parts.length > 0 ? `${parts.join(", ")} (${files})` : `(${files})`;
@@ -1243,11 +1863,15 @@ ${import_chalk5.default.bold(`Detected: (monorepo, ${packages.length} packages)`
1243
1863
  if (stack.packageManager) {
1244
1864
  console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.packageManager)}`);
1245
1865
  }
1246
- if (stack.linter) {
1247
- console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.linter)}`);
1248
- }
1249
- if (stack.formatter) {
1250
- console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.formatter)}`);
1866
+ if (stack.linter && stack.formatter && stack.linter.name === stack.formatter.name) {
1867
+ console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.linter)} (lint + format)`);
1868
+ } else {
1869
+ if (stack.linter) {
1870
+ console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.linter)}`);
1871
+ }
1872
+ if (stack.formatter) {
1873
+ console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.formatter)}`);
1874
+ }
1251
1875
  }
1252
1876
  if (stack.testRunner) {
1253
1877
  console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.testRunner)}`);
@@ -1278,23 +1902,27 @@ ${import_chalk5.default.bold("Structure:")}`);
1278
1902
  function formatPackageSummaryPlain(pkg) {
1279
1903
  const parts = [];
1280
1904
  if (pkg.stack.framework) {
1281
- parts.push(formatItem(pkg.stack.framework, import_types2.FRAMEWORK_NAMES));
1905
+ parts.push(formatItem(pkg.stack.framework, import_types3.FRAMEWORK_NAMES));
1282
1906
  }
1283
1907
  if (pkg.stack.styling) {
1284
- parts.push(formatItem(pkg.stack.styling, import_types2.STYLING_NAMES));
1908
+ parts.push(formatItem(pkg.stack.styling, import_types3.STYLING_NAMES));
1285
1909
  }
1286
1910
  const files = `${pkg.statistics.totalFiles} files`;
1287
1911
  const detail = parts.length > 0 ? `${parts.join(", ")} (${files})` : `(${files})`;
1288
1912
  return ` ${pkg.relativePath} \u2014 ${detail}`;
1289
1913
  }
1290
- function formatMonorepoResultsText(scanResult, config) {
1914
+ function formatMonorepoResultsText(scanResult) {
1291
1915
  const lines = [];
1292
1916
  const { stack, packages } = scanResult;
1293
1917
  lines.push(`Detected: (monorepo, ${packages.length} packages)`);
1294
1918
  const sharedParts = [formatItem(stack.language)];
1295
1919
  if (stack.packageManager) sharedParts.push(formatItem(stack.packageManager));
1296
- if (stack.linter) sharedParts.push(formatItem(stack.linter));
1297
- if (stack.formatter) sharedParts.push(formatItem(stack.formatter));
1920
+ if (stack.linter && stack.formatter && stack.linter.name === stack.formatter.name) {
1921
+ sharedParts.push(`${formatItem(stack.linter)} (lint + format)`);
1922
+ } else {
1923
+ if (stack.linter) sharedParts.push(formatItem(stack.linter));
1924
+ if (stack.formatter) sharedParts.push(formatItem(stack.formatter));
1925
+ }
1298
1926
  if (stack.testRunner) sharedParts.push(formatItem(stack.testRunner));
1299
1927
  lines.push(` \u2713 ${sharedParts.join(" \xB7 ")}`);
1300
1928
  lines.push("");
@@ -1324,7 +1952,6 @@ function formatMonorepoResultsText(scanResult, config) {
1324
1952
  if (ext) {
1325
1953
  lines.push(ext);
1326
1954
  }
1327
- lines.push(...formatRulesText(config));
1328
1955
  return lines.join("\n");
1329
1956
  }
1330
1957
 
@@ -1347,7 +1974,7 @@ function displayConventions(scanResult) {
1347
1974
  ${import_chalk6.default.bold("Conventions:")}`);
1348
1975
  for (const [key, convention] of conventionEntries) {
1349
1976
  if (convention.confidence === "low") continue;
1350
- const label = import_types3.CONVENTION_LABELS[key] ?? key;
1977
+ const label = import_types4.CONVENTION_LABELS[key] ?? key;
1351
1978
  if (scanResult.packages.length > 1) {
1352
1979
  const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1353
1980
  const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
@@ -1388,23 +2015,27 @@ function displayScanResults(scanResult) {
1388
2015
  console.log(`
1389
2016
  ${import_chalk6.default.bold("Detected:")}`);
1390
2017
  if (stack.framework) {
1391
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.framework, import_types3.FRAMEWORK_NAMES)}`);
2018
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.framework, import_types4.FRAMEWORK_NAMES)}`);
1392
2019
  }
1393
2020
  console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.language)}`);
1394
2021
  if (stack.styling) {
1395
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.styling, import_types3.STYLING_NAMES)}`);
2022
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.styling, import_types4.STYLING_NAMES)}`);
1396
2023
  }
1397
2024
  if (stack.backend) {
1398
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.backend, import_types3.FRAMEWORK_NAMES)}`);
2025
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.backend, import_types4.FRAMEWORK_NAMES)}`);
1399
2026
  }
1400
2027
  if (stack.orm) {
1401
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.orm, import_types3.ORM_NAMES)}`);
1402
- }
1403
- if (stack.linter) {
1404
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.linter)}`);
2028
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.orm, import_types4.ORM_NAMES)}`);
1405
2029
  }
1406
- if (stack.formatter) {
1407
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.formatter)}`);
2030
+ if (stack.linter && stack.formatter && stack.linter.name === stack.formatter.name) {
2031
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.linter)} (lint + format)`);
2032
+ } else {
2033
+ if (stack.linter) {
2034
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.linter)}`);
2035
+ }
2036
+ if (stack.formatter) {
2037
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.formatter)}`);
2038
+ }
1408
2039
  }
1409
2040
  if (stack.testRunner) {
1410
2041
  console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.testRunner)}`);
@@ -1414,7 +2045,7 @@ ${import_chalk6.default.bold("Detected:")}`);
1414
2045
  }
1415
2046
  if (stack.libraries.length > 0) {
1416
2047
  for (const lib of stack.libraries) {
1417
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(lib, import_types3.LIBRARY_NAMES)}`);
2048
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(lib, import_types4.LIBRARY_NAMES)}`);
1418
2049
  }
1419
2050
  }
1420
2051
  const groups = groupByRole(scanResult.structure.directories);
@@ -1429,25 +2060,23 @@ ${import_chalk6.default.bold("Structure:")}`);
1429
2060
  displaySummarySection(scanResult);
1430
2061
  console.log("");
1431
2062
  }
1432
- function getConventionStr(cv) {
1433
- return typeof cv === "string" ? cv : cv.value;
1434
- }
1435
2063
  function displayRulesPreview(config) {
1436
- console.log(`${import_chalk6.default.bold("Rules:")}`);
2064
+ const root = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2065
+ console.log(
2066
+ `${import_chalk6.default.bold("Rules:")} ${import_chalk6.default.dim("(warns on violation; use --enforce in CI to block)")}`
2067
+ );
1437
2068
  console.log(` ${import_chalk6.default.dim("\u2022")} Max file size: ${config.rules.maxFileLines} lines`);
1438
- if (config.rules.requireTests && config.structure.testPattern) {
2069
+ if (config.rules.testCoverage > 0 && root?.structure?.testPattern) {
1439
2070
  console.log(
1440
- ` ${import_chalk6.default.dim("\u2022")} Require test files: yes (${config.structure.testPattern})`
2071
+ ` ${import_chalk6.default.dim("\u2022")} Test coverage target: ${config.rules.testCoverage}% (${root.structure.testPattern})`
1441
2072
  );
1442
- } else if (config.rules.requireTests) {
1443
- console.log(` ${import_chalk6.default.dim("\u2022")} Require test files: yes`);
2073
+ } else if (config.rules.testCoverage > 0) {
2074
+ console.log(` ${import_chalk6.default.dim("\u2022")} Test coverage target: ${config.rules.testCoverage}%`);
1444
2075
  } else {
1445
- console.log(` ${import_chalk6.default.dim("\u2022")} Require test files: no`);
2076
+ console.log(` ${import_chalk6.default.dim("\u2022")} Test coverage target: disabled`);
1446
2077
  }
1447
- if (config.rules.enforceNaming && config.conventions.fileNaming) {
1448
- console.log(
1449
- ` ${import_chalk6.default.dim("\u2022")} Enforce file naming: ${getConventionStr(config.conventions.fileNaming)}`
1450
- );
2078
+ if (config.rules.enforceNaming && root?.conventions?.fileNaming) {
2079
+ console.log(` ${import_chalk6.default.dim("\u2022")} Enforce file naming: ${root.conventions.fileNaming}`);
1451
2080
  } else {
1452
2081
  console.log(` ${import_chalk6.default.dim("\u2022")} Enforce file naming: no`);
1453
2082
  }
@@ -1455,146 +2084,163 @@ function displayRulesPreview(config) {
1455
2084
  ` ${import_chalk6.default.dim("\u2022")} Enforce boundaries: ${config.rules.enforceBoundaries ? "yes" : "no"}`
1456
2085
  );
1457
2086
  console.log("");
1458
- if (config.enforcement === "enforce") {
1459
- console.log(`${import_chalk6.default.bold("Enforcement mode:")} enforce (violations will block commits)`);
1460
- } else {
1461
- console.log(
1462
- `${import_chalk6.default.bold("Enforcement mode:")} warn (violations shown but won't block commits)`
1463
- );
1464
- }
1465
- console.log("");
1466
2087
  }
1467
2088
 
1468
- // src/display-text.ts
1469
- function getConventionStr2(cv) {
1470
- return typeof cv === "string" ? cv : cv.value;
1471
- }
1472
- function plainConfidenceLabel(convention) {
1473
- const pct = Math.round(convention.consistency);
1474
- if (convention.confidence === "high") {
1475
- return `${pct}%`;
2089
+ // src/utils/check-prerequisites.ts
2090
+ var import_node_child_process4 = require("child_process");
2091
+ var fs12 = __toESM(require("fs"), 1);
2092
+ var path12 = __toESM(require("path"), 1);
2093
+ var clack6 = __toESM(require("@clack/prompts"), 1);
2094
+ var import_chalk7 = __toESM(require("chalk"), 1);
2095
+ function checkCoveragePrereqs(projectRoot, scanResult) {
2096
+ const testRunner = scanResult.stack.testRunner;
2097
+ if (!testRunner) return [];
2098
+ const runner = testRunner.name;
2099
+ const pm = scanResult.stack.packageManager.name;
2100
+ if (runner === "vitest") {
2101
+ const hasV8 = hasDependency(projectRoot, "@vitest/coverage-v8");
2102
+ const hasIstanbul = hasDependency(projectRoot, "@vitest/coverage-istanbul");
2103
+ const installed = hasV8 || hasIstanbul;
2104
+ const addCmd = pm === "yarn" ? "yarn add -D" : pm === "npm" ? "npm install -D" : `${pm} add -D`;
2105
+ return [
2106
+ {
2107
+ label: "@vitest/coverage-v8",
2108
+ installed,
2109
+ installCommand: installed ? void 0 : `${addCmd} @vitest/coverage-v8`,
2110
+ reason: "Required for coverage percentage checks with vitest"
2111
+ }
2112
+ ];
1476
2113
  }
1477
- return `${pct}%, suggested only`;
1478
- }
1479
- function formatConventionsText(scanResult) {
1480
- const lines = [];
1481
- const conventionEntries = Object.entries(scanResult.conventions);
1482
- if (conventionEntries.length === 0) return lines;
1483
- lines.push("");
1484
- lines.push("Conventions:");
1485
- for (const [key, convention] of conventionEntries) {
1486
- if (convention.confidence === "low") continue;
1487
- const label = import_types4.CONVENTION_LABELS[key] ?? key;
1488
- if (scanResult.packages.length > 1) {
1489
- const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1490
- const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
1491
- if (allSame || pkgValues.length <= 1) {
1492
- const ind = convention.confidence === "high" ? "\u2713" : "~";
1493
- lines.push(` ${ind} ${label}: ${convention.value} (${plainConfidenceLabel(convention)})`);
1494
- } else {
1495
- lines.push(` ~ ${label}: varies by package`);
1496
- for (const pv of pkgValues) {
1497
- const pct = Math.round(pv.convention.consistency);
1498
- lines.push(` ${pv.relativePath}: ${pv.convention.value} (${pct}%)`);
2114
+ return [];
2115
+ }
2116
+ function displayMissingPrereqs(prereqs) {
2117
+ const missing = prereqs.filter((p) => !p.installed);
2118
+ for (const m of missing) {
2119
+ console.log(` ${import_chalk7.default.yellow("!")} ${m.label} not installed \u2014 ${m.reason}`);
2120
+ if (m.installCommand) {
2121
+ console.log(` Install: ${import_chalk7.default.cyan(m.installCommand)}`);
2122
+ }
2123
+ }
2124
+ }
2125
+ async function promptMissingPrereqs(projectRoot, prereqs) {
2126
+ const missing = prereqs.filter((p) => !p.installed);
2127
+ if (missing.length === 0) return { disableCoverage: false };
2128
+ const prereqLines = prereqs.map(
2129
+ (p) => `${p.installed ? "\u2713" : "\u2717"} ${p.label}${p.installed ? "" : ` \u2014 ${p.reason}`}`
2130
+ ).join("\n");
2131
+ clack6.note(prereqLines, "Coverage prerequisites");
2132
+ let disableCoverage = false;
2133
+ for (const m of missing) {
2134
+ if (!m.installCommand) continue;
2135
+ const choice = await clack6.select({
2136
+ message: `${m.label} is not installed. It is required for coverage percentage checks.`,
2137
+ options: [
2138
+ {
2139
+ value: "install",
2140
+ label: `Yes, install now`,
2141
+ hint: m.installCommand
2142
+ },
2143
+ {
2144
+ value: "disable",
2145
+ label: "No, disable coverage percentage checks",
2146
+ hint: "missing-test checks still active"
2147
+ },
2148
+ {
2149
+ value: "skip",
2150
+ label: "Skip for now",
2151
+ hint: `install later: ${m.installCommand}`
1499
2152
  }
2153
+ ]
2154
+ });
2155
+ assertNotCancelled(choice);
2156
+ if (choice === "install") {
2157
+ const is = clack6.spinner();
2158
+ is.start(`Installing ${m.label}...`);
2159
+ const result = (0, import_node_child_process4.spawnSync)(m.installCommand, {
2160
+ cwd: projectRoot,
2161
+ shell: true,
2162
+ encoding: "utf-8",
2163
+ stdio: "pipe"
2164
+ });
2165
+ if (result.status === 0) {
2166
+ is.stop(`Installed ${m.label}`);
2167
+ } else {
2168
+ is.stop(`Failed to install ${m.label}`);
2169
+ clack6.log.warn(
2170
+ `Install manually: ${m.installCommand}
2171
+ Coverage percentage checks will not work until the dependency is installed.`
2172
+ );
1500
2173
  }
2174
+ } else if (choice === "disable") {
2175
+ disableCoverage = true;
2176
+ clack6.log.info("Coverage percentage checks disabled. Missing-test checks remain active.");
1501
2177
  } else {
1502
- const ind = convention.confidence === "high" ? "\u2713" : "~";
1503
- lines.push(` ${ind} ${label}: ${convention.value} (${plainConfidenceLabel(convention)})`);
2178
+ clack6.log.info(
2179
+ `Coverage percentage checks will fail until ${m.label} is installed.
2180
+ Install later: ${m.installCommand}`
2181
+ );
1504
2182
  }
1505
2183
  }
1506
- return lines;
2184
+ return { disableCoverage };
1507
2185
  }
1508
- function formatRulesText(config) {
1509
- const lines = [];
1510
- lines.push("");
1511
- lines.push("Rules:");
1512
- lines.push(` \u2022 Max file size: ${config.rules.maxFileLines} lines`);
1513
- if (config.rules.requireTests && config.structure.testPattern) {
1514
- lines.push(` \u2022 Require test files: yes (${config.structure.testPattern})`);
1515
- } else if (config.rules.requireTests) {
1516
- lines.push(" \u2022 Require test files: yes");
1517
- } else {
1518
- lines.push(" \u2022 Require test files: no");
1519
- }
1520
- if (config.rules.enforceNaming && config.conventions.fileNaming) {
1521
- lines.push(` \u2022 Enforce file naming: ${getConventionStr2(config.conventions.fileNaming)}`);
1522
- } else {
1523
- lines.push(" \u2022 Enforce file naming: no");
2186
+ function hasDependency(projectRoot, name) {
2187
+ try {
2188
+ const pkgPath = path12.join(projectRoot, "package.json");
2189
+ const pkg = JSON.parse(fs12.readFileSync(pkgPath, "utf-8"));
2190
+ return !!(pkg.devDependencies?.[name] || pkg.dependencies?.[name]);
2191
+ } catch {
2192
+ return false;
1524
2193
  }
1525
- lines.push(` \u2022 Enforcement mode: ${config.enforcement}`);
1526
- return lines;
1527
2194
  }
1528
- function formatScanResultsText(scanResult, config) {
1529
- if (scanResult.packages.length > 1) {
1530
- return formatMonorepoResultsText(scanResult, config);
1531
- }
1532
- const lines = [];
1533
- const { stack } = scanResult;
1534
- lines.push("Detected:");
1535
- if (stack.framework) {
1536
- lines.push(` \u2713 ${formatItem(stack.framework, import_types4.FRAMEWORK_NAMES)}`);
1537
- }
1538
- lines.push(` \u2713 ${formatItem(stack.language)}`);
1539
- if (stack.styling) {
1540
- lines.push(` \u2713 ${formatItem(stack.styling, import_types4.STYLING_NAMES)}`);
1541
- }
1542
- if (stack.backend) {
1543
- lines.push(` \u2713 ${formatItem(stack.backend, import_types4.FRAMEWORK_NAMES)}`);
1544
- }
1545
- if (stack.orm) {
1546
- lines.push(` \u2713 ${formatItem(stack.orm, import_types4.ORM_NAMES)}`);
1547
- }
1548
- const secondaryParts = [];
1549
- if (stack.packageManager) secondaryParts.push(formatItem(stack.packageManager));
1550
- if (stack.linter) secondaryParts.push(formatItem(stack.linter));
1551
- if (stack.formatter) secondaryParts.push(formatItem(stack.formatter));
1552
- if (stack.testRunner) secondaryParts.push(formatItem(stack.testRunner));
1553
- if (secondaryParts.length > 0) {
1554
- lines.push(` \u2713 ${secondaryParts.join(" \xB7 ")}`);
1555
- }
1556
- if (stack.libraries.length > 0) {
1557
- for (const lib of stack.libraries) {
1558
- lines.push(` \u2713 ${formatItem(lib, import_types4.LIBRARY_NAMES)}`);
2195
+
2196
+ // src/utils/filter-confidence.ts
2197
+ function filterHighConfidence(conventions, meta) {
2198
+ if (!meta) return conventions;
2199
+ const filtered = {};
2200
+ for (const [key, value] of Object.entries(conventions)) {
2201
+ if (value === void 0) continue;
2202
+ const convMeta = meta[key];
2203
+ if (!convMeta || convMeta.confidence === "high") {
2204
+ filtered[key] = value;
1559
2205
  }
1560
2206
  }
1561
- const groups = groupByRole(scanResult.structure.directories);
1562
- if (groups.length > 0) {
1563
- lines.push("");
1564
- lines.push("Structure:");
1565
- for (const group of groups) {
1566
- lines.push(` \u2713 ${formatRoleGroup(group)}`);
1567
- }
2207
+ return filtered;
2208
+ }
2209
+
2210
+ // src/utils/update-gitignore.ts
2211
+ var fs13 = __toESM(require("fs"), 1);
2212
+ var path13 = __toESM(require("path"), 1);
2213
+ function updateGitignore(projectRoot) {
2214
+ const gitignorePath = path13.join(projectRoot, ".gitignore");
2215
+ let content = "";
2216
+ if (fs13.existsSync(gitignorePath)) {
2217
+ content = fs13.readFileSync(gitignorePath, "utf-8");
1568
2218
  }
1569
- lines.push(...formatConventionsText(scanResult));
1570
- const pkgCount = scanResult.packages.length > 1 ? scanResult.packages.length : void 0;
1571
- lines.push("");
1572
- lines.push(formatSummary(scanResult.statistics, pkgCount));
1573
- const ext = formatExtensions(scanResult.statistics.filesByExtension);
1574
- if (ext) {
1575
- lines.push(ext);
2219
+ if (!content.includes(".viberails/scan-result.json")) {
2220
+ const block = "\n# viberails\n.viberails/scan-result.json\n";
2221
+ const prefix = content.length === 0 ? "" : `${content.trimEnd()}
2222
+ `;
2223
+ fs13.writeFileSync(gitignorePath, `${prefix}${block}`);
1576
2224
  }
1577
- lines.push(...formatRulesText(config));
1578
- return lines.join("\n");
1579
2225
  }
1580
2226
 
1581
2227
  // src/utils/write-generated-files.ts
1582
- var fs11 = __toESM(require("fs"), 1);
1583
- var path11 = __toESM(require("path"), 1);
2228
+ var fs14 = __toESM(require("fs"), 1);
2229
+ var path14 = __toESM(require("path"), 1);
1584
2230
  var import_context = require("@viberails/context");
1585
2231
  var CONTEXT_DIR = ".viberails";
1586
2232
  var CONTEXT_FILE = "context.md";
1587
2233
  var SCAN_RESULT_FILE = "scan-result.json";
1588
2234
  function writeGeneratedFiles(projectRoot, config, scanResult) {
1589
- const contextDir = path11.join(projectRoot, CONTEXT_DIR);
2235
+ const contextDir = path14.join(projectRoot, CONTEXT_DIR);
1590
2236
  try {
1591
- if (!fs11.existsSync(contextDir)) {
1592
- fs11.mkdirSync(contextDir, { recursive: true });
2237
+ if (!fs14.existsSync(contextDir)) {
2238
+ fs14.mkdirSync(contextDir, { recursive: true });
1593
2239
  }
1594
2240
  const context = (0, import_context.generateContext)(config);
1595
- fs11.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1596
- fs11.writeFileSync(
1597
- path11.join(contextDir, SCAN_RESULT_FILE),
2241
+ fs14.writeFileSync(path14.join(contextDir, CONTEXT_FILE), context);
2242
+ fs14.writeFileSync(
2243
+ path14.join(contextDir, SCAN_RESULT_FILE),
1598
2244
  `${JSON.stringify(scanResult, null, 2)}
1599
2245
  `
1600
2246
  );
@@ -1605,39 +2251,41 @@ function writeGeneratedFiles(projectRoot, config, scanResult) {
1605
2251
  }
1606
2252
 
1607
2253
  // src/commands/init-hooks.ts
1608
- var fs12 = __toESM(require("fs"), 1);
1609
- var path12 = __toESM(require("path"), 1);
1610
- var import_chalk7 = __toESM(require("chalk"), 1);
2254
+ var fs15 = __toESM(require("fs"), 1);
2255
+ var path15 = __toESM(require("path"), 1);
2256
+ var import_chalk8 = __toESM(require("chalk"), 1);
1611
2257
  var import_yaml = require("yaml");
1612
2258
  function setupPreCommitHook(projectRoot) {
1613
- const lefthookPath = path12.join(projectRoot, "lefthook.yml");
1614
- if (fs12.existsSync(lefthookPath)) {
2259
+ const lefthookPath = path15.join(projectRoot, "lefthook.yml");
2260
+ if (fs15.existsSync(lefthookPath)) {
1615
2261
  addLefthookPreCommit(lefthookPath);
1616
- console.log(` ${import_chalk7.default.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
1617
- return;
2262
+ console.log(` ${import_chalk8.default.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
2263
+ return "lefthook.yml";
1618
2264
  }
1619
- const huskyDir = path12.join(projectRoot, ".husky");
1620
- if (fs12.existsSync(huskyDir)) {
2265
+ const huskyDir = path15.join(projectRoot, ".husky");
2266
+ if (fs15.existsSync(huskyDir)) {
1621
2267
  writeHuskyPreCommit(huskyDir);
1622
- console.log(` ${import_chalk7.default.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
1623
- return;
2268
+ console.log(` ${import_chalk8.default.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
2269
+ return ".husky/pre-commit";
1624
2270
  }
1625
- const gitDir = path12.join(projectRoot, ".git");
1626
- if (fs12.existsSync(gitDir)) {
1627
- const hooksDir = path12.join(gitDir, "hooks");
1628
- if (!fs12.existsSync(hooksDir)) {
1629
- fs12.mkdirSync(hooksDir, { recursive: true });
2271
+ const gitDir = path15.join(projectRoot, ".git");
2272
+ if (fs15.existsSync(gitDir)) {
2273
+ const hooksDir = path15.join(gitDir, "hooks");
2274
+ if (!fs15.existsSync(hooksDir)) {
2275
+ fs15.mkdirSync(hooksDir, { recursive: true });
1630
2276
  }
1631
2277
  writeGitHookPreCommit(hooksDir);
1632
- console.log(` ${import_chalk7.default.green("\u2713")} .git/hooks/pre-commit`);
2278
+ console.log(` ${import_chalk8.default.green("\u2713")} .git/hooks/pre-commit`);
2279
+ return ".git/hooks/pre-commit";
1633
2280
  }
2281
+ return void 0;
1634
2282
  }
1635
2283
  function writeGitHookPreCommit(hooksDir) {
1636
- const hookPath = path12.join(hooksDir, "pre-commit");
1637
- if (fs12.existsSync(hookPath)) {
1638
- const existing = fs12.readFileSync(hookPath, "utf-8");
2284
+ const hookPath = path15.join(hooksDir, "pre-commit");
2285
+ if (fs15.existsSync(hookPath)) {
2286
+ const existing = fs15.readFileSync(hookPath, "utf-8");
1639
2287
  if (existing.includes("viberails")) return;
1640
- fs12.writeFileSync(
2288
+ fs15.writeFileSync(
1641
2289
  hookPath,
1642
2290
  `${existing.trimEnd()}
1643
2291
 
@@ -1654,10 +2302,10 @@ npx viberails check --staged
1654
2302
  "npx viberails check --staged",
1655
2303
  ""
1656
2304
  ].join("\n");
1657
- fs12.writeFileSync(hookPath, script, { mode: 493 });
2305
+ fs15.writeFileSync(hookPath, script, { mode: 493 });
1658
2306
  }
1659
2307
  function addLefthookPreCommit(lefthookPath) {
1660
- const content = fs12.readFileSync(lefthookPath, "utf-8");
2308
+ const content = fs15.readFileSync(lefthookPath, "utf-8");
1661
2309
  if (content.includes("viberails")) return;
1662
2310
  const doc = (0, import_yaml.parse)(content) ?? {};
1663
2311
  if (!doc["pre-commit"]) {
@@ -1669,29 +2317,29 @@ function addLefthookPreCommit(lefthookPath) {
1669
2317
  doc["pre-commit"].commands.viberails = {
1670
2318
  run: "npx viberails check --staged"
1671
2319
  };
1672
- fs12.writeFileSync(lefthookPath, (0, import_yaml.stringify)(doc));
2320
+ fs15.writeFileSync(lefthookPath, (0, import_yaml.stringify)(doc));
1673
2321
  }
1674
2322
  function detectHookManager(projectRoot) {
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";
2323
+ if (fs15.existsSync(path15.join(projectRoot, "lefthook.yml"))) return "Lefthook";
2324
+ if (fs15.existsSync(path15.join(projectRoot, ".husky"))) return "Husky";
2325
+ if (fs15.existsSync(path15.join(projectRoot, ".git"))) return "git hook";
1678
2326
  return void 0;
1679
2327
  }
1680
2328
  function setupClaudeCodeHook(projectRoot) {
1681
- const claudeDir = path12.join(projectRoot, ".claude");
1682
- if (!fs12.existsSync(claudeDir)) {
1683
- fs12.mkdirSync(claudeDir, { recursive: true });
2329
+ const claudeDir = path15.join(projectRoot, ".claude");
2330
+ if (!fs15.existsSync(claudeDir)) {
2331
+ fs15.mkdirSync(claudeDir, { recursive: true });
1684
2332
  }
1685
- const settingsPath = path12.join(claudeDir, "settings.json");
2333
+ const settingsPath = path15.join(claudeDir, "settings.json");
1686
2334
  let settings = {};
1687
- if (fs12.existsSync(settingsPath)) {
2335
+ if (fs15.existsSync(settingsPath)) {
1688
2336
  try {
1689
- settings = JSON.parse(fs12.readFileSync(settingsPath, "utf-8"));
2337
+ settings = JSON.parse(fs15.readFileSync(settingsPath, "utf-8"));
1690
2338
  } catch {
1691
2339
  console.warn(
1692
- ` ${import_chalk7.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
2340
+ ` ${import_chalk8.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
1693
2341
  );
1694
- console.warn(` Fix the JSON manually, then re-run ${import_chalk7.default.cyan("viberails init --force")}`);
2342
+ console.warn(` Fix the JSON manually, then re-run ${import_chalk8.default.cyan("viberails init --force")}`);
1695
2343
  return;
1696
2344
  }
1697
2345
  }
@@ -1712,208 +2360,406 @@ function setupClaudeCodeHook(projectRoot) {
1712
2360
  }
1713
2361
  ];
1714
2362
  settings.hooks = hooks;
1715
- fs12.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
2363
+ fs15.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1716
2364
  `);
1717
- console.log(` ${import_chalk7.default.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
2365
+ console.log(` ${import_chalk8.default.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
1718
2366
  }
1719
2367
  function setupClaudeMdReference(projectRoot) {
1720
- const claudeMdPath = path12.join(projectRoot, "CLAUDE.md");
2368
+ const claudeMdPath = path15.join(projectRoot, "CLAUDE.md");
1721
2369
  let content = "";
1722
- if (fs12.existsSync(claudeMdPath)) {
1723
- content = fs12.readFileSync(claudeMdPath, "utf-8");
2370
+ if (fs15.existsSync(claudeMdPath)) {
2371
+ content = fs15.readFileSync(claudeMdPath, "utf-8");
1724
2372
  }
1725
2373
  if (content.includes("@.viberails/context.md")) return;
1726
2374
  const ref = "\n@.viberails/context.md\n";
1727
2375
  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`);
2376
+ fs15.writeFileSync(claudeMdPath, prefix + ref);
2377
+ console.log(` ${import_chalk8.default.green("\u2713")} CLAUDE.md \u2014 added @.viberails/context.md reference`);
2378
+ }
2379
+ function setupGithubAction(projectRoot, packageManager) {
2380
+ const workflowDir = path15.join(projectRoot, ".github", "workflows");
2381
+ const workflowPath = path15.join(workflowDir, "viberails.yml");
2382
+ if (fs15.existsSync(workflowPath)) {
2383
+ const existing = fs15.readFileSync(workflowPath, "utf-8");
2384
+ if (existing.includes("viberails")) return void 0;
2385
+ }
2386
+ fs15.mkdirSync(workflowDir, { recursive: true });
2387
+ const pm = packageManager || "npm";
2388
+ const installCmd = pm === "yarn" ? "yarn install --frozen-lockfile" : pm === "pnpm" ? "pnpm install --frozen-lockfile" : "npm ci";
2389
+ const runPrefix = pm === "npm" ? "npx" : `${pm} exec`;
2390
+ const lines = [
2391
+ "name: viberails",
2392
+ "",
2393
+ "on:",
2394
+ " pull_request:",
2395
+ " branches: [main]",
2396
+ "",
2397
+ "jobs:",
2398
+ " check:",
2399
+ " runs-on: ubuntu-latest",
2400
+ " steps:",
2401
+ " - uses: actions/checkout@v4",
2402
+ " with:",
2403
+ " fetch-depth: 0",
2404
+ ""
2405
+ ];
2406
+ if (pm === "pnpm") {
2407
+ lines.push(" - uses: pnpm/action-setup@v4", "");
2408
+ }
2409
+ lines.push(
2410
+ " - uses: actions/setup-node@v4",
2411
+ " with:",
2412
+ " node-version: 22",
2413
+ pm !== "npm" ? ` cache: ${pm}` : "",
2414
+ "",
2415
+ ` - run: ${installCmd}`,
2416
+ ` - run: ${runPrefix} viberails check --enforce --diff-base origin/\${{ github.event.pull_request.base.ref }}`,
2417
+ ""
2418
+ );
2419
+ const content = lines.filter((l) => l !== void 0).join("\n");
2420
+ fs15.writeFileSync(workflowPath, content);
2421
+ return ".github/workflows/viberails.yml";
1730
2422
  }
1731
2423
  function writeHuskyPreCommit(huskyDir) {
1732
- const hookPath = path12.join(huskyDir, "pre-commit");
1733
- if (fs12.existsSync(hookPath)) {
1734
- const existing = fs12.readFileSync(hookPath, "utf-8");
2424
+ const hookPath = path15.join(huskyDir, "pre-commit");
2425
+ if (fs15.existsSync(hookPath)) {
2426
+ const existing = fs15.readFileSync(hookPath, "utf-8");
1735
2427
  if (!existing.includes("viberails")) {
1736
- fs12.writeFileSync(hookPath, `${existing.trimEnd()}
2428
+ fs15.writeFileSync(hookPath, `${existing.trimEnd()}
1737
2429
  npx viberails check --staged
1738
2430
  `);
1739
2431
  }
1740
2432
  return;
1741
2433
  }
1742
- fs12.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
2434
+ fs15.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1743
2435
  }
1744
2436
 
1745
- // src/commands/init.ts
1746
- var CONFIG_FILE4 = "viberails.config.json";
1747
- function filterHighConfidence(conventions) {
1748
- const filtered = {};
1749
- for (const [key, value] of Object.entries(conventions)) {
1750
- if (value === void 0) continue;
1751
- if (typeof value === "string") {
1752
- filtered[key] = value;
1753
- } else if (value._confidence === "high") {
1754
- filtered[key] = value;
2437
+ // src/commands/init-hooks-extra.ts
2438
+ var fs16 = __toESM(require("fs"), 1);
2439
+ var path16 = __toESM(require("path"), 1);
2440
+ var import_chalk9 = __toESM(require("chalk"), 1);
2441
+ var import_yaml2 = require("yaml");
2442
+ function addPreCommitStep(projectRoot, name, command, marker) {
2443
+ const lefthookPath = path16.join(projectRoot, "lefthook.yml");
2444
+ if (fs16.existsSync(lefthookPath)) {
2445
+ const content = fs16.readFileSync(lefthookPath, "utf-8");
2446
+ if (content.includes(marker)) return void 0;
2447
+ const doc = (0, import_yaml2.parse)(content) ?? {};
2448
+ if (!doc["pre-commit"]) doc["pre-commit"] = { commands: {} };
2449
+ if (!doc["pre-commit"].commands) doc["pre-commit"].commands = {};
2450
+ doc["pre-commit"].commands[name] = { run: command };
2451
+ fs16.writeFileSync(lefthookPath, (0, import_yaml2.stringify)(doc));
2452
+ return "lefthook.yml";
2453
+ }
2454
+ const huskyDir = path16.join(projectRoot, ".husky");
2455
+ if (fs16.existsSync(huskyDir)) {
2456
+ const hookPath = path16.join(huskyDir, "pre-commit");
2457
+ if (fs16.existsSync(hookPath)) {
2458
+ const existing = fs16.readFileSync(hookPath, "utf-8");
2459
+ if (existing.includes(marker)) return void 0;
2460
+ fs16.writeFileSync(hookPath, `${existing.trimEnd()}
2461
+ ${command}
2462
+ `);
2463
+ } else {
2464
+ fs16.writeFileSync(hookPath, `#!/bin/sh
2465
+ ${command}
2466
+ `, { mode: 493 });
2467
+ }
2468
+ return ".husky/pre-commit";
2469
+ }
2470
+ const gitDir = path16.join(projectRoot, ".git");
2471
+ if (fs16.existsSync(gitDir)) {
2472
+ const hooksDir = path16.join(gitDir, "hooks");
2473
+ if (!fs16.existsSync(hooksDir)) fs16.mkdirSync(hooksDir, { recursive: true });
2474
+ const hookPath = path16.join(hooksDir, "pre-commit");
2475
+ if (fs16.existsSync(hookPath)) {
2476
+ const existing = fs16.readFileSync(hookPath, "utf-8");
2477
+ if (existing.includes(marker)) return void 0;
2478
+ fs16.writeFileSync(hookPath, `${existing.trimEnd()}
2479
+
2480
+ # ${name}
2481
+ ${command}
2482
+ `);
2483
+ } else {
2484
+ fs16.writeFileSync(hookPath, `#!/bin/sh
2485
+ # Generated by viberails
2486
+
2487
+ # ${name}
2488
+ ${command}
2489
+ `, {
2490
+ mode: 493
2491
+ });
1755
2492
  }
2493
+ return ".git/hooks/pre-commit";
1756
2494
  }
1757
- return filtered;
2495
+ return void 0;
1758
2496
  }
1759
- function getConventionStr3(cv) {
1760
- if (!cv) return void 0;
1761
- return typeof cv === "string" ? cv : cv.value;
2497
+ function setupTypecheckHook(projectRoot) {
2498
+ const target = addPreCommitStep(projectRoot, "typecheck", "npx tsc --noEmit", "tsc");
2499
+ if (target) {
2500
+ console.log(` ${import_chalk9.default.green("\u2713")} ${target} \u2014 added typecheck (tsc --noEmit)`);
2501
+ }
2502
+ return target;
2503
+ }
2504
+ function setupLintHook(projectRoot, linter) {
2505
+ const command = linter === "biome" ? "npx biome check ." : "npx eslint .";
2506
+ const linterName = linter === "biome" ? "Biome" : "ESLint";
2507
+ const target = addPreCommitStep(projectRoot, "lint", command, linter);
2508
+ if (target) {
2509
+ console.log(` ${import_chalk9.default.green("\u2713")} ${target} \u2014 added ${linterName} lint check`);
2510
+ }
2511
+ return target;
2512
+ }
2513
+ function setupSelectedIntegrations(projectRoot, integrations, opts) {
2514
+ const created = [];
2515
+ if (integrations.preCommitHook) {
2516
+ const t = setupPreCommitHook(projectRoot);
2517
+ created.push(t ? `${t} \u2014 added viberails pre-commit` : "pre-commit hook skipped");
2518
+ }
2519
+ if (integrations.typecheckHook) {
2520
+ const t = setupTypecheckHook(projectRoot);
2521
+ if (t) created.push(`${t} \u2014 added typecheck`);
2522
+ }
2523
+ if (integrations.lintHook && opts.linter) {
2524
+ const t = setupLintHook(projectRoot, opts.linter);
2525
+ if (t) created.push(`${t} \u2014 added lint check`);
2526
+ }
2527
+ if (integrations.claudeCodeHook) {
2528
+ setupClaudeCodeHook(projectRoot);
2529
+ created.push(".claude/settings.json \u2014 added viberails hook");
2530
+ }
2531
+ if (integrations.claudeMdRef) {
2532
+ setupClaudeMdReference(projectRoot);
2533
+ created.push("CLAUDE.md \u2014 added @.viberails/context.md reference");
2534
+ }
2535
+ if (integrations.githubAction) {
2536
+ const t = setupGithubAction(projectRoot, opts.packageManager ?? "npm");
2537
+ if (t) created.push(`${t} \u2014 blocks PRs on violations`);
2538
+ }
2539
+ return created;
2540
+ }
2541
+
2542
+ // src/commands/init.ts
2543
+ var CONFIG_FILE4 = "viberails.config.json";
2544
+ function getExemptedPackages(config) {
2545
+ return config.packages.filter((pkg) => pkg.rules?.testCoverage === 0 && pkg.path !== ".").map((pkg) => pkg.path);
1762
2546
  }
1763
2547
  async function initCommand(options, cwd) {
1764
- const startDir = cwd ?? process.cwd();
1765
- const projectRoot = findProjectRoot(startDir);
2548
+ const projectRoot = findProjectRoot(cwd ?? process.cwd());
1766
2549
  if (!projectRoot) {
1767
2550
  throw new Error(
1768
- "No package.json found in this directory or any parent.\n\nMake sure you are inside a JavaScript or TypeScript project, then run:\n npx viberails"
2551
+ "No package.json found. Make sure you are inside a JS/TS project, then run:\n npx viberails"
1769
2552
  );
1770
2553
  }
1771
- const configPath = path13.join(projectRoot, CONFIG_FILE4);
1772
- if (fs13.existsSync(configPath) && !options.force) {
2554
+ const configPath = path17.join(projectRoot, CONFIG_FILE4);
2555
+ if (fs17.existsSync(configPath) && !options.force) {
1773
2556
  console.log(
1774
- `${import_chalk8.default.yellow("!")} viberails is already initialized.
1775
- Run ${import_chalk8.default.cyan("viberails sync")} to update, or ${import_chalk8.default.cyan("viberails init --force")} to start fresh.`
2557
+ `${import_chalk10.default.yellow("!")} viberails is already initialized.
2558
+ Run ${import_chalk10.default.cyan("viberails sync")} to update, or ${import_chalk10.default.cyan("viberails init --force")} to start fresh.`
1776
2559
  );
1777
2560
  return;
1778
2561
  }
1779
- if (options.yes) {
1780
- console.log(import_chalk8.default.dim("Scanning project..."));
1781
- const scanResult2 = await (0, import_scanner.scan)(projectRoot);
1782
- const config2 = (0, import_config4.generateConfig)(scanResult2);
1783
- config2.conventions = filterHighConfidence(config2.conventions);
1784
- displayScanResults(scanResult2);
1785
- displayRulesPreview(config2);
1786
- if (config2.workspace?.packages && config2.workspace.packages.length > 0) {
1787
- console.log(import_chalk8.default.dim("Building import graph..."));
1788
- const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
1789
- const packages = resolveWorkspacePackages(projectRoot, config2.workspace);
1790
- const graph = await buildImportGraph(projectRoot, {
1791
- packages,
1792
- ignore: config2.ignore
1793
- });
1794
- const inferred = inferBoundaries(graph);
1795
- const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1796
- if (denyCount > 0) {
1797
- config2.boundaries = inferred;
1798
- config2.rules.enforceBoundaries = true;
1799
- console.log(` Inferred ${denyCount} boundary rules`);
1800
- }
1801
- }
1802
- fs13.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
2562
+ if (options.yes) return initNonInteractive(projectRoot, configPath);
2563
+ await initInteractive(projectRoot, configPath, options);
2564
+ }
2565
+ async function initNonInteractive(projectRoot, configPath) {
2566
+ console.log(import_chalk10.default.dim("Scanning project..."));
2567
+ const scanResult = await (0, import_scanner.scan)(projectRoot);
2568
+ const config = (0, import_config6.generateConfig)(scanResult);
2569
+ for (const pkg of config.packages) {
2570
+ const pkgMeta = config._meta?.packages?.[pkg.path]?.conventions;
2571
+ pkg.conventions = filterHighConfidence(pkg.conventions ?? {}, pkgMeta);
2572
+ }
2573
+ displayMissingPrereqs(checkCoveragePrereqs(projectRoot, scanResult));
2574
+ displayScanResults(scanResult);
2575
+ displayRulesPreview(config);
2576
+ const exempted = getExemptedPackages(config);
2577
+ if (exempted.length > 0) {
2578
+ console.log(
2579
+ ` ${import_chalk10.default.dim("Auto-exempted from coverage:")} ${exempted.join(", ")} ${import_chalk10.default.dim("(types-only)")}`
2580
+ );
2581
+ }
2582
+ if (config.packages.length > 1) {
2583
+ console.log(import_chalk10.default.dim("Building import graph..."));
2584
+ const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
2585
+ const packages = resolveWorkspacePackages(projectRoot, config.packages);
2586
+ const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
2587
+ const inferred = inferBoundaries(graph);
2588
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
2589
+ if (denyCount > 0) {
2590
+ config.boundaries = inferred;
2591
+ config.rules.enforceBoundaries = true;
2592
+ console.log(` Inferred ${denyCount} boundary rules`);
2593
+ }
2594
+ }
2595
+ const compacted = (0, import_config6.compactConfig)(config);
2596
+ fs17.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
1803
2597
  `);
1804
- writeGeneratedFiles(projectRoot, config2, scanResult2);
1805
- updateGitignore(projectRoot);
1806
- setupClaudeMdReference(projectRoot);
1807
- console.log(`
1808
- Created:`);
1809
- console.log(` ${import_chalk8.default.green("\u2713")} ${CONFIG_FILE4}`);
1810
- console.log(` ${import_chalk8.default.green("\u2713")} .viberails/context.md`);
1811
- console.log(` ${import_chalk8.default.green("\u2713")} .viberails/scan-result.json`);
1812
- return;
2598
+ writeGeneratedFiles(projectRoot, config, scanResult);
2599
+ updateGitignore(projectRoot);
2600
+ setupClaudeCodeHook(projectRoot);
2601
+ setupClaudeMdReference(projectRoot);
2602
+ const preCommitTarget = setupPreCommitHook(projectRoot);
2603
+ const rootPkg = config.packages[0];
2604
+ const rootPkgPm = rootPkg?.stack?.packageManager ?? "npm";
2605
+ const actionTarget = setupGithubAction(projectRoot, rootPkgPm);
2606
+ const typecheckTarget = rootPkg?.stack?.language === "typescript" ? setupTypecheckHook(projectRoot) : void 0;
2607
+ const linter = rootPkg?.stack?.linter?.split("@")[0];
2608
+ const lintTarget = linter ? setupLintHook(projectRoot, linter) : void 0;
2609
+ const ok = import_chalk10.default.green("\u2713");
2610
+ const created = [
2611
+ `${ok} ${path17.basename(configPath)}`,
2612
+ `${ok} .viberails/context.md`,
2613
+ `${ok} .viberails/scan-result.json`,
2614
+ `${ok} .claude/settings.json \u2014 added viberails hook`,
2615
+ `${ok} CLAUDE.md \u2014 added @.viberails/context.md reference`,
2616
+ preCommitTarget ? `${ok} ${preCommitTarget}` : `${import_chalk10.default.yellow("!")} pre-commit hook skipped`,
2617
+ typecheckTarget ? `${ok} ${typecheckTarget} \u2014 added typecheck` : "",
2618
+ lintTarget ? `${ok} ${lintTarget} \u2014 added lint check` : "",
2619
+ actionTarget ? `${ok} ${actionTarget} \u2014 blocks PRs on violations` : ""
2620
+ ].filter(Boolean);
2621
+ console.log(`
2622
+ Created:
2623
+ ${created.map((f) => ` ${f}`).join("\n")}`);
2624
+ }
2625
+ async function initInteractive(projectRoot, configPath, options) {
2626
+ clack7.intro("viberails");
2627
+ if (fs17.existsSync(configPath) && options.force) {
2628
+ const replace = await confirmDangerous(
2629
+ `${path17.basename(configPath)} already exists and will be replaced. Continue?`
2630
+ );
2631
+ if (!replace) {
2632
+ clack7.outro("Aborted. No files were written.");
2633
+ return;
2634
+ }
1813
2635
  }
1814
- clack2.intro("viberails");
1815
- const s = clack2.spinner();
2636
+ const s = clack7.spinner();
1816
2637
  s.start("Scanning project...");
1817
2638
  const scanResult = await (0, import_scanner.scan)(projectRoot);
1818
- const config = (0, import_config4.generateConfig)(scanResult);
2639
+ const config = (0, import_config6.generateConfig)(scanResult);
1819
2640
  s.stop("Scan complete");
2641
+ const prereqResult = await promptMissingPrereqs(
2642
+ projectRoot,
2643
+ checkCoveragePrereqs(projectRoot, scanResult)
2644
+ );
2645
+ if (prereqResult.disableCoverage) {
2646
+ config.rules.testCoverage = 0;
2647
+ }
1820
2648
  if (scanResult.statistics.totalFiles === 0) {
1821
- clack2.log.warn(
1822
- "No source files detected. viberails will generate context\nwith minimal content. Run viberails sync after adding files."
2649
+ clack7.log.warn(
2650
+ "No source files detected. Try running from the project root,\nor check that source files exist. Run viberails sync after adding files."
1823
2651
  );
1824
2652
  }
1825
- const resultsText = formatScanResultsText(scanResult, config);
1826
- clack2.note(resultsText, "Scan results");
2653
+ clack7.note(formatScanResultsText(scanResult), "Scan results");
2654
+ const rulesLines = formatRulesText(config);
2655
+ const exemptedPkgs = getExemptedPackages(config);
2656
+ if (exemptedPkgs.length > 0)
2657
+ rulesLines.push(`Auto-exempted from coverage: ${exemptedPkgs.join(", ")} (types-only)`);
2658
+ clack7.note(rulesLines.join("\n"), "Rules");
1827
2659
  const decision = await promptInitDecision();
1828
2660
  if (decision === "customize") {
2661
+ const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
1829
2662
  const overrides = await promptRuleMenu({
1830
2663
  maxFileLines: config.rules.maxFileLines,
1831
- requireTests: config.rules.requireTests,
2664
+ testCoverage: config.rules.testCoverage,
2665
+ enforceMissingTests: config.rules.enforceMissingTests,
1832
2666
  enforceNaming: config.rules.enforceNaming,
1833
- enforcement: config.enforcement,
1834
- fileNamingValue: getConventionStr3(config.conventions.fileNaming),
2667
+ fileNamingValue: rootPkg.conventions?.fileNaming,
2668
+ coverageSummaryPath: "coverage/coverage-summary.json",
2669
+ coverageCommand: config.defaults?.coverage?.command,
1835
2670
  packageOverrides: config.packages
1836
2671
  });
2672
+ if (overrides.packageOverrides) config.packages = overrides.packageOverrides;
1837
2673
  config.rules.maxFileLines = overrides.maxFileLines;
1838
- config.rules.requireTests = overrides.requireTests;
2674
+ config.rules.testCoverage = overrides.testCoverage;
2675
+ config.rules.enforceMissingTests = overrides.enforceMissingTests;
1839
2676
  config.rules.enforceNaming = overrides.enforceNaming;
1840
- config.enforcement = overrides.enforcement;
2677
+ for (const pkg of config.packages) {
2678
+ pkg.coverage = pkg.coverage ?? {};
2679
+ if (pkg.coverage.summaryPath === void 0) {
2680
+ pkg.coverage.summaryPath = overrides.coverageSummaryPath;
2681
+ }
2682
+ if (pkg.coverage.command === void 0 && overrides.coverageCommand) {
2683
+ pkg.coverage.command = overrides.coverageCommand;
2684
+ }
2685
+ }
2686
+ if (overrides.fileNamingValue) {
2687
+ const oldNaming = rootPkg.conventions?.fileNaming;
2688
+ rootPkg.conventions = rootPkg.conventions ?? {};
2689
+ rootPkg.conventions.fileNaming = overrides.fileNamingValue;
2690
+ if (oldNaming && oldNaming !== overrides.fileNamingValue) {
2691
+ for (const pkg of config.packages) {
2692
+ if (pkg.conventions?.fileNaming === oldNaming) {
2693
+ pkg.conventions.fileNaming = overrides.fileNamingValue;
2694
+ }
2695
+ }
2696
+ }
2697
+ }
1841
2698
  }
1842
- if (config.workspace?.packages && config.workspace.packages.length > 0) {
1843
- clack2.note(
2699
+ if (config.packages.length > 1) {
2700
+ clack7.note(
1844
2701
  "Boundary rules prevent packages from importing where they\nshouldn't. viberails scans your existing imports and creates\nrules based on what's already working.",
1845
2702
  "Boundaries"
1846
2703
  );
1847
- const shouldInfer = await confirm2("Infer boundary rules from import patterns?");
2704
+ const shouldInfer = await confirm3("Infer boundary rules from import patterns?");
1848
2705
  if (shouldInfer) {
1849
- const bs = clack2.spinner();
2706
+ const bs = clack7.spinner();
1850
2707
  bs.start("Building import graph...");
1851
2708
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
1852
- const packages = resolveWorkspacePackages(projectRoot, config.workspace);
1853
- const graph = await buildImportGraph(projectRoot, {
1854
- packages,
1855
- ignore: config.ignore
1856
- });
2709
+ const packages = resolveWorkspacePackages(projectRoot, config.packages);
2710
+ const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
1857
2711
  const inferred = inferBoundaries(graph);
1858
2712
  const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1859
2713
  if (denyCount > 0) {
1860
2714
  config.boundaries = inferred;
1861
2715
  config.rules.enforceBoundaries = true;
1862
2716
  bs.stop(`Inferred ${denyCount} boundary rules`);
2717
+ const boundaryLines = Object.entries(inferred.deny).map(([pkg, denied]) => `${pkg} must NOT import from: ${denied.join(", ")}`).join("\n");
2718
+ clack7.note(boundaryLines, "Boundary rules");
1863
2719
  } else {
1864
2720
  bs.stop("No boundary rules inferred");
1865
2721
  }
1866
2722
  }
1867
2723
  }
1868
2724
  const hookManager = detectHookManager(projectRoot);
1869
- const integrations = await promptIntegrations(hookManager);
1870
- fs13.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
2725
+ const rootPkgStack = (config.packages.find((p) => p.path === ".") ?? config.packages[0])?.stack;
2726
+ const integrations = await promptIntegrations(hookManager, {
2727
+ isTypeScript: rootPkgStack?.language === "typescript",
2728
+ linter: rootPkgStack?.linter?.split("@")[0]
2729
+ });
2730
+ const shouldWrite = await confirm3("Write configuration and set up selected integrations?");
2731
+ if (!shouldWrite) {
2732
+ clack7.outro("Aborted. No files were written.");
2733
+ return;
2734
+ }
2735
+ const compacted = (0, import_config6.compactConfig)(config);
2736
+ fs17.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
1871
2737
  `);
1872
2738
  writeGeneratedFiles(projectRoot, config, scanResult);
1873
2739
  updateGitignore(projectRoot);
1874
2740
  const createdFiles = [
1875
- CONFIG_FILE4,
2741
+ path17.basename(configPath),
1876
2742
  ".viberails/context.md",
1877
- ".viberails/scan-result.json"
2743
+ ".viberails/scan-result.json",
2744
+ ...setupSelectedIntegrations(projectRoot, integrations, {
2745
+ linter: rootPkgStack?.linter?.split("@")[0],
2746
+ packageManager: rootPkgStack?.packageManager
2747
+ })
1878
2748
  ];
1879
- if (integrations.preCommitHook) {
1880
- setupPreCommitHook(projectRoot);
1881
- if (hookManager === "Lefthook") {
1882
- createdFiles.push(`lefthook.yml \u2014 added viberails pre-commit`);
1883
- }
1884
- }
1885
- if (integrations.claudeCodeHook) {
1886
- setupClaudeCodeHook(projectRoot);
1887
- createdFiles.push(".claude/settings.json \u2014 added viberails hook");
1888
- }
1889
- if (integrations.claudeMdRef) {
1890
- setupClaudeMdReference(projectRoot);
1891
- createdFiles.push("CLAUDE.md \u2014 added @.viberails/context.md reference");
1892
- }
1893
- clack2.log.success(`Created:
2749
+ clack7.log.success(`Created:
1894
2750
  ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1895
- clack2.outro("Done! Next: review viberails.config.json, then run viberails check");
1896
- }
1897
- function updateGitignore(projectRoot) {
1898
- const gitignorePath = path13.join(projectRoot, ".gitignore");
1899
- let content = "";
1900
- if (fs13.existsSync(gitignorePath)) {
1901
- content = fs13.readFileSync(gitignorePath, "utf-8");
1902
- }
1903
- if (!content.includes(".viberails/scan-result.json")) {
1904
- const block = "\n# viberails\n.viberails/scan-result.json\n";
1905
- const prefix = content.length === 0 ? "" : `${content.trimEnd()}
1906
- `;
1907
- fs13.writeFileSync(gitignorePath, `${prefix}${block}`);
1908
- }
2751
+ clack7.outro(
2752
+ `Done! Next: review viberails.config.json, then run viberails check
2753
+ ${import_chalk10.default.dim("Tip: use")} ${import_chalk10.default.cyan("viberails check --enforce")} ${import_chalk10.default.dim("in CI to block PRs on violations.")}`
2754
+ );
1909
2755
  }
1910
2756
 
1911
2757
  // src/commands/sync.ts
1912
- var fs14 = __toESM(require("fs"), 1);
1913
- var path14 = __toESM(require("path"), 1);
1914
- var import_config5 = require("@viberails/config");
2758
+ var fs18 = __toESM(require("fs"), 1);
2759
+ var path18 = __toESM(require("path"), 1);
2760
+ var import_config7 = require("@viberails/config");
1915
2761
  var import_scanner2 = require("@viberails/scanner");
1916
- var import_chalk9 = __toESM(require("chalk"), 1);
2762
+ var import_chalk11 = __toESM(require("chalk"), 1);
1917
2763
 
1918
2764
  // src/utils/diff-configs.ts
1919
2765
  var import_types5 = require("@viberails/types");
@@ -1934,11 +2780,8 @@ function displayStackName(s) {
1934
2780
  const display = allMaps[name] ?? name;
1935
2781
  return version ? `${display} ${version}` : display;
1936
2782
  }
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;
2783
+ function isNewlyDetected(config, pkgPath, key) {
2784
+ return config._meta?.packages?.[pkgPath]?.conventions?.[key]?.detected === true;
1942
2785
  }
1943
2786
  var STACK_FIELDS = [
1944
2787
  "framework",
@@ -1965,59 +2808,66 @@ var STRUCTURE_FIELDS = [
1965
2808
  { key: "tests", label: "tests directory" },
1966
2809
  { key: "testPattern", label: "test pattern" }
1967
2810
  ];
1968
- function diffConfigs(existing, merged) {
2811
+ function diffPackage(existing, merged, mergedConfig) {
1969
2812
  const changes = [];
2813
+ const pkgPrefix = existing.path === "." ? "" : `${existing.path}: `;
1970
2814
  for (const field of STACK_FIELDS) {
1971
- const oldVal = existing.stack[field];
1972
- const newVal = merged.stack[field];
2815
+ const oldVal = existing.stack?.[field];
2816
+ const newVal = merged.stack?.[field];
1973
2817
  if (!oldVal && newVal) {
1974
- changes.push({ type: "added", description: `Stack: added ${displayStackName(newVal)}` });
2818
+ changes.push({
2819
+ type: "added",
2820
+ description: `${pkgPrefix}Stack: added ${displayStackName(newVal)}`
2821
+ });
1975
2822
  } else if (oldVal && newVal && oldVal !== newVal) {
1976
2823
  changes.push({
1977
2824
  type: "changed",
1978
- description: `Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
2825
+ description: `${pkgPrefix}Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
1979
2826
  });
1980
2827
  }
1981
2828
  }
1982
2829
  for (const key of CONVENTION_KEYS) {
1983
- const oldVal = existing.conventions[key];
1984
- const newVal = merged.conventions[key];
2830
+ const oldVal = existing.conventions?.[key];
2831
+ const newVal = merged.conventions?.[key];
1985
2832
  const label = import_types5.CONVENTION_LABELS[key] ?? key;
1986
2833
  if (!oldVal && newVal) {
1987
2834
  changes.push({
1988
2835
  type: "added",
1989
- description: `New convention: ${label} (${conventionStr(newVal)})`
2836
+ description: `${pkgPrefix}New convention: ${label} (${newVal})`
1990
2837
  });
1991
- } else if (oldVal && newVal && isDetected(newVal)) {
2838
+ } else if (oldVal && newVal && oldVal !== newVal) {
2839
+ const suffix = isNewlyDetected(mergedConfig, merged.path, key) ? " (newly detected)" : "";
1992
2840
  changes.push({
1993
2841
  type: "changed",
1994
- description: `Convention updated: ${label} (${conventionStr(newVal)})`
2842
+ description: `${pkgPrefix}Convention updated: ${label} (${newVal})${suffix}`
1995
2843
  });
1996
2844
  }
1997
2845
  }
1998
2846
  for (const { key, label } of STRUCTURE_FIELDS) {
1999
- const oldVal = existing.structure[key];
2000
- const newVal = merged.structure[key];
2847
+ const oldVal = existing.structure?.[key];
2848
+ const newVal = merged.structure?.[key];
2001
2849
  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}` });
2850
+ changes.push({
2851
+ type: "added",
2852
+ description: `${pkgPrefix}Structure: detected ${label} (${newVal})`
2853
+ });
2009
2854
  }
2010
2855
  }
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}` });
2856
+ return changes;
2857
+ }
2858
+ function diffConfigs(existing, merged) {
2859
+ const changes = [];
2860
+ const existingByPath = new Map(existing.packages.map((p) => [p.path, p]));
2861
+ const mergedByPath = new Map(merged.packages.map((p) => [p.path, p]));
2862
+ for (const existingPkg of existing.packages) {
2863
+ const mergedPkg = mergedByPath.get(existingPkg.path);
2864
+ if (mergedPkg) {
2865
+ changes.push(...diffPackage(existingPkg, mergedPkg, merged));
2016
2866
  }
2017
2867
  }
2018
- for (const pkg of existingWsPkgs) {
2019
- if (!mergedWsPkgs.has(pkg)) {
2020
- changes.push({ type: "removed", description: `Workspace: removed ${pkg}` });
2868
+ for (const mergedPkg of merged.packages) {
2869
+ if (!existingByPath.has(mergedPkg.path)) {
2870
+ changes.push({ type: "added", description: `New package: ${mergedPkg.path}` });
2021
2871
  }
2022
2872
  }
2023
2873
  return changes;
@@ -2042,9 +2892,9 @@ function formatStatsDelta(oldStats, newStats) {
2042
2892
  var CONFIG_FILE5 = "viberails.config.json";
2043
2893
  var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
2044
2894
  function loadPreviousStats(projectRoot) {
2045
- const scanResultPath = path14.join(projectRoot, SCAN_RESULT_FILE2);
2895
+ const scanResultPath = path18.join(projectRoot, SCAN_RESULT_FILE2);
2046
2896
  try {
2047
- const raw = fs14.readFileSync(scanResultPath, "utf-8");
2897
+ const raw = fs18.readFileSync(scanResultPath, "utf-8");
2048
2898
  const parsed = JSON.parse(raw);
2049
2899
  if (parsed?.statistics?.totalFiles !== void 0) {
2050
2900
  return parsed.statistics;
@@ -2061,44 +2911,47 @@ async function syncCommand(cwd) {
2061
2911
  "No package.json found in this directory or any parent.\n\nMake sure you are inside a JavaScript or TypeScript project, then run:\n npx viberails"
2062
2912
  );
2063
2913
  }
2064
- const configPath = path14.join(projectRoot, CONFIG_FILE5);
2065
- const existing = await (0, import_config5.loadConfig)(configPath);
2914
+ const configPath = path18.join(projectRoot, CONFIG_FILE5);
2915
+ const existing = await (0, import_config7.loadConfig)(configPath);
2066
2916
  const previousStats = loadPreviousStats(projectRoot);
2067
- console.log(import_chalk9.default.dim("Scanning project..."));
2917
+ console.log(import_chalk11.default.dim("Scanning project..."));
2068
2918
  const scanResult = await (0, import_scanner2.scan)(projectRoot);
2069
- const merged = (0, import_config5.mergeConfig)(existing, scanResult);
2070
- const existingJson = JSON.stringify(existing, null, 2);
2071
- const mergedJson = JSON.stringify(merged, null, 2);
2072
- const configChanged = existingJson !== mergedJson;
2919
+ const merged = (0, import_config7.mergeConfig)(existing, scanResult);
2920
+ const compacted = (0, import_config7.compactConfig)(merged);
2921
+ const compactedJson = JSON.stringify(compacted, null, 2);
2922
+ const rawDisk = fs18.readFileSync(configPath, "utf-8").trim();
2923
+ const diskWithoutSync = rawDisk.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
2924
+ const mergedWithoutSync = compactedJson.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
2925
+ const configChanged = diskWithoutSync !== mergedWithoutSync;
2073
2926
  const changes = configChanged ? diffConfigs(existing, merged) : [];
2074
2927
  const statsDelta = previousStats ? formatStatsDelta(previousStats, scanResult.statistics) : void 0;
2075
2928
  if (changes.length > 0 || statsDelta) {
2076
2929
  console.log(`
2077
- ${import_chalk9.default.bold("Changes:")}`);
2930
+ ${import_chalk11.default.bold("Changes:")}`);
2078
2931
  for (const change of changes) {
2079
- const icon = change.type === "removed" ? import_chalk9.default.red("-") : import_chalk9.default.green("+");
2932
+ const icon = change.type === "removed" ? import_chalk11.default.red("-") : import_chalk11.default.green("+");
2080
2933
  console.log(` ${icon} ${change.description}`);
2081
2934
  }
2082
2935
  if (statsDelta) {
2083
- console.log(` ${import_chalk9.default.dim(statsDelta)}`);
2936
+ console.log(` ${import_chalk11.default.dim(statsDelta)}`);
2084
2937
  }
2085
2938
  }
2086
- fs14.writeFileSync(configPath, `${mergedJson}
2939
+ fs18.writeFileSync(configPath, `${compactedJson}
2087
2940
  `);
2088
2941
  writeGeneratedFiles(projectRoot, merged, scanResult);
2089
2942
  console.log(`
2090
- ${import_chalk9.default.bold("Synced:")}`);
2943
+ ${import_chalk11.default.bold("Synced:")}`);
2091
2944
  if (configChanged) {
2092
- console.log(` ${import_chalk9.default.yellow("!")} ${CONFIG_FILE5} \u2014 updated (review changes)`);
2945
+ console.log(` ${import_chalk11.default.yellow("!")} ${CONFIG_FILE5} \u2014 updated (review changes)`);
2093
2946
  } else {
2094
- console.log(` ${import_chalk9.default.green("\u2713")} ${CONFIG_FILE5} \u2014 unchanged`);
2947
+ console.log(` ${import_chalk11.default.green("\u2713")} ${CONFIG_FILE5} \u2014 unchanged`);
2095
2948
  }
2096
- console.log(` ${import_chalk9.default.green("\u2713")} .viberails/context.md \u2014 regenerated`);
2097
- console.log(` ${import_chalk9.default.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
2949
+ console.log(` ${import_chalk11.default.green("\u2713")} .viberails/context.md \u2014 regenerated`);
2950
+ console.log(` ${import_chalk11.default.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
2098
2951
  }
2099
2952
 
2100
2953
  // src/index.ts
2101
- var VERSION = "0.4.0";
2954
+ var VERSION = "0.5.0";
2102
2955
  var program = new import_commander.Command();
2103
2956
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
2104
2957
  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) => {
@@ -2106,7 +2959,7 @@ program.command("init", { isDefault: true }).description("Scan your project and
2106
2959
  await initCommand(options);
2107
2960
  } catch (err) {
2108
2961
  const message = err instanceof Error ? err.message : String(err);
2109
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2962
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2110
2963
  process.exit(1);
2111
2964
  }
2112
2965
  });
@@ -2115,11 +2968,11 @@ program.command("sync").description("Re-scan and update generated files").action
2115
2968
  await syncCommand();
2116
2969
  } catch (err) {
2117
2970
  const message = err instanceof Error ? err.message : String(err);
2118
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2971
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2119
2972
  process.exit(1);
2120
2973
  }
2121
2974
  });
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(
2975
+ 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("--diff-base <ref>", "Only check files changed since <ref> (for CI on PRs)").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("--enforce", "Exit with error on violations (for CI)").option("--hook", "Claude Code hook mode: read file from stdin, output to stderr").action(
2123
2976
  async (options) => {
2124
2977
  try {
2125
2978
  if (options.hook) {
@@ -2128,13 +2981,15 @@ program.command("check").description("Check files against enforced rules").optio
2128
2981
  }
2129
2982
  const exitCode = await checkCommand({
2130
2983
  ...options,
2984
+ diffBase: options.diffBase,
2985
+ enforce: options.enforce,
2131
2986
  noBoundaries: options.boundaries === false,
2132
2987
  format: options.format === "json" ? "json" : "text"
2133
2988
  });
2134
2989
  process.exit(exitCode);
2135
2990
  } catch (err) {
2136
2991
  const message = err instanceof Error ? err.message : String(err);
2137
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2992
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2138
2993
  process.exit(1);
2139
2994
  }
2140
2995
  }
@@ -2145,7 +3000,7 @@ program.command("fix").description("Auto-fix file naming violations and generate
2145
3000
  process.exit(exitCode);
2146
3001
  } catch (err) {
2147
3002
  const message = err instanceof Error ? err.message : String(err);
2148
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
3003
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2149
3004
  process.exit(1);
2150
3005
  }
2151
3006
  });
@@ -2154,7 +3009,7 @@ program.command("boundaries").description("Display, infer, or inspect import bou
2154
3009
  await boundariesCommand(options);
2155
3010
  } catch (err) {
2156
3011
  const message = err instanceof Error ? err.message : String(err);
2157
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
3012
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2158
3013
  process.exit(1);
2159
3014
  }
2160
3015
  });