@goplusvn/core 0.1.66 → 0.1.69

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 (37) hide show
  1. package/CHANGELOG.md +116 -0
  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/auth/__tests__/permissions.test.ts +162 -0
  7. package/src/auth/index.ts +113 -34
  8. package/src/guardrails/__tests__/guardrails.test.ts +430 -0
  9. package/src/guardrails/index.ts +57 -0
  10. package/src/guardrails/preset.ts +75 -0
  11. package/src/guardrails/primitives.ts +307 -0
  12. package/src/guardrails/rules/auth.ts +178 -0
  13. package/src/guardrails/rules/debt.ts +71 -0
  14. package/src/guardrails/rules/design.ts +95 -0
  15. package/src/guardrails/rules/layering.ts +160 -0
  16. package/src/guardrails/rules/one-door.ts +115 -0
  17. package/src/guardrails/rules/rbac.ts +282 -0
  18. package/src/guardrails/rules/safety.ts +86 -0
  19. package/src/guardrails/rules/structure.ts +136 -0
  20. package/src/guardrails/run.ts +130 -0
  21. package/src/guardrails/scanner.ts +144 -0
  22. package/src/guardrails/types.ts +181 -0
  23. package/src/rbac/index.ts +6 -9
  24. package/src/types/index.ts +17 -0
  25. package/src/ui/data-display/shallow-pagination.tsx +189 -0
  26. package/src/ui/index.tsx +1 -0
  27. package/src/user/components/index.ts +1 -0
  28. package/src/user/components/user-toolbar.tsx +8 -2
  29. package/src/user/components/user-visuals.tsx +84 -0
  30. package/src/user/components/users-card-view.tsx +1 -26
  31. package/src/user/components/users-table.tsx +215 -0
  32. package/src/user/pages/users-client-page.tsx +84 -259
  33. package/templates/starter-app/AGENTS.md +39 -3
  34. package/templates/starter-app/eslint.config.mjs +85 -0
  35. package/templates/starter-app/package.json +19 -2
  36. package/templates/starter-app/prettier.config.mjs +54 -0
  37. package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
@@ -0,0 +1,86 @@
1
+ import { forbidPattern } from "../primitives";
2
+ import type { GuardrailRule } from "../types";
3
+
4
+ /**
5
+ * An toàn giao dịch & khoá.
6
+ *
7
+ * Hai rule đầu chặn kiểu lỗi làm TREO CẢ APP chứ không phải sai một màn hình:
8
+ * giữ kết nối DB trong lúc chờ mạng thì pool cạn, và khoá hàng bằng `FOR UPDATE`
9
+ * thì deadlock với advisory lock đánh số chứng từ. Rule thứ ba chặn kiểu chậm
10
+ * dần đều: truy vấn không chặn số dòng, chạy tốt hôm nay và chết theo lịch sử.
11
+ */
12
+ export const safetyRules: GuardrailRule[] = [
13
+ forbidPattern({
14
+ id: "safety/no-raw-for-update",
15
+ title: "không dùng `FOR UPDATE` thô — nối tiếp qua advisory lock",
16
+ why:
17
+ "Khoá hàng bằng FOR UPDATE cùng lúc với advisory lock đánh số chứng từ tạo " +
18
+ "hai thứ tự khoá khác nhau → deadlock, và deadlock ở đây biểu hiện thành " +
19
+ "'lưu đơn bị treo' chứ không thành lỗi rõ ràng.",
20
+ fix: "Dùng advisory lock của bộ đánh số chứng từ (@goerp/core/document-number).",
21
+ pattern: /\bFOR\s+UPDATE\b/i,
22
+ }),
23
+
24
+ {
25
+ id: "safety/no-network-in-transaction",
26
+ title: "không gọi mạng/tích hợp bên trong thân $transaction",
27
+ why:
28
+ "Chờ HTTP trong khi đang giữ một kết nối DB và các khoá của nó: một tích " +
29
+ "hợp chậm là đủ làm cạn pool và cả app đứng, chứ không chỉ tính năng đó lỗi.",
30
+ fix: "Gọi tích hợp TRƯỚC hoặc SAU transaction; trong transaction chỉ đọc/ghi DB.",
31
+ run(ctx) {
32
+ const danger = ctx.options.domain?.networkInTransaction ?? /\bfetch\(/;
33
+ const test = new RegExp(danger.source, danger.flags.replace(/[gy]/g, ""));
34
+ const offenders: string[] = [];
35
+ for (const file of ctx.files) {
36
+ const code = ctx.readCode(file);
37
+ if (!code.includes("$transaction(")) continue;
38
+ if (ctx.callBodies(code, "$transaction(").some((b) => test.test(b))) {
39
+ offenders.push(ctx.rel(file));
40
+ }
41
+ }
42
+ return offenders;
43
+ },
44
+ },
45
+
46
+ {
47
+ id: "safety/page-findmany-capped",
48
+ title: "findMany của bảng giao dịch trong page phải chặn số dòng bằng `take`",
49
+ why:
50
+ "Một Server Component nạp cả bảng giao dịch, JSON.stringify nó, rồi đổ vào " +
51
+ "`<Select>` sẽ render mọi hàng — chi phí lớn dần theo lịch sử, nên nó chạy " +
52
+ "tốt lúc viết và chậm dần tới mức không mở nổi trang sau vài tháng.",
53
+ fix: "Thêm `take` (và chèn riêng hàng đang được tham chiếu nếu nó có thể nằm ngoài giới hạn).",
54
+ run(ctx) {
55
+ const models = ctx.options.domain?.cappedFindManyModels;
56
+ if (!models || models.length === 0) return null;
57
+ const allowed = new Set(ctx.options.allowlists["safety/page-findmany-capped"] ?? []);
58
+ const offenders: string[] = [];
59
+ for (const file of ctx.files) {
60
+ const rel = ctx.rel(file);
61
+ if (!rel.startsWith("app/") || !rel.endsWith("page.tsx")) continue;
62
+ if (allowed.has(rel)) continue;
63
+ const code = ctx.readCode(file);
64
+ for (const model of models) {
65
+ for (const body of ctx.callBodies(code, `db.${model}.findMany(`)) {
66
+ if (!/\btake\s*:/.test(body)) {
67
+ offenders.push(`${rel} — db.${model}.findMany thiếu take`);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return offenders;
73
+ },
74
+ staleAllowlist(ctx) {
75
+ const models = ctx.options.domain?.cappedFindManyModels ?? [];
76
+ const allowed = ctx.options.allowlists["safety/page-findmany-capped"] ?? [];
77
+ return allowed.filter((rel) => {
78
+ if (!ctx.existsInSrc(rel)) return true;
79
+ const code = ctx.readCodeRel(rel);
80
+ return models.every((m) =>
81
+ ctx.callBodies(code, `db.${m}.findMany(`).every((b) => /\btake\s*:/.test(b)),
82
+ );
83
+ });
84
+ },
85
+ },
86
+ ];
@@ -0,0 +1,136 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+
4
+ import type { GuardrailContext, GuardrailRule } from "../types";
5
+
6
+ const appFiles = (ctx: GuardrailContext) =>
7
+ ctx.files.map(ctx.rel).filter((rel) => rel.startsWith("app/"));
8
+
9
+ /**
10
+ * Docker dùng glob riêng, không phải glob của shell. Mô phỏng lại để bắt được
11
+ * trường hợp `.dockerignore` nuốt mất mã nguồn thật.
12
+ */
13
+ function dockerGlobToRegex(pattern: string): RegExp {
14
+ const parts = pattern.split("/");
15
+ let re = "^";
16
+ parts.forEach((seg, i) => {
17
+ if (seg === "**") {
18
+ re += "(.*/)?";
19
+ } else {
20
+ re += seg
21
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
22
+ .replace(/\\\*/g, "[^/]*")
23
+ .replace(/\\\?/g, "[^/]");
24
+ if (i !== parts.length - 1) re += "/";
25
+ }
26
+ });
27
+ return new RegExp(re + "$");
28
+ }
29
+
30
+ /**
31
+ * Đặt tên & vị trí file.
32
+ *
33
+ * Nghe như chuyện thẩm mỹ, thực ra là chuyện đọc nhầm file. Một trang gồm ĐÚNG
34
+ * hai file cạnh nhau: `page.tsx` (server, lấy dữ liệu) và `<slug>-client-page.tsx`
35
+ * (client). Khi cùng một vai trò mang bốn cái tên khác nhau thì nhìn tên file
36
+ * không đoán được đâu là trang, đâu là mảnh giao diện — và mở nhầm là chuyện
37
+ * xảy ra hằng ngày.
38
+ */
39
+ export const structureRules: GuardrailRule[] = [
40
+ {
41
+ id: "structure/no-client-suffix",
42
+ title: "không còn `*-client.tsx` (dùng `*-client-page.tsx`)",
43
+ why: "Hai quy ước song song cho cùng một vai trò khiến tìm kiếm ra hai nhóm kết quả rời rạc.",
44
+ fix: "Đổi tên thành `<slug>-client-page.tsx`.",
45
+ run: (ctx) => appFiles(ctx).filter((rel) => /-client\.tsx$/.test(rel)),
46
+ },
47
+
48
+ {
49
+ id: "structure/page-suffix-reserved",
50
+ title: "chỉ `page.tsx` và `*-client-page.tsx` được mang đuôi `-page`",
51
+ why: "Đuôi `-page` là tín hiệu 'đây là một trang'. Dùng cho mảnh giao diện thì tín hiệu đó vô nghĩa.",
52
+ fix: "Đổi tên mảnh giao diện thành danh từ mô tả nó, đặt trong `_components/`.",
53
+ run: (ctx) =>
54
+ appFiles(ctx).filter(
55
+ (rel) => /-page\.tsx$/.test(rel) && !/-client-page\.tsx$/.test(rel),
56
+ ),
57
+ },
58
+
59
+ {
60
+ id: "structure/client-page-beside-page",
61
+ title: "`*-client-page.tsx` nằm CẠNH `page.tsx`, không nhét vào `_components/`",
62
+ why: "Cặp server/client của một trang tách hai chỗ thì đọc luồng dữ liệu phải nhảy thư mục.",
63
+ fix: "Chuyển file lên cùng cấp với `page.tsx`.",
64
+ run: (ctx) =>
65
+ appFiles(ctx).filter(
66
+ (rel) => /-client-page\.tsx$/.test(rel) && rel.includes("/_components/"),
67
+ ),
68
+ },
69
+
70
+ {
71
+ id: "structure/private-folders-underscored",
72
+ title: "thư mục phụ trong app/ phải gạch dưới (`_components`, không phải `components`)",
73
+ why: "Không gạch dưới thì Next coi đó là một route segment thật — sinh ra URL không ai định tạo.",
74
+ fix: "Đổi tên thành `_components`, `_hooks`, `_utils`…",
75
+ run: (ctx) =>
76
+ appFiles(ctx).filter((rel) =>
77
+ /\/(components|hooks|constants|utils|types)\//.test(rel),
78
+ ),
79
+ },
80
+
81
+ {
82
+ id: "structure/no-backup-files",
83
+ title: "không có .bak/.old/.orig/.copy trong src/",
84
+ why:
85
+ "Bản sao cũ của một file đang sống là bẫy đọc nhầm tệ nhất: nội dung na ná, " +
86
+ "tìm kiếm ra hai kết quả, không có gì cho biết cái nào mới. Một file .bak 46KB " +
87
+ "từng nằm trong repo nhiều ngày mà đợt quét code chết không thấy (nó chỉ duyệt .ts/.tsx).",
88
+ fix: "Xoá file backup — git giữ lịch sử rồi.",
89
+ run(ctx) {
90
+ const junk: string[] = [];
91
+ const walk = (dir: string) => {
92
+ for (const e of readdirSync(dir)) {
93
+ if (e === "node_modules" || e === ".next") continue;
94
+ const full = join(dir, e);
95
+ if (statSync(full).isDirectory()) walk(full);
96
+ else if (/\.(bak|old|orig|copy)$|\.tsx?\.(bak|old|orig)$/i.test(e)) {
97
+ junk.push(relative(ctx.srcDir, full).split("\\").join("/"));
98
+ }
99
+ }
100
+ };
101
+ if (existsSync(ctx.srcDir)) walk(ctx.srcDir);
102
+ return junk;
103
+ },
104
+ },
105
+
106
+ {
107
+ id: "structure/dockerignore-keeps-source",
108
+ title: ".dockerignore không loại nhầm mã nguồn app",
109
+ why:
110
+ "Đã dính: glob `**/check-*.ts` nhằm loại script dev nuốt luôn handler thật " +
111
+ "`modules/sales/api/.../check-resold.ts`. Build trên Docker 500 'Module not " +
112
+ "found', còn build local (không đọc .dockerignore) vẫn xanh — nên không cách " +
113
+ "nào phát hiện trước khi lên server.",
114
+ fix: "Thu hẹp glob của script dev về `**/scripts/`, đừng để `**/check-*.ts` trần.",
115
+ run(ctx) {
116
+ const path = join(ctx.root, ".dockerignore");
117
+ if (!existsSync(path)) return null;
118
+ const patterns = readFileSync(path, "utf8")
119
+ .split("\n")
120
+ .map((l) => l.trim())
121
+ .filter((l) => l && !l.startsWith("#") && !l.startsWith("!"))
122
+ .map(dockerGlobToRegex);
123
+
124
+ const srcName = relative(ctx.root, ctx.srcDir).split("\\").join("/") || "src";
125
+ const offenders: string[] = [];
126
+ for (const file of ctx.files) {
127
+ const rel = ctx.rel(file);
128
+ if (!rel.startsWith("app/") && !rel.startsWith("modules/")) continue;
129
+ // .dockerignore neo ở gốc build context.
130
+ const contextPath = `${srcName}/${rel}`;
131
+ if (patterns.some((re) => re.test(contextPath))) offenders.push(contextPath);
132
+ }
133
+ return offenders;
134
+ },
135
+ },
136
+ ];
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { coreGuardrails, skipReasonFor } from "./preset";
4
+ import { createContext } from "./scanner";
5
+ import type { GuardrailOptions, GuardrailRule } from "./types";
6
+
7
+ const GROUP_TITLES: Record<string, string> = {
8
+ auth: "cổng xác thực & phân quyền",
9
+ rbac: "toàn vẹn registry quyền",
10
+ layering: "ranh giới tầng",
11
+ "one-door": "một cửa",
12
+ structure: "đặt tên & vị trí file",
13
+ design: "hệ thiết kế",
14
+ safety: "an toàn giao dịch & hiệu năng",
15
+ debt: "nợ kỹ thuật (ratchet)",
16
+ };
17
+
18
+ /** Thông báo khi đỏ: nói SỰ CỐ trước, rồi mới nói phải làm gì. */
19
+ function failureMessage(rule: GuardrailRule, extra?: string): string {
20
+ return [
21
+ `[${rule.id}] ${rule.why}`,
22
+ `→ ${rule.fix}`,
23
+ extra,
24
+ "Đổi quy ước có chủ đích? Sửa allowlist/trần trong CÙNG commit, hoặc khai " +
25
+ "`skip: { \"" + rule.id + '": "lý do" }` — suất tắt phải nhìn thấy được trong diff.',
26
+ ]
27
+ .filter(Boolean)
28
+ .join("\n");
29
+ }
30
+
31
+ /**
32
+ * Nạp bộ hàng rào chuẩn vào file test của app.
33
+ *
34
+ * File `src/__tests__/architecture.test.ts` của app chỉ còn phần DỮ LIỆU riêng
35
+ * của nó (allowlist nợ cũ, trần, rule domain). Luật thì đi theo `@goerp/core`,
36
+ * nên nâng core là nhận luật mới — thay vì mỗi app giữ một bản chép tay đóng
37
+ * băng ở ngày nó được scaffold.
38
+ *
39
+ * ```ts
40
+ * runCoreGuardrails({
41
+ * permissionRegistry,
42
+ * navigations,
43
+ * allowlists: { "design/list-table-uses-kit": [...] },
44
+ * ceilings: { "debt/any": { app: 671 } },
45
+ * skip: { "layering/domain-framework-free": "nợ 49 page SSR, xem docs/..." },
46
+ * })
47
+ * ```
48
+ */
49
+ export function runCoreGuardrails(options: GuardrailOptions = {}): void {
50
+ const ctx = createContext(options);
51
+ const rules = coreGuardrails(options);
52
+ const skip = options.skip ?? {};
53
+
54
+ const byGroup = new Map<string, GuardrailRule[]>();
55
+ for (const rule of rules) {
56
+ const group = rule.id.split("/")[0];
57
+ byGroup.set(group, [...(byGroup.get(group) ?? []), rule]);
58
+ }
59
+
60
+ describe("hàng rào kiến trúc (@goerp/core/guardrails)", () => {
61
+ it("bộ rule lành mạnh: có rule để chạy, id không trùng", () => {
62
+ expect(rules.length).toBeGreaterThan(0);
63
+ const seen = new Set<string>();
64
+ const dup = rules.map((r) => r.id).filter((id) => !seen.has(id) && !seen.add(id));
65
+ expect(dup, "Hai rule cùng id thì cái sau che cái trước khi khai `skip`.").toEqual(
66
+ [],
67
+ );
68
+ });
69
+
70
+ it("mọi suất tắt (`skip`) đều trỏ tới rule có thật và vẫn còn cần thiết", () => {
71
+ const ids = new Set(rules.map((r) => r.id));
72
+ const stale: string[] = [];
73
+ for (const key of Object.keys(skip)) {
74
+ if (key.endsWith("/*")) {
75
+ const group = key.slice(0, -2);
76
+ if (![...ids].some((id) => id.startsWith(`${group}/`))) {
77
+ stale.push(`${key} — không có rule nào thuộc nhóm này`);
78
+ }
79
+ continue;
80
+ }
81
+ if (!ids.has(key)) {
82
+ stale.push(`${key} — không có rule nào mang id này (đổi tên? gõ nhầm?)`);
83
+ continue;
84
+ }
85
+ const rule = rules.find((r) => r.id === key)!;
86
+ const violations = rule.run(ctx);
87
+ if (violations !== null && violations.length === 0) {
88
+ stale.push(`${key} — rule này giờ đã XANH, gỡ khỏi \`skip\``);
89
+ }
90
+ }
91
+ expect(
92
+ stale,
93
+ "Suất tắt bỏ ngỏ là cách hàng rào mục dần: nợ trả rồi mà `skip` còn đó " +
94
+ "thì lần sau có người lặng lẽ dùng lại nó.",
95
+ ).toEqual([]);
96
+ });
97
+ });
98
+
99
+ for (const [group, groupRules] of byGroup) {
100
+ describe(`${group} — ${GROUP_TITLES[group] ?? group}`, () => {
101
+ for (const rule of groupRules) {
102
+ const reason = skipReasonFor(rule.id, skip);
103
+ if (reason) {
104
+ it.skip(`${rule.title} [tắt: ${reason}]`, () => {});
105
+ continue;
106
+ }
107
+
108
+ it(rule.title, () => {
109
+ const violations = rule.run(ctx);
110
+ if (violations === null) return; // không áp dụng cho app này
111
+ expect(violations, failureMessage(rule)).toEqual([]);
112
+ });
113
+
114
+ if (rule.staleAllowlist) {
115
+ it(`${rule.title} — allowlist chỉ được rút bớt`, () => {
116
+ const stale = rule.staleAllowlist!(ctx);
117
+ expect(
118
+ stale,
119
+ failureMessage(
120
+ rule,
121
+ "Các mục trên hết lý do tồn tại (file đã xoá hoặc đã hết vi phạm). " +
122
+ "Mỗi tên thừa là một suất miễn trừ bỏ ngỏ.",
123
+ ),
124
+ ).toEqual([]);
125
+ });
126
+ }
127
+ }
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+
4
+ import type {
5
+ GuardrailContext,
6
+ GuardrailOptions,
7
+ ResolvedGuardrailOptions,
8
+ } from "./types";
9
+
10
+ const DEFAULT_IGNORE = [
11
+ "node_modules",
12
+ ".next",
13
+ ".turbo",
14
+ "__tests__",
15
+ "dist",
16
+ "coverage",
17
+ ];
18
+
19
+ /**
20
+ * Bỏ chú thích trước khi quét.
21
+ *
22
+ * Code trong hệ này viết tiếng Việt rất nhiều ở phần chú thích, và chú thích
23
+ * thường NHẮC LẠI đúng cái mẫu mà rule đang cấm ("đừng dùng window.open"). Quét
24
+ * trên bản thô là guardrail tự bắt chính lời cảnh báo của mình.
25
+ */
26
+ export function stripComments(src: string): string {
27
+ return (
28
+ src
29
+ .replace(/\/\*[\s\S]*?\*\//g, "")
30
+ // Giữ `http://` — nếu không thì mọi URL trong chuỗi bị cắt cụt.
31
+ .replace(/(^|[^:])\/\/.*$/gm, "$1")
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Cắt thân đối số của từng lời gọi `<needle>` bằng cách đếm ngoặc cân bằng.
37
+ *
38
+ * Dùng cho các rule kiểu "trong thân `$transaction(...)` không được gọi mạng"
39
+ * hay "`findMany(...)` phải có `take`": tham số trải nhiều dòng nên regex một
40
+ * dòng hoặc bỏ sót, hoặc ăn lem sang lời gọi kế tiếp.
41
+ */
42
+ export function callBodies(code: string, needle: string): string[] {
43
+ const bodies: string[] = [];
44
+ let idx = code.indexOf(needle);
45
+ while (idx !== -1) {
46
+ let i = idx + needle.length;
47
+ let depth = 1;
48
+ const start = i;
49
+ while (i < code.length && depth > 0) {
50
+ const ch = code[i];
51
+ if (ch === "(") depth++;
52
+ else if (ch === ")") depth--;
53
+ i++;
54
+ }
55
+ bodies.push(code.slice(start, i - 1));
56
+ idx = code.indexOf(needle, i);
57
+ }
58
+ return bodies;
59
+ }
60
+
61
+ export function resolveOptions(
62
+ options: GuardrailOptions = {},
63
+ ): ResolvedGuardrailOptions {
64
+ const root = resolve(options.root ?? process.cwd());
65
+ return {
66
+ ...options,
67
+ root,
68
+ srcDir: options.srcDir ? resolve(options.srcDir) : join(root, "src"),
69
+ allowlists: options.allowlists ?? {},
70
+ ceilings: options.ceilings ?? {},
71
+ skip: options.skip ?? {},
72
+ doors: {
73
+ prisma: options.doors?.prisma ?? "lib/prisma.ts",
74
+ storage: options.doors?.storage ?? ["lib/storage.ts"],
75
+ // Composition root (`lib/prisma.ts`) là cửa hợp lệ mặc định: app nhỏ cắm
76
+ // branch-guard thẳng vào chỗ tạo client thay vì tách file riêng.
77
+ branchScope: options.doors?.branchScope ?? [
78
+ "lib/branch-scope.ts",
79
+ "lib/rbac/branch-scope.ts",
80
+ "lib/prisma.ts",
81
+ ],
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Dựng ngữ cảnh quét: đi hết cây `src`, nhớ nội dung để ~70 rule không phải đọc
88
+ * lại cùng một file 70 lần (một app cỡ vinhhoa là ~1.400 file — đọc lại là mất
89
+ * vài chục giây mỗi lần chạy test).
90
+ */
91
+ export function createContext(options: GuardrailOptions = {}): GuardrailContext {
92
+ const resolved = resolveOptions(options);
93
+ const { srcDir, root } = resolved;
94
+ const ignore = new Set([...DEFAULT_IGNORE, ...(options.ignoreDirs ?? [])]);
95
+
96
+ const files: string[] = [];
97
+ const walk = (dir: string) => {
98
+ for (const entry of readdirSync(dir)) {
99
+ if (ignore.has(entry)) continue;
100
+ const full = join(dir, entry);
101
+ if (statSync(full).isDirectory()) walk(full);
102
+ else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
103
+ files.push(full);
104
+ }
105
+ }
106
+ };
107
+ if (existsSync(srcDir)) walk(srcDir);
108
+
109
+ const rawCache = new Map<string, string>();
110
+ const codeCache = new Map<string, string>();
111
+
112
+ const read = (file: string) => {
113
+ let cached = rawCache.get(file);
114
+ if (cached === undefined) {
115
+ cached = existsSync(file) ? readFileSync(file, "utf8") : "";
116
+ rawCache.set(file, cached);
117
+ }
118
+ return cached;
119
+ };
120
+
121
+ const readCode = (file: string) => {
122
+ let cached = codeCache.get(file);
123
+ if (cached === undefined) {
124
+ cached = stripComments(read(file));
125
+ codeCache.set(file, cached);
126
+ }
127
+ return cached;
128
+ };
129
+
130
+ return {
131
+ root,
132
+ srcDir,
133
+ files,
134
+ rel: (file) => relative(srcDir, file).split("\\").join("/"),
135
+ read,
136
+ readCode,
137
+ existsInSrc: (relPath) => existsSync(join(srcDir, relPath)),
138
+ existsInRoot: (relPath) => existsSync(join(root, relPath)),
139
+ readRel: (relPath) => read(join(srcDir, relPath)),
140
+ readCodeRel: (relPath) => readCode(join(srcDir, relPath)),
141
+ callBodies,
142
+ options: resolved,
143
+ };
144
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Kiểu dữ liệu của bộ hàng rào kiến trúc.
3
+ *
4
+ * Mỗi `GuardrailRule` là một **ca lỗi thật** đã từng tốn thời gian ở app gốc
5
+ * (vinhhoa) được viết lại thành phép quét tĩnh. Rule mang theo `why` — nguyên
6
+ * văn sự cố — vì thông báo lỗi mới là chỗ người/agent đời sau thực sự đọc;
7
+ * quy tắc nằm trong tài liệu thì bị bỏ qua, nằm trong test đỏ thì không.
8
+ */
9
+
10
+ /** Một rule không áp dụng cho app này (thiếu thư mục/tệp mà nó soi). */
11
+ export const NOT_APPLICABLE = null;
12
+
13
+ export interface GuardrailContext {
14
+ /** Gốc app (chứa package.json, .dockerignore…). */
15
+ root: string;
16
+ /** Thư mục mã nguồn, thường là `<root>/src`. */
17
+ srcDir: string;
18
+ /** Đường dẫn TUYỆT ĐỐI mọi file .ts/.tsx đang được soi (đã loại test). */
19
+ files: string[];
20
+ /** Đường dẫn tương đối `srcDir`, luôn dùng dấu `/` kể cả trên Windows. */
21
+ rel(file: string): string;
22
+ /** Nội dung thô. */
23
+ read(file: string): string;
24
+ /** Nội dung đã BỎ chú thích — mọi phép quét mẫu nên dùng bản này. */
25
+ readCode(file: string): string;
26
+ /** Tồn tại không, đường dẫn tương đối `srcDir`. */
27
+ existsInSrc(relPath: string): boolean;
28
+ /** Tồn tại không, đường dẫn tương đối `root`. */
29
+ existsInRoot(relPath: string): boolean;
30
+ /** Đọc file theo đường dẫn tương đối `srcDir` (rỗng nếu không có). */
31
+ readRel(relPath: string): string;
32
+ /** Như `readRel` nhưng đã bỏ chú thích — mặc định nên dùng bản này. */
33
+ readCodeRel(relPath: string): string;
34
+ /**
35
+ * Cắt thân đối số của mọi lời gọi `<needle>` bằng cách khớp ngoặc cân bằng.
36
+ * Regex một dòng không làm được việc này khi tham số trải nhiều dòng.
37
+ */
38
+ callBodies(code: string, needle: string): string[];
39
+ /** Tuỳ chọn người dùng truyền vào, đã điền mặc định. */
40
+ options: ResolvedGuardrailOptions;
41
+ }
42
+
43
+ export interface GuardrailRule {
44
+ /** Định danh ổn định, dạng `nhóm/tên-gạch-nối` — dùng làm khoá `skip`. */
45
+ id: string;
46
+ /** Câu mô tả hiển thị làm tên `it(...)`. */
47
+ title: string;
48
+ /** Sự cố có thật đứng sau rule. In ra khi test đỏ. */
49
+ why: string;
50
+ /** Việc cần làm để hết đỏ. */
51
+ fix: string;
52
+ /**
53
+ * Trả danh sách vi phạm (rỗng = đạt), hoặc `NOT_APPLICABLE` khi app không có
54
+ * thứ mà rule soi — im lặng bỏ qua thay vì đỏ oan.
55
+ */
56
+ run(ctx: GuardrailContext): string[] | null;
57
+ /**
58
+ * Với rule có allowlist: entry nào đã hết lý do tồn tại. Suất miễn trừ bỏ
59
+ * ngỏ là cách guardrail mục dần — nợ trả rồi mà tên vẫn nằm đó thì lần sau
60
+ * có người lặng lẽ dùng lại suất ấy.
61
+ */
62
+ staleAllowlist?(ctx: GuardrailContext): string[];
63
+ }
64
+
65
+ /** Nhóm rule để báo cáo và để `skip` theo cả cụm (`"design/*"`). */
66
+ export type GuardrailGroup =
67
+ | "auth"
68
+ | "rbac"
69
+ | "layering"
70
+ | "one-door"
71
+ | "structure"
72
+ | "design"
73
+ | "safety";
74
+
75
+ export interface GuardrailOptions {
76
+ /** Gốc app. Mặc định `process.cwd()`. */
77
+ root?: string;
78
+ /** Thư mục nguồn. Mặc định `<root>/src`. */
79
+ srcDir?: string;
80
+ /**
81
+ * Registry quyền của app (`src/configs/permissions`). Thiếu thì nhóm rule
82
+ * `rbac/*` tự bỏ qua.
83
+ */
84
+ permissionRegistry?: PermissionFeatureLike[];
85
+ /** Cây menu (`src/data/navigations`). Thiếu thì rule nav tự bỏ qua. */
86
+ navigations?: NavigationGroupLike[];
87
+ /**
88
+ * Bộ action ai cũng hiểu. PHẢI trùng danh sách trong `scripts/rbac-sync.ts`
89
+ * của app — lệch nhau thì test xanh mà seed ném lỗi lúc deploy.
90
+ */
91
+ standardActions?: string[];
92
+ /**
93
+ * Nợ cũ theo từng rule: `{ "<ruleId>": ["đường/dẫn.ts", …] }`. Engine chỉ
94
+ * cho RÚT BỚT — thêm tên mới thì phải giải trình trong review.
95
+ */
96
+ allowlists?: Record<string, string[]>;
97
+ /**
98
+ * Trần theo thư mục cấp 1: `{ "<ruleId>": { app: 671, lib: 91 } }`.
99
+ * Vượt trần = đỏ. Dư trần cũng đỏ — trả nợ tới đâu hạ trần tới đó.
100
+ */
101
+ ceilings?: Record<string, Record<string, number>>;
102
+ /**
103
+ * Tắt rule kèm LÝ DO bắt buộc. Đây là van an toàn để bump core không làm
104
+ * gãy app đang chạy; đổi lại mỗi suất tắt đều hiện hình trong git diff.
105
+ * Nhận cả `"nhóm/*"`.
106
+ */
107
+ skip?: Record<string, string>;
108
+ /** Rule riêng của app (domain SSOT, một-cửa repository…). */
109
+ extraRules?: GuardrailRule[];
110
+ /** Thư mục bỏ qua khi quét, ngoài mặc định. */
111
+ ignoreDirs?: string[];
112
+ /** Vài rule cần biết từ vựng riêng của app. */
113
+ domain?: {
114
+ /**
115
+ * Mẫu lời gọi mạng/tích hợp bị cấm trong thân `$transaction`. Mặc định chỉ
116
+ * bắt `fetch(`; app nên bổ sung tên client tích hợp của mình.
117
+ */
118
+ networkInTransaction?: RegExp;
119
+ /**
120
+ * Model mà `findMany` trong một `page.tsx` bắt buộc phải có `take` — dùng
121
+ * cho bảng giao dịch lớn đổ vào dropdown.
122
+ */
123
+ cappedFindManyModels?: string[];
124
+ /**
125
+ * Tên hàm CỔNG — thứ mà `{ resource, action }` truyền vào là khai báo
126
+ * quyền. Mặc định `apiHandler` / `requirePermission` /
127
+ * `requireApiPermission`. Đặt tên cổng khác mà quên khai ở đây thì hai
128
+ * thước `rbac/gate-*` im lặng không soi gì cả.
129
+ */
130
+ gateCalls?: string[];
131
+ };
132
+ /** Đường vào của các engine singleton — mỗi app đặt tên hơi khác nhau. */
133
+ doors?: {
134
+ /** File DUY NHẤT được `new PrismaClient()`. Mặc định `lib/prisma.ts`. */
135
+ prisma?: string;
136
+ /** File DUY NHẤT được import `@goerp/core/storage`. */
137
+ storage?: string[];
138
+ /** File hạ tầng được import `@goerp/core/branch-scope`. */
139
+ branchScope?: string[];
140
+ };
141
+ }
142
+
143
+ export type ResolvedGuardrailOptions = GuardrailOptions &
144
+ Required<Pick<GuardrailOptions, "root" | "srcDir">> & {
145
+ allowlists: Record<string, string[]>;
146
+ ceilings: Record<string, Record<string, number>>;
147
+ skip: Record<string, string>;
148
+ doors: {
149
+ prisma: string;
150
+ storage: string[];
151
+ branchScope: string[];
152
+ };
153
+ };
154
+
155
+ /**
156
+ * Hình dạng TỐI THIỂU của registry quyền mà rule cần đọc. Cố ý không dùng thẳng
157
+ * kiểu `PermissionFeature` của app: app khai thêm trường gì cũng vẫn khớp, và
158
+ * guardrails không kéo theo phụ thuộc ngược lên tầng config.
159
+ */
160
+ export interface PermissionFeatureLike {
161
+ resources: Array<{
162
+ code: string;
163
+ actions: string[];
164
+ defaultGrants?: Record<string, string[] | "*">;
165
+ }>;
166
+ customActions?: Array<{ code: string }>;
167
+ }
168
+
169
+ export interface NavigationGroupLike {
170
+ items?: Array<{ resource?: string; [key: string]: unknown }>;
171
+ [key: string]: unknown;
172
+ }
173
+
174
+ /** Kết quả chạy một rule — dùng cho `goerp doctor` (không cần vitest). */
175
+ export interface GuardrailResult {
176
+ rule: GuardrailRule;
177
+ status: "pass" | "fail" | "skipped" | "not-applicable";
178
+ violations: string[];
179
+ staleAllowlist: string[];
180
+ skipReason?: string;
181
+ }