@kekkai/structure-lint 1.0.0 → 1.1.1
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 +70 -6
- package/README.zh-TW.md +68 -4
- package/dist/bin.js +7 -5
- package/dist/index.js +14 -7
- package/dist/main.d.ts +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/utils.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
[](https://codecov.io/gh/taco3064/kekkai-structure-lint)
|
|
2
|
+
|
|
1
3
|
**English** | [繁體中文](https://github.com/taco3064/kekkai-structure-lint/blob/main/README.zh-TW.md)
|
|
2
4
|
|
|
3
|
-
#
|
|
5
|
+
# @kekkai/structure-lint
|
|
4
6
|
|
|
5
7
|
A **config-driven** ESLint structure rule generator that enforces **one-way folder dependency flow** in your project, with a separate CLI for syncing and validating dependency rules in documentation.
|
|
6
8
|
|
|
@@ -13,7 +15,7 @@ In medium to large front-end projects, folder structures tend to degrade over ti
|
|
|
13
15
|
- Architecture rules live only in documentation, not in tooling
|
|
14
16
|
- Documentation and actual code gradually drift apart
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
`@kekkai/structure-lint` is not about code style.
|
|
17
19
|
Its goal is to **turn folder structure and dependency direction into enforceable ESLint rules**.
|
|
18
20
|
|
|
19
21
|
> ⚠️ **ESLint v9+ Required**
|
|
@@ -61,10 +63,6 @@ This package is built on three core ideas:
|
|
|
61
63
|
|
|
62
64
|
```bash
|
|
63
65
|
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
66
|
```
|
|
69
67
|
|
|
70
68
|
This package provides:
|
|
@@ -145,6 +143,26 @@ This file is read by both:
|
|
|
145
143
|
*/
|
|
146
144
|
"lintFiles": "src/{folder}/**/*.{ts,tsx}",
|
|
147
145
|
|
|
146
|
+
/**
|
|
147
|
+
* moduleLayout
|
|
148
|
+
* type (optional)
|
|
149
|
+
* - 'folder' | 'flat'
|
|
150
|
+
*
|
|
151
|
+
* Controls how modules are structured within each layer and how
|
|
152
|
+
* relative import restrictions are generated.
|
|
153
|
+
*
|
|
154
|
+
* - 'folder' (default):
|
|
155
|
+
* Each module lives in its own subfolder (e.g. components/Card/*),
|
|
156
|
+
* and imports are only allowed via the module entry.
|
|
157
|
+
*
|
|
158
|
+
* - 'flat':
|
|
159
|
+
* Modules are represented directly by files under the layer
|
|
160
|
+
* (e.g. components/Card.tsx).
|
|
161
|
+
*
|
|
162
|
+
* Default: 'folder'
|
|
163
|
+
*/
|
|
164
|
+
"moduleLayout": "folder",
|
|
165
|
+
|
|
148
166
|
/**
|
|
149
167
|
* dependencyFlow
|
|
150
168
|
* type (required):
|
|
@@ -248,6 +266,52 @@ Based on the `markerTag` configuration, the CLI will locate and overwrite the fo
|
|
|
248
266
|
<!-- DEPENDENCY_RULE:END -->
|
|
249
267
|
```
|
|
250
268
|
|
|
269
|
+
## 🔁 Circular Dependencies (Optional)
|
|
270
|
+
|
|
271
|
+
`@kekkai/structure-lint` enforces one-way dependency flow **across layers**,
|
|
272
|
+
but intentionally allows **same-layer imports as a design trade-off**.
|
|
273
|
+
|
|
274
|
+
As a result, circular dependencies may still occur within the same layer.
|
|
275
|
+
This is usually acceptable for small modules, but may become risky as the codebase grows.
|
|
276
|
+
|
|
277
|
+
If your team wants to detect these cases, you can optionally enable:
|
|
278
|
+
|
|
279
|
+
- `import/no-cycle` (from [`eslint-plugin-import`](https://www.npmjs.com/package/eslint-plugin-import))
|
|
280
|
+
|
|
281
|
+
> ⚠️ **TypeScript projects must configure a resolver**, otherwise circular dependencies may not be detected.
|
|
282
|
+
> Try [`eslint-import-resolver-typescript`](https://www.npmjs.com/package/eslint-import-resolver-typescript).
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import imports from 'eslint-plugin-import';
|
|
286
|
+
import { defineConfig } from 'eslint/config';
|
|
287
|
+
import { createStructureLint } from '@kekkai/structure-lint';
|
|
288
|
+
|
|
289
|
+
export default defineConfig([
|
|
290
|
+
{
|
|
291
|
+
plugins: {
|
|
292
|
+
import: imports,
|
|
293
|
+
},
|
|
294
|
+
settings: {
|
|
295
|
+
'import/parsers': {
|
|
296
|
+
// Project file extensions handled by the TypeScript parser
|
|
297
|
+
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
|
298
|
+
},
|
|
299
|
+
'import/resolver': {
|
|
300
|
+
typescript: true,
|
|
301
|
+
node: {
|
|
302
|
+
// Project file extensions used for module resolution
|
|
303
|
+
extensions: ['.ts', '.tsx'],
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
rules: {
|
|
308
|
+
'import/no-cycle': 'error',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
...createStructureLint(),
|
|
312
|
+
]);
|
|
313
|
+
```
|
|
314
|
+
|
|
251
315
|
## 🧠 Philosophy
|
|
252
316
|
|
|
253
317
|
`@kekkai/structure-lint` treats folder structure as architecture.
|
package/README.zh-TW.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
[](https://codecov.io/gh/taco3064/kekkai-structure-lint)
|
|
2
|
+
|
|
1
3
|
[English](https://github.com/taco3064/kekkai-structure-lint/blob/main/README.md) | **繁體中文**
|
|
2
4
|
|
|
3
5
|
# 📦 @kekkai/structure-lint
|
|
@@ -60,10 +62,6 @@
|
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
64
|
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
65
|
```
|
|
68
66
|
|
|
69
67
|
本套件同時提供:
|
|
@@ -140,6 +138,27 @@ structure.config.json 用來定義專案的 資料夾依賴規則 與 文件同
|
|
|
140
138
|
*/
|
|
141
139
|
"lintFiles": "src/{folder}/**/*.{ts,tsx}",
|
|
142
140
|
|
|
141
|
+
/**
|
|
142
|
+
* moduleLayout
|
|
143
|
+
* type (optional)
|
|
144
|
+
* - 'folder' | 'flat'
|
|
145
|
+
*
|
|
146
|
+
* 用來指定各層(layer)中模組的組織方式,
|
|
147
|
+
* 並影響相對路徑(relative import)的限制規則產生方式。
|
|
148
|
+
*
|
|
149
|
+
* - 'folder'(預設):
|
|
150
|
+
* 每個模組以資料夾為單位存在
|
|
151
|
+
* (例如 components/Card/*),
|
|
152
|
+
* 跨模組只能透過模組入口(index.ts)引用。
|
|
153
|
+
*
|
|
154
|
+
* - 'flat':
|
|
155
|
+
* 模組直接以檔案形式存在於該層底下
|
|
156
|
+
* (例如 components/Card.tsx)。
|
|
157
|
+
*
|
|
158
|
+
* 預設值:'folder'
|
|
159
|
+
*/
|
|
160
|
+
"moduleLayout": "folder",
|
|
161
|
+
|
|
143
162
|
/**
|
|
144
163
|
* dependencyFlow
|
|
145
164
|
* type (required):
|
|
@@ -238,6 +257,51 @@ flowchart TD
|
|
|
238
257
|
<!-- DEPENDENCY_RULE:END -->
|
|
239
258
|
```
|
|
240
259
|
|
|
260
|
+
## 🔁 Circular Dependencies (Optional)
|
|
261
|
+
|
|
262
|
+
`@kekkai/structure-lint` 會嚴格限制 **跨層** 的單向依賴方向,但仍刻意允許 **同一層內的模組彼此引用** 作為設計取捨。
|
|
263
|
+
|
|
264
|
+
因此,在相同 layer 之內,仍然可能發生循環依賴(circular dependencies)。
|
|
265
|
+
這在模組規模較小時通常是可接受的,但隨著專案成長,可能會逐漸帶來風險。
|
|
266
|
+
|
|
267
|
+
如果你的團隊希望進一步偵測這類情況,可以選擇性地啟用以下規則:
|
|
268
|
+
|
|
269
|
+
- `import/no-cycle` (from [`eslint-plugin-import`](https://www.npmjs.com/package/eslint-plugin-import))
|
|
270
|
+
|
|
271
|
+
> ⚠️ **TypeScript 專案必須正確設定 resolver**,否則可能無法偵測到循環依賴。
|
|
272
|
+
> 建議搭配使用 [`eslint-import-resolver-typescript`](https://www.npmjs.com/package/eslint-import-resolver-typescript)。
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import imports from 'eslint-plugin-import';
|
|
276
|
+
import { defineConfig } from 'eslint/config';
|
|
277
|
+
import { createStructureLint } from '@kekkai/structure-lint';
|
|
278
|
+
|
|
279
|
+
export default defineConfig([
|
|
280
|
+
{
|
|
281
|
+
plugins: {
|
|
282
|
+
import: imports,
|
|
283
|
+
},
|
|
284
|
+
settings: {
|
|
285
|
+
'import/parsers': {
|
|
286
|
+
// Project file extensions handled by the TypeScript parser
|
|
287
|
+
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
|
288
|
+
},
|
|
289
|
+
'import/resolver': {
|
|
290
|
+
typescript: true,
|
|
291
|
+
node: {
|
|
292
|
+
// Project file extensions used for module resolution
|
|
293
|
+
extensions: ['.ts', '.tsx'],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
rules: {
|
|
298
|
+
'import/no-cycle': 'error',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
...createStructureLint(),
|
|
302
|
+
]);
|
|
303
|
+
```
|
|
304
|
+
|
|
241
305
|
## 🧠 Philosophy
|
|
242
306
|
|
|
243
307
|
`@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(
|
|
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: "\n🚫 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>(
|
|
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.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|