viberails 0.3.3 → 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,118 +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 });
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
115
+ });
73
116
  assertNotCancelled(result);
74
- 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
+ };
75
125
  }
76
- async function confirmDangerous(message) {
77
- const result = await clack.confirm({ message, initialValue: false });
78
- assertNotCancelled(result);
79
- return result;
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;
139
+ }
140
+ if (pkg.coverage && Object.keys(pkg.coverage).length === 0) {
141
+ delete pkg.coverage;
142
+ }
143
+ }
144
+ return packages;
80
145
  }
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
- ]
88
- });
89
- assertNotCancelled(result);
90
- return result;
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
+ ]
174
+ });
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;
210
+ }
211
+ } else {
212
+ target.rules = { ...target.rules ?? {}, testCoverage: nextCoverage };
213
+ }
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;
226
+ }
227
+ } else {
228
+ target.coverage = { ...target.coverage ?? {}, summaryPath: value };
229
+ }
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 };
246
+ }
247
+ }
248
+ if (choice === "reset") {
249
+ if (target.rules) {
250
+ delete target.rules.testCoverage;
251
+ }
252
+ delete target.coverage;
253
+ }
254
+ normalizePackageOverrides(editablePackages);
255
+ }
256
+ }
257
+ return normalizePackageOverrides(packages);
91
258
  }
92
- async function promptRuleCustomization(defaults) {
93
- const maxFileLinesResult = await clack.text({
94
- message: "Maximum lines per source file?",
95
- placeholder: String(defaults.maxFileLines),
96
- initialValue: String(defaults.maxFileLines),
97
- validate: (v) => {
98
- const n = Number.parseInt(v, 10);
99
- if (Number.isNaN(n) || n < 1) return "Enter a positive number";
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]}`);
100
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]}`);
283
+ }
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}%`
101
316
  });
102
- assertNotCancelled(maxFileLinesResult);
103
- const requireTestsResult = await clack.confirm({
104
- message: "Require matching test files for source files?",
105
- initialValue: defaults.requireTests
106
- });
107
- assertNotCancelled(requireTestsResult);
108
- const namingLabel = defaults.fileNamingValue ? `Enforce file naming? (detected: ${defaults.fileNamingValue})` : "Enforce file naming?";
109
- const enforceNamingResult = await clack.confirm({
110
- message: namingLabel,
111
- initialValue: defaults.enforceNaming
317
+ options.push({
318
+ value: "enforceMissingTests",
319
+ label: "Enforce missing tests",
320
+ hint: state.enforceMissingTests ? "yes" : "no"
112
321
  });
113
- assertNotCancelled(enforceNamingResult);
114
- const enforcementResult = await clack.select({
115
- message: "Enforcement mode",
116
- options: [
322
+ if (state.testCoverage > 0) {
323
+ options.push(
117
324
  {
118
- value: "warn",
119
- label: "warn",
120
- hint: "show violations but don't block commits (recommended)"
325
+ value: "coverageSummaryPath",
326
+ label: "Coverage summary path",
327
+ hint: state.coverageSummaryPath
121
328
  },
122
329
  {
123
- value: "enforce",
124
- label: "enforce",
125
- hint: "block commits with violations"
330
+ value: "coverageCommand",
331
+ label: "Coverage command",
332
+ hint: state.coverageCommand ?? "auto-detect from package.json test runner"
126
333
  }
127
- ],
128
- initialValue: defaults.enforcement
129
- });
130
- assertNotCancelled(enforcementResult);
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`
340
+ });
341
+ }
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
384
+ });
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
+ }
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);
489
+ }
131
490
  return {
132
- maxFileLines: Number.parseInt(maxFileLinesResult, 10),
133
- requireTests: requireTestsResult,
134
- enforceNaming: enforceNamingResult,
135
- enforcement: enforcementResult
491
+ maxFileLines: state.maxFileLines,
492
+ testCoverage: state.testCoverage,
493
+ enforceMissingTests: state.enforceMissingTests,
494
+ enforceNaming: state.enforceNaming,
495
+ fileNamingValue: state.fileNamingValue,
496
+ coverageSummaryPath: state.coverageSummaryPath,
497
+ coverageCommand: state.coverageCommand,
498
+ packageOverrides: state.packageOverrides
136
499
  };
137
500
  }
138
- async function promptIntegrations(hookManager) {
139
- const hookLabel = hookManager ? `Pre-commit hook (${hookManager})` : "Pre-commit hook (git hook)";
140
- const result = await clack.multiselect({
141
- 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?",
142
522
  options: [
143
523
  {
144
- value: "preCommit",
145
- label: hookLabel,
146
- hint: "runs checks when you commit"
147
- },
148
- {
149
- value: "claude",
150
- label: "Claude Code hook",
151
- 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"
152
527
  },
153
- {
154
- value: "claudeMd",
155
- label: "CLAUDE.md reference",
156
- hint: "appends @.viberails/context.md so Claude loads rules automatically"
157
- }
158
- ],
159
- initialValues: ["preCommit", "claude", "claudeMd"],
160
- required: false
528
+ { value: "customize", label: "Let me customize rules" }
529
+ ]
161
530
  });
162
531
  assertNotCancelled(result);
163
- return {
164
- preCommitHook: result.includes("preCommit"),
165
- claudeCodeHook: result.includes("claude"),
166
- claudeMdRef: result.includes("claudeMd")
167
- };
532
+ return result;
168
533
  }
169
534
 
170
535
  // src/utils/resolve-workspace-packages.ts
171
536
  var fs2 = __toESM(require("fs"), 1);
172
537
  var path2 = __toESM(require("path"), 1);
173
- function resolveWorkspacePackages(projectRoot, workspace) {
174
- const packages = [];
175
- 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;
176
543
  const absPath = path2.join(projectRoot, relativePath);
177
544
  const pkgJsonPath = path2.join(absPath, "package.json");
178
545
  if (!fs2.existsSync(pkgJsonPath)) continue;
@@ -188,13 +555,13 @@ function resolveWorkspacePackages(projectRoot, workspace) {
188
555
  ...Object.keys(pkg.dependencies ?? {}),
189
556
  ...Object.keys(pkg.devDependencies ?? {})
190
557
  ];
191
- packages.push({ name, path: absPath, relativePath, internalDeps: allDeps });
558
+ resolved.push({ name, path: absPath, relativePath, internalDeps: allDeps });
192
559
  }
193
- const packageNames = new Set(packages.map((p) => p.name));
194
- for (const pkg of packages) {
560
+ const packageNames = new Set(resolved.map((p) => p.name));
561
+ for (const pkg of resolved) {
195
562
  pkg.internalDeps = pkg.internalDeps.filter((dep) => packageNames.has(dep));
196
563
  }
197
- return packages;
564
+ return resolved;
198
565
  }
199
566
 
200
567
  // src/commands/boundaries.ts
@@ -245,7 +612,7 @@ Enforcement: ${config.rules.enforceBoundaries ? import_chalk.default.green("on")
245
612
  async function inferAndDisplay(projectRoot, config, configPath) {
246
613
  console.log(import_chalk.default.dim("Analyzing imports..."));
247
614
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
248
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
615
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
249
616
  const graph = await buildImportGraph(projectRoot, {
250
617
  packages,
251
618
  ignore: config.ignore
@@ -269,11 +636,11 @@ ${import_chalk.default.bold("Inferred boundary rules:")}
269
636
  console.log(`
270
637
  ${totalRules} denied`);
271
638
  console.log("");
272
- const shouldSave = await confirm2("Save to viberails.config.json?");
639
+ const shouldSave = await confirm3("Save to viberails.config.json?");
273
640
  if (shouldSave) {
274
641
  config.boundaries = inferred;
275
642
  config.rules.enforceBoundaries = true;
276
- fs3.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
643
+ fs3.writeFileSync(configPath, `${JSON.stringify((0, import_config.compactConfig)(config), null, 2)}
277
644
  `);
278
645
  console.log(`${import_chalk.default.green("\u2713")} Saved ${totalRules} rules`);
279
646
  }
@@ -281,7 +648,7 @@ ${import_chalk.default.bold("Inferred boundary rules:")}
281
648
  async function showGraph(projectRoot, config) {
282
649
  console.log(import_chalk.default.dim("Building import graph..."));
283
650
  const { buildImportGraph } = await import("@viberails/graph");
284
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
651
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
285
652
  const graph = await buildImportGraph(projectRoot, {
286
653
  packages,
287
654
  ignore: config.ignore
@@ -309,42 +676,191 @@ ${import_chalk.default.yellow("Cycles detected:")}`);
309
676
  }
310
677
 
311
678
  // src/commands/check.ts
312
- var fs6 = __toESM(require("fs"), 1);
313
- var path6 = __toESM(require("path"), 1);
314
- 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");
315
682
  var import_chalk2 = __toESM(require("chalk"), 1);
316
683
 
317
684
  // src/commands/check-config.ts
685
+ var import_config2 = require("@viberails/config");
318
686
  function resolveConfigForFile(relPath, config) {
319
- if (!config.packages || config.packages.length === 0) {
320
- return { rules: config.rules, conventions: config.conventions };
321
- }
322
687
  const sortedPackages = [...config.packages].sort((a, b) => b.path.length - a.path.length);
323
688
  for (const pkg of sortedPackages) {
689
+ if (pkg.path === ".") continue;
324
690
  if (relPath.startsWith(`${pkg.path}/`) || relPath === pkg.path) {
325
691
  return {
326
692
  rules: { ...config.rules, ...pkg.rules },
327
- conventions: { ...config.conventions, ...pkg.conventions }
693
+ conventions: pkg.conventions ?? {},
694
+ coverage: {
695
+ ...config.defaults?.coverage ?? {},
696
+ ...pkg.coverage ?? {}
697
+ }
328
698
  };
329
699
  }
330
700
  }
331
- 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 ?? []];
332
713
  }
333
714
  function resolveIgnoreForFile(relPath, config) {
334
- const globalIgnore = config.ignore;
335
- if (!config.packages) return globalIgnore;
336
- for (const pkg of config.packages) {
337
- if (pkg.ignore && relPath.startsWith(`${pkg.path}/`)) {
338
- return [...globalIgnore, ...pkg.ignore];
339
- }
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];
340
721
  }
341
- return globalIgnore;
722
+ return withRoot;
342
723
  }
343
724
 
344
- // src/commands/check-files.ts
725
+ // src/commands/check-coverage.ts
345
726
  var import_node_child_process = require("child_process");
346
727
  var fs4 = __toESM(require("fs"), 1);
347
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");
348
864
  var import_picomatch = __toESM(require("picomatch"), 1);
349
865
  var ALWAYS_SKIP_DIRS = /* @__PURE__ */ new Set([
350
866
  "node_modules",
@@ -387,7 +903,7 @@ function isIgnored(relPath, ignorePatterns) {
387
903
  }
388
904
  function countFileLines(filePath) {
389
905
  try {
390
- const content = fs4.readFileSync(filePath, "utf-8");
906
+ const content = fs5.readFileSync(filePath, "utf-8");
391
907
  if (content.length === 0) return 0;
392
908
  let count = 1;
393
909
  for (let i = 0; i < content.length; i++) {
@@ -399,14 +915,14 @@ function countFileLines(filePath) {
399
915
  }
400
916
  }
401
917
  function checkNaming(relPath, conventions) {
402
- const filename = path4.basename(relPath);
403
- const ext = path4.extname(filename);
918
+ const filename = path5.basename(relPath);
919
+ const ext = path5.extname(filename);
404
920
  if (!SOURCE_EXTS.has(ext)) return void 0;
405
921
  if (filename.startsWith("index.") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith(".") || filename.startsWith("_") || filename.startsWith("+") || filename.startsWith("$") || filename.startsWith("[")) {
406
922
  return void 0;
407
923
  }
408
924
  const bare = filename.slice(0, filename.indexOf("."));
409
- const convention = typeof conventions.fileNaming === "string" ? conventions.fileNaming : conventions.fileNaming?.value;
925
+ const convention = conventions.fileNaming;
410
926
  if (!convention) return void 0;
411
927
  const pattern = NAMING_PATTERNS[convention];
412
928
  if (!pattern || pattern.test(bare)) return void 0;
@@ -414,35 +930,57 @@ function checkNaming(relPath, conventions) {
414
930
  }
415
931
  function getStagedFiles(projectRoot) {
416
932
  try {
417
- 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", {
418
934
  cwd: projectRoot,
419
- encoding: "utf-8"
935
+ encoding: "utf-8",
936
+ stdio: ["ignore", "pipe", "ignore"]
420
937
  });
421
938
  return output.trim().split("\n").filter(Boolean);
422
939
  } catch {
423
940
  return [];
424
941
  }
425
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
+ }
426
963
  function getAllSourceFiles(projectRoot, config) {
964
+ const effectiveIgnore = [...import_config3.BUILTIN_IGNORE, ...config.ignore ?? []];
427
965
  const files = [];
428
966
  const walk = (dir) => {
429
967
  let entries;
430
968
  try {
431
- entries = fs4.readdirSync(dir, { withFileTypes: true });
969
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
432
970
  } catch {
433
971
  return;
434
972
  }
435
973
  for (const entry of entries) {
436
- const rel = path4.relative(projectRoot, path4.join(dir, entry.name));
974
+ const rel = path5.relative(projectRoot, path5.join(dir, entry.name));
437
975
  if (entry.isDirectory()) {
438
976
  if (ALWAYS_SKIP_DIRS.has(entry.name)) {
439
977
  continue;
440
978
  }
441
- if (isIgnored(rel, config.ignore)) continue;
442
- walk(path4.join(dir, entry.name));
979
+ if (isIgnored(rel, effectiveIgnore)) continue;
980
+ walk(path5.join(dir, entry.name));
443
981
  } else if (entry.isFile()) {
444
- const ext = path4.extname(entry.name);
445
- 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)) {
446
984
  files.push(rel);
447
985
  }
448
986
  }
@@ -456,16 +994,16 @@ function collectSourceFiles(dir, projectRoot) {
456
994
  const walk = (d) => {
457
995
  let entries;
458
996
  try {
459
- entries = fs4.readdirSync(d, { withFileTypes: true });
997
+ entries = fs5.readdirSync(d, { withFileTypes: true });
460
998
  } catch {
461
999
  return;
462
1000
  }
463
1001
  for (const entry of entries) {
464
1002
  if (entry.isDirectory()) {
465
1003
  if (entry.name === "node_modules") continue;
466
- walk(path4.join(d, entry.name));
1004
+ walk(path5.join(d, entry.name));
467
1005
  } else if (entry.isFile()) {
468
- files.push(path4.relative(projectRoot, path4.join(d, entry.name)));
1006
+ files.push(path5.relative(projectRoot, path5.join(d, entry.name)));
469
1007
  }
470
1008
  }
471
1009
  };
@@ -474,8 +1012,8 @@ function collectSourceFiles(dir, projectRoot) {
474
1012
  }
475
1013
 
476
1014
  // src/commands/check-tests.ts
477
- var fs5 = __toESM(require("fs"), 1);
478
- var path5 = __toESM(require("path"), 1);
1015
+ var fs6 = __toESM(require("fs"), 1);
1016
+ var path6 = __toESM(require("path"), 1);
479
1017
  var SOURCE_EXTS2 = /* @__PURE__ */ new Set([
480
1018
  ".ts",
481
1019
  ".tsx",
@@ -489,44 +1027,58 @@ var SOURCE_EXTS2 = /* @__PURE__ */ new Set([
489
1027
  ]);
490
1028
  function checkMissingTests(projectRoot, config, severity) {
491
1029
  const violations = [];
492
- const { testPattern } = config.structure;
493
- if (!testPattern) return violations;
494
- const srcDir = config.structure.srcDir;
495
- if (!srcDir) return violations;
496
- const srcPath = path5.join(projectRoot, srcDir);
497
- if (!fs5.existsSync(srcPath)) return violations;
498
- const testSuffix = testPattern.replace("*", "");
499
- const sourceFiles = collectSourceFiles(srcPath, projectRoot);
500
- for (const relFile of sourceFiles) {
501
- const basename7 = path5.basename(relFile);
502
- if (basename7.includes(".test.") || basename7.includes(".spec.") || basename7.startsWith("index.") || basename7.endsWith(".d.ts")) {
503
- continue;
504
- }
505
- const ext = path5.extname(basename7);
506
- if (!SOURCE_EXTS2.has(ext)) continue;
507
- const stem = basename7.slice(0, basename7.indexOf("."));
508
- const expectedTestFile = `${stem}${testSuffix}`;
509
- const dir = path5.dirname(path5.join(projectRoot, relFile));
510
- const colocatedTest = path5.join(dir, expectedTestFile);
511
- const testsDir = config.structure.tests;
512
- const dedicatedTest = testsDir ? path5.join(projectRoot, testsDir, expectedTestFile) : null;
513
- const hasTest = fs5.existsSync(colocatedTest) || dedicatedTest !== null && fs5.existsSync(dedicatedTest);
514
- if (!hasTest) {
515
- violations.push({
516
- file: relFile,
517
- rule: "missing-test",
518
- message: `No test file found. Expected \`${expectedTestFile}\`.`,
519
- severity
520
- });
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
+ }
521
1064
  }
522
1065
  }
523
1066
  return violations;
524
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
+ }
525
1077
 
526
1078
  // src/commands/check.ts
527
1079
  var CONFIG_FILE2 = "viberails.config.json";
528
1080
  function isTestFile(relPath) {
529
- const filename = path6.basename(relPath);
1081
+ const filename = path7.basename(relPath);
530
1082
  return filename.includes(".test.") || filename.includes(".spec.") || filename.startsWith("test.") || filename.startsWith("spec.") || relPath.includes("__tests__/") || relPath.includes("__test__/");
531
1083
  }
532
1084
  function printGroupedViolations(violations, limit) {
@@ -536,7 +1088,13 @@ function printGroupedViolations(violations, limit) {
536
1088
  existing.push(v);
537
1089
  groups.set(v.rule, existing);
538
1090
  }
539
- 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
+ ];
540
1098
  const sortedKeys = [...groups.keys()].sort(
541
1099
  (a, b) => (ruleOrder.indexOf(a) === -1 ? 99 : ruleOrder.indexOf(a)) - (ruleOrder.indexOf(b) === -1 ? 99 : ruleOrder.indexOf(b))
542
1100
  );
@@ -576,34 +1134,43 @@ async function checkCommand(options, cwd) {
576
1134
  console.error(`${import_chalk2.default.red("Error:")} No package.json found. Are you in a JS/TS project?`);
577
1135
  return 1;
578
1136
  }
579
- const configPath = path6.join(projectRoot, CONFIG_FILE2);
580
- if (!fs6.existsSync(configPath)) {
1137
+ const configPath = path7.join(projectRoot, CONFIG_FILE2);
1138
+ if (!fs7.existsSync(configPath)) {
581
1139
  console.error(
582
1140
  `${import_chalk2.default.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
583
1141
  );
584
1142
  return 1;
585
1143
  }
586
- const config = await (0, import_config2.loadConfig)(configPath);
1144
+ const config = await (0, import_config4.loadConfig)(configPath);
587
1145
  let filesToCheck;
1146
+ let diffAddedFiles = null;
588
1147
  if (options.staged) {
589
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);
590
1153
  } else if (options.files && options.files.length > 0) {
591
1154
  filesToCheck = options.files;
592
1155
  } else {
593
1156
  filesToCheck = getAllSourceFiles(projectRoot, config);
594
1157
  }
595
1158
  if (filesToCheck.length === 0) {
596
- console.log(`${import_chalk2.default.green("\u2713")} No files to check.`);
1159
+ if (options.format === "json") {
1160
+ console.log(JSON.stringify({ violations: [], checkedFiles: 0 }));
1161
+ } else {
1162
+ console.log(`${import_chalk2.default.green("\u2713")} No files to check.`);
1163
+ }
597
1164
  return 0;
598
1165
  }
599
1166
  const violations = [];
600
- const severity = config.enforcement === "enforce" ? "error" : "warn";
1167
+ const severity = options.enforce ? "error" : "warn";
601
1168
  for (const file of filesToCheck) {
602
- const absPath = path6.isAbsolute(file) ? file : path6.join(projectRoot, file);
603
- const relPath = path6.relative(projectRoot, absPath);
1169
+ const absPath = path7.isAbsolute(file) ? file : path7.join(projectRoot, file);
1170
+ const relPath = path7.relative(projectRoot, absPath);
604
1171
  const effectiveIgnore = resolveIgnoreForFile(relPath, config);
605
1172
  if (isIgnored(relPath, effectiveIgnore)) continue;
606
- if (!fs6.existsSync(absPath)) continue;
1173
+ if (!fs7.existsSync(absPath)) continue;
607
1174
  const resolved = resolveConfigForFile(relPath, config);
608
1175
  const testFile = isTestFile(relPath);
609
1176
  const maxLines = testFile ? resolved.rules.maxTestFileLines : resolved.rules.maxFileLines;
@@ -630,23 +1197,34 @@ async function checkCommand(options, cwd) {
630
1197
  }
631
1198
  }
632
1199
  }
633
- if (config.rules.requireTests && !options.staged && !options.files) {
1200
+ if (!options.staged && !options.files) {
634
1201
  const testViolations = checkMissingTests(projectRoot, config, severity);
635
- 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);
636
1214
  }
637
1215
  if (config.rules.enforceBoundaries && config.boundaries && Object.keys(config.boundaries.deny).length > 0 && !options.noBoundaries) {
638
1216
  const startTime = Date.now();
639
1217
  const { buildImportGraph, checkBoundaries } = await import("@viberails/graph");
640
- const packages = config.workspace ? resolveWorkspacePackages(projectRoot, config.workspace) : void 0;
1218
+ const packages = config.packages.length > 1 ? resolveWorkspacePackages(projectRoot, config.packages) : void 0;
641
1219
  const graph = await buildImportGraph(projectRoot, {
642
1220
  packages,
643
1221
  ignore: config.ignore
644
1222
  });
645
1223
  const boundaryViolations = checkBoundaries(graph, config.boundaries);
646
- 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;
647
1225
  for (const bv of boundaryViolations) {
648
1226
  if (filterSet && !filterSet.has(bv.file)) continue;
649
- const relFile = path6.relative(projectRoot, bv.file);
1227
+ const relFile = path7.relative(projectRoot, bv.file);
650
1228
  violations.push({
651
1229
  file: relFile,
652
1230
  rule: "boundary-violation",
@@ -655,17 +1233,18 @@ async function checkCommand(options, cwd) {
655
1233
  });
656
1234
  }
657
1235
  const elapsed = Date.now() - startTime;
658
- console.log(import_chalk2.default.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
1236
+ if (options.format !== "json") {
1237
+ console.log(import_chalk2.default.dim(` Boundary check: ${graph.nodes.length} files in ${elapsed}ms`));
1238
+ }
659
1239
  }
660
1240
  if (options.format === "json") {
661
1241
  console.log(
662
1242
  JSON.stringify({
663
1243
  violations,
664
- checkedFiles: filesToCheck.length,
665
- enforcement: config.enforcement
1244
+ checkedFiles: filesToCheck.length
666
1245
  })
667
1246
  );
668
- return config.enforcement === "enforce" && violations.length > 0 ? 1 : 0;
1247
+ return options.enforce && violations.length > 0 ? 1 : 0;
669
1248
  }
670
1249
  if (violations.length === 0) {
671
1250
  console.log(`${import_chalk2.default.green("\u2713")} ${filesToCheck.length} files checked \u2014 no violations`);
@@ -675,21 +1254,67 @@ async function checkCommand(options, cwd) {
675
1254
  printGroupedViolations(violations, options.limit);
676
1255
  }
677
1256
  printSummary(violations);
678
- if (config.enforcement === "enforce") {
1257
+ if (options.enforce) {
679
1258
  console.log(import_chalk2.default.red("Fix violations before committing."));
680
1259
  return 1;
681
1260
  }
682
1261
  return 0;
683
1262
  }
684
1263
 
1264
+ // src/commands/check-hook.ts
1265
+ var fs8 = __toESM(require("fs"), 1);
1266
+ function parseHookFilePath(input) {
1267
+ try {
1268
+ if (!input.trim()) return void 0;
1269
+ const parsed = JSON.parse(input);
1270
+ return parsed?.tool_input?.file_path ?? void 0;
1271
+ } catch {
1272
+ return void 0;
1273
+ }
1274
+ }
1275
+ function readStdin() {
1276
+ try {
1277
+ return fs8.readFileSync(0, "utf-8");
1278
+ } catch {
1279
+ return "";
1280
+ }
1281
+ }
1282
+ async function hookCheckCommand(cwd) {
1283
+ try {
1284
+ const filePath = parseHookFilePath(readStdin());
1285
+ if (!filePath) return 0;
1286
+ const originalWrite = process.stdout.write.bind(process.stdout);
1287
+ let captured = "";
1288
+ process.stdout.write = (chunk) => {
1289
+ captured += typeof chunk === "string" ? chunk : chunk.toString();
1290
+ return true;
1291
+ };
1292
+ try {
1293
+ await checkCommand({ files: [filePath], format: "json" }, cwd);
1294
+ } finally {
1295
+ process.stdout.write = originalWrite;
1296
+ }
1297
+ if (!captured.trim()) return 0;
1298
+ const result = JSON.parse(captured);
1299
+ if (result.violations?.length > 0) {
1300
+ process.stderr.write(`${captured.trim()}
1301
+ `);
1302
+ return 2;
1303
+ }
1304
+ return 0;
1305
+ } catch {
1306
+ return 0;
1307
+ }
1308
+ }
1309
+
685
1310
  // src/commands/fix.ts
686
- var fs9 = __toESM(require("fs"), 1);
687
- var path10 = __toESM(require("path"), 1);
688
- 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");
689
1314
  var import_chalk4 = __toESM(require("chalk"), 1);
690
1315
 
691
1316
  // src/commands/fix-helpers.ts
692
- var import_node_child_process2 = require("child_process");
1317
+ var import_node_child_process3 = require("child_process");
693
1318
  var import_chalk3 = __toESM(require("chalk"), 1);
694
1319
  function printPlan(renames, stubs) {
695
1320
  if (renames.length > 0) {
@@ -707,9 +1332,10 @@ function printPlan(renames, stubs) {
707
1332
  }
708
1333
  function checkGitDirty(projectRoot) {
709
1334
  try {
710
- const output = (0, import_node_child_process2.execSync)("git status --porcelain", {
1335
+ const output = (0, import_node_child_process3.execSync)("git status --porcelain", {
711
1336
  cwd: projectRoot,
712
- encoding: "utf-8"
1337
+ encoding: "utf-8",
1338
+ stdio: ["ignore", "pipe", "ignore"]
713
1339
  });
714
1340
  return output.trim().length > 0;
715
1341
  } catch {
@@ -718,14 +1344,11 @@ function checkGitDirty(projectRoot) {
718
1344
  }
719
1345
  function getConventionValue(convention) {
720
1346
  if (typeof convention === "string") return convention;
721
- if (convention && typeof convention === "object" && "value" in convention) {
722
- return convention.value;
723
- }
724
1347
  return void 0;
725
1348
  }
726
1349
 
727
1350
  // src/commands/fix-imports.ts
728
- var path7 = __toESM(require("path"), 1);
1351
+ var path8 = __toESM(require("path"), 1);
729
1352
  function stripExtension(filePath) {
730
1353
  return filePath.replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
731
1354
  }
@@ -743,7 +1366,7 @@ async function updateImportsAfterRenames(renames, projectRoot) {
743
1366
  const renameMap = /* @__PURE__ */ new Map();
744
1367
  for (const r of renames) {
745
1368
  const oldStripped = stripExtension(r.oldAbsPath);
746
- const newFilename = path7.basename(r.newPath);
1369
+ const newFilename = path8.basename(r.newPath);
747
1370
  const newName = newFilename.slice(0, newFilename.indexOf("."));
748
1371
  renameMap.set(oldStripped, { newBare: newName });
749
1372
  }
@@ -751,14 +1374,14 @@ async function updateImportsAfterRenames(renames, projectRoot) {
751
1374
  tsConfigFilePath: void 0,
752
1375
  skipAddingFilesFromTsConfig: true
753
1376
  });
754
- project.addSourceFilesAtPaths(path7.join(projectRoot, "**/*.{ts,tsx,js,jsx,mjs,cjs}"));
1377
+ project.addSourceFilesAtPaths(path8.join(projectRoot, "**/*.{ts,tsx,js,jsx,mjs,cjs}"));
755
1378
  const updates = [];
756
1379
  const extensions = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
757
1380
  for (const sourceFile of project.getSourceFiles()) {
758
1381
  const filePath = sourceFile.getFilePath();
759
- const segments = filePath.split(path7.sep);
1382
+ const segments = filePath.split(path8.sep);
760
1383
  if (segments.includes("node_modules") || segments.includes("dist")) continue;
761
- const fileDir = path7.dirname(filePath);
1384
+ const fileDir = path8.dirname(filePath);
762
1385
  for (const decl of sourceFile.getImportDeclarations()) {
763
1386
  const specifier = decl.getModuleSpecifierValue();
764
1387
  if (!specifier.startsWith(".")) continue;
@@ -815,7 +1438,7 @@ async function updateImportsAfterRenames(renames, projectRoot) {
815
1438
  }
816
1439
  function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
817
1440
  const cleanSpec = specifier.endsWith(".js") ? specifier.slice(0, -3) : specifier;
818
- const resolved = path7.resolve(fromDir, cleanSpec);
1441
+ const resolved = path8.resolve(fromDir, cleanSpec);
819
1442
  for (const ext of extensions) {
820
1443
  const candidate = resolved + ext;
821
1444
  const stripped = stripExtension(candidate);
@@ -826,8 +1449,8 @@ function resolveToRenamedFile(specifier, fromDir, renameMap, extensions) {
826
1449
  }
827
1450
 
828
1451
  // src/commands/fix-naming.ts
829
- var fs7 = __toESM(require("fs"), 1);
830
- var path8 = __toESM(require("path"), 1);
1452
+ var fs9 = __toESM(require("fs"), 1);
1453
+ var path9 = __toESM(require("path"), 1);
831
1454
 
832
1455
  // src/commands/convert-name.ts
833
1456
  function splitIntoWords(name) {
@@ -876,8 +1499,8 @@ function capitalize(word) {
876
1499
 
877
1500
  // src/commands/fix-naming.ts
878
1501
  function computeRename(relPath, targetConvention, projectRoot) {
879
- const filename = path8.basename(relPath);
880
- const dir = path8.dirname(relPath);
1502
+ const filename = path9.basename(relPath);
1503
+ const dir = path9.dirname(relPath);
881
1504
  const dotIndex = filename.indexOf(".");
882
1505
  if (dotIndex === -1) return null;
883
1506
  const bare = filename.slice(0, dotIndex);
@@ -885,15 +1508,15 @@ function computeRename(relPath, targetConvention, projectRoot) {
885
1508
  const newBare = convertName(bare, targetConvention);
886
1509
  if (newBare === bare) return null;
887
1510
  const newFilename = newBare + suffix;
888
- const newRelPath = path8.join(dir, newFilename);
889
- const oldAbsPath = path8.join(projectRoot, relPath);
890
- const newAbsPath = path8.join(projectRoot, newRelPath);
891
- if (fs7.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;
892
1515
  return { oldPath: relPath, newPath: newRelPath, oldAbsPath, newAbsPath };
893
1516
  }
894
1517
  function executeRename(rename) {
895
- if (fs7.existsSync(rename.newAbsPath)) return false;
896
- fs7.renameSync(rename.oldAbsPath, rename.newAbsPath);
1518
+ if (fs9.existsSync(rename.newAbsPath)) return false;
1519
+ fs9.renameSync(rename.oldAbsPath, rename.newAbsPath);
897
1520
  return true;
898
1521
  }
899
1522
  function deduplicateRenames(renames) {
@@ -908,33 +1531,38 @@ function deduplicateRenames(renames) {
908
1531
  }
909
1532
 
910
1533
  // src/commands/fix-tests.ts
911
- var fs8 = __toESM(require("fs"), 1);
912
- var path9 = __toESM(require("path"), 1);
1534
+ var fs10 = __toESM(require("fs"), 1);
1535
+ var path10 = __toESM(require("path"), 1);
913
1536
  function generateTestStub(sourceRelPath, config, projectRoot) {
914
- const { testPattern } = config.structure;
1537
+ const pkg = resolvePackageForFile(sourceRelPath, config);
1538
+ const testPattern = pkg?.structure?.testPattern;
915
1539
  if (!testPattern) return null;
916
- const basename7 = path9.basename(sourceRelPath);
917
- 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);
918
1544
  const testSuffix = testPattern.replace("*", "");
919
1545
  const testFilename = `${stem}${testSuffix}`;
920
- const dir = path9.dirname(path9.join(projectRoot, sourceRelPath));
921
- const testAbsPath = path9.join(dir, testFilename);
922
- if (fs8.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;
923
1549
  return {
924
- path: path9.relative(projectRoot, testAbsPath),
1550
+ path: path10.relative(projectRoot, testAbsPath),
925
1551
  absPath: testAbsPath,
926
1552
  moduleName: stem
927
1553
  };
928
1554
  }
929
1555
  function writeTestStub(stub, config) {
930
- 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";
931
1559
  const importLine = runner === "jest" ? "" : "import { describe, it, expect } from 'vitest';\n\n";
932
1560
  const content = `${importLine}describe('${stub.moduleName}', () => {
933
1561
  it.todo('add tests');
934
1562
  });
935
1563
  `;
936
- fs8.mkdirSync(path9.dirname(stub.absPath), { recursive: true });
937
- fs8.writeFileSync(stub.absPath, content);
1564
+ fs10.mkdirSync(path10.dirname(stub.absPath), { recursive: true });
1565
+ fs10.writeFileSync(stub.absPath, content);
938
1566
  }
939
1567
 
940
1568
  // src/commands/fix.ts
@@ -946,14 +1574,14 @@ async function fixCommand(options, cwd) {
946
1574
  console.error(`${import_chalk4.default.red("Error:")} No package.json found. Are you in a JS/TS project?`);
947
1575
  return 1;
948
1576
  }
949
- const configPath = path10.join(projectRoot, CONFIG_FILE3);
950
- if (!fs9.existsSync(configPath)) {
1577
+ const configPath = path11.join(projectRoot, CONFIG_FILE3);
1578
+ if (!fs11.existsSync(configPath)) {
951
1579
  console.error(
952
1580
  `${import_chalk4.default.red("Error:")} No viberails.config.json found. Run \`viberails init\` first.`
953
1581
  );
954
1582
  return 1;
955
1583
  }
956
- const config = await (0, import_config3.loadConfig)(configPath);
1584
+ const config = await (0, import_config5.loadConfig)(configPath);
957
1585
  if (!options.dryRun) {
958
1586
  const isDirty = checkGitDirty(projectRoot);
959
1587
  if (isDirty) {
@@ -980,7 +1608,7 @@ async function fixCommand(options, cwd) {
980
1608
  }
981
1609
  const dedupedRenames = deduplicateRenames(renames);
982
1610
  const testStubs = [];
983
- if (shouldFixTests && config.rules.requireTests) {
1611
+ if (shouldFixTests) {
984
1612
  const testViolations = checkMissingTests(projectRoot, config, "warn");
985
1613
  for (const v of testViolations) {
986
1614
  const stub = generateTestStub(v.file, config, projectRoot);
@@ -1011,13 +1639,13 @@ async function fixCommand(options, cwd) {
1011
1639
  }
1012
1640
  let importUpdateCount = 0;
1013
1641
  if (renameCount > 0) {
1014
- const appliedRenames = dedupedRenames.filter((r) => fs9.existsSync(r.newAbsPath));
1642
+ const appliedRenames = dedupedRenames.filter((r) => fs11.existsSync(r.newAbsPath));
1015
1643
  const updates = await updateImportsAfterRenames(appliedRenames, projectRoot);
1016
1644
  importUpdateCount = updates.length;
1017
1645
  }
1018
1646
  let stubCount = 0;
1019
1647
  for (const stub of testStubs) {
1020
- if (!fs9.existsSync(stub.absPath)) {
1648
+ if (!fs11.existsSync(stub.absPath)) {
1021
1649
  writeTestStub(stub, config);
1022
1650
  stubCount++;
1023
1651
  }
@@ -1038,15 +1666,16 @@ async function fixCommand(options, cwd) {
1038
1666
  }
1039
1667
 
1040
1668
  // src/commands/init.ts
1041
- var fs12 = __toESM(require("fs"), 1);
1042
- var path13 = __toESM(require("path"), 1);
1043
- var clack2 = __toESM(require("@clack/prompts"), 1);
1044
- 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");
1045
1673
  var import_scanner = require("@viberails/scanner");
1046
- var import_chalk8 = __toESM(require("chalk"), 1);
1674
+ var import_chalk10 = __toESM(require("chalk"), 1);
1047
1675
 
1048
- // src/display-text.ts
1676
+ // src/display.ts
1049
1677
  var import_types4 = require("@viberails/types");
1678
+ var import_chalk6 = __toESM(require("chalk"), 1);
1050
1679
 
1051
1680
  // src/display-helpers.ts
1052
1681
  var import_types = require("@viberails/types");
@@ -1097,20 +1726,130 @@ function formatRoleGroup(group) {
1097
1726
  return `${group.label} \u2014 ${dirs} (${files})`;
1098
1727
  }
1099
1728
 
1100
- // src/display.ts
1729
+ // src/display-monorepo.ts
1101
1730
  var import_types3 = require("@viberails/types");
1102
- var import_chalk6 = __toESM(require("chalk"), 1);
1731
+ var import_chalk5 = __toESM(require("chalk"), 1);
1103
1732
 
1104
- // src/display-monorepo.ts
1733
+ // src/display-text.ts
1105
1734
  var import_types2 = require("@viberails/types");
1106
- 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
1107
1846
  function formatPackageSummary(pkg) {
1108
1847
  const parts = [];
1109
1848
  if (pkg.stack.framework) {
1110
- parts.push(formatItem(pkg.stack.framework, import_types2.FRAMEWORK_NAMES));
1849
+ parts.push(formatItem(pkg.stack.framework, import_types3.FRAMEWORK_NAMES));
1111
1850
  }
1112
1851
  if (pkg.stack.styling) {
1113
- parts.push(formatItem(pkg.stack.styling, import_types2.STYLING_NAMES));
1852
+ parts.push(formatItem(pkg.stack.styling, import_types3.STYLING_NAMES));
1114
1853
  }
1115
1854
  const files = `${pkg.statistics.totalFiles} files`;
1116
1855
  const detail = parts.length > 0 ? `${parts.join(", ")} (${files})` : `(${files})`;
@@ -1124,11 +1863,15 @@ ${import_chalk5.default.bold(`Detected: (monorepo, ${packages.length} packages)`
1124
1863
  if (stack.packageManager) {
1125
1864
  console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.packageManager)}`);
1126
1865
  }
1127
- if (stack.linter) {
1128
- console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.linter)}`);
1129
- }
1130
- if (stack.formatter) {
1131
- 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
+ }
1132
1875
  }
1133
1876
  if (stack.testRunner) {
1134
1877
  console.log(` ${import_chalk5.default.green("\u2713")} ${formatItem(stack.testRunner)}`);
@@ -1159,23 +1902,27 @@ ${import_chalk5.default.bold("Structure:")}`);
1159
1902
  function formatPackageSummaryPlain(pkg) {
1160
1903
  const parts = [];
1161
1904
  if (pkg.stack.framework) {
1162
- parts.push(formatItem(pkg.stack.framework, import_types2.FRAMEWORK_NAMES));
1905
+ parts.push(formatItem(pkg.stack.framework, import_types3.FRAMEWORK_NAMES));
1163
1906
  }
1164
1907
  if (pkg.stack.styling) {
1165
- parts.push(formatItem(pkg.stack.styling, import_types2.STYLING_NAMES));
1908
+ parts.push(formatItem(pkg.stack.styling, import_types3.STYLING_NAMES));
1166
1909
  }
1167
1910
  const files = `${pkg.statistics.totalFiles} files`;
1168
1911
  const detail = parts.length > 0 ? `${parts.join(", ")} (${files})` : `(${files})`;
1169
1912
  return ` ${pkg.relativePath} \u2014 ${detail}`;
1170
1913
  }
1171
- function formatMonorepoResultsText(scanResult, config) {
1914
+ function formatMonorepoResultsText(scanResult) {
1172
1915
  const lines = [];
1173
1916
  const { stack, packages } = scanResult;
1174
1917
  lines.push(`Detected: (monorepo, ${packages.length} packages)`);
1175
1918
  const sharedParts = [formatItem(stack.language)];
1176
1919
  if (stack.packageManager) sharedParts.push(formatItem(stack.packageManager));
1177
- if (stack.linter) sharedParts.push(formatItem(stack.linter));
1178
- 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
+ }
1179
1926
  if (stack.testRunner) sharedParts.push(formatItem(stack.testRunner));
1180
1927
  lines.push(` \u2713 ${sharedParts.join(" \xB7 ")}`);
1181
1928
  lines.push("");
@@ -1205,7 +1952,6 @@ function formatMonorepoResultsText(scanResult, config) {
1205
1952
  if (ext) {
1206
1953
  lines.push(ext);
1207
1954
  }
1208
- lines.push(...formatRulesText(config));
1209
1955
  return lines.join("\n");
1210
1956
  }
1211
1957
 
@@ -1228,7 +1974,7 @@ function displayConventions(scanResult) {
1228
1974
  ${import_chalk6.default.bold("Conventions:")}`);
1229
1975
  for (const [key, convention] of conventionEntries) {
1230
1976
  if (convention.confidence === "low") continue;
1231
- const label = import_types3.CONVENTION_LABELS[key] ?? key;
1977
+ const label = import_types4.CONVENTION_LABELS[key] ?? key;
1232
1978
  if (scanResult.packages.length > 1) {
1233
1979
  const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1234
1980
  const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
@@ -1269,23 +2015,27 @@ function displayScanResults(scanResult) {
1269
2015
  console.log(`
1270
2016
  ${import_chalk6.default.bold("Detected:")}`);
1271
2017
  if (stack.framework) {
1272
- 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)}`);
1273
2019
  }
1274
2020
  console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.language)}`);
1275
2021
  if (stack.styling) {
1276
- 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)}`);
1277
2023
  }
1278
2024
  if (stack.backend) {
1279
- 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)}`);
1280
2026
  }
1281
2027
  if (stack.orm) {
1282
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.orm, import_types3.ORM_NAMES)}`);
2028
+ console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.orm, import_types4.ORM_NAMES)}`);
1283
2029
  }
1284
- if (stack.linter) {
1285
- console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.linter)}`);
1286
- }
1287
- if (stack.formatter) {
1288
- 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
+ }
1289
2039
  }
1290
2040
  if (stack.testRunner) {
1291
2041
  console.log(` ${import_chalk6.default.green("\u2713")} ${formatItem(stack.testRunner)}`);
@@ -1295,7 +2045,7 @@ ${import_chalk6.default.bold("Detected:")}`);
1295
2045
  }
1296
2046
  if (stack.libraries.length > 0) {
1297
2047
  for (const lib of stack.libraries) {
1298
- 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)}`);
1299
2049
  }
1300
2050
  }
1301
2051
  const groups = groupByRole(scanResult.structure.directories);
@@ -1310,25 +2060,23 @@ ${import_chalk6.default.bold("Structure:")}`);
1310
2060
  displaySummarySection(scanResult);
1311
2061
  console.log("");
1312
2062
  }
1313
- function getConventionStr(cv) {
1314
- return typeof cv === "string" ? cv : cv.value;
1315
- }
1316
2063
  function displayRulesPreview(config) {
1317
- 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
+ );
1318
2068
  console.log(` ${import_chalk6.default.dim("\u2022")} Max file size: ${config.rules.maxFileLines} lines`);
1319
- if (config.rules.requireTests && config.structure.testPattern) {
2069
+ if (config.rules.testCoverage > 0 && root?.structure?.testPattern) {
1320
2070
  console.log(
1321
- ` ${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})`
1322
2072
  );
1323
- } else if (config.rules.requireTests) {
1324
- 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}%`);
1325
2075
  } else {
1326
- console.log(` ${import_chalk6.default.dim("\u2022")} Require test files: no`);
2076
+ console.log(` ${import_chalk6.default.dim("\u2022")} Test coverage target: disabled`);
1327
2077
  }
1328
- if (config.rules.enforceNaming && config.conventions.fileNaming) {
1329
- console.log(
1330
- ` ${import_chalk6.default.dim("\u2022")} Enforce file naming: ${getConventionStr(config.conventions.fileNaming)}`
1331
- );
2078
+ if (config.rules.enforceNaming && root?.conventions?.fileNaming) {
2079
+ console.log(` ${import_chalk6.default.dim("\u2022")} Enforce file naming: ${root.conventions.fileNaming}`);
1332
2080
  } else {
1333
2081
  console.log(` ${import_chalk6.default.dim("\u2022")} Enforce file naming: no`);
1334
2082
  }
@@ -1336,146 +2084,163 @@ function displayRulesPreview(config) {
1336
2084
  ` ${import_chalk6.default.dim("\u2022")} Enforce boundaries: ${config.rules.enforceBoundaries ? "yes" : "no"}`
1337
2085
  );
1338
2086
  console.log("");
1339
- if (config.enforcement === "enforce") {
1340
- console.log(`${import_chalk6.default.bold("Enforcement mode:")} enforce (violations will block commits)`);
1341
- } else {
1342
- console.log(
1343
- `${import_chalk6.default.bold("Enforcement mode:")} warn (violations shown but won't block commits)`
1344
- );
1345
- }
1346
- console.log("");
1347
2087
  }
1348
2088
 
1349
- // src/display-text.ts
1350
- function getConventionStr2(cv) {
1351
- return typeof cv === "string" ? cv : cv.value;
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
+ ];
2113
+ }
2114
+ return [];
1352
2115
  }
1353
- function plainConfidenceLabel(convention) {
1354
- const pct = Math.round(convention.consistency);
1355
- if (convention.confidence === "high") {
1356
- return `${pct}%`;
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
+ }
1357
2123
  }
1358
- return `${pct}%, suggested only`;
1359
2124
  }
1360
- function formatConventionsText(scanResult) {
1361
- const lines = [];
1362
- const conventionEntries = Object.entries(scanResult.conventions);
1363
- if (conventionEntries.length === 0) return lines;
1364
- lines.push("");
1365
- lines.push("Conventions:");
1366
- for (const [key, convention] of conventionEntries) {
1367
- if (convention.confidence === "low") continue;
1368
- const label = import_types4.CONVENTION_LABELS[key] ?? key;
1369
- if (scanResult.packages.length > 1) {
1370
- const pkgValues = scanResult.packages.filter((pkg) => pkg.conventions[key] && pkg.conventions[key].confidence !== "low").map((pkg) => ({ relativePath: pkg.relativePath, convention: pkg.conventions[key] }));
1371
- const allSame = pkgValues.every((pv) => pv.convention.value === convention.value);
1372
- if (allSame || pkgValues.length <= 1) {
1373
- const ind = convention.confidence === "high" ? "\u2713" : "~";
1374
- lines.push(` ${ind} ${label}: ${convention.value} (${plainConfidenceLabel(convention)})`);
1375
- } else {
1376
- lines.push(` ~ ${label}: varies by package`);
1377
- for (const pv of pkgValues) {
1378
- const pct = Math.round(pv.convention.consistency);
1379
- lines.push(` ${pv.relativePath}: ${pv.convention.value} (${pct}%)`);
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}`
1380
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
+ );
1381
2173
  }
2174
+ } else if (choice === "disable") {
2175
+ disableCoverage = true;
2176
+ clack6.log.info("Coverage percentage checks disabled. Missing-test checks remain active.");
1382
2177
  } else {
1383
- const ind = convention.confidence === "high" ? "\u2713" : "~";
1384
- 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
+ );
1385
2182
  }
1386
2183
  }
1387
- return lines;
2184
+ return { disableCoverage };
1388
2185
  }
1389
- function formatRulesText(config) {
1390
- const lines = [];
1391
- lines.push("");
1392
- lines.push("Rules:");
1393
- lines.push(` \u2022 Max file size: ${config.rules.maxFileLines} lines`);
1394
- if (config.rules.requireTests && config.structure.testPattern) {
1395
- lines.push(` \u2022 Require test files: yes (${config.structure.testPattern})`);
1396
- } else if (config.rules.requireTests) {
1397
- lines.push(" \u2022 Require test files: yes");
1398
- } else {
1399
- lines.push(" \u2022 Require test files: no");
1400
- }
1401
- if (config.rules.enforceNaming && config.conventions.fileNaming) {
1402
- lines.push(` \u2022 Enforce file naming: ${getConventionStr2(config.conventions.fileNaming)}`);
1403
- } else {
1404
- 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;
1405
2193
  }
1406
- lines.push(` \u2022 Enforcement mode: ${config.enforcement}`);
1407
- return lines;
1408
2194
  }
1409
- function formatScanResultsText(scanResult, config) {
1410
- if (scanResult.packages.length > 1) {
1411
- return formatMonorepoResultsText(scanResult, config);
1412
- }
1413
- const lines = [];
1414
- const { stack } = scanResult;
1415
- lines.push("Detected:");
1416
- if (stack.framework) {
1417
- lines.push(` \u2713 ${formatItem(stack.framework, import_types4.FRAMEWORK_NAMES)}`);
1418
- }
1419
- lines.push(` \u2713 ${formatItem(stack.language)}`);
1420
- if (stack.styling) {
1421
- lines.push(` \u2713 ${formatItem(stack.styling, import_types4.STYLING_NAMES)}`);
1422
- }
1423
- if (stack.backend) {
1424
- lines.push(` \u2713 ${formatItem(stack.backend, import_types4.FRAMEWORK_NAMES)}`);
1425
- }
1426
- if (stack.orm) {
1427
- lines.push(` \u2713 ${formatItem(stack.orm, import_types4.ORM_NAMES)}`);
1428
- }
1429
- const secondaryParts = [];
1430
- if (stack.packageManager) secondaryParts.push(formatItem(stack.packageManager));
1431
- if (stack.linter) secondaryParts.push(formatItem(stack.linter));
1432
- if (stack.formatter) secondaryParts.push(formatItem(stack.formatter));
1433
- if (stack.testRunner) secondaryParts.push(formatItem(stack.testRunner));
1434
- if (secondaryParts.length > 0) {
1435
- lines.push(` \u2713 ${secondaryParts.join(" \xB7 ")}`);
1436
- }
1437
- if (stack.libraries.length > 0) {
1438
- for (const lib of stack.libraries) {
1439
- 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;
1440
2205
  }
1441
2206
  }
1442
- const groups = groupByRole(scanResult.structure.directories);
1443
- if (groups.length > 0) {
1444
- lines.push("");
1445
- lines.push("Structure:");
1446
- for (const group of groups) {
1447
- lines.push(` \u2713 ${formatRoleGroup(group)}`);
1448
- }
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");
1449
2218
  }
1450
- lines.push(...formatConventionsText(scanResult));
1451
- const pkgCount = scanResult.packages.length > 1 ? scanResult.packages.length : void 0;
1452
- lines.push("");
1453
- lines.push(formatSummary(scanResult.statistics, pkgCount));
1454
- const ext = formatExtensions(scanResult.statistics.filesByExtension);
1455
- if (ext) {
1456
- 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}`);
1457
2224
  }
1458
- lines.push(...formatRulesText(config));
1459
- return lines.join("\n");
1460
2225
  }
1461
2226
 
1462
2227
  // src/utils/write-generated-files.ts
1463
- var fs10 = __toESM(require("fs"), 1);
1464
- var path11 = __toESM(require("path"), 1);
2228
+ var fs14 = __toESM(require("fs"), 1);
2229
+ var path14 = __toESM(require("path"), 1);
1465
2230
  var import_context = require("@viberails/context");
1466
2231
  var CONTEXT_DIR = ".viberails";
1467
2232
  var CONTEXT_FILE = "context.md";
1468
2233
  var SCAN_RESULT_FILE = "scan-result.json";
1469
2234
  function writeGeneratedFiles(projectRoot, config, scanResult) {
1470
- const contextDir = path11.join(projectRoot, CONTEXT_DIR);
2235
+ const contextDir = path14.join(projectRoot, CONTEXT_DIR);
1471
2236
  try {
1472
- if (!fs10.existsSync(contextDir)) {
1473
- fs10.mkdirSync(contextDir, { recursive: true });
2237
+ if (!fs14.existsSync(contextDir)) {
2238
+ fs14.mkdirSync(contextDir, { recursive: true });
1474
2239
  }
1475
2240
  const context = (0, import_context.generateContext)(config);
1476
- fs10.writeFileSync(path11.join(contextDir, CONTEXT_FILE), context);
1477
- fs10.writeFileSync(
1478
- 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),
1479
2244
  `${JSON.stringify(scanResult, null, 2)}
1480
2245
  `
1481
2246
  );
@@ -1486,38 +2251,41 @@ function writeGeneratedFiles(projectRoot, config, scanResult) {
1486
2251
  }
1487
2252
 
1488
2253
  // src/commands/init-hooks.ts
1489
- var fs11 = __toESM(require("fs"), 1);
1490
- var path12 = __toESM(require("path"), 1);
1491
- 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);
2257
+ var import_yaml = require("yaml");
1492
2258
  function setupPreCommitHook(projectRoot) {
1493
- const lefthookPath = path12.join(projectRoot, "lefthook.yml");
1494
- if (fs11.existsSync(lefthookPath)) {
2259
+ const lefthookPath = path15.join(projectRoot, "lefthook.yml");
2260
+ if (fs15.existsSync(lefthookPath)) {
1495
2261
  addLefthookPreCommit(lefthookPath);
1496
- console.log(` ${import_chalk7.default.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
1497
- return;
2262
+ console.log(` ${import_chalk8.default.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
2263
+ return "lefthook.yml";
1498
2264
  }
1499
- const huskyDir = path12.join(projectRoot, ".husky");
1500
- if (fs11.existsSync(huskyDir)) {
2265
+ const huskyDir = path15.join(projectRoot, ".husky");
2266
+ if (fs15.existsSync(huskyDir)) {
1501
2267
  writeHuskyPreCommit(huskyDir);
1502
- console.log(` ${import_chalk7.default.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
1503
- return;
1504
- }
1505
- const gitDir = path12.join(projectRoot, ".git");
1506
- if (fs11.existsSync(gitDir)) {
1507
- const hooksDir = path12.join(gitDir, "hooks");
1508
- if (!fs11.existsSync(hooksDir)) {
1509
- fs11.mkdirSync(hooksDir, { recursive: true });
2268
+ console.log(` ${import_chalk8.default.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
2269
+ return ".husky/pre-commit";
2270
+ }
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 });
1510
2276
  }
1511
2277
  writeGitHookPreCommit(hooksDir);
1512
- 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";
1513
2280
  }
2281
+ return void 0;
1514
2282
  }
1515
2283
  function writeGitHookPreCommit(hooksDir) {
1516
- const hookPath = path12.join(hooksDir, "pre-commit");
1517
- if (fs11.existsSync(hookPath)) {
1518
- const existing = fs11.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");
1519
2287
  if (existing.includes("viberails")) return;
1520
- fs11.writeFileSync(
2288
+ fs15.writeFileSync(
1521
2289
  hookPath,
1522
2290
  `${existing.trimEnd()}
1523
2291
 
@@ -1534,71 +2302,51 @@ npx viberails check --staged
1534
2302
  "npx viberails check --staged",
1535
2303
  ""
1536
2304
  ].join("\n");
1537
- fs11.writeFileSync(hookPath, script, { mode: 493 });
2305
+ fs15.writeFileSync(hookPath, script, { mode: 493 });
1538
2306
  }
1539
2307
  function addLefthookPreCommit(lefthookPath) {
1540
- const content = fs11.readFileSync(lefthookPath, "utf-8");
2308
+ const content = fs15.readFileSync(lefthookPath, "utf-8");
1541
2309
  if (content.includes("viberails")) return;
1542
- const hasPreCommit = /^pre-commit:/m.test(content);
1543
- if (hasPreCommit) {
1544
- const commandBlock = ["", " viberails:", " run: npx viberails check --staged"].join(
1545
- "\n"
1546
- );
1547
- const updated = `${content.trimEnd()}
1548
- ${commandBlock}
1549
- `;
1550
- fs11.writeFileSync(lefthookPath, updated);
1551
- } else {
1552
- const section = [
1553
- "",
1554
- "pre-commit:",
1555
- " commands:",
1556
- " viberails:",
1557
- " run: npx viberails check --staged"
1558
- ].join("\n");
1559
- fs11.writeFileSync(lefthookPath, `${content.trimEnd()}
1560
- ${section}
1561
- `);
2310
+ const doc = (0, import_yaml.parse)(content) ?? {};
2311
+ if (!doc["pre-commit"]) {
2312
+ doc["pre-commit"] = { commands: {} };
2313
+ }
2314
+ if (!doc["pre-commit"].commands) {
2315
+ doc["pre-commit"].commands = {};
1562
2316
  }
2317
+ doc["pre-commit"].commands.viberails = {
2318
+ run: "npx viberails check --staged"
2319
+ };
2320
+ fs15.writeFileSync(lefthookPath, (0, import_yaml.stringify)(doc));
1563
2321
  }
1564
2322
  function detectHookManager(projectRoot) {
1565
- if (fs11.existsSync(path12.join(projectRoot, "lefthook.yml"))) return "Lefthook";
1566
- if (fs11.existsSync(path12.join(projectRoot, ".husky"))) return "Husky";
1567
- if (fs11.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";
1568
2326
  return void 0;
1569
2327
  }
1570
2328
  function setupClaudeCodeHook(projectRoot) {
1571
- const claudeDir = path12.join(projectRoot, ".claude");
1572
- if (!fs11.existsSync(claudeDir)) {
1573
- fs11.mkdirSync(claudeDir, { recursive: true });
2329
+ const claudeDir = path15.join(projectRoot, ".claude");
2330
+ if (!fs15.existsSync(claudeDir)) {
2331
+ fs15.mkdirSync(claudeDir, { recursive: true });
1574
2332
  }
1575
- const settingsPath = path12.join(claudeDir, "settings.json");
2333
+ const settingsPath = path15.join(claudeDir, "settings.json");
1576
2334
  let settings = {};
1577
- if (fs11.existsSync(settingsPath)) {
2335
+ if (fs15.existsSync(settingsPath)) {
1578
2336
  try {
1579
- settings = JSON.parse(fs11.readFileSync(settingsPath, "utf-8"));
2337
+ settings = JSON.parse(fs15.readFileSync(settingsPath, "utf-8"));
1580
2338
  } catch {
1581
2339
  console.warn(
1582
- ` ${import_chalk7.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 resetting to add hook`
2340
+ ` ${import_chalk8.default.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
1583
2341
  );
1584
- settings = {};
2342
+ console.warn(` Fix the JSON manually, then re-run ${import_chalk8.default.cyan("viberails init --force")}`);
2343
+ return;
1585
2344
  }
1586
2345
  }
1587
2346
  const hooks = settings.hooks ?? {};
1588
2347
  const existing = hooks.PostToolUse ?? [];
1589
2348
  if (existing.some((h) => JSON.stringify(h).includes("viberails"))) return;
1590
- const extractFile = `node -e "try{process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).tool_input?.file_path??'')}catch{}"`;
1591
- const checkAndReport = [
1592
- `FILE=$(${extractFile})`,
1593
- 'if [ -z "$FILE" ]; then exit 0; fi',
1594
- 'OUTPUT=$(npx viberails check --files "$FILE" --format json 2>&1)',
1595
- `if echo "$OUTPUT" | node -e "process.exit(JSON.parse(require('fs').readFileSync(0,'utf8')).violations?.length?0:1)" 2>/dev/null; then`,
1596
- ' echo "$OUTPUT" >&2',
1597
- " exit 2",
1598
- "fi",
1599
- "exit 0"
1600
- ].join("\n");
1601
- const hookCommand = checkAndReport;
2349
+ const hookCommand = "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --hook; else npx viberails check --hook; fi";
1602
2350
  hooks.PostToolUse = [
1603
2351
  ...existing,
1604
2352
  {
@@ -1612,228 +2360,406 @@ function setupClaudeCodeHook(projectRoot) {
1612
2360
  }
1613
2361
  ];
1614
2362
  settings.hooks = hooks;
1615
- fs11.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
2363
+ fs15.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1616
2364
  `);
1617
- 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`);
1618
2366
  }
1619
2367
  function setupClaudeMdReference(projectRoot) {
1620
- const claudeMdPath = path12.join(projectRoot, "CLAUDE.md");
2368
+ const claudeMdPath = path15.join(projectRoot, "CLAUDE.md");
1621
2369
  let content = "";
1622
- if (fs11.existsSync(claudeMdPath)) {
1623
- content = fs11.readFileSync(claudeMdPath, "utf-8");
2370
+ if (fs15.existsSync(claudeMdPath)) {
2371
+ content = fs15.readFileSync(claudeMdPath, "utf-8");
1624
2372
  }
1625
2373
  if (content.includes("@.viberails/context.md")) return;
1626
2374
  const ref = "\n@.viberails/context.md\n";
1627
2375
  const prefix = content.length === 0 ? "" : content.trimEnd();
1628
- fs11.writeFileSync(claudeMdPath, prefix + ref);
1629
- 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";
1630
2422
  }
1631
2423
  function writeHuskyPreCommit(huskyDir) {
1632
- const hookPath = path12.join(huskyDir, "pre-commit");
1633
- if (fs11.existsSync(hookPath)) {
1634
- const existing = fs11.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");
1635
2427
  if (!existing.includes("viberails")) {
1636
- fs11.writeFileSync(hookPath, `${existing.trimEnd()}
2428
+ fs15.writeFileSync(hookPath, `${existing.trimEnd()}
1637
2429
  npx viberails check --staged
1638
2430
  `);
1639
2431
  }
1640
2432
  return;
1641
2433
  }
1642
- fs11.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
2434
+ fs15.writeFileSync(hookPath, "#!/bin/sh\nnpx viberails check --staged\n", { mode: 493 });
1643
2435
  }
1644
2436
 
1645
- // src/commands/init.ts
1646
- var CONFIG_FILE4 = "viberails.config.json";
1647
- function filterHighConfidence(conventions) {
1648
- const filtered = {};
1649
- for (const [key, value] of Object.entries(conventions)) {
1650
- if (value === void 0) continue;
1651
- if (typeof value === "string") {
1652
- filtered[key] = value;
1653
- } else if (value._confidence === "high") {
1654
- 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
+ });
1655
2492
  }
2493
+ return ".git/hooks/pre-commit";
1656
2494
  }
1657
- return filtered;
2495
+ return void 0;
1658
2496
  }
1659
- function getConventionStr3(cv) {
1660
- if (!cv) return void 0;
1661
- 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;
1662
2503
  }
1663
- function hasConventionOverrides(config) {
1664
- if (!config.packages || config.packages.length === 0) return false;
1665
- return config.packages.some((pkg) => pkg.conventions && Object.keys(pkg.conventions).length > 0);
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);
1666
2546
  }
1667
2547
  async function initCommand(options, cwd) {
1668
- const startDir = cwd ?? process.cwd();
1669
- const projectRoot = findProjectRoot(startDir);
2548
+ const projectRoot = findProjectRoot(cwd ?? process.cwd());
1670
2549
  if (!projectRoot) {
1671
2550
  throw new Error(
1672
- "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"
1673
2552
  );
1674
2553
  }
1675
- const configPath = path13.join(projectRoot, CONFIG_FILE4);
1676
- if (fs12.existsSync(configPath) && !options.force) {
2554
+ const configPath = path17.join(projectRoot, CONFIG_FILE4);
2555
+ if (fs17.existsSync(configPath) && !options.force) {
1677
2556
  console.log(
1678
- `${import_chalk8.default.yellow("!")} viberails is already initialized.
1679
- 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.`
1680
2559
  );
1681
2560
  return;
1682
2561
  }
1683
- if (options.yes) {
1684
- console.log(import_chalk8.default.dim("Scanning project..."));
1685
- const scanResult2 = await (0, import_scanner.scan)(projectRoot);
1686
- const config2 = (0, import_config4.generateConfig)(scanResult2);
1687
- config2.conventions = filterHighConfidence(config2.conventions);
1688
- displayScanResults(scanResult2);
1689
- displayRulesPreview(config2);
1690
- if (config2.workspace?.packages && config2.workspace.packages.length > 0) {
1691
- console.log(import_chalk8.default.dim("Building import graph..."));
1692
- const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
1693
- const packages = resolveWorkspacePackages(projectRoot, config2.workspace);
1694
- const graph = await buildImportGraph(projectRoot, {
1695
- packages,
1696
- ignore: config2.ignore
1697
- });
1698
- const inferred = inferBoundaries(graph);
1699
- const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1700
- if (denyCount > 0) {
1701
- config2.boundaries = inferred;
1702
- config2.rules.enforceBoundaries = true;
1703
- console.log(` Inferred ${denyCount} boundary rules`);
1704
- }
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`);
1705
2593
  }
1706
- fs12.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
2594
+ }
2595
+ const compacted = (0, import_config6.compactConfig)(config);
2596
+ fs17.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
1707
2597
  `);
1708
- writeGeneratedFiles(projectRoot, config2, scanResult2);
1709
- updateGitignore(projectRoot);
1710
- setupClaudeMdReference(projectRoot);
1711
- console.log(`
1712
- Created:`);
1713
- console.log(` ${import_chalk8.default.green("\u2713")} ${CONFIG_FILE4}`);
1714
- console.log(` ${import_chalk8.default.green("\u2713")} .viberails/context.md`);
1715
- console.log(` ${import_chalk8.default.green("\u2713")} .viberails/scan-result.json`);
1716
- 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
+ }
1717
2635
  }
1718
- clack2.intro("viberails");
1719
- const s = clack2.spinner();
2636
+ const s = clack7.spinner();
1720
2637
  s.start("Scanning project...");
1721
2638
  const scanResult = await (0, import_scanner.scan)(projectRoot);
1722
- const config = (0, import_config4.generateConfig)(scanResult);
2639
+ const config = (0, import_config6.generateConfig)(scanResult);
1723
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
+ }
1724
2648
  if (scanResult.statistics.totalFiles === 0) {
1725
- clack2.log.warn(
1726
- "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."
1727
2651
  );
1728
2652
  }
1729
- const resultsText = formatScanResultsText(scanResult, config);
1730
- 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");
1731
2659
  const decision = await promptInitDecision();
1732
2660
  if (decision === "customize") {
1733
- clack2.note(
1734
- "Rules control what viberails checks for.\nYou can change these later in viberails.config.json.",
1735
- "Rules"
1736
- );
1737
- const overrides = await promptRuleCustomization({
2661
+ const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2662
+ const overrides = await promptRuleMenu({
1738
2663
  maxFileLines: config.rules.maxFileLines,
1739
- requireTests: config.rules.requireTests,
2664
+ testCoverage: config.rules.testCoverage,
2665
+ enforceMissingTests: config.rules.enforceMissingTests,
1740
2666
  enforceNaming: config.rules.enforceNaming,
1741
- enforcement: config.enforcement,
1742
- fileNamingValue: getConventionStr3(config.conventions.fileNaming)
2667
+ fileNamingValue: rootPkg.conventions?.fileNaming,
2668
+ coverageSummaryPath: "coverage/coverage-summary.json",
2669
+ coverageCommand: config.defaults?.coverage?.command,
2670
+ packageOverrides: config.packages
1743
2671
  });
2672
+ if (overrides.packageOverrides) config.packages = overrides.packageOverrides;
1744
2673
  config.rules.maxFileLines = overrides.maxFileLines;
1745
- config.rules.requireTests = overrides.requireTests;
2674
+ config.rules.testCoverage = overrides.testCoverage;
2675
+ config.rules.enforceMissingTests = overrides.enforceMissingTests;
1746
2676
  config.rules.enforceNaming = overrides.enforceNaming;
1747
- config.enforcement = overrides.enforcement;
1748
- if (config.workspace?.packages && config.workspace.packages.length > 0) {
1749
- clack2.note(
1750
- 'These rules apply globally. To customize per package,\nedit the "packages" section in viberails.config.json.',
1751
- "Per-package overrides"
1752
- );
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
+ }
1753
2697
  }
1754
2698
  }
1755
- if (config.workspace?.packages && config.workspace.packages.length > 0) {
1756
- clack2.note(
2699
+ if (config.packages.length > 1) {
2700
+ clack7.note(
1757
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.",
1758
2702
  "Boundaries"
1759
2703
  );
1760
- const shouldInfer = await confirm2("Infer boundary rules from import patterns?");
2704
+ const shouldInfer = await confirm3("Infer boundary rules from import patterns?");
1761
2705
  if (shouldInfer) {
1762
- const bs = clack2.spinner();
2706
+ const bs = clack7.spinner();
1763
2707
  bs.start("Building import graph...");
1764
2708
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
1765
- const packages = resolveWorkspacePackages(projectRoot, config.workspace);
1766
- const graph = await buildImportGraph(projectRoot, {
1767
- packages,
1768
- ignore: config.ignore
1769
- });
2709
+ const packages = resolveWorkspacePackages(projectRoot, config.packages);
2710
+ const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
1770
2711
  const inferred = inferBoundaries(graph);
1771
2712
  const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
1772
2713
  if (denyCount > 0) {
1773
2714
  config.boundaries = inferred;
1774
2715
  config.rules.enforceBoundaries = true;
1775
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");
1776
2719
  } else {
1777
2720
  bs.stop("No boundary rules inferred");
1778
2721
  }
1779
2722
  }
1780
2723
  }
1781
2724
  const hookManager = detectHookManager(projectRoot);
1782
- const integrations = await promptIntegrations(hookManager);
1783
- if (hasConventionOverrides(config)) {
1784
- clack2.note(
1785
- "Some packages use different conventions. Per-package\noverrides have been saved in viberails.config.json \u2014\nreview and adjust as needed.",
1786
- "Per-package conventions"
1787
- );
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;
1788
2734
  }
1789
- fs12.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
2735
+ const compacted = (0, import_config6.compactConfig)(config);
2736
+ fs17.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
1790
2737
  `);
1791
2738
  writeGeneratedFiles(projectRoot, config, scanResult);
1792
2739
  updateGitignore(projectRoot);
1793
2740
  const createdFiles = [
1794
- CONFIG_FILE4,
2741
+ path17.basename(configPath),
1795
2742
  ".viberails/context.md",
1796
- ".viberails/scan-result.json"
2743
+ ".viberails/scan-result.json",
2744
+ ...setupSelectedIntegrations(projectRoot, integrations, {
2745
+ linter: rootPkgStack?.linter?.split("@")[0],
2746
+ packageManager: rootPkgStack?.packageManager
2747
+ })
1797
2748
  ];
1798
- if (integrations.preCommitHook) {
1799
- setupPreCommitHook(projectRoot);
1800
- const hookMgr = detectHookManager(projectRoot);
1801
- if (hookMgr) {
1802
- createdFiles.push(`lefthook.yml \u2014 added viberails pre-commit`);
1803
- }
1804
- }
1805
- if (integrations.claudeCodeHook) {
1806
- setupClaudeCodeHook(projectRoot);
1807
- createdFiles.push(".claude/settings.json \u2014 added viberails hook");
1808
- }
1809
- if (integrations.claudeMdRef) {
1810
- setupClaudeMdReference(projectRoot);
1811
- createdFiles.push("CLAUDE.md \u2014 added @.viberails/context.md reference");
1812
- }
1813
- clack2.log.success(`Created:
2749
+ clack7.log.success(`Created:
1814
2750
  ${createdFiles.map((f) => ` ${f}`).join("\n")}`);
1815
- clack2.outro("Done! Next: review viberails.config.json, then run viberails check");
1816
- }
1817
- function updateGitignore(projectRoot) {
1818
- const gitignorePath = path13.join(projectRoot, ".gitignore");
1819
- let content = "";
1820
- if (fs12.existsSync(gitignorePath)) {
1821
- content = fs12.readFileSync(gitignorePath, "utf-8");
1822
- }
1823
- if (!content.includes(".viberails/scan-result.json")) {
1824
- const block = "\n# viberails\n.viberails/scan-result.json\n";
1825
- const prefix = content.length === 0 ? "" : `${content.trimEnd()}
1826
- `;
1827
- fs12.writeFileSync(gitignorePath, `${prefix}${block}`);
1828
- }
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
+ );
1829
2755
  }
1830
2756
 
1831
2757
  // src/commands/sync.ts
1832
- var fs13 = __toESM(require("fs"), 1);
1833
- var path14 = __toESM(require("path"), 1);
1834
- 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");
1835
2761
  var import_scanner2 = require("@viberails/scanner");
1836
- var import_chalk9 = __toESM(require("chalk"), 1);
2762
+ var import_chalk11 = __toESM(require("chalk"), 1);
1837
2763
 
1838
2764
  // src/utils/diff-configs.ts
1839
2765
  var import_types5 = require("@viberails/types");
@@ -1854,11 +2780,8 @@ function displayStackName(s) {
1854
2780
  const display = allMaps[name] ?? name;
1855
2781
  return version ? `${display} ${version}` : display;
1856
2782
  }
1857
- function conventionStr(cv) {
1858
- return typeof cv === "string" ? cv : cv.value;
1859
- }
1860
- function isDetected(cv) {
1861
- return typeof cv !== "string" && cv._detected === true;
2783
+ function isNewlyDetected(config, pkgPath, key) {
2784
+ return config._meta?.packages?.[pkgPath]?.conventions?.[key]?.detected === true;
1862
2785
  }
1863
2786
  var STACK_FIELDS = [
1864
2787
  "framework",
@@ -1885,59 +2808,66 @@ var STRUCTURE_FIELDS = [
1885
2808
  { key: "tests", label: "tests directory" },
1886
2809
  { key: "testPattern", label: "test pattern" }
1887
2810
  ];
1888
- function diffConfigs(existing, merged) {
2811
+ function diffPackage(existing, merged, mergedConfig) {
1889
2812
  const changes = [];
2813
+ const pkgPrefix = existing.path === "." ? "" : `${existing.path}: `;
1890
2814
  for (const field of STACK_FIELDS) {
1891
- const oldVal = existing.stack[field];
1892
- const newVal = merged.stack[field];
2815
+ const oldVal = existing.stack?.[field];
2816
+ const newVal = merged.stack?.[field];
1893
2817
  if (!oldVal && newVal) {
1894
- changes.push({ type: "added", description: `Stack: added ${displayStackName(newVal)}` });
2818
+ changes.push({
2819
+ type: "added",
2820
+ description: `${pkgPrefix}Stack: added ${displayStackName(newVal)}`
2821
+ });
1895
2822
  } else if (oldVal && newVal && oldVal !== newVal) {
1896
2823
  changes.push({
1897
2824
  type: "changed",
1898
- description: `Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
2825
+ description: `${pkgPrefix}Stack: ${displayStackName(oldVal)} \u2192 ${displayStackName(newVal)}`
1899
2826
  });
1900
2827
  }
1901
2828
  }
1902
2829
  for (const key of CONVENTION_KEYS) {
1903
- const oldVal = existing.conventions[key];
1904
- const newVal = merged.conventions[key];
2830
+ const oldVal = existing.conventions?.[key];
2831
+ const newVal = merged.conventions?.[key];
1905
2832
  const label = import_types5.CONVENTION_LABELS[key] ?? key;
1906
2833
  if (!oldVal && newVal) {
1907
2834
  changes.push({
1908
2835
  type: "added",
1909
- description: `New convention: ${label} (${conventionStr(newVal)})`
2836
+ description: `${pkgPrefix}New convention: ${label} (${newVal})`
1910
2837
  });
1911
- } else if (oldVal && newVal && isDetected(newVal)) {
2838
+ } else if (oldVal && newVal && oldVal !== newVal) {
2839
+ const suffix = isNewlyDetected(mergedConfig, merged.path, key) ? " (newly detected)" : "";
1912
2840
  changes.push({
1913
2841
  type: "changed",
1914
- description: `Convention updated: ${label} (${conventionStr(newVal)})`
2842
+ description: `${pkgPrefix}Convention updated: ${label} (${newVal})${suffix}`
1915
2843
  });
1916
2844
  }
1917
2845
  }
1918
2846
  for (const { key, label } of STRUCTURE_FIELDS) {
1919
- const oldVal = existing.structure[key];
1920
- const newVal = merged.structure[key];
2847
+ const oldVal = existing.structure?.[key];
2848
+ const newVal = merged.structure?.[key];
1921
2849
  if (!oldVal && newVal) {
1922
- changes.push({ type: "added", description: `Structure: detected ${label} (${newVal})` });
1923
- }
1924
- }
1925
- const existingPaths = new Set((existing.packages ?? []).map((p) => p.path));
1926
- for (const pkg of merged.packages ?? []) {
1927
- if (!existingPaths.has(pkg.path)) {
1928
- changes.push({ type: "added", description: `New package: ${pkg.path}` });
2850
+ changes.push({
2851
+ type: "added",
2852
+ description: `${pkgPrefix}Structure: detected ${label} (${newVal})`
2853
+ });
1929
2854
  }
1930
2855
  }
1931
- const existingWsPkgs = new Set(existing.workspace?.packages ?? []);
1932
- const mergedWsPkgs = new Set(merged.workspace?.packages ?? []);
1933
- for (const pkg of mergedWsPkgs) {
1934
- if (!existingWsPkgs.has(pkg)) {
1935
- 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));
1936
2866
  }
1937
2867
  }
1938
- for (const pkg of existingWsPkgs) {
1939
- if (!mergedWsPkgs.has(pkg)) {
1940
- 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}` });
1941
2871
  }
1942
2872
  }
1943
2873
  return changes;
@@ -1962,9 +2892,9 @@ function formatStatsDelta(oldStats, newStats) {
1962
2892
  var CONFIG_FILE5 = "viberails.config.json";
1963
2893
  var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
1964
2894
  function loadPreviousStats(projectRoot) {
1965
- const scanResultPath = path14.join(projectRoot, SCAN_RESULT_FILE2);
2895
+ const scanResultPath = path18.join(projectRoot, SCAN_RESULT_FILE2);
1966
2896
  try {
1967
- const raw = fs13.readFileSync(scanResultPath, "utf-8");
2897
+ const raw = fs18.readFileSync(scanResultPath, "utf-8");
1968
2898
  const parsed = JSON.parse(raw);
1969
2899
  if (parsed?.statistics?.totalFiles !== void 0) {
1970
2900
  return parsed.statistics;
@@ -1981,44 +2911,47 @@ async function syncCommand(cwd) {
1981
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"
1982
2912
  );
1983
2913
  }
1984
- const configPath = path14.join(projectRoot, CONFIG_FILE5);
1985
- 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);
1986
2916
  const previousStats = loadPreviousStats(projectRoot);
1987
- console.log(import_chalk9.default.dim("Scanning project..."));
2917
+ console.log(import_chalk11.default.dim("Scanning project..."));
1988
2918
  const scanResult = await (0, import_scanner2.scan)(projectRoot);
1989
- const merged = (0, import_config5.mergeConfig)(existing, scanResult);
1990
- const existingJson = JSON.stringify(existing, null, 2);
1991
- const mergedJson = JSON.stringify(merged, null, 2);
1992
- 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;
1993
2926
  const changes = configChanged ? diffConfigs(existing, merged) : [];
1994
2927
  const statsDelta = previousStats ? formatStatsDelta(previousStats, scanResult.statistics) : void 0;
1995
2928
  if (changes.length > 0 || statsDelta) {
1996
2929
  console.log(`
1997
- ${import_chalk9.default.bold("Changes:")}`);
2930
+ ${import_chalk11.default.bold("Changes:")}`);
1998
2931
  for (const change of changes) {
1999
- 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("+");
2000
2933
  console.log(` ${icon} ${change.description}`);
2001
2934
  }
2002
2935
  if (statsDelta) {
2003
- console.log(` ${import_chalk9.default.dim(statsDelta)}`);
2936
+ console.log(` ${import_chalk11.default.dim(statsDelta)}`);
2004
2937
  }
2005
2938
  }
2006
- fs13.writeFileSync(configPath, `${mergedJson}
2939
+ fs18.writeFileSync(configPath, `${compactedJson}
2007
2940
  `);
2008
2941
  writeGeneratedFiles(projectRoot, merged, scanResult);
2009
2942
  console.log(`
2010
- ${import_chalk9.default.bold("Synced:")}`);
2943
+ ${import_chalk11.default.bold("Synced:")}`);
2011
2944
  if (configChanged) {
2012
- 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)`);
2013
2946
  } else {
2014
- console.log(` ${import_chalk9.default.green("\u2713")} ${CONFIG_FILE5} \u2014 unchanged`);
2947
+ console.log(` ${import_chalk11.default.green("\u2713")} ${CONFIG_FILE5} \u2014 unchanged`);
2015
2948
  }
2016
- console.log(` ${import_chalk9.default.green("\u2713")} .viberails/context.md \u2014 regenerated`);
2017
- 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`);
2018
2951
  }
2019
2952
 
2020
2953
  // src/index.ts
2021
- var VERSION = "0.3.3";
2954
+ var VERSION = "0.5.0";
2022
2955
  var program = new import_commander.Command();
2023
2956
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
2024
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) => {
@@ -2026,7 +2959,7 @@ program.command("init", { isDefault: true }).description("Scan your project and
2026
2959
  await initCommand(options);
2027
2960
  } catch (err) {
2028
2961
  const message = err instanceof Error ? err.message : String(err);
2029
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2962
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2030
2963
  process.exit(1);
2031
2964
  }
2032
2965
  });
@@ -2035,22 +2968,28 @@ program.command("sync").description("Re-scan and update generated files").action
2035
2968
  await syncCommand();
2036
2969
  } catch (err) {
2037
2970
  const message = err instanceof Error ? err.message : String(err);
2038
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2971
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2039
2972
  process.exit(1);
2040
2973
  }
2041
2974
  });
2042
- program.command("check").description("Check files against enforced rules").option("--staged", "Check only staged files (for pre-commit hooks)").option("--files <files...>", "Check specific files").option("--no-boundaries", "Skip boundary checking").option("--quiet", "Show only summary counts, not individual violations").option("--limit <n>", "Maximum number of violations to display", Number.parseInt).option("--format <format>", "Output format: text (default) or json").action(
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(
2043
2976
  async (options) => {
2044
2977
  try {
2978
+ if (options.hook) {
2979
+ const exitCode2 = await hookCheckCommand();
2980
+ process.exit(exitCode2);
2981
+ }
2045
2982
  const exitCode = await checkCommand({
2046
2983
  ...options,
2984
+ diffBase: options.diffBase,
2985
+ enforce: options.enforce,
2047
2986
  noBoundaries: options.boundaries === false,
2048
2987
  format: options.format === "json" ? "json" : "text"
2049
2988
  });
2050
2989
  process.exit(exitCode);
2051
2990
  } catch (err) {
2052
2991
  const message = err instanceof Error ? err.message : String(err);
2053
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
2992
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2054
2993
  process.exit(1);
2055
2994
  }
2056
2995
  }
@@ -2061,7 +3000,7 @@ program.command("fix").description("Auto-fix file naming violations and generate
2061
3000
  process.exit(exitCode);
2062
3001
  } catch (err) {
2063
3002
  const message = err instanceof Error ? err.message : String(err);
2064
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
3003
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2065
3004
  process.exit(1);
2066
3005
  }
2067
3006
  });
@@ -2070,7 +3009,7 @@ program.command("boundaries").description("Display, infer, or inspect import bou
2070
3009
  await boundariesCommand(options);
2071
3010
  } catch (err) {
2072
3011
  const message = err instanceof Error ? err.message : String(err);
2073
- console.error(`${import_chalk10.default.red("Error:")} ${message}`);
3012
+ console.error(`${import_chalk12.default.red("Error:")} ${message}`);
2074
3013
  process.exit(1);
2075
3014
  }
2076
3015
  });