zonefence 0.0.8 → 0.0.9

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/README.ja.md CHANGED
@@ -152,6 +152,86 @@ src/
152
152
 
153
153
  `scope.apply: self` を指定すると、そのルールは自フォルダのみに適用され、子フォルダには継承されません。
154
154
 
155
+ ## ディレクトリパターン(コロケーション対応)
156
+
157
+ ページごとに `containers/`、`presenters/` などのディレクトリを配置するコロケーションパターンを使用している場合、`directoryPatterns` を定義することで、`zonefence.yaml` を各ディレクトリに重複して配置せずにルールを自動適用できます。
158
+
159
+ ### ディレクトリ構造の例
160
+
161
+ ```
162
+ src/
163
+ ├── pages/
164
+ │ ├── zonefence.yaml ← ここにdirectoryPatternsを定義
165
+ │ ├── home/
166
+ │ │ ├── containers/ ← パターンマッチ(pagesのスコープ内)
167
+ │ │ └── presenters/ ← パターンマッチ
168
+ │ └── settings/
169
+ │ ├── containers/ ← パターンマッチ
170
+ │ └── presenters/ ← パターンマッチ
171
+ └── api/
172
+ └── containers/ ← マッチしない(pagesのスコープ外)
173
+ ```
174
+
175
+ ### 設定例
176
+
177
+ ```yaml
178
+ # src/pages/zonefence.yaml
179
+ version: 1
180
+ description: "Pages層のルール"
181
+
182
+ directoryPatterns:
183
+ - pattern: "**/containers"
184
+ config:
185
+ description: "Container層"
186
+ imports:
187
+ allow:
188
+ - from: "../presenters/**"
189
+ - from: "../hooks/**"
190
+ deny:
191
+ - from: "../containers/**"
192
+ message: "Containerは兄弟Containerからimportしてはいけません"
193
+
194
+ - pattern: "**/presenters"
195
+ config:
196
+ imports:
197
+ allow:
198
+ - from: "./**"
199
+ - from: "@/ui/**"
200
+ deny:
201
+ - from: "../containers/**"
202
+ message: "PresenterはContainerからimportしてはいけません"
203
+
204
+ scope:
205
+ exclude:
206
+ - "**/*.test.tsx"
207
+ ```
208
+
209
+ ### パターンオプション
210
+
211
+ | オプション | 説明 | デフォルト |
212
+ |-----------|------|-----------|
213
+ | `pattern` | マッチするディレクトリのglobパターン(zonefence.yamlの位置からの相対パス) | 必須 |
214
+ | `config.description` | マッチしたディレクトリの説明 | - |
215
+ | `config.imports` | マッチしたディレクトリのimportルール | - |
216
+ | `config.mergeStrategy` | `"merge"`(他のルールと結合)または `"override"`(置換) | `"merge"` |
217
+ | `priority` | 複数パターンがマッチした場合、優先度が高い方が先に適用される | `0` |
218
+
219
+ ### パターン書式
220
+
221
+ | パターン | マッチ例(`pages/zonefence.yaml`から) |
222
+ |---------|---------------------------------------|
223
+ | `**/containers` | `pages/home/containers`, `pages/settings/containers`, `pages/a/b/containers` |
224
+ | `*/presenters` | `pages/home/presenters`(直下の子のみ) |
225
+ | `home/containers` | `pages/home/containers`(完全一致) |
226
+
227
+ ### 優先順位
228
+
229
+ 複数のルールがディレクトリに適用される場合:
230
+
231
+ 1. **ディレクトリ固有の `zonefence.yaml`**(最優先)
232
+ 2. **パターンルール**(`priority`値順、次に具体性順)
233
+ 3. **親ディレクトリからの継承**(最低優先)
234
+
155
235
  ## エラー出力例
156
236
 
157
237
  ```
package/README.md CHANGED
@@ -152,6 +152,86 @@ src/
152
152
 
153
153
  With `scope.apply: self`, the rule applies only to the current folder and is not inherited by child folders.
154
154
 
155
+ ## Directory Patterns (Colocation Support)
156
+
157
+ For projects using colocation patterns (e.g., `containers/`, `presenters/` directories under each page), you can define `directoryPatterns` to apply rules automatically to matching directories without duplicating `zonefence.yaml` files.
158
+
159
+ ### Example Structure
160
+
161
+ ```
162
+ src/
163
+ ├── pages/
164
+ │ ├── zonefence.yaml ← Define directoryPatterns here
165
+ │ ├── home/
166
+ │ │ ├── containers/ ← Pattern matches (within pages scope)
167
+ │ │ └── presenters/ ← Pattern matches
168
+ │ └── settings/
169
+ │ ├── containers/ ← Pattern matches
170
+ │ └── presenters/ ← Pattern matches
171
+ └── api/
172
+ └── containers/ ← Does NOT match (outside pages scope)
173
+ ```
174
+
175
+ ### Configuration
176
+
177
+ ```yaml
178
+ # src/pages/zonefence.yaml
179
+ version: 1
180
+ description: "Pages layer rules"
181
+
182
+ directoryPatterns:
183
+ - pattern: "**/containers"
184
+ config:
185
+ description: "Container layer"
186
+ imports:
187
+ allow:
188
+ - from: "../presenters/**"
189
+ - from: "../hooks/**"
190
+ deny:
191
+ - from: "../containers/**"
192
+ message: "Containers should not import from sibling containers"
193
+
194
+ - pattern: "**/presenters"
195
+ config:
196
+ imports:
197
+ allow:
198
+ - from: "./**"
199
+ - from: "@/ui/**"
200
+ deny:
201
+ - from: "../containers/**"
202
+ message: "Presenters should not import from containers"
203
+
204
+ scope:
205
+ exclude:
206
+ - "**/*.test.tsx"
207
+ ```
208
+
209
+ ### Pattern Options
210
+
211
+ | Option | Description | Default |
212
+ |--------|-------------|---------|
213
+ | `pattern` | Glob pattern to match directories (relative to the zonefence.yaml location) | Required |
214
+ | `config.description` | Description for matched directories | - |
215
+ | `config.imports` | Import rules for matched directories | - |
216
+ | `config.mergeStrategy` | `"merge"` (combine with other rules) or `"override"` (replace) | `"merge"` |
217
+ | `priority` | Higher priority patterns are applied first (when multiple patterns match) | `0` |
218
+
219
+ ### Pattern Syntax
220
+
221
+ | Pattern | Matches (from `pages/zonefence.yaml`) |
222
+ |---------|---------------------------------------|
223
+ | `**/containers` | `pages/home/containers`, `pages/settings/containers`, `pages/a/b/containers` |
224
+ | `*/presenters` | `pages/home/presenters` (direct children only) |
225
+ | `home/containers` | `pages/home/containers` (exact path) |
226
+
227
+ ### Priority Order
228
+
229
+ When multiple rules apply to a directory:
230
+
231
+ 1. **Directory's own `zonefence.yaml`** (highest priority)
232
+ 2. **Pattern rules** (sorted by `priority` value, then by specificity)
233
+ 3. **Parent directory inheritance** (lowest priority)
234
+
155
235
  ## Error Output Example
156
236
 
157
237
  ```
@@ -28,7 +28,7 @@ var import_commander = require("commander");
28
28
 
29
29
  // src/cli/commands/check.ts
30
30
  var import_node_fs2 = __toESM(require("fs"), 1);
31
- var import_node_path5 = __toESM(require("path"), 1);
31
+ var import_node_path6 = __toESM(require("path"), 1);
32
32
 
33
33
  // src/core/import-collector.ts
34
34
  function collectImports(project, rootDir) {
@@ -411,6 +411,21 @@ var importRuleSchema = import_zod.z.union([
411
411
  message: import_zod.z.string().optional()
412
412
  })
413
413
  ]);
414
+ var importsSchema = import_zod.z.object({
415
+ allow: import_zod.z.array(importRuleSchema).optional().default([]),
416
+ deny: import_zod.z.array(importRuleSchema).optional().default([]),
417
+ mode: import_zod.z.enum(["allow-first", "deny-first"]).optional().default("allow-first")
418
+ });
419
+ var patternRuleConfigSchema = import_zod.z.object({
420
+ description: import_zod.z.string().optional(),
421
+ imports: importsSchema.optional(),
422
+ mergeStrategy: import_zod.z.enum(["merge", "override"]).optional().default("merge")
423
+ });
424
+ var directoryPatternRuleSchema = import_zod.z.object({
425
+ pattern: import_zod.z.string(),
426
+ config: patternRuleConfigSchema,
427
+ priority: import_zod.z.number().int().optional().default(0)
428
+ });
414
429
  var zoneFenceConfigSchema = import_zod.z.object({
415
430
  version: import_zod.z.number().int().positive(),
416
431
  description: import_zod.z.string().optional(),
@@ -418,11 +433,8 @@ var zoneFenceConfigSchema = import_zod.z.object({
418
433
  apply: import_zod.z.enum(["self", "descendants"]).optional().default("descendants"),
419
434
  exclude: import_zod.z.array(import_zod.z.string()).optional().default([])
420
435
  }).optional().default({}),
421
- imports: import_zod.z.object({
422
- allow: import_zod.z.array(importRuleSchema).optional().default([]),
423
- deny: import_zod.z.array(importRuleSchema).optional().default([]),
424
- mode: import_zod.z.enum(["allow-first", "deny-first"]).optional().default("allow-first")
425
- }).optional().default({})
436
+ imports: importsSchema.optional().default({}),
437
+ directoryPatterns: import_zod.z.array(directoryPatternRuleSchema).optional().default([])
426
438
  });
427
439
  function parseConfig(data) {
428
440
  return zoneFenceConfigSchema.parse(data);
@@ -431,11 +443,17 @@ function parseConfig(data) {
431
443
  // src/rules/loader.ts
432
444
  var RULE_FILE_NAME = "zonefence.yaml";
433
445
  async function loadRulesForDirectory(rootDir) {
446
+ const result = await loadRulesForDirectoryWithAllDirs(rootDir);
447
+ return result.rules;
448
+ }
449
+ async function loadRulesForDirectoryWithAllDirs(rootDir) {
434
450
  const rules = {};
435
- await scanDirectory(rootDir, rootDir, rules);
436
- return rules;
451
+ const allDirectories = [];
452
+ await scanDirectory(rootDir, rootDir, rules, allDirectories);
453
+ return { rules, allDirectories };
437
454
  }
438
- async function scanDirectory(currentDir, rootDir, rules) {
455
+ async function scanDirectory(currentDir, rootDir, rules, allDirectories) {
456
+ allDirectories.push(currentDir);
439
457
  const ruleFilePath = import_node_path3.default.join(currentDir, RULE_FILE_NAME);
440
458
  if (import_node_fs.default.existsSync(ruleFilePath)) {
441
459
  const config = await loadRuleFile(ruleFilePath);
@@ -448,7 +466,7 @@ async function scanDirectory(currentDir, rootDir, rules) {
448
466
  for (const entry of entries) {
449
467
  if (entry.isDirectory() && !shouldSkipDirectory(entry.name)) {
450
468
  const subDir = import_node_path3.default.join(currentDir, entry.name);
451
- await scanDirectory(subDir, rootDir, rules);
469
+ await scanDirectory(subDir, rootDir, rules, allDirectories);
452
470
  }
453
471
  }
454
472
  }
@@ -463,30 +481,138 @@ function shouldSkipDirectory(name) {
463
481
  }
464
482
 
465
483
  // src/rules/resolver.ts
484
+ var import_node_path5 = __toESM(require("path"), 1);
485
+
486
+ // src/rules/pattern-matcher.ts
466
487
  var import_node_path4 = __toESM(require("path"), 1);
488
+ var import_minimatch2 = require("minimatch");
489
+ function matchDirectoryPattern(targetDir, pattern, sourceDir) {
490
+ const relativePath = import_node_path4.default.relative(sourceDir, targetDir);
491
+ if (!relativePath || relativePath.startsWith("..") || import_node_path4.default.isAbsolute(relativePath)) {
492
+ return false;
493
+ }
494
+ return (0, import_minimatch2.minimatch)(relativePath, pattern, { dot: false });
495
+ }
496
+ function calculateSpecificity(pattern) {
497
+ let specificity = 0;
498
+ const segments = pattern.split("/");
499
+ for (const segment of segments) {
500
+ if (segment === "**") {
501
+ specificity += 1;
502
+ } else if (segment === "*") {
503
+ specificity += 5;
504
+ } else if (segment.includes("*")) {
505
+ specificity += 8;
506
+ } else {
507
+ specificity += 10;
508
+ }
509
+ }
510
+ return specificity;
511
+ }
512
+ function findMatchingPatterns(targetDir, patternSources) {
513
+ const matches = [];
514
+ for (const source of patternSources) {
515
+ for (const rule of source.patterns) {
516
+ if (matchDirectoryPattern(targetDir, rule.pattern, source.sourceDir)) {
517
+ matches.push({
518
+ pattern: rule.pattern,
519
+ config: rule.config,
520
+ priority: rule.priority ?? 0,
521
+ sourceFile: source.sourceFile,
522
+ specificity: calculateSpecificity(rule.pattern)
523
+ });
524
+ }
525
+ }
526
+ }
527
+ matches.sort((a, b) => {
528
+ if (a.priority !== b.priority) {
529
+ return b.priority - a.priority;
530
+ }
531
+ return b.specificity - a.specificity;
532
+ });
533
+ return matches;
534
+ }
535
+ function collectPatternSources(rulesByDirectory) {
536
+ const sources = [];
537
+ for (const [directory, { config, ruleFilePath }] of Object.entries(rulesByDirectory)) {
538
+ if (config.directoryPatterns && config.directoryPatterns.length > 0) {
539
+ sources.push({
540
+ sourceFile: ruleFilePath,
541
+ sourceDir: directory,
542
+ patterns: config.directoryPatterns
543
+ });
544
+ }
545
+ }
546
+ return sources;
547
+ }
548
+
549
+ // src/rules/resolver.ts
467
550
  function resolveRules(rulesByDirectory) {
468
551
  const resolvedRules = [];
469
552
  const directories = Object.keys(rulesByDirectory).sort((a, b) => a.length - b.length);
553
+ const patternSources = collectPatternSources(rulesByDirectory);
470
554
  for (const directory of directories) {
471
555
  const { config, ruleFilePath } = rulesByDirectory[directory];
472
556
  const parentRules = findParentRules(directory, directories, rulesByDirectory);
473
- const mergedConfig = mergeConfigs(parentRules, config);
557
+ let mergedConfig = mergeConfigs(parentRules, config);
558
+ const patternMatches = findMatchingPatterns(directory, patternSources);
559
+ const appliedPatternRules = [];
560
+ if (patternMatches.length > 0) {
561
+ let patternConfig = createEmptyConfig(mergedConfig.version);
562
+ for (const match of [...patternMatches].reverse()) {
563
+ patternConfig = applyPatternRule(patternConfig, match);
564
+ appliedPatternRules.unshift({
565
+ pattern: match.pattern,
566
+ sourceFile: match.sourceFile,
567
+ priority: match.priority
568
+ });
569
+ }
570
+ mergedConfig = mergeTwoConfigs(patternConfig, mergedConfig);
571
+ }
474
572
  const excludePatterns = collectExcludePatterns(mergedConfig);
475
573
  resolvedRules.push({
476
574
  directory,
477
575
  ruleFilePath,
478
576
  config: mergedConfig,
479
- excludePatterns
577
+ excludePatterns,
578
+ appliedPatternRules: appliedPatternRules.length > 0 ? appliedPatternRules : void 0
480
579
  });
481
580
  }
482
581
  return resolvedRules;
483
582
  }
583
+ function createEmptyConfig(version) {
584
+ return { version };
585
+ }
586
+ function applyPatternRule(base, match) {
587
+ const { config } = match;
588
+ const mergeStrategy = config.mergeStrategy ?? "merge";
589
+ if (mergeStrategy === "override") {
590
+ return {
591
+ ...base,
592
+ description: config.description ?? base.description,
593
+ imports: config.imports ? {
594
+ allow: config.imports.allow ?? [],
595
+ deny: config.imports.deny ?? [],
596
+ mode: config.imports.mode ?? base.imports?.mode ?? "allow-first"
597
+ } : base.imports
598
+ };
599
+ }
600
+ return {
601
+ ...base,
602
+ description: config.description ?? base.description,
603
+ imports: {
604
+ allow: mergeImportRules(base.imports?.allow, config.imports?.allow),
605
+ deny: mergeImportRules(base.imports?.deny, config.imports?.deny),
606
+ mode: config.imports?.mode ?? base.imports?.mode ?? "allow-first"
607
+ }
608
+ };
609
+ }
484
610
  function findParentRules(directory, allDirectories, rulesByDirectory) {
485
611
  const parents = [];
486
612
  for (const potentialParent of allDirectories) {
487
613
  if (potentialParent === directory) continue;
488
- const relative = import_node_path4.default.relative(potentialParent, directory);
489
- if (!relative.startsWith("..") && !import_node_path4.default.isAbsolute(relative)) {
614
+ const relative = import_node_path5.default.relative(potentialParent, directory);
615
+ if (!relative.startsWith("..") && !import_node_path5.default.isAbsolute(relative)) {
490
616
  const parentConfig = rulesByDirectory[potentialParent].config;
491
617
  const scopeApply = parentConfig.scope?.apply ?? "descendants";
492
618
  if (scopeApply === "descendants") {
@@ -546,12 +672,12 @@ function collectExcludePatterns(config) {
546
672
  // src/cli/commands/check.ts
547
673
  function findTsConfig(startDir) {
548
674
  let dir = startDir;
549
- while (dir !== import_node_path5.default.dirname(dir)) {
550
- const configPath = import_node_path5.default.join(dir, "tsconfig.json");
675
+ while (dir !== import_node_path6.default.dirname(dir)) {
676
+ const configPath = import_node_path6.default.join(dir, "tsconfig.json");
551
677
  if (import_node_fs2.default.existsSync(configPath)) {
552
678
  return configPath;
553
679
  }
554
- dir = import_node_path5.default.dirname(dir);
680
+ dir = import_node_path6.default.dirname(dir);
555
681
  }
556
682
  return void 0;
557
683
  }
@@ -561,20 +687,20 @@ function getPathsMapping(project, rootDir, tsConfigFilePath) {
561
687
  if (!originalPaths || !tsConfigFilePath) {
562
688
  return originalPaths;
563
689
  }
564
- const tsConfigDir = import_node_path5.default.dirname(tsConfigFilePath);
690
+ const tsConfigDir = import_node_path6.default.dirname(tsConfigFilePath);
565
691
  const adjustedPaths = {};
566
692
  for (const [alias, targets] of Object.entries(originalPaths)) {
567
693
  adjustedPaths[alias] = targets.map((target) => {
568
694
  const targetWithoutGlob = target.replace(/\*$/, "");
569
- const absoluteTarget = import_node_path5.default.resolve(tsConfigDir, targetWithoutGlob);
570
- const relativeToRoot = import_node_path5.default.relative(rootDir, absoluteTarget);
695
+ const absoluteTarget = import_node_path6.default.resolve(tsConfigDir, targetWithoutGlob);
696
+ const relativeToRoot = import_node_path6.default.relative(rootDir, absoluteTarget);
571
697
  return (relativeToRoot || ".") + (target.endsWith("*") ? "/*" : "");
572
698
  });
573
699
  }
574
700
  return adjustedPaths;
575
701
  }
576
702
  async function checkCommand(targetPath, options) {
577
- const absolutePath = import_node_path5.default.resolve(targetPath);
703
+ const absolutePath = import_node_path6.default.resolve(targetPath);
578
704
  console.log(`Checking import boundaries in: ${absolutePath}
579
705
  `);
580
706
  try {