@viberails/config 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,17 @@
1
+ // src/generate-config.ts
2
+ import * as path from "path";
3
+
1
4
  // src/defaults.ts
2
5
  var DEFAULT_RULES = {
3
6
  maxFileLines: 300,
4
7
  maxTestFileLines: 0,
5
- maxFunctionLines: 50,
6
- requireTests: true,
8
+ testCoverage: 80,
7
9
  enforceNaming: true,
8
- enforceBoundaries: false
10
+ enforceBoundaries: false,
11
+ enforceMissingTests: true
9
12
  };
10
- var DEFAULT_IGNORE = [
13
+ var DEFAULT_IGNORE = [];
14
+ var BUILTIN_IGNORE = [
11
15
  "**/*.d.ts",
12
16
  "**/*.min.js",
13
17
  "**/*.min.cjs",
@@ -29,70 +33,68 @@ var DEFAULT_IGNORE = [
29
33
  "**/__generated__/**"
30
34
  ];
31
35
 
32
- // src/generate-config.ts
33
- import * as path from "path";
34
-
35
- // src/generate-overrides.ts
36
- function conventionsDiffer(pkgConventions, globalConventions) {
37
- const overrides = {};
38
- let hasDiff = false;
36
+ // src/generate-packages.ts
37
+ function buildPackageStack(pkg) {
38
+ const config = {
39
+ language: formatStackItem(pkg.stack.language),
40
+ packageManager: formatStackItem(pkg.stack.packageManager)
41
+ };
42
+ if (pkg.stack.framework) config.framework = formatStackItem(pkg.stack.framework);
43
+ if (pkg.stack.styling) config.styling = formatStackItem(pkg.stack.styling);
44
+ if (pkg.stack.backend) config.backend = formatStackItem(pkg.stack.backend);
45
+ if (pkg.stack.orm) config.orm = formatStackItem(pkg.stack.orm);
46
+ if (pkg.stack.linter) config.linter = formatStackItem(pkg.stack.linter);
47
+ if (pkg.stack.formatter) config.formatter = formatStackItem(pkg.stack.formatter);
48
+ if (pkg.stack.testRunner) config.testRunner = formatStackItem(pkg.stack.testRunner);
49
+ return config;
50
+ }
51
+ function buildPackageConventions(pkgConventions) {
52
+ const config = {};
39
53
  for (const key of CONVENTION_KEYS) {
40
54
  const detected = pkgConventions[key];
41
- if (!detected) continue;
42
- const mapped = mapConvention(detected);
43
- if (mapped === void 0) continue;
44
- const globalValue = globalConventions[key];
45
- const globalStr = typeof globalValue === "string" ? globalValue : globalValue?.value;
46
- if (detected.value !== globalStr) {
47
- overrides[key] = mapped;
48
- hasDiff = true;
55
+ if (detected && detected.confidence !== "low") {
56
+ config[key] = detected.value;
49
57
  }
50
58
  }
51
- return hasDiff ? overrides : void 0;
59
+ return config;
52
60
  }
53
- function generatePackageOverrides(scanResult, globalConfig) {
61
+ function generatePackages(scanResult) {
54
62
  if (!scanResult.packages || scanResult.packages.length <= 1) return void 0;
55
- const overrides = [];
63
+ const packages = [];
56
64
  for (const pkg of scanResult.packages) {
57
- const override = {
65
+ const pkgScanResult = {
66
+ root: pkg.root,
67
+ stack: pkg.stack,
68
+ structure: pkg.structure,
69
+ conventions: pkg.conventions,
70
+ statistics: pkg.statistics
71
+ };
72
+ const packageConfig = {
58
73
  name: pkg.name,
59
- path: pkg.relativePath
74
+ path: pkg.relativePath,
75
+ stack: buildPackageStack(pkg),
76
+ structure: mapStructure(pkgScanResult),
77
+ conventions: buildPackageConventions(pkg.conventions)
60
78
  };
61
- let hasDiff = false;
62
- const stackOverride = {};
63
- let hasStackDiff = false;
64
- const optionalStackFields = [
65
- "framework",
66
- "styling",
67
- "backend",
68
- "orm",
69
- "linter",
70
- "formatter",
71
- "testRunner"
72
- ];
73
- for (const field of optionalStackFields) {
74
- const pkgItem = pkg.stack[field];
75
- if (!pkgItem) continue;
76
- const pkgValue = formatStackItem(pkgItem);
77
- if (pkgValue !== globalConfig.stack[field]) {
78
- stackOverride[field] = pkgValue;
79
- hasStackDiff = true;
80
- }
81
- }
82
- if (hasStackDiff) {
83
- override.stack = stackOverride;
84
- hasDiff = true;
85
- }
86
- const conventionOverrides = conventionsDiffer(pkg.conventions, globalConfig.conventions);
87
- if (conventionOverrides) {
88
- override.conventions = conventionOverrides;
89
- hasDiff = true;
90
- }
91
- if (hasDiff) {
92
- overrides.push(override);
79
+ if (pkg.typesOnly) {
80
+ packageConfig.rules = { testCoverage: 0 };
93
81
  }
82
+ packages.push(packageConfig);
83
+ }
84
+ return packages.length > 0 ? packages : void 0;
85
+ }
86
+
87
+ // src/infer-coverage-command.ts
88
+ function inferCoverageCommand(testRunner) {
89
+ if (!testRunner) return void 0;
90
+ const runner = testRunner.split("@")[0];
91
+ if (runner === "vitest") {
92
+ return "npx vitest run --coverage --coverage.reporter=json-summary";
93
+ }
94
+ if (runner === "jest") {
95
+ return "npx jest --coverage --coverageReporters=json-summary";
94
96
  }
95
- return overrides.length > 0 ? overrides : void 0;
97
+ return void 0;
96
98
  }
97
99
 
98
100
  // src/generate-config.ts
@@ -139,16 +141,6 @@ function mapStructure(scanResult) {
139
141
  }
140
142
  return config;
141
143
  }
142
- function mapConvention(convention) {
143
- if (convention.confidence === "low") {
144
- return void 0;
145
- }
146
- return {
147
- value: convention.value,
148
- _confidence: convention.confidence,
149
- _consistency: convention.consistency
150
- };
151
- }
152
144
  var CONVENTION_KEYS = [
153
145
  "fileNaming",
154
146
  "componentNaming",
@@ -156,67 +148,308 @@ var CONVENTION_KEYS = [
156
148
  "importAlias"
157
149
  ];
158
150
  function mapConventions(scanResult) {
159
- const config = {};
151
+ const conventions = {};
152
+ const meta = {};
160
153
  for (const key of CONVENTION_KEYS) {
161
154
  const detected = scanResult.conventions[key];
162
- if (detected) {
163
- const value = mapConvention(detected);
164
- if (value !== void 0) {
165
- config[key] = value;
166
- }
155
+ if (detected && detected.confidence !== "low") {
156
+ conventions[key] = detected.value;
157
+ meta[key] = {
158
+ value: detected.value,
159
+ confidence: detected.confidence,
160
+ consistency: detected.consistency
161
+ };
167
162
  }
168
163
  }
169
- return config;
164
+ return { conventions, meta };
165
+ }
166
+ function buildConventionMeta(conventions) {
167
+ const meta = {};
168
+ for (const key of CONVENTION_KEYS) {
169
+ const detected = conventions[key];
170
+ if (detected && detected.confidence !== "low") {
171
+ meta[key] = {
172
+ value: detected.value,
173
+ confidence: detected.confidence,
174
+ consistency: detected.consistency
175
+ };
176
+ }
177
+ }
178
+ return meta;
170
179
  }
171
180
  function generateConfig(scanResult) {
181
+ const projectName = path.basename(scanResult.root);
182
+ const { conventions, meta } = mapConventions(scanResult);
183
+ const rootPackage = {
184
+ name: projectName,
185
+ path: ".",
186
+ stack: mapStack(scanResult),
187
+ structure: mapStructure(scanResult),
188
+ conventions
189
+ };
190
+ const _meta = {
191
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
192
+ packages: {
193
+ ".": { conventions: Object.keys(meta).length > 0 ? meta : void 0 }
194
+ }
195
+ };
172
196
  const config = {
173
197
  $schema: "https://viberails.sh/schema/v1.json",
174
198
  version: 1,
175
- name: path.basename(scanResult.root),
176
- enforcement: "warn",
177
- stack: mapStack(scanResult),
178
- structure: mapStructure(scanResult),
179
- conventions: mapConventions(scanResult),
199
+ name: projectName,
180
200
  rules: { ...DEFAULT_RULES },
181
- ignore: [...DEFAULT_IGNORE]
201
+ ignore: [...DEFAULT_IGNORE],
202
+ packages: [rootPackage],
203
+ _meta
182
204
  };
205
+ const testRunner = scanResult.stack.testRunner;
206
+ const coverageCommand = inferCoverageCommand(
207
+ testRunner ? formatStackItem(testRunner) : void 0
208
+ );
209
+ if (coverageCommand) {
210
+ config.defaults = { coverage: { command: coverageCommand } };
211
+ }
183
212
  if (scanResult.workspace) {
184
- config.workspace = {
185
- packages: scanResult.workspace.packages.map((p) => p.relativePath),
186
- isMonorepo: true
187
- };
213
+ const packages = generatePackages(scanResult);
214
+ if (packages) {
215
+ config.packages = packages;
216
+ const pkgMeta = {};
217
+ for (const pkg of scanResult.packages) {
218
+ const convMeta = buildConventionMeta(pkg.conventions);
219
+ if (Object.keys(convMeta).length > 0) {
220
+ pkgMeta[pkg.relativePath] = { conventions: convMeta };
221
+ }
222
+ }
223
+ if (Object.keys(pkgMeta).length > 0) {
224
+ _meta.packages = pkgMeta;
225
+ }
226
+ }
188
227
  config.boundaries = { deny: {} };
189
228
  }
190
- const packageOverrides = generatePackageOverrides(scanResult, config);
191
- if (packageOverrides) {
192
- config.packages = packageOverrides;
193
- }
194
229
  return config;
195
230
  }
196
231
 
232
+ // src/compact-config.ts
233
+ var STACK_KEYS = [
234
+ "language",
235
+ "packageManager",
236
+ "framework",
237
+ "styling",
238
+ "backend",
239
+ "orm",
240
+ "linter",
241
+ "formatter",
242
+ "testRunner"
243
+ ];
244
+ var STRUCTURE_KEYS = [
245
+ "srcDir",
246
+ "pages",
247
+ "components",
248
+ "hooks",
249
+ "utils",
250
+ "types",
251
+ "tests",
252
+ "testPattern"
253
+ ];
254
+ function compactConfig(config) {
255
+ const pkgs = config.packages ?? [];
256
+ const defaults = {};
257
+ const packages = pkgs.map((p) => {
258
+ const copy = { ...p };
259
+ if (config.defaults?.coverage) {
260
+ copy.coverage = { ...config.defaults.coverage, ...copy.coverage ?? {} };
261
+ }
262
+ if (config.defaults?.stack) {
263
+ copy.stack = { ...config.defaults.stack, ...copy.stack ?? {} };
264
+ }
265
+ if (config.defaults?.structure) {
266
+ copy.structure = { ...config.defaults.structure, ...copy.structure ?? {} };
267
+ }
268
+ if (config.defaults?.conventions) {
269
+ copy.conventions = { ...config.defaults.conventions, ...copy.conventions ?? {} };
270
+ }
271
+ return copy;
272
+ });
273
+ if (packages.length <= 1) {
274
+ for (const pkg of packages) {
275
+ if (pkg.stack && Object.keys(pkg.stack).length === 0) delete pkg.stack;
276
+ if (pkg.structure && Object.keys(pkg.structure).length === 0) delete pkg.structure;
277
+ if (pkg.conventions && Object.keys(pkg.conventions).length === 0) delete pkg.conventions;
278
+ if (pkg.coverage && Object.keys(pkg.coverage).length === 0) delete pkg.coverage;
279
+ }
280
+ const { defaults: _d, ...rest } = config;
281
+ return { ...rest, packages };
282
+ }
283
+ const sharedStack = {};
284
+ for (const key of STACK_KEYS) {
285
+ const values = packages.map((p) => (p.stack ?? {})[key]);
286
+ if (values[0] !== void 0 && values.every((v) => v === values[0])) {
287
+ sharedStack[key] = values[0];
288
+ }
289
+ }
290
+ if (Object.keys(sharedStack).length > 0) {
291
+ defaults.stack = sharedStack;
292
+ for (const pkg of packages) {
293
+ const pkgStack = pkg.stack ?? {};
294
+ const sparse = {};
295
+ for (const key of STACK_KEYS) {
296
+ if (pkgStack[key] !== void 0 && pkgStack[key] !== sharedStack[key]) {
297
+ sparse[key] = pkgStack[key];
298
+ }
299
+ }
300
+ pkg.stack = sparse;
301
+ }
302
+ }
303
+ const sharedStructure = {};
304
+ for (const key of STRUCTURE_KEYS) {
305
+ const values = packages.map((p) => p.structure?.[key]);
306
+ const first = values[0];
307
+ if (first !== void 0 && values.every((v) => JSON.stringify(v) === JSON.stringify(first))) {
308
+ sharedStructure[key] = first;
309
+ }
310
+ }
311
+ if (Object.keys(sharedStructure).length > 0) {
312
+ defaults.structure = sharedStructure;
313
+ for (const pkg of packages) {
314
+ const pkgStructure = pkg.structure ?? {};
315
+ const sparse = {};
316
+ for (const key of STRUCTURE_KEYS) {
317
+ const val = pkgStructure[key];
318
+ if (val !== void 0 && JSON.stringify(val) !== JSON.stringify(sharedStructure[key])) {
319
+ sparse[key] = val;
320
+ }
321
+ }
322
+ pkg.structure = sparse;
323
+ }
324
+ }
325
+ const sharedConventions = {};
326
+ for (const key of CONVENTION_KEYS) {
327
+ const values = packages.map((p) => p.conventions?.[key]);
328
+ if (values[0] !== void 0 && values.every((v) => v === values[0])) {
329
+ sharedConventions[key] = values[0];
330
+ }
331
+ }
332
+ const sharedCoverage = {};
333
+ const coverageKeys = ["command", "summaryPath"];
334
+ for (const key of coverageKeys) {
335
+ const values = packages.map((p) => p.coverage?.[key]);
336
+ if (values[0] !== void 0 && values.every((v) => v === values[0])) {
337
+ sharedCoverage[key] = values[0];
338
+ }
339
+ }
340
+ if (Object.keys(sharedCoverage).length > 0) {
341
+ defaults.coverage = sharedCoverage;
342
+ for (const pkg of packages) {
343
+ const pkgCoverage = pkg.coverage ?? {};
344
+ const sparse = {};
345
+ for (const key of coverageKeys) {
346
+ if (pkgCoverage[key] !== void 0 && pkgCoverage[key] !== sharedCoverage[key]) {
347
+ sparse[key] = pkgCoverage[key];
348
+ }
349
+ }
350
+ pkg.coverage = sparse;
351
+ }
352
+ }
353
+ if (Object.keys(sharedConventions).length > 0) {
354
+ defaults.conventions = sharedConventions;
355
+ for (const pkg of packages) {
356
+ const pkgConventions = pkg.conventions ?? {};
357
+ const sparse = {};
358
+ for (const key of CONVENTION_KEYS) {
359
+ if (pkgConventions[key] !== void 0 && pkgConventions[key] !== sharedConventions[key]) {
360
+ sparse[key] = pkgConventions[key];
361
+ }
362
+ }
363
+ pkg.conventions = sparse;
364
+ }
365
+ }
366
+ for (const pkg of packages) {
367
+ if (pkg.stack && Object.keys(pkg.stack).length === 0) {
368
+ delete pkg.stack;
369
+ }
370
+ if (pkg.structure && Object.keys(pkg.structure).length === 0) {
371
+ delete pkg.structure;
372
+ }
373
+ if (pkg.conventions && Object.keys(pkg.conventions).length === 0) {
374
+ delete pkg.conventions;
375
+ }
376
+ if (pkg.coverage && Object.keys(pkg.coverage).length === 0) {
377
+ delete pkg.coverage;
378
+ }
379
+ }
380
+ const result = {
381
+ ...config.$schema ? { $schema: config.$schema } : {},
382
+ version: config.version,
383
+ name: config.name,
384
+ rules: config.rules,
385
+ ...config.ignore && config.ignore.length > 0 ? { ignore: config.ignore } : {},
386
+ ...config.boundaries ? { boundaries: config.boundaries } : {},
387
+ ...Object.keys(defaults).length > 0 ? { defaults } : {},
388
+ packages,
389
+ ...config._meta ? { _meta: config._meta } : {}
390
+ };
391
+ return result;
392
+ }
393
+ function expandDefaults(config) {
394
+ const defaults = config.defaults;
395
+ const packages = config.packages.map((pkg) => {
396
+ const expanded = { ...pkg };
397
+ expanded.stack = { ...defaults?.stack ?? {}, ...pkg.stack ?? {} };
398
+ expanded.structure = { ...defaults?.structure ?? {}, ...pkg.structure ?? {} };
399
+ expanded.conventions = { ...defaults?.conventions ?? {}, ...pkg.conventions ?? {} };
400
+ const mergedCoverage = { ...defaults?.coverage ?? {}, ...pkg.coverage ?? {} };
401
+ if (Object.keys(mergedCoverage).length > 0) {
402
+ expanded.coverage = mergedCoverage;
403
+ } else {
404
+ delete expanded.coverage;
405
+ }
406
+ return expanded;
407
+ });
408
+ const ignore = config.ignore ?? [];
409
+ const { defaults: _d, ...rest } = config;
410
+ return { ...rest, ignore, packages };
411
+ }
412
+
197
413
  // src/load-config.ts
198
414
  import * as fs from "fs/promises";
199
415
  function validateConfig(parsed, configPath) {
200
416
  const errors = [];
201
- const required = ["version", "name", "stack", "rules"];
417
+ const required = ["version", "name", "packages", "rules"];
202
418
  const missing = required.filter((field) => parsed[field] === void 0);
203
419
  if (missing.length > 0) {
204
420
  throw new Error(
205
421
  `Invalid viberails config at ${configPath}: missing required field(s): ${missing.join(", ")}`
206
422
  );
207
423
  }
208
- if (typeof parsed.version !== "number") errors.push('"version" must be a number');
209
- if (typeof parsed.name !== "string") errors.push('"name" must be a string');
210
- if (parsed.enforcement !== void 0 && parsed.enforcement !== "warn" && parsed.enforcement !== "enforce") {
211
- errors.push('"enforcement" must be "warn" or "enforce"');
424
+ if (typeof parsed.version !== "number") {
425
+ errors.push('"version" must be a number');
426
+ } else if (parsed.version !== 1) {
427
+ errors.push('"version" must be 1');
212
428
  }
213
- if (typeof parsed.stack !== "object" || parsed.stack === null) {
214
- errors.push('"stack" must be an object');
429
+ if (typeof parsed.name !== "string") errors.push('"name" must be a string');
430
+ if (!Array.isArray(parsed.packages)) {
431
+ errors.push('"packages" must be an array');
432
+ } else if (parsed.packages.length === 0) {
433
+ errors.push('"packages" must contain at least one package');
215
434
  } else {
216
- const stack = parsed.stack;
217
- if (typeof stack.language !== "string") errors.push('"stack.language" must be a string');
218
- if (typeof stack.packageManager !== "string")
219
- errors.push('"stack.packageManager" must be a string');
435
+ for (let i = 0; i < parsed.packages.length; i++) {
436
+ const pkg = parsed.packages[i];
437
+ if (typeof pkg.name !== "string") errors.push(`"packages[${i}].name" must be a string`);
438
+ if (typeof pkg.path !== "string") errors.push(`"packages[${i}].path" must be a string`);
439
+ if (pkg.coverage !== void 0) {
440
+ if (typeof pkg.coverage !== "object" || pkg.coverage === null) {
441
+ errors.push(`"packages[${i}].coverage" must be an object`);
442
+ } else {
443
+ const coverage = pkg.coverage;
444
+ if (coverage.command !== void 0 && typeof coverage.command !== "string") {
445
+ errors.push(`"packages[${i}].coverage.command" must be a string`);
446
+ }
447
+ if (coverage.summaryPath !== void 0 && typeof coverage.summaryPath !== "string") {
448
+ errors.push(`"packages[${i}].coverage.summaryPath" must be a string`);
449
+ }
450
+ }
451
+ }
452
+ }
220
453
  }
221
454
  if (typeof parsed.rules !== "object" || parsed.rules === null) {
222
455
  errors.push('"rules" must be an object');
@@ -224,10 +457,18 @@ function validateConfig(parsed, configPath) {
224
457
  const rules = parsed.rules;
225
458
  if (typeof rules.maxFileLines !== "number")
226
459
  errors.push('"rules.maxFileLines" must be a number');
227
- if (typeof rules.maxFunctionLines !== "number")
228
- errors.push('"rules.maxFunctionLines" must be a number');
229
- if (typeof rules.requireTests !== "boolean")
230
- errors.push('"rules.requireTests" must be a boolean');
460
+ else if (rules.maxFileLines < 0) errors.push('"rules.maxFileLines" must be >= 0');
461
+ if (rules.maxTestFileLines !== void 0) {
462
+ if (typeof rules.maxTestFileLines !== "number") {
463
+ errors.push('"rules.maxTestFileLines" must be a number');
464
+ } else if (rules.maxTestFileLines < 0) {
465
+ errors.push('"rules.maxTestFileLines" must be >= 0');
466
+ }
467
+ }
468
+ if (typeof rules.testCoverage !== "number")
469
+ errors.push('"rules.testCoverage" must be a number');
470
+ else if (rules.testCoverage < 0 || rules.testCoverage > 100)
471
+ errors.push('"rules.testCoverage" must be between 0 and 100');
231
472
  if (typeof rules.enforceNaming !== "boolean")
232
473
  errors.push('"rules.enforceNaming" must be a boolean');
233
474
  if (typeof rules.enforceBoundaries !== "boolean")
@@ -236,6 +477,26 @@ function validateConfig(parsed, configPath) {
236
477
  if (parsed.ignore !== void 0 && !Array.isArray(parsed.ignore)) {
237
478
  errors.push('"ignore" must be an array');
238
479
  }
480
+ if (parsed.defaults !== void 0) {
481
+ if (typeof parsed.defaults !== "object" || parsed.defaults === null) {
482
+ errors.push('"defaults" must be an object');
483
+ } else {
484
+ const defaults = parsed.defaults;
485
+ if (defaults.coverage !== void 0) {
486
+ if (typeof defaults.coverage !== "object" || defaults.coverage === null) {
487
+ errors.push('"defaults.coverage" must be an object');
488
+ } else {
489
+ const coverage = defaults.coverage;
490
+ if (coverage.command !== void 0 && typeof coverage.command !== "string") {
491
+ errors.push('"defaults.coverage.command" must be a string');
492
+ }
493
+ if (coverage.summaryPath !== void 0 && typeof coverage.summaryPath !== "string") {
494
+ errors.push('"defaults.coverage.summaryPath" must be a string');
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
239
500
  if (errors.length > 0) {
240
501
  throw new Error(`Invalid viberails config at ${configPath}: ${errors.join("; ")}`);
241
502
  }
@@ -262,7 +523,15 @@ async function loadConfig(configPath) {
262
523
  if (rules.maxTestFileLines === void 0) {
263
524
  rules.maxTestFileLines = 0;
264
525
  }
265
- return parsed;
526
+ if (rules.enforceMissingTests === void 0) {
527
+ rules.enforceMissingTests = true;
528
+ }
529
+ if (parsed.ignore === void 0) {
530
+ parsed.ignore = [];
531
+ }
532
+ let config = parsed;
533
+ config = expandDefaults(config);
534
+ return config;
266
535
  }
267
536
  async function loadConfigSafe(configPath) {
268
537
  try {
@@ -298,43 +567,79 @@ function mergeStructure(existing, fresh) {
298
567
  testPattern: existing.testPattern ?? fresh.testPattern
299
568
  };
300
569
  }
301
- function hasConvention(conventions, key) {
302
- return conventions[key] !== void 0;
303
- }
304
- function markAsDetected(value) {
305
- if (typeof value === "string") {
306
- return { value, _confidence: "high", _consistency: 100, _detected: true };
307
- }
308
- return { ...value, _detected: true };
309
- }
310
- function mergeConventions(existing, fresh) {
311
- const merged = { ...existing };
570
+ function mergeConventions(existing, fresh, existingMeta, freshMeta) {
571
+ const conventions = { ...existing };
572
+ const meta = { ...existingMeta ?? {} };
312
573
  for (const key of CONVENTION_KEYS) {
313
- if (!hasConvention(existing, key) && fresh[key] !== void 0) {
314
- const freshValue = fresh[key];
315
- if (freshValue !== void 0) {
316
- merged[key] = markAsDetected(freshValue);
574
+ if (existing[key] === void 0 && fresh[key] !== void 0) {
575
+ conventions[key] = fresh[key];
576
+ if (freshMeta?.[key]) {
577
+ meta[key] = { ...freshMeta[key], detected: true };
317
578
  }
579
+ } else if (existing[key] !== void 0 && freshMeta?.[key]) {
580
+ meta[key] = { ...freshMeta[key], value: freshMeta[key].value };
318
581
  }
319
582
  }
320
- return merged;
583
+ return { conventions, meta };
584
+ }
585
+ function mergePackage(existing, fresh, existingMeta, freshMeta) {
586
+ const { conventions, meta } = mergeConventions(
587
+ existing.conventions ?? {},
588
+ fresh.conventions ?? {},
589
+ existingMeta,
590
+ freshMeta
591
+ );
592
+ return {
593
+ pkg: {
594
+ ...existing,
595
+ stack: mergeStack(existing.stack ?? {}, fresh.stack ?? {}),
596
+ structure: mergeStructure(existing.structure ?? {}, fresh.structure ?? {}),
597
+ conventions
598
+ },
599
+ meta
600
+ };
321
601
  }
322
602
  function mergeConfig(existing, scanResult) {
323
603
  const fresh = generateConfig(scanResult);
604
+ const existingByPath = new Map(existing.packages.map((p) => [p.path, p]));
605
+ const freshByPath = new Map(fresh.packages.map((p) => [p.path, p]));
606
+ const mergedPackages = [];
607
+ const mergedPkgMeta = {};
608
+ for (const existingPkg of existing.packages) {
609
+ const freshPkg = freshByPath.get(existingPkg.path);
610
+ if (freshPkg) {
611
+ const existingConvMeta = existing._meta?.packages?.[existingPkg.path]?.conventions;
612
+ const freshConvMeta = fresh._meta?.packages?.[existingPkg.path]?.conventions;
613
+ const { pkg, meta } = mergePackage(existingPkg, freshPkg, existingConvMeta, freshConvMeta);
614
+ mergedPackages.push(pkg);
615
+ if (Object.keys(meta).length > 0) {
616
+ mergedPkgMeta[pkg.path] = { conventions: meta };
617
+ }
618
+ } else {
619
+ mergedPackages.push(existingPkg);
620
+ }
621
+ }
622
+ for (const freshPkg of fresh.packages) {
623
+ if (!existingByPath.has(freshPkg.path)) {
624
+ mergedPackages.push(freshPkg);
625
+ const freshConvMeta = fresh._meta?.packages?.[freshPkg.path]?.conventions;
626
+ if (freshConvMeta && Object.keys(freshConvMeta).length > 0) {
627
+ mergedPkgMeta[freshPkg.path] = { conventions: freshConvMeta };
628
+ }
629
+ }
630
+ }
324
631
  const merged = {
325
632
  $schema: existing.$schema ?? fresh.$schema,
326
633
  version: existing.version,
327
634
  name: existing.name,
328
- enforcement: existing.enforcement,
329
- stack: mergeStack(existing.stack, fresh.stack),
330
- structure: mergeStructure(existing.structure, fresh.structure),
331
- conventions: mergeConventions(existing.conventions, fresh.conventions),
332
635
  rules: { ...existing.rules },
333
- ignore: [...existing.ignore]
636
+ ignore: [...existing.ignore ?? []],
637
+ packages: mergedPackages,
638
+ _meta: {
639
+ lastSync: (/* @__PURE__ */ new Date()).toISOString(),
640
+ ...Object.keys(mergedPkgMeta).length > 0 ? { packages: mergedPkgMeta } : {}
641
+ }
334
642
  };
335
- if (fresh.workspace) {
336
- merged.workspace = fresh.workspace;
337
- }
338
643
  if (existing.boundaries) {
339
644
  merged.boundaries = {
340
645
  deny: { ...existing.boundaries.deny },
@@ -346,57 +651,10 @@ function mergeConfig(existing, scanResult) {
346
651
  ...fresh.boundaries.ignore ? { ignore: [...fresh.boundaries.ignore] } : {}
347
652
  };
348
653
  }
349
- if (existing.packages || fresh.packages) {
350
- merged.packages = mergePackageOverrides(existing.packages, fresh.packages);
351
- }
352
654
  return merged;
353
655
  }
354
- function mergePackageOverrides(existing, fresh) {
355
- if (!fresh || fresh.length === 0) return existing;
356
- if (!existing || existing.length === 0) return fresh;
357
- const existingByPath = new Map(existing.map((p) => [p.path, p]));
358
- const merged = [...existing];
359
- for (const freshPkg of fresh) {
360
- if (!existingByPath.has(freshPkg.path)) {
361
- merged.push(freshPkg);
362
- }
363
- }
364
- return merged.length > 0 ? merged : void 0;
365
- }
366
656
 
367
657
  // src/schema-parts.ts
368
- var conventionValueDef = {
369
- description: "A convention value \u2014 either a plain string (user-confirmed) or an object with scanner metadata.",
370
- oneOf: [
371
- { type: "string" },
372
- {
373
- type: "object",
374
- required: ["value", "_confidence", "_consistency"],
375
- properties: {
376
- value: {
377
- type: "string",
378
- description: "The convention value."
379
- },
380
- _confidence: {
381
- type: "string",
382
- enum: ["high", "medium", "low"],
383
- description: "Scanner confidence level."
384
- },
385
- _consistency: {
386
- type: "number",
387
- minimum: 0,
388
- maximum: 100,
389
- description: "Scanner consistency percentage."
390
- },
391
- _detected: {
392
- type: "boolean",
393
- description: "Set by mergeConfig when a convention is newly detected during sync."
394
- }
395
- },
396
- additionalProperties: false
397
- }
398
- ]
399
- };
400
658
  var boundarySchema = {
401
659
  type: "object",
402
660
  required: ["deny"],
@@ -417,44 +675,112 @@ var boundarySchema = {
417
675
  },
418
676
  additionalProperties: false
419
677
  };
678
+ var stackSchema = {
679
+ type: "object",
680
+ properties: {
681
+ framework: { type: "string", description: 'Primary framework (e.g. "nextjs@15").' },
682
+ language: { type: "string", description: 'Primary language (e.g. "typescript").' },
683
+ styling: { type: "string", description: 'Styling solution (e.g. "tailwindcss@4").' },
684
+ backend: { type: "string", description: 'Backend framework (e.g. "express@5").' },
685
+ orm: { type: "string", description: 'ORM or database client (e.g. "prisma").' },
686
+ packageManager: { type: "string", description: 'Package manager (e.g. "pnpm").' },
687
+ linter: { type: "string", description: 'Linter (e.g. "eslint@9").' },
688
+ formatter: { type: "string", description: 'Formatter (e.g. "prettier").' },
689
+ testRunner: { type: "string", description: 'Test runner (e.g. "vitest").' }
690
+ },
691
+ additionalProperties: false
692
+ };
693
+ var structureSchema = {
694
+ type: "object",
695
+ properties: {
696
+ srcDir: { type: "string", description: 'Source directory (e.g. "src").' },
697
+ pages: { type: "string", description: "Pages or routes directory." },
698
+ components: { type: "string", description: "Components directory." },
699
+ hooks: { type: "string", description: "Hooks directory." },
700
+ utils: { type: "string", description: "Utilities directory." },
701
+ types: { type: "string", description: "Type definitions directory." },
702
+ tests: { type: "string", description: "Tests directory." },
703
+ testPattern: { type: "string", description: 'Test file naming pattern (e.g. "*.test.ts").' }
704
+ },
705
+ additionalProperties: false
706
+ };
707
+ var conventionsSchema = {
708
+ type: "object",
709
+ properties: {
710
+ fileNaming: { type: "string", description: 'File naming convention (e.g. "kebab-case").' },
711
+ componentNaming: { type: "string", description: "Component naming convention." },
712
+ hookNaming: { type: "string", description: "Hook naming convention." },
713
+ importAlias: { type: "string", description: 'Import alias pattern (e.g. "@/*").' }
714
+ },
715
+ additionalProperties: false
716
+ };
717
+ var coverageSchema = {
718
+ type: "object",
719
+ properties: {
720
+ command: {
721
+ type: "string",
722
+ description: "Command to generate coverage summary data for this package."
723
+ },
724
+ summaryPath: {
725
+ type: "string",
726
+ description: "Path to coverage summary JSON relative to package root."
727
+ }
728
+ },
729
+ additionalProperties: false
730
+ };
420
731
  var packageItemSchema = {
421
732
  type: "object",
422
733
  required: ["name", "path"],
423
734
  properties: {
424
735
  name: { type: "string", description: "Package name from package.json." },
425
- path: { type: "string", description: "Relative path to the package." },
426
- stack: {
427
- type: "object",
428
- properties: {
429
- framework: { type: "string" },
430
- language: { type: "string" },
431
- styling: { type: "string" },
432
- backend: { type: "string" },
433
- orm: { type: "string" },
434
- packageManager: { type: "string" },
435
- linter: { type: "string" },
436
- formatter: { type: "string" },
437
- testRunner: { type: "string" }
438
- },
439
- additionalProperties: false
736
+ path: { type: "string", description: 'Relative path to the package ("." for root).' },
737
+ stack: { ...stackSchema, description: "Technology stack for this package." },
738
+ structure: { ...structureSchema, description: "Directory structure for this package." },
739
+ conventions: { ...conventionsSchema, description: "Coding conventions for this package." },
740
+ coverage: {
741
+ ...coverageSchema,
742
+ description: "Coverage generation and summary settings for this package."
440
743
  },
441
- conventions: { $ref: "#/properties/conventions" },
442
744
  rules: {
443
745
  type: "object",
444
746
  properties: {
445
747
  maxFileLines: { type: "number" },
446
748
  maxTestFileLines: { type: "number" },
447
- maxFunctionLines: { type: "number" },
448
- requireTests: { type: "boolean" },
749
+ testCoverage: { type: "number" },
449
750
  enforceNaming: { type: "boolean" },
450
751
  enforceBoundaries: { type: "boolean" }
451
752
  },
452
753
  additionalProperties: false
453
754
  },
454
- ignore: { type: "array", items: { type: "string" } }
755
+ ignore: { type: "array", items: { type: "string" } },
756
+ boundaries: {
757
+ type: "object",
758
+ properties: {
759
+ deny: { type: "array", items: { type: "string" } },
760
+ ignore: { type: "array", items: { type: "string" } }
761
+ },
762
+ additionalProperties: false
763
+ }
455
764
  },
456
765
  additionalProperties: false
457
766
  };
767
+ var defaultsSchema = {
768
+ type: "object",
769
+ properties: {
770
+ stack: { ...stackSchema, description: "Default stack inherited by all packages." },
771
+ structure: { ...structureSchema, description: "Default structure inherited by all packages." },
772
+ conventions: {
773
+ ...conventionsSchema,
774
+ description: "Default conventions inherited by all packages."
775
+ },
776
+ coverage: {
777
+ ...coverageSchema,
778
+ description: "Default coverage settings inherited by all packages."
779
+ }
780
+ },
781
+ additionalProperties: false,
782
+ description: "Shared defaults for all packages. Packages inherit and can override."
783
+ };
458
784
 
459
785
  // src/schema.ts
460
786
  var configSchema = {
@@ -463,7 +789,7 @@ var configSchema = {
463
789
  title: "viberails configuration",
464
790
  description: "Configuration file for viberails \u2014 guardrails for vibe coding.",
465
791
  type: "object",
466
- required: ["version", "name", "stack", "rules"],
792
+ required: ["version", "name", "packages", "rules"],
467
793
  properties: {
468
794
  $schema: {
469
795
  type: "string",
@@ -472,120 +798,20 @@ var configSchema = {
472
798
  version: {
473
799
  type: "number",
474
800
  const: 1,
475
- description: "Config format version. Always 1 for V1.0."
801
+ description: "Config format version. Always 1."
476
802
  },
477
803
  name: {
478
804
  type: "string",
479
805
  description: "Project name, typically from package.json."
480
806
  },
481
- enforcement: {
482
- type: "string",
483
- enum: ["warn", "enforce"],
484
- default: "warn",
485
- description: "Whether conventions are warned about or enforced as errors."
486
- },
487
- stack: {
488
- type: "object",
489
- required: ["language", "packageManager"],
490
- properties: {
491
- framework: {
492
- type: "string",
493
- description: 'Primary framework identifier (e.g. "nextjs@15", "remix@2").'
494
- },
495
- language: {
496
- type: "string",
497
- description: 'Primary language (e.g. "typescript", "javascript").'
498
- },
499
- styling: {
500
- type: "string",
501
- description: 'Styling solution (e.g. "tailwindcss@4", "css-modules").'
502
- },
503
- backend: {
504
- type: "string",
505
- description: 'Backend framework (e.g. "express@5", "fastify").'
506
- },
507
- orm: {
508
- type: "string",
509
- description: 'ORM or database client (e.g. "prisma", "drizzle", "typeorm").'
510
- },
511
- packageManager: {
512
- type: "string",
513
- description: 'Package manager (e.g. "pnpm", "npm", "yarn").'
514
- },
515
- linter: {
516
- type: "string",
517
- description: 'Linter (e.g. "eslint@9", "biome").'
518
- },
519
- formatter: {
520
- type: "string",
521
- description: 'Formatter (e.g. "prettier", "biome").'
522
- },
523
- testRunner: {
524
- type: "string",
525
- description: 'Test runner (e.g. "vitest", "jest").'
526
- }
527
- },
528
- additionalProperties: false,
529
- description: "Detected or configured technology stack."
530
- },
531
- structure: {
532
- type: "object",
533
- properties: {
534
- srcDir: {
535
- type: "string",
536
- description: 'Source directory (e.g. "src"), or omit for flat structure.'
537
- },
538
- pages: {
539
- type: "string",
540
- description: 'Pages or routes directory (e.g. "src/app").'
541
- },
542
- components: {
543
- type: "string",
544
- description: 'Components directory (e.g. "src/components").'
545
- },
546
- hooks: {
547
- type: "string",
548
- description: 'Hooks directory (e.g. "src/hooks").'
549
- },
550
- utils: {
551
- type: "string",
552
- description: 'Utilities directory (e.g. "src/utils", "src/lib").'
553
- },
554
- types: {
555
- type: "string",
556
- description: 'Type definitions directory (e.g. "src/types").'
557
- },
558
- tests: {
559
- type: "string",
560
- description: 'Tests directory (e.g. "tests", "__tests__").'
561
- },
562
- testPattern: {
563
- type: "string",
564
- description: 'Test file naming pattern (e.g. "*.test.ts", "*.spec.ts").'
565
- }
566
- },
567
- additionalProperties: false,
568
- description: "Detected or configured directory structure."
569
- },
570
- conventions: {
571
- type: "object",
572
- properties: {
573
- fileNaming: { $ref: "#/definitions/conventionValue" },
574
- componentNaming: { $ref: "#/definitions/conventionValue" },
575
- hookNaming: { $ref: "#/definitions/conventionValue" },
576
- importAlias: { $ref: "#/definitions/conventionValue" }
577
- },
578
- additionalProperties: false,
579
- description: "Detected or configured coding conventions."
580
- },
581
807
  rules: {
582
808
  type: "object",
583
809
  required: [
584
810
  "maxFileLines",
585
- "maxFunctionLines",
586
- "requireTests",
811
+ "testCoverage",
587
812
  "enforceNaming",
588
- "enforceBoundaries"
813
+ "enforceBoundaries",
814
+ "enforceMissingTests"
589
815
  ],
590
816
  properties: {
591
817
  maxFileLines: {
@@ -596,17 +822,12 @@ var configSchema = {
596
822
  maxTestFileLines: {
597
823
  type: "number",
598
824
  default: 0,
599
- description: "Maximum number of lines allowed per test file. Set to 0 to exempt test files from size checks."
825
+ description: "Maximum number of lines allowed per test file. Set to 0 to exempt test files."
600
826
  },
601
- maxFunctionLines: {
827
+ testCoverage: {
602
828
  type: "number",
603
- default: 50,
604
- description: "Maximum number of lines allowed per function."
605
- },
606
- requireTests: {
607
- type: "boolean",
608
- default: true,
609
- description: "Whether to require test files for source modules."
829
+ default: 80,
830
+ description: "Minimum line coverage target percentage. 0 disables coverage threshold checks."
610
831
  },
611
832
  enforceNaming: {
612
833
  type: "boolean",
@@ -617,6 +838,11 @@ var configSchema = {
617
838
  type: "boolean",
618
839
  default: false,
619
840
  description: "Whether to enforce module boundary rules."
841
+ },
842
+ enforceMissingTests: {
843
+ type: "boolean",
844
+ default: true,
845
+ description: "Whether to enforce that every source file has a corresponding test file."
620
846
  }
621
847
  },
622
848
  additionalProperties: false,
@@ -625,49 +851,61 @@ var configSchema = {
625
851
  ignore: {
626
852
  type: "array",
627
853
  items: { type: "string" },
628
- description: "Glob patterns for files and directories to ignore."
854
+ description: "Project-specific glob patterns to ignore (universal patterns are built-in)."
629
855
  },
630
856
  boundaries: {
631
857
  ...boundarySchema,
632
858
  description: "Module boundary rules for import enforcement."
633
859
  },
634
- workspace: {
860
+ defaults: defaultsSchema,
861
+ packages: {
862
+ type: "array",
863
+ items: packageItemSchema,
864
+ description: 'Per-package configs. Single projects use path ".".'
865
+ },
866
+ _meta: {
635
867
  type: "object",
636
- required: ["packages", "isMonorepo"],
637
868
  properties: {
869
+ lastSync: { type: "string", description: "ISO timestamp of last sync." },
638
870
  packages: {
639
- type: "array",
640
- items: { type: "string" },
641
- description: "Relative paths to workspace packages."
642
- },
643
- isMonorepo: {
644
- type: "boolean",
645
- description: "Whether this project is a monorepo with multiple packages."
871
+ type: "object",
872
+ additionalProperties: {
873
+ type: "object",
874
+ properties: {
875
+ conventions: {
876
+ type: "object",
877
+ additionalProperties: {
878
+ type: "object",
879
+ properties: {
880
+ value: { type: "string" },
881
+ confidence: { type: "string", enum: ["high", "medium", "low"] },
882
+ consistency: { type: "number" },
883
+ detected: { type: "boolean" }
884
+ }
885
+ }
886
+ }
887
+ }
888
+ }
646
889
  }
647
890
  },
648
- additionalProperties: false,
649
- description: "Workspace configuration for monorepo projects."
650
- },
651
- packages: {
652
- type: "array",
653
- items: packageItemSchema,
654
- description: "Per-package overrides for monorepo projects."
891
+ description: "Scanner metadata. Regenerated on every sync \u2014 not user-editable."
655
892
  }
656
893
  },
657
- additionalProperties: false,
658
- definitions: {
659
- conventionValue: conventionValueDef
660
- }
894
+ additionalProperties: false
661
895
  };
662
896
 
663
897
  // src/index.ts
664
- var VERSION = "0.3.3";
898
+ var VERSION = "0.5.0";
665
899
  export {
900
+ BUILTIN_IGNORE,
666
901
  DEFAULT_IGNORE,
667
902
  DEFAULT_RULES,
668
903
  VERSION,
904
+ compactConfig,
669
905
  configSchema,
906
+ expandDefaults,
670
907
  generateConfig,
908
+ inferCoverageCommand,
671
909
  loadConfig,
672
910
  loadConfigSafe,
673
911
  mergeConfig