@kekkai/structure-lint 1.0.0 → 1.1.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/README.md CHANGED
@@ -13,7 +13,7 @@ In medium to large front-end projects, folder structures tend to degrade over ti
13
13
  - Architecture rules live only in documentation, not in tooling
14
14
  - Documentation and actual code gradually drift apart
15
15
 
16
- @kekkai/structure-lint is not about code style.
16
+ `@kekkai/structure-lint` is not about code style.
17
17
  Its goal is to **turn folder structure and dependency direction into enforceable ESLint rules**.
18
18
 
19
19
  > ⚠️ **ESLint v9+ Required**
@@ -61,10 +61,6 @@ This package is built on three core ideas:
61
61
 
62
62
  ```bash
63
63
  npm install -D @kekkai/structure-lint
64
- # or
65
- pnpm add -D @kekkai/structure-lint
66
- # or
67
- yarn add -D @kekkai/structure-lint
68
64
  ```
69
65
 
70
66
  This package provides:
@@ -145,6 +141,26 @@ This file is read by both:
145
141
  */
146
142
  "lintFiles": "src/{folder}/**/*.{ts,tsx}",
147
143
 
144
+ /**
145
+ * moduleLayout
146
+ * type (optional)
147
+ * - 'folder' | 'flat'
148
+ *
149
+ * Controls how modules are structured within each layer and how
150
+ * relative import restrictions are generated.
151
+ *
152
+ * - 'folder' (default):
153
+ * Each module lives in its own subfolder (e.g. components/Card/*),
154
+ * and imports are only allowed via the module entry.
155
+ *
156
+ * - 'flat':
157
+ * Modules are represented directly by files under the layer
158
+ * (e.g. components/Card.tsx).
159
+ *
160
+ * Default: 'folder'
161
+ */
162
+ "moduleLayout": "folder",
163
+
148
164
  /**
149
165
  * dependencyFlow
150
166
  * type (required):
@@ -248,6 +264,52 @@ Based on the `markerTag` configuration, the CLI will locate and overwrite the fo
248
264
  <!-- DEPENDENCY_RULE:END -->
249
265
  ```
250
266
 
267
+ ## 🔁 Circular Dependencies (Optional)
268
+
269
+ `@kekkai/structure-lint` enforces one-way dependency flow **across layers**,
270
+ but intentionally allows **same-layer imports as a design trade-off**.
271
+
272
+ As a result, circular dependencies may still occur within the same layer.
273
+ This is usually acceptable for small modules, but may become risky as the codebase grows.
274
+
275
+ If your team wants to detect these cases, you can optionally enable:
276
+
277
+ - `import/no-cycle` (from [`eslint-plugin-import`](https://www.npmjs.com/package/eslint-plugin-import))
278
+
279
+ > ⚠️ **TypeScript projects must configure a resolver**, otherwise circular dependencies may not be detected.
280
+ > Try [`eslint-import-resolver-typescript`](https://www.npmjs.com/package/eslint-import-resolver-typescript).
281
+
282
+ ```ts
283
+ import imports from 'eslint-plugin-import';
284
+ import { defineConfig } from 'eslint/config';
285
+ import { createStructureLint } from '@kekkai/structure-lint';
286
+
287
+ export default defineConfig([
288
+ {
289
+ plugins: {
290
+ import: imports,
291
+ },
292
+ settings: {
293
+ 'import/parsers': {
294
+ // Project file extensions handled by the TypeScript parser
295
+ '@typescript-eslint/parser': ['.ts', '.tsx'],
296
+ },
297
+ 'import/resolver': {
298
+ typescript: true,
299
+ node: {
300
+ // Project file extensions used for module resolution
301
+ extensions: ['.ts', '.tsx'],
302
+ },
303
+ },
304
+ },
305
+ rules: {
306
+ 'import/no-cycle': 'error',
307
+ },
308
+ },
309
+ ...createStructureLint(),
310
+ ]);
311
+ ```
312
+
251
313
  ## 🧠 Philosophy
252
314
 
253
315
  `@kekkai/structure-lint` treats folder structure as architecture.
package/README.zh-TW.md CHANGED
@@ -60,10 +60,6 @@
60
60
 
61
61
  ```bash
62
62
  npm install -D @kekkai/structure-lint
63
- # or
64
- pnpm add -D @kekkai/structure-lint
65
- # or
66
- yarn add -D @kekkai/structure-lint
67
63
  ```
68
64
 
69
65
  本套件同時提供:
@@ -140,6 +136,27 @@ structure.config.json 用來定義專案的 資料夾依賴規則 與 文件同
140
136
  */
141
137
  "lintFiles": "src/{folder}/**/*.{ts,tsx}",
142
138
 
139
+ /**
140
+ * moduleLayout
141
+ * type (optional)
142
+ * - 'folder' | 'flat'
143
+ *
144
+ * 用來指定各層(layer)中模組的組織方式,
145
+ * 並影響相對路徑(relative import)的限制規則產生方式。
146
+ *
147
+ * - 'folder'(預設):
148
+ * 每個模組以資料夾為單位存在
149
+ * (例如 components/Card/*),
150
+ * 跨模組只能透過模組入口(index.ts)引用。
151
+ *
152
+ * - 'flat':
153
+ * 模組直接以檔案形式存在於該層底下
154
+ * (例如 components/Card.tsx)。
155
+ *
156
+ * 預設值:'folder'
157
+ */
158
+ "moduleLayout": "folder",
159
+
143
160
  /**
144
161
  * dependencyFlow
145
162
  * type (required):
@@ -238,6 +255,51 @@ flowchart TD
238
255
  <!-- DEPENDENCY_RULE:END -->
239
256
  ```
240
257
 
258
+ ## 🔁 Circular Dependencies (Optional)
259
+
260
+ `@kekkai/structure-lint` 會嚴格限制 **跨層** 的單向依賴方向,但仍刻意允許 **同一層內的模組彼此引用** 作為設計取捨。
261
+
262
+ 因此,在相同 layer 之內,仍然可能發生循環依賴(circular dependencies)。
263
+ 這在模組規模較小時通常是可接受的,但隨著專案成長,可能會逐漸帶來風險。
264
+
265
+ 如果你的團隊希望進一步偵測這類情況,可以選擇性地啟用以下規則:
266
+
267
+ - `import/no-cycle` (from [`eslint-plugin-import`](https://www.npmjs.com/package/eslint-plugin-import))
268
+
269
+ > ⚠️ **TypeScript 專案必須正確設定 resolver**,否則可能無法偵測到循環依賴。
270
+ > 建議搭配使用 [`eslint-import-resolver-typescript`](https://www.npmjs.com/package/eslint-import-resolver-typescript)。
271
+
272
+ ```ts
273
+ import imports from 'eslint-plugin-import';
274
+ import { defineConfig } from 'eslint/config';
275
+ import { createStructureLint } from '@kekkai/structure-lint';
276
+
277
+ export default defineConfig([
278
+ {
279
+ plugins: {
280
+ import: imports,
281
+ },
282
+ settings: {
283
+ 'import/parsers': {
284
+ // Project file extensions handled by the TypeScript parser
285
+ '@typescript-eslint/parser': ['.ts', '.tsx'],
286
+ },
287
+ 'import/resolver': {
288
+ typescript: true,
289
+ node: {
290
+ // Project file extensions used for module resolution
291
+ extensions: ['.ts', '.tsx'],
292
+ },
293
+ },
294
+ },
295
+ rules: {
296
+ 'import/no-cycle': 'error',
297
+ },
298
+ },
299
+ ...createStructureLint(),
300
+ ]);
301
+ ```
302
+
241
303
  ## 🧠 Philosophy
242
304
 
243
305
  `@kekkai/structure-lint` 將資料夾結構視為架構本身。
package/dist/bin.js CHANGED
@@ -77,7 +77,7 @@ function loadStructureConfig(defaultConfigPath = "structure.config.json") {
77
77
  if (!fs.existsSync(configPath)) throw new Error(`structure.config.json not found at ${configPath}`);
78
78
  return isConfigValid(JSON.parse(fs.readFileSync(configPath, "utf-8")));
79
79
  }
80
- function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRules, packageImportRules }) {
80
+ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, moduleLayout, overrideRules, packageImportRules }) {
81
81
  if (typeof appAlias !== "string" || !appAlias?.trim()) throw new Error("appAlias is required in structure.config.json");
82
82
  if (!Array.isArray(dependencyFlow)) throw new Error("dependencyFlow must be an array in structure.config.json");
83
83
  else for (const item of dependencyFlow) if (!Array.isArray(item)) throw new Error("Each item in dependencyFlow must be a tuple [string, string, options?] in structure.config.json");
@@ -90,6 +90,11 @@ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRule
90
90
  else if (typeof markerTag !== "string" || !markerTag?.trim()) throw new Error("docs.markerTag is required in structure.config.json if docs is provided");
91
91
  else if (content && typeof content !== "string") throw new Error("docs.content must be a string in structure.config.json");
92
92
  }
93
+ const files = Array.isArray(lintFiles) ? lintFiles : [lintFiles];
94
+ if (!files.length) throw new Error("lintFiles is required in structure.config.json");
95
+ else for (const file of files) if (typeof file !== "string" || !file?.trim()) throw new Error("lintFiles must be a non-empty string or an array of non-empty strings in structure.config.json");
96
+ else if (!LINT_FILES_REGEX.test(file)) throw new Error("Each lintFiles entry must include the \"{folder}\" placeholder in structure.config.json");
97
+ if (moduleLayout !== void 0 && !["folder", "flat"].includes(moduleLayout)) throw new Error("moduleLayout must be either \"folder\" or \"flat\" in structure.config.json");
93
98
  if (overrideRules) {
94
99
  const entries = Object.entries(overrideRules);
95
100
  if (entries.some(([key]) => !folders.includes(key))) throw new Error("overrideRules contains invalid folder keys not present in dependencyFlow in structure.config.json");
@@ -105,15 +110,12 @@ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRule
105
110
  else if (allowedInFolders.some((folder) => !folders.includes(folder))) throw new Error("allowedInFolders in packageImportRule contains invalid folder names not present in dependencyFlow in structure.config.json");
106
111
  }
107
112
  }
108
- const files = Array.isArray(lintFiles) ? lintFiles : [lintFiles];
109
- if (!files.length) throw new Error("lintFiles is required in structure.config.json");
110
- else for (const file of files) if (typeof file !== "string" || !file?.trim()) throw new Error("lintFiles must be a non-empty string or an array of non-empty strings in structure.config.json");
111
- else if (!LINT_FILES_REGEX.test(file)) throw new Error("Each lintFiles entry must include the \"{folder}\" placeholder in structure.config.json");
112
113
  return {
113
114
  appAlias,
114
115
  dependencyFlow,
115
116
  docs,
116
117
  lintFiles: files,
118
+ moduleLayout,
117
119
  overrideRules,
118
120
  packageImportRules
119
121
  };
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ function loadStructureConfig(defaultConfigPath = "structure.config.json") {
25
25
  if (!fs.existsSync(configPath)) throw new Error(`structure.config.json not found at ${configPath}`);
26
26
  return isConfigValid(JSON.parse(fs.readFileSync(configPath, "utf-8")));
27
27
  }
28
- function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRules, packageImportRules }) {
28
+ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, moduleLayout, overrideRules, packageImportRules }) {
29
29
  if (typeof appAlias !== "string" || !appAlias?.trim()) throw new Error("appAlias is required in structure.config.json");
30
30
  if (!Array.isArray(dependencyFlow)) throw new Error("dependencyFlow must be an array in structure.config.json");
31
31
  else for (const item of dependencyFlow) if (!Array.isArray(item)) throw new Error("Each item in dependencyFlow must be a tuple [string, string, options?] in structure.config.json");
@@ -38,6 +38,11 @@ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRule
38
38
  else if (typeof markerTag !== "string" || !markerTag?.trim()) throw new Error("docs.markerTag is required in structure.config.json if docs is provided");
39
39
  else if (content && typeof content !== "string") throw new Error("docs.content must be a string in structure.config.json");
40
40
  }
41
+ const files = Array.isArray(lintFiles) ? lintFiles : [lintFiles];
42
+ if (!files.length) throw new Error("lintFiles is required in structure.config.json");
43
+ else for (const file of files) if (typeof file !== "string" || !file?.trim()) throw new Error("lintFiles must be a non-empty string or an array of non-empty strings in structure.config.json");
44
+ else if (!LINT_FILES_REGEX.test(file)) throw new Error("Each lintFiles entry must include the \"{folder}\" placeholder in structure.config.json");
45
+ if (moduleLayout !== void 0 && !["folder", "flat"].includes(moduleLayout)) throw new Error("moduleLayout must be either \"folder\" or \"flat\" in structure.config.json");
41
46
  if (overrideRules) {
42
47
  const entries = Object.entries(overrideRules);
43
48
  if (entries.some(([key]) => !folders.includes(key))) throw new Error("overrideRules contains invalid folder keys not present in dependencyFlow in structure.config.json");
@@ -53,15 +58,12 @@ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRule
53
58
  else if (allowedInFolders.some((folder) => !folders.includes(folder))) throw new Error("allowedInFolders in packageImportRule contains invalid folder names not present in dependencyFlow in structure.config.json");
54
59
  }
55
60
  }
56
- const files = Array.isArray(lintFiles) ? lintFiles : [lintFiles];
57
- if (!files.length) throw new Error("lintFiles is required in structure.config.json");
58
- else for (const file of files) if (typeof file !== "string" || !file?.trim()) throw new Error("lintFiles must be a non-empty string or an array of non-empty strings in structure.config.json");
59
- else if (!LINT_FILES_REGEX.test(file)) throw new Error("Each lintFiles entry must include the \"{folder}\" placeholder in structure.config.json");
60
61
  return {
61
62
  appAlias,
62
63
  dependencyFlow,
63
64
  docs,
64
65
  lintFiles: files,
66
+ moduleLayout,
65
67
  overrideRules,
66
68
  packageImportRules
67
69
  };
@@ -69,7 +71,8 @@ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRule
69
71
 
70
72
  //#endregion
71
73
  //#region src/lint/main.ts
72
- function createStructureLint({ appAlias, dependencyFlow, lintFiles, overrideRules, packageImportRules } = loadStructureConfig()) {
74
+ function createStructureLint(config) {
75
+ const { appAlias, dependencyFlow, lintFiles, moduleLayout = "folder", overrideRules, packageImportRules } = config || loadStructureConfig();
73
76
  const folders = extractAllFolders(dependencyFlow);
74
77
  return folders.map((folder) => {
75
78
  const disableFolderImports = getDisableFolderImports(dependencyFlow, folders, folder);
@@ -81,7 +84,11 @@ function createStructureLint({ appAlias, dependencyFlow, lintFiles, overrideRule
81
84
  "no-restricted-imports": ["error", {
82
85
  patterns: [
83
86
  {
84
- group: ["../*/**"],
87
+ group: ["./../**", "././**"],
88
+ message: "🚫 Redundant relative path segments (././, ./../) are not allowed. They bypass structural import rules."
89
+ },
90
+ {
91
+ group: [moduleLayout === "folder" ? "../*/**" : "../**"],
85
92
  message: "\n🚫 Do not import from upper-level directories. Use the project alias (e.g. \"~app/*\") to follow the dependency flow."
86
93
  },
87
94
  {
package/dist/main.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { ConfigWithExtendsArray } from '@eslint/config-helpers';
2
2
  import type { DefineOptions } from './types';
3
- export declare function createStructureLint<F extends string>({ appAlias, dependencyFlow, lintFiles, overrideRules, packageImportRules, }?: Omit<DefineOptions<F>, 'docs'>): ConfigWithExtendsArray;
3
+ export declare function createStructureLint<F extends string>(config?: Omit<DefineOptions<F>, 'docs'>): ConfigWithExtendsArray;
package/dist/types.d.ts CHANGED
@@ -22,6 +22,7 @@ export interface DefineOptions<F extends string> {
22
22
  dependencyFlow: DependencyFlow<F>[];
23
23
  docs?: DocsOptions;
24
24
  lintFiles: string | string[];
25
+ moduleLayout?: 'folder' | 'flat';
25
26
  overrideRules?: Partial<Record<F, Partial<RulesConfig>>>;
26
27
  packageImportRules?: PackageImportRule<F>[];
27
28
  }
package/dist/utils.d.ts CHANGED
@@ -4,4 +4,4 @@ export declare function extractAllFolders<F extends string>(dependencyFlow: Depe
4
4
  export declare function getDisableFolderImports<F extends string>(config: DependencyFlow<F>[], folders: Readonly<F[]>, folder: F): F[];
5
5
  export declare function getLintFiles<F extends string>(folder: F, lintFiles: string | string[]): string[];
6
6
  export declare function loadStructureConfig<F extends string>(defaultConfigPath?: string): DefineOptions<F>;
7
- export declare function isConfigValid<F extends string>({ appAlias, dependencyFlow, docs, lintFiles, overrideRules, packageImportRules, }: Partial<Record<keyof DefineOptions<F>, JsonValue>>): DefineOptions<F>;
7
+ export declare function isConfigValid<F extends string>({ appAlias, dependencyFlow, docs, lintFiles, moduleLayout, overrideRules, packageImportRules, }: Partial<Record<keyof DefineOptions<F>, JsonValue>>): DefineOptions<F>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kekkai/structure-lint",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "description": "Config-driven ESLint structure rule generator enforcing one-way folder dependency flow, with a separate CLI for docs sync/verification.",
6
6
  "type": "module",