@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.
- package/CHANGELOG.md +94 -1
- package/bin/goerp-guardrails.mjs +45 -0
- package/eslint/index.mjs +120 -0
- package/package.json +10 -3
- package/scripts/doctor.ts +99 -0
- package/src/guardrails/__tests__/guardrails.test.ts +430 -0
- package/src/guardrails/index.ts +57 -0
- package/src/guardrails/preset.ts +75 -0
- package/src/guardrails/primitives.ts +307 -0
- package/src/guardrails/rules/auth.ts +178 -0
- package/src/guardrails/rules/debt.ts +71 -0
- package/src/guardrails/rules/design.ts +95 -0
- package/src/guardrails/rules/layering.ts +160 -0
- package/src/guardrails/rules/one-door.ts +115 -0
- package/src/guardrails/rules/rbac.ts +282 -0
- package/src/guardrails/rules/safety.ts +86 -0
- package/src/guardrails/rules/structure.ts +136 -0
- package/src/guardrails/run.ts +130 -0
- package/src/guardrails/scanner.ts +144 -0
- package/src/guardrails/types.ts +181 -0
- package/src/print/print-styles.tsx +4 -1
- package/src/types/index.ts +1 -1
- package/src/ui/data-display/shallow-pagination.tsx +189 -0
- package/src/ui/index.tsx +1 -0
- package/src/user/components/index.ts +1 -0
- package/src/user/components/user-toolbar.tsx +8 -2
- package/src/user/components/user-visuals.tsx +84 -0
- package/src/user/components/users-card-view.tsx +1 -26
- package/src/user/components/users-table.tsx +215 -0
- package/src/user/pages/users-client-page.tsx +84 -259
- package/templates/starter-app/AGENTS.md +39 -3
- package/templates/starter-app/eslint.config.mjs +85 -0
- package/templates/starter-app/package.json +19 -2
- package/templates/starter-app/prettier.config.mjs +54 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +35 -143
|
@@ -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
|
+
}
|
|
@@ -20,7 +20,10 @@ export function PrintStyles({ pageSize = "A4" }: PrintStylesProps) {
|
|
|
20
20
|
}
|
|
21
21
|
case "A5-Landscape":
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
// Kích thước tường minh thay cho "A5 landscape": một số driver máy in
|
|
24
|
+
// (PC cửa hàng) bỏ qua từ khóa landscape rồi in dọc + scale fill,
|
|
25
|
+
// cắt mất nửa phải trang.
|
|
26
|
+
size: "210mm 148.5mm",
|
|
24
27
|
maxWidth: "210mm", // width
|
|
25
28
|
padding: "12mm 12mm", // increased top padding from 8mm
|
|
26
29
|
fontSize: "10pt",
|
package/src/types/index.ts
CHANGED
|
@@ -170,7 +170,7 @@ export type PermissionType = Permission;
|
|
|
170
170
|
* Vì sao cần: một user nhiều vai trò có thể có hàng nghìn cặp
|
|
171
171
|
* resource/action, mà `Permission[]` lặp lại tên resource ở MỌI cặp. Đo trên
|
|
172
172
|
* ERP thật (2.428 quyền / 73 resource): `Permission[]` là 139,7 KB JSON, bản
|
|
173
|
-
* map là
|
|
173
|
+
* map là 35,5 KB — mà payload đó đi kèm mọi lượt đọc session, kể cả về client.
|
|
174
174
|
*
|
|
175
175
|
* Tra cứu cũng đổi chất: `Permission[]` bắt quét tuyến tính cả mảng cho MỖI
|
|
176
176
|
* lần `checkPermission`, map thì tra thẳng theo resource rồi chỉ quét danh
|