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