viberails 0.6.4 → 0.6.6

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import chalk14 from "chalk";
4
+ import chalk15 from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/boundaries.ts
@@ -30,146 +30,285 @@ function findProjectRoot(startDir) {
30
30
  // src/utils/prompt.ts
31
31
  import * as clack5 from "@clack/prompts";
32
32
 
33
- // src/utils/prompt-integrations.ts
34
- import * as clack from "@clack/prompts";
33
+ // src/utils/prompt-rules.ts
34
+ import * as clack4 from "@clack/prompts";
35
35
 
36
- // src/utils/spawn-async.ts
37
- import { spawn } from "child_process";
38
- function spawnAsync(command, cwd) {
39
- return new Promise((resolve4) => {
40
- const child = spawn(command, { cwd, shell: true, stdio: "pipe" });
41
- let stdout = "";
42
- let stderr = "";
43
- child.stdout.on("data", (d) => {
44
- stdout += d.toString();
45
- });
46
- child.stderr.on("data", (d) => {
47
- stderr += d.toString();
48
- });
49
- child.on("close", (status) => {
50
- resolve4({ status, stdout, stderr });
51
- });
52
- child.on("error", () => {
53
- resolve4({ status: 1, stdout, stderr });
54
- });
55
- });
36
+ // src/utils/get-root-package.ts
37
+ function getRootPackage(packages) {
38
+ return packages.find((pkg) => pkg.path === ".") ?? packages[0];
56
39
  }
57
40
 
58
- // src/utils/prompt-integrations.ts
59
- async function promptHookManagerInstall(projectRoot, packageManager, isWorkspace) {
60
- const choice = await clack.select({
61
- message: "No shared git hook manager detected. Install Lefthook?",
62
- options: [
41
+ // src/utils/prompt-menu-handlers.ts
42
+ import * as clack3 from "@clack/prompts";
43
+
44
+ // src/utils/prompt-package-overrides.ts
45
+ import * as clack2 from "@clack/prompts";
46
+
47
+ // src/utils/prompt-constants.ts
48
+ var SENTINEL_DONE = "__done__";
49
+ var SENTINEL_CLEAR = "__clear__";
50
+ var SENTINEL_CUSTOM = "__custom__";
51
+ var SENTINEL_NONE = "__none__";
52
+ var SENTINEL_INHERIT = "__inherit__";
53
+ var SENTINEL_SKIP = "__skip__";
54
+
55
+ // src/utils/prompt-submenus.ts
56
+ import * as clack from "@clack/prompts";
57
+ var FILE_NAMING_OPTIONS = [
58
+ { value: "kebab-case", label: "kebab-case" },
59
+ { value: "camelCase", label: "camelCase" },
60
+ { value: "PascalCase", label: "PascalCase" },
61
+ { value: "snake_case", label: "snake_case" }
62
+ ];
63
+ var COMPONENT_NAMING_OPTIONS = [
64
+ { value: "PascalCase", label: "PascalCase", hint: "MyComponent.tsx" },
65
+ { value: "camelCase", label: "camelCase", hint: "myComponent.tsx" }
66
+ ];
67
+ var HOOK_NAMING_OPTIONS = [
68
+ { value: "useXxx", label: "useXxx", hint: "useAuth, useFormData" },
69
+ { value: "use-*", label: "use-*", hint: "use-auth, use-form-data" }
70
+ ];
71
+ async function promptFileLimitsMenu(state) {
72
+ while (true) {
73
+ const choice = await clack.select({
74
+ message: "File limits",
75
+ options: [
76
+ { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
77
+ {
78
+ value: "maxTestFileLines",
79
+ label: "Max test file lines",
80
+ hint: state.maxTestFileLines > 0 ? String(state.maxTestFileLines) : "0 (unlimited)"
81
+ },
82
+ { value: "back", label: "Back" }
83
+ ]
84
+ });
85
+ assertNotCancelled(choice);
86
+ if (choice === "back") return;
87
+ if (choice === "maxFileLines") {
88
+ const result = await clack.text({
89
+ message: "Maximum lines per source file?",
90
+ initialValue: String(state.maxFileLines),
91
+ validate: (v) => {
92
+ if (typeof v !== "string") return "Enter a positive number";
93
+ const n = Number.parseInt(v, 10);
94
+ if (Number.isNaN(n) || n < 1) return "Enter a positive number";
95
+ }
96
+ });
97
+ assertNotCancelled(result);
98
+ state.maxFileLines = Number.parseInt(result, 10);
99
+ }
100
+ if (choice === "maxTestFileLines") {
101
+ const result = await clack.text({
102
+ message: "Maximum lines per test file (0 to disable)?",
103
+ initialValue: String(state.maxTestFileLines),
104
+ validate: (v) => {
105
+ if (typeof v !== "string") return "Enter a number (0 or positive)";
106
+ const n = Number.parseInt(v, 10);
107
+ if (Number.isNaN(n) || n < 0) return "Enter a number (0 or positive)";
108
+ }
109
+ });
110
+ assertNotCancelled(result);
111
+ state.maxTestFileLines = Number.parseInt(result, 10);
112
+ }
113
+ }
114
+ }
115
+ async function promptNamingMenu(state) {
116
+ while (true) {
117
+ const options = [
118
+ {
119
+ value: "enforceNaming",
120
+ label: "Enforce file naming",
121
+ hint: state.enforceNaming ? "yes" : "no"
122
+ }
123
+ ];
124
+ if (state.enforceNaming) {
125
+ options.push({
126
+ value: "fileNaming",
127
+ label: "File naming convention",
128
+ hint: state.fileNamingValue ?? "(not set)"
129
+ });
130
+ }
131
+ options.push(
63
132
  {
64
- value: "install",
65
- label: "Yes, install Lefthook",
66
- hint: "recommended \u2014 hooks are committed to the repo and shared with your team"
133
+ value: "componentNaming",
134
+ label: "Component naming",
135
+ hint: state.componentNaming ?? "(not set)"
67
136
  },
68
137
  {
69
- value: "skip",
70
- label: "No, skip",
71
- hint: "pre-commit hooks will be local-only (.git/hooks) and not shared"
72
- }
73
- ]
74
- });
75
- assertNotCancelled(choice);
76
- if (choice !== "install") return void 0;
77
- const pm = packageManager || "npm";
78
- const installCmd = pm === "yarn" ? "yarn add -D lefthook" : pm === "pnpm" ? `pnpm add -D${isWorkspace ? " -w" : ""} lefthook` : "npm install -D lefthook";
79
- const s = clack.spinner();
80
- s.start("Installing Lefthook...");
81
- const result = await spawnAsync(installCmd, projectRoot);
82
- if (result.status === 0) {
83
- const fs21 = await import("fs");
84
- const path21 = await import("path");
85
- const lefthookPath = path21.join(projectRoot, "lefthook.yml");
86
- if (!fs21.existsSync(lefthookPath)) {
87
- fs21.writeFileSync(lefthookPath, "# Managed by viberails \u2014 https://viberails.sh\n");
88
- }
89
- s.stop("Installed Lefthook");
90
- return "Lefthook";
91
- }
92
- s.stop("Failed to install Lefthook");
93
- clack.log.warn(`Install manually: ${installCmd}`);
94
- return void 0;
95
- }
96
- async function promptIntegrations(projectRoot, hookManager, tools) {
97
- let resolvedHookManager = hookManager;
98
- if (!resolvedHookManager) {
99
- resolvedHookManager = await promptHookManagerInstall(
100
- projectRoot,
101
- tools?.packageManager ?? "npm",
102
- tools?.isWorkspace
138
+ value: "hookNaming",
139
+ label: "Hook naming",
140
+ hint: state.hookNaming ?? "(not set)"
141
+ },
142
+ {
143
+ value: "importAlias",
144
+ label: "Import alias",
145
+ hint: state.importAlias ?? "(not set)"
146
+ },
147
+ { value: "back", label: "Back" }
103
148
  );
104
- }
105
- const isBareHook = !resolvedHookManager;
106
- const hookLabel = resolvedHookManager ? `Pre-commit hook (${resolvedHookManager})` : "Pre-commit hook (git hook \u2014 local only)";
107
- const hookHint = isBareHook ? "local only \u2014 will NOT be committed or shared with collaborators" : "runs viberails checks when you commit";
108
- const options = [
109
- {
110
- value: "preCommit",
111
- label: hookLabel,
112
- hint: hookHint
149
+ const choice = await clack.select({ message: "Naming & conventions", options });
150
+ assertNotCancelled(choice);
151
+ if (choice === "back") return;
152
+ if (choice === "enforceNaming") {
153
+ const result = await clack.confirm({
154
+ message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
155
+ initialValue: state.enforceNaming
156
+ });
157
+ assertNotCancelled(result);
158
+ if (result && !state.fileNamingValue) {
159
+ const selected = await clack.select({
160
+ message: "Which file naming convention should be enforced?",
161
+ options: [...FILE_NAMING_OPTIONS]
162
+ });
163
+ assertNotCancelled(selected);
164
+ state.fileNamingValue = selected;
165
+ }
166
+ state.enforceNaming = result;
167
+ }
168
+ if (choice === "fileNaming") {
169
+ const selected = await clack.select({
170
+ message: "Which file naming convention should be enforced?",
171
+ options: [...FILE_NAMING_OPTIONS],
172
+ initialValue: state.fileNamingValue
173
+ });
174
+ assertNotCancelled(selected);
175
+ state.fileNamingValue = selected;
176
+ }
177
+ if (choice === "componentNaming") {
178
+ const selected = await clack.select({
179
+ message: "Component naming convention",
180
+ options: [
181
+ ...COMPONENT_NAMING_OPTIONS,
182
+ { value: SENTINEL_CLEAR, label: "Clear (no convention)" }
183
+ ],
184
+ initialValue: state.componentNaming ?? SENTINEL_CLEAR
185
+ });
186
+ assertNotCancelled(selected);
187
+ state.componentNaming = selected === SENTINEL_CLEAR ? void 0 : selected;
188
+ }
189
+ if (choice === "hookNaming") {
190
+ const selected = await clack.select({
191
+ message: "Hook naming convention",
192
+ options: [
193
+ ...HOOK_NAMING_OPTIONS,
194
+ { value: SENTINEL_CLEAR, label: "Clear (no convention)" }
195
+ ],
196
+ initialValue: state.hookNaming ?? SENTINEL_CLEAR
197
+ });
198
+ assertNotCancelled(selected);
199
+ state.hookNaming = selected === SENTINEL_CLEAR ? void 0 : selected;
200
+ }
201
+ if (choice === "importAlias") {
202
+ const selected = await clack.select({
203
+ message: "Import alias pattern",
204
+ options: [
205
+ { value: "@/*", label: "@/*", hint: "import { x } from '@/utils'" },
206
+ { value: "~/*", label: "~/*", hint: "import { x } from '~/utils'" },
207
+ { value: SENTINEL_CUSTOM, label: "Custom..." },
208
+ { value: SENTINEL_CLEAR, label: "Clear (no alias)" }
209
+ ],
210
+ initialValue: state.importAlias ?? SENTINEL_CLEAR
211
+ });
212
+ assertNotCancelled(selected);
213
+ if (selected === SENTINEL_CLEAR) {
214
+ state.importAlias = void 0;
215
+ } else if (selected === SENTINEL_CUSTOM) {
216
+ const result = await clack.text({
217
+ message: "Custom import alias (e.g. #/*)?",
218
+ initialValue: state.importAlias ?? "",
219
+ placeholder: "e.g. #/*",
220
+ validate: (v) => {
221
+ if (typeof v !== "string" || !v.trim()) return "Alias cannot be empty";
222
+ if (!/^[a-zA-Z@~#$][a-zA-Z0-9@~#$_-]*\/\*$/.test(v.trim()))
223
+ return "Must match pattern like @/*, ~/*, or #src/*";
224
+ }
225
+ });
226
+ assertNotCancelled(result);
227
+ state.importAlias = result.trim();
228
+ } else {
229
+ state.importAlias = selected;
230
+ }
113
231
  }
114
- ];
115
- if (tools?.isTypeScript) {
116
- options.push({
117
- value: "typecheck",
118
- label: "Typecheck (tsc --noEmit)",
119
- hint: "pre-commit hook + CI check"
120
- });
121
- }
122
- if (tools?.linter) {
123
- const linterName = tools.linter === "biome" ? "Biome" : "ESLint";
124
- options.push({
125
- value: "lint",
126
- label: `Lint check (${linterName})`,
127
- hint: "pre-commit hook + CI check"
128
- });
129
232
  }
130
- options.push(
131
- {
132
- value: "claude",
133
- label: "Claude Code hook",
134
- hint: "checks files when Claude edits them"
135
- },
136
- {
137
- value: "claudeMd",
138
- label: "CLAUDE.md reference",
139
- hint: "appends @.viberails/context.md so Claude loads rules automatically"
140
- },
141
- {
142
- value: "githubAction",
143
- label: "GitHub Actions workflow",
144
- hint: "blocks PRs that fail viberails check"
233
+ }
234
+ async function promptTestingMenu(state) {
235
+ while (true) {
236
+ const options = [
237
+ {
238
+ value: "enforceMissingTests",
239
+ label: "Enforce missing tests",
240
+ hint: state.enforceMissingTests ? "yes" : "no"
241
+ },
242
+ {
243
+ value: "testCoverage",
244
+ label: "Test coverage target",
245
+ hint: state.testCoverage === 0 ? "0 (disabled)" : `${state.testCoverage}%`
246
+ }
247
+ ];
248
+ if (state.testCoverage > 0) {
249
+ options.push(
250
+ {
251
+ value: "coverageSummaryPath",
252
+ label: "Coverage summary path",
253
+ hint: state.coverageSummaryPath
254
+ },
255
+ {
256
+ value: "coverageCommand",
257
+ label: "Coverage command",
258
+ hint: state.coverageCommand ?? "auto-detect from package.json test runner"
259
+ }
260
+ );
145
261
  }
146
- );
147
- const initialValues = isBareHook ? options.filter((o) => o.value !== "preCommit").map((o) => o.value) : options.map((o) => o.value);
148
- const result = await clack.multiselect({
149
- message: "Optional integrations",
150
- options,
151
- initialValues,
152
- required: false
153
- });
154
- assertNotCancelled(result);
155
- return {
156
- preCommitHook: result.includes("preCommit"),
157
- claudeCodeHook: result.includes("claude"),
158
- claudeMdRef: result.includes("claudeMd"),
159
- githubAction: result.includes("githubAction"),
160
- typecheckHook: result.includes("typecheck"),
161
- lintHook: result.includes("lint")
162
- };
262
+ options.push({ value: "back", label: "Back" });
263
+ const choice = await clack.select({ message: "Testing & coverage", options });
264
+ assertNotCancelled(choice);
265
+ if (choice === "back") return;
266
+ if (choice === "enforceMissingTests") {
267
+ const result = await clack.confirm({
268
+ message: "Require every source file to have a corresponding test file?",
269
+ initialValue: state.enforceMissingTests
270
+ });
271
+ assertNotCancelled(result);
272
+ state.enforceMissingTests = result;
273
+ }
274
+ if (choice === "testCoverage") {
275
+ const result = await clack.text({
276
+ message: "Test coverage target (0 disables coverage checks)?",
277
+ initialValue: String(state.testCoverage),
278
+ validate: (v) => {
279
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
280
+ const n = Number.parseInt(v, 10);
281
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
282
+ }
283
+ });
284
+ assertNotCancelled(result);
285
+ state.testCoverage = Number.parseInt(result, 10);
286
+ }
287
+ if (choice === "coverageSummaryPath") {
288
+ const result = await clack.text({
289
+ message: "Coverage summary path (relative to package root)?",
290
+ initialValue: state.coverageSummaryPath,
291
+ validate: (v) => {
292
+ if (typeof v !== "string" || v.trim().length === 0) return "Path cannot be empty";
293
+ }
294
+ });
295
+ assertNotCancelled(result);
296
+ state.coverageSummaryPath = result.trim();
297
+ }
298
+ if (choice === "coverageCommand") {
299
+ const result = await clack.text({
300
+ message: "Coverage command (blank to auto-detect from package.json)?",
301
+ initialValue: state.coverageCommand ?? "",
302
+ placeholder: "(auto-detect from package.json test runner)"
303
+ });
304
+ assertNotCancelled(result);
305
+ const trimmed = result.trim();
306
+ state.coverageCommand = trimmed.length > 0 ? trimmed : void 0;
307
+ }
308
+ }
163
309
  }
164
310
 
165
- // src/utils/prompt-rules.ts
166
- import * as clack4 from "@clack/prompts";
167
-
168
- // src/utils/prompt-menu-handlers.ts
169
- import * as clack3 from "@clack/prompts";
170
-
171
311
  // src/utils/prompt-package-overrides.ts
172
- import * as clack2 from "@clack/prompts";
173
312
  function normalizePackageOverrides(packages) {
174
313
  for (const pkg of packages) {
175
314
  if (pkg.rules && Object.keys(pkg.rules).length === 0) {
@@ -178,121 +317,177 @@ function normalizePackageOverrides(packages) {
178
317
  if (pkg.coverage && Object.keys(pkg.coverage).length === 0) {
179
318
  delete pkg.coverage;
180
319
  }
320
+ if (pkg.conventions && Object.keys(pkg.conventions).length === 0) {
321
+ delete pkg.conventions;
322
+ }
181
323
  }
182
324
  return packages;
183
325
  }
184
- function packageCoverageHint(pkg, defaults) {
326
+ function packageOverrideHint(pkg, defaults) {
327
+ const tags = [];
328
+ if (pkg.conventions?.fileNaming && pkg.conventions.fileNaming !== defaults.fileNamingValue) {
329
+ tags.push(pkg.conventions.fileNaming);
330
+ }
331
+ if (pkg.rules?.maxFileLines !== void 0 && pkg.rules.maxFileLines !== defaults.maxFileLines && pkg.rules.maxFileLines > 0) {
332
+ tags.push(`${pkg.rules.maxFileLines} lines`);
333
+ }
185
334
  const coverage = pkg.rules?.testCoverage ?? defaults.testCoverage;
186
335
  const isExempt = coverage === 0;
336
+ const nameSegments = pkg.name.replace(/^@[^/]+\//, "").split(/[-/]/);
337
+ const isTypesOnly = isExempt && nameSegments.some((s) => s === "types");
338
+ if (isExempt) {
339
+ tags.push(isTypesOnly ? "exempt (types-only)" : "exempt");
340
+ } else if (pkg.rules?.testCoverage !== void 0 && pkg.rules.testCoverage !== defaults.testCoverage) {
341
+ tags.push(`${coverage}%`);
342
+ }
187
343
  const hasSummaryOverride = pkg.coverage?.summaryPath !== void 0 && pkg.coverage.summaryPath !== defaults.coverageSummaryPath;
188
344
  const defaultCommand = defaults.coverageCommand ?? "";
189
345
  const hasCommandOverride = pkg.coverage?.command !== void 0 && pkg.coverage.command !== defaultCommand;
190
- const tags = [];
191
- const nameSegments = pkg.name.replace(/^@[^/]+\//, "").split(/[-/]/);
192
- const isTypesOnly = isExempt && nameSegments.some((s) => s === "types");
193
- tags.push(isExempt ? isTypesOnly ? "exempt (types-only)" : "exempt" : `${coverage}%`);
194
346
  if (hasSummaryOverride) tags.push("summary override");
195
347
  if (hasCommandOverride) tags.push("command override");
196
- return tags.join(", ");
348
+ return tags.length > 0 ? tags.join(", ") : "(no overrides)";
197
349
  }
198
- async function promptPackageCoverageOverrides(packages, defaults) {
350
+ async function promptPackageOverrides(packages, defaults) {
199
351
  const editablePackages = packages.filter((pkg) => pkg.path !== ".");
200
352
  if (editablePackages.length === 0) return packages;
201
353
  while (true) {
202
354
  const selectedPath = await clack2.select({
203
- message: "Select package to edit coverage overrides",
355
+ message: "Select package to edit overrides",
204
356
  options: [
205
357
  ...editablePackages.map((pkg) => ({
206
358
  value: pkg.path,
207
359
  label: `${pkg.path} (${pkg.name})`,
208
- hint: packageCoverageHint(pkg, defaults)
360
+ hint: packageOverrideHint(pkg, defaults)
209
361
  })),
210
- { value: "__done__", label: "Done" }
362
+ { value: SENTINEL_DONE, label: "Done" }
211
363
  ]
212
364
  });
213
365
  assertNotCancelled(selectedPath);
214
- if (selectedPath === "__done__") break;
366
+ if (selectedPath === SENTINEL_DONE) break;
215
367
  const target = editablePackages.find((pkg) => pkg.path === selectedPath);
216
368
  if (!target) continue;
217
- while (true) {
218
- const effectiveCoverage = target.rules?.testCoverage ?? defaults.testCoverage;
219
- const effectiveSummary = target.coverage?.summaryPath ?? defaults.coverageSummaryPath;
220
- const effectiveCommand = target.coverage?.command ?? defaults.coverageCommand ?? "(auto-detect)";
221
- const choice = await clack2.select({
222
- message: `Edit coverage overrides for ${target.path}`,
369
+ await promptSinglePackageOverrides(target, defaults);
370
+ normalizePackageOverrides(editablePackages);
371
+ }
372
+ return normalizePackageOverrides(packages);
373
+ }
374
+ async function promptSinglePackageOverrides(target, defaults) {
375
+ while (true) {
376
+ const effectiveNaming = target.conventions?.fileNaming ?? defaults.fileNamingValue;
377
+ const effectiveMaxLines = target.rules?.maxFileLines ?? defaults.maxFileLines;
378
+ const effectiveCoverage = target.rules?.testCoverage ?? defaults.testCoverage;
379
+ const effectiveSummary = target.coverage?.summaryPath ?? defaults.coverageSummaryPath;
380
+ const effectiveCommand = target.coverage?.command ?? defaults.coverageCommand ?? "(auto-detect)";
381
+ const hasNamingOverride = target.conventions?.fileNaming !== void 0 && target.conventions.fileNaming !== defaults.fileNamingValue;
382
+ const hasMaxLinesOverride = target.rules?.maxFileLines !== void 0 && target.rules.maxFileLines !== defaults.maxFileLines;
383
+ const namingHint = hasNamingOverride ? String(effectiveNaming) : `(inherits: ${effectiveNaming ?? "not set"})`;
384
+ const maxLinesHint = hasMaxLinesOverride ? String(effectiveMaxLines) : `(inherits: ${effectiveMaxLines})`;
385
+ const choice = await clack2.select({
386
+ message: `Edit overrides for ${target.path}`,
387
+ options: [
388
+ { value: "fileNaming", label: "File naming", hint: namingHint },
389
+ { value: "maxFileLines", label: "Max file lines", hint: maxLinesHint },
390
+ { value: "testCoverage", label: "Test coverage", hint: String(effectiveCoverage) },
391
+ { value: "summaryPath", label: "Coverage summary path", hint: effectiveSummary },
392
+ { value: "command", label: "Coverage command", hint: effectiveCommand },
393
+ { value: "reset", label: "Reset all overrides for this package" },
394
+ { value: "back", label: "Back to package list" }
395
+ ]
396
+ });
397
+ assertNotCancelled(choice);
398
+ if (choice === "back") break;
399
+ if (choice === "fileNaming") {
400
+ const selected = await clack2.select({
401
+ message: `File naming for ${target.path}`,
223
402
  options: [
224
- { value: "testCoverage", label: "testCoverage", hint: String(effectiveCoverage) },
225
- { value: "summaryPath", label: "coverage.summaryPath", hint: effectiveSummary },
226
- { value: "command", label: "coverage.command", hint: effectiveCommand },
227
- { value: "reset", label: "Reset this package to inherit defaults" },
228
- { value: "back", label: "Back to package list" }
229
- ]
230
- });
231
- assertNotCancelled(choice);
232
- if (choice === "back") break;
233
- if (choice === "testCoverage") {
234
- const result = await clack2.text({
235
- message: "Package testCoverage (0 to exempt package)?",
236
- initialValue: String(effectiveCoverage),
237
- validate: (v) => {
238
- if (typeof v !== "string") return "Enter a number between 0 and 100";
239
- const n = Number.parseInt(v, 10);
240
- if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
241
- }
242
- });
243
- assertNotCancelled(result);
244
- const nextCoverage = Number.parseInt(result, 10);
245
- if (nextCoverage === defaults.testCoverage) {
246
- if (target.rules) {
247
- delete target.rules.testCoverage;
403
+ ...FILE_NAMING_OPTIONS,
404
+ { value: SENTINEL_NONE, label: "(none \u2014 exempt from checks)" },
405
+ {
406
+ value: SENTINEL_INHERIT,
407
+ label: `Inherit default${defaults.fileNamingValue ? ` (${defaults.fileNamingValue})` : ""}`
248
408
  }
249
- } else {
250
- target.rules = { ...target.rules ?? {}, testCoverage: nextCoverage };
251
- }
409
+ ],
410
+ initialValue: target.conventions?.fileNaming ?? SENTINEL_INHERIT
411
+ });
412
+ assertNotCancelled(selected);
413
+ if (selected === SENTINEL_INHERIT) {
414
+ if (target.conventions) delete target.conventions.fileNaming;
415
+ } else if (selected === SENTINEL_NONE) {
416
+ target.conventions = { ...target.conventions ?? {}, fileNaming: "" };
417
+ } else {
418
+ target.conventions = { ...target.conventions ?? {}, fileNaming: selected };
252
419
  }
253
- if (choice === "summaryPath") {
254
- const result = await clack2.text({
255
- message: "Package coverage.summaryPath (blank to inherit default)?",
256
- initialValue: target.coverage?.summaryPath !== void 0 ? target.coverage.summaryPath : "",
257
- placeholder: defaults.coverageSummaryPath
258
- });
259
- assertNotCancelled(result);
260
- const value = result.trim();
261
- if (value.length === 0 || value === defaults.coverageSummaryPath) {
262
- if (target.coverage) {
263
- delete target.coverage.summaryPath;
264
- }
265
- } else {
266
- target.coverage = { ...target.coverage ?? {}, summaryPath: value };
267
- }
420
+ }
421
+ if (choice === "maxFileLines") {
422
+ const result = await clack2.text({
423
+ message: `Max file lines for ${target.path} (blank to inherit default)?`,
424
+ initialValue: target.rules?.maxFileLines !== void 0 ? String(target.rules.maxFileLines) : "",
425
+ placeholder: String(defaults.maxFileLines)
426
+ });
427
+ assertNotCancelled(result);
428
+ const value = result.trim();
429
+ if (value.length === 0 || Number.parseInt(value, 10) === defaults.maxFileLines) {
430
+ if (target.rules) delete target.rules.maxFileLines;
431
+ } else {
432
+ target.rules = { ...target.rules ?? {}, maxFileLines: Number.parseInt(value, 10) };
268
433
  }
269
- if (choice === "command") {
270
- const result = await clack2.text({
271
- message: "Package coverage.command (blank to inherit default/auto)?",
272
- initialValue: target.coverage?.command !== void 0 ? target.coverage.command : "",
273
- placeholder: defaults.coverageCommand ?? "(auto-detect from package.json test runner)"
274
- });
275
- assertNotCancelled(result);
276
- const value = result.trim();
277
- const defaultCommand = defaults.coverageCommand ?? "";
278
- if (value.length === 0 || value === defaultCommand) {
279
- if (target.coverage) {
280
- delete target.coverage.command;
281
- }
282
- } else {
283
- target.coverage = { ...target.coverage ?? {}, command: value };
434
+ }
435
+ if (choice === "testCoverage") {
436
+ const result = await clack2.text({
437
+ message: "Package testCoverage (0 to exempt package)?",
438
+ initialValue: String(effectiveCoverage),
439
+ validate: (v) => {
440
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
441
+ const n = Number.parseInt(v, 10);
442
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
284
443
  }
444
+ });
445
+ assertNotCancelled(result);
446
+ const nextCoverage = Number.parseInt(result, 10);
447
+ if (nextCoverage === defaults.testCoverage) {
448
+ if (target.rules) delete target.rules.testCoverage;
449
+ } else {
450
+ target.rules = { ...target.rules ?? {}, testCoverage: nextCoverage };
285
451
  }
286
- if (choice === "reset") {
287
- if (target.rules) {
288
- delete target.rules.testCoverage;
289
- }
290
- delete target.coverage;
452
+ }
453
+ if (choice === "summaryPath") {
454
+ const result = await clack2.text({
455
+ message: "Path to coverage summary file (blank to inherit default)?",
456
+ initialValue: target.coverage?.summaryPath !== void 0 ? target.coverage.summaryPath : "",
457
+ placeholder: defaults.coverageSummaryPath
458
+ });
459
+ assertNotCancelled(result);
460
+ const value = result.trim();
461
+ if (value.length === 0 || value === defaults.coverageSummaryPath) {
462
+ if (target.coverage) delete target.coverage.summaryPath;
463
+ } else {
464
+ target.coverage = { ...target.coverage ?? {}, summaryPath: value };
465
+ }
466
+ }
467
+ if (choice === "command") {
468
+ const result = await clack2.text({
469
+ message: "Coverage command (blank to auto-detect)?",
470
+ initialValue: target.coverage?.command !== void 0 ? target.coverage.command : "",
471
+ placeholder: defaults.coverageCommand ?? "(auto-detect from package.json test runner)"
472
+ });
473
+ assertNotCancelled(result);
474
+ const value = result.trim();
475
+ const defaultCommand = defaults.coverageCommand ?? "";
476
+ if (value.length === 0 || value === defaultCommand) {
477
+ if (target.coverage) delete target.coverage.command;
478
+ } else {
479
+ target.coverage = { ...target.coverage ?? {}, command: value };
291
480
  }
292
- normalizePackageOverrides(editablePackages);
481
+ }
482
+ if (choice === "reset") {
483
+ if (target.rules) {
484
+ delete target.rules.testCoverage;
485
+ delete target.rules.maxFileLines;
486
+ }
487
+ delete target.coverage;
488
+ delete target.conventions;
293
489
  }
294
490
  }
295
- return normalizePackageOverrides(packages);
296
491
  }
297
492
 
298
493
  // src/utils/prompt-menu-handlers.ts
@@ -335,48 +530,21 @@ function getPackageDiffs(pkg, root) {
335
530
  return diffs;
336
531
  }
337
532
  function buildMenuOptions(state, packageCount) {
338
- const namingHint = state.enforceNaming ? `yes${state.fileNamingValue ? ` (${state.fileNamingValue})` : ""}` : "no";
533
+ const fileLimitsHint2 = state.maxTestFileLines > 0 ? `max ${state.maxFileLines} lines, tests ${state.maxTestFileLines}` : `max ${state.maxFileLines} lines, test files unlimited`;
534
+ const namingHint = state.enforceNaming ? `${state.fileNamingValue ?? "not set"} (enforced)` : "not enforced";
535
+ const testingHint = state.testCoverage > 0 ? `${state.testCoverage}% coverage, missing tests ${state.enforceMissingTests ? "enforced" : "not enforced"}` : `coverage disabled, missing tests ${state.enforceMissingTests ? "enforced" : "not enforced"}`;
339
536
  const options = [
340
- { value: "maxFileLines", label: "Max file lines", hint: String(state.maxFileLines) },
341
- { value: "enforceNaming", label: "Enforce file naming", hint: namingHint }
537
+ { value: "fileLimits", label: "File limits", hint: fileLimitsHint2 },
538
+ { value: "naming", label: "Naming & conventions", hint: namingHint },
539
+ { value: "testing", label: "Testing & coverage", hint: testingHint }
342
540
  ];
343
- if (state.fileNamingValue) {
541
+ if (packageCount > 0) {
344
542
  options.push({
345
- value: "fileNaming",
346
- label: "File naming convention",
347
- hint: state.fileNamingValue
543
+ value: "packageOverrides",
544
+ label: "Per-package overrides",
545
+ hint: `${packageCount} package${packageCount > 1 ? "s" : ""} configurable`
348
546
  });
349
547
  }
350
- const isMonorepo = packageCount > 0;
351
- const coverageLabel = isMonorepo ? "Default coverage target" : "Test coverage target";
352
- const coverageHint = state.testCoverage === 0 ? "0 (disabled)" : isMonorepo ? `${state.testCoverage}% (per-package default)` : `${state.testCoverage}%`;
353
- options.push({ value: "testCoverage", label: coverageLabel, hint: coverageHint });
354
- options.push({
355
- value: "enforceMissingTests",
356
- label: "Enforce missing tests",
357
- hint: state.enforceMissingTests ? "yes" : "no"
358
- });
359
- if (state.testCoverage > 0) {
360
- options.push(
361
- {
362
- value: "coverageSummaryPath",
363
- label: isMonorepo ? "Default coverage summary path" : "Coverage summary path",
364
- hint: state.coverageSummaryPath
365
- },
366
- {
367
- value: "coverageCommand",
368
- label: isMonorepo ? "Default coverage command" : "Coverage command",
369
- hint: state.coverageCommand ?? "auto-detect from package.json test runner"
370
- }
371
- );
372
- if (isMonorepo) {
373
- options.push({
374
- value: "packageOverrides",
375
- label: "Per-package coverage overrides",
376
- hint: `${packageCount} package${packageCount > 1 ? "s" : ""} configurable`
377
- });
378
- }
379
- }
380
548
  options.push(
381
549
  { value: "reset", label: "Reset all to detected defaults" },
382
550
  { value: "done", label: "Done" }
@@ -384,37 +552,43 @@ function buildMenuOptions(state, packageCount) {
384
552
  return options;
385
553
  }
386
554
  function clonePackages(packages) {
387
- return packages?.map((pkg) => ({
388
- ...pkg,
389
- stack: pkg.stack ? { ...pkg.stack } : void 0,
390
- structure: pkg.structure ? { ...pkg.structure } : void 0,
391
- conventions: pkg.conventions ? { ...pkg.conventions } : void 0,
392
- rules: pkg.rules ? { ...pkg.rules } : void 0,
393
- coverage: pkg.coverage ? { ...pkg.coverage } : void 0,
394
- ignore: pkg.ignore ? [...pkg.ignore] : void 0,
395
- boundaries: pkg.boundaries ? {
396
- deny: [...pkg.boundaries.deny],
397
- ignore: pkg.boundaries.ignore ? [...pkg.boundaries.ignore] : void 0
398
- } : void 0
399
- }));
555
+ return packages ? structuredClone(packages) : void 0;
400
556
  }
401
557
  async function handleMenuChoice(choice, state, defaults, root) {
402
558
  if (choice === "reset") {
403
559
  state.maxFileLines = defaults.maxFileLines;
560
+ state.maxTestFileLines = defaults.maxTestFileLines;
404
561
  state.testCoverage = defaults.testCoverage;
405
562
  state.enforceMissingTests = defaults.enforceMissingTests;
406
563
  state.enforceNaming = defaults.enforceNaming;
407
564
  state.fileNamingValue = defaults.fileNamingValue;
565
+ state.componentNaming = defaults.componentNaming;
566
+ state.hookNaming = defaults.hookNaming;
567
+ state.importAlias = defaults.importAlias;
408
568
  state.coverageSummaryPath = defaults.coverageSummaryPath;
409
569
  state.coverageCommand = defaults.coverageCommand;
410
570
  state.packageOverrides = clonePackages(defaults.packageOverrides);
411
571
  clack3.log.info("Reset all rules to detected defaults.");
412
572
  return;
413
573
  }
574
+ if (choice === "fileLimits") {
575
+ await promptFileLimitsMenu(state);
576
+ return;
577
+ }
578
+ if (choice === "naming") {
579
+ await promptNamingMenu(state);
580
+ return;
581
+ }
582
+ if (choice === "testing") {
583
+ await promptTestingMenu(state);
584
+ return;
585
+ }
414
586
  if (choice === "packageOverrides") {
415
587
  if (state.packageOverrides) {
416
588
  const packageDiffs = root ? state.packageOverrides.filter((pkg) => pkg.path !== root.path).map((pkg) => ({ pkg, diffs: getPackageDiffs(pkg, root) })).filter((entry) => entry.diffs.length > 0) : [];
417
- state.packageOverrides = await promptPackageCoverageOverrides(state.packageOverrides, {
589
+ state.packageOverrides = await promptPackageOverrides(state.packageOverrides, {
590
+ fileNamingValue: state.fileNamingValue,
591
+ maxFileLines: state.maxFileLines,
418
592
  testCoverage: state.testCoverage,
419
593
  coverageSummaryPath: state.coverageSummaryPath,
420
594
  coverageCommand: state.coverageCommand
@@ -427,89 +601,9 @@ async function handleMenuChoice(choice, state, defaults, root) {
427
601
  }
428
602
  return;
429
603
  }
430
- if (choice === "maxFileLines") {
431
- const result = await clack3.text({
432
- message: "Maximum lines per source file?",
433
- initialValue: String(state.maxFileLines),
434
- validate: (v) => {
435
- if (typeof v !== "string") return "Enter a positive number";
436
- const n = Number.parseInt(v, 10);
437
- if (Number.isNaN(n) || n < 1) return "Enter a positive number";
438
- }
439
- });
440
- assertNotCancelled(result);
441
- state.maxFileLines = Number.parseInt(result, 10);
442
- }
443
- if (choice === "enforceMissingTests") {
444
- const result = await clack3.confirm({
445
- message: "Require every source file to have a corresponding test file?",
446
- initialValue: state.enforceMissingTests
447
- });
448
- assertNotCancelled(result);
449
- state.enforceMissingTests = result;
450
- }
451
- if (choice === "testCoverage") {
452
- const result = await clack3.text({
453
- message: "Test coverage target (0 disables coverage checks)?",
454
- initialValue: String(state.testCoverage),
455
- validate: (v) => {
456
- if (typeof v !== "string") return "Enter a number between 0 and 100";
457
- const n = Number.parseInt(v, 10);
458
- if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
459
- }
460
- });
461
- assertNotCancelled(result);
462
- state.testCoverage = Number.parseInt(result, 10);
463
- }
464
- if (choice === "coverageSummaryPath") {
465
- const result = await clack3.text({
466
- message: "Coverage summary path (relative to package root)?",
467
- initialValue: state.coverageSummaryPath,
468
- validate: (v) => {
469
- if (typeof v !== "string" || v.trim().length === 0) return "Path cannot be empty";
470
- }
471
- });
472
- assertNotCancelled(result);
473
- state.coverageSummaryPath = result.trim();
474
- }
475
- if (choice === "coverageCommand") {
476
- const result = await clack3.text({
477
- message: "Coverage command (blank to auto-detect from package.json)?",
478
- initialValue: state.coverageCommand ?? "",
479
- placeholder: "(auto-detect from package.json test runner)"
480
- });
481
- assertNotCancelled(result);
482
- const trimmed = result.trim();
483
- state.coverageCommand = trimmed.length > 0 ? trimmed : void 0;
484
- }
485
- if (choice === "enforceNaming") {
486
- const result = await clack3.confirm({
487
- message: state.fileNamingValue ? `Enforce file naming? (detected: ${state.fileNamingValue})` : "Enforce file naming?",
488
- initialValue: state.enforceNaming
489
- });
490
- assertNotCancelled(result);
491
- state.enforceNaming = result;
492
- }
493
- if (choice === "fileNaming") {
494
- const selected = await clack3.select({
495
- message: "Which file naming convention should be enforced?",
496
- options: [
497
- { value: "kebab-case", label: "kebab-case" },
498
- { value: "camelCase", label: "camelCase" },
499
- { value: "PascalCase", label: "PascalCase" },
500
- { value: "snake_case", label: "snake_case" }
501
- ],
502
- initialValue: state.fileNamingValue
503
- });
504
- assertNotCancelled(selected);
505
- state.fileNamingValue = selected;
506
- }
507
604
  }
508
605
 
509
606
  // src/utils/prompt-rules.ts
510
- function getRootPackage(packages) {
511
- return packages.find((pkg) => pkg.path === ".") ?? packages[0];
512
- }
513
607
  async function promptRuleMenu(defaults) {
514
608
  const state = {
515
609
  ...defaults,
@@ -526,10 +620,14 @@ async function promptRuleMenu(defaults) {
526
620
  }
527
621
  return {
528
622
  maxFileLines: state.maxFileLines,
623
+ maxTestFileLines: state.maxTestFileLines,
529
624
  testCoverage: state.testCoverage,
530
625
  enforceMissingTests: state.enforceMissingTests,
531
626
  enforceNaming: state.enforceNaming,
532
627
  fileNamingValue: state.fileNamingValue,
628
+ componentNaming: state.componentNaming,
629
+ hookNaming: state.hookNaming,
630
+ importAlias: state.importAlias,
533
631
  coverageSummaryPath: state.coverageSummaryPath,
534
632
  coverageCommand: state.coverageCommand,
535
633
  packageOverrides: state.packageOverrides
@@ -577,30 +675,6 @@ async function promptExistingConfigAction(configFile) {
577
675
  assertNotCancelled(result);
578
676
  return result;
579
677
  }
580
- async function promptInitDecision() {
581
- const result = await clack5.select({
582
- message: "How do you want to proceed?",
583
- options: [
584
- {
585
- value: "accept",
586
- label: "Accept defaults",
587
- hint: "writes the config with these defaults; use --enforce in CI to block"
588
- },
589
- {
590
- value: "customize",
591
- label: "Customize rules",
592
- hint: "edit limits, naming, test coverage, and package overrides"
593
- },
594
- {
595
- value: "review",
596
- label: "Review detected details",
597
- hint: "show the full scan report with package and structure details"
598
- }
599
- ]
600
- });
601
- assertNotCancelled(result);
602
- return result;
603
- }
604
678
 
605
679
  // src/utils/resolve-workspace-packages.ts
606
680
  import * as fs2 from "fs";
@@ -1218,13 +1292,13 @@ function checkMissingTests(projectRoot, config, severity) {
1218
1292
  const testSuffix = testPattern.replace("*", "");
1219
1293
  const sourceFiles = collectSourceFiles(srcPath, projectRoot);
1220
1294
  for (const relFile of sourceFiles) {
1221
- const basename9 = path6.basename(relFile);
1222
- if (basename9.includes(".test.") || basename9.includes(".spec.") || basename9.startsWith("index.") || basename9.endsWith(".d.ts")) {
1295
+ const basename10 = path6.basename(relFile);
1296
+ if (basename10.includes(".test.") || basename10.includes(".spec.") || basename10.startsWith("index.") || basename10.endsWith(".d.ts")) {
1223
1297
  continue;
1224
1298
  }
1225
- const ext = path6.extname(basename9);
1299
+ const ext = path6.extname(basename10);
1226
1300
  if (!SOURCE_EXTS2.has(ext)) continue;
1227
- const stem = basename9.slice(0, -ext.length);
1301
+ const stem = basename10.slice(0, -ext.length);
1228
1302
  const expectedTestFile = `${stem}${testSuffix}`;
1229
1303
  const dir = path6.dirname(path6.join(projectRoot, relFile));
1230
1304
  const colocatedTest = path6.join(dir, expectedTestFile);
@@ -1305,9 +1379,9 @@ async function checkCommand(options, cwd) {
1305
1379
  }
1306
1380
  const violations = [];
1307
1381
  const severity = options.enforce ? "error" : "warn";
1308
- const log7 = options.format !== "json" && !options.hook && !options.quiet ? (msg) => process.stderr.write(chalk3.dim(msg)) : () => {
1382
+ const log8 = options.format !== "json" && !options.hook && !options.quiet ? (msg) => process.stderr.write(chalk3.dim(msg)) : () => {
1309
1383
  };
1310
- log7(" Checking files...");
1384
+ log8(" Checking files...");
1311
1385
  for (const file of filesToCheck) {
1312
1386
  const absPath = path7.isAbsolute(file) ? file : path7.join(projectRoot, file);
1313
1387
  const relPath = path7.relative(projectRoot, absPath);
@@ -1340,9 +1414,9 @@ async function checkCommand(options, cwd) {
1340
1414
  }
1341
1415
  }
1342
1416
  }
1343
- log7(" done\n");
1417
+ log8(" done\n");
1344
1418
  if (!options.files) {
1345
- log7(" Checking missing tests...");
1419
+ log8(" Checking missing tests...");
1346
1420
  const testViolations = checkMissingTests(projectRoot, config, severity);
1347
1421
  if (options.staged) {
1348
1422
  const stagedSet = new Set(filesToCheck);
@@ -1355,14 +1429,14 @@ async function checkCommand(options, cwd) {
1355
1429
  } else {
1356
1430
  violations.push(...testViolations);
1357
1431
  }
1358
- log7(" done\n");
1432
+ log8(" done\n");
1359
1433
  }
1360
1434
  if (!options.files && !options.staged && !options.diffBase) {
1361
- log7(" Running test coverage...\n");
1435
+ log8(" Running test coverage...\n");
1362
1436
  const coverageViolations = checkCoverage(projectRoot, config, filesToCheck, {
1363
1437
  staged: options.staged,
1364
1438
  enforce: options.enforce,
1365
- onProgress: (pkg) => log7(` Coverage: ${pkg}...
1439
+ onProgress: (pkg) => log8(` Coverage: ${pkg}...
1366
1440
  `)
1367
1441
  });
1368
1442
  violations.push(...coverageViolations);
@@ -1387,7 +1461,7 @@ async function checkCommand(options, cwd) {
1387
1461
  severity
1388
1462
  });
1389
1463
  }
1390
- log7(` Boundary check: ${graph.nodes.length} files in ${Date.now() - startTime}ms
1464
+ log8(` Boundary check: ${graph.nodes.length} files in ${Date.now() - startTime}ms
1391
1465
  `);
1392
1466
  }
1393
1467
  if (options.format === "json") {
@@ -1654,15 +1728,6 @@ function formatMonorepoResultsText(scanResult) {
1654
1728
  }
1655
1729
 
1656
1730
  // src/display.ts
1657
- var INIT_OVERVIEW_NAMES = {
1658
- typescript: "TypeScript",
1659
- javascript: "JavaScript",
1660
- eslint: "ESLint",
1661
- prettier: "Prettier",
1662
- jest: "Jest",
1663
- vitest: "Vitest",
1664
- biome: "Biome"
1665
- };
1666
1731
  function formatItem(item, nameMap) {
1667
1732
  const name = nameMap?.[item.name] ?? item.name;
1668
1733
  return item.version ? `${name} ${item.version}` : name;
@@ -1792,134 +1857,6 @@ function displayRulesPreview(config) {
1792
1857
  );
1793
1858
  console.log("");
1794
1859
  }
1795
- function formatDetectedOverview(scanResult) {
1796
- const { stack } = scanResult;
1797
- const primaryParts = [];
1798
- const secondaryParts = [];
1799
- const formatOverviewItem = (item, nameMap) => formatItem(item, { ...INIT_OVERVIEW_NAMES, ...nameMap });
1800
- if (scanResult.packages.length > 1) {
1801
- primaryParts.push("monorepo");
1802
- primaryParts.push(`${scanResult.packages.length} packages`);
1803
- } else if (stack.framework) {
1804
- primaryParts.push(formatItem(stack.framework, FRAMEWORK_NAMES2));
1805
- } else {
1806
- primaryParts.push("single package");
1807
- }
1808
- primaryParts.push(formatOverviewItem(stack.language));
1809
- if (stack.styling) {
1810
- primaryParts.push(formatOverviewItem(stack.styling, STYLING_NAMES2));
1811
- }
1812
- if (stack.packageManager) secondaryParts.push(formatOverviewItem(stack.packageManager));
1813
- if (stack.linter) secondaryParts.push(formatOverviewItem(stack.linter));
1814
- if (stack.formatter) secondaryParts.push(formatOverviewItem(stack.formatter));
1815
- if (stack.testRunner) secondaryParts.push(formatOverviewItem(stack.testRunner));
1816
- const primary = primaryParts.map((part) => chalk5.cyan(part)).join(chalk5.dim(" \xB7 "));
1817
- const secondary = secondaryParts.join(chalk5.dim(" \xB7 "));
1818
- return secondary ? `${primary}
1819
- ${chalk5.dim(secondary)}` : primary;
1820
- }
1821
- function displayInitOverview(scanResult, config, exemptedPackages) {
1822
- const root = config.packages.find((p) => p.path === ".") ?? config.packages[0];
1823
- const isMonorepo = config.packages.length > 1;
1824
- const ok = chalk5.green("\u2713");
1825
- const info = chalk5.yellow("~");
1826
- console.log("");
1827
- console.log(` ${chalk5.bold("Ready to initialize:")}`);
1828
- console.log(` ${formatDetectedOverview(scanResult)}`);
1829
- console.log("");
1830
- console.log(` ${chalk5.bold("Rules to apply:")}`);
1831
- console.log(` ${ok} Max file size: ${chalk5.cyan(`${config.rules.maxFileLines} lines`)}`);
1832
- const fileNaming = root?.conventions?.fileNaming ?? config.packages.find((p) => p.conventions?.fileNaming)?.conventions?.fileNaming;
1833
- if (config.rules.enforceNaming && fileNaming) {
1834
- console.log(` ${ok} File naming: ${chalk5.cyan(fileNaming)}`);
1835
- } else {
1836
- console.log(` ${info} File naming: ${chalk5.dim("not enforced")}`);
1837
- }
1838
- const testPattern = root?.structure?.testPattern ?? config.packages.find((p) => p.structure?.testPattern)?.structure?.testPattern;
1839
- if (config.rules.enforceMissingTests && testPattern) {
1840
- console.log(` ${ok} Missing tests: ${chalk5.cyan(`enforced (${testPattern})`)}`);
1841
- } else if (config.rules.enforceMissingTests) {
1842
- console.log(` ${ok} Missing tests: ${chalk5.cyan("enforced")}`);
1843
- } else {
1844
- console.log(` ${info} Missing tests: ${chalk5.dim("not enforced")}`);
1845
- }
1846
- if (config.rules.testCoverage > 0) {
1847
- if (isMonorepo) {
1848
- const withCoverage = config.packages.filter(
1849
- (p) => (p.rules?.testCoverage ?? config.rules.testCoverage) > 0
1850
- );
1851
- console.log(
1852
- ` ${ok} Coverage: ${chalk5.cyan(`${config.rules.testCoverage}%`)} default ${chalk5.dim(`(${withCoverage.length}/${config.packages.length} packages)`)}`
1853
- );
1854
- } else {
1855
- console.log(` ${ok} Coverage: ${chalk5.cyan(`${config.rules.testCoverage}%`)}`);
1856
- }
1857
- } else {
1858
- console.log(` ${info} Coverage: ${chalk5.dim("disabled")}`);
1859
- }
1860
- if (exemptedPackages.length > 0) {
1861
- console.log(
1862
- ` ${chalk5.dim(" exempted:")} ${chalk5.dim(exemptedPackages.join(", "))} ${chalk5.dim("(types-only)")}`
1863
- );
1864
- }
1865
- console.log("");
1866
- console.log(` ${chalk5.bold("Also available:")}`);
1867
- if (isMonorepo) {
1868
- console.log(` ${info} Infer boundaries from current imports`);
1869
- }
1870
- console.log(` ${info} Set up hooks, Claude integration, and CI checks`);
1871
- console.log(
1872
- `
1873
- ${chalk5.dim("Defaults warn locally. Use --enforce in CI when you want failures to block.")}`
1874
- );
1875
- console.log("");
1876
- }
1877
- function summarizeSelectedIntegrations(integrations, opts) {
1878
- const lines = [];
1879
- if (opts.hasBoundaries) {
1880
- lines.push("\u2713 Boundary rules: inferred from current imports");
1881
- } else {
1882
- lines.push("~ Boundary rules: not enabled");
1883
- }
1884
- if (opts.hasCoverage) {
1885
- lines.push("\u2713 Coverage checks: enabled");
1886
- } else {
1887
- lines.push("~ Coverage checks: disabled");
1888
- }
1889
- const selectedIntegrations = [
1890
- integrations.preCommitHook ? "pre-commit hook" : void 0,
1891
- integrations.typecheckHook ? "typecheck" : void 0,
1892
- integrations.lintHook ? "lint check" : void 0,
1893
- integrations.claudeCodeHook ? "Claude Code hook" : void 0,
1894
- integrations.claudeMdRef ? "CLAUDE.md reference" : void 0,
1895
- integrations.githubAction ? "GitHub Actions workflow" : void 0
1896
- ].filter(Boolean);
1897
- if (selectedIntegrations.length > 0) {
1898
- lines.push(`\u2713 Integrations: ${selectedIntegrations.join(" \xB7 ")}`);
1899
- } else {
1900
- lines.push("~ Integrations: none selected");
1901
- }
1902
- return lines;
1903
- }
1904
- function displaySetupPlan(config, integrations, opts = {}) {
1905
- const configFile = opts.configFile ?? "viberails.config.json";
1906
- const lines = summarizeSelectedIntegrations(integrations, {
1907
- hasBoundaries: config.rules.enforceBoundaries,
1908
- hasCoverage: config.rules.testCoverage > 0
1909
- });
1910
- console.log("");
1911
- console.log(` ${chalk5.bold("Ready to write:")}`);
1912
- console.log(
1913
- ` ${opts.replacingExistingConfig ? chalk5.yellow("!") : chalk5.green("\u2713")} ${configFile}${opts.replacingExistingConfig ? chalk5.dim(" (replacing existing config)") : ""}`
1914
- );
1915
- console.log(` ${chalk5.green("\u2713")} .viberails/context.md`);
1916
- console.log(` ${chalk5.green("\u2713")} .viberails/scan-result.json`);
1917
- for (const line of lines) {
1918
- const icon = line.startsWith("\u2713") ? chalk5.green("\u2713") : chalk5.yellow("~");
1919
- console.log(` ${icon} ${line.slice(2)}`);
1920
- }
1921
- console.log("");
1922
- }
1923
1860
 
1924
1861
  // src/display-text.ts
1925
1862
  function plainConfidenceLabel(convention) {
@@ -2038,7 +1975,9 @@ function formatScanResultsText(scanResult) {
2038
1975
  // src/utils/apply-rule-overrides.ts
2039
1976
  function applyRuleOverrides(config, overrides) {
2040
1977
  if (overrides.packageOverrides) config.packages = overrides.packageOverrides;
1978
+ const rootPkg = getRootPackage(config.packages);
2041
1979
  config.rules.maxFileLines = overrides.maxFileLines;
1980
+ config.rules.maxTestFileLines = overrides.maxTestFileLines;
2042
1981
  config.rules.testCoverage = overrides.testCoverage;
2043
1982
  config.rules.enforceMissingTests = overrides.enforceMissingTests;
2044
1983
  config.rules.enforceNaming = overrides.enforceNaming;
@@ -2052,7 +1991,6 @@ function applyRuleOverrides(config, overrides) {
2052
1991
  }
2053
1992
  }
2054
1993
  if (overrides.fileNamingValue) {
2055
- const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2056
1994
  const oldNaming = rootPkg.conventions?.fileNaming;
2057
1995
  rootPkg.conventions = rootPkg.conventions ?? {};
2058
1996
  rootPkg.conventions.fileNaming = overrides.fileNamingValue;
@@ -2064,6 +2002,18 @@ function applyRuleOverrides(config, overrides) {
2064
2002
  }
2065
2003
  }
2066
2004
  }
2005
+ if (rootPkg) {
2006
+ rootPkg.conventions = rootPkg.conventions ?? {};
2007
+ if (overrides.componentNaming !== void 0) {
2008
+ rootPkg.conventions.componentNaming = overrides.componentNaming || void 0;
2009
+ }
2010
+ if (overrides.hookNaming !== void 0) {
2011
+ rootPkg.conventions.hookNaming = overrides.hookNaming || void 0;
2012
+ }
2013
+ if (overrides.importAlias !== void 0) {
2014
+ rootPkg.conventions.importAlias = overrides.importAlias || void 0;
2015
+ }
2016
+ }
2067
2017
  }
2068
2018
 
2069
2019
  // src/utils/diff-configs.ts
@@ -2240,10 +2190,14 @@ async function configCommand(options, cwd) {
2240
2190
  const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
2241
2191
  const overrides = await promptRuleMenu({
2242
2192
  maxFileLines: config.rules.maxFileLines,
2193
+ maxTestFileLines: config.rules.maxTestFileLines,
2243
2194
  testCoverage: config.rules.testCoverage,
2244
2195
  enforceMissingTests: config.rules.enforceMissingTests,
2245
2196
  enforceNaming: config.rules.enforceNaming,
2246
2197
  fileNamingValue: rootPkg.conventions?.fileNaming,
2198
+ componentNaming: rootPkg.conventions?.componentNaming,
2199
+ hookNaming: rootPkg.conventions?.hookNaming,
2200
+ importAlias: rootPkg.conventions?.importAlias,
2247
2201
  coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
2248
2202
  coverageCommand: config.defaults?.coverage?.command,
2249
2203
  packageOverrides: config.packages
@@ -2624,10 +2578,10 @@ function generateTestStub(sourceRelPath, config, projectRoot) {
2624
2578
  const pkg = resolvePackageForFile(sourceRelPath, config);
2625
2579
  const testPattern = pkg?.structure?.testPattern;
2626
2580
  if (!testPattern) return null;
2627
- const basename9 = path12.basename(sourceRelPath);
2628
- const ext = path12.extname(basename9);
2581
+ const basename10 = path12.basename(sourceRelPath);
2582
+ const ext = path12.extname(basename10);
2629
2583
  if (!ext) return null;
2630
- const stem = basename9.slice(0, -ext.length);
2584
+ const stem = basename10.slice(0, -ext.length);
2631
2585
  const testSuffix = testPattern.replace("*", "");
2632
2586
  const testFilename = `${stem}${testSuffix}`;
2633
2587
  const dir = path12.dirname(path12.join(projectRoot, sourceRelPath));
@@ -2799,169 +2753,601 @@ ${chalk8.yellow("!")} No safe fixes to apply. Resolve aliased imports first.`);
2799
2753
  }
2800
2754
 
2801
2755
  // src/commands/init.ts
2802
- import * as fs19 from "fs";
2803
- import * as path19 from "path";
2756
+ import * as fs21 from "fs";
2757
+ import * as path21 from "path";
2758
+ import * as clack12 from "@clack/prompts";
2759
+ import { compactConfig as compactConfig4, generateConfig as generateConfig2 } from "@viberails/config";
2760
+ import { scan as scan3 } from "@viberails/scanner";
2761
+ import chalk13 from "chalk";
2762
+
2763
+ // src/utils/check-prerequisites.ts
2764
+ import * as fs14 from "fs";
2765
+ import * as path14 from "path";
2766
+ import * as clack7 from "@clack/prompts";
2767
+ import chalk9 from "chalk";
2768
+
2769
+ // src/utils/spawn-async.ts
2770
+ import { spawn } from "child_process";
2771
+ function spawnAsync(command, cwd) {
2772
+ return new Promise((resolve4) => {
2773
+ const child = spawn(command, { cwd, shell: true, stdio: "pipe" });
2774
+ let stdout = "";
2775
+ let stderr = "";
2776
+ child.stdout.on("data", (d) => {
2777
+ stdout += d.toString();
2778
+ });
2779
+ child.stderr.on("data", (d) => {
2780
+ stderr += d.toString();
2781
+ });
2782
+ child.on("close", (status) => {
2783
+ resolve4({ status, stdout, stderr });
2784
+ });
2785
+ child.on("error", () => {
2786
+ resolve4({ status: 1, stdout, stderr });
2787
+ });
2788
+ });
2789
+ }
2790
+
2791
+ // src/utils/check-prerequisites.ts
2792
+ function checkCoveragePrereqs(projectRoot, scanResult) {
2793
+ const pm = scanResult.stack.packageManager.name;
2794
+ const vitestPackages = scanResult.packages.filter((pkg) => pkg.stack.testRunner?.name === "vitest").map((pkg) => pkg.relativePath);
2795
+ const hasVitest = vitestPackages.length > 0 || scanResult.stack.testRunner?.name === "vitest";
2796
+ if (!hasVitest) return [];
2797
+ let installed = hasDependency(projectRoot, "@vitest/coverage-v8") || hasDependency(projectRoot, "@vitest/coverage-istanbul");
2798
+ if (!installed && vitestPackages.length > 0) {
2799
+ installed = vitestPackages.every((rel) => {
2800
+ const pkgDir = path14.join(projectRoot, rel);
2801
+ return hasDependency(pkgDir, "@vitest/coverage-v8") || hasDependency(pkgDir, "@vitest/coverage-istanbul");
2802
+ });
2803
+ }
2804
+ const isWorkspace = scanResult.packages.length > 1;
2805
+ const addCmd = pm === "yarn" ? "yarn add -D" : pm === "pnpm" && isWorkspace ? "pnpm add -D -w" : pm === "npm" ? "npm install -D" : `${pm} add -D`;
2806
+ const affectedPackages = vitestPackages.length > 1 ? vitestPackages : void 0;
2807
+ const reason = affectedPackages ? `Required for coverage in: ${affectedPackages.join(", ")}` : "Required for coverage percentage checks with vitest";
2808
+ return [
2809
+ {
2810
+ label: "@vitest/coverage-v8",
2811
+ installed,
2812
+ installCommand: installed ? void 0 : `${addCmd} @vitest/coverage-v8`,
2813
+ reason,
2814
+ affectedPackages
2815
+ }
2816
+ ];
2817
+ }
2818
+ function displayMissingPrereqs(prereqs) {
2819
+ const missing = prereqs.filter((p) => !p.installed);
2820
+ for (const m of missing) {
2821
+ const suffix = m.affectedPackages ? ` \u2014 needed for coverage in: ${m.affectedPackages.join(", ")}` : ` \u2014 ${m.reason}`;
2822
+ console.log(` ${chalk9.yellow("!")} ${m.label} not installed${suffix}`);
2823
+ if (m.installCommand) {
2824
+ console.log(` Install: ${chalk9.cyan(m.installCommand)}`);
2825
+ }
2826
+ }
2827
+ }
2828
+ function planCoverageInstall(prereqs) {
2829
+ const missing = prereqs.find((p) => !p.installed && p.installCommand);
2830
+ if (!missing?.installCommand) return void 0;
2831
+ return {
2832
+ label: missing.label,
2833
+ command: missing.installCommand
2834
+ };
2835
+ }
2836
+ function hasDependency(projectRoot, name) {
2837
+ try {
2838
+ const pkgPath = path14.join(projectRoot, "package.json");
2839
+ const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf-8"));
2840
+ return !!(pkg.devDependencies?.[name] || pkg.dependencies?.[name]);
2841
+ } catch {
2842
+ return false;
2843
+ }
2844
+ }
2845
+
2846
+ // src/utils/deferred-install.ts
2804
2847
  import * as clack8 from "@clack/prompts";
2805
- import { compactConfig as compactConfig3, generateConfig } from "@viberails/config";
2806
- import { scan as scan2 } from "@viberails/scanner";
2807
- import chalk12 from "chalk";
2848
+ async function executeDeferredInstalls(projectRoot, installs) {
2849
+ if (installs.length === 0) return 0;
2850
+ let successCount = 0;
2851
+ for (const install of installs) {
2852
+ const s = clack8.spinner();
2853
+ s.start(`Installing ${install.label}...`);
2854
+ const result = await spawnAsync(install.command, projectRoot);
2855
+ if (result.status === 0) {
2856
+ s.stop(`Installed ${install.label}`);
2857
+ install.onSuccess?.();
2858
+ successCount++;
2859
+ } else {
2860
+ s.stop(`Failed to install ${install.label}`);
2861
+ clack8.log.warn(`Install manually: ${install.command}`);
2862
+ install.onFailure?.();
2863
+ }
2864
+ }
2865
+ return successCount;
2866
+ }
2867
+
2868
+ // src/utils/prompt-main-menu.ts
2869
+ import * as clack10 from "@clack/prompts";
2870
+
2871
+ // src/utils/prompt-integrations.ts
2872
+ import * as fs15 from "fs";
2873
+ import * as path15 from "path";
2874
+ import * as clack9 from "@clack/prompts";
2875
+ function buildLefthookInstallCommand(pm, isWorkspace) {
2876
+ if (pm === "yarn") return "yarn add -D lefthook";
2877
+ if (pm === "pnpm") return `pnpm add -D${isWorkspace ? " -w" : ""} lefthook`;
2878
+ if (pm === "npm") return "npm install -D lefthook";
2879
+ return `${pm} add -D lefthook`;
2880
+ }
2881
+ async function promptIntegrationsDeferred(hookManager, tools, packageManager, isWorkspace, projectRoot) {
2882
+ const options = [];
2883
+ const needsLefthook = !hookManager;
2884
+ if (needsLefthook) {
2885
+ const pm = packageManager ?? "npm";
2886
+ options.push({
2887
+ value: "installLefthook",
2888
+ label: "Install Lefthook",
2889
+ hint: `after final confirmation \u2014 ${buildLefthookInstallCommand(pm, isWorkspace)}`
2890
+ });
2891
+ }
2892
+ const hookLabel = hookManager ? `Pre-commit hook (${hookManager})` : "Pre-commit hook";
2893
+ const hookHint = needsLefthook ? "uses Lefthook if installed above, otherwise local git hook" : "runs viberails checks when you commit";
2894
+ options.push({ value: "preCommit", label: hookLabel, hint: hookHint });
2895
+ if (tools?.isTypeScript) {
2896
+ options.push({
2897
+ value: "typecheck",
2898
+ label: "Typecheck (tsc --noEmit)",
2899
+ hint: "pre-commit hook + CI check"
2900
+ });
2901
+ }
2902
+ if (tools?.linter) {
2903
+ const linterName = tools.linter === "biome" ? "Biome" : "ESLint";
2904
+ options.push({
2905
+ value: "lint",
2906
+ label: `Lint check (${linterName})`,
2907
+ hint: "pre-commit hook + CI check"
2908
+ });
2909
+ }
2910
+ options.push(
2911
+ {
2912
+ value: "claude",
2913
+ label: "Claude Code hook",
2914
+ hint: "checks files when Claude edits them"
2915
+ },
2916
+ {
2917
+ value: "claudeMd",
2918
+ label: "CLAUDE.md reference",
2919
+ hint: "appends @.viberails/context.md so Claude loads rules automatically"
2920
+ },
2921
+ {
2922
+ value: "githubAction",
2923
+ label: "GitHub Actions workflow",
2924
+ hint: "blocks PRs that fail viberails check"
2925
+ }
2926
+ );
2927
+ const initialValues = options.map((o) => o.value);
2928
+ const result = await clack9.multiselect({
2929
+ message: "Integrations",
2930
+ options,
2931
+ initialValues,
2932
+ required: false
2933
+ });
2934
+ assertNotCancelled(result);
2935
+ let lefthookInstall;
2936
+ if (needsLefthook && result.includes("installLefthook")) {
2937
+ const pm = packageManager ?? "npm";
2938
+ lefthookInstall = {
2939
+ label: "Lefthook",
2940
+ command: buildLefthookInstallCommand(pm, isWorkspace),
2941
+ onSuccess: projectRoot ? () => {
2942
+ const ymlPath = path15.join(projectRoot, "lefthook.yml");
2943
+ if (!fs15.existsSync(ymlPath)) {
2944
+ fs15.writeFileSync(ymlPath, "# Generated by viberails\n");
2945
+ }
2946
+ } : void 0
2947
+ };
2948
+ }
2949
+ return {
2950
+ choice: {
2951
+ preCommitHook: result.includes("preCommit"),
2952
+ claudeCodeHook: result.includes("claude"),
2953
+ claudeMdRef: result.includes("claudeMd"),
2954
+ githubAction: result.includes("githubAction"),
2955
+ typecheckHook: result.includes("typecheck"),
2956
+ lintHook: result.includes("lint")
2957
+ },
2958
+ lefthookInstall
2959
+ };
2960
+ }
2961
+
2962
+ // src/utils/prompt-main-menu-hints.ts
2963
+ function fileLimitsHint(config) {
2964
+ const max = config.rules.maxFileLines;
2965
+ const test = config.rules.maxTestFileLines;
2966
+ return test > 0 ? `${max} lines, tests ${test}` : `${max} lines`;
2967
+ }
2968
+ function fileNamingHint(config, scanResult) {
2969
+ const rootPkg = getRootPackage(config.packages);
2970
+ const naming = rootPkg.conventions?.fileNaming;
2971
+ if (!config.rules.enforceNaming) return "not enforced";
2972
+ if (naming) {
2973
+ const detected = scanResult.packages.some(
2974
+ (p) => p.conventions.fileNaming?.value === naming && p.conventions.fileNaming.confidence === "high"
2975
+ );
2976
+ return detected ? `${naming} (detected)` : naming;
2977
+ }
2978
+ return "mixed \u2014 will not enforce if skipped";
2979
+ }
2980
+ function fileNamingStatus(config) {
2981
+ if (!config.rules.enforceNaming) return "disabled";
2982
+ const rootPkg = getRootPackage(config.packages);
2983
+ return rootPkg.conventions?.fileNaming ? "ok" : "needs-input";
2984
+ }
2985
+ function missingTestsHint(config) {
2986
+ if (!config.rules.enforceMissingTests) return "not enforced";
2987
+ const rootPkg = getRootPackage(config.packages);
2988
+ const pattern = rootPkg.structure?.testPattern;
2989
+ return pattern ? `enforced (${pattern})` : "enforced";
2990
+ }
2991
+ function coverageHint(config, hasTestRunner) {
2992
+ if (config.rules.testCoverage === 0) return "disabled";
2993
+ if (!hasTestRunner)
2994
+ return `${config.rules.testCoverage}% target (inactive \u2014 no test runner)`;
2995
+ const isMonorepo = config.packages.length > 1;
2996
+ if (isMonorepo) {
2997
+ const withCov = config.packages.filter(
2998
+ (p) => (p.rules?.testCoverage ?? config.rules.testCoverage) > 0
2999
+ );
3000
+ const exempt = config.packages.length - withCov.length;
3001
+ return exempt > 0 ? `${config.rules.testCoverage}% (${withCov.length}/${config.packages.length} packages, ${exempt} exempt)` : `${config.rules.testCoverage}%`;
3002
+ }
3003
+ return `${config.rules.testCoverage}%`;
3004
+ }
3005
+ function advancedNamingHint(config) {
3006
+ const rootPkg = getRootPackage(config.packages);
3007
+ const parts = [];
3008
+ if (rootPkg.conventions?.componentNaming)
3009
+ parts.push(`${rootPkg.conventions.componentNaming} components`);
3010
+ if (rootPkg.conventions?.hookNaming) parts.push(`${rootPkg.conventions.hookNaming} hooks`);
3011
+ if (rootPkg.conventions?.importAlias) parts.push(rootPkg.conventions.importAlias);
3012
+ return parts.length > 0 ? parts.join(", ") : "component, hook, and alias conventions";
3013
+ }
3014
+ function integrationsHint(state) {
3015
+ if (!state.visited.integrations || !state.integrations)
3016
+ return "not configured \u2014 select to set up";
3017
+ const items = [];
3018
+ if (state.integrations.preCommitHook) items.push("pre-commit");
3019
+ if (state.integrations.typecheckHook) items.push("typecheck");
3020
+ if (state.integrations.lintHook) items.push("lint");
3021
+ if (state.integrations.claudeCodeHook) items.push("Claude");
3022
+ if (state.integrations.claudeMdRef) items.push("CLAUDE.md");
3023
+ if (state.integrations.githubAction) items.push("CI");
3024
+ return items.length > 0 ? items.join(" \xB7 ") : "none selected";
3025
+ }
3026
+ function packageOverridesHint(config) {
3027
+ const rootNaming = getRootPackage(config.packages).conventions?.fileNaming;
3028
+ const editable = config.packages.filter((p) => p.path !== ".");
3029
+ const customized = editable.filter(
3030
+ (p) => p.rules || p.coverage || p.conventions?.fileNaming !== void 0 && p.conventions.fileNaming !== rootNaming
3031
+ ).length;
3032
+ return customized > 0 ? `${editable.length} packages (${customized} customized)` : `${editable.length} packages`;
3033
+ }
3034
+ function boundariesHint(config, state) {
3035
+ if (!state.visited.boundaries || !config.rules.enforceBoundaries) return "not enabled";
3036
+ const deny = config.boundaries?.deny;
3037
+ if (!deny) return "enabled";
3038
+ const ruleCount = Object.values(deny).reduce((s, a) => s + a.length, 0);
3039
+ const pkgCount = Object.keys(deny).length;
3040
+ return `${ruleCount} rules across ${pkgCount} packages`;
3041
+ }
3042
+ function statusIcon(status) {
3043
+ if (status === "ok") return "\u2713";
3044
+ if (status === "needs-input") return "?";
3045
+ return "~";
3046
+ }
3047
+ function buildMainMenuOptions(config, scanResult, state) {
3048
+ const namingStatus = fileNamingStatus(config);
3049
+ const coverageStatus = config.rules.testCoverage === 0 ? "disabled" : !state.hasTestRunner ? "disabled" : "ok";
3050
+ const missingTestsStatus = config.rules.enforceMissingTests ? "ok" : "disabled";
3051
+ const options = [
3052
+ {
3053
+ value: "fileLimits",
3054
+ label: `${statusIcon("ok")} Max file size`,
3055
+ hint: fileLimitsHint(config)
3056
+ },
3057
+ {
3058
+ value: "fileNaming",
3059
+ label: `${statusIcon(namingStatus)} File naming`,
3060
+ hint: fileNamingHint(config, scanResult)
3061
+ },
3062
+ {
3063
+ value: "missingTests",
3064
+ label: `${statusIcon(missingTestsStatus)} Missing tests`,
3065
+ hint: missingTestsHint(config)
3066
+ },
3067
+ {
3068
+ value: "coverage",
3069
+ label: `${statusIcon(coverageStatus)} Coverage`,
3070
+ hint: coverageHint(config, state.hasTestRunner)
3071
+ },
3072
+ { value: "advancedNaming", label: " Advanced naming", hint: advancedNamingHint(config) }
3073
+ ];
3074
+ if (config.packages.length > 1) {
3075
+ const bIcon = state.visited.boundaries && config.rules.enforceBoundaries ? statusIcon("ok") : " ";
3076
+ options.push(
3077
+ {
3078
+ value: "packageOverrides",
3079
+ label: " Per-package overrides",
3080
+ hint: packageOverridesHint(config)
3081
+ },
3082
+ { value: "boundaries", label: `${bIcon} Boundaries`, hint: boundariesHint(config, state) }
3083
+ );
3084
+ }
3085
+ const iIcon = state.visited.integrations ? statusIcon("ok") : " ";
3086
+ options.push(
3087
+ { value: "integrations", label: `${iIcon} Integrations`, hint: integrationsHint(state) },
3088
+ { value: "reset", label: " Reset all to defaults" },
3089
+ { value: "review", label: " Review scan details" },
3090
+ { value: "done", label: " Done \u2014 write config" }
3091
+ );
3092
+ return options;
3093
+ }
2808
3094
 
2809
- // src/utils/check-prerequisites.ts
2810
- import * as fs14 from "fs";
2811
- import * as path14 from "path";
2812
- import * as clack7 from "@clack/prompts";
2813
- import chalk9 from "chalk";
2814
- function checkCoveragePrereqs(projectRoot, scanResult) {
2815
- const pm = scanResult.stack.packageManager.name;
2816
- const vitestPackages = scanResult.packages.filter((pkg) => pkg.stack.testRunner?.name === "vitest").map((pkg) => pkg.relativePath);
2817
- const hasVitest = vitestPackages.length > 0 || scanResult.stack.testRunner?.name === "vitest";
2818
- if (!hasVitest) return [];
2819
- let installed = hasDependency(projectRoot, "@vitest/coverage-v8") || hasDependency(projectRoot, "@vitest/coverage-istanbul");
2820
- if (!installed && vitestPackages.length > 0) {
2821
- installed = vitestPackages.every((rel) => {
2822
- const pkgDir = path14.join(projectRoot, rel);
2823
- return hasDependency(pkgDir, "@vitest/coverage-v8") || hasDependency(pkgDir, "@vitest/coverage-istanbul");
2824
- });
3095
+ // src/utils/prompt-main-menu.ts
3096
+ async function handleAdvancedNaming(config) {
3097
+ const rootPkg = getRootPackage(config.packages);
3098
+ const state = {
3099
+ maxFileLines: config.rules.maxFileLines,
3100
+ maxTestFileLines: config.rules.maxTestFileLines,
3101
+ testCoverage: config.rules.testCoverage,
3102
+ enforceMissingTests: config.rules.enforceMissingTests,
3103
+ enforceNaming: config.rules.enforceNaming,
3104
+ fileNamingValue: rootPkg.conventions?.fileNaming,
3105
+ componentNaming: rootPkg.conventions?.componentNaming,
3106
+ hookNaming: rootPkg.conventions?.hookNaming,
3107
+ importAlias: rootPkg.conventions?.importAlias,
3108
+ coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
3109
+ coverageCommand: config.defaults?.coverage?.command
3110
+ };
3111
+ await promptNamingMenu(state);
3112
+ rootPkg.conventions = rootPkg.conventions ?? {};
3113
+ config.rules.enforceNaming = state.enforceNaming;
3114
+ if (state.fileNamingValue) {
3115
+ rootPkg.conventions.fileNaming = state.fileNamingValue;
3116
+ } else {
3117
+ delete rootPkg.conventions.fileNaming;
2825
3118
  }
2826
- const isWorkspace = scanResult.packages.length > 1;
2827
- const addCmd = pm === "yarn" ? "yarn add -D" : pm === "pnpm" && isWorkspace ? "pnpm add -D -w" : pm === "npm" ? "npm install -D" : `${pm} add -D`;
2828
- const affectedPackages = vitestPackages.length > 1 ? vitestPackages : void 0;
2829
- const reason = affectedPackages ? `Required for coverage in: ${affectedPackages.join(", ")}` : "Required for coverage percentage checks with vitest";
2830
- return [
2831
- {
2832
- label: "@vitest/coverage-v8",
2833
- installed,
2834
- installCommand: installed ? void 0 : `${addCmd} @vitest/coverage-v8`,
2835
- reason,
2836
- affectedPackages
3119
+ rootPkg.conventions.componentNaming = state.componentNaming || void 0;
3120
+ rootPkg.conventions.hookNaming = state.hookNaming || void 0;
3121
+ rootPkg.conventions.importAlias = state.importAlias || void 0;
3122
+ }
3123
+ async function promptMainMenu(config, scanResult, opts) {
3124
+ const originalConfig = structuredClone(config);
3125
+ const state = {
3126
+ visited: { integrations: false, boundaries: false },
3127
+ deferredInstalls: [],
3128
+ hasTestRunner: opts.hasTestRunner,
3129
+ hookManager: opts.hookManager
3130
+ };
3131
+ while (true) {
3132
+ const options = buildMainMenuOptions(config, scanResult, state);
3133
+ const choice = await clack10.select({ message: "Configure viberails", options });
3134
+ assertNotCancelled(choice);
3135
+ if (choice === "done") {
3136
+ if (config.rules.enforceNaming && !getRootPackage(config.packages).conventions?.fileNaming) {
3137
+ config.rules.enforceNaming = false;
3138
+ }
3139
+ break;
2837
3140
  }
2838
- ];
3141
+ if (choice === "fileLimits") {
3142
+ const s = {
3143
+ maxFileLines: config.rules.maxFileLines,
3144
+ maxTestFileLines: config.rules.maxTestFileLines
3145
+ };
3146
+ await promptFileLimitsMenu(s);
3147
+ config.rules.maxFileLines = s.maxFileLines;
3148
+ config.rules.maxTestFileLines = s.maxTestFileLines;
3149
+ }
3150
+ if (choice === "fileNaming") await handleFileNaming(config, scanResult);
3151
+ if (choice === "missingTests") await handleMissingTests(config);
3152
+ if (choice === "coverage") await handleCoverage(config, state, opts);
3153
+ if (choice === "advancedNaming") await handleAdvancedNaming(config);
3154
+ if (choice === "packageOverrides") await handlePackageOverrides(config);
3155
+ if (choice === "boundaries") await handleBoundaries(config, state, opts);
3156
+ if (choice === "integrations") await handleIntegrations(state, opts);
3157
+ if (choice === "review") clack10.note(formatScanResultsText(scanResult), "Scan details");
3158
+ if (choice === "reset") {
3159
+ Object.assign(config, structuredClone(originalConfig));
3160
+ state.deferredInstalls = [];
3161
+ state.visited = { integrations: false, boundaries: false };
3162
+ state.integrations = void 0;
3163
+ clack10.log.info("Reset all settings to scan-detected defaults.");
3164
+ }
3165
+ }
3166
+ return state;
2839
3167
  }
2840
- function displayMissingPrereqs(prereqs) {
2841
- const missing = prereqs.filter((p) => !p.installed);
2842
- for (const m of missing) {
2843
- const suffix = m.affectedPackages ? ` \u2014 needed for coverage in: ${m.affectedPackages.join(", ")}` : ` \u2014 ${m.reason}`;
2844
- console.log(` ${chalk9.yellow("!")} ${m.label} not installed${suffix}`);
2845
- if (m.installCommand) {
2846
- console.log(` Install: ${chalk9.cyan(m.installCommand)}`);
3168
+ async function handleFileNaming(config, scanResult) {
3169
+ const isMonorepo = config.packages.length > 1;
3170
+ if (isMonorepo) {
3171
+ const pkgData = scanResult.packages.filter((p) => p.conventions.fileNaming && p.conventions.fileNaming.confidence !== "low").map((p) => ({
3172
+ path: p.relativePath,
3173
+ naming: p.conventions.fileNaming
3174
+ }));
3175
+ if (pkgData.length > 0) {
3176
+ const lines = pkgData.map(
3177
+ (p) => `${p.path}: ${p.naming.value} (${Math.round(p.naming.consistency)}%)`
3178
+ );
3179
+ clack10.note(lines.join("\n"), "Per-package file naming detected");
3180
+ }
3181
+ }
3182
+ const namingOptions = FILE_NAMING_OPTIONS.map((opt) => {
3183
+ if (isMonorepo) {
3184
+ const pkgs = scanResult.packages.filter((p) => p.conventions.fileNaming?.value === opt.value);
3185
+ const hint = pkgs.length > 0 ? `${pkgs.length} package${pkgs.length > 1 ? "s" : ""}` : void 0;
3186
+ return { value: opt.value, label: opt.label, hint };
2847
3187
  }
3188
+ return { value: opt.value, label: opt.label };
3189
+ });
3190
+ const rootPkg = getRootPackage(config.packages);
3191
+ const selected = await clack10.select({
3192
+ message: isMonorepo ? "Default file naming convention" : "File naming convention",
3193
+ options: [...namingOptions, { value: SENTINEL_SKIP, label: "Don't enforce" }],
3194
+ initialValue: rootPkg.conventions?.fileNaming ?? SENTINEL_SKIP
3195
+ });
3196
+ assertNotCancelled(selected);
3197
+ if (selected === SENTINEL_SKIP) {
3198
+ config.rules.enforceNaming = false;
3199
+ if (rootPkg.conventions) delete rootPkg.conventions.fileNaming;
3200
+ } else {
3201
+ config.rules.enforceNaming = true;
3202
+ rootPkg.conventions = rootPkg.conventions ?? {};
3203
+ rootPkg.conventions.fileNaming = selected;
2848
3204
  }
2849
3205
  }
2850
- async function promptMissingPrereqs(projectRoot, prereqs) {
2851
- const missing = prereqs.filter((p) => !p.installed);
2852
- if (missing.length === 0) return { disableCoverage: false };
2853
- const prereqLines = prereqs.map((p) => {
2854
- if (p.installed) return `\u2713 ${p.label}`;
2855
- const detail = p.affectedPackages ? `needed by: ${p.affectedPackages.join(", ")}` : p.reason;
2856
- return `\u2717 ${p.label} \u2014 ${detail}`;
2857
- }).join("\n");
2858
- clack7.note(prereqLines, "Coverage support");
2859
- let disableCoverage = false;
2860
- for (const m of missing) {
2861
- if (!m.installCommand) continue;
2862
- const pkgCount = m.affectedPackages?.length;
2863
- const message = pkgCount ? `${m.label} is not installed. Required for coverage in ${pkgCount} packages using vitest.` : `${m.label} is not installed. It is required for coverage percentage checks.`;
2864
- const choice = await clack7.select({
2865
- message,
3206
+ async function handleMissingTests(config) {
3207
+ const result = await clack10.confirm({
3208
+ message: "Require every source file to have a test file?",
3209
+ initialValue: config.rules.enforceMissingTests
3210
+ });
3211
+ assertNotCancelled(result);
3212
+ config.rules.enforceMissingTests = result;
3213
+ }
3214
+ async function handleCoverage(config, state, opts) {
3215
+ if (!opts.hasTestRunner) {
3216
+ clack10.log.info("Coverage checks are inactive \u2014 no test runner detected.");
3217
+ return;
3218
+ }
3219
+ const planned = planCoverageInstall(opts.coveragePrereqs);
3220
+ if (planned) {
3221
+ const choice = await clack10.select({
3222
+ message: `${planned.label} is not installed. Needed for coverage checks.`,
2866
3223
  options: [
2867
3224
  {
2868
3225
  value: "install",
2869
- label: "Install now",
2870
- hint: m.installCommand
2871
- },
2872
- {
2873
- value: "disable",
2874
- label: "Disable coverage checks",
2875
- hint: "missing-test checks still stay active"
3226
+ label: "Install (after final confirmation)",
3227
+ hint: planned.command
2876
3228
  },
3229
+ { value: "disable", label: "Disable coverage checks" },
2877
3230
  {
2878
3231
  value: "skip",
2879
3232
  label: "Skip for now",
2880
- hint: `install later: ${m.installCommand}`
3233
+ hint: `install later: ${planned.command}`
2881
3234
  }
2882
3235
  ]
2883
3236
  });
2884
3237
  assertNotCancelled(choice);
3238
+ state.deferredInstalls = state.deferredInstalls.filter((d) => d.command !== planned.command);
2885
3239
  if (choice === "install") {
2886
- const is = clack7.spinner();
2887
- is.start(`Installing ${m.label}...`);
2888
- const result = await spawnAsync(m.installCommand, projectRoot);
2889
- if (result.status === 0) {
2890
- is.stop(`Installed ${m.label}`);
2891
- } else {
2892
- is.stop(`Failed to install ${m.label}`);
2893
- clack7.log.warn(
2894
- `Install manually: ${m.installCommand}
2895
- Coverage percentage checks will not work until the dependency is installed.`
2896
- );
2897
- }
3240
+ planned.onFailure = () => {
3241
+ config.rules.testCoverage = 0;
3242
+ };
3243
+ state.deferredInstalls.push(planned);
2898
3244
  } else if (choice === "disable") {
2899
- disableCoverage = true;
2900
- clack7.log.info("Coverage percentage checks disabled. Missing-test checks remain active.");
2901
- } else {
2902
- clack7.log.info(
2903
- `Coverage percentage checks will fail until ${m.label} is installed.
2904
- Install later: ${m.installCommand}`
2905
- );
3245
+ config.rules.testCoverage = 0;
3246
+ return;
2906
3247
  }
2907
3248
  }
2908
- return { disableCoverage };
3249
+ const result = await clack10.text({
3250
+ message: "Test coverage target (0 = disable)?",
3251
+ initialValue: String(config.rules.testCoverage),
3252
+ validate: (v) => {
3253
+ if (typeof v !== "string") return "Enter a number between 0 and 100";
3254
+ const n = Number.parseInt(v, 10);
3255
+ if (Number.isNaN(n) || n < 0 || n > 100) return "Enter a number between 0 and 100";
3256
+ }
3257
+ });
3258
+ assertNotCancelled(result);
3259
+ config.rules.testCoverage = Number.parseInt(result, 10);
2909
3260
  }
2910
- function hasDependency(projectRoot, name) {
3261
+ async function handlePackageOverrides(config) {
3262
+ const rootPkg = getRootPackage(config.packages);
3263
+ config.packages = await promptPackageOverrides(config.packages, {
3264
+ fileNamingValue: rootPkg.conventions?.fileNaming,
3265
+ maxFileLines: config.rules.maxFileLines,
3266
+ testCoverage: config.rules.testCoverage,
3267
+ coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
3268
+ coverageCommand: config.defaults?.coverage?.command
3269
+ });
3270
+ normalizePackageOverrides(config.packages);
3271
+ }
3272
+ async function handleBoundaries(config, state, opts) {
3273
+ const shouldInfer = await clack10.confirm({
3274
+ message: "Infer boundary rules from current import patterns?",
3275
+ initialValue: false
3276
+ });
3277
+ assertNotCancelled(shouldInfer);
3278
+ state.visited.boundaries = true;
3279
+ if (!shouldInfer) {
3280
+ config.rules.enforceBoundaries = false;
3281
+ return;
3282
+ }
3283
+ const bs = clack10.spinner();
3284
+ bs.start("Building import graph...");
2911
3285
  try {
2912
- const pkgPath = path14.join(projectRoot, "package.json");
2913
- const pkg = JSON.parse(fs14.readFileSync(pkgPath, "utf-8"));
2914
- return !!(pkg.devDependencies?.[name] || pkg.dependencies?.[name]);
2915
- } catch {
2916
- return false;
3286
+ const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
3287
+ const packages = resolveWorkspacePackages(opts.projectRoot, config.packages);
3288
+ const graph = await buildImportGraph(opts.projectRoot, { packages, ignore: config.ignore });
3289
+ const inferred = inferBoundaries(graph);
3290
+ const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
3291
+ if (denyCount > 0) {
3292
+ config.boundaries = inferred;
3293
+ config.rules.enforceBoundaries = true;
3294
+ const pkgCount = Object.keys(inferred.deny).length;
3295
+ bs.stop(`Inferred ${denyCount} boundary rules across ${pkgCount} packages`);
3296
+ } else {
3297
+ bs.stop("No boundary rules inferred");
3298
+ }
3299
+ } catch (err) {
3300
+ bs.stop("Failed to build import graph");
3301
+ clack10.log.warn(`Boundary inference failed: ${err instanceof Error ? err.message : err}`);
2917
3302
  }
2918
3303
  }
2919
-
2920
- // src/utils/filter-confidence.ts
2921
- function filterHighConfidence(conventions, meta) {
2922
- if (!meta) return conventions;
2923
- const filtered = {};
2924
- for (const [key, value] of Object.entries(conventions)) {
2925
- if (value === void 0) continue;
2926
- const convMeta = meta[key];
2927
- if (!convMeta || convMeta.confidence === "high") {
2928
- filtered[key] = value;
2929
- }
3304
+ async function handleIntegrations(state, opts) {
3305
+ const result = await promptIntegrationsDeferred(
3306
+ state.hookManager,
3307
+ opts.tools,
3308
+ opts.tools.packageManager,
3309
+ opts.tools.isWorkspace,
3310
+ opts.projectRoot
3311
+ );
3312
+ state.visited.integrations = true;
3313
+ state.integrations = result.choice;
3314
+ state.deferredInstalls = state.deferredInstalls.filter((d) => !d.command.includes("lefthook"));
3315
+ if (result.lefthookInstall) {
3316
+ state.deferredInstalls.push(result.lefthookInstall);
2930
3317
  }
2931
- return filtered;
2932
3318
  }
2933
3319
 
2934
3320
  // src/utils/update-gitignore.ts
2935
- import * as fs15 from "fs";
2936
- import * as path15 from "path";
3321
+ import * as fs16 from "fs";
3322
+ import * as path16 from "path";
2937
3323
  function updateGitignore(projectRoot) {
2938
- const gitignorePath = path15.join(projectRoot, ".gitignore");
3324
+ const gitignorePath = path16.join(projectRoot, ".gitignore");
2939
3325
  let content = "";
2940
- if (fs15.existsSync(gitignorePath)) {
2941
- content = fs15.readFileSync(gitignorePath, "utf-8");
3326
+ if (fs16.existsSync(gitignorePath)) {
3327
+ content = fs16.readFileSync(gitignorePath, "utf-8");
2942
3328
  }
2943
3329
  if (!content.includes(".viberails/scan-result.json")) {
2944
3330
  const block = "\n# viberails\n.viberails/scan-result.json\n";
2945
3331
  const prefix = content.length === 0 ? "" : `${content.trimEnd()}
2946
3332
  `;
2947
- fs15.writeFileSync(gitignorePath, `${prefix}${block}`);
3333
+ fs16.writeFileSync(gitignorePath, `${prefix}${block}`);
2948
3334
  }
2949
3335
  }
2950
3336
 
2951
3337
  // src/commands/init-hooks.ts
2952
- import * as fs17 from "fs";
2953
- import * as path17 from "path";
3338
+ import * as fs18 from "fs";
3339
+ import * as path18 from "path";
2954
3340
  import chalk10 from "chalk";
2955
3341
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
2956
3342
 
2957
3343
  // src/commands/resolve-typecheck.ts
2958
- import * as fs16 from "fs";
2959
- import * as path16 from "path";
3344
+ import * as fs17 from "fs";
3345
+ import * as path17 from "path";
2960
3346
  function hasTurboTask(projectRoot, taskName) {
2961
- const turboPath = path16.join(projectRoot, "turbo.json");
2962
- if (!fs16.existsSync(turboPath)) return false;
3347
+ const turboPath = path17.join(projectRoot, "turbo.json");
3348
+ if (!fs17.existsSync(turboPath)) return false;
2963
3349
  try {
2964
- const turbo = JSON.parse(fs16.readFileSync(turboPath, "utf-8"));
3350
+ const turbo = JSON.parse(fs17.readFileSync(turboPath, "utf-8"));
2965
3351
  const tasks = turbo.tasks ?? turbo.pipeline ?? {};
2966
3352
  return taskName in tasks;
2967
3353
  } catch {
@@ -2972,10 +3358,10 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2972
3358
  if (hasTurboTask(projectRoot, "typecheck")) {
2973
3359
  return { command: "npx turbo typecheck", label: "turbo typecheck" };
2974
3360
  }
2975
- const pkgJsonPath = path16.join(projectRoot, "package.json");
2976
- if (fs16.existsSync(pkgJsonPath)) {
3361
+ const pkgJsonPath = path17.join(projectRoot, "package.json");
3362
+ if (fs17.existsSync(pkgJsonPath)) {
2977
3363
  try {
2978
- const pkg = JSON.parse(fs16.readFileSync(pkgJsonPath, "utf-8"));
3364
+ const pkg = JSON.parse(fs17.readFileSync(pkgJsonPath, "utf-8"));
2979
3365
  if (pkg.scripts?.typecheck) {
2980
3366
  const pm = packageManager ?? "npm";
2981
3367
  return { command: `${pm} run typecheck`, label: `${pm} run typecheck` };
@@ -2983,7 +3369,7 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2983
3369
  } catch {
2984
3370
  }
2985
3371
  }
2986
- if (fs16.existsSync(path16.join(projectRoot, "tsconfig.json"))) {
3372
+ if (fs17.existsSync(path17.join(projectRoot, "tsconfig.json"))) {
2987
3373
  return { command: "npx tsc --noEmit", label: "tsc --noEmit" };
2988
3374
  }
2989
3375
  return {
@@ -2993,23 +3379,23 @@ function resolveTypecheckCommand(projectRoot, packageManager) {
2993
3379
 
2994
3380
  // src/commands/init-hooks.ts
2995
3381
  function setupPreCommitHook(projectRoot) {
2996
- const lefthookPath = path17.join(projectRoot, "lefthook.yml");
2997
- if (fs17.existsSync(lefthookPath)) {
3382
+ const lefthookPath = path18.join(projectRoot, "lefthook.yml");
3383
+ if (fs18.existsSync(lefthookPath)) {
2998
3384
  addLefthookPreCommit(lefthookPath);
2999
3385
  console.log(` ${chalk10.green("\u2713")} lefthook.yml \u2014 added viberails pre-commit`);
3000
3386
  return "lefthook.yml";
3001
3387
  }
3002
- const huskyDir = path17.join(projectRoot, ".husky");
3003
- if (fs17.existsSync(huskyDir)) {
3388
+ const huskyDir = path18.join(projectRoot, ".husky");
3389
+ if (fs18.existsSync(huskyDir)) {
3004
3390
  writeHuskyPreCommit(huskyDir);
3005
3391
  console.log(` ${chalk10.green("\u2713")} .husky/pre-commit \u2014 added viberails check`);
3006
3392
  return ".husky/pre-commit";
3007
3393
  }
3008
- const gitDir = path17.join(projectRoot, ".git");
3009
- if (fs17.existsSync(gitDir)) {
3010
- const hooksDir = path17.join(gitDir, "hooks");
3011
- if (!fs17.existsSync(hooksDir)) {
3012
- fs17.mkdirSync(hooksDir, { recursive: true });
3394
+ const gitDir = path18.join(projectRoot, ".git");
3395
+ if (fs18.existsSync(gitDir)) {
3396
+ const hooksDir = path18.join(gitDir, "hooks");
3397
+ if (!fs18.existsSync(hooksDir)) {
3398
+ fs18.mkdirSync(hooksDir, { recursive: true });
3013
3399
  }
3014
3400
  writeGitHookPreCommit(hooksDir);
3015
3401
  console.log(` ${chalk10.green("\u2713")} .git/hooks/pre-commit`);
@@ -3018,11 +3404,11 @@ function setupPreCommitHook(projectRoot) {
3018
3404
  return void 0;
3019
3405
  }
3020
3406
  function writeGitHookPreCommit(hooksDir) {
3021
- const hookPath = path17.join(hooksDir, "pre-commit");
3022
- if (fs17.existsSync(hookPath)) {
3023
- const existing = fs17.readFileSync(hookPath, "utf-8");
3407
+ const hookPath = path18.join(hooksDir, "pre-commit");
3408
+ if (fs18.existsSync(hookPath)) {
3409
+ const existing = fs18.readFileSync(hookPath, "utf-8");
3024
3410
  if (existing.includes("viberails")) return;
3025
- fs17.writeFileSync(
3411
+ fs18.writeFileSync(
3026
3412
  hookPath,
3027
3413
  `${existing.trimEnd()}
3028
3414
 
@@ -3039,10 +3425,10 @@ if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails chec
3039
3425
  "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi",
3040
3426
  ""
3041
3427
  ].join("\n");
3042
- fs17.writeFileSync(hookPath, script, { mode: 493 });
3428
+ fs18.writeFileSync(hookPath, script, { mode: 493 });
3043
3429
  }
3044
3430
  function addLefthookPreCommit(lefthookPath) {
3045
- const content = fs17.readFileSync(lefthookPath, "utf-8");
3431
+ const content = fs18.readFileSync(lefthookPath, "utf-8");
3046
3432
  if (content.includes("viberails")) return;
3047
3433
  const doc = parseYaml(content) ?? {};
3048
3434
  if (!doc["pre-commit"]) {
@@ -3054,23 +3440,23 @@ function addLefthookPreCommit(lefthookPath) {
3054
3440
  doc["pre-commit"].commands.viberails = {
3055
3441
  run: "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi"
3056
3442
  };
3057
- fs17.writeFileSync(lefthookPath, stringifyYaml(doc));
3443
+ fs18.writeFileSync(lefthookPath, stringifyYaml(doc));
3058
3444
  }
3059
3445
  function detectHookManager(projectRoot) {
3060
- if (fs17.existsSync(path17.join(projectRoot, "lefthook.yml"))) return "Lefthook";
3061
- if (fs17.existsSync(path17.join(projectRoot, ".husky"))) return "Husky";
3446
+ if (fs18.existsSync(path18.join(projectRoot, "lefthook.yml"))) return "Lefthook";
3447
+ if (fs18.existsSync(path18.join(projectRoot, ".husky"))) return "Husky";
3062
3448
  return void 0;
3063
3449
  }
3064
3450
  function setupClaudeCodeHook(projectRoot) {
3065
- const claudeDir = path17.join(projectRoot, ".claude");
3066
- if (!fs17.existsSync(claudeDir)) {
3067
- fs17.mkdirSync(claudeDir, { recursive: true });
3451
+ const claudeDir = path18.join(projectRoot, ".claude");
3452
+ if (!fs18.existsSync(claudeDir)) {
3453
+ fs18.mkdirSync(claudeDir, { recursive: true });
3068
3454
  }
3069
- const settingsPath = path17.join(claudeDir, "settings.json");
3455
+ const settingsPath = path18.join(claudeDir, "settings.json");
3070
3456
  let settings = {};
3071
- if (fs17.existsSync(settingsPath)) {
3457
+ if (fs18.existsSync(settingsPath)) {
3072
3458
  try {
3073
- settings = JSON.parse(fs17.readFileSync(settingsPath, "utf-8"));
3459
+ settings = JSON.parse(fs18.readFileSync(settingsPath, "utf-8"));
3074
3460
  } catch {
3075
3461
  console.warn(
3076
3462
  ` ${chalk10.yellow("!")} .claude/settings.json contains invalid JSON \u2014 skipping hook setup`
@@ -3096,30 +3482,30 @@ function setupClaudeCodeHook(projectRoot) {
3096
3482
  }
3097
3483
  ];
3098
3484
  settings.hooks = hooks;
3099
- fs17.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
3485
+ fs18.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
3100
3486
  `);
3101
3487
  console.log(` ${chalk10.green("\u2713")} .claude/settings.json \u2014 added viberails PostToolUse hook`);
3102
3488
  }
3103
3489
  function setupClaudeMdReference(projectRoot) {
3104
- const claudeMdPath = path17.join(projectRoot, "CLAUDE.md");
3490
+ const claudeMdPath = path18.join(projectRoot, "CLAUDE.md");
3105
3491
  let content = "";
3106
- if (fs17.existsSync(claudeMdPath)) {
3107
- content = fs17.readFileSync(claudeMdPath, "utf-8");
3492
+ if (fs18.existsSync(claudeMdPath)) {
3493
+ content = fs18.readFileSync(claudeMdPath, "utf-8");
3108
3494
  }
3109
3495
  if (content.includes("@.viberails/context.md")) return;
3110
3496
  const ref = "\n@.viberails/context.md\n";
3111
3497
  const prefix = content.length === 0 ? "" : content.trimEnd();
3112
- fs17.writeFileSync(claudeMdPath, prefix + ref);
3498
+ fs18.writeFileSync(claudeMdPath, prefix + ref);
3113
3499
  console.log(` ${chalk10.green("\u2713")} CLAUDE.md \u2014 added @.viberails/context.md reference`);
3114
3500
  }
3115
3501
  function setupGithubAction(projectRoot, packageManager, options) {
3116
- const workflowDir = path17.join(projectRoot, ".github", "workflows");
3117
- const workflowPath = path17.join(workflowDir, "viberails.yml");
3118
- if (fs17.existsSync(workflowPath)) {
3119
- const existing = fs17.readFileSync(workflowPath, "utf-8");
3502
+ const workflowDir = path18.join(projectRoot, ".github", "workflows");
3503
+ const workflowPath = path18.join(workflowDir, "viberails.yml");
3504
+ if (fs18.existsSync(workflowPath)) {
3505
+ const existing = fs18.readFileSync(workflowPath, "utf-8");
3120
3506
  if (existing.includes("viberails")) return void 0;
3121
3507
  }
3122
- fs17.mkdirSync(workflowDir, { recursive: true });
3508
+ fs18.mkdirSync(workflowDir, { recursive: true });
3123
3509
  const pm = packageManager || "npm";
3124
3510
  const installCmd = pm === "yarn" ? "yarn install --frozen-lockfile" : pm === "pnpm" ? "pnpm install --frozen-lockfile" : "npm ci";
3125
3511
  const runPrefix = pm === "npm" ? "npx" : `${pm} exec`;
@@ -3173,74 +3559,74 @@ function setupGithubAction(projectRoot, packageManager, options) {
3173
3559
  ""
3174
3560
  );
3175
3561
  const content = lines.filter((l) => l !== void 0).join("\n");
3176
- fs17.writeFileSync(workflowPath, content);
3562
+ fs18.writeFileSync(workflowPath, content);
3177
3563
  return ".github/workflows/viberails.yml";
3178
3564
  }
3179
3565
  function writeHuskyPreCommit(huskyDir) {
3180
- const hookPath = path17.join(huskyDir, "pre-commit");
3566
+ const hookPath = path18.join(huskyDir, "pre-commit");
3181
3567
  const cmd = "if [ -x ./node_modules/.bin/viberails ]; then ./node_modules/.bin/viberails check --staged; else npx viberails check --staged; fi";
3182
- if (fs17.existsSync(hookPath)) {
3183
- const existing = fs17.readFileSync(hookPath, "utf-8");
3568
+ if (fs18.existsSync(hookPath)) {
3569
+ const existing = fs18.readFileSync(hookPath, "utf-8");
3184
3570
  if (!existing.includes("viberails")) {
3185
- fs17.writeFileSync(hookPath, `${existing.trimEnd()}
3571
+ fs18.writeFileSync(hookPath, `${existing.trimEnd()}
3186
3572
  ${cmd}
3187
3573
  `);
3188
3574
  }
3189
3575
  return;
3190
3576
  }
3191
- fs17.writeFileSync(hookPath, `#!/bin/sh
3577
+ fs18.writeFileSync(hookPath, `#!/bin/sh
3192
3578
  ${cmd}
3193
3579
  `, { mode: 493 });
3194
3580
  }
3195
3581
 
3196
3582
  // src/commands/init-hooks-extra.ts
3197
- import * as fs18 from "fs";
3198
- import * as path18 from "path";
3583
+ import * as fs19 from "fs";
3584
+ import * as path19 from "path";
3199
3585
  import chalk11 from "chalk";
3200
3586
  import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
3201
3587
  function addPreCommitStep(projectRoot, name, command, marker, lefthookExtra) {
3202
- const lefthookPath = path18.join(projectRoot, "lefthook.yml");
3203
- if (fs18.existsSync(lefthookPath)) {
3204
- const content = fs18.readFileSync(lefthookPath, "utf-8");
3588
+ const lefthookPath = path19.join(projectRoot, "lefthook.yml");
3589
+ if (fs19.existsSync(lefthookPath)) {
3590
+ const content = fs19.readFileSync(lefthookPath, "utf-8");
3205
3591
  if (content.includes(marker)) return void 0;
3206
3592
  const doc = parseYaml2(content) ?? {};
3207
3593
  if (!doc["pre-commit"]) doc["pre-commit"] = { commands: {} };
3208
3594
  if (!doc["pre-commit"].commands) doc["pre-commit"].commands = {};
3209
3595
  doc["pre-commit"].commands[name] = { run: command, ...lefthookExtra };
3210
- fs18.writeFileSync(lefthookPath, stringifyYaml2(doc));
3596
+ fs19.writeFileSync(lefthookPath, stringifyYaml2(doc));
3211
3597
  return "lefthook.yml";
3212
3598
  }
3213
- const huskyDir = path18.join(projectRoot, ".husky");
3214
- if (fs18.existsSync(huskyDir)) {
3215
- const hookPath = path18.join(huskyDir, "pre-commit");
3216
- if (fs18.existsSync(hookPath)) {
3217
- const existing = fs18.readFileSync(hookPath, "utf-8");
3599
+ const huskyDir = path19.join(projectRoot, ".husky");
3600
+ if (fs19.existsSync(huskyDir)) {
3601
+ const hookPath = path19.join(huskyDir, "pre-commit");
3602
+ if (fs19.existsSync(hookPath)) {
3603
+ const existing = fs19.readFileSync(hookPath, "utf-8");
3218
3604
  if (existing.includes(marker)) return void 0;
3219
- fs18.writeFileSync(hookPath, `${existing.trimEnd()}
3605
+ fs19.writeFileSync(hookPath, `${existing.trimEnd()}
3220
3606
  ${command}
3221
3607
  `);
3222
3608
  } else {
3223
- fs18.writeFileSync(hookPath, `#!/bin/sh
3609
+ fs19.writeFileSync(hookPath, `#!/bin/sh
3224
3610
  ${command}
3225
3611
  `, { mode: 493 });
3226
3612
  }
3227
3613
  return ".husky/pre-commit";
3228
3614
  }
3229
- const gitDir = path18.join(projectRoot, ".git");
3230
- if (fs18.existsSync(gitDir)) {
3231
- const hooksDir = path18.join(gitDir, "hooks");
3232
- if (!fs18.existsSync(hooksDir)) fs18.mkdirSync(hooksDir, { recursive: true });
3233
- const hookPath = path18.join(hooksDir, "pre-commit");
3234
- if (fs18.existsSync(hookPath)) {
3235
- const existing = fs18.readFileSync(hookPath, "utf-8");
3615
+ const gitDir = path19.join(projectRoot, ".git");
3616
+ if (fs19.existsSync(gitDir)) {
3617
+ const hooksDir = path19.join(gitDir, "hooks");
3618
+ if (!fs19.existsSync(hooksDir)) fs19.mkdirSync(hooksDir, { recursive: true });
3619
+ const hookPath = path19.join(hooksDir, "pre-commit");
3620
+ if (fs19.existsSync(hookPath)) {
3621
+ const existing = fs19.readFileSync(hookPath, "utf-8");
3236
3622
  if (existing.includes(marker)) return void 0;
3237
- fs18.writeFileSync(hookPath, `${existing.trimEnd()}
3623
+ fs19.writeFileSync(hookPath, `${existing.trimEnd()}
3238
3624
 
3239
3625
  # ${name}
3240
3626
  ${command}
3241
3627
  `);
3242
3628
  } else {
3243
- fs18.writeFileSync(hookPath, `#!/bin/sh
3629
+ fs19.writeFileSync(hookPath, `#!/bin/sh
3244
3630
  # Generated by viberails
3245
3631
 
3246
3632
  # ${name}
@@ -3266,7 +3652,7 @@ function setupTypecheckHook(projectRoot, packageManager) {
3266
3652
  return target;
3267
3653
  }
3268
3654
  function setupLintHook(projectRoot, linter) {
3269
- const isLefthook = fs18.existsSync(path18.join(projectRoot, "lefthook.yml"));
3655
+ const isLefthook = fs19.existsSync(path19.join(projectRoot, "lefthook.yml"));
3270
3656
  const linterName = linter === "biome" ? "Biome" : "ESLint";
3271
3657
  let command;
3272
3658
  let lefthookExtra;
@@ -3290,6 +3676,9 @@ function setupSelectedIntegrations(projectRoot, integrations, opts) {
3290
3676
  const created = [];
3291
3677
  if (integrations.preCommitHook) {
3292
3678
  const t = setupPreCommitHook(projectRoot);
3679
+ if (t && opts.lefthookExpected && !t.includes("lefthook")) {
3680
+ console.log(` ${chalk11.yellow("!")} Lefthook install failed \u2014 fell back to ${t}`);
3681
+ }
3293
3682
  created.push(t ? `${t} \u2014 added viberails pre-commit` : "pre-commit hook skipped");
3294
3683
  }
3295
3684
  if (integrations.typecheckHook) {
@@ -3318,34 +3707,34 @@ function setupSelectedIntegrations(projectRoot, integrations, opts) {
3318
3707
  return created;
3319
3708
  }
3320
3709
 
3321
- // src/commands/init.ts
3322
- var CONFIG_FILE5 = "viberails.config.json";
3323
- function getExemptedPackages(config) {
3324
- return config.packages.filter((pkg) => pkg.rules?.testCoverage === 0 && pkg.path !== ".").map((pkg) => pkg.path);
3325
- }
3326
- async function initCommand(options, cwd) {
3327
- const projectRoot = findProjectRoot(cwd ?? process.cwd());
3328
- if (!projectRoot) {
3329
- throw new Error(
3330
- "No package.json found. Make sure you are inside a JS/TS project, then run:\n npx viberails"
3331
- );
3332
- }
3333
- const configPath = path19.join(projectRoot, CONFIG_FILE5);
3334
- if (fs19.existsSync(configPath) && !options.force) {
3335
- if (!options.yes) {
3336
- return initInteractive(projectRoot, configPath, options);
3710
+ // src/commands/init-non-interactive.ts
3711
+ import * as fs20 from "fs";
3712
+ import * as path20 from "path";
3713
+ import * as clack11 from "@clack/prompts";
3714
+ import { compactConfig as compactConfig3, generateConfig } from "@viberails/config";
3715
+ import { scan as scan2 } from "@viberails/scanner";
3716
+ import chalk12 from "chalk";
3717
+
3718
+ // src/utils/filter-confidence.ts
3719
+ function filterHighConfidence(conventions, meta) {
3720
+ if (!meta) return conventions;
3721
+ const filtered = {};
3722
+ for (const [key, value] of Object.entries(conventions)) {
3723
+ if (value === void 0) continue;
3724
+ const convMeta = meta[key];
3725
+ if (!convMeta || convMeta.confidence === "high") {
3726
+ filtered[key] = value;
3337
3727
  }
3338
- console.log(
3339
- `${chalk12.yellow("!")} viberails is already initialized.
3340
- Run ${chalk12.cyan("viberails")} to review or edit the existing setup, ${chalk12.cyan("viberails sync")} to update generated files, or ${chalk12.cyan("viberails init --force")} to replace it.`
3341
- );
3342
- return;
3343
3728
  }
3344
- if (options.yes) return initNonInteractive(projectRoot, configPath);
3345
- await initInteractive(projectRoot, configPath, options);
3729
+ return filtered;
3730
+ }
3731
+
3732
+ // src/commands/init-non-interactive.ts
3733
+ function getExemptedPackages(config) {
3734
+ return config.packages.filter((pkg) => pkg.rules?.testCoverage === 0 && pkg.path !== ".").map((pkg) => pkg.path);
3346
3735
  }
3347
3736
  async function initNonInteractive(projectRoot, configPath) {
3348
- const s = clack8.spinner();
3737
+ const s = clack11.spinner();
3349
3738
  s.start("Scanning project...");
3350
3739
  const scanResult = await scan2(projectRoot);
3351
3740
  const config = generateConfig(scanResult);
@@ -3364,7 +3753,7 @@ async function initNonInteractive(projectRoot, configPath) {
3364
3753
  );
3365
3754
  }
3366
3755
  if (config.packages.length > 1) {
3367
- const bs = clack8.spinner();
3756
+ const bs = clack11.spinner();
3368
3757
  bs.start("Building import graph...");
3369
3758
  const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
3370
3759
  const packages = resolveWorkspacePackages(projectRoot, config.packages);
@@ -3380,7 +3769,7 @@ async function initNonInteractive(projectRoot, configPath) {
3380
3769
  }
3381
3770
  }
3382
3771
  const compacted = compactConfig3(config);
3383
- fs19.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3772
+ fs20.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3384
3773
  `);
3385
3774
  writeGeneratedFiles(projectRoot, config, scanResult);
3386
3775
  updateGitignore(projectRoot);
@@ -3399,7 +3788,7 @@ async function initNonInteractive(projectRoot, configPath) {
3399
3788
  const preCommitTarget = hasHookManager ? setupPreCommitHook(projectRoot) : void 0;
3400
3789
  const ok = chalk12.green("\u2713");
3401
3790
  const created = [
3402
- `${ok} ${path19.basename(configPath)}`,
3791
+ `${ok} ${path20.basename(configPath)}`,
3403
3792
  `${ok} .viberails/context.md`,
3404
3793
  `${ok} .viberails/scan-result.json`,
3405
3794
  `${ok} .claude/settings.json \u2014 added viberails hook`,
@@ -3413,13 +3802,36 @@ async function initNonInteractive(projectRoot, configPath) {
3413
3802
  Created:
3414
3803
  ${created.map((f) => ` ${f}`).join("\n")}`);
3415
3804
  }
3805
+
3806
+ // src/commands/init.ts
3807
+ var CONFIG_FILE5 = "viberails.config.json";
3808
+ async function initCommand(options, cwd) {
3809
+ const projectRoot = findProjectRoot(cwd ?? process.cwd());
3810
+ if (!projectRoot) {
3811
+ throw new Error(
3812
+ "No package.json found. Make sure you are inside a JS/TS project, then run:\n npx viberails"
3813
+ );
3814
+ }
3815
+ const configPath = path21.join(projectRoot, CONFIG_FILE5);
3816
+ if (fs21.existsSync(configPath) && !options.force) {
3817
+ if (!options.yes) {
3818
+ return initInteractive(projectRoot, configPath, options);
3819
+ }
3820
+ console.log(
3821
+ `${chalk13.yellow("!")} viberails is already initialized.
3822
+ Run ${chalk13.cyan("viberails")} to review or edit the existing setup, ${chalk13.cyan("viberails sync")} to update generated files, or ${chalk13.cyan("viberails init --force")} to replace it.`
3823
+ );
3824
+ return;
3825
+ }
3826
+ if (options.yes) return initNonInteractive(projectRoot, configPath);
3827
+ await initInteractive(projectRoot, configPath, options);
3828
+ }
3416
3829
  async function initInteractive(projectRoot, configPath, options) {
3417
- clack8.intro("viberails");
3418
- const replacingExistingConfig = fs19.existsSync(configPath);
3419
- if (fs19.existsSync(configPath) && !options.force) {
3420
- const action = await promptExistingConfigAction(path19.basename(configPath));
3830
+ clack12.intro("viberails");
3831
+ if (fs21.existsSync(configPath) && !options.force) {
3832
+ const action = await promptExistingConfigAction(path21.basename(configPath));
3421
3833
  if (action === "cancel") {
3422
- clack8.outro("Aborted. No files were written.");
3834
+ clack12.outro("Aborted. No files were written.");
3423
3835
  return;
3424
3836
  }
3425
3837
  if (action === "edit") {
@@ -3428,136 +3840,93 @@ async function initInteractive(projectRoot, configPath, options) {
3428
3840
  }
3429
3841
  options.force = true;
3430
3842
  }
3431
- if (fs19.existsSync(configPath) && options.force) {
3843
+ if (fs21.existsSync(configPath) && options.force) {
3432
3844
  const replace = await confirmDangerous(
3433
- `${path19.basename(configPath)} already exists and will be replaced. Continue?`
3845
+ `${path21.basename(configPath)} already exists and will be replaced. Continue?`
3434
3846
  );
3435
3847
  if (!replace) {
3436
- clack8.outro("Aborted. No files were written.");
3848
+ clack12.outro("Aborted. No files were written.");
3437
3849
  return;
3438
3850
  }
3439
3851
  }
3440
- const s = clack8.spinner();
3852
+ const s = clack12.spinner();
3441
3853
  s.start("Scanning project...");
3442
- const scanResult = await scan2(projectRoot);
3443
- const config = generateConfig(scanResult);
3854
+ const scanResult = await scan3(projectRoot);
3855
+ const config = generateConfig2(scanResult);
3444
3856
  s.stop("Scan complete");
3445
3857
  if (scanResult.statistics.totalFiles === 0) {
3446
- clack8.log.warn(
3858
+ clack12.log.warn(
3447
3859
  "No source files detected. Try running from the project root,\nor check that source files exist. Run viberails sync after adding files."
3448
3860
  );
3449
3861
  }
3450
- const exemptedPkgs = getExemptedPackages(config);
3451
- let decision;
3452
- while (true) {
3453
- displayInitOverview(scanResult, config, exemptedPkgs);
3454
- const nextDecision = await promptInitDecision();
3455
- if (nextDecision === "review") {
3456
- clack8.note(formatScanResultsText(scanResult), "Detected details");
3457
- continue;
3458
- }
3459
- decision = nextDecision;
3460
- break;
3461
- }
3462
- if (decision === "customize") {
3463
- const rootPkg = config.packages.find((p) => p.path === ".") ?? config.packages[0];
3464
- const overrides = await promptRuleMenu({
3465
- maxFileLines: config.rules.maxFileLines,
3466
- testCoverage: config.rules.testCoverage,
3467
- enforceMissingTests: config.rules.enforceMissingTests,
3468
- enforceNaming: config.rules.enforceNaming,
3469
- fileNamingValue: rootPkg.conventions?.fileNaming,
3470
- coverageSummaryPath: "coverage/coverage-summary.json",
3471
- coverageCommand: config.defaults?.coverage?.command,
3472
- packageOverrides: config.packages
3473
- });
3474
- applyRuleOverrides(config, overrides);
3475
- }
3476
- if (config.packages.length > 1) {
3477
- clack8.note(
3478
- "Optional for monorepos. viberails can infer package boundaries\nfrom imports that already work today, so you start with rules\nthat match the current codebase.",
3479
- "Boundaries"
3862
+ const hasTestRunner = !!scanResult.stack.testRunner;
3863
+ if (!hasTestRunner) {
3864
+ clack12.log.info(
3865
+ "No test runner detected. Coverage checks are inactive until a test runner is installed.\nInstall a test runner (e.g. vitest) and re-run viberails init."
3480
3866
  );
3481
- const shouldInfer = await confirm3("Infer boundary rules from current import patterns?");
3482
- if (shouldInfer) {
3483
- const bs = clack8.spinner();
3484
- bs.start("Building import graph...");
3485
- const { buildImportGraph, inferBoundaries } = await import("@viberails/graph");
3486
- const packages = resolveWorkspacePackages(projectRoot, config.packages);
3487
- const graph = await buildImportGraph(projectRoot, { packages, ignore: config.ignore });
3488
- const inferred = inferBoundaries(graph);
3489
- const denyCount = Object.values(inferred.deny).reduce((sum, arr) => sum + arr.length, 0);
3490
- if (denyCount > 0) {
3491
- config.boundaries = inferred;
3492
- config.rules.enforceBoundaries = true;
3493
- const pkgCount = Object.keys(inferred.deny).length;
3494
- bs.stop(`Inferred ${denyCount} boundary rules across ${pkgCount} packages`);
3495
- } else {
3496
- bs.stop("No boundary rules inferred");
3497
- }
3498
- }
3499
3867
  }
3500
3868
  const hookManager = detectHookManager(projectRoot);
3501
3869
  const coveragePrereqs = checkCoveragePrereqs(projectRoot, scanResult);
3502
- const hasMissingPrereqs = coveragePrereqs.some((p) => !p.installed) || !hookManager;
3503
- if (hasMissingPrereqs) {
3504
- clack8.log.info("Some dependencies are needed for full functionality.");
3505
- }
3506
- const prereqResult = await promptMissingPrereqs(projectRoot, coveragePrereqs);
3507
- if (prereqResult.disableCoverage) {
3508
- config.rules.testCoverage = 0;
3509
- }
3510
3870
  const rootPkgStack = (config.packages.find((p) => p.path === ".") ?? config.packages[0])?.stack;
3511
- const integrations = await promptIntegrations(projectRoot, hookManager, {
3512
- isTypeScript: rootPkgStack?.language?.split("@")[0] === "typescript",
3513
- linter: rootPkgStack?.linter?.split("@")[0],
3514
- packageManager: rootPkgStack?.packageManager?.split("@")[0],
3515
- isWorkspace: config.packages.length > 1
3516
- });
3517
- displaySetupPlan(config, integrations, {
3518
- replacingExistingConfig,
3519
- configFile: path19.basename(configPath)
3871
+ const state = await promptMainMenu(config, scanResult, {
3872
+ hasTestRunner,
3873
+ hookManager,
3874
+ coveragePrereqs,
3875
+ projectRoot,
3876
+ tools: {
3877
+ isTypeScript: rootPkgStack?.language?.split("@")[0] === "typescript",
3878
+ linter: rootPkgStack?.linter?.split("@")[0],
3879
+ packageManager: rootPkgStack?.packageManager?.split("@")[0],
3880
+ isWorkspace: config.packages.length > 1
3881
+ }
3520
3882
  });
3521
3883
  const shouldWrite = await confirm3("Apply this setup?");
3522
3884
  if (!shouldWrite) {
3523
- clack8.outro("Aborted. No files were written.");
3885
+ clack12.outro("Aborted. No files were written.");
3524
3886
  return;
3525
3887
  }
3526
- const ws = clack8.spinner();
3888
+ if (state.deferredInstalls.length > 0) {
3889
+ await executeDeferredInstalls(projectRoot, state.deferredInstalls);
3890
+ }
3891
+ const ws = clack12.spinner();
3527
3892
  ws.start("Writing configuration...");
3528
- const compacted = compactConfig3(config);
3529
- fs19.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3893
+ const compacted = compactConfig4(config);
3894
+ fs21.writeFileSync(configPath, `${JSON.stringify(compacted, null, 2)}
3530
3895
  `);
3531
3896
  writeGeneratedFiles(projectRoot, config, scanResult);
3532
3897
  updateGitignore(projectRoot);
3533
3898
  ws.stop("Configuration written");
3534
- const ok = chalk12.green("\u2713");
3535
- clack8.log.step(`${ok} ${path19.basename(configPath)}`);
3536
- clack8.log.step(`${ok} .viberails/context.md`);
3537
- clack8.log.step(`${ok} .viberails/scan-result.json`);
3538
- setupSelectedIntegrations(projectRoot, integrations, {
3539
- linter: rootPkgStack?.linter?.split("@")[0],
3540
- packageManager: rootPkgStack?.packageManager?.split("@")[0]
3541
- });
3542
- clack8.outro(
3899
+ const ok = chalk13.green("\u2713");
3900
+ clack12.log.step(`${ok} ${path21.basename(configPath)}`);
3901
+ clack12.log.step(`${ok} .viberails/context.md`);
3902
+ clack12.log.step(`${ok} .viberails/scan-result.json`);
3903
+ if (state.visited.integrations && state.integrations) {
3904
+ const lefthookExpected = state.deferredInstalls.some((d) => d.command.includes("lefthook"));
3905
+ setupSelectedIntegrations(projectRoot, state.integrations, {
3906
+ linter: rootPkgStack?.linter?.split("@")[0],
3907
+ packageManager: rootPkgStack?.packageManager?.split("@")[0],
3908
+ lefthookExpected
3909
+ });
3910
+ }
3911
+ clack12.outro(
3543
3912
  `Done! Next: review viberails.config.json, then run viberails check
3544
- ${chalk12.dim("Tip: use")} ${chalk12.cyan("viberails check --enforce")} ${chalk12.dim("in CI to block PRs on violations.")}`
3913
+ ${chalk13.dim("Tip: use")} ${chalk13.cyan("viberails check --enforce")} ${chalk13.dim("in CI to block PRs on violations.")}`
3545
3914
  );
3546
3915
  }
3547
3916
 
3548
3917
  // src/commands/sync.ts
3549
- import * as fs20 from "fs";
3550
- import * as path20 from "path";
3551
- import * as clack9 from "@clack/prompts";
3552
- import { compactConfig as compactConfig4, loadConfig as loadConfig5, mergeConfig as mergeConfig2 } from "@viberails/config";
3553
- import { scan as scan3 } from "@viberails/scanner";
3554
- import chalk13 from "chalk";
3918
+ import * as fs22 from "fs";
3919
+ import * as path22 from "path";
3920
+ import * as clack13 from "@clack/prompts";
3921
+ import { compactConfig as compactConfig5, loadConfig as loadConfig5, mergeConfig as mergeConfig2 } from "@viberails/config";
3922
+ import { scan as scan4 } from "@viberails/scanner";
3923
+ import chalk14 from "chalk";
3555
3924
  var CONFIG_FILE6 = "viberails.config.json";
3556
3925
  var SCAN_RESULT_FILE2 = ".viberails/scan-result.json";
3557
3926
  function loadPreviousStats(projectRoot) {
3558
- const scanResultPath = path20.join(projectRoot, SCAN_RESULT_FILE2);
3927
+ const scanResultPath = path22.join(projectRoot, SCAN_RESULT_FILE2);
3559
3928
  try {
3560
- const raw = fs20.readFileSync(scanResultPath, "utf-8");
3929
+ const raw = fs22.readFileSync(scanResultPath, "utf-8");
3561
3930
  const parsed = JSON.parse(raw);
3562
3931
  if (parsed?.statistics?.totalFiles !== void 0) {
3563
3932
  return parsed.statistics;
@@ -3574,17 +3943,17 @@ async function syncCommand(options, cwd) {
3574
3943
  "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"
3575
3944
  );
3576
3945
  }
3577
- const configPath = path20.join(projectRoot, CONFIG_FILE6);
3946
+ const configPath = path22.join(projectRoot, CONFIG_FILE6);
3578
3947
  const existing = await loadConfig5(configPath);
3579
3948
  const previousStats = loadPreviousStats(projectRoot);
3580
- const s = clack9.spinner();
3949
+ const s = clack13.spinner();
3581
3950
  s.start("Scanning project...");
3582
- const scanResult = await scan3(projectRoot);
3951
+ const scanResult = await scan4(projectRoot);
3583
3952
  s.stop("Scan complete");
3584
3953
  const merged = mergeConfig2(existing, scanResult);
3585
- const compacted = compactConfig4(merged);
3954
+ const compacted = compactConfig5(merged);
3586
3955
  const compactedJson = JSON.stringify(compacted, null, 2);
3587
- const rawDisk = fs20.readFileSync(configPath, "utf-8").trim();
3956
+ const rawDisk = fs22.readFileSync(configPath, "utf-8").trim();
3588
3957
  const diskWithoutSync = rawDisk.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
3589
3958
  const mergedWithoutSync = compactedJson.replace(/"lastSync":\s*"[^"]*"/, '"lastSync": ""');
3590
3959
  const configChanged = diskWithoutSync !== mergedWithoutSync;
@@ -3592,19 +3961,19 @@ async function syncCommand(options, cwd) {
3592
3961
  const statsDelta = previousStats ? formatStatsDelta(previousStats, scanResult.statistics) : void 0;
3593
3962
  if (changes.length > 0 || statsDelta) {
3594
3963
  console.log(`
3595
- ${chalk13.bold("Changes:")}`);
3964
+ ${chalk14.bold("Changes:")}`);
3596
3965
  for (const change of changes) {
3597
- const icon = change.type === "removed" ? chalk13.red("-") : chalk13.green("+");
3966
+ const icon = change.type === "removed" ? chalk14.red("-") : chalk14.green("+");
3598
3967
  console.log(` ${icon} ${change.description}`);
3599
3968
  }
3600
3969
  if (statsDelta) {
3601
- console.log(` ${chalk13.dim(statsDelta)}`);
3970
+ console.log(` ${chalk14.dim(statsDelta)}`);
3602
3971
  }
3603
3972
  }
3604
3973
  if (options?.interactive) {
3605
- clack9.intro("viberails sync (interactive)");
3606
- clack9.note(formatRulesText(merged).join("\n"), "Rules after sync");
3607
- const decision = await clack9.select({
3974
+ clack13.intro("viberails sync (interactive)");
3975
+ clack13.note(formatRulesText(merged).join("\n"), "Rules after sync");
3976
+ const decision = await clack13.select({
3608
3977
  message: "How would you like to proceed?",
3609
3978
  options: [
3610
3979
  { value: "accept", label: "Accept changes" },
@@ -3614,47 +3983,51 @@ ${chalk13.bold("Changes:")}`);
3614
3983
  });
3615
3984
  assertNotCancelled(decision);
3616
3985
  if (decision === "cancel") {
3617
- clack9.outro("Sync cancelled. No files were written.");
3986
+ clack13.outro("Sync cancelled. No files were written.");
3618
3987
  return;
3619
3988
  }
3620
3989
  if (decision === "customize") {
3621
3990
  const rootPkg = merged.packages.find((p) => p.path === ".") ?? merged.packages[0];
3622
3991
  const overrides = await promptRuleMenu({
3623
3992
  maxFileLines: merged.rules.maxFileLines,
3993
+ maxTestFileLines: merged.rules.maxTestFileLines,
3624
3994
  testCoverage: merged.rules.testCoverage,
3625
3995
  enforceMissingTests: merged.rules.enforceMissingTests,
3626
3996
  enforceNaming: merged.rules.enforceNaming,
3627
3997
  fileNamingValue: rootPkg.conventions?.fileNaming,
3998
+ componentNaming: rootPkg.conventions?.componentNaming,
3999
+ hookNaming: rootPkg.conventions?.hookNaming,
4000
+ importAlias: rootPkg.conventions?.importAlias,
3628
4001
  coverageSummaryPath: rootPkg.coverage?.summaryPath ?? "coverage/coverage-summary.json",
3629
4002
  coverageCommand: merged.defaults?.coverage?.command,
3630
4003
  packageOverrides: merged.packages
3631
4004
  });
3632
4005
  applyRuleOverrides(merged, overrides);
3633
- const recompacted = compactConfig4(merged);
3634
- fs20.writeFileSync(configPath, `${JSON.stringify(recompacted, null, 2)}
4006
+ const recompacted = compactConfig5(merged);
4007
+ fs22.writeFileSync(configPath, `${JSON.stringify(recompacted, null, 2)}
3635
4008
  `);
3636
4009
  writeGeneratedFiles(projectRoot, merged, scanResult);
3637
- clack9.log.success("Updated config with your customizations.");
3638
- clack9.outro("Done! Run viberails check to verify.");
4010
+ clack13.log.success("Updated config with your customizations.");
4011
+ clack13.outro("Done! Run viberails check to verify.");
3639
4012
  return;
3640
4013
  }
3641
4014
  }
3642
- fs20.writeFileSync(configPath, `${compactedJson}
4015
+ fs22.writeFileSync(configPath, `${compactedJson}
3643
4016
  `);
3644
4017
  writeGeneratedFiles(projectRoot, merged, scanResult);
3645
4018
  console.log(`
3646
- ${chalk13.bold("Synced:")}`);
4019
+ ${chalk14.bold("Synced:")}`);
3647
4020
  if (configChanged) {
3648
- console.log(` ${chalk13.yellow("!")} ${CONFIG_FILE6} \u2014 updated (review changes)`);
4021
+ console.log(` ${chalk14.yellow("!")} ${CONFIG_FILE6} \u2014 updated (review changes)`);
3649
4022
  } else {
3650
- console.log(` ${chalk13.green("\u2713")} ${CONFIG_FILE6} \u2014 unchanged`);
4023
+ console.log(` ${chalk14.green("\u2713")} ${CONFIG_FILE6} \u2014 unchanged`);
3651
4024
  }
3652
- console.log(` ${chalk13.green("\u2713")} .viberails/context.md \u2014 regenerated`);
3653
- console.log(` ${chalk13.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
4025
+ console.log(` ${chalk14.green("\u2713")} .viberails/context.md \u2014 regenerated`);
4026
+ console.log(` ${chalk14.green("\u2713")} .viberails/scan-result.json \u2014 updated`);
3654
4027
  }
3655
4028
 
3656
4029
  // src/index.ts
3657
- var VERSION = "0.6.4";
4030
+ var VERSION = "0.6.6";
3658
4031
  var program = new Command();
3659
4032
  program.name("viberails").description("Guardrails for vibe coding").version(VERSION);
3660
4033
  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) => {
@@ -3662,7 +4035,7 @@ program.command("init", { isDefault: true }).description("Scan your project and
3662
4035
  await initCommand(options);
3663
4036
  } catch (err) {
3664
4037
  const message = err instanceof Error ? err.message : String(err);
3665
- console.error(`${chalk14.red("Error:")} ${message}`);
4038
+ console.error(`${chalk15.red("Error:")} ${message}`);
3666
4039
  process.exit(1);
3667
4040
  }
3668
4041
  });
@@ -3671,7 +4044,7 @@ program.command("sync").description("Re-scan and update generated files").option
3671
4044
  await syncCommand(options);
3672
4045
  } catch (err) {
3673
4046
  const message = err instanceof Error ? err.message : String(err);
3674
- console.error(`${chalk14.red("Error:")} ${message}`);
4047
+ console.error(`${chalk15.red("Error:")} ${message}`);
3675
4048
  process.exit(1);
3676
4049
  }
3677
4050
  });
@@ -3680,7 +4053,7 @@ program.command("config").description("Interactively edit existing config rules"
3680
4053
  await configCommand(options);
3681
4054
  } catch (err) {
3682
4055
  const message = err instanceof Error ? err.message : String(err);
3683
- console.error(`${chalk14.red("Error:")} ${message}`);
4056
+ console.error(`${chalk15.red("Error:")} ${message}`);
3684
4057
  process.exit(1);
3685
4058
  }
3686
4059
  });
@@ -3701,7 +4074,7 @@ program.command("check").description("Check files against enforced rules").optio
3701
4074
  process.exit(exitCode);
3702
4075
  } catch (err) {
3703
4076
  const message = err instanceof Error ? err.message : String(err);
3704
- console.error(`${chalk14.red("Error:")} ${message}`);
4077
+ console.error(`${chalk15.red("Error:")} ${message}`);
3705
4078
  process.exit(1);
3706
4079
  }
3707
4080
  }
@@ -3712,7 +4085,7 @@ program.command("fix").description("Auto-fix file naming violations and generate
3712
4085
  process.exit(exitCode);
3713
4086
  } catch (err) {
3714
4087
  const message = err instanceof Error ? err.message : String(err);
3715
- console.error(`${chalk14.red("Error:")} ${message}`);
4088
+ console.error(`${chalk15.red("Error:")} ${message}`);
3716
4089
  process.exit(1);
3717
4090
  }
3718
4091
  });
@@ -3721,7 +4094,7 @@ program.command("boundaries").description("Display, infer, or inspect import bou
3721
4094
  await boundariesCommand(options);
3722
4095
  } catch (err) {
3723
4096
  const message = err instanceof Error ? err.message : String(err);
3724
- console.error(`${chalk14.red("Error:")} ${message}`);
4097
+ console.error(`${chalk15.red("Error:")} ${message}`);
3725
4098
  process.exit(1);
3726
4099
  }
3727
4100
  });