@goplusvn/core 0.1.67 → 0.1.70

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/bin/goerp-guardrails.mjs +45 -0
  3. package/eslint/index.mjs +120 -0
  4. package/package.json +10 -3
  5. package/scripts/doctor.ts +99 -0
  6. package/src/guardrails/__tests__/guardrails.test.ts +430 -0
  7. package/src/guardrails/index.ts +57 -0
  8. package/src/guardrails/preset.ts +75 -0
  9. package/src/guardrails/primitives.ts +307 -0
  10. package/src/guardrails/rules/auth.ts +178 -0
  11. package/src/guardrails/rules/debt.ts +71 -0
  12. package/src/guardrails/rules/design.ts +95 -0
  13. package/src/guardrails/rules/layering.ts +160 -0
  14. package/src/guardrails/rules/one-door.ts +115 -0
  15. package/src/guardrails/rules/rbac.ts +282 -0
  16. package/src/guardrails/rules/safety.ts +86 -0
  17. package/src/guardrails/rules/structure.ts +136 -0
  18. package/src/guardrails/run.ts +130 -0
  19. package/src/guardrails/scanner.ts +144 -0
  20. package/src/guardrails/types.ts +181 -0
  21. package/src/print/print-styles.tsx +4 -1
  22. package/src/types/index.ts +1 -1
  23. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  24. package/src/ui/index.tsx +1 -0
  25. package/src/user/components/index.ts +1 -0
  26. package/src/user/components/user-toolbar.tsx +8 -2
  27. package/src/user/components/user-visuals.tsx +84 -0
  28. package/src/user/components/users-card-view.tsx +1 -26
  29. package/src/user/components/users-table.tsx +215 -0
  30. package/src/user/pages/users-client-page.tsx +84 -259
  31. package/templates/starter-app/AGENTS.md +39 -3
  32. package/templates/starter-app/eslint.config.mjs +85 -0
  33. package/templates/starter-app/package.json +19 -2
  34. package/templates/starter-app/prettier.config.mjs +54 -0
  35. package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
@@ -0,0 +1,307 @@
1
+ import { join } from "node:path";
2
+
3
+ import type { GuardrailContext, GuardrailRule } from "./types";
4
+
5
+ /** Bỏ cờ `g` để `.test()` không bị con trỏ `lastIndex` làm cho lúc đúng lúc sai. */
6
+ const stateless = (re: RegExp) =>
7
+ new RegExp(re.source, re.flags.replace(/[gy]/g, ""));
8
+
9
+ /** Nợ cũ của rule: app truyền vào, mặc định lấy `defaults` của chính rule. */
10
+ export function allowlistFor(
11
+ ctx: GuardrailContext,
12
+ ruleId: string,
13
+ defaults: string[] = [],
14
+ ): Set<string> {
15
+ return new Set(ctx.options.allowlists[ruleId] ?? defaults);
16
+ }
17
+
18
+ /**
19
+ * Một entry allowlist là FILE, hoặc THƯ MỤC nếu kết thúc bằng `/`.
20
+ *
21
+ * Có loại miễn trừ mà danh sách file không diễn đạt nổi: `_handlers/` của một
22
+ * dispatcher khai resource bằng BIẾN, nên mọi handler trong đó vi phạm vì đúng
23
+ * một lý do, và số file thì tăng mỗi lần thêm hành động. Liệt kê từng file ở đó
24
+ * chỉ đẻ ra một danh sách phải sửa cho xanh — mà danh sách sửa-cho-xanh thì
25
+ * không còn là ratchet. Miễn trừ theo thư mục nêu đúng một lý do, một lần.
26
+ */
27
+ export const allowlistCovers = (allowed: Set<string>, rel: string): boolean =>
28
+ allowed.has(rel) ||
29
+ [...allowed].some((entry) => entry.endsWith("/") && rel.startsWith(entry));
30
+
31
+ interface ForbidPatternSpec {
32
+ id: string;
33
+ title: string;
34
+ why: string;
35
+ fix: string;
36
+ /** Mẫu bị cấm, soi trên bản đã bỏ chú thích. */
37
+ pattern: RegExp;
38
+ /** Chỉ soi file thoả điều kiện này (đường dẫn tương đối `src`). */
39
+ include?: (rel: string) => boolean;
40
+ /**
41
+ * Điều kiện GHÉP trên nội dung, xét TRƯỚC `pattern` — ví dụ "chỉ soi file
42
+ * mang chỉ thị `use server`". Cùng dùng cho phép kiểm allowlist ôi, nên một
43
+ * entry hết là server action cũng bị coi là hết lý do tồn tại.
44
+ */
45
+ when?: (code: string, rel: string) => boolean;
46
+ /** Nợ cũ mặc định của app gốc; app khác đè bằng `options.allowlists[id]`. */
47
+ defaultAllowlist?: string[];
48
+ /** Rule chỉ chạy khi hàm này đúng — dùng để tự bỏ qua app không có thứ đó. */
49
+ applies?: (ctx: GuardrailContext) => boolean;
50
+ /** Đọc bản thô thay vì bản đã bỏ chú thích (hiếm — chỉ khi soi chính chú thích). */
51
+ raw?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Rule "không được xuất hiện mẫu X", kèm allowlist chỉ-được-rút-bớt.
56
+ *
57
+ * Đây là khuôn của phần lớn hàng rào: một mẫu code đã từng gây sự cố, cộng một
58
+ * danh sách file được miễn vì lý do chính đáng. Phần allowlist mới là chỗ dễ
59
+ * mục: người ta thêm tên vào cho test xanh rồi quên. Nên mỗi rule kiểu này tự
60
+ * sinh thêm phép kiểm "entry nào hết lý do thì phải gỡ".
61
+ */
62
+ export function forbidPattern(spec: ForbidPatternSpec): GuardrailRule {
63
+ const test = stateless(spec.pattern);
64
+ const source = (ctx: GuardrailContext, file: string) =>
65
+ spec.raw ? ctx.read(file) : ctx.readCode(file);
66
+
67
+ return {
68
+ id: spec.id,
69
+ title: spec.title,
70
+ why: spec.why,
71
+ fix: spec.fix,
72
+ run(ctx) {
73
+ if (spec.applies && !spec.applies(ctx)) return null;
74
+ const allowed = allowlistFor(ctx, spec.id, spec.defaultAllowlist);
75
+ const offenders: string[] = [];
76
+ for (const file of ctx.files) {
77
+ const rel = ctx.rel(file);
78
+ if (spec.include && !spec.include(rel)) continue;
79
+ if (allowlistCovers(allowed, rel)) continue;
80
+ const code = source(ctx, file);
81
+ if (spec.when && !spec.when(code, rel)) continue;
82
+ if (test.test(code)) offenders.push(rel);
83
+ }
84
+ return offenders;
85
+ },
86
+ staleAllowlist(ctx) {
87
+ if (spec.applies && !spec.applies(ctx)) return [];
88
+ const allowed = allowlistFor(ctx, spec.id, spec.defaultAllowlist);
89
+ const violates = (rel: string, file: string) => {
90
+ const code = source(ctx, file);
91
+ if (spec.when && !spec.when(code, rel)) return false;
92
+ return test.test(code);
93
+ };
94
+ return [...allowed].filter((entry) => {
95
+ // Entry thư mục hết hạn khi KHÔNG còn file nào dưới nó vi phạm.
96
+ if (entry.endsWith("/")) {
97
+ return !ctx.files.some(
98
+ (f) => ctx.rel(f).startsWith(entry) && violates(ctx.rel(f), f),
99
+ );
100
+ }
101
+ if (!ctx.existsInSrc(entry)) return true;
102
+ return !violates(entry, join(ctx.srcDir, entry));
103
+ });
104
+ },
105
+ };
106
+ }
107
+
108
+ interface SingleDoorSpec {
109
+ id: string;
110
+ title: string;
111
+ why: string;
112
+ fix: string;
113
+ /** Import bị coi là "đi cửa sau" nếu khớp mẫu này. */
114
+ pattern: RegExp;
115
+ /** Các file hạ tầng ĐƯỢC phép đi thẳng (tương đối `src`). */
116
+ doors: (ctx: GuardrailContext) => string[];
117
+ /**
118
+ * Cửa chính phải chứa mẫu này (thường là lời gọi `configureX`). Cửa duy nhất
119
+ * mà quên cấu hình singleton thì "một cửa" chỉ còn là hình thức: mọi lời gọi
120
+ * vẫn đúng đường mà chạy thật vẫn nổ.
121
+ */
122
+ mustConfigure?: RegExp;
123
+ /** Tên gọi để in ra khi thiếu cấu hình — mặc định lấy phần chữ đầu của mẫu. */
124
+ mustConfigureName?: string;
125
+ /**
126
+ * Chỉ đòi cấu hình khi app THẬT SỰ dùng phần cần cấu hình.
127
+ *
128
+ * Ví dụ: extension branch-guard (lớp 2) chạy được mà không cần
129
+ * `configureBranchScope`; chỉ lớp 1 (`scopedBranchWhere`) mới cần. Đòi vô
130
+ * điều kiện là bắt app nhỏ khai một thứ nó không dùng — và thước nào bắt người
131
+ * ta khai thừa thì sớm muộn cũng bị tắt cả cụm.
132
+ */
133
+ mustConfigureWhen?: (ctx: GuardrailContext) => boolean;
134
+ }
135
+
136
+ /**
137
+ * Rule "engine singleton chỉ được vào qua đúng một cửa".
138
+ *
139
+ * Bẫy có thật: engine kho tập tin / phạm vi chi nhánh của core là singleton cần
140
+ * `configureX()` một lần. Import thẳng từ core lấy được hàm nhưng KHÔNG kéo
141
+ * theo lời gọi cấu hình — code chạy tới lúc thật mới nổ, và với branch-scope
142
+ * thì "nổ" nghĩa là người chi nhánh này nhìn thấy dữ liệu chi nhánh khác.
143
+ */
144
+ export function singleDoorImport(spec: SingleDoorSpec): GuardrailRule {
145
+ const test = stateless(spec.pattern);
146
+ return {
147
+ id: spec.id,
148
+ title: spec.title,
149
+ why: spec.why,
150
+ fix: spec.fix,
151
+ run(ctx) {
152
+ const doors = spec.doors(ctx);
153
+ // Không app nào có cửa nào → app này chưa dùng engine đó.
154
+ if (!doors.some((d) => ctx.existsInSrc(d))) return null;
155
+
156
+ const offenders: string[] = [];
157
+ for (const file of ctx.files) {
158
+ const rel = ctx.rel(file);
159
+ if (doors.includes(rel)) continue;
160
+ if (test.test(ctx.readCode(file))) offenders.push(rel);
161
+ }
162
+
163
+ if (spec.mustConfigure && (spec.mustConfigureWhen?.(ctx) ?? true)) {
164
+ const main = doors.find((d) => ctx.existsInSrc(d));
165
+ if (
166
+ main &&
167
+ !stateless(spec.mustConfigure).test(ctx.readCodeRel(main))
168
+ ) {
169
+ const name =
170
+ spec.mustConfigureName ??
171
+ /[A-Za-z][A-Za-z0-9]*/.exec(spec.mustConfigure.source)?.[0];
172
+ offenders.push(`${main} — cửa duy nhất nhưng KHÔNG gọi ${name}()`);
173
+ }
174
+ }
175
+ return offenders;
176
+ },
177
+ };
178
+ }
179
+
180
+ interface CeilingSpec {
181
+ id: string;
182
+ title: string;
183
+ why: string;
184
+ fix: string;
185
+ /** Mẫu được ĐẾM (bắt buộc có cờ `g`). */
186
+ pattern: RegExp;
187
+ /** Trần mặc định theo thư mục cấp 1; app đè bằng `options.ceilings[id]`. */
188
+ defaultCeilings?: Record<string, number>;
189
+ }
190
+
191
+ /**
192
+ * Ratchet đếm theo thư mục: chỉ được giảm.
193
+ *
194
+ * Dùng cho nợ kỹ thuật đông người (`any`, literal mã trạng thái) — cấm tuyệt
195
+ * đối thì không ai migrate nổi, thả lỏng thì nợ lặng lẽ phình. Trần = hiện
196
+ * trạng. Có HAI phép kiểm, và phép thứ hai mới là phép giữ cho rule sống: trần
197
+ * dư = chỗ trống cho nợ mới lọt vào mà test vẫn xanh.
198
+ */
199
+ export function perDirectoryCeiling(spec: CeilingSpec): GuardrailRule[] {
200
+ const count = (ctx: GuardrailContext) => {
201
+ const counts: Record<string, number> = {};
202
+ for (const file of ctx.files) {
203
+ const top = ctx.rel(file).split("/")[0];
204
+ const re = new RegExp(spec.pattern.source, spec.pattern.flags);
205
+ counts[top] =
206
+ (counts[top] ?? 0) + (ctx.readCode(file).match(re) ?? []).length;
207
+ }
208
+ return counts;
209
+ };
210
+ const ceilings = (ctx: GuardrailContext) =>
211
+ ctx.options.ceilings[spec.id] ?? spec.defaultCeilings ?? {};
212
+
213
+ return [
214
+ {
215
+ id: spec.id,
216
+ title: spec.title,
217
+ why: spec.why,
218
+ fix: spec.fix,
219
+ run(ctx) {
220
+ const counts = count(ctx);
221
+ const caps = ceilings(ctx);
222
+ return Object.entries(counts)
223
+ .filter(([dir, n]) => n > (caps[dir] ?? 0))
224
+ .map(([dir, n]) => `${dir}: ${n} > trần ${caps[dir] ?? 0}`);
225
+ },
226
+ },
227
+ {
228
+ id: `${spec.id}-no-slack`,
229
+ title: `${spec.title} — trần không được dư`,
230
+ why:
231
+ "Trần dư là chỗ trống cho nợ mới lọt vào mà ratchet vẫn xanh: trả nợ " +
232
+ "xong mà không hạ trần thì lần sau thêm nợ ngang phần đã trả, không ai hay.",
233
+ fix: "Sửa trần xuống đúng số hiện tại trong CÙNG commit — đó là bản ghi nhìn thấy được của tiến độ.",
234
+ run(ctx) {
235
+ const counts = count(ctx);
236
+ return Object.entries(ceilings(ctx))
237
+ .filter(([dir, cap]) => (counts[dir] ?? 0) < cap)
238
+ .map(
239
+ ([dir, cap]) => `${dir}: trần ${cap} → thực tế ${counts[dir] ?? 0}`,
240
+ );
241
+ },
242
+ },
243
+ ];
244
+ }
245
+
246
+ interface FileContractSpec {
247
+ id: string;
248
+ title: string;
249
+ why: string;
250
+ fix: string;
251
+ /** Đường dẫn tương đối `src`. */
252
+ file: string;
253
+ /** File phải tồn tại. Bỏ qua rule nếu `false` và file vắng mặt. */
254
+ required?: boolean;
255
+ /** File phải chứa các mẫu này. */
256
+ mustMatch?: RegExp[];
257
+ /** File KHÔNG được chứa các mẫu này. */
258
+ mustNotMatch?: RegExp[];
259
+ }
260
+
261
+ /** Rule "một file hạ tầng phải giữ đúng hợp đồng của nó". */
262
+ export function fileContract(spec: FileContractSpec): GuardrailRule {
263
+ return {
264
+ id: spec.id,
265
+ title: spec.title,
266
+ why: spec.why,
267
+ fix: spec.fix,
268
+ run(ctx) {
269
+ if (!ctx.existsInSrc(spec.file)) {
270
+ return spec.required ? [`thiếu file ${spec.file}`] : null;
271
+ }
272
+ const code = ctx.readRel(spec.file);
273
+ const problems: string[] = [];
274
+ for (const re of spec.mustMatch ?? []) {
275
+ if (!stateless(re).test(code)) {
276
+ problems.push(`${spec.file} — thiếu \`${re.source}\``);
277
+ }
278
+ }
279
+ for (const re of spec.mustNotMatch ?? []) {
280
+ if (stateless(re).test(code)) {
281
+ problems.push(`${spec.file} — còn \`${re.source}\``);
282
+ }
283
+ }
284
+ return problems;
285
+ },
286
+ };
287
+ }
288
+
289
+ /** Rule "file/thư mục này phải KHÔNG tồn tại" (di sản đã dọn, cấm mọc lại). */
290
+ export function forbidFile(spec: {
291
+ id: string;
292
+ title: string;
293
+ why: string;
294
+ fix: string;
295
+ /** Đường dẫn tương đối gốc app. */
296
+ paths: string[];
297
+ }): GuardrailRule {
298
+ return {
299
+ id: spec.id,
300
+ title: spec.title,
301
+ why: spec.why,
302
+ fix: spec.fix,
303
+ run(ctx) {
304
+ return spec.paths.filter((p) => ctx.existsInRoot(p));
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,178 @@
1
+ import { fileContract, forbidPattern } from "../primitives";
2
+ import type { GuardrailContext, GuardrailRule } from "../types";
3
+
4
+ const isApiRoute = (rel: string) =>
5
+ rel.startsWith("app/api/") && rel.endsWith("/route.ts");
6
+
7
+ /** Handler đã có cổng: bọc apiHandler, factory core, hoặc tự lấy phiên. */
8
+ const GATED = /apiHandler[<(]|create\w*Handlers\(|getSession/;
9
+
10
+ /**
11
+ * Nơi cổng THẬT SỰ nằm, cho một file route.
12
+ *
13
+ * App đã module hoá thì `app/api/**\/route.ts` chỉ là stub `export * from
14
+ * "@/modules/<domain>/api/..."` — Next cần một file ở đó để mount URL, còn
15
+ * handler (và cổng của nó) sống trong module. Đọc mỗi stub rồi kết luận
16
+ * "không gác" là báo động giả cho toàn bộ app: đúng 212 route của vinhhoa.
17
+ */
18
+ /**
19
+ * URL của một file route: `app/api/x/[id]/route.ts` → `/api/x/[id]`.
20
+ * Route group `(...)` không xuất hiện trong URL nên bị bỏ.
21
+ */
22
+ const routeUrl = (rel: string) =>
23
+ "/" +
24
+ rel
25
+ .replace(/^app\//, "")
26
+ .replace(/\/route\.ts$/, "")
27
+ .split("/")
28
+ .filter((seg) => !/^\(.*\)$/.test(seg))
29
+ .join("/");
30
+
31
+ /**
32
+ * Tiền tố API tự-xác-thực, đọc thẳng từ `proxy.ts`.
33
+ *
34
+ * Không hard-code trong core: mỗi app có bề mặt công khai riêng (webhook, mini
35
+ * app, link theo token). Điều core ép là bề mặt đó phải được KHAI Ở MỘT CHỖ —
36
+ * chính chỗ cổng mặc-định-chặn đọc — chứ không phải suy ra từ việc route quên gác.
37
+ */
38
+ function publicPrefixes(ctx: GuardrailContext): string[] {
39
+ if (!ctx.existsInSrc("proxy.ts")) return [];
40
+ return [...ctx.readCodeRel("proxy.ts").matchAll(/["'](\/api\/[^"']*)["']/g)].map(
41
+ (m) => m[1].replace(/\/$/, ""),
42
+ );
43
+ }
44
+
45
+ function gateSources(ctx: GuardrailContext, code: string): string[] {
46
+ const targets = [...code.matchAll(/export \* from ["']@\/([^"']+)["']/g)].map(
47
+ (m) => m[1],
48
+ );
49
+ if (targets.length === 0) return [code];
50
+ return targets.map((spec) => {
51
+ const candidate = [`${spec}.ts`, `${spec}/index.ts`, `${spec}/route.ts`].find(
52
+ (p) => ctx.existsInSrc(p),
53
+ );
54
+ // Không lần ra đích thì xét chính stub — thà báo thừa còn hơn bỏ lọt.
55
+ return candidate ? ctx.readCodeRel(candidate) : code;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Cổng xác thực & phân quyền.
61
+ *
62
+ * Cả nhóm bảo vệ đúng một tài sản: **không bề mặt gọi được nào thoát khỏi
63
+ * cổng**. Ba bề mặt đó là proxy (middleware của Next 16), route API, và server
64
+ * action. Mỗi lỗ ở đây đều từng là lỗ hổng thật, không phải giả định.
65
+ */
66
+ export const authRules: GuardrailRule[] = [
67
+ {
68
+ id: "auth/proxy-default-deny",
69
+ title: "proxy.ts có cổng mặc-định-chặn cho bề mặt /api",
70
+ why:
71
+ "Trước khi có cổng mặc-định-chặn, mỗi route API tự lo phần xác thực của " +
72
+ "mình — chỉ cần một route quên là thủng, và không có gì báo.",
73
+ fix: "Dùng `createAuthProxy({ publicApiPrefixes })` (@goerp/core/auth/proxy-gate), hoặc tự dựng nhánh `pathname.startsWith('/api')` + `getSessionCookie` + danh sách trắng công khai.",
74
+ run(ctx) {
75
+ if (!ctx.existsInSrc("proxy.ts")) return ["thiếu src/proxy.ts — không có cổng nào cả"];
76
+ const code = ctx.readCodeRel("proxy.ts");
77
+ // Hai lối dựng đều hợp lệ: factory của core, hoặc bản tự dựng của app.
78
+ // Ràng cứng vào một lối là bắt app phải giống hệt vinhhoa về CÁCH VIẾT,
79
+ // trong khi thứ cần ràng là TÍNH CHẤT: /api chặn trước, công khai phải khai.
80
+ const viaCore = /createAuthProxy\s*\(/.test(code) && /publicApiPrefixes/.test(code);
81
+ const handRolled =
82
+ /getSessionCookie/.test(code) &&
83
+ /pathname\.startsWith\(["']\/api["']\)/.test(code) &&
84
+ /isPublicApiPath|PUBLIC_API_PREFIXES/.test(code);
85
+ return viaCore || handRolled
86
+ ? []
87
+ : ["proxy.ts — không thấy cổng /api mặc-định-chặn nào (createAuthProxy hoặc bản tự dựng)"];
88
+ },
89
+ },
90
+
91
+ {
92
+ id: "auth/proxy-matcher-covers-api",
93
+ title: "matcher của proxy không loại trừ /api (để cổng thật sự chạy)",
94
+ why:
95
+ "Cấu hình mặc định của Next loại /api khỏi matcher bằng `(?!api|...)`. " +
96
+ "Để nguyên thì cổng mặc-định-chặn nằm đó mà không bao giờ được gọi — " +
97
+ "đọc code thì tưởng đã an toàn.",
98
+ fix: "Bỏ `(?!api` khỏi `config.matcher` trong src/proxy.ts.",
99
+ run(ctx) {
100
+ if (!ctx.existsInSrc("proxy.ts")) return null;
101
+ const matcher =
102
+ ctx.readRel("proxy.ts").match(/matcher:\s*\[([\s\S]*?)\]/)?.[1] ?? "";
103
+ return /\(\?!api\b/.test(matcher)
104
+ ? ["proxy.ts — matcher còn loại trừ /api"]
105
+ : [];
106
+ },
107
+ },
108
+
109
+ {
110
+ id: "auth/no-stray-middleware",
111
+ title: "không có middleware.ts lạc (Next 16 dùng proxy.ts)",
112
+ why: "Có cả hai file thì build gãy, và người sửa dễ vá nhầm vào file không chạy.",
113
+ fix: "Xoá src/middleware.ts, giữ src/proxy.ts.",
114
+ run(ctx) {
115
+ return ctx.existsInSrc("middleware.ts") ? ["src/middleware.ts"] : [];
116
+ },
117
+ },
118
+
119
+ {
120
+ id: "auth/api-route-gated",
121
+ title: "route API hoặc đi qua cổng, hoặc được KHAI là công khai trong proxy.ts",
122
+ why:
123
+ "Route quên gác là lỗ hổng im lặng: nó trả dữ liệu đúng, test chức năng " +
124
+ "vẫn xanh, chỉ có điều ai gọi cũng được. Thước này không cấm route công " +
125
+ "khai — nó cấm route công khai KHÔNG AI BIẾT: muốn mở thì phải ghi tiền " +
126
+ "tố vào danh sách công khai của proxy.ts, nơi người review nhìn thấy.",
127
+ fix: "Bọc `apiHandler(fn, { resource, action })`, hoặc nếu route thật sự công khai thì thêm tiền tố vào danh sách công khai trong src/proxy.ts (kèm ghi rõ nó tự xác thực bằng gì).",
128
+ run(ctx) {
129
+ const routes = ctx.files.filter((f) => isApiRoute(ctx.rel(f)));
130
+ if (routes.length === 0) return null;
131
+ const publics = publicPrefixes(ctx);
132
+ const allowed = new Set(ctx.options.allowlists["auth/api-route-gated"] ?? []);
133
+ return routes
134
+ .map(ctx.rel)
135
+ .filter((rel) => {
136
+ if (allowed.has(rel)) return false;
137
+ const url = routeUrl(rel);
138
+ if (publics.some((p) => url === p || url.startsWith(`${p}/`))) return false;
139
+ // Chưa gác = KHÔNG nguồn cổng nào (stub trỏ tới) có dấu hiệu gác.
140
+ return !gateSources(ctx, ctx.readCodeRel(rel)).some((src) => GATED.test(src));
141
+ });
142
+ },
143
+ staleAllowlist(ctx) {
144
+ const publics = publicPrefixes(ctx);
145
+ return (ctx.options.allowlists["auth/api-route-gated"] ?? []).filter((rel) => {
146
+ if (!ctx.existsInSrc(rel)) return true;
147
+ const url = routeUrl(rel);
148
+ if (publics.some((p) => url === p || url.startsWith(`${p}/`))) return true;
149
+ return gateSources(ctx, ctx.readCodeRel(rel)).some((src) => GATED.test(src));
150
+ });
151
+ },
152
+ },
153
+
154
+ forbidPattern({
155
+ id: "auth/server-action-bare-session",
156
+ title: "file 'use server' không gọi getSession trần — dùng requirePermission",
157
+ why:
158
+ "Server action là API surface thật: mọi client đã đăng nhập đều gọi được, " +
159
+ "nhưng nó KHÔNG đi qua apiHandler. `getSession()` trần chỉ trả lời 'anh là " +
160
+ "ai', không trả lời 'anh được làm gì' — đúng kiểu lỗ print-actions từng hở.",
161
+ fix: "Gọi `requirePermission(resource, action)` ở đầu mỗi action (guard action của app).",
162
+ pattern: /\bgetSession\s*\(/,
163
+ when: (code) => /^\s*["']use server["']/.test(code),
164
+ }),
165
+
166
+ forbidPattern({
167
+ id: "auth/api-inline-check-permission",
168
+ title: "tầng API không checkPermission inline — khai quyền ở cổng",
169
+ why:
170
+ "Quyền khai rải trong thân handler thì không cách nào liệt kê tĩnh 'route " +
171
+ "này cần quyền gì', nên registry và thực tế trôi khỏi nhau; rbac-sync seed " +
172
+ "thiếu quyền mà không ai biết.",
173
+ fix: "Khai `{ resource, action }` ở `apiHandler(...)` — cổng khai báo, quét được.",
174
+ pattern: /\bcheckPermission\s*\(/,
175
+ include: (rel) =>
176
+ rel.startsWith("app/api/") || /^modules\/[a-z-]+\/api\//.test(rel),
177
+ }),
178
+ ];
@@ -0,0 +1,71 @@
1
+ import { perDirectoryCeiling } from "../primitives";
2
+ import type { GuardrailRule } from "../types";
3
+
4
+ /**
5
+ * Bắt cả ba lối viết `any`: chú thích kiểu (`: any`), ép kiểu (`as any`), và
6
+ * tham số generic (`Record<string, any>`, `Promise<any>`, `any[]` trong ngoặc
7
+ * nhọn). Bản chỉ bắt hai lối đầu từng bỏ sót hơn trăm điểm nợ, trong đó có
8
+ * nguyên một thư mục ai cũng tưởng đã sạch.
9
+ */
10
+ const ANY_PATTERN = /:\s*any\b|\bas any\b|<[^<>\n]*\bany\b[^<>\n]*>/g;
11
+
12
+ /**
13
+ * Ratchet nợ `any`.
14
+ *
15
+ * App mới bắt đầu ở trần rỗng — nghĩa là 0 ở mọi thư mục, và mọi `any` mới đều
16
+ * đỏ ngay. App đã có lịch sử thì khai trần bằng hiện trạng rồi hạ dần.
17
+ */
18
+ export const anyDebtRules: GuardrailRule[] = perDirectoryCeiling({
19
+ id: "debt/any",
20
+ title: "số `any` mỗi thư mục không vượt trần",
21
+ why:
22
+ "`any` tắt kiểm tra kiểu ở đúng chỗ nó cần nhất và lan theo giá trị trả về. " +
23
+ "Cấm tuyệt đối thì không migrate nổi app cũ, thả lỏng thì nợ phình lặng lẽ — " +
24
+ "nên khoá bằng trần, chỉ được giảm.",
25
+ fix: "Type cho đúng, hoặc dùng `unknown` + thu hẹp. Trả nợ chỗ nào thì hạ trần chỗ đó.",
26
+ pattern: ANY_PATTERN,
27
+ });
28
+
29
+ export interface StatusLiteralSpec {
30
+ /** Tên các cột trạng thái, ví dụ `["status", "paymentStatus"]`. */
31
+ fields: string[];
32
+ /** Từ vựng mã trạng thái, ví dụ `["pending", "paid", "cancelled"]`. */
33
+ values: string[];
34
+ /** Trần mặc định theo thư mục cấp 1. */
35
+ ceilings?: Record<string, number>;
36
+ }
37
+
38
+ /**
39
+ * Ratchet literal mã trạng thái — nhà máy, vì từ vựng là của từng app.
40
+ *
41
+ * Mã trạng thái là CODE: nó nằm trong guard nghiệp vụ (`paymentStatus !== "paid"`
42
+ * chặn một thao tác), không phải nhãn hiển thị. Rải chuỗi thô khắp nơi đẻ ra từ
43
+ * vựng ma — một app thật đã sống nhiều tháng với `misa_status === "exported"`
44
+ * trong khi cột đó chưa bao giờ có giá trị `exported`, và một bộ lọc khai
45
+ * `"partial"` nên luôn trả rỗng. Cả hai im lặng vì chuỗi thô thì compiler không
46
+ * có gì để đối chiếu.
47
+ *
48
+ * Thước bắt ba lối viết: so sánh (`=== "x"`), khai giá trị (`: "x"`), và gán
49
+ * (`status = "x"`). Vế gán bắt buộc có khoảng trắng hai bên `=` — prettier viết
50
+ * gán JS như vậy, còn prop JSX thì `status="pending"` dính liền, nhờ đó
51
+ * `<StatusIndicator status="pending" />` (trình bày thuần) không bị tính mà
52
+ * không cần allowlist theo file.
53
+ */
54
+ export function statusLiteralCeiling(spec: StatusLiteralSpec): GuardrailRule[] {
55
+ const pattern = new RegExp(
56
+ `\\b(?:${spec.fields.join("|")})(?:\\s*(?:===|!==|:)\\s*|\\s+=\\s+)"(?:${spec.values.join("|")})"`,
57
+ "g",
58
+ );
59
+ return perDirectoryCeiling({
60
+ id: "debt/status-literal",
61
+ title: "literal mã trạng thái mỗi thư mục không vượt trần",
62
+ why:
63
+ "Từ vựng ma: mã không tồn tại vẫn biên dịch, guard nghiệp vụ so sai thì " +
64
+ "không báo lỗi mà chỉ lặng lẽ trả rỗng hoặc cho qua.",
65
+ fix:
66
+ "Dùng hằng số từ nguồn trạng thái của app (`src/lib/status`). Nếu là state " +
67
+ "bước UI cục bộ thì đổi tên field thành `phase`/`step` — thước sẽ không đụng tới.",
68
+ pattern,
69
+ defaultCeilings: spec.ceilings,
70
+ });
71
+ }
@@ -0,0 +1,95 @@
1
+ import { forbidFile } from "../primitives";
2
+ import type { GuardrailRule } from "../types";
3
+
4
+ const BASE_DISPLAY = /^(flex|inline-flex|grid|inline-grid|block|inline-block|table)$/;
5
+ const BP_HIDDEN = /^(sm|md|lg|xl|2xl):hidden$/;
6
+
7
+ /**
8
+ * Hệ thiết kế.
9
+ *
10
+ * Hai rule đầu giữ cho token ở MỘT nguồn (core), rule thứ ba là một bẫy
11
+ * Tailwind v4 rất khó truy, rule cuối giữ bảng danh sách không phân mảnh trở lại.
12
+ */
13
+ export const designRules: GuardrailRule[] = [
14
+ forbidFile({
15
+ id: "design/no-tailwind-config",
16
+ title: "không có tailwind.config.ts — Tailwind v4 đọc @theme trong globals.css",
17
+ why:
18
+ "File config JS bị v4 bỏ qua, nên nó là code chết trông như đang có tác " +
19
+ "dụng: sửa token ở đó không đổi gì và người sửa mất khá lâu mới hiểu.",
20
+ fix: "Xoá tailwind.config.ts; khai token trong `@theme` của globals.css.",
21
+ paths: ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs"],
22
+ }),
23
+
24
+ {
25
+ id: "design/globals-imports-core-tokens",
26
+ title: "globals.css nạp tầng token chuẩn từ @goerp/core",
27
+ why:
28
+ "Hệ token (Radix → shadcn → semantic) sống ở `@goerp/core/styles/base.css` " +
29
+ "và được mọi app dùng chung. App tự khai lại tầng `@theme` là fork âm thầm: " +
30
+ "nâng cấp core không tới được app đó nữa.",
31
+ fix: "Giữ `@import \"@goerp/core/styles/base.css\"` trong src/app/globals.css.",
32
+ run(ctx) {
33
+ if (!ctx.existsInSrc("app/globals.css")) return null;
34
+ const css = ctx.readRel("app/globals.css");
35
+ return css.includes("@goerp/core/styles/base.css")
36
+ ? []
37
+ : ["app/globals.css — thiếu @import @goerp/core/styles/base.css"];
38
+ },
39
+ },
40
+
41
+ {
42
+ id: "design/no-fragile-flex-hidden",
43
+ title: "không dùng `flex {bp}:hidden` — dùng `hidden max-{bp}:flex`",
44
+ why:
45
+ "Tailwind v4 + Next chia CSS thành chunk: class `.flex` cơ sở có thể được " +
46
+ "emit ở chunk tải SAU rồi đè `.lg:hidden` (media query KHÔNG tăng độ ưu " +
47
+ "tiên → cái đứng sau thắng). Hậu quả: phần tử 'chỉ-mobile' vẫn hiện trên " +
48
+ "desktop → header/thanh công cụ NHÂN ĐÔI. Đã dính ở 4 màn cùng lúc.",
49
+ fix: "Đảo lại: cơ sở là `hidden`, hiện lại bằng `max-{bp}:flex` — không có `.flex` cơ sở để bị đè.",
50
+ run(ctx) {
51
+ const offenders: string[] = [];
52
+ for (const file of ctx.files) {
53
+ if (!file.endsWith(".tsx")) continue;
54
+ const code = ctx.readCode(file);
55
+ const classStrings = [
56
+ ...code.matchAll(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g),
57
+ ].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
58
+ for (const cls of classStrings) {
59
+ const tokens = cls.split(/\s+/).filter(Boolean);
60
+ if (
61
+ tokens.some((t) => BASE_DISPLAY.test(t)) &&
62
+ tokens.some((t) => BP_HIDDEN.test(t))
63
+ ) {
64
+ offenders.push(`${ctx.rel(file)} ["${cls.slice(0, 70)}"]`);
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ return offenders;
70
+ },
71
+ },
72
+
73
+ {
74
+ id: "design/list-table-uses-kit",
75
+ title: "bảng mới trong app/ dùng DataTableWrapper, không tự dựng <Table>",
76
+ why:
77
+ "Bảng tự dựng lại thiếu một trong: header dính, phân trang, trạng thái " +
78
+ "rỗng, hàng bấm được, footer tổng. Mỗi bảng thiếu một thứ khác nhau, và " +
79
+ "người dùng học lại thao tác ở mỗi trang.",
80
+ fix: "Dùng `DataTableWrapper` + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
81
+ run(ctx) {
82
+ const allowed = new Set(ctx.options.allowlists["design/list-table-uses-kit"] ?? []);
83
+ const handRolled = ctx.files
84
+ .map(ctx.rel)
85
+ .filter((rel) => rel.startsWith("app/") && /<TableBody/.test(ctx.readCodeRel(rel)));
86
+ return handRolled.filter((rel) => !allowed.has(rel));
87
+ },
88
+ staleAllowlist(ctx) {
89
+ const allowed = ctx.options.allowlists["design/list-table-uses-kit"] ?? [];
90
+ return allowed.filter(
91
+ (rel) => !ctx.existsInSrc(rel) || !/<TableBody/.test(ctx.readCodeRel(rel)),
92
+ );
93
+ },
94
+ },
95
+ ];