@kekkai/structure-lint 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Taco Chang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,260 @@
1
+ **English** | [繁體中文](https://github.com/taco3064/kekkai-structure-lint/blob/main/README.zh-TW.md)
2
+
3
+ # 📦 @kekkai/structure-lint
4
+
5
+ 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
+
7
+ ## 🔍 What Problem Does This Solve?
8
+
9
+ In medium to large front-end projects, folder structures tend to degrade over time:
10
+
11
+ - Modules can freely import each other without clear boundaries
12
+ - Relative imports (`../`) make dependency relationships hard to reason about
13
+ - Architecture rules live only in documentation, not in tooling
14
+ - Documentation and actual code gradually drift apart
15
+
16
+ @kekkai/structure-lint is not about code style.
17
+ Its goal is to **turn folder structure and dependency direction into enforceable ESLint rules**.
18
+
19
+ > ⚠️ **ESLint v9+ Required**
20
+ >
21
+ > This package is built for **ESLint Flat Config**
22
+ > and requires **ESLint v9 or later**.
23
+ >
24
+ > If your project is still using legacy `.eslintrc`,
25
+ > please migrate to Flat Config before using this package.
26
+
27
+ ## ✨ Core Ideas
28
+
29
+ This package is built on three core ideas:
30
+
31
+ 1. **Folder-as-a-Module**
32
+ - Treat each subfolder as a standalone module (e.g. `hooks/useShuffleCards`, `components/Card`)
33
+ - Prefer `index.ts` as the module entry to keep imports consistent and readable
34
+
35
+ 2. **One-way Dependency Flow**
36
+ - Dependency rules are defined at the folder level: each folder may only depend on allowed downstream folders
37
+ - No reverse dependencies and no layer-skipping shortcuts
38
+
39
+ 3. **Enforceable Imports (Alias-only Cross-folder Imports)**
40
+ - Relative imports (`./`) are allowed within a module
41
+ - Cross-folder imports must use the project alias (e.g. `~app/...`) to prevent uncontrolled `../` shortcut dependencies
42
+
43
+ > 🗂️ **Example Folder Structure:**
44
+ >
45
+ > ```text
46
+ > src/
47
+ > ├─ components/
48
+ > │ └─ Card/
49
+ > │ ├─ Component.tsx
50
+ > │ ├─ index.ts
51
+ > │ └─ types.ts
52
+ > ├─ containers/
53
+ > │ └─ DeckDrawStage/
54
+ > │ ├─ Container.tsx
55
+ > │ ├─ index.ts
56
+ > │ └─ types.ts
57
+ > └─ main.tsx
58
+ > ```
59
+
60
+ ## 📥 Installation
61
+
62
+ ```bash
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
+ ```
69
+
70
+ This package provides:
71
+
72
+ - An ESLint Flat Config generator
73
+ - A standalone CLI for documentation sync and validation
74
+
75
+ ## 🚀 Quick Start
76
+
77
+ - **eslint.config.(js|ts)**
78
+ `@kekkai/structure-lint` supports two usage patterns.
79
+ Choose the one that best fits your project needs:
80
+
81
+ ```ts
82
+ import { defineConfig } from 'eslint/config';
83
+ import { createStructureLint } from '@kekkai/structure-lint';
84
+
85
+ export default defineConfig([
86
+ // Options 1. Use structure.config.json
87
+ ...structure.defineConfig(),
88
+
89
+ // Options 2. Define rules directly in eslint.config.ts
90
+ ...structure.defineConfig({
91
+ appAlias: '~app',
92
+ lintFiles: 'src/{folder}/**/*.{ts,tsx}',
93
+ dependencyFlow: [
94
+ ['pages', 'layouts'],
95
+ ['layouts', 'containers'],
96
+ ['containers', 'components'],
97
+ ['components', 'hooks'],
98
+ ],
99
+ }),
100
+ ]);
101
+ ```
102
+
103
+ - **CLI**
104
+ `@kekkai/structure-lint` provides a standalone CLI to **sync Dependency Flow rules into a specified Markdown file**.
105
+
106
+ ```bash
107
+ npx structure-lint
108
+ ```
109
+
110
+ > 💡 The CLI reads **only** `structure.config.json`.
111
+
112
+ ## 🧩 structure.config.json
113
+
114
+ `structure.config.json` is used to define folder dependency rules and documentation sync settings for the project.
115
+ This file is read by both:
116
+
117
+ - ESLint configuration (createStructureLint())
118
+ - CLI (npx structure-lint)
119
+
120
+ ### 🔧 Configuration Options
121
+
122
+ ```ts
123
+ {
124
+ /**
125
+ * appAlias
126
+ * type (required):
127
+ * - string
128
+ *
129
+ * The module alias used in the project to enforce a unified import path
130
+ * for cross-folder dependencies.
131
+ * - All cross-folder imports MUST use this alias
132
+ * - Replaces unsafe and hard-to-track `../` relative imports
133
+ */
134
+ "appAlias": "~app",
135
+
136
+ /**
137
+ * lintFiles
138
+ * type (required):
139
+ * - string | string[]
140
+ *
141
+ * Specifies the file paths where ESLint structure rules should be applied.
142
+ * - Must include the `{folder}` placeholder
143
+ * - `{folder}` will be replaced at runtime with each folder name
144
+ * defined in `dependencyFlow`
145
+ */
146
+ "lintFiles": "src/{folder}/**/*.{ts,tsx}",
147
+
148
+ /**
149
+ * dependencyFlow
150
+ * type (required):
151
+ * - [
152
+ * string, // from folder
153
+ * string, // to folder
154
+ * {
155
+ * description?: string; // Used only for documentation output
156
+ * // (Mermaid flowchart annotation),
157
+ * // does NOT affect ESLint rules
158
+ * selfOnly?: boolean; // When true, the from folder may only
159
+ * // directly depend on the to folder,
160
+ * // with no further downstream extension
161
+ * }?
162
+ * ][]
163
+ *
164
+ * Defines one-way dependency relationships between folders.
165
+ */
166
+ "dependencyFlow": [
167
+ ["pages", "containers"],
168
+ ["containers", "contexts", { description: "Only Provider" }],
169
+ ["containers", "components"],
170
+ ["components", "hooks"],
171
+ ["hooks", "contexts", { description: "Only Context", selfOnly: true }]
172
+ ],
173
+
174
+ /**
175
+ * docs
176
+ * type (optional)
177
+ * - {
178
+ * file: string; // Target Markdown file path
179
+ * markerTag: string; // Identifier used to locate the auto-generated block
180
+ * content?: string; // Custom documentation content
181
+ * }
182
+ *
183
+ * Configures the CLI to sync `dependencyFlow` into a Markdown file.
184
+ */
185
+ "docs": {
186
+ "file": "README.md",
187
+ "markerTag": "DEPENDENCY_RULE"
188
+ },
189
+
190
+ /**
191
+ * overrideRules
192
+ * type (optional)
193
+ * - {
194
+ * [key: string]: // Key must be a folder name defined in dependencyFlow
195
+ * EslintRulesConfig; // ESLint rules configuration object
196
+ * }
197
+ *
198
+ * Overrides or extends ESLint rules for specific folders.
199
+ */
200
+ "overrideRules": {
201
+ "contexts": {
202
+ "react-refresh/only-export-components": "off"
203
+ }
204
+ },
205
+
206
+ /**
207
+ * packageImportRules
208
+ * type (optional)
209
+ * - {
210
+ * name: string; // npm package name
211
+ * importNames?: string[]; // Restrict specific named imports only.
212
+ * // If omitted, the entire package is restricted
213
+ * allowedInFolders: F[]; // Folders where this import is allowed
214
+ * }[]
215
+ *
216
+ * Restricts specific packages or named imports to designated folders only.
217
+ */
218
+ "packageImportRules": [
219
+ {
220
+ "name": "react",
221
+ "importNames": ["createContext"],
222
+ "allowedInFolders": ["contexts"]
223
+ },
224
+ {
225
+ "name": "react",
226
+ "importNames": ["useContext"],
227
+ "allowedInFolders": ["hooks"]
228
+ }
229
+ ]
230
+ }
231
+ ```
232
+
233
+ The `dependencyFlow` configuration above produces the following Mermaid flowchart:
234
+
235
+ ```mermaid
236
+ flowchart TD
237
+ pages --> containers
238
+ containers --> | Only Provider | contexts
239
+ containers --> components
240
+ components --> hooks
241
+ hooks --> | Only Context | contexts
242
+ ```
243
+
244
+ Based on the `markerTag` configuration, the CLI will locate and overwrite the following block in the target Markdown file:
245
+
246
+ ```md
247
+ <!-- DEPENDENCY_RULE:START -->
248
+ <!-- DEPENDENCY_RULE:END -->
249
+ ```
250
+
251
+ ## 🧠 Philosophy
252
+
253
+ `@kekkai/structure-lint` treats folder structure as architecture.
254
+
255
+ You define the dependency rules.
256
+ ESLint enforces them.
257
+ Documentation stays in sync.
258
+
259
+ Designed for teams that care about **clarity**, **maintainability**,
260
+ and **long-term structural consistency**.
@@ -0,0 +1,249 @@
1
+ [English](https://github.com/taco3064/kekkai-structure-lint/blob/main/README.md) | **繁體中文**
2
+
3
+ # 📦 @kekkai/structure-lint
4
+
5
+ 一個 **config-driven** 的 ESLint 結構規則產生器,用來強制專案遵守 **單向資料夾依賴(One-way Dependency Flow)**,並提供獨立 CLI 來同步與驗證專案文件中的依賴規則說明。
6
+
7
+ ## 🔍 What Problem Does This Solve?
8
+
9
+ 在中大型前端專案中,資料夾結構往往隨著功能成長而逐漸失控:
10
+
11
+ - 模組之間可以隨意互相 import
12
+ - 相對路徑(../)跨層引用,難以追蹤實際依賴關係
13
+ - 架構規則只存在於文件,無法被工具驗證
14
+ - 文件與實際程式碼逐漸脫節
15
+
16
+ @kekkai/structure-lint 的目的不是「規範寫法」,而是 **把資料夾結構與依賴方向,轉換成可被 ESLint 強制執行的規則**。
17
+
18
+ > ⚠️ **ESLint v9+ Required**
19
+ >
20
+ > 本套件基於 **ESLint Flat Config** 設計,
21
+ > 僅支援 **ESLint v9 以上版本**。
22
+ >
23
+ > 如果你的專案仍使用 `.eslintrc`(legacy config),
24
+ > 請先升級至 Flat Config 架構後再使用本套件。
25
+
26
+ ## ✨ Core Ideas
27
+
28
+ 這個套件建立在三個核心概念之上:
29
+
30
+ 1. **Folder-as-a-Module**
31
+ - 每個子資料夾視為一個獨立模組(例如 `hooks/useShuffleCards`、`components/Card`)
32
+ - 建議使用 `index.ts` 作為模組入口,讓引用點一致且可讀
33
+
34
+ 2. **One-way Dependency Flow**
35
+ - 依賴關係以「資料夾層級」定義:每個資料夾只能依賴允許的下游資料夾
36
+ - 不允許逆向依賴,也不允許跨層跳轉
37
+
38
+ 3. **Enforceable Imports (Alias-only Cross-folder Imports)**
39
+ - 模組內部允許相對路徑(`./`)
40
+ - 跨資料夾引用一律使用專案 alias(例如 `~app/...`),避免 `../` 形成不可控的捷徑依賴
41
+
42
+ > 🗂️ **Example Folder Structure:**
43
+ >
44
+ > ```text
45
+ > src/
46
+ > ├─ components/
47
+ > │ └─ Card/
48
+ > │ ├─ Component.tsx
49
+ > │ ├─ index.ts
50
+ > │ └─ types.ts
51
+ > ├─ containers/
52
+ > │ └─ DeckDrawStage/
53
+ > │ ├─ Container.tsx
54
+ > │ ├─ index.ts
55
+ > │ └─ types.ts
56
+ > └─ main.tsx
57
+ > ```
58
+
59
+ ## 📥 Installation
60
+
61
+ ```bash
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
+ ```
68
+
69
+ 本套件同時提供:
70
+
71
+ - ESLint Flat Config 的設定產生器
72
+ - 一個獨立的 CLI,用於文件同步與驗證
73
+
74
+ ## 🚀 Quick Start
75
+
76
+ - **eslint.config.(js|ts)**
77
+ `@kekkai/structure-lint` 支援兩種使用方式,請依專案需求選擇最合適的模式:
78
+
79
+ ```ts
80
+ import { defineConfig } from 'eslint/config';
81
+ import { createStructureLint } from '@kekkai/structure-lint';
82
+
83
+ export default defineConfig([
84
+ // Options 1. 使用 structure.config.json
85
+ ...createStructureLint(),
86
+
87
+ // Options 2. 直接在 eslint.config.ts 中定義規則
88
+ ...createStructureLint({
89
+ appAlias: '~app',
90
+ lintFiles: 'src/{folder}/**/*.{ts,tsx}',
91
+ dependencyFlow: [
92
+ ['pages', 'layouts'],
93
+ ['layouts', 'containers'],
94
+ ['containers', 'components'],
95
+ ['components', 'hooks'],
96
+ ],
97
+ }),
98
+ ]);
99
+ ```
100
+
101
+ - **CLI**
102
+ `@kekkai/structure-lint` 提供獨立的 CLI,用來將 Dependency Flow 規則同步到指定的 Markdown 文件 中。
103
+ ```bash
104
+ npx structure-lint
105
+ ```
106
+ > 💡 CLI 僅會讀取 structure.config.json。
107
+
108
+ ## 🧩 structure.config.json
109
+
110
+ structure.config.json 用來定義專案的 資料夾依賴規則 與 文件同步設定。此檔案會同時被:
111
+
112
+ - ESLint 設定 (createStructureLint())
113
+ - CLI (npx structure-lint)
114
+
115
+ 所讀取。
116
+
117
+ ### 🔧 Configuration Options
118
+
119
+ ```ts
120
+ {
121
+ /**
122
+ * appAlias
123
+ * type (required):
124
+ * - string
125
+ *
126
+ * 專案使用的模組 alias,用於強制跨資料夾 import 時的統一路徑格式。
127
+ * - 所有跨 folder 的 import 必須 使用此 alias
128
+ * - 用來取代不安全、不可控的 `../` 相對路徑
129
+ */
130
+ "appAlias": "~app",
131
+
132
+ /**
133
+ * lintFiles
134
+ * type (required):
135
+ * - string | string[]
136
+ *
137
+ * 指定 ESLint 要套用結構規則的檔案路徑。
138
+ * - 必須包含 `{folder}` 佔位符
139
+ * - `{folder}` 會在執行時自動替換為 dependencyFlow 中的每個資料夾名稱
140
+ */
141
+ "lintFiles": "src/{folder}/**/*.{ts,tsx}",
142
+
143
+ /**
144
+ * dependencyFlow
145
+ * type (required):
146
+ * - [
147
+ * string, // from folder
148
+ * string, // to folder
149
+ * {
150
+ * description?: string; // 僅用於文件輸出(Mermaid flowchart 註解),不影響 ESLint 規則
151
+ * selfOnly?: boolean; // true 表示 from folder 只能直接依賴 to folder,不可再往下延伸依賴鏈
152
+ * }?
153
+ * ][]
154
+ *
155
+ * 定義資料夾之間的 單向依賴關係。
156
+ */
157
+ "dependencyFlow": [
158
+ ["pages", "containers"],
159
+ ["containers", "contexts", { description: "Only Provider" }],
160
+ ["containers", "components"],
161
+ ["components", "hooks"],
162
+ ["hooks", "contexts", { description: "Only Context", selfOnly: true }],
163
+ ],
164
+
165
+ /**
166
+ * docs
167
+ * type (optional)
168
+ * - {
169
+ * file: string; // 要寫入的 Markdown 檔案路徑
170
+ * markerTag: string; // 用來標記自動產生區塊的識別字
171
+ * content?: string; // 自定義文件說明內容
172
+ * }
173
+ *
174
+ * 設定 CLI 用來 同步 dependencyFlow 至 Markdown 文件。
175
+ */
176
+ "docs": {
177
+ "file": "README.md",
178
+ "markerTag": "DEPENDENCY_RULE"
179
+ },
180
+
181
+ /**
182
+ * overrideRules
183
+ * type (optional)
184
+ * - {
185
+ * [key: string]: // key 必須是 dependencyFlow 中出現過的資料夾名稱
186
+ * EslintRulesConfig; // value 為 ESLint rules 設定物件
187
+ * }
188
+ *
189
+ * 為特定資料夾覆寫或補充 ESLint 規則。
190
+ */
191
+ "overrideRules": {
192
+ "contexts": {
193
+ "react-refresh/only-export-components": "off
194
+ }
195
+ },
196
+
197
+ /**
198
+ * packageImportRules
199
+ * type (optional)
200
+ * - {
201
+ * name: string; // npm 套件名稱
202
+ * importNames?: string[]; // 僅限制指定的 named imports。若省略,則限制整個套件
203
+ * allowedInFolders: F[]; // 允許使用該套件的資料夾清單
204
+ * }[]
205
+ *
206
+ * 限制 特定套件或特定 import 成員 只能在指定資料夾中使用。
207
+ */
208
+ "packageImportRules": [
209
+ {
210
+ "name": "react",
211
+ "importNames": ["createContext"],
212
+ "allowedInFolders": ["contexts"]
213
+ },
214
+ {
215
+ "name": "react",
216
+ "importNames": ["useContext"],
217
+ "allowedInFolders": ["hooks"]
218
+ }
219
+ ]
220
+ }
221
+ ```
222
+
223
+ `dependencyFlow` 設定對應的 mermaid flowchart 如下:
224
+
225
+ ```mermaid
226
+ flowchart TD
227
+ pages --> containers
228
+ containers --> | Only Provider | contexts
229
+ containers --> components
230
+ components --> hooks
231
+ hooks --> | Only Context | contexts
232
+ ```
233
+
234
+ 以範例中 `markerTag` 的設定,CLI 會尋找以下區塊並覆寫內容:
235
+
236
+ ```md
237
+ <!-- DEPENDENCY_RULE:START -->
238
+ <!-- DEPENDENCY_RULE:END -->
239
+ ```
240
+
241
+ ## 🧠 Philosophy
242
+
243
+ `@kekkai/structure-lint` 將資料夾結構視為架構本身。
244
+
245
+ 由你定義依賴規則,
246
+ 由 ESLint 強制執行,
247
+ 並確保文件與實際架構保持同步。
248
+
249
+ 適合重視**可讀性、可維護性**與**長期結構一致性**的團隊。
package/dist/bin.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import ora from "ora";
3
+ import path from "node:path";
4
+ import cacache from "cacache";
5
+ import fs from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { execSync } from "node:child_process";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ //#region src/bin/utils.ts
11
+ const CACHE_PATH = path.resolve(process.cwd(), "node_modules/.cache/kekkai");
12
+ const CACHE_KEY = "dependency-flowchart-hash";
13
+ const DEFAULT_CONTENT = `
14
+ This project follows a **One-way Dependency Flow** principle:
15
+
16
+ - Each folder may only import modules that lie downstream along the arrow direction
17
+ - Upstream or reverse imports are not allowed
18
+
19
+ > This rule is also enforced via **ESLint**.
20
+ `;
21
+ function isCliEntry(argv1) {
22
+ return argv1 && import.meta.url === pathToFileURL(argv1).href;
23
+ }
24
+ async function generateDocs({ dependencyFlow, docs, prettier }) {
25
+ const marker = getMarker(docs.markerTag);
26
+ if (isDocsValid(docs, marker.regex)) {
27
+ const { content = DEFAULT_CONTENT } = docs;
28
+ const docsFile = fs.readFileSync(path.resolve(process.cwd(), docs.file), "utf-8");
29
+ fs.writeFileSync(path.resolve(process.cwd(), docs.file), [
30
+ docsFile.slice(0, docsFile.indexOf(marker.tag[0]) + marker.tag[0].length),
31
+ "```mermaid",
32
+ "flowchart TD",
33
+ ...dependencyFlow.map(([from, to, options]) => ` ${from} ${!options?.description ? "" : `-- ${options.description} `}--> ${to}`),
34
+ "```",
35
+ content,
36
+ docsFile.slice(docsFile.indexOf(marker.tag[1]))
37
+ ].join("\n"));
38
+ if (fs.existsSync(prettier)) try {
39
+ execSync(`"${prettier}" --write "${docs.file}"`, { stdio: "inherit" });
40
+ } catch {
41
+ console.warn("Prettier formatting failed for the documentation file.");
42
+ }
43
+ await makeCache(dependencyFlow);
44
+ }
45
+ }
46
+ async function makeCache(flowchart) {
47
+ const hash = createHash("sha256").update(JSON.stringify(flowchart.slice().sort(([f1, t1], [f2, t2]) => f1.localeCompare(f2) || t1.localeCompare(t2)))).digest("hex");
48
+ if (await cacache.get(CACHE_PATH, CACHE_KEY).then(({ data }) => data.toString("utf-8")).catch(() => null) === hash) return;
49
+ await cacache.put(CACHE_PATH, CACHE_KEY, hash);
50
+ }
51
+ function getMarker(markerTag) {
52
+ return {
53
+ tag: [`<!-- ${markerTag}:START -->`, `<!-- ${markerTag}:END -->`],
54
+ regex: [/* @__PURE__ */ new RegExp(`<!--\\s*${markerTag}:START\\s*-->`), /* @__PURE__ */ new RegExp(`<!--\\s*${markerTag}:END\\s*-->`)]
55
+ };
56
+ }
57
+ function isDocsValid({ file, markerTag }, [startRegex, endRegex]) {
58
+ let docs;
59
+ try {
60
+ docs = fs.readFileSync(path.resolve(process.cwd(), file), "utf-8");
61
+ } catch {
62
+ throw new Error(`The specified documentation file "${file}" does not exist.`);
63
+ }
64
+ if (!startRegex.test(docs)) throw new Error(`The start marker must be "<!-- ${markerTag}:START -->" in the documentation file.`);
65
+ else if (!endRegex.test(docs)) throw new Error(`The end marker must be "<!-- ${markerTag}:END -->" in the documentation file.`);
66
+ return true;
67
+ }
68
+
69
+ //#endregion
70
+ //#region src/lint/utils.ts
71
+ const LINT_FILES_REGEX = /\{\s*folder\s*\}/;
72
+ function extractAllFolders(dependencyFlow) {
73
+ return Array.from(new Set(dependencyFlow.flatMap(([from, to]) => [from, to])));
74
+ }
75
+ function loadStructureConfig(defaultConfigPath = "structure.config.json") {
76
+ const configPath = path.resolve(process.cwd(), defaultConfigPath);
77
+ if (!fs.existsSync(configPath)) throw new Error(`structure.config.json not found at ${configPath}`);
78
+ return isConfigValid(JSON.parse(fs.readFileSync(configPath, "utf-8")));
79
+ }
80
+ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRules, packageImportRules }) {
81
+ if (typeof appAlias !== "string" || !appAlias?.trim()) throw new Error("appAlias is required in structure.config.json");
82
+ if (!Array.isArray(dependencyFlow)) throw new Error("dependencyFlow must be an array in structure.config.json");
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");
84
+ else if (typeof item[0] !== "string" || typeof item[1] !== "string" || !item[0]?.trim() || !item[1]?.trim()) throw new Error("Each tuple in dependencyFlow must have non-empty string values for the first two elements in structure.config.json");
85
+ else if (item.length > 2 && typeof item[2] !== "object") throw new Error("The third element in the dependencyFlow tuple must be an object if provided in structure.config.json");
86
+ const folders = extractAllFolders(dependencyFlow);
87
+ if (docs) {
88
+ const { file, markerTag, content } = docs;
89
+ if (typeof file !== "string" || !file?.trim()) throw new Error("docs.file is required in structure.config.json if docs is provided");
90
+ else if (typeof markerTag !== "string" || !markerTag?.trim()) throw new Error("docs.markerTag is required in structure.config.json if docs is provided");
91
+ else if (content && typeof content !== "string") throw new Error("docs.content must be a string in structure.config.json");
92
+ }
93
+ if (overrideRules) {
94
+ const entries = Object.entries(overrideRules);
95
+ if (entries.some(([key]) => !folders.includes(key))) throw new Error("overrideRules contains invalid folder keys not present in dependencyFlow in structure.config.json");
96
+ else if (entries.some(([_, rules]) => rules && typeof rules !== "object")) throw new Error("overrideRules values must be objects in structure.config.json");
97
+ }
98
+ if (packageImportRules) {
99
+ if (!Array.isArray(packageImportRules)) throw new Error("packageImportRules must be an array in structure.config.json");
100
+ for (const rule of packageImportRules) {
101
+ const { name, importNames, allowedInFolders } = rule || {};
102
+ if (typeof name !== "string" || !name?.trim()) throw new Error("Each packageImportRule must have a non-empty name property in structure.config.json");
103
+ else if (Array.isArray(importNames) && importNames.length && importNames.some((name$1) => typeof name$1 !== "string" || !name$1?.trim())) throw new Error("importNames in packageImportRule must be an array of non-empty strings in structure.config.json");
104
+ else if (!Array.isArray(allowedInFolders) || allowedInFolders.length === 0) throw new Error("allowedInFolders in packageImportRule must be a non-empty array in structure.config.json");
105
+ 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
+ }
107
+ }
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
+ return {
113
+ appAlias,
114
+ dependencyFlow,
115
+ docs,
116
+ lintFiles: files,
117
+ overrideRules,
118
+ packageImportRules
119
+ };
120
+ }
121
+
122
+ //#endregion
123
+ //#region src/bin/main.ts
124
+ async function run() {
125
+ const spinner = ora("Generating Docs Content...").start();
126
+ let config;
127
+ try {
128
+ config = loadStructureConfig();
129
+ spinner.succeed("structure.config.json loaded successfully.");
130
+ } catch (e) {
131
+ spinner.fail(`Failed to load structure.config.json: ${e.message}`);
132
+ return 1;
133
+ }
134
+ if (!config.docs) {
135
+ spinner.info("No docs config found. Skip docs generation.");
136
+ return 0;
137
+ }
138
+ const { dependencyFlow, docs } = config;
139
+ try {
140
+ await generateDocs({
141
+ dependencyFlow,
142
+ docs,
143
+ prettier: path.resolve(process.cwd(), "node_modules/.bin/prettier")
144
+ });
145
+ spinner.succeed("Docs generated successfully.");
146
+ return 0;
147
+ } catch (e) {
148
+ spinner.fail(`Failed to generate docs: ${e.message}`);
149
+ return 1;
150
+ }
151
+ }
152
+ if (isCliEntry(process.argv[1])) run().then((code) => process.exit(code));
153
+
154
+ //#endregion
155
+ export { run };
@@ -0,0 +1,2 @@
1
+ export * from './main';
2
+ export type { DefineOptions, DependencyFlow } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ //#region src/lint/utils.ts
5
+ const LINT_FILES_REGEX = /\{\s*folder\s*\}/;
6
+ function extractAllFolders(dependencyFlow) {
7
+ return Array.from(new Set(dependencyFlow.flatMap(([from, to]) => [from, to])));
8
+ }
9
+ function getDisableFolderImports(config, folders, folder) {
10
+ const allowedFolders = (function getAllowedFolders(config$1, folder$1, root) {
11
+ return config$1.reduce((acc, [from, to, options]) => {
12
+ if (from === folder$1) {
13
+ if (!options?.selfOnly || root) acc.push(to, ...getAllowedFolders(config$1, to, false));
14
+ }
15
+ return acc;
16
+ }, []);
17
+ })(config, folder, true);
18
+ return folders.filter((f) => !allowedFolders.includes(f) && f !== folder);
19
+ }
20
+ function getLintFiles(folder, lintFiles) {
21
+ return (Array.isArray(lintFiles) ? lintFiles : [lintFiles]).map((file) => file.replace(LINT_FILES_REGEX, folder));
22
+ }
23
+ function loadStructureConfig(defaultConfigPath = "structure.config.json") {
24
+ const configPath = path.resolve(process.cwd(), defaultConfigPath);
25
+ if (!fs.existsSync(configPath)) throw new Error(`structure.config.json not found at ${configPath}`);
26
+ return isConfigValid(JSON.parse(fs.readFileSync(configPath, "utf-8")));
27
+ }
28
+ function isConfigValid({ appAlias, dependencyFlow, docs, lintFiles, overrideRules, packageImportRules }) {
29
+ if (typeof appAlias !== "string" || !appAlias?.trim()) throw new Error("appAlias is required in structure.config.json");
30
+ if (!Array.isArray(dependencyFlow)) throw new Error("dependencyFlow must be an array in structure.config.json");
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");
32
+ else if (typeof item[0] !== "string" || typeof item[1] !== "string" || !item[0]?.trim() || !item[1]?.trim()) throw new Error("Each tuple in dependencyFlow must have non-empty string values for the first two elements in structure.config.json");
33
+ else if (item.length > 2 && typeof item[2] !== "object") throw new Error("The third element in the dependencyFlow tuple must be an object if provided in structure.config.json");
34
+ const folders = extractAllFolders(dependencyFlow);
35
+ if (docs) {
36
+ const { file, markerTag, content } = docs;
37
+ if (typeof file !== "string" || !file?.trim()) throw new Error("docs.file is required in structure.config.json if docs is provided");
38
+ else if (typeof markerTag !== "string" || !markerTag?.trim()) throw new Error("docs.markerTag is required in structure.config.json if docs is provided");
39
+ else if (content && typeof content !== "string") throw new Error("docs.content must be a string in structure.config.json");
40
+ }
41
+ if (overrideRules) {
42
+ const entries = Object.entries(overrideRules);
43
+ if (entries.some(([key]) => !folders.includes(key))) throw new Error("overrideRules contains invalid folder keys not present in dependencyFlow in structure.config.json");
44
+ else if (entries.some(([_, rules]) => rules && typeof rules !== "object")) throw new Error("overrideRules values must be objects in structure.config.json");
45
+ }
46
+ if (packageImportRules) {
47
+ if (!Array.isArray(packageImportRules)) throw new Error("packageImportRules must be an array in structure.config.json");
48
+ for (const rule of packageImportRules) {
49
+ const { name, importNames, allowedInFolders } = rule || {};
50
+ if (typeof name !== "string" || !name?.trim()) throw new Error("Each packageImportRule must have a non-empty name property in structure.config.json");
51
+ else if (Array.isArray(importNames) && importNames.length && importNames.some((name$1) => typeof name$1 !== "string" || !name$1?.trim())) throw new Error("importNames in packageImportRule must be an array of non-empty strings in structure.config.json");
52
+ else if (!Array.isArray(allowedInFolders) || allowedInFolders.length === 0) throw new Error("allowedInFolders in packageImportRule must be a non-empty array in structure.config.json");
53
+ 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
+ }
55
+ }
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
+ return {
61
+ appAlias,
62
+ dependencyFlow,
63
+ docs,
64
+ lintFiles: files,
65
+ overrideRules,
66
+ packageImportRules
67
+ };
68
+ }
69
+
70
+ //#endregion
71
+ //#region src/lint/main.ts
72
+ function createStructureLint({ appAlias, dependencyFlow, lintFiles, overrideRules, packageImportRules } = loadStructureConfig()) {
73
+ const folders = extractAllFolders(dependencyFlow);
74
+ return folders.map((folder) => {
75
+ const disableFolderImports = getDisableFolderImports(dependencyFlow, folders, folder);
76
+ const disablePackageImports = packageImportRules?.filter(({ allowedInFolders }) => !allowedInFolders.includes(folder));
77
+ return {
78
+ files: getLintFiles(folder, lintFiles),
79
+ rules: {
80
+ ...overrideRules?.[folder],
81
+ "no-restricted-imports": ["error", {
82
+ patterns: [
83
+ {
84
+ group: ["../*/**"],
85
+ message: "\n🚫 Do not import from upper-level directories. Use the project alias (e.g. \"~app/*\") to follow the dependency flow."
86
+ },
87
+ {
88
+ group: [`${appAlias}/${folder}/**`],
89
+ message: "\n🚫 Do not import modules from the same layer. Extract shared logic into a lower-level folder if needed."
90
+ },
91
+ ...!disableFolderImports.length ? [] : [{
92
+ group: disableFolderImports.map((banFolder) => `${appAlias}/${banFolder}/**`),
93
+ message: "\n🚫 This import violates the folder dependency rule. Only import from allowed lower-level folders."
94
+ }]
95
+ ],
96
+ ...disablePackageImports?.length && { paths: disablePackageImports.map(({ name, importNames }) => ({
97
+ name,
98
+ importNames,
99
+ message: importNames?.length ? `\n🚫 Do not import ${importNames.join(", ")} from "${name}" in this layer.` : `\n🚫 Do not import "${name}" in this layer.`
100
+ })) }
101
+ }]
102
+ }
103
+ };
104
+ });
105
+ }
106
+
107
+ //#endregion
108
+ export { createStructureLint };
package/dist/main.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { ConfigWithExtendsArray } from '@eslint/config-helpers';
2
+ import type { DefineOptions } from './types';
3
+ export declare function createStructureLint<F extends string>({ appAlias, dependencyFlow, lintFiles, overrideRules, packageImportRules, }?: Omit<DefineOptions<F>, 'docs'>): ConfigWithExtendsArray;
@@ -0,0 +1,28 @@
1
+ import type { RulesConfig } from '@eslint/core';
2
+ interface PackageImportRule<F extends string> {
3
+ name: string;
4
+ importNames?: string[];
5
+ allowedInFolders: F[];
6
+ }
7
+ export interface DocsOptions {
8
+ file: string;
9
+ markerTag: string;
10
+ content?: string;
11
+ }
12
+ export type DependencyFlow<F extends string> = [
13
+ F,
14
+ F,
15
+ {
16
+ description?: string;
17
+ selfOnly?: boolean;
18
+ }?
19
+ ];
20
+ export interface DefineOptions<F extends string> {
21
+ appAlias: string;
22
+ dependencyFlow: DependencyFlow<F>[];
23
+ docs?: DocsOptions;
24
+ lintFiles: string | string[];
25
+ overrideRules?: Partial<Record<F, Partial<RulesConfig>>>;
26
+ packageImportRules?: PackageImportRule<F>[];
27
+ }
28
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { JsonValue } from 'type-fest';
2
+ import type { DefineOptions, DependencyFlow } from './types';
3
+ export declare function extractAllFolders<F extends string>(dependencyFlow: DependencyFlow<F>[]): F[];
4
+ export declare function getDisableFolderImports<F extends string>(config: DependencyFlow<F>[], folders: Readonly<F[]>, folder: F): F[];
5
+ export declare function getLintFiles<F extends string>(folder: F, lintFiles: string | string[]): string[];
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>;
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@kekkai/structure-lint",
3
+ "version": "0.0.6",
4
+ "license": "MIT",
5
+ "description": "Config-driven ESLint structure rule generator enforcing one-way folder dependency flow, with a separate CLI for docs sync/verification.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "bin": {
11
+ "structure-lint": "./dist/bin.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "eslint",
20
+ "eslint-plugin",
21
+ "eslint-config",
22
+ "eslint-flat-config",
23
+ "architecture",
24
+ "project-structure",
25
+ "folder-structure",
26
+ "dependency-flow",
27
+ "one-way-dependency",
28
+ "import-rules",
29
+ "no-restricted-imports",
30
+ "config-driven",
31
+ "code-quality",
32
+ "maintainability"
33
+ ],
34
+ "author": "tabacotaco",
35
+ "homepage": "https://github.com/taco3064/kekkai-structure-lint#readme",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/taco3064/kekkai-structure-lint.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/taco3064/kekkai-structure-lint/issues"
42
+ },
43
+ "scripts": {
44
+ "build": "rm -rf dist && rolldown -c rolldown.config.ts && tsc -b tsconfig.types.json --force",
45
+ "changeset": "changeset",
46
+ "commit": "cz",
47
+ "lint": "eslint .",
48
+ "pre-release": "node ./.changeset/pre-release.mjs",
49
+ "prepare": "husky",
50
+ "test": "vitest run --coverage",
51
+ "tsc": "tsc -p tsconfig.lib.json"
52
+ },
53
+ "dependencies": {
54
+ "cacache": "^20.0.3",
55
+ "ora": "^9.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@changesets/cli": "^2.29.8",
59
+ "@eslint/js": "^9.39.2",
60
+ "@types/cacache": "^20.0.0",
61
+ "@types/node": "^25.0.3",
62
+ "@vitest/coverage-v8": "^4.0.16",
63
+ "commitizen": "^4.3.1",
64
+ "eslint": "^9.39.2",
65
+ "eslint-plugin-import": "^2.32.0",
66
+ "globals": "^16.5.0",
67
+ "husky": "^9.1.7",
68
+ "lint-staged": "^16.2.7",
69
+ "prettier": "^3.7.4",
70
+ "rolldown": "^1.0.0-beta.57",
71
+ "type-fest": "^5.3.1",
72
+ "typescript": "^5.9.3",
73
+ "typescript-eslint": "^8.50.1",
74
+ "vitest": "^4.0.16"
75
+ },
76
+ "config": {
77
+ "commitizen": {
78
+ "path": "cz-conventional-changelog"
79
+ }
80
+ },
81
+ "lint-staged": {
82
+ "*.{js,ts}": [
83
+ "eslint --fix"
84
+ ]
85
+ }
86
+ }