prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt Code Review

57 prompt
Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)
Kỹ năng

Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.

---
name: sql-index-recommendation-reviewer
description: Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---

# Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)

Bạn giúp các lập trình viên tăng tốc các truy vấn chậm mà không làm bảng dữ liệu bị quá tải bởi hàng tá chỉ mục (index). Bạn đọc schema và truy vấn theo đúng tư duy của một kỹ sư cơ sở dữ liệu: cột nào được so sánh bằng (equality), cột nào lọc theo khoảng giá trị (range), truy vấn sắp xếp theo cột nào, kết nối (join) trên cột nào, và những chỉ mục nào đã tồn tại sẵn. Bạn đề xuất tập hợp chỉ mục nhỏ gọn nhất đáp ứng được tải công việc (workload) theo đúng thứ tự cột, và luôn thẳng thắn rằng chỉ có lệnh EXPLAIN chạy trên dữ liệu thực tế mới có thể kiểm chứng chắc chắn kết quả.

## Các tệp trong kỹ năng này

- `scripts/suggest_indexes.py` - phân tích cú pháp các câu lệnh CREATE TABLE / CREATE INDEX và các truy vấn SELECT, UPDATE, DELETE (tách riêng các truy vấn con subquery và phần thân CTE), sau đó in ra các chỉ mục ứng viên kèm mã DDL, các truy vấn mà mỗi chỉ mục phục vụ, các cột bao phủ (covering columns), sự chồng chéo và các phát hiện trên schema (chỉ dùng thư viện chuẩn Python 3; không kết nối cơ sở dữ liệu)
- `references/indexing-rules.md` - quy tắc về thứ tự cột, chỉ mục bao phủ (covering indexes), chỉ mục từng phần (partial) và chỉ mục biểu thức (expression), tính chất sargable (khả năng tận dụng chỉ mục), cùng các quy tắc tránh đánh chỉ mục quá mức
- `references/explain-reading-guide.md` - hướng dẫn cách đọc kết quả EXPLAIN trong PostgreSQL, MySQL và SQLite để xác nhận hoặc bác bỏ một đề xuất
- `templates/index-review.md` - mẫu báo cáo đánh giá trả về cho người dùng
- `examples/example-shop-orders-review.md` - ví dụ thực tế đánh giá các truy vấn chậm của một cửa hàng trực tuyến

## Quy trình làm việc

### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Hệ quản trị cơ sở dữ liệu và phiên bản (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server), cùng với schema và các chỉ mục hiện có (`\d table`, `SHOW CREATE TABLE`, hoặc các tệp migration).
- Các truy vấn chậm nguyên bản như khi ứng dụng gửi đi, kèm theo các giá trị tham số điển hình, tần suất chạy của mỗi truy vấn và mức độ chậm thực tế.
- Kích thước bảng (số dòng), tần suất ghi (write-heavy) của mỗi bảng, và bất kỳ kết quả EXPLAIN hoặc EXPLAIN ANALYZE nào hiện có.
- Các ràng buộc vận hành: nhóm có thể tạo chỉ mục trực tuyến (online) không, dung lượng ổ cứng còn trống và những lo ngại về độ trễ sao chép dữ liệu (replication lag).

### 2. Chạy công cụ hỗ trợ
```bash
python3 scripts/suggest_indexes.py --schema schema.sql slow-queries.sql
python3 scripts/suggest_indexes.py all-in-one.sql --dialect mysql
python3 scripts/suggest_indexes.py --schema schema.sql - --json < query.sql
python3 scripts/suggest_indexes.py --schema schema.sql queries.sql --strict   # exit 1 on non-sargable filters or duplicate indexes
```
Công cụ hỗ trợ này là một trình phân tích cú pháp dựa trên quy tắc ước lượng (heuristic parser), không phải bộ lập kế hoạch truy vấn (query planner): nó không biết số lượng dòng, phân phối dữ liệu hay kế hoạch thực thi mà cơ sở dữ liệu sẽ chọn. Nó có thể bỏ sót các điều kiện lọc (predicates) trong câu SQL phức tạp (và sẽ liệt kê chúng vào mục "not understood" - chưa hiểu được). Hãy tự mình đọc kỹ từng truy vấn và coi mỗi gợi ý từ công cụ là một ứng viên cần kiểm chứng. Nếu bạn không thể chạy script, hãy áp dụng thủ công các quy tắc trong `references/indexing-rules.md` theo đúng trình tự tương tự.

### 3. Ra quyết định
1. Sửa lại câu truy vấn trước khi thêm chỉ mục: viết lại các bộ lọc không thể tận dụng chỉ mục (non-sargable filters — như gọi hàm trên cột, phép tính số học trên cột, toán tử LIKE có ký tự đại diện `%` ở đầu, phép OR giữa nhiều cột khác nhau) để cơ sở dữ liệu có thể sử dụng được chỉ mục.
2. Đối với mỗi truy vấn, hãy chọn thứ tự cột: các cột so sánh bằng (ưu tiên cột có độ chọn lọc cao nhất lên trước khi bạn biết rõ điều đó), tiếp theo là các cột trong ORDER BY hoặc GROUP BY nếu truy vấn có sắp xếp đi kèm LIMIT, sau cùng là một cột lọc theo khoảng giá trị (range).
3. Hợp nhất chỉ mục: một chỉ mục phức hợp (composite index) có thể phục vụ nhiều truy vấn cùng lúc khi chúng dùng chung các cột ngoài cùng bên trái (leftmost columns). Ưu tiên mở rộng một chỉ mục sẵn có hơn là tạo thêm chỉ mục mới.
4. Chỉ thêm các cột bao phủ (INCLUDE / covering columns) cho các truy vấn chạy cực kỳ thường xuyên (hot) và thiên về đọc dữ liệu (read-heavy), đồng thời giữ số lượng cột này ở mức tối thiểu.
5. Dọn dẹp trước khi thêm mới: xóa bỏ các chỉ mục bị trùng lặp hoàn toàn và các chỉ mục tiền tố dư thừa (redundant-prefix indexes) sau khi đã kiểm tra thống kê tần suất sử dụng (`pg_stat_user_indexes`, `sys.schema_unused_indexes`).
6. Tính toán chi phí đánh đổi: mỗi chỉ mục thêm vào đều làm chậm thao tác INSERT và UPDATE trên các cột của nó, đồng thời tiêu tốn dung lượng ổ cứng và bộ nhớ RAM. Những bảng có tần suất ghi cao (write-heavy) nên có ít chỉ mục hơn và ưu tiên các chỉ mục đa dụng.

### 4. Bàn giao kết quả
Điền đầy đủ vào mẫu `templates/index-review.md`: chẩn đoán từng truy vấn, mã lệnh DDL khuyến nghị theo đúng thứ tự khởi tạo kèm danh sách các truy vấn mà mỗi chỉ mục phục vụ, các chỉ mục cần xóa bỏ, cách viết lại truy vấn, những sự đánh đổi (tradeoffs), và hướng dẫn cách kiểm chứng bằng lệnh EXPLAIN trước và sau khi áp dụng (xem `references/explain-reading-guide.md`). Tuân theo văn phong trình bày của `examples/example-shop-orders-review.md`.

## Quy tắc
- Không bao giờ tuyên bố truy vấn sẽ nhanh hơn bao nhiêu lần mà không chỉ rõ cách đo lường; hãy nêu những thay đổi dự kiến trong kế hoạch thực thi ("chuyển sang quét chỉ mục index scan thay vì quét tuần tự sequential scan và sắp xếp sort"), tuyệt đối không tự bịa ra các con số thời gian chạy.
- Sử dụng các lệnh tạo chỉ mục an toàn cho môi trường production: `CREATE INDEX CONCURRENTLY` trong PostgreSQL, tạo DDL trực tuyến (`ALGORITHM=INPLACE, LOCK=NONE`) trong MySQL, và lên lịch khung giờ bảo trì (maintenance window) đối với SQLite.
- Không khuyến nghị tạo chỉ mục đơn lẻ trên một cột có độ chọn lọc thấp (như cột trạng thái status, cột boolean); hãy đưa cột đó vào bên trong một chỉ mục phức hợp hoặc dùng làm điều kiện cho chỉ mục từng phần (partial index).
- Giữ nguyên các khóa chính (primary keys) và ràng buộc duy nhất (unique constraints); không bao giờ đề xuất xóa chúng đi để giảm "sự dư thừa".
- Nhắn người dùng tạo từng chỉ mục một và đối chiếu kế hoạch thực thi (execution plans), đồng thời gỡ bỏ bất kỳ chỉ mục nào mà lệnh EXPLAIN không bao giờ sử dụng tới.
FILE:references/indexing-rules.md
# Các quy tắc đánh chỉ mục (Indexing rules)

Dưới đây là những quy tắc mà công cụ hỗ trợ áp dụng, cùng với những đánh giá chuyên môn mà công cụ không thể tự quyết định thay con người. Các ví dụ sử dụng cú pháp PostgreSQL trừ khi có ghi chú khác.

## 1. Thứ tự cột trong chỉ mục phức hợp (composite index)

Một chỉ mục B-tree được sắp xếp theo cột đầu tiên của nó, sau đó đến cột thứ hai, và cứ tiếp tục như vậy. Một câu truy vấn có thể tìm kiếm nhảy cóc (seek) trên một chuỗi các cột liên tiếp tính từ ngoài cùng bên trái được so sánh bằng (equality), sau đó sử dụng thêm một cột tiếp theo cho điều kiện khoảng (range) hoặc cho việc sắp xếp (sorting).

Thứ tự chuẩn: **các cột so sánh bằng (equality columns), tiếp đến là các cột sắp xếp (sort columns), rồi đến một cột khoảng giá trị (range column)**.
```sql
-- WHERE customer_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20
CREATE INDEX ON orders (customer_id, status, created_at);
```
- Giữa các cột cùng dùng phép so sánh bằng, hãy đặt cột được sử dụng bởi nhiều truy vấn nhất lên đầu tiên để chỉ mục đó phục vụ được tất cả các truy vấn này; độ chọn lọc (selectivity) ít quan trọng hơn khả năng tái sử dụng một khi tất cả các cột đó đều là phép so sánh bằng.
- Đằng sau một cột lọc theo khoảng giá trị (`>`, `<`, `BETWEEN`, `LIKE 'abc%'`), các cột đứng sau nó không thể được dùng để tìm kiếm định vị (seeking) nữa, mà chỉ có thể dùng để lọc tuần tự ngay bên trong chỉ mục.
- Thao tác sắp xếp (sort) chỉ có thể tận dụng chỉ mục nếu mọi cột trong mệnh đề ORDER BY đều nằm trong chỉ mục theo đúng thứ tự đó và có chiều sắp xếp giống hệt nhau (hoặc đảo ngược hoàn toàn tất cả). Nếu chiều sắp xếp hỗn hợp (tăng giảm đan xen), cần phải định nghĩa chỉ mục khớp đúng theo chiều đó (`created_at DESC, id ASC`).
- Khi vừa có điều kiện khoảng giá trị vừa có mệnh đề ORDER BY trên một cột khác, cơ sở dữ liệu buộc phải lựa chọn: hoặc tìm kiếm theo khoảng giá trị rồi mới đem đi sắp xếp, hoặc duyệt theo thứ tự đã sắp xếp sẵn của chỉ mục rồi lọc dần. Khi đi kèm với LIMIT, phương án duyệt theo thứ tự sắp xếp sẵn thường chiến thắng.

## 2. Tiền tố ngoài cùng bên trái (Leftmost prefix) và việc hợp nhất chỉ mục

Một chỉ mục trên `(a, b, c)` phục vụ được các truy vấn lọc trên `a`, trên `a, b`, và trên `a, b, c`, nhưng không phục vụ được truy vấn chỉ lọc riêng trên `b` hoặc `c`. Hai chỉ mục ứng viên mà trong đó một cái là tiền tố ngoài cùng bên trái của cái kia thì nên được gộp lại thành một chỉ mục duy nhất. Một chỉ mục hiện có trên `(a)` sẽ trở nên dư thừa một khi đã có chỉ mục `(a, b)`, trừ phi chỉ mục `(a)` đó có ràng buộc duy nhất (unique), là chỉ mục từng phần (partial), hoặc có kích thước nhỏ hơn hẳn và được truy cập cực kỳ thường xuyên.

## 3. Phép nối (Joins) và khóa ngoại (Foreign keys)

- Hãy đánh chỉ mục trên cột thuộc về phía bảng được tra cứu: thông thường chính là cột khóa ngoại (`orders.customer_id`). Phía khóa chính vốn dĩ đã được đánh chỉ mục sẵn.
- PostgreSQL không tự động đánh chỉ mục cho các khóa ngoại (trong khi MySQL InnoDB thì có). Việc thiếu chỉ mục khóa ngoại cũng khiến thao tác xóa (DELETE) trên bảng cha bị chậm đi đáng kể.
- Cột dùng để join cộng với các điều kiện lọc riêng của chính bảng đó có thể kết hợp lại thành một chỉ mục phức hợp: `(customer_id, created_at)`.

## 4. Chỉ mục bao phủ (Covering indexes)

Nếu chỉ mục đã lưu giữ toàn bộ mọi cột mà câu truy vấn cần đọc, cơ sở dữ liệu có thể bỏ qua bước đọc bảng chính (quét chỉ mục thuần túy — index-only scan).
- PostgreSQL 11+ và SQL Server: dùng mệnh đề `INCLUDE (col, ...)` để đính kèm thêm các cột phụ vào trang lá của chỉ mục mà không cần sắp xếp theo chúng.
- MySQL và SQLite: nối thêm các cột đó vào cuối phần khóa chỉ mục; các chỉ mục phụ (secondary indexes) của InnoDB vốn đã tự động đính kèm khóa chính bên trong.
- Chỉ nên áp dụng cho các truy vấn chạy liên tục, chủ yếu là đọc dữ liệu và chỉ lấy ra một vài cột. Các chỉ mục bao phủ quá rộng sẽ chiếm nhiều dung lượng và làm chậm tốc độ ghi.
- Quá trình quét chỉ mục thuần túy (index-only scan) trên PostgreSQL cũng đòi hỏi bảng dữ liệu phải được dọn dẹp (`VACUUM`) thường xuyên để cập nhật bản đồ hiển thị (visibility map).

## 5. Chỉ mục từng phần (Partial) và chỉ mục biểu thức (Expression)

- Chỉ mục từng phần (PostgreSQL, SQLite; tương đương filtered index trong SQL Server): `CREATE INDEX ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;` Cực kỳ nhỏ gọn và nhanh chóng khi điều kiện WHERE chỉ khớp với một tỷ lệ nhỏ các dòng dữ liệu. Lưu ý câu truy vấn bắt buộc phải lặp lại chính xác điều kiện lọc đó thì mới kích hoạt được chỉ mục.
- Biểu thức (Expression): `CREATE INDEX ON users (lower(email));` phục vụ điều kiện `WHERE lower(email) = ?`. MySQL 8 hỗ trợ chỉ mục hàm (functional index) `((lower(email)))` hoặc tạo một cột sinh tự động (generated column) kèm chỉ mục.

## 6. Khả năng tận dụng chỉ mục (Sargability): những bộ lọc mà chỉ mục không thể sử dụng

| Khuôn mẫu | Vấn đề | Cách khắc phục |
|---|---|---|
| `WHERE date(created_at) = '2026-10-12'` | gọi hàm trên cột | `created_at >= '2026-10-12' AND created_at < '2026-10-13'` |
| `WHERE YEAR(updated_at) = 2026` | gọi hàm trên cột | lọc theo khoảng giá trị trên cột |
| `WHERE price * 1.2 > 100` | phép tính số học trên cột | `price > 100 / 1.2` |
| `WHERE name LIKE '%lamp%'` | ký tự đại diện (wildcard) ở đầu | tìm kiếm toàn văn (full-text search), hoặc chỉ mục GIN `pg_trgm` |
| `WHERE lower(name) = ?` | gọi hàm | chỉ mục biểu thức trên `lower(name)` hoặc dùng bảng đối chiếu không phân biệt hoa thường (case-insensitive collation) |
| `WHERE a = 1 OR b = 2` | dùng OR giữa các cột khác nhau | `UNION` hai câu truy vấn có chỉ mục, hoặc tạo một chỉ mục cho mỗi cột (bitmap OR trong PostgreSQL) |
| `WHERE status <> 'done'`, `NOT IN` | bộ lọc phủ định | dùng danh sách khẳng định `IN (...)` hoặc chỉ mục một phần (partial index) |
| `WHERE id = '42'` trên cột số nguyên | ép kiểu ngầm định (MySQL có thể bỏ qua chỉ mục trên các cột chuỗi khi đem so sánh với số) | so sánh đúng kiểu dữ liệu |

## 7. Đánh chỉ mục quá mức (Over-indexing)

- Mỗi chỉ mục đều phải cập nhật khi thực hiện INSERT, DELETE và khi UPDATE các cột của nó; ngoài ra nó còn tiêu tốn dung lượng ổ đĩa, bộ nhớ (buffer cache), cũng như băng thông sao lưu và nhân bản dữ liệu (replication).
- Các chỉ mục trùng lặp (cùng danh sách cột, cùng điều kiện) hoàn toàn chỉ gây tốn kém chi phí: hãy xóa bớt một cái.
- Chỉ mục đơn cột trên các cột có độ chọn lọc thấp (boolean, status, type) hiếm khi được sử dụng; bộ lập kế hoạch (planner) thường ưu tiên quét bảng khi một giá trị khớp với hơn khoảng 5 đến 20 phần trăm số dòng. Hãy đưa chúng vào trong các chỉ mục phức hợp (composite index) hoặc dùng làm điều kiện cho chỉ mục một phần (partial index).
- Theo kinh nghiệm thực tế, nếu một bảng có tần suất ghi cao mà có trên 5 hoặc 6 chỉ mục thì cần được rà soát lại; các bảng chủ yếu phục vụ đọc có thể chịu được nhiều chỉ mục hơn.
- Tìm các chỉ mục không được sử dụng trước khi xóa: trên PostgreSQL kiểm tra `pg_stat_user_indexes.idx_scan = 0` (kể từ lần đặt lại thống kê gần nhất, trên tất cả các máy chủ replica), trên MySQL kiểm tra `sys.schema_unused_indexes`. Xóa bằng lệnh `DROP INDEX CONCURRENTLY` trong PostgreSQL.

## 8. Xây dựng chỉ mục an toàn

- PostgreSQL: `CREATE INDEX CONCURRENTLY` giúp tránh chặn các thao tác ghi; lệnh này không thể chạy bên trong một giao dịch (transaction) và sẽ để lại một chỉ mục ở trạng thái INVALID nếu thất bại (hãy xóa đi và thử lại).
- MySQL 8: `ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE`; cần theo dõi độ trễ nhân bản (replication lag) trên các bảng lớn.
- SQLite: quá trình tạo chỉ mục sẽ khóa cơ sở dữ liệu; hãy chạy vào thời điểm ít lưu lượng truy cập.
- Tạo từng chỉ mục một, kiểm tra kế hoạch thực thi, rồi mới tiếp tục với chỉ mục tiếp theo.
FILE:references/explain-reading-guide.md
# Đọc EXPLAIN để xác nhận hiệu quả chỉ mục

Luôn so sánh kế hoạch thực thi trước và sau khi thay đổi, trên dữ liệu tương tự môi trường production và với các giá trị tham số thực tế. Các bảng kiểm thử quy mô nhỏ sẽ khiến bộ lập kế hoạch chọn phương án quét bảng vốn chỉ phù hợp cho bảng nhỏ.

## PostgreSQL

```sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;   -- runs the query; wrap writes in BEGIN ... ROLLBACK
```
| Nút kế hoạch (Plan node) | Ý nghĩa |
|---|---|
| Seq Scan | đọc toàn bộ bảng; ổn đối với bảng nhỏ hoặc khi phần lớn số dòng đều khớp điều kiện |
| Index Scan | tra cứu chỉ mục, sau đó đọc các dòng tương ứng trong bảng |
| Index Only Scan | trả kết quả trực tiếp từ chỉ mục; hãy kiểm tra chỉ số "Heap Fetches" xem có thấp không (nếu không thấp hãy chạy vacuum) |
| Bitmap Index Scan + Bitmap Heap Scan | thu thập nhiều kết quả khớp, sau đó đọc các trang dữ liệu theo thứ tự; phổ biến với độ chọn lọc trung bình và điều kiện OR |
| Sort (kèm "external merge") | quá trình sắp xếp bị tràn ra ổ đĩa; một chỉ mục theo đúng thứ tự ORDER BY có thể loại bỏ bước này |
| Rows Removed by Filter | số dòng đã đọc lên nhưng bị loại bỏ; con số này cao nghĩa là chỉ mục chưa bao phủ được bộ lọc |

Hãy chú ý đến: thời gian thực tế (actual time) của nút trên cùng, số dòng ước tính so với thực tế (chênh lệch gấp 10 lần nghĩa là thống kê đã cũ: hãy chạy `ANALYZE table`), và các chỉ số bộ đệm shared hit/read buffers. Xác nhận chỉ mục mới thực sự được sử dụng thông qua `pg_stat_user_indexes.idx_scan` sau một ngày có lưu lượng truy cập thực tế.

## MySQL 8

```sql
EXPLAIN FORMAT=TREE SELECT ...;
EXPLAIN ANALYZE SELECT ...;               -- 8.0.18+, runs the query
```
Trong đầu ra `EXPLAIN` truyền thống, hãy kiểm tra: `type` (ALL = quét toàn bộ bảng, ref/eq_ref/range = có dùng chỉ mục, index = quét toàn bộ chỉ mục), `key` (chỉ mục được chọn), `rows` (ước tính), và `Extra` ("Using index" = chỉ mục bao phủ, "Using filesort" = phát sinh bước sắp xếp phụ, "Using temporary" = dùng bảng tạm cho GROUP BY hoặc DISTINCT). Làm mới thống kê bằng lệnh `ANALYZE TABLE t`.

## SQLite

```sql
EXPLAIN QUERY PLAN SELECT ...;
```
"SCAN t" là quét toàn bộ bảng; "SEARCH t USING INDEX idx (a=? AND b>?)" cho biết những cột nào được dùng để tra cứu; "USING COVERING INDEX" nghĩa là không cần truy cập vào bảng dữ liệu; "USE TEMP B-TREE FOR ORDER BY" nghĩa là phát sinh thêm bước sắp xếp. Hãy chạy `ANALYZE;` để bộ lập kế hoạch có dữ liệu thống kê.

## Dấu hiệu của sự thành công

- Kế hoạch thực thi của câu truy vấn chậm chuyển từ quét toàn bộ bảng kèm sắp xếp sang quét chỉ mục (hoặc chỉ quét chỉ mục - index only scan) mà không cần sắp xếp, đồng thời số dòng phải đọc giảm xuống gần bằng số dòng trả về.
- Các thao tác ghi trên bảng không bị chậm đi đáng kể (hãy kiểm tra độ trễ p95 của thao tác insert và update sau khi triển khai).
- Không có câu truy vấn quan trọng nào khác bị đổi sang kế hoạch thực thi tệ hơn; hãy chạy tập các truy vấn chiếm nhiều tổng thời gian nhất (`pg_stat_statements`, các bảng digest trong performance schema của MySQL) trước và sau khi thay đổi.

## Khi chỉ mục không được sử dụng

1. Thống kê đã cũ: hãy chạy ANALYZE.
2. Bộ lọc không có khả năng tận dụng chỉ mục - non-sargable (xem mục 6 trong indexing-rules.md).
3. Giá trị cần tìm khớp với quá nhiều dòng; bộ lập kế hoạch chọn quét bảng là hoàn toàn hợp lý.
4. Kiểu dữ liệu của cột hoặc bảng đối chiếu (collation) không đồng nhất giữa câu truy vấn và cột trong bảng (ép kiểu ngầm định).
5. Câu truy vấn tham số hóa sử dụng kế hoạch thực thi chung (generic plan); hãy kiểm thử bằng câu lệnh prepared statement thực tế.
6. Các cột trong chỉ mục đang sắp xếp sai thứ tự so với yêu cầu của câu truy vấn.
FILE:templates/index-review.md
# Đánh giá chỉ mục: app_or_service

**Hệ CSDL (Engine):** engine_and_version | **Các bảng:** tables_with_row_counts | **Tải ghi (Write load):** write_profile
**Công cụ hỗ trợ (Helper):** Đã phân tích n_queries truy vấn, n_suggested ứng viên chỉ mục, n_findings phát hiện về lược đồ (schema)

## Tóm tắt

Hai hoặc ba câu: nguyên nhân chính gây chậm, các chỉ mục cần thêm vào và xóa đi, cùng sự thay đổi dự kiến trong kế hoạch thực thi.

## Chẩn đoán theo từng truy vấn

| Truy vấn | Tần suất chạy | Hiện tại (kế hoạch / thời gian) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| Q1 | per_minute | Seq Scan + Sort | | |

## Viết lại câu truy vấn (thực hiện bước này trước)

```sql
-- before
-- after
```

## Các chỉ mục cần thêm (tạo theo đúng thứ tự này)

| # | Lệnh DDL | Phục vụ truy vấn | Lý do chọn thứ tự cột này | Chi phí |
|---|---|---|---|---|
| 1 | | | | |

## Các chỉ mục cần xóa (sau khi đã kiểm tra mức độ sử dụng)

| Chỉ mục | Lý do | Kiểm tra trước khi xóa |
|---|---|---|
| | trùng lặp / tiền tố dư thừa / không được sử dụng / độ chọn lọc thấp | `idx_scan` trên máy chủ chính (primary) và các máy chủ bản sao (replicas) |

## Tác động ròng trên từng bảng

| Bảng | Số chỉ mục trước | Sau khi thay đổi | Ghi chú |
|---|---|---|---|

## Cách xác minh

1. Thu thập `EXPLAIN (ANALYZE, BUFFERS)` cho từng câu truy vấn trước khi thay đổi.
2. Tạo từng chỉ mục một (`CONCURRENTLY` / online DDL), chạy `ANALYZE`, sau đó thu thập lại kế hoạch thực thi.
3. Kỳ vọng: (sự thay đổi kế hoạch thực thi cho từng câu truy vấn).
4. Theo dõi độ trễ ghi và độ trễ nhân bản (replication lag) trong một ngày; sau đó mới xóa các chỉ mục được liệt kê ở trên.

## Lưu ý

- Các đề xuất chỉ dựa trên văn bản câu truy vấn và lược đồ (schema); số lượng dòng thực tế và độ lệch dữ liệu (data skew) có thể làm thay đổi lựa chọn tối ưu nhất.
FILE:examples/example-shop-orders-review.md
# Ví dụ: Đánh giá chỉ mục cho một cửa hàng trực tuyến

## Yêu cầu của người dùng

"Cơ sở dữ liệu cửa hàng chạy PostgreSQL 16 của chúng tôi đang bị chậm dần. Bảng orders có 12 triệu dòng và nhận khoảng 40 lệnh insert mỗi giây, bảng customers có 900 nghìn dòng, bảng products có 20 nghìn dòng. Dưới đây là schema và sáu câu truy vấn xuất hiện trong pg_stat_statements. Chúng tôi nên thêm những chỉ mục nào, và liệu hiện tại chúng tôi đã có quá nhiều chỉ mục hay chưa?"

## Chạy công cụ hỗ trợ (Helper)

```
$ python3 scripts/suggest_indexes.py --schema shop-schema.sql slow-queries.sql
Schema: 4 table(s), 12 existing index(es) incl. keys | dialect: postgres
Queries analyzed: 7

Q1 SELECT: SELECT o.id, o.total, o.created_at FROM orders o WHERE o.customer_id = $1 AND o.status = 'paid' ORDER BY o.cre...
  equality: orders.customer_id (=), orders.status (=)
  order by: orders.created_at DESC  (LIMIT)
Q2 SELECT: SELECT c.email, count(*) AS orders FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.country = 'D...
  equality: customers.country (=)
  range: orders.created_at (range)
  joins: orders.customer_id = customers.id
  group by: customers.email
Q3 SELECT: SELECT * FROM products WHERE lower(name) LIKE '%lamp%'
  WARN non-sargable: lower(name) in the WHERE clause hides the column from a normal index; ... the LIKE pattern '%lamp%' also starts with a wildcard, so only full-text search or a trigram index can help
Q4 SELECT: ... WHERE oi.order_id IN (SELECT id FROM orders WHERE customer_id = 42 AND created_at > now() - interval '30 days')
  note: subquery analyzed as Q5
Q6 UPDATE: UPDATE orders SET status = 'shipped', shipped_at = now() WHERE status = 'paid' AND shipped_at IS NULL AND crea...
Q7 SELECT: SELECT id FROM customers WHERE email = 'ann@example.com' OR phone = '+491701234567'
  WARN OR across different columns (...): one composite index cannot serve both sides; consider UNION ALL of two indexed queries or one index per side

Suggested indexes (4):
  1. CREATE INDEX CONCURRENTLY idx_customers_country_email ON customers (country, email);
  2. CREATE INDEX CONCURRENTLY idx_orders_customer_id_created_at ON orders (customer_id, created_at);
     serves: Q2, Q5 | overlap: same leading column as #3
  3. CREATE INDEX CONCURRENTLY idx_orders_customer_id_status_created_at ON orders (customer_id, status, created_at) INCLUDE (id, total);
     serves: Q1 | overlap: same leading column as #2
  4. CREATE INDEX CONCURRENTLY idx_orders_status_shipped_at_created_at ON orders (status, shipped_at, created_at);
     serves: Q6 | alternative: ... ON orders (status, created_at) WHERE shipped_at IS NULL;

Schema findings (5):
  WARN redundant-prefix  customers: idx_customers_country (country) is a leftmost prefix of idx_customers_country_created ...
  WARN low-selectivity   customers: idx_customers_active indexes only 'is_active' ...
  HIGH duplicate-index   orders: idx_orders_created and orders_created_at_idx have the same columns (created_at); drop one
  WARN low-selectivity   orders: idx_orders_status indexes only 'status' ...
  WARN many-indexes      orders: 4 existing + 3 suggested = 7 indexes (limit 6) ...
```

## Bản đánh giá (mẫu đã điền, rút gọn)

**Tóm tắt:** Bảng orders không có chỉ mục nào bắt đầu bằng cột customer_id, vì vậy trang lịch sử đơn hàng (Q1) và truy vấn tra cứu đơn hàng gần đây (Q5) phải quét hàng triệu dòng, đồng thời hai trong số bốn chỉ mục hiện có của bảng này là một chỉ mục bị trùng lặp và một chỉ mục trên cột status có độ chọn lọc thấp. Hãy thêm một chỉ mục phức hợp và một chỉ mục một phần (partial index) trên bảng orders, thêm một chỉ mục cột phone và một chỉ mục trigram ở các bảng khác, đồng thời xóa đi ba chỉ mục: bảng orders cuối cùng vẫn giữ nguyên tổng số 4 chỉ mục nhưng mọi chỉ mục đều thực sự hữu ích.

| Truy vấn | Vấn đề | Cách khắc phục |
|---|---|---|
| Q1 lịch sử đơn hàng | không có chỉ mục trên customer_id; cần sắp xếp theo created_at | đánh chỉ mục (customer_id, created_at) kèm status và total trong INCLUDE: lấy ngay các dòng mới nhất của khách hàng đó, lọc status ngay bên trong chỉ mục, LIMIT dừng lại sau 20 dòng |
| Q2 báo cáo theo quốc gia | phép join cần orders.customer_id; cột customers.country đã được phục vụ bởi idx_customers_country_created | dùng chung chỉ mục của bảng orders ở trên; không cần thêm chỉ mục mới cho bảng customers (đề xuất #1 của công cụ hỗ trợ chỉ giúp ích cho GROUP BY, mà báo cáo này chỉ chạy mỗi đêm một lần) |
| Q3 tìm kiếm sản phẩm | `lower(name) LIKE '%lamp%'` không thể dùng chỉ mục B-tree | tạo chỉ mục GIN pg_trgm trên biểu thức lower(name) |
| Q4/Q5 các mặt hàng gần đây | truy vấn con cần (customer_id, created_at); khóa chính PK (order_id, product_id) của bảng order_items đã phục vụ toán tử IN | dùng chung chỉ mục của bảng orders ở trên |
| Q6 tiến trình giao hàng | quét bảng theo cột status | tạo chỉ mục một phần (partial index) trên cột created_at chỉ dành riêng cho các đơn hàng đã thanh toán nhưng chưa giao |
| Q7 tra cứu hỗ trợ khách hàng | dùng OR giữa hai cột email và phone | tách thành UNION của hai truy vấn tra cứu; cột email đã có ràng buộc unique sẵn, chỉ cần thêm một chỉ mục cho cột phone |

Tại sao không thêm cả chỉ mục đề xuất #3 của công cụ hỗ trợ: đề xuất #2 và #3 có chung cột dẫn đầu customer_id, và trên một bảng có tới 40 lệnh insert mỗi giây thì duy trì một chỉ mục sẽ tốt hơn là hai. Người dùng đã xác nhận rằng khoảng 95 phần trăm đơn hàng ở trạng thái đã thanh toán (paid), do đó Q1 chỉ phải loại bỏ rất ít dòng, và chỉ mục (customer_id, created_at) có đính kèm status trong INCLUDE vẫn giữ cho Q1 chạy rất nhanh. Nếu tỷ lệ đơn hàng chưa thanh toán tăng lên trong tương lai, khi đó hãy chuyển sang thứ tự cột của đề xuất #3.

**Các chỉ mục cần thêm (theo đúng thứ tự này):**

```sql
CREATE INDEX CONCURRENTLY idx_orders_customer_created
    ON orders (customer_id, created_at) INCLUDE (id, status, total);        -- Q1, Q2, Q5
CREATE INDEX CONCURRENTLY idx_orders_paid_unshipped
    ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;    -- Q6, holds only paid, unshipped orders
CREATE INDEX CONCURRENTLY idx_customers_phone ON customers (phone);       -- Q7 after the rewrite
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_products_name_trgm
    ON products USING gin (lower(name) gin_trgm_ops);                      -- Q3, query unchanged
```

**Viết lại câu truy vấn (Q7):**

```sql
SELECT id FROM customers WHERE email = 'ann@example.com'
UNION
SELECT id FROM customers WHERE phone = '+491701234567';
```

**Các chỉ mục cần xóa (sau khi kiểm tra `pg_stat_user_indexes.idx_scan` trên máy chủ chính và cả hai máy chủ replica):**

| Chỉ mục | Lý do |
|---|---|
| orders_created_at_idx | trùng lặp với idx_orders_created |
| idx_orders_status | độ chọn lọc thấp; Q6 hiện đã chuyển sang dùng chỉ mục một phần (partial index) |
| idx_customers_country | là tiền tố ngoài cùng bên trái của idx_customers_country_created |
| idx_customers_active (cần kiểm tra) | kiểu boolean; chỉ giữ lại nếu idx_scan cho thấy có sử dụng thực tế |

**Tác động ròng:** bảng orders từ 4 -> 4 chỉ mục (thay thế hai chỉ mục cũ bằng hai chỉ mục tốt hơn), bảng customers từ 5 -> 5 (xóa một, thêm chỉ mục phone; hoặc giảm xuống 4 nếu xóa luôn cả is_active), bảng products từ 2 -> 3.

**Kiểm tra lại bằng công cụ hỗ trợ** (lược đồ sau khi thay đổi, các câu truy vấn sau khi viết lại; số thứ tự truy vấn tuân theo tệp mới, nên Q3 là Q5 cũ còn Q4/Q5 là hai nhánh của phép UNION):

```
$ python3 scripts/suggest_indexes.py --schema shop-schema-after.sql shop-queries-after.sql --no-covering
Already served by existing indexes (5):
  customers (email) -> customers_email_key (Q4)
  customers (id, country) -> customers_pkey (Q2)
  customers (phone) -> idx_customers_phone (Q5)
  orders (created_at) -> idx_orders_created (Q2)
  orders (customer_id, created_at) -> idx_orders_customer_created (Q2, Q3)
```
Công cụ hỗ trợ vẫn liệt kê (customer_id, status, created_at) cho Q1 và (country, email) cho Q2; cả hai đều đã được chủ đích loại bỏ vì những lý do nêu trên. Không còn phát hiện nào về chỉ mục trùng lặp hay tiền tố dư thừa nữa.

**Cách xác minh:** trước và sau mỗi lần tạo chỉ mục, hãy chạy `EXPLAIN (ANALYZE, BUFFERS)` cho Q1 với một mã khách hàng (customer id) thực tế. Kết quả kỳ vọng là "Index Scan using idx_orders_customer_created" (hoặc Index Only Scan) không còn nút Sort và chỉ đọc khoảng 20 dòng thay vì phải thực hiện Bitmap Heap Scan qua hàng nghìn dòng. Hãy theo dõi độ trễ thao tác insert trên bảng orders trong một ngày trước khi xóa các chỉ mục cũ.
FILE:scripts/suggest_indexes.py
#!/usr/bin/env python3
"""Suggest candidate indexes for slow SQL queries from a schema and the query text (no database needed).

Usage:
  python3 suggest_indexes.py --schema schema.sql queries.sql [options]
  python3 suggest_indexes.py all-in-one.sql                 (CREATE statements and queries in one file)
  python3 suggest_indexes.py --schema schema.sql - < slow.sql

Options:
  --schema FILE       CREATE TABLE / CREATE INDEX statements (may also be inside the query file)
  --dialect NAME      postgres (default), mysql, or sqlite; changes INCLUDE and online-build hints
  --max-indexes N     warn when a table would have more than N indexes (default 6)
  --no-covering       do not suggest covering (INCLUDE) columns
  --json              print the result as JSON
  --strict            exit 1 if any query has a non-sargable predicate or the schema has duplicate indexes

What it does: lightweight parsing of SELECT, UPDATE, and DELETE statements (subqueries are analyzed
separately) to find equality filters, range filters, join columns, ORDER BY and GROUP BY columns,
and selected columns per table. It builds composite candidates (equality columns, then sort, then
range), merges candidates that are prefixes of each other, skips those already served by an existing
index, and reports duplicate and redundant indexes, low-selectivity single-column indexes, and tables
with many indexes. It does not know row counts or data distribution: confirm every suggestion with
EXPLAIN on real data.
Exit codes: 0 ok, 1 --strict findings, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import signal
import sys

if hasattr(signal, "SIGPIPE"):
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)  # quiet exit when piped into head

KEYWORDS = set("""select from where and or not in is null like ilike between join inner left right full outer cross on
using group by order having limit offset as asc desc distinct union all exists case when then else end update set
delete insert into values returning with true false interval current_date current_timestamp now lateral natural
fetch first rows only for share nowait skip locked""".split())
LOW_CARD = re.compile(r"^(is_|has_|can_|should_)|^(status|state|type|kind|flag|active|enabled|deleted|archived|gender|"
                      r"visible|published|verified|priority|level|tier|role)$", re.I)
CLAUSE_END = r"(?=\b(?:where|group\s+by|order\s+by|having|limit|offset|union|returning|for\s+update|window)\b|$)"


class UsageError(Exception):
    pass


# ---------------------------------------------------------------- text helpers

def strip_comments(sql):
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
    return re.sub(r"--[^\n]*", " ", sql)


def split_statements(sql):
    out, buf, quote = [], [], None
    for ch in sql:
        if quote:
            buf.append(ch)
            if ch == quote:
                quote = None
            continue
        if ch in "'\"`":
            quote = ch
        if ch == ";":
            out.append("".join(buf))
            buf = []
        else:
            buf.append(ch)
    out.append("".join(buf))
    return [s.strip() for s in out if s.strip()]


def unquote_idents(sql):
    sql = re.sub(r'"([A-Za-z_][\w$]*)"', r"\1", sql)
    sql = re.sub(r"`([A-Za-z_][\w$]*)`", r"\1", sql)
    return re.sub(r"\[([A-Za-z_][\w$]*)\]", r"\1", sql)


def mask_strings(sql):
    """Replace string literals with placeholders, remembering LIKE patterns."""
    lits = []
    def rep(m):
        lits.append(m.group(0)[1:-1])
        return f" __lit{len(lits) - 1}__ "
    return re.sub(r"'(?:[^']|'')*'", rep, sql), lits


def split_top(text, sep_regex):
    """Split on a keyword at parenthesis depth 0."""
    parts, depth, last = [], 0, 0
    for m in re.finditer(r"\(|\)|" + sep_regex, text, flags=re.I):
        tok = m.group(0)
        if tok == "(":
            depth += 1
        elif tok == ")":
            depth -= 1
        elif depth == 0:
            parts.append(text[last:m.start()])
            last = m.end()
    parts.append(text[last:])
    return [p.strip() for p in parts if p.strip()]


def split_and(text):
    """Split on top-level AND, keeping 'x BETWEEN a AND b' together."""
    parts = split_top(text, r"\band\b")
    out = []
    for part in parts:
        if out and re.search(r"\bbetween\s+[^\s]+$", out[-1], re.I):
            out[-1] = f"{out[-1]} AND {part}"
        else:
            out.append(part)
    return out


def extract_subqueries(sql):
    """Pull out innermost (SELECT ...) blocks; return (outer_sql, [subqueries])."""
    subs = []
    pattern = re.compile(r"\(\s*(select\b[^()]*(?:\([^()]*\)[^()]*)*)\)", re.I)
    while True:
        m = pattern.search(sql)
        if not m:
            break
        subs.append(m.group(1))
        sql = sql[:m.start()] + " __subq__ " + sql[m.end():]
    return sql, subs


# ---------------------------------------------------------------- schema

def parse_columns_list(text):
    return [re.sub(r"\s+(asc|desc)\b.*$", "", c.strip(), flags=re.I).strip().lower()
            for c in split_top(text, r",")]


def parse_schema(statements):
    tables, indexes = {}, []
    for st in statements:
        s = unquote_idents(strip_comments(st)).strip()
        m = re.match(r"create\s+table\s+(?:if\s+not\s+exists\s+)?([\w.]+)\s*\((.*)\)\s*[^)]*$", s, re.I | re.S)
        if m:
            name = m.group(1).split(".")[-1].lower()
            cols, pk = [], None
            for part in split_top(m.group(2), r","):
                p = part.strip()
                low = p.lower()
                c = re.match(r"(?:constraint\s+\w+\s+)?primary\s+key\s*\(([^)]*)\)", low)
                if c:
                    pk = parse_columns_list(c.group(1))
                    continue
                c = re.match(r"(?:constraint\s+(\w+)\s+)?unique\s*(?:key\s+\w+\s*)?\(([^)]*)\)", low)
                if c:
                    indexes.append({"name": c.group(1) or f"{name}_unique", "table": name,
                                    "columns": parse_columns_list(c.group(2)), "unique": True, "kind": "unique"})
                    continue
                c = re.match(r"(?:key|index)\s+(\w+)\s*\(([^)]*)\)", low)
                if c:
                    indexes.append({"name": c.group(1), "table": name, "columns": parse_columns_list(c.group(2)),
                                    "unique": False, "kind": "index"})
                    continue
                if re.match(r"(constraint|foreign\s+key|check|exclude)\b", low):
                    continue
                col = re.match(r"([a-z_][\w$]*)\s+", low + " ")
                if col:
                    cols.append(col.group(1))
                    if re.search(r"\bprimary\s+key\b", low):
                        pk = [col.group(1)]
                    elif re.search(r"\bunique\b", low):
                        indexes.append({"name": f"{name}_{col.group(1)}_key", "table": name,
                                        "columns": [col.group(1)], "unique": True, "kind": "unique"})
            tables[name] = {"columns": cols}
            if pk:
                indexes.append({"name": f"{name}_pkey", "table": name, "columns": pk, "unique": True,
                                "kind": "primary"})
            continue
        m = re.match(r"create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(\w+)\s+on\s+"
                     r"([\w.]+)\s*(?:using\s+\w+\s*)?\(([^;]*?)\)\s*(include\s*\(([^)]*)\))?\s*(where\s+.*)?$",
                     s, re.I | re.S)
        if m:
            cols = parse_columns_list(m.group(4))
            indexes.append({"name": m.group(2), "table": m.group(3).split(".")[-1].lower(), "columns": cols,
                            "unique": bool(m.group(1)), "kind": "index",
                            "include": parse_columns_list(m.group(6)) if m.group(6) else [],
                            "partial": (m.group(7) or "").strip() or None})
    return tables, indexes


# ---------------------------------------------------------------- queries

def parse_from(from_text):
    """Return (tables {alias: table}, order list, join predicates text list)."""
    aliases, order, ons = {}, [], []
    pieces = re.split(r"\b((?:natural\s+)?(?:inner\s+|left\s+(?:outer\s+)?|right\s+(?:outer\s+)?|full\s+(?:outer\s+)?|"
                      r"cross\s+)?join)\b", from_text, flags=re.I)
    items = [pieces[0]] + [pieces[i + 1] for i in range(1, len(pieces) - 1, 2)]
    for item in items:
        on_split = re.split(r"\bon\b", item, maxsplit=1, flags=re.I)
        using = re.search(r"\busing\s*\(([^)]*)\)", on_split[0], re.I)
        head = re.sub(r"\busing\s*\([^)]*\)", "", on_split[0], flags=re.I)
        for ref in head.split(","):
            toks = [t for t in re.split(r"\s+", ref.strip()) if t and t.lower() not in ("as", "lateral", "only")]
            if not toks or toks[0] == "__subq__":
                continue
            table = toks[0].split(".")[-1].lower()
            alias = toks[1].lower() if len(toks) > 1 else table
            aliases[alias] = table
            order.append(alias)
        if len(on_split) > 1:
            ons.append(on_split[1])
        if using and len(order) >= 2:
            for col in parse_columns_list(using.group(1)):
                ons.append(f"{order[-2]}.{col} = {order[-1]}.{col}")
    return aliases, order, ons


COL = r"((?:[a-z_][\w$]*\.)?[a-z_][\w$]*)"
VALUE = r"(?:__lit\d+__|\?|:\w+|\$\d+|@\w+|%s|-?\d+(?:\.\d+)?|true|false|null|__subq__|current_date|current_timestamp|now\(\))"


def resolve(ref, aliases, tables):
    ref = ref.lower()
    if "." in ref:
        a, c = ref.split(".", 1)
        return (aliases.get(a, a), c)
    if len(set(aliases.values())) == 1:
        return (next(iter(aliases.values())), ref)
    owners = [t for t in set(aliases.values()) if ref in tables.get(t, {}).get("columns", [])]
    return (owners[0], ref) if len(owners) == 1 else (None, ref)


def analyze_predicate(pred, lits, aliases, tables, q):
    p = pred.strip()
    while p.startswith("(") and p.endswith(")") and len(split_top(p[1:-1], r"\band\b")) >= 1 and \
            p.count("(") == p.count(")") and _balanced_wrap(p):
        p = p[1:-1].strip()
    if len(split_and(p)) > 1:
        for sub in split_and(p):
            analyze_predicate(sub, lits, aliases, tables, q)
        return
    ors = split_top(p, r"\bor\b")
    if len(ors) > 1:
        cols = set()
        for o in ors:
            m = re.match(rf"^\(?\s*{COL}\s*(=|in\b)", o.strip(), re.I)
            cols.add(m.group(1).lower() if m else None)
        if len(cols) == 1 and None not in cols:
            t, c = resolve(cols.pop(), aliases, tables)
            q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "OR of equalities (same as IN)"})
        else:
            shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)
            shown = shown if len(shown) <= 70 else shown[:67] + "..."
            q["warnings"].append(f"OR across different columns ({shown}): one composite index cannot serve "
                                 "both sides; consider UNION ALL of two indexed queries or one index per side")
        return
    if re.match(r"^not\b", p, re.I) or re.search(r"(<>|!=|\bnot\s+in\b|\bnot\s+like\b)", p, re.I):
        m = re.match(rf"^(?:not\s+)?{COL}", p, re.I)
        shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)[:50]
        q["notes"].append(f"negative predicate '{shown}' rarely uses an index; it is applied as a filter")
        return
    m = re.match(rf"^{COL}\s*(=|<=>)\s*{COL}$", p, re.I)
    if m and not re.fullmatch(VALUE, m.group(3), re.I) and m.group(3).lower() not in KEYWORDS:
        left, right = resolve(m.group(1), aliases, tables), resolve(m.group(3), aliases, tables)
        if left[0] and right[0] and left[0] != right[0]:
            q["joins"].append((left, right))
            return
    m = re.match(rf"^{VALUE}\s*(=|<|>|<=|>=)\s*{COL}$", p, re.I)
    if m:
        flip = {"<": ">", ">": "<", "<=": ">=", ">=": "<=", "=": "="}
        p = f"{m.group(2)} {flip[m.group(1)]} __v__"
    m = re.match(rf"^{COL}\s*(=|<=>)\s*(.+)$", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "="})
        return
    m = re.match(rf"^{COL}\s+in\s+__subq__$", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN (subquery)"})
        return
    m = re.match(rf"^{COL}\s+in\s*\((.*)\)$", p, re.I | re.S)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN"})
        return
    m = re.match(rf"^{COL}\s+is\s+(not\s+)?null$", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        if m.group(2):
            q["notes"].append(f"'{c} IS NOT NULL' is usually not selective; a partial index can help if most rows are NULL")
        else:
            q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IS NULL"})
        return
    m = re.match(rf"^{COL}\s*(<|>|<=|>=)\s*(.+)$", p, re.I) or re.match(rf"^{COL}\s+between\s+(.+)\s+and\s+(.+)$", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        t, c = resolve(m.group(1), aliases, tables)
        q["filters"].append({"table": t, "column": c, "kind": "range", "op": "range"})
        return
    m = re.match(rf"^{COL}\s+(i?like)\s+__lit(\d+)__", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        pattern = lits[int(m.group(3))]
        if pattern.startswith(("%", "_")):
            q["warnings"].append(f"non-sargable: {c} {m.group(2).upper()} '{pattern}' starts with a wildcard, so a "
                                 "B-tree index cannot seek; use full-text search or a trigram index (PostgreSQL pg_trgm)")
            q["non_sargable"] = True
        else:
            q["filters"].append({"table": t, "column": c, "kind": "range", "op": f"LIKE '{pattern}'"})
            if m.group(2).lower() == "ilike":
                q["notes"].append(f"ILIKE on {c}: a plain B-tree index is case-sensitive; index lower({c}) instead")
        return
    m = re.match(rf"^([a-z_]+)\s*\(\s*{COL}[^)]*\)\s*(=|<|>|<=|>=|like|between)", p, re.I)
    if m and m.group(1).lower() not in KEYWORDS:
        func, col = m.group(1).lower(), m.group(2)
        t, c = resolve(col, aliases, tables)
        wild = re.search(r"like\s+__lit(\d+)__", p, re.I)
        extra = ""
        if wild and lits[int(wild.group(1))].startswith(("%", "_")):
            extra = (f"; the LIKE pattern '{lits[int(wild.group(1))]}' also starts with a wildcard, so only full-text "
                     "search or a trigram index can help")
        q["warnings"].append(f"non-sargable: {func}({c}) in the WHERE clause hides the column from a normal index; "
                             f"rewrite as a range on {c} or add an expression index on {func}({c}){extra}")
        q["non_sargable"] = True
        return
    m = re.match(rf"^{COL}\s*[-+*/]\s*\S+\s*(=|<|>|<=|>=)", p, re.I)
    if m:
        t, c = resolve(m.group(1), aliases, tables)
        q["warnings"].append(f"non-sargable: arithmetic on {c} ('{p[:40]}'); move the arithmetic to the other side")
        q["non_sargable"] = True
        return
    if "__subq__" in p and re.search(r"\bexists\b", p, re.I):
        return
    q["notes"].append(f"predicate not understood, check by hand: {p[:60]}")


def _balanced_wrap(p):
    depth = 0
    for i, ch in enumerate(p):
        depth += ch == "("
        depth -= ch == ")"
        if depth == 0 and i < len(p) - 1:
            return False
    return True


def parse_query(raw, tables, number, lits=None, outer_aliases=None):
    if lits is None:
        sql, lits = mask_strings(unquote_idents(strip_comments(raw)))
    else:
        sql = raw
    sql = re.sub(r"\s+", " ", sql).strip()
    outer, subs = extract_subqueries(sql)
    shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", re.sub(r"\s+", " ", raw.strip()))
    shown = re.sub(r"\s+", " ", shown)
    q = {"n": number, "text": shown[:110] + ("..." if len(shown) > 110 else ""), "kind": None, "aliases": {},
         "filters": [], "joins": [], "order_by": [], "group_by": [], "selected": {}, "select_star": False,
         "limit": False, "warnings": [], "notes": [], "non_sargable": False, "subqueries": subs, "lits": lits}
    low = outer.lower()
    cte_names = set()
    if low.startswith("with "):
        cte_names = {n.lower() for n in re.findall(r"(?:with|,)\s*(?:recursive\s+)?(\w+)\s+as\b", outer, re.I)}
        q["notes"].append(f"CTE {', '.join(sorted(cte_names))} analyzed as separate queries; the final SELECT reads from it")
        m = re.search(r"(?:\)|__subq__)\s*((?:select|update|delete)\b.*)$", outer, re.I)
        outer = m.group(1) if m else outer
        low = outer.lower()
    if low.startswith("select"):
        q["kind"] = "SELECT"
        m = re.match(r"select\s+(?:distinct\s+)?(.*?)\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
        if not m:
            q["notes"].append("no FROM clause found")
            return q
        select_list, from_text = m.group(1), m.group(2)
    elif low.startswith("update"):
        q["kind"] = "UPDATE"
        m = re.match(r"update\s+(.*?)\s+set\s+", outer, re.I)
        select_list, from_text = "", m.group(1) if m else ""
    elif low.startswith("delete"):
        q["kind"] = "DELETE"
        m = re.match(r"delete\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
        select_list, from_text = "", m.group(1) if m else ""
    else:
        return None
    own, _order, ons = parse_from(from_text)
    own = {a: t for a, t in own.items() if t not in cte_names}
    q["aliases"] = own
    aliases = {**(outer_aliases or {}), **own}  # outer aliases resolve correlated references
    unknown = [t for t in set(own.values()) if tables and t not in tables]
    if unknown:
        q["notes"].append(f"table(s) not in the schema: {', '.join(sorted(unknown))}; existing indexes unknown")
    for on in ons:
        for pred in split_and(on):
            analyze_predicate(pred, lits, aliases, tables, q)
    w = re.search(r"\bwhere\b(.*?)(?=\b(?:group\s+by|order\s+by|having|limit|offset|returning|for\s+update)\b|$)", outer, re.I)
    if w:
        for pred in split_and(w.group(1)):
            analyze_predicate(pred, lits, aliases, tables, q)
    for clause, key in (("group\\s+by", "group_by"), ("order\\s+by", "order_by")):
        m = re.search(rf"\b{clause}\b(.*?)(?=\b(?:having|order\s+by|limit|offset|for\s+update)\b|$)", outer, re.I)
        if m:
            for item in split_top(m.group(1), r","):
                mm = re.match(rf"^{COL}\s*(asc|desc)?(?:\s+nulls\s+(?:first|last))?$", item.strip(), re.I)
                if mm:
                    t, c = resolve(mm.group(1), aliases, tables)
                    q[key].append({"table": t, "column": c, "dir": (mm.group(2) or "asc").lower()})
                else:
                    q["notes"].append(f"{key.replace('_', ' ')} expression '{item.strip()[:40]}' cannot use a plain column index")
    q["limit"] = bool(re.search(r"\b(limit|fetch\s+first)\b", outer, re.I))
    if select_list:
        if re.search(r"(^|,)\s*(\w+\.)?\*\s*(,|$)", select_list):
            q["select_star"] = True
        for ref in re.findall(rf"\b{COL}\b(?!\s*\()", select_list, re.I):
            if ref.lower() in KEYWORDS or ref.startswith("__") or re.fullmatch(r"\d+", ref):
                continue
            t, c = resolve(ref, aliases, tables)
            if t and (not tables or c in tables.get(t, {}).get("columns", [c])):
                q["selected"].setdefault(t, [])
                if c not in q["selected"][t]:
                    q["selected"][t].append(c)
    for f in q["filters"]:
        if f["table"] is None:
            q["notes"].append(f"column '{f['column']}' could belong to several tables; qualify it with an alias")
    return q


# ---------------------------------------------------------------- candidates

def candidates_for(q, dialect, covering, pk_cols=None):
    out = []
    by_table = {}
    for f in q["filters"]:
        if f["table"]:
            by_table.setdefault(f["table"], {"eq": [], "range": []})
            lst = by_table[f["table"]][f["kind"]]
            if f["column"] not in lst:
                lst.append(f["column"])
    tables_in_q = set(q["aliases"].values())
    correlated = set()
    for left, right in q["joins"]:
        if left[0] in tables_in_q and right[0] not in tables_in_q:
            correlated.add(left[0])
        if right[0] in tables_in_q and left[0] not in tables_in_q:
            correlated.add(right[0])
    order_tables = {o["table"] for o in q["order_by"]}
    for t in tables_in_q:
        if t in correlated:
            continue  # reached through the correlated join below
        eq = by_table.get(t, {}).get("eq", [])
        rng = [c for c in by_table.get(t, {}).get("range", []) if c not in eq]
        sort = []
        if q["order_by"] and order_tables == {t} and len({o["dir"] for o in q["order_by"]}) == 1:
            sort = [o["column"] for o in q["order_by"] if o["column"] not in eq]
        elif q["group_by"] and {g["table"] for g in q["group_by"]} == {t}:
            sort = [g["column"] for g in q["group_by"] if g["column"] not in eq]
        key, why = list(eq), []
        if eq:
            why.append(f"equality on {', '.join(eq)} first")
        if sort and (not rng or q["limit"]):
            key += [c for c in sort if c not in key]
            purpose = "already sorted" if q["order_by"] else "already grouped"
            why.append(f"then {', '.join(sort)} so rows come out {purpose}" + (" (stops early with LIMIT)" if q["limit"] else ""))
            if rng and rng[0] not in key:
                key.append(rng[0])
                why.append(f"{rng[0]} last as an in-index filter")
        elif rng:
            key.append(rng[0])
            why.append(f"then the range on {rng[0]}" if eq else f"range on {rng[0]}")
            if len(rng) > 1:
                q["notes"].append(f"several range filters on {t} ({', '.join(rng)}): only the first can seek; the rest filter")
            if sort:
                q["notes"].append(f"ORDER BY on {t} cannot use the same index as the range on {rng[0]}; expect a sort step")
        if not key:
            continue
        include = []
        if covering and not q["select_star"] and q["kind"] == "SELECT":
            extra = [c for c in q["selected"].get(t, []) if c not in key
                     and not (dialect == "mysql" and c in (pk_cols or {}).get(t, []))]
            if 0 < len(extra) <= 3:
                include = extra
        out.append({"table": t, "columns": key, "include": include, "why": "; ".join(why), "queries": [q["n"]],
                    "source": "filter", "eq": list(eq),
                    "null_filter": [f["column"] for f in q["filters"] if f["table"] == t and f["op"] == "IS NULL"]})
    for left, right in q["joins"]:
        for (t, c), (ot, oc) in ((left, right), (right, left)):
            if t not in tables_in_q:
                continue  # outer table of a correlated subquery
            eq = [e for e in by_table.get(t, {}).get("eq", []) if e != c]
            rng = [r for r in by_table.get(t, {}).get("range", []) if r != c and r not in eq][:1]
            why = f"join column {t}.{c} = {ot}.{oc} first (lets the planner look up matching rows)"
            if eq or rng:
                why += f", then {', '.join(eq + rng)} to filter inside the index"
            if t in correlated:
                why = f"correlated lookup {t}.{c} = outer {ot}.{oc} first" + (f", then {', '.join(eq + rng)}" if eq or rng else "")
            out.append({"table": t, "columns": [c] + eq + rng, "include": [], "queries": [q["n"]], "source": "join",
                        "eq": [c] + eq, "why": why})
    return out


def served_by(cand, indexes):
    for ix in indexes:
        if ix["table"] != cand["table"]:
            continue
        cols = ix["columns"]
        if cand["source"] == "join" and ix["unique"] and cols == cand["columns"][:1] and not ix.get("partial"):
            return ix  # joining on a unique key: one row per lookup, extra columns add little
        if cols[:len(cand["columns"])] == cand["columns"]:
            return ix
        n_eq = len(cand.get("eq", []))
        if n_eq > 1 and len(cols) >= len(cand["columns"]) and set(cols[:n_eq]) == set(cand["columns"][:n_eq]) \
                and cols[n_eq:len(cand["columns"])] == cand["columns"][n_eq:]:
            return ix  # same equality columns in another order serve the query equally well
    return None


def served_by_named(c, indexes):
    ix = served_by(c, indexes)
    return {"existing": ix["name"] if ix else "?"}


def merge(cands):
    merged = []
    for c in sorted(cands, key=lambda c: -len(c["columns"])):
        for m in merged:
            if m["table"] == c["table"] and m["columns"][:len(c["columns"])] == c["columns"]:
                m["queries"] = sorted(set(m["queries"]) | set(c["queries"]))
                m["include"] = m["include"] or []
                break
        else:
            merged.append(dict(c))
    return sorted(merged, key=lambda c: (c["table"], c["columns"]))


def schema_findings(indexes, max_indexes, new_by_table):
    out = []
    by_table = {}
    for ix in indexes:
        by_table.setdefault(ix["table"], []).append(ix)
    for t, ixs in by_table.items():
        for i, a in enumerate(ixs):
            for b in ixs[i + 1:]:
                if a["columns"] == b["columns"] and (a.get("partial") == b.get("partial")):
                    out.append({"level": "high", "rule": "duplicate-index",
                                "message": f"{t}: {a['name']} and {b['name']} have the same columns ({', '.join(a['columns'])}); drop one"})
                elif a["columns"] != b["columns"]:
                    short, long_ = (a, b) if len(a["columns"]) < len(b["columns"]) else (b, a)
                    if long_["columns"][:len(short["columns"])] == short["columns"] and not short["unique"] \
                            and not short.get("partial"):
                        out.append({"level": "warn", "rule": "redundant-prefix",
                                    "message": f"{t}: {short['name']} ({', '.join(short['columns'])}) is a leftmost prefix of "
                                               f"{long_['name']} ({', '.join(long_['columns'])}); usually droppable"})
        for ix in ixs:
            if len(ix["columns"]) == 1 and LOW_CARD.search(ix["columns"][0]) and ix["kind"] == "index" and not ix.get("partial"):
                out.append({"level": "warn", "rule": "low-selectivity",
                            "message": f"{t}: {ix['name']} indexes only '{ix['columns'][0]}', which sounds like a flag or status "
                                       "with few values; use a composite or partial index instead"})
        total = len(ixs) + new_by_table.get(t, 0)
        if total > max_indexes:
            out.append({"level": "warn", "rule": "many-indexes",
                        "message": f"{t}: {len(ixs)} existing + {new_by_table.get(t, 0)} suggested = {total} indexes "
                                   f"(limit {max_indexes}); every insert and update maintains all of them"})
    return out


def ddl(c, dialect):
    name = f"idx_{c['table']}_{'_'.join(c['columns'])}"[:63]
    cols = ", ".join(c["columns"])
    if c["include"]:
        if dialect == "postgres":
            return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols}) INCLUDE ({', '.join(c['include'])});"
        cols += ", " + ", ".join(c["include"])
    if dialect == "postgres":
        return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols});"
    if dialect == "mysql":
        return f"ALTER TABLE {c['table']} ADD INDEX {name} ({cols}), ALGORITHM=INPLACE, LOCK=NONE;"
    return f"CREATE INDEX {name} ON {c['table']} ({cols});"


# ---------------------------------------------------------------- main

def parse_args(argv):
    opts = {"schema": None, "files": [], "dialect": "postgres", "max_indexes": 6, "covering": True,
            "json": False, "strict": False}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("-h", "--help"):
            print(__doc__)
            sys.exit(0)
        if a in ("--schema", "--dialect", "--max-indexes"):
            if i + 1 >= len(argv):
                raise UsageError(f"{a} needs a value")
            v = argv[i + 1]
            if a == "--schema":
                opts["schema"] = v
            elif a == "--dialect":
                if v not in ("postgres", "mysql", "sqlite"):
                    raise UsageError("--dialect must be postgres, mysql, or sqlite")
                opts["dialect"] = v
            else:
                try:
                    opts["max_indexes"] = int(v)
                except ValueError:
                    raise UsageError(f"--max-indexes needs a whole number, got {v!r}")
            i += 2
        elif a == "--no-covering":
            opts["covering"] = False; i += 1
        elif a == "--json":
            opts["json"] = True; i += 1
        elif a == "--strict":
            opts["strict"] = True; i += 1
        elif a.startswith("--"):
            raise UsageError(f"unknown option {a}")
        else:
            opts["files"].append(a); i += 1
    if not opts["files"] and not opts["schema"]:
        raise UsageError("give a query file (or - for stdin), optionally with --schema FILE")
    return opts


def read(path):
    if path == "-":
        return sys.stdin.read()
    with open(path, encoding="utf-8") as f:
        return f.read()


def main(argv):
    try:
        opts = parse_args(argv)
        schema_text = read(opts["schema"]) if opts["schema"] else ""
        query_texts = [read(p) for p in opts["files"]]
    except UsageError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    except OSError as e:
        print(f"error: cannot read {e.filename}: {e.strerror}", file=sys.stderr)
        return 2
    statements = split_statements(strip_comments(schema_text))
    query_stmts = []
    for text in query_texts:
        clean = strip_comments(text)
        for st in split_statements(clean):
            (statements if re.match(r"\s*create\s", st, re.I) else query_stmts).append(st)
    tables, indexes = parse_schema(statements)
    queries, n = [], 0
    expanded = []
    for st in query_stmts:
        masked, _l = mask_strings(st)
        if len(split_top(masked, r"\bunion(?:\s+all)?\b")) > 1:  # a real top-level UNION, not one inside a string
            expanded.extend(split_top(st, r"\bunion(?:\s+all)?\b"))  # each branch is analyzed as its own query
        else:
            expanded.append(st)
    for st in expanded:
        pending = [(st, None, None)]
        while pending:
            cur, lits, outer = pending.pop(0)
            n += 1
            q = parse_query(cur, tables, n, lits, outer)
            if q is None:
                n -= 1
                continue
            queries.append(q)
            for sub in q["subqueries"]:
                pending.append((sub, q["lits"], {**(outer or {}), **q["aliases"]}))
                q["notes"].append(f"subquery analyzed as Q{n + len(pending)}")
    if not queries:
        print("error: no SELECT, UPDATE, or DELETE statements found", file=sys.stderr)
        return 2
    pks = {ix["table"]: ix["columns"] for ix in indexes if ix["kind"] == "primary"}
    cands = []
    for q in queries:
        cands += candidates_for(q, opts["dialect"], opts["covering"], pks)
    covered, open_cands = [], []
    for c in cands:
        ix = served_by(c, indexes)
        if ix:
            covered.append({**c, "existing": ix["name"]})
        else:
            open_cands.append(c)
    suggested = merge(open_cands)
    covered = merge(covered)
    for c in covered:
        c["existing"] = next(x["existing"] for x in [served_by_named(c, indexes)])
    new_by_table = {}
    for c in suggested:
        new_by_table[c["table"]] = new_by_table.get(c["table"], 0) + 1
        c["ddl"] = ddl(c, opts["dialect"])
        lead = c["columns"][0]
        c["tradeoff"] = ("every INSERT, UPDATE of these columns, and DELETE on " + c["table"] +
                         " also writes this index; size grows with the row count and column widths")
        if LOW_CARD.search(lead) and len(c["columns"]) == 1:
            c["tradeoff"] += f"; '{lead}' looks low-selectivity, so the planner may still prefer a scan"
    for a in suggested:
        others = [k for k, b in enumerate(suggested, 1) if b is not a and b["table"] == a["table"]
                  and b["columns"][0] == a["columns"][0]]
        a["overlaps"] = others
        a["makes_redundant"] = [ix["name"] for ix in indexes if ix["table"] == a["table"] and not ix["unique"]
                                and ix["kind"] != "primary" and len(ix["columns"]) < len(a["columns"])
                                and a["columns"][:len(ix["columns"])] == ix["columns"]]
        if a.get("null_filter") and opts["dialect"] == "postgres":
            col = a["null_filter"][0]
            rest = [c for c in a["columns"] if c != col]
            name = f"idx_{a['table']}_{'_'.join(rest)}_{col}_null"[:63]
            a["partial_hint"] = (f"CREATE INDEX CONCURRENTLY {name} ON {a['table']} ({', '.join(rest)}) "
                                 f"WHERE {col} IS NULL;  -- smaller if few rows match")
    findings = schema_findings(indexes, opts["max_indexes"], new_by_table)
    report = {"dialect": opts["dialect"], "tables": {t: len(v["columns"]) for t, v in tables.items()},
              "existing_indexes": indexes, "queries": [{k: v for k, v in q.items() if k not in ("subqueries", "lits")}
                                                       for q in queries],
              "suggested": suggested, "already_covered": covered, "schema_findings": findings}
    for q in report["queries"]:
        q["joins"] = [f"{a[0]}.{a[1]} = {b[0]}.{b[1]}" for a, b in q["joins"]]
    strict_fail = opts["strict"] and (any(q["non_sargable"] for q in queries) or
                                      any(f["rule"] == "duplicate-index" for f in findings))
    if opts["json"]:
        print(json.dumps(report, indent=2))
        return 1 if strict_fail else 0
    print(f"Schema: {len(tables)} table(s), {len(indexes)} existing index(es) incl. keys | dialect: {opts['dialect']}")
    print(f"Queries analyzed: {len(queries)}")
    for q in report["queries"]:
        print(f"\nQ{q['n']} {q['kind']}: {q['text']}")
        tabs = ", ".join(f"{t} {a}" if a != t else t for a, t in q["aliases"].items())
        print(f"  tables: {tabs or '-'}")
        eqs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "eq"]
        rngs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "range"]
        if eqs:
            print(f"  equality: {', '.join(eqs)}")
        if rngs:
            print(f"  range: {', '.join(rngs)}")
        if q["joins"]:
            print(f"  joins: {', '.join(q['joins'])}")
        if q["order_by"]:
            print("  order by: " + ", ".join(f"{o['table']}.{o['column']} {o['dir'].upper()}" for o in q["order_by"])
                  + ("  (LIMIT)" if q["limit"] else ""))
        if q["group_by"]:
            print("  group by: " + ", ".join(f"{g['table']}.{g['column']}" for g in q["group_by"]))
        for w in q["warnings"]:
            print(f"  WARN {w}")
        for nt in q["notes"]:
            print(f"  note: {nt}")
    print(f"\nSuggested indexes ({len(suggested)}):")
    if not suggested:
        print("  none: existing indexes already lead with the needed columns")
    for k, c in enumerate(suggested, 1):
        print(f"  {k}. {c['ddl']}")
        print(f"     serves: {', '.join('Q' + str(x) for x in c['queries'])} | why: {c['why']}")
        if c["include"]:
            print(f"     covering: {', '.join(c['include'])} added so the query can skip the table (optional)")
        print(f"     tradeoff: {c['tradeoff']}")
        if c.get("overlaps"):
            print(f"     overlap: same leading column as #{', #'.join(map(str, c['overlaps']))}; "
                  "check whether one index can serve both before creating two")
        if c.get("makes_redundant"):
            print(f"     makes redundant: {', '.join(c['makes_redundant'])} (leftmost prefix of this index; drop after checking usage)")
        if c.get("partial_hint"):
            print(f"     alternative: {c['partial_hint']}")
    if covered:
        print(f"\nAlready served by existing indexes ({len(covered)}):")
        for c in covered:
            print(f"  {c['table']} ({', '.join(c['columns'])}) -> {c['existing']} (Q{', Q'.join(map(str, c['queries']))})")
    print(f"\nSchema findings ({len(findings)}):")
    if not findings:
        print("  none")
    for f in findings:
        print(f"  {f['level'].upper():<4} {f['rule']:<17} {f['message']}")
    print("\nNext: run EXPLAIN (ANALYZE, BUFFERS) or EXPLAIN on real data before and after; build one index at a time.")
    return 1 if strict_fail else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PythonSQLDatabase+2
F@f
0
Cố Vấn Tăng Phiên Bản SemVer (SemVer Bump Advisor)
Kỹ năng

Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.

---
name: semver-bump-advisor
description: Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.
---

# Cố Vấn Tăng Phiên Bản SemVer (SemVer Bump Advisor)

Bạn giúp các nhóm phát triển chọn phiên bản Semantic Version tiếp theo dựa trên các tín hiệu thay đổi thực tế: dòng tiêu đề Conventional Commit, đoạn changelog ngắn, hoặc danh sách rõ ràng các loại thay đổi. Bạn tách bạch rõ mức patch (sửa lỗi, tài liệu, công việc bảo trì không làm thay đổi API công khai), minor (tính năng mới tương thích ngược), và major (thay đổi phá vỡ tính tương thích - breaking changes), sau đó hiển thị chuỗi phiên bản kết quả. Bạn không tự bịa ra ghi chú phát hành (release notes) mà người dùng không hàm ý.

## Các tệp trong kỹ năng này

- `scripts/semver_bump.py` - phân tích các commit hoặc danh sách dạng CHANGELOG, chấm điểm mức tăng cao nhất cần thiết và in bảng giải thích lý do (chỉ dùng thư viện chuẩn Python 3)
- `references/conventional-commits-semver.md` - cách ánh xạ các loại commit sang SemVer, bao gồm `BREAKING CHANGE` và `!`
- `references/edge-cases.md` - các quy tắc cho phiên bản trước 1.0 (pre-1.0), siêu dữ liệu bản dựng (build metadata), đa gói (multiple packages), và khi nào không cần tăng phiên bản
- `templates/bump-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-library-release.md` - ví dụ minh họa chi tiết từ một lần phát hành thư viện nhỏ

## Quy trình làm việc

### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Phiên bản hiện tại (`MAJOR.MINOR.PATCH`, tùy chọn hậu tố tiền phát hành pre-release).
- Các tín hiệu thay đổi: các dòng Conventional Commit, changelog kể từ tag gần nhất, hoặc các gạch đầu dòng đã gắn nhãn (fix / feat / breaking).
- Liệu dự án có đang ở giai đoạn `0.x` hay không (sẽ có các kỳ vọng khác) và liệu API công khai ở đây có nghĩa là các cờ CLI, các route HTTP, hay các hàm/đối tượng export của thư viện.

### 2. Chạy công cụ cố vấn
```bash
python3 scripts/semver_bump.py 1.4.2 --commits commits.txt
python3 scripts/semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
python3 scripts/semver_bump.py 2.0.0-rc.1 --json < changes.txt
```
Một tệp chứa các commit gồm mỗi dòng một tiêu đề (tùy chọn tiền tố `type(scope):`). Các dòng có thể bao gồm phần chân trang (footer) `BREAKING CHANGE:` ở các dòng thụt lề tiếp theo, hoặc dấu `!` ngay sau loại commit. Luôn hiển thị kết quả đầu ra của công cụ, sau đó giải thích lại bằng ngôn ngữ dễ hiểu.

### 3. Tư vấn
1. Nêu rõ mức tăng phiên bản cao nhất cần thiết và lý do tại sao (trích dẫn commit hoặc dòng thay đổi mang tính quyết định).
2. Hiển thị chuỗi phiên bản tiếp theo, bao gồm cả cách đặt lại (reset) các phiên bản tiền phát hành (pre-release) khi chuyển lên bản phát hành ổn định (stable) nếu có liên quan.
3. Chỉ ra các dòng còn mơ hồ (ví dụ như `refactor` có khả năng gây lỗi tương thích) và yêu cầu người dùng xác nhận mức độ ảnh hưởng tới API khi cần thiết.
4. Trả kết quả theo mẫu `templates/bump-report.md`, tuân theo cách trình bày của `examples/example-library-release.md`.

## Quy tắc
- Không bao giờ bỏ qua việc tăng phiên bản major khi rõ ràng có mặt một thay đổi gây lỗi tương thích (breaking change), trừ khi người dùng chủ động chọn cách xử lý tăng nhẹ (soft-major) cho giai đoạn trước phiên bản 1.0.
- Không tự bịa ra các commit; nếu danh sách trống, hãy nói rõ và từ chối đoán mò.
- Giữ phần siêu dữ liệu bản dựng (`+...`) nằm ngoài phép tính tăng phiên bản; chỉ loại bỏ hoặc giữ lại phần đó theo đúng yêu cầu của người dùng.
- Ưu tiên bảng ánh xạ Conventional Commits trong `references/conventional-commits-semver.md` hơn là các nhãn tự phát.
FILE:references/conventional-commits-semver.md
# Conventional Commits → SemVer

## Ánh xạ loại commit (mặc định)

| Tín hiệu commit | Mức tăng SemVer |
|---|---|
| `fix`, `docs`, `style`, `test`, `chore`, `ci`, `perf` (không gây lỗi tương thích) | **patch** |
| `feat` (không gây lỗi tương thích) | **minor** |
| Phần chân trang `BREAKING CHANGE`, hoặc `type!:` / `type(scope)!:` | **major** |
| `refactor`, `build`, `revert` không có dấu hiệu gây lỗi tương thích | **patch** (xác nhận lại nếu API công khai bị thay đổi) |

Khi có nhiều commit xuất hiện, lấy mức tăng **cao nhất**: major > minor > patch.

## Dấu hiệu thay đổi gây lỗi tương thích (Breaking change markers)
1. Một dòng ở phần chân trang (footer) bắt đầu bằng `BREAKING CHANGE:` hoặc `BREAKING-CHANGE:`.
2. Dấu `!` nằm ngay trước dấu hai chấm: `feat!: ...` hoặc `feat(api)!: ...`.

## Phạm vi (Scopes)
Bản thân phần phạm vi (scope) không làm thay đổi mức tăng phiên bản. `feat(cli): add --json` vẫn là mức minor. `fix(docs): typo` vẫn là mức patch.

## Các phiên bản tiền phát hành (Pre-release versions)
Giả sử phiên bản hiện tại là `1.2.0-rc.3`:
- Một thay đổi ở mức patch khi vẫn đang trong giai đoạn RC thường sẽ trở thành `1.2.0-rc.4` nếu bạn tiếp tục duy trì trên kênh RC.
- Việc chuyển lên bản ổn định (stable) sau giai đoạn RC là quyết định về mặt sản phẩm: thường sẽ là `1.2.0` (chứ không phải `1.2.1`) khi bản RC đó vốn đã đại diện cho bản minor đó rồi.

Script của kỹ năng này xử lý phần nhân số học `MAJOR.MINOR.PATCH` cho việc tính toán tăng phiên bản và in riêng các ghi chú hướng dẫn xử lý hậu tố pre-release.
FILE:references/edge-cases.md
# Các trường hợp biên (Edge cases)

## Giai đoạn trước 1.0 (`0.x.y`)
SemVer cho phép các thay đổi gây lỗi tương thích trong nhánh `0.x` mà không cần tăng phiên bản major, nhưng nhiều nhóm vẫn dùng quy tắc `feat` → minor và breaking → major để đảm bảo sự rõ ràng. Hãy đưa ra cả hai lựa chọn:
- Tuân thủ linh hoạt theo SemVer: thay đổi gây lỗi tương thích trong giai đoạn `0.x` có thể chỉ cần tăng mức minor.
- Chính sách nội bộ của nhóm: coi thay đổi gây lỗi tương thích là mức major ngay cả trước phiên bản 1.0 (tắt cờ `--pre-1-0-major-as-minor`).

## Đa gói (Multiple packages)
Các kho lưu trữ kiểu monorepo có thể tăng phiên bản cho từng gói một cách độc lập. Hãy chạy công cụ cố vấn một lần cho mỗi gói chỉ với các commit thuộc về gói đó.

## Các bản refactor mơ hồ
`refactor: rename export Foo to Bar` là thay đổi gây lỗi tương thích đối với các thư viện ngay cả khi không có dấu `!`. Hãy cảnh báo và hỏi lại người dùng: nếu API công khai bị đổi tên → tăng major; nếu chỉ thay đổi nội bộ → tăng patch.

## Các dòng trống hoặc không theo chuẩn Conventional
Các câu mô tả thông thường không có tiền tố phân loại vẫn được tính nếu chúng ghi rõ là "breaking" hoặc "add feature"; nếu không, hãy đánh dấu chúng là **unknown** (chưa rõ) và hỏi lại người dùng.

## Siêu dữ liệu bản dựng (Build metadata)
`1.2.3+build.9` — hãy tăng phần số phiên bản cốt lõi, sau đó quyết định xem nên bỏ đi hay làm mới phần `+build...`. Không để siêu dữ liệu ảnh hưởng đến phép tính major/minor/patch.
FILE:templates/bump-report.md
# Báo cáo tăng phiên bản SemVer

## Phiên bản hiện tại
- …

## Mức tăng khuyến nghị
- Cấp độ: patch | minor | major
- Phiên bản tiếp theo: …
- Thay đổi mang tính quyết định: …

## Bảng thống kê thay đổi
| Cấp độ | Số lượng | Ví dụ |
|---|---|---|
| major | | |
| minor | | |
| patch | | |
| unknown | | |

## Ghi chú và câu hỏi
- …

## Lệnh gắn thẻ (tag) / phát hành đề xuất
```bash
# example
git tag -a vX.Y.Z -m "…"
```
FILE:examples/example-library-release.md
# Ví dụ: phát hành thư viện từ các Conventional Commits

**Phiên bản hiện tại:** `1.4.2`

**Các commit kể từ bản v1.4.2:**
```
fix: handle empty Accept headers
feat(api): add cursor pagination
docs: fix README typo
feat!: remove deprecated /v1/list route
```

**Kết quả từ công cụ cố vấn:** major → `2.0.0` do có commit `feat!: remove deprecated /v1/list route`.

**Lý do:** Dấu `!` đánh dấu việc gỡ bỏ một API gây lỗi tương thích ngược. Riêng tính năng phân trang mới chỉ dừng ở mức minor (`1.5.0`), và riêng phần sửa lỗi/tài liệu chỉ ở mức patch (`1.4.3`), nhưng mức major được ưu tiên cao nhất.

**Trích đoạn báo cáo:** Khuyến nghị phát hành bản `2.0.0` kèm theo hướng dẫn chuyển đổi (migration note) cho các bên đang gọi `/v1/list`; đồng thời nêu bật tính năng phân trang bằng con trỏ (cursor pagination) như là tính năng tiêu điểm của bản cập nhật major này.
FILE:scripts/semver_bump.py
#!/usr/bin/env python3
"""Recommend a SemVer bump from Conventional Commit subjects or change lines.

Usage:
  python3 semver_bump.py 1.4.2 --commits commits.txt
  python3 semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
  python3 semver_bump.py 2.0.0 --json < changes.txt
"""
from __future__ import annotations

import argparse
import json
import re
import sys

TYPE_PATCH = {"fix", "docs", "style", "test", "chore", "ci", "perf", "build", "refactor", "revert"}
TYPE_MINOR = {"feat"}
COMMIT_RE = re.compile(
    r"^(?P<type>[a-zA-Z]+)(?P<scope>\([^)]*\))?(?P<breaking>!)?:\s*(?P<sub>.+)$"
)
VER_RE = re.compile(
    r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
    r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)
LEVELS = {"none": 0, "patch": 1, "minor": 2, "major": 3}


def parse_version(v: str) -> dict:
    m = VER_RE.match(v.strip())
    if not m:
        raise SystemExit(f"error: not a SemVer core version: {v!r}")
    return {k: m.group(k) for k in ("major", "minor", "patch", "pre", "build")}


def classify_line(line: str) -> tuple[str, str]:
    raw = line.strip()
    if not raw or raw.startswith("#"):
        return "skip", raw
    lower = raw.lower()
    if lower.startswith("breaking change:") or lower.startswith("breaking-change:"):
        return "major", raw
    m = COMMIT_RE.match(raw)
    if m:
        if m.group("breaking"):
            return "major", raw
        t = m.group("type").lower()
        if t in TYPE_MINOR:
            return "minor", raw
        if t in TYPE_PATCH:
            return "patch", raw
        return "unknown", raw
    if "breaking" in lower:
        return "major", raw
    if lower.startswith("feat") or "new feature" in lower or lower.startswith("add "):
        return "minor", raw
    if lower.startswith("fix") or "bug" in lower:
        return "patch", raw
    return "unknown", raw


def read_lines(path: str) -> list[str]:
    if path == "-":
        return sys.stdin.read().splitlines()
    with open(path, encoding="utf-8") as f:
        return f.read().splitlines()


def bump(ver: dict, level: str, pre10_soft: bool) -> str:
    major, minor, patch = int(ver["major"]), int(ver["minor"]), int(ver["patch"])
    if level == "none":
        core = f"{major}.{minor}.{patch}"
    elif level == "major":
        if pre10_soft and major == 0:
            minor += 1
            patch = 0
        else:
            major += 1
            minor = 0
            patch = 0
        core = f"{major}.{minor}.{patch}"
    elif level == "minor":
        minor += 1
        patch = 0
        core = f"{major}.{minor}.{patch}"
    else:
        patch += 1
        core = f"{major}.{minor}.{patch}"
    return core


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("version", help="Current SemVer, e.g. 1.4.2 or 0.9.1-rc.1")
    ap.add_argument("--commits", default="-", help="File of commit subjects (default: stdin)")
    ap.add_argument("--pre-1-0-major-as-minor", action="store_true",
                    help="In 0.x, treat breaking as minor bump")
    ap.add_argument("--json", action="store_true", help="Machine-readable JSON output")
    args = ap.parse_args(argv)

    ver = parse_version(args.version)
    lines = read_lines(args.commits)
    tallies = {"major": [], "minor": [], "patch": [], "unknown": []}
    best = "none"
    for line in lines:
        level, raw = classify_line(line)
        if level == "skip":
            continue
        tallies[level].append(raw)
        if LEVELS[level] > LEVELS[best]:
            best = level

    if best == "none":
        print("error: no usable change lines", file=sys.stderr)
        return 1

    next_v = bump(ver, best, args.pre_1_0_major_as_minor)
    decisive = (tallies[best][0] if tallies[best] else "")
    note = None
    if args.pre_1_0_major_as_minor and int(ver["major"]) == 0 and best == "major":
        note = "pre-1.0 soft major: breaking treated as minor bump"
        # best label stays major for tally, but version used soft path
    result = {
        "current": args.version,
        "bump": best,
        "next": next_v,
        "decisive": decisive,
        "tally": {k: len(v) for k, v in tallies.items()},
        "examples": {k: v[:5] for k, v in tallies.items() if v},
        "note": note,
    }
    if args.json:
        json.dump(result, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        print(f"current: {args.version}")
        print(f"bump:    {best}")
        print(f"next:    {next_v}")
        if note:
            print(f"note:    {note}")
        print(f"decisive:{decisive}")
        print("tally:")
        for k in ("major", "minor", "patch", "unknown"):
            print(f"  {k}: {len(tallies[k])}")
            for ex in tallies[k][:5]:
                print(f"    - {ex}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
Best PracticesDevOpsCode Review+3
F@f
0
Cố vấn Mã trạng thái HTTP
Kỹ năng

Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.

---
name: http-status-code-advisor
description: Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.
---

# Cố vấn Mã trạng thái HTTP

Bạn giúp các nhà thiết kế và người đánh giá API chọn mã trạng thái HTTP phù hợp nhất với những gì thực sự đã xảy ra trong một yêu cầu (request). Bạn lập luận dựa trên phương thức yêu cầu (request method), kết quả đầu ra và bên nào có lỗi (máy khách, máy chủ hay dịch vụ thượng nguồn - upstream), đồng thời ưu tiên mã tiêu chuẩn cụ thể nhất mà máy khách có thể dựa vào đó để xử lý. Bạn tuân thủ ngữ nghĩa RFC 9110 và không tự bịa ra các mã phi tiêu chuẩn.

## Các tệp trong kỹ năng này

- `scripts/suggest_status.py` - ánh xạ một mô tả tình huống ngắn gọn (các từ khóa cộng với phương thức) sang một mã được đề xuất, các phương án thay thế và lý do dài một dòng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/status-cheatsheet.md` - các mã quan trọng đối với API, được nhóm theo phân lớp (class), kèm theo thời điểm sử dụng từng mã
- `references/common-mistakes.md` - các cách dùng sai phổ biến (trả về 200 kèm nội dung báo lỗi, nhầm lẫn 401 với 403, dùng 404 cho danh sách trống, dùng 500 cho lỗi xác thực dữ liệu)
- `templates/decision-tree.md` - luồng câu hỏi từng bước để đi đến mã phù hợp
- `examples/api-scenarios.md` - các tình huống thực tế đã được giải quyết kèm theo mã được chọn và cấu trúc phản hồi

## Quy trình làm việc

### 1. Thu thập thông tin thực tế
Yêu cầu cung cấp, hoặc suy luận từ mã nguồn hay mô tả:
- Phương thức HTTP và endpoint (ví dụ: `POST /orders`)
- Chuyện gì đã xảy ra: thành công, thành công một phần, đã chấp nhận xử lý bất đồng bộ, sự cố đầu vào, sự cố xác thực/phân quyền, thiếu tài nguyên, xung đột trạng thái, giới hạn tần suất (rate limit), lỗi máy chủ hoặc dịch vụ phụ thuộc
- Có trả về phần thân phản hồi (response body) hay không, và có tài nguyên mới nào được tạo ra hay không
- Bất kỳ quy ước API hiện có nào mà nhóm đang tuân theo

### 2. Duyệt qua cây quyết định
Sử dụng `templates/decision-tree.md`. Quyết định phân lớp trước (2xx, 3xx, 4xx, 5xx), sau đó mới đến mã cụ thể. Bên nào gây ra lỗi sẽ quyết định việc chọn 4xx hay 5xx.

### 3. Chạy công cụ hỗ trợ (tùy chọn)
```
python3 scripts/suggest_status.py --method POST "created new order"
python3 scripts/suggest_status.py --method GET "user not logged in"
python3 scripts/suggest_status.py --json "email already exists"
```
Hãy coi kết quả của script như một dự đoán ban đầu; cần xác nhận lại với bảng tra cứu nhanh (cheatsheet).

### 4. Kiểm tra các lỗi phổ biến
Đối chiếu lựa chọn với `references/common-mistakes.md`, đặc biệt là 401 so với 403, 400 so với 422, và 404 so với danh sách trống trả về mã 200.

### 5. Trả lời
Trả về:
1. **Mã được đề xuất** và cụm từ lý do (ví dụ: `409 Conflict`)
2. **Tại sao** trong một hoặc hai câu, gắn liền với các thông tin thực tế
3. **Các phương án thay thế** đã cân nhắc và lý do chúng không được chọn
4. **Các header cần bao gồm** khi có liên quan (`Location` cho 201, `Retry-After` cho 429/503, `WWW-Authenticate` cho 401, `Allow` cho 405)
5. **Phác thảo phần thân báo lỗi** sử dụng chuẩn RFC 9457 Problem Details (`type`, `title`, `status`, `detail`, `instance`) đối với các mã 4xx/5xx

## Quy tắc

- Không bao giờ trả về 2xx cho một thao tác thất bại.
- Chỉ sử dụng 5xx khi máy chủ hoặc dịch vụ thượng nguồn mà nó phụ thuộc vào gặp sự cố, không bao giờ dùng cho dữ liệu đầu vào sai từ phía máy khách.
- Giữ sự nhất quán với các quy ước hiện có của nhóm nếu chúng hợp lý; chỉ cần nhắc đến lựa chọn tiêu chuẩn một lần nếu có sự khác biệt.
- Nếu yêu cầu bảo mật đòi hỏi phải che giấu sự tồn tại của một tài nguyên, hãy nói rõ điều đó khi đề xuất dùng 404 thay vì 403.
FILE:references/common-mistakes.md
# Các lỗi Mã trạng thái phổ biến

1. **Trả về 200 kèm phần thân báo lỗi** (`{"success": false}`). Các máy khách, proxy, bộ nhớ đệm (caches) và hệ thống giám sát đều coi 200 là thành công. Hãy sử dụng đúng mã 4xx/5xx và đưa chi tiết lỗi vào phần thân (body).

2. **Nhầm lẫn giữa 401 và 403.** 401 có nghĩa là "bạn là ai?" (thiếu thông tin xác thực hoặc thông tin xác thực sai). 403 có nghĩa là "tôi biết bạn là ai, và câu trả lời là không được phép." Một người dùng đã đăng nhập nhưng không có quyền sẽ nhận được 403, không phải 401.

3. **Dùng 500 cho đầu vào không hợp lệ.** Lỗi xác thực dữ liệu (validation) là lỗi của phía máy khách: hãy dùng 400 hoặc 422. Dành riêng mã 500 cho các lỗi phần mềm (bugs) và sự cố máy chủ ngoài dự kiến. Sự gia tăng đột biến của mã 500 phải mang ý nghĩa là có thứ gì đó đang bị hỏng ở phía hệ thống của bạn.

4. **Dùng 404 cho một tập hợp trống.** `GET /users?role=admin` khi không có kết quả khớp phải là `200` kèm theo `[]`. Mã 404 chỉ dành cho một tài nguyên cụ thể bị thiếu, chẳng hạn như `/users/9999`.

5. **Dùng 400 cho mọi thứ.** Ưu tiên sử dụng mã cụ thể khi nó giúp máy khách biết cách xử lý: 409 cho dữ liệu trùng lặp, 413 cho dữ liệu vượt quá kích thước, 415 cho sai kiểu nội dung (content type), 429 cho giới hạn tần suất (rate limits).

6. **Dùng 201 mà không có `Location`.** Khi bạn tạo một tài nguyên có URL riêng, hãy gửi kèm `Location: /orders/123`.

7. **Dùng 204 kèm theo phần thân (body).** Mã 204 không được phép chứa nội dung trả về. Nếu bạn trả về tài nguyên, hãy sử dụng 200.

8. **Dùng 302 trong API.** Các máy khách có thể tự động đổi phương thức POST thành GET. Hãy sử dụng 307 hoặc 308 khi cần giữ nguyên phương thức gọi.

9. **503 so với 500.** 503 có nghĩa là sự cố tạm thời và có thể thử lại (đang bảo trì, quá tải), lý tưởng nhất là đi kèm `Retry-After`. 500 có nghĩa là một lỗi bất ngờ không lường trước.

10. **Làm lộ sự tồn tại của tài nguyên qua mã 403.** Đối với các tài nguyên riêng tư, việc trả về 404 cho người dùng không có quyền có thể ngăn chặn hành vi dò quét liệt kê (enumeration). Hãy quyết định một cách có chủ đích và ghi lại vào tài liệu.

11. **Sử dụng 422 và 400 thiếu nhất quán.** Hãy chọn một quy tắc (400 = không thể phân tích cú pháp, 422 = đã phân tích cú pháp nhưng giá trị không hợp lệ) và áp dụng nó trên mọi endpoint.

12. **Các mã phi tiêu chuẩn** (ví dụ: dùng 418 trên môi trường production, 499, các mã 6xx tự chế). Nhiều máy khách và proxy xử lý chúng rất kém. Hãy tuân thủ các mã đã được đăng ký chính thức.
FILE:references/status-cheatsheet.md
# Bảng tra cứu nhanh Mã trạng thái HTTP cho API

Dựa trên RFC 9110 (Ngữ nghĩa HTTP), RFC 6585 (429), và RFC 9457 (Chi tiết sự cố - Problem Details).

## 2xx Thành công (Success)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 200 | OK | Yêu cầu thành công và phần thân chứa kết quả (GET, hoặc PUT/PATCH trả về tài nguyên đã cập nhật). |
| 201 | Created | Một tài nguyên mới đã được tạo. Gửi kèm `Location` chứa URL của nó; phần thân thường chứa tài nguyên đó. |
| 202 | Accepted | Công việc đã được đưa vào hàng đợi và sẽ hoàn tất sau. Trả về một URL theo dõi trạng thái hoặc job id. |
| 204 | No Content | Thành công nhưng không có phần thân trả về (DELETE, hoặc PUT/PATCH khi bạn không trả về dữ liệu gì). |
| 206 | Partial Content | Phục vụ một dải byte (byte range) để phản hồi lại header `Range`. |

## 3xx Chuyển hướng (Redirection)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 301 | Moved Permanently | Tài nguyên có một URL cố định mới (phương thức có thể bị đổi thành GET trên các máy khách cũ). |
| 304 | Not Modified | Yêu cầu GET có điều kiện (`If-None-Match` / `If-Modified-Since`) và không có gì thay đổi. |
| 307 | Temporary Redirect | Di chuyển tạm thời; máy khách phải lặp lại yêu cầu với cùng phương thức và phần thân. |
| 308 | Permanent Redirect | Di chuyển vĩnh viễn; giữ nguyên cùng phương thức và phần thân. |

## 4xx Lỗi phía máy khách (Client errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 400 | Bad Request | Cú pháp sai định dạng: JSON không hợp lệ, sai cấu trúc nội dung, thiếu tham số truy vấn (query param) bắt buộc. |
| 401 | Unauthorized | Không có thông tin xác thực hoặc thông tin xác thực không hợp lệ/đã hết hạn. Gửi kèm `WWW-Authenticate`. |
| 403 | Forbidden | Đã xác thực danh tính, nhưng không được phép thực hiện thao tác này. |
| 404 | Not Found | Tài nguyên không tồn tại (hoặc bạn muốn ẩn việc nó có tồn tại). |
| 405 | Method Not Allowed | URL có tồn tại nhưng không hỗ trợ phương thức này. Gửi kèm `Allow`. |
| 406 | Not Acceptable | Không thể tạo ra bất kỳ định dạng nào được liệt kê trong `Accept`. |
| 409 | Conflict | Yêu cầu xung đột với trạng thái hiện tại: trùng khóa duy nhất (unique key), xung đột phiên bản, chuyển đổi trạng thái không hợp lệ. |
| 410 | Gone | Tài nguyên từng tồn tại và đã bị xóa vĩnh viễn. |
| 412 | Precondition Failed | Điều kiện tiên quyết `If-Match` / `If-Unmodified-Since` thất bại (khóa lạc quan - optimistic locking). |
| 413 | Content Too Large | Phần thân vượt quá giới hạn kích thước. |
| 415 | Unsupported Media Type | `Content-Type` không được hỗ trợ. |
| 422 | Unprocessable Content | Cú pháp hoàn toàn ổn nhưng giá trị không vượt qua được bước kiểm tra hợp lệ (email sai, ngày kết thúc trước ngày bắt đầu). |
| 428 | Precondition Required | Máy chủ yêu cầu một truy vấn có điều kiện để ngăn chặn việc ghi đè làm mất dữ liệu cập nhật. |
| 429 | Too Many Requests | Vượt quá giới hạn tần suất gọi. Gửi kèm `Retry-After`. |

## 5xx Lỗi phía máy chủ (Server errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 500 | Internal Server Error | Sự cố bất ngờ trong mã nguồn của bạn. |
| 501 | Not Implemented | Máy chủ hoàn toàn không hỗ trợ chức năng này. |
| 502 | Bad Gateway | Một dịch vụ thượng nguồn trả về phản hồi không hợp lệ. |
| 503 | Service Unavailable | Tạm thời quá tải hoặc đang bảo trì. Gửi kèm `Retry-After` nếu biết thời gian. |
| 504 | Gateway Timeout | Một dịch vụ thượng nguồn không phản hồi kịp thời gian quy định. |
FILE:templates/decision-tree.md
# Cây quyết định Mã trạng thái

Trả lời theo thứ tự. Dừng lại ở kết quả khớp đầu tiên.

1. **Yêu cầu có thành công không?**
   - Có, công việc đã hoàn tất và một tài nguyên mới được tạo ra -> **201** (+ `Location`)
   - Có, công việc đã được chấp nhận nhưng sẽ hoàn tất sau -> **202** (+ URL trạng thái)
   - Có, không có gì để trả về -> **204**
   - Có, có trả về dữ liệu -> **200**
   - Không -> chuyển sang câu 2

2. **Máy khách có đang được chuyển hướng không?**
   - GET có điều kiện, dữ liệu không đổi -> **304**
   - Đã di chuyển, phải giữ nguyên phương thức -> **307** (tạm thời) / **308** (vĩnh viễn)
   - Không -> chuyển sang câu 3

3. **Đó có phải là vấn đề xác thực danh tính (authentication) không?**
   - Không có thông tin xác thực, hoặc token không hợp lệ / đã hết hạn -> **401** (+ `WWW-Authenticate`)
   - Không -> chuyển sang câu 4

4. **Người gọi đã xác thực có bị từ chối quyền thực hiện không?**
   - Có, và có thể tiết lộ sự tồn tại của tài nguyên -> **403**
   - Có, nhưng phải che giấu sự tồn tại của tài nguyên -> **404**
   - Không -> chuyển sang câu 5

5. **Đích đến có bị thiếu hoặc phương thức có bị sai không?**
   - Tài nguyên không tồn tại -> **404**
   - Tài nguyên đã bị xóa vĩnh viễn -> **410**
   - URL tồn tại, nhưng phương thức không được hỗ trợ -> **405** (+ `Allow`)
   - Không -> chuyển sang câu 6

6. **Bản thân yêu cầu có ở trạng thái không thể sử dụng được không?**
   - Không thể phân tích cú pháp (JSON lỗi, sai kiểu dữ liệu) -> **400**
   - Sai `Content-Type` -> **415**
   - Phần thân quá lớn -> **413**
   - Không thể đáp ứng `Accept` -> **406**
   - Không -> chuyển sang câu 7

7. **Đầu vào hợp lệ về cú pháp nhưng bị từ chối?**
   - Không vượt qua kiểm tra quy tắc nghiệp vụ -> **422**
   - Xung đột với trạng thái hiện tại (trùng lặp, sai phiên bản, máy trạng thái) -> **409**
   - Điều kiện tiên quyết `If-Match` thất bại -> **412**
   - Quá nhiều yêu cầu -> **429** (+ `Retry-After`)
   - Không -> chuyển sang câu 8

8. **Sự cố phía máy chủ**
   - Dịch vụ thượng nguồn trả về dữ liệu rác/lỗi -> **502**
   - Dịch vụ thượng nguồn bị hết thời gian chờ (timed out) -> **504**
   - Tạm thời không khả dụng / đang bảo trì -> **503** (+ `Retry-After`)
   - Tính năng hoàn toàn không được hỗ trợ -> **501**
   - Lỗi phần mềm (bug) bất ngờ -> **500**

## Mẫu câu trả lời

```
Recommended: <code> <reason phrase>
Why: <1-2 sentences>
Alternatives: <code> - <why not>; <code> - <why not>
Headers: <header: value>
Body (Problem Details):
{
  "type": "https://example.com/problems/<slug>",
  "title": "<short title>",
  "status": <code>,
  "detail": "<specific explanation>",
  "instance": "<request path or id>"
}
```
FILE:examples/api-scenarios.md
# Các tình huống API thực tế

## 1. Đăng ký bằng một email đã tồn tại
`POST /users` với `{"email": "ana@example.com"}` và email đó đã có người dùng.
- **Đề xuất:** 409 Conflict
- **Tại sao:** Đầu vào đúng định dạng và hợp lệ, nhưng nó xung đột với trạng thái hiện có (một khóa duy nhất).
- **Phương án thay thế:** 422 (dùng được nếu nhóm của bạn sử dụng 422 cho mọi lỗi vi phạm quy tắc, nhưng 409 cho máy khách biết vấn đề cụ thể nằm ở trạng thái dữ liệu); 400 (quá mơ hồ).
- **Phần thân (Body):**
```json
{"type": "https://example.com/problems/email-taken", "title": "Email already registered", "status": 409, "detail": "An account with this email already exists.", "instance": "/users"}
```

## 2. Đã tạo đơn hàng
`POST /orders` thành công và trả về đơn hàng đó.
- **Đề xuất:** 201 Created, `Location: /orders/8812`
- **Phương án thay thế:** 200 (làm mất tín hiệu "đã tạo mới"), 202 (chỉ dùng nếu quá trình xử lý là bất đồng bộ và đơn hàng chưa được lưu bền vững).

## 3. Xuất báo cáo được đưa vào hàng đợi
`POST /reports/exports` bắt đầu một tác vụ kéo dài 5 phút.
- **Đề xuất:** 202 Accepted kèm theo `{"jobId": "exp_41", "statusUrl": "/reports/exports/exp_41"}`

## 4. Người xem (Viewer) cố gắng xóa một dự án
Người dùng đã đăng nhập với vai trò chỉ đọc gọi `DELETE /projects/7`.
- **Đề xuất:** 403 Forbidden
- **Tại sao:** Danh tính đã được xác định; thứ bị thiếu là quyền hạn. Mã 401 sẽ gợi ý sai rằng họ nên đăng nhập lại.

## 5. Access token đã hết hạn
`GET /me` với một JWT đã hết hạn.
- **Đề xuất:** 401 Unauthorized, `WWW-Authenticate: Bearer error="invalid_token"`

## 6. Tìm kiếm không có kết quả khớp
`GET /products?q=zzzz`
- **Đề xuất:** 200 OK kèm theo `{"items": [], "total": 0}`
- **Tại sao:** Tập hợp tài nguyên có tồn tại; nó chỉ đang trống mà thôi. Mã 404 sẽ làm lỗi các máy khách vốn coi 404 là một lỗi thực sự.

## 7. Ngày bắt đầu sau ngày kết thúc
`POST /bookings` với `start=2026-10-20`, `end=2026-10-18`.
- **Đề xuất:** 422 Unprocessable Content
- **Phương án thay thế:** 400 (chấp nhận được nếu quy ước của bạn gộp chung lỗi phân tích cú pháp và lỗi kiểm tra tính hợp lệ).

## 8. Nhà cung cấp thanh toán hết thời gian chờ
`POST /checkout` và cổng thanh toán không trả lời trong vòng 10 giây.
- **Đề xuất:** 504 Gateway Timeout
- **Phương án thay thế:** 502 (nếu cổng thanh toán có trả lời nhưng bằng một phản hồi không hợp lệ), 503 (nếu bạn đang chủ động giảm tải).

## 9. Quá nhiều lần thử đăng nhập
- **Đề xuất:** 429 Too Many Requests, `Retry-After: 60`

## 10. Cập nhật dữ liệu cũ (Stale update)
`PUT /docs/3` với `If-Match: "v4"` nhưng phiên bản hiện tại là `"v5"`.
- **Đề xuất:** 412 Precondition Failed
FILE:scripts/suggest_status.py
#!/usr/bin/env python3
"""Suggest an HTTP status code from a short scenario description.

Usage:
    python3 suggest_status.py [--method METHOD] [--json] "scenario text"
    python3 suggest_status.py --self-test

Keyword-based and deliberately simple: it gives a first guess plus
alternatives. Always confirm against references/status-cheatsheet.md.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys

# Ordered rules: first match wins. Each rule: (keywords, code, reason, alternatives, headers)
RULES = [
    (["rate limit", "too many", "throttl", "quota exceeded"], 429,
     "Client exceeded a rate limit or quota.", [503], ["Retry-After"]),
    (["not logged in", "no token", "missing token", "expired token", "invalid token",
      "unauthenticated", "no credentials", "bad credentials", "login required"], 401,
     "Credentials are missing or invalid.", [403], ["WWW-Authenticate"]),
    (["no permission", "not allowed", "forbidden", "insufficient role", "not authorized",
      "read-only user", "not the owner"], 403,
     "Caller is authenticated but lacks permission.", [404], []),
    (["already exists", "duplicate", "conflict", "version mismatch", "already taken",
      "invalid state", "state transition"], 409,
     "Request conflicts with the current state of the resource.", [422, 412], []),
    (["precondition", "if-match", "etag mismatch", "stale"], 412,
     "A conditional request header did not match.", [409], ["ETag"]),
    (["too large", "payload size", "file too big", "body too big"], 413,
     "Request body exceeds the allowed size.", [400], []),
    (["unsupported media", "content-type", "wrong content type"], 415,
     "Content-Type is not supported.", [400], ["Accept"]),
    (["malformed", "invalid json", "parse error", "cannot parse", "syntax error",
      "missing required"], 400,
     "Request cannot be parsed or is missing required parts.", [422], []),
    (["validation", "invalid email", "out of range", "must be", "before start",
      "invalid value", "fails rule"], 422,
     "Request is well-formed but values fail validation.", [400], []),
    (["method not allowed", "wrong method", "unsupported method"], 405,
     "The URL exists but does not support this method.", [404], ["Allow"]),
    (["gone", "permanently deleted", "removed permanently"], 410,
     "Resource existed and was permanently removed.", [404], []),
    (["not found", "does not exist", "no such", "unknown id"], 404,
     "Target resource does not exist.", [410], []),
    (["upstream timeout", "gateway timeout", "timed out", "timeout"], 504,
     "An upstream dependency did not respond in time.", [502, 503], []),
    (["bad gateway", "upstream error", "invalid upstream", "upstream returned"], 502,
     "An upstream dependency returned an invalid response.", [504], []),
    (["maintenance", "overloaded", "unavailable", "down for"], 503,
     "Service is temporarily unavailable.", [500], ["Retry-After"]),
    (["not implemented", "unsupported feature"], 501,
     "The server does not support this functionality.", [405], []),
    (["crash", "exception", "bug", "unexpected error", "internal error"], 500,
     "Unexpected server-side failure.", [503], []),
    (["queued", "async", "background job", "accepted for processing", "will process"], 202,
     "Work was accepted and will complete later.", [201], ["Location"]),
    (["created", "new resource", "signed up", "registered"], 201,
     "A new resource was created.", [200, 202], ["Location"]),
    (["deleted", "no content", "nothing to return"], 204,
     "Success with no response body.", [200], []),
    (["not modified", "unchanged", "cache hit"], 304,
     "Conditional request and resource is unchanged.", [200], ["ETag"]),
    (["empty list", "no results", "no matches", "empty collection"], 200,
     "Collection exists but is empty: return 200 with an empty list.", [404], []),
    (["success", "ok", "returned", "fetched", "updated"], 200,
     "Request succeeded and returns content.", [204], []),
]

REASONS = {
    200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 304: "Not Modified",
    400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
    405: "Method Not Allowed", 409: "Conflict", 410: "Gone", 412: "Precondition Failed",
    413: "Content Too Large", 415: "Unsupported Media Type", 422: "Unprocessable Content",
    429: "Too Many Requests", 500: "Internal Server Error", 501: "Not Implemented",
    502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
}


def suggest(text, method=None):
    t = " " + re.sub(r"\s+", " ", text.lower()) + " "
    for keywords, code, reason, alts, headers in RULES:
        for kw in keywords:
            # word-boundary match for very short keywords like "ok"
            if len(kw) <= 3:
                if re.search(r"\b" + re.escape(kw) + r"\b", t):
                    break
            elif kw in t:
                break
        else:
            continue
        m = (method or "").upper()
        notes = []
        if code == 200 and m == "DELETE":
            code, reason, alts = 204, "DELETE succeeded; usually no body.", [200]
        if code == 201 and m and m not in ("POST", "PUT"):
            notes.append("201 is normally returned by POST or PUT.")
        if code == 204 and m == "GET":
            notes.append("GET rarely returns 204; prefer 200 with a body.")
        return {
            "code": code,
            "reason_phrase": REASONS.get(code, ""),
            "why": reason,
            "alternatives": [f"{a} {REASONS.get(a, '')}".strip() for a in alts],
            "headers": headers,
            "matched_keyword": kw,
            "notes": notes,
        }
    return {
        "code": None,
        "reason_phrase": "",
        "why": "No rule matched. Walk templates/decision-tree.md manually.",
        "alternatives": [],
        "headers": [],
        "matched_keyword": None,
        "notes": [],
    }


def self_test():
    cases = [
        ("created new order", "POST", 201),
        ("user not logged in", "GET", 401),
        ("read-only user tries to delete", "DELETE", 403),
        ("email already exists", "POST", 409),
        ("search returned no results", "GET", 200),
        ("end date before start date validation", "POST", 422),
        ("invalid json body", "POST", 400),
        ("payment gateway timed out", "POST", 504),
        ("too many login attempts", "POST", 429),
        ("order id does not exist", "GET", 404),
        ("report export queued as background job", "POST", 202),
        ("record deleted", "DELETE", 204),
        ("unexpected exception in handler", "GET", 500),
        ("something odd happened", "GET", None),
    ]
    failed = 0
    for text, method, expected in cases:
        got = suggest(text, method)["code"]
        ok = got == expected
        failed += not ok
        print(f"{'PASS' if ok else 'FAIL'}  {method:6} {text!r} -> {got} (expected {expected})")
    print(f"\n{len(cases) - failed}/{len(cases)} passed")
    return 1 if failed else 0


def main(argv=None):
    p = argparse.ArgumentParser(description="Suggest an HTTP status code for an API scenario.")
    p.add_argument("scenario", nargs="*", help="Short description of what happened")
    p.add_argument("--method", help="HTTP method, e.g. POST")
    p.add_argument("--json", action="store_true", help="Print JSON output")
    p.add_argument("--self-test", action="store_true", help="Run built-in tests")
    a = p.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.scenario:
        p.error("scenario text is required (or use --self-test)")
    r = suggest(" ".join(a.scenario), a.method)
    if a.json:
        print(json.dumps(r, indent=2))
        return 0
    if r["code"] is None:
        print(r["why"])
        return 2
    print(f"Recommended: {r['code']} {r['reason_phrase']}")
    print(f"Why: {r['why']}")
    if r["alternatives"]:
        print("Alternatives: " + ", ".join(r["alternatives"]))
    if r["headers"]:
        print("Headers: " + ", ".join(r["headers"]))
    for n in r["notes"]:
        print("Note: " + n)
    return 0


if __name__ == "__main__":
    sys.exit(main())
APICode Review
F@f
0
Trình kiểm toán Scripts trong package.json
Kỹ năng

Kiểm toán phần scripts của tệp package.json trong npm để đảm bảo tính an toàn và rõ ràng: các hook cài đặt rủi ro, lệnh curl truyền thẳng qua pipe vào shell, rm -rf, sudo, thông tin bí mật nhúng trực tiếp, npx không ghim phiên bản, cú pháp shell không tương thích đa nền tảng, các script kiểm thử giữ chỗ, thiếu các script tiêu chuẩn và tên gọi gây nhầm lẫn. Sử dụng khi đánh giá một kho lưu trữ (repo), một gói phụ thuộc (dependency), hoặc một PR thay đổi các script trong package.json. Bao gồm một trình kiểm toán sử dụng thư viện chuẩn Python với các mức độ nghiêm trọng.

---
name: package-json-scripts-auditor
description: Kiểm toán phần scripts của tệp package.json trong npm để đảm bảo tính an toàn và rõ ràng: các hook cài đặt rủi ro, lệnh curl truyền thẳng qua pipe vào shell, rm -rf, sudo, thông tin bí mật nhúng trực tiếp, npx không ghim phiên bản, cú pháp shell không tương thích đa nền tảng, các script kiểm thử giữ chỗ, thiếu các script tiêu chuẩn và tên gọi gây nhầm lẫn. Sử dụng khi đánh giá một kho lưu trữ (repo), một gói phụ thuộc (dependency), hoặc một PR thay đổi các script trong package.json. Bao gồm một trình kiểm toán sử dụng thư viện chuẩn Python với các mức độ nghiêm trọng.
---

# Trình kiểm toán Scripts trong package.json

Bạn đánh giá khối `scripts` trong tệp `package.json` của Node.js và giải thích từng script làm gì, những script nào tiềm ẩn rủi ro, và cách làm cho tập hợp các script trở nên rõ ràng cũng như dễ tương thích đa nền tảng hơn. Bạn phục vụ hai nhóm đối tượng: những người bảo trì đang dọn dẹp dự án của chính họ, và những người đánh giá đang kiểm tra xem một gói phụ thuộc hoặc một PR có chạy bất kỳ lệnh nguy hiểm nào vào thời điểm cài đặt hay không. Bạn không bao giờ tự chạy các script; bạn chỉ đọc chúng.

## Các tệp trong kỹ năng này

- `scripts/audit_scripts.py` - đọc tệp package.json và báo cáo các phát hiện kèm theo mức độ nghiêm trọng (high, medium, low, info) cùng cách khắc phục được đề xuất (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/script-conventions.md` - các tên script tiêu chuẩn, các hook vòng đời (lifecycle hooks), quy ước đặt tên và tính tương thích đa nền tảng
- `references/dangerous-patterns.md` - các mẫu lệnh cần xem xét kỹ hơn, lý do chúng quan trọng và các giải pháp thay thế an toàn hơn
- `templates/audit-report.md` - cấu trúc báo cáo cần trả về
- `examples/package.json` - một ví dụ cố tình làm lộn xộn để thực hành

## Quy trình làm việc

### 1. Thu thập ngữ cảnh
Yêu cầu toàn bộ tệp `package.json` (không chỉ phần `scripts`, vì `name`, `private`, `bin`, `workspaces`, và `packageManager` đều quan trọng), và hỏi xem đây là dự án của chính người dùng hay là một gói của bên thứ ba. Hỏi xem nhóm sử dụng hệ điều hành nào (việc hỗ trợ Windows sẽ làm thay đổi lời khuyên về tính tương thích đa nền tảng).

### 2. Chạy trình kiểm toán
```
python3 scripts/audit_scripts.py path/to/package.json
python3 scripts/audit_scripts.py --json path/to/package.json
python3 scripts/audit_scripts.py --self-test
```
Mã thoát (Exit code) 0 = không có phát hiện mức độ cao (high), 1 = có ít nhất một phát hiện mức độ cao, 2 = lỗi tệp.

### 3. Đánh giá thủ công
Script kiểm toán hoạt động dựa trên mẫu (pattern-based). Bạn cũng hãy tự đọc từng script và kiểm tra:
- Các hook vòng đời (`preinstall`, `install`, `postinstall`, `prepare`) chạy tự động đối với người dùng gói; bất kỳ lệnh gọi mạng hoặc ghi tệp nào bên ngoài dự án tại đó đều là dấu hiệu cảnh báo đỏ.
- Các script gọi những tệp khác (`node scripts/setup.js`, `sh ./build.sh`): hãy yêu cầu xem các tệp đó trước khi kết luận gói là an toàn.
- Việc làm rối mã (obfuscation) (base64, chuỗi hex, `eval`, `node -e` với các câu lệnh một dòng dài) đáng bị nghi ngờ ở mức độ cao.

### 4. Đề xuất
Sử dụng `references/script-conventions.md` và `references/dangerous-patterns.md` để đề xuất các phương án thay thế cụ thể: `rimraf` hoặc `del-cli` thay cho `rm -rf`, `cross-env` thay cho `VAR=value cmd`, ghim phiên bản `npx pkg@1.2.3`, `prepublishOnly` chạy kiểm thử, và đặt tên theo nhóm như `lint:js`, `test:unit`.

### 5. Báo cáo
Điền vào `templates/audit-report.md`: kết luận, bảng các phát hiện, khối `scripts` được viết lại, và các câu hỏi tiếp theo.

## Quy tắc

- Đánh giá mức độ nghiêm trọng theo tác động: bất kỳ thứ gì có thể chạy mã từ xa hoặc xóa tệp bên ngoài dự án khi cài đặt đều ở mức cao (high).
- Không buộc tội tác giả có ý đồ xấu; hãy mô tả hành vi và rủi ro.
- Giữ nguyên tên các script hiện có của người dùng nếu việc đổi tên sẽ làm hỏng CI, và cho biết những tệp CI nào sẽ cần cập nhật nếu bạn đề xuất đổi tên.
- Đề xuất `npm install --ignore-scripts` để kiểm toán các gói không đáng tin cậy, và chỉ đề xuất `npm config set ignore-scripts true` khi đã giải thích rõ các mặt đánh đổi.
FILE:references/dangerous-patterns.md
# Các mẫu Script nguy hiểm và rủi ro

| Mẫu | Ví dụ | Tại sao lại quan trọng | Giải pháp thay thế an toàn hơn | Mức độ nghiêm trọng |
|---|---|---|---|---|
| Script từ xa truyền qua pipe vào shell | `curl -fsSL https://x.sh \| sh` | Chạy bất cứ thứ gì máy chủ trả về, không ghim phiên bản và không được xác minh | Đóng gói trực tiếp script vào dự án (vendor), ghim bằng checksum, hoặc dùng một gói đã xuất bản | high |
| Tải dữ liệu mạng trong các hook cài đặt | `"postinstall": "node download.js"` / `wget` | Chạy trên máy của mọi người dùng vào thời điểm cài đặt | Đóng gói sẵn các tạo phẩm (artifacts) trong gói, hoặc làm cho bước này trở thành tùy chọn (opt-in) | high |
| Xóa đệ quy các đường dẫn rộng | `rm -rf /`, `rm -rf ~`, `rm -rf $DIR/` với biến chưa được đặt giá trị | Có thể xóa sạch dữ liệu bên ngoài dự án | `rimraf dist`, các đường dẫn tương đối rõ ràng | high |
| `sudo` | `sudo npm i -g x` | Leo thang đặc quyền từ một script | Không bao giờ yêu cầu quyền root trong các script; hãy ghi tài liệu hướng dẫn các bước thủ công | high |
| `eval` / các payload được mã hóa | `eval "$(echo ... \| base64 -d)"`, `node -e "...Buffer.from('..','base64')"` | Che giấu những gì thực sự chạy | Các lệnh thuần túy, dễ đọc đặt trong các tệp | high |
| Thông tin bí mật nhúng trực tiếp (Inline secrets) | `API_TOKEN=abc123 node deploy.js` | Rò rỉ trong lịch sử git và nhật ký (logs) CI | Các biến môi trường từ kho lưu trữ bí mật hoặc tệp `.env` không được commit | high |
| Quyền ghi cho mọi người (World-writable) | `chmod 777`, `chmod -R a+w` | Cho phép bất kỳ người dùng nào sửa đổi tệp | Quyền tối thiểu (`chmod +x file`) | medium |
| Bỏ qua các bước kiểm tra an toàn | `git commit --no-verify`, `npm publish --force`, `--unsafe-perm` | Vượt qua các hook và cơ chế bảo vệ | Sửa lỗi kiểm tra đang bị thất bại thay vì bỏ qua | medium |
| `npx` / `dlx` không ghim phiên bản | `npx create-thing` | Tải xuống và chạy phiên bản mới nhất vào thời điểm chạy | `npx create-thing@3.2.1` hoặc thêm vào làm devDependency | medium |
| Cài đặt toàn cục (global) trong các script | `npm i -g typescript` | Làm thay đổi máy của người dùng; gây lệch phiên bản | devDependency + tệp thực thi (bin) cục bộ | medium |
| Các URL HTTP thuần túy | `curl http://...` | Có thể bị can thiệp trong quá trình truyền tải | HTTPS | medium |
| `git push` / `npm publish` trong các hook | `"postbuild": "npm publish"` | Phát hành ngoài ý muốn | Script `release` rõ ràng được chạy bởi CI | medium |
| Cú pháp shell không tương thích đa nền tảng | `NODE_ENV=prod`, `export`, `cp -r` | Bị lỗi trên Windows | `cross-env`, `cpy-cli`, các script Node | low |
| Kiểm thử giữ chỗ (Placeholder test) | `echo "Error: no test specified" && exit 1` | CI thất bại hoặc các bài kiểm thử âm thầm bị thiếu | Trình chạy kiểm thử (test runner) thực sự | low |
| Câu lệnh một dòng (one-liners) rất dài | > 200 ký tự | Khó đánh giá; ẩn chứa rủi ro | Chuyển sang một tệp script riêng | low |

## Mẹo đánh giá
- Đối với các gói của bên thứ ba: kiểm tra bằng lệnh `npm pack <pkg>` và đọc tệp nén tarball, hoặc cài đặt với cờ `--ignore-scripts` trước.
- Lần theo các tham chiếu tệp: `node scripts/postinstall.js` chỉ an toàn tương đương với mức độ an toàn của chính tệp đó.
- Chú ý đến `prepare` trong các gói phụ thuộc git: nó sẽ chạy khi cài đặt từ một URL git.
FILE:references/script-conventions.md
# Quy ước Script trong npm

## Các tên tiêu chuẩn
| Script | Mục đích | Chạy bằng lệnh |
|---|---|---|
| `start` | Chạy ứng dụng ở chế độ production | `npm start` |
| `dev` | Chạy kèm theo dõi thay đổi (watch) / tải lại nóng (hot reload) | `npm run dev` |
| `build` | Tạo đầu ra bản dựng (build output) | `npm run build` |
| `test` | Chạy bộ kiểm thử (mặc định của CI) | `npm test` |
| `lint` | Kiểm tra tĩnh | `npm run lint` |
| `format` | Áp dụng trình định dạng mã | `npm run format` |
| `typecheck` | `tsc --noEmit` hoặc tương tự | `npm run typecheck` |
| `clean` | Xóa các tạo phẩm bản dựng (build artifacts) | `npm run clean` |

Sử dụng dấu hai chấm để nhóm các biến thể: `test:unit`, `test:e2e`, `lint:css`, `build:docs`. Sử dụng `npm-run-all` / `run-p` / `run-s`, hoặc `npm run a && npm run b`, để kết hợp chúng.

## Các hook vòng đời (Lifecycle hooks)
| Hook | Chạy khi | Ghi chú |
|---|---|---|
| `preinstall`, `install`, `postinstall` | Gói của bạn được cài đặt, bao gồm cả khi làm gói phụ thuộc | Chạy trên máy của mọi người dùng. Hãy để trống trừ khi cần biên dịch mã gốc (native code). |
| `prepare` | Sau khi chạy `npm install` cục bộ và trước `npm publish`/`npm pack`; cũng chạy đối với các gói phụ thuộc git | Tốt để dựng (build) hoặc thiết lập các git hook (ví dụ: `husky`). |
| `prepublishOnly` | Chỉ trước khi chạy `npm publish` | Đặt `npm test && npm run build` ở đây. |
| `prepack`, `postpack` | Xung quanh lệnh `npm pack` / publish | |
| `preX` / `postX` | Tự động trước/sau bất kỳ script `X` nào | Có thể gây bất ngờ cho người đọc; nên ưu tiên nối chuỗi lệnh rõ ràng. pnpm mặc định không chạy chúng. |

## Tính tương thích đa nền tảng (Portability)
- `cmd.exe` của Windows không hiểu `VAR=value cmd`, `export`, `rm -rf`, `cp -r`, chạy nền bằng `&`, dấu nháy đơn, hoặc `$VAR`. Hãy sử dụng `cross-env`, `rimraf`, `cpy-cli`, `concurrently`, và các script Node.
- `&&` hoạt động trong cả hai trình shell; `;` không chạy theo cùng một cách trong `cmd.exe`.
- Ưu tiên các tệp thực thi cục bộ (từ `devDependencies`) hơn là các tệp được cài đặt toàn cục; npm sẽ thêm `node_modules/.bin` vào PATH bên trong các script.

## Sự rõ ràng
- Mỗi script chỉ đảm nhận một công việc; chuyển các logic dài (> ~120 ký tự) vào các tệp `scripts/*.js` hoặc `*.mjs`.
- Tránh những tên làm che khuất (shadow) các lệnh npm hoặc trùng lặp lẫn nhau (`install`, `publish`, `test2`).
- Thay thế lệnh mặc định `echo "Error: no test specified" && exit 1` bằng một lệnh kiểm thử thực sự hoặc xóa nó đi.
- Ghi tài liệu cho các script khác thường trong tệp README.
- Đặt `"private": true` cho các ứng dụng không bao giờ được phép xuất bản.
FILE:templates/audit-report.md
# Kiểm toán Scripts trong package.json

**Gói:** `<name>@<version>`  |  **Chủ sở hữu:** dự án riêng / bên thứ ba  |  **Riêng tư (Private):** có/không
**Kết luận:** An toàn / Cần thay đổi / Không cài đặt cho đến khi được đánh giá
**Số lượng:** high `<n>`, medium `<n>`, low `<n>`, info `<n>`

## Danh mục Script
| Script | Chức năng (diễn giải dễ hiểu) | Có chạy tự động không? |
|---|---|---|
| `build` | | không |
| `postinstall` | | có, trong mọi lần cài đặt |

## Các phát hiện
| # | Mức độ nghiêm trọng | Script | Mẫu | Tại sao lại quan trọng | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | high | | | | |

## Khối scripts được đề xuất
```json
{
  "scripts": {
  }
}
```

## Kiểm tra thêm
- Các tệp được tham chiếu bởi các script: `<list>`
- Các tệp CI gọi những script đã được đổi tên: `<list>`
- Các devDependencies cần thêm: `<rimraf, cross-env, ...>`

## Câu hỏi dành cho người bảo trì
1.
FILE:examples/package.json
{
  "name": "acme-dashboard",
  "version": "0.4.2",
  "description": "Internal dashboard (example with deliberately messy scripts for auditing practice)",
  "main": "dist/index.js",
  "scripts": {
    "preinstall": "curl -fsSL http://get.example.com/setup.sh | sh",
    "postinstall": "node scripts/fetch-assets.js",
    "start": "NODE_ENV=production node dist/index.js",
    "dev": "nodemon src/index.ts",
    "build": "rm -rf dist && tsc -p .",
    "test": "echo \"Error: no test specified\" && exit 1",
    "deploy": "API_TOKEN=sk_live_example123 node scripts/deploy.js",
    "fix-perms": "chmod -R 777 ./uploads",
    "commit": "git add -A && git commit --no-verify -m wip",
    "gen": "npx create-acme-widget",
    "postbuild": "npm publish",
    "setup": "sudo npm install -g typescript"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "typescript": "^5.6.0"
  }
}
FILE:scripts/audit_scripts.py
#!/usr/bin/env python3
"""Audit the "scripts" section of an npm package.json.

Usage:
    python3 audit_scripts.py path/to/package.json [--json]
    python3 audit_scripts.py --self-test

Exit codes: 0 = no high findings, 1 = high findings present, 2 = file error.
Pattern-based: always read the scripts and any files they call yourself.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys

INSTALL_HOOKS = {"preinstall", "install", "postinstall", "prepare"}
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}

# (id, regex, severity, message, fix)
PATTERNS = [
    ("pipe-to-shell", r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", "high",
     "Downloads a remote script and pipes it into a shell.",
     "Vendor the script, verify a checksum, or use a pinned package."),
    ("broad-rm", r"\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(/|~|\$HOME|\$\{?\w+\}?/?)(\s|$|&|;)", "high",
     "Recursive delete of a root, home, or variable-based path.",
     "Delete an explicit relative path, e.g. `rimraf dist`."),
    ("sudo", r"(^|[\s;&|])sudo\s", "high",
     "Uses sudo inside an npm script.",
     "Never require root from scripts; document any manual setup."),
    ("eval-or-encoded", r"\beval\b|base64\s+(-d|--decode)|Buffer\.from\([^)]*base64", "high",
     "Uses eval or decodes an encoded payload, hiding what runs.",
     "Replace with plain, readable commands in a script file."),
    ("inline-secret", r"\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*=(?!\$)\S+", "high",
     "Hard-coded secret value in a script.",
     "Read secrets from the environment or a secret manager; rotate the exposed value."),
    ("chmod-777", r"\bchmod\s+(-R\s+)?(0?777|a\+w|o\+w)\b", "medium",
     "Makes files world-writable.",
     "Use minimal permissions, e.g. `chmod +x bin/cli.js`."),
    ("skip-checks", r"--no-verify|--force\b|--unsafe-perm|--ignore-engines", "medium",
     "Bypasses safety checks or hooks.",
     "Fix the underlying failure instead of skipping the check."),
    ("unpinned-npx", r"\b(npx|pnpm\s+dlx|bunx|yarn\s+dlx)\s+(-y\s+|--yes\s+)?(?!-)(@?[\w./-]+)(?=\s|$)(?<!@latest)", "medium",
     "Runs a package via npx/dlx without a pinned version.",
     "Pin it (`npx pkg@1.2.3`) or add it to devDependencies."),
    ("global-install", r"\bnpm\s+(i|install)\s+(-g|--global)\b|\byarn\s+global\s+add\b", "medium",
     "Installs packages globally from a script.",
     "Add as a devDependency and use the local binary."),
    ("plain-http", r"http://(?!localhost|127\.0\.0\.1)", "medium",
     "Fetches over plain HTTP.",
     "Use HTTPS."),
    ("publish-or-push", r"\bnpm\s+publish\b|\bgit\s+push\b", "medium",
     "Publishes or pushes from a script.",
     "Keep releases in an explicit `release` script or CI job."),
    ("non-portable-env", r"(^|&&\s*|;\s*)[A-Z_][A-Z0-9_]*=\S+\s+\S", "low",
     "Inline env assignment `VAR=value cmd` does not work in Windows cmd.",
     "Use `cross-env VAR=value cmd`."),
    ("non-portable-cmd", r"(^|[\s;&|])(rm\s+-|cp\s+-r|export\s+\w+=|mv\s)", "low",
     "Unix-only shell command.",
     "Use rimraf / cpy-cli / a Node script for Windows support."),
]

PLACEHOLDER_TEST = re.compile(r'no test specified', re.I)
NETWORK = re.compile(r"\b(curl|wget|git\s+clone|Invoke-WebRequest)\b|https?://", re.I)
FILE_REF = re.compile(r"\b(?:node|sh|bash|python3?)\s+(\.?/?[\w./-]+\.(?:m?js|cjs|ts|sh|py))")


def audit(pkg):
    findings = []

    def add(sev, script, rule, msg, fix):
        findings.append({"severity": sev, "script": script, "rule": rule, "message": msg, "fix": fix})

    scripts = pkg.get("scripts")
    if not isinstance(scripts, dict) or not scripts:
        add("info", None, "no-scripts", "No scripts section found.", "Add standard scripts such as build, test, lint.")
        return findings

    for name, cmd in scripts.items():
        if not isinstance(cmd, str):
            add("medium", name, "not-string", "Script value is not a string.", "Make it a shell command string.")
            continue
        for rule, rx, sev, msg, fix in PATTERNS:
            if re.search(rx, cmd):
                add(sev, name, rule, msg, fix)
        if name in INSTALL_HOOKS:
            if name == "prepare":
                add("info", name, "install-hook", "`prepare` runs on local install and for git dependencies.",
                    "Keep it limited to build or git-hook setup.")
            else:
                add("medium", name, "install-hook", f"`{name}` runs automatically on every consumer's install.",
                    "Remove it unless it compiles native code; move setup to an explicit script.")
            if NETWORK.search(cmd):
                add("high", name, "network-in-install-hook", "Install hook performs network access.",
                    "Ship assets in the package or make the download opt-in.")
            for ref in FILE_REF.findall(cmd):
                add("high" if name != "prepare" else "medium", name, "install-hook-runs-file",
                    f"Install hook executes `{ref}`; its contents decide the real risk.",
                    f"Read `{ref}` before trusting this package.")
        if name == "test" and PLACEHOLDER_TEST.search(cmd):
            add("low", name, "placeholder-test", "Default placeholder test script.", "Wire up a real test runner.")
        if len(cmd) > 200:
            add("low", name, "long-script", f"Script is {len(cmd)} characters long.", "Move the logic into a script file.")
        if name.startswith(("pre", "post")) and name not in INSTALL_HOOKS and name not in ("prepublishOnly", "prepack", "postpack"):
            base = name[3:] if name.startswith("pre") else name[4:]
            if base in scripts:
                add("info", name, "implicit-hook", f"Runs automatically around `{base}` (pnpm skips these by default).",
                    f"Consider chaining explicitly inside `{base}`.")
        if not re.match(r"^[a-z0-9][a-z0-9:._-]*$", name):
            add("low", name, "naming", "Script name uses unusual characters or uppercase.",
                "Use lowercase names grouped with colons, e.g. `test:unit`.")

    for std in ("build", "test", "lint"):
        if std not in scripts and not any(k.startswith(std + ":") for k in scripts):
            add("info", None, "missing-standard", f"No `{std}` script.", f"Add a `{std}` script if the project needs one.")
    if not pkg.get("private") and "prepublishOnly" not in scripts and "publish-or-push" not in [f["rule"] for f in findings]:
        add("info", None, "no-prepublishonly", "Package is publishable but has no `prepublishOnly` check.",
            "Add `\"prepublishOnly\": \"npm test && npm run build\"` or set `\"private\": true`.")
    findings.sort(key=lambda f: (SEVERITY_ORDER[f["severity"]], f["script"] or ""))
    return findings


def self_test():
    pkg = {
        "name": "t", "scripts": {
            "preinstall": "curl -fsSL http://x.example/s.sh | sh",
            "postinstall": "node scripts/setup.js",
            "clean": "rm -rf $OUT/",
            "deploy": "API_TOKEN=abc123 node deploy.js",
            "perm": "chmod -R 777 uploads",
            "gen": "npx create-foo",
            "gen2": "npx create-foo@1.2.3",
            "start": "NODE_ENV=production node index.js",
            "test": "echo \"Error: no test specified\" && exit 1",
            "setup": "sudo npm i -g typescript",
            "build": "tsc",
        }}
    f = audit(pkg)
    rules = {(x["script"], x["rule"]) for x in f}
    checks = [
        (("preinstall", "pipe-to-shell"), True),
        (("preinstall", "network-in-install-hook"), True),
        (("preinstall", "plain-http"), True),
        (("postinstall", "install-hook-runs-file"), True),
        (("clean", "broad-rm"), True),
        (("deploy", "inline-secret"), True),
        (("perm", "chmod-777"), True),
        (("gen", "unpinned-npx"), True),
        (("gen2", "unpinned-npx"), False),
        (("start", "non-portable-env"), True),
        (("test", "placeholder-test"), True),
        (("setup", "sudo"), True),
        (("setup", "global-install"), True),
        (("build", "broad-rm"), False),
    ]
    failed = 0
    for key, expected in checks:
        ok = (key in rules) == expected
        failed += not ok
        print(f"{'PASS' if ok else 'FAIL'}  {key[0]}:{key[1]} expected={'present' if expected else 'absent'}")
    clean = audit({"name": "c", "private": True, "scripts": {"build": "tsc -p .", "test": "vitest run", "lint": "eslint ."}})
    ok = not [x for x in clean if x["severity"] in ("high", "medium", "low")]
    failed += not ok
    print(f"{'PASS' if ok else 'FAIL'}  clean package has no high/medium/low findings")
    total = len(checks) + 1
    print(f"\n{total - failed}/{total} passed")
    return 1 if failed else 0


def main(argv=None):
    p = argparse.ArgumentParser(description="Audit npm package.json scripts.")
    p.add_argument("file", nargs="?", help="Path to package.json")
    p.add_argument("--json", action="store_true", help="Output JSON")
    p.add_argument("--self-test", action="store_true", help="Run built-in tests")
    a = p.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        p.error("file is required (or use --self-test)")
    try:
        with open(a.file, encoding="utf-8") as fh:
            pkg = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
        return 2
    findings = audit(pkg)
    counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_ORDER}
    if a.json:
        print(json.dumps({"package": pkg.get("name"), "counts": counts, "findings": findings}, indent=2))
    else:
        print(f"Package: {pkg.get('name', '?')}@{pkg.get('version', '?')}")
        for f in findings:
            where = f["script"] or "-"
            print(f"[{f['severity'].upper():6}] {where:12} {f['rule']}: {f['message']}\n{'':22}fix: {f['fix']}")
        print("\n" + ", ".join(f"{k} {v}" for k, v in counts.items()))
    return 1 if counts["high"] else 0


if __name__ == "__main__":
    sys.exit(main())
JavaScriptCode ReviewNode.js
F@f
0
Công cụ Kiểm tra Lệch chuẩn Đặc tả OpenAPI
Kỹ năng

So sánh đặc tả OpenAPI YAML/JSON với danh sách các route đã được triển khai và gắn cờ cảnh báo sự lệch chuẩn (drift): thiếu thao tác (operation), thừa route, lệch phương thức (method), và khác biệt về cấu trúc tham số đường dẫn (path parameter). Sử dụng khi đánh giá PR của API, trước khi phát hành, hoặc khi hỏi "mã nguồn của chúng ta có còn khớp với OpenAPI không?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: openapi-spec-drift-checker
description: So sánh đặc tả OpenAPI YAML/JSON với danh sách các route đã được triển khai và gắn cờ cảnh báo sự lệch chuẩn (drift): thiếu thao tác (operation), thừa route, lệch phương thức (method), và khác biệt về cấu trúc tham số đường dẫn (path parameter). Sử dụng khi đánh giá PR của API, trước khi phát hành, hoặc khi hỏi "mã nguồn của chúng ta có còn khớp với OpenAPI không?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Công cụ Kiểm tra Lệch chuẩn Đặc tả OpenAPI

Bạn giúp các đội ngũ phát triển API tìm ra sự lệch chuẩn (drift) giữa tài liệu OpenAPI 3.x và các route mà mã nguồn của họ thực sự phục vụ. Bạn so sánh các đường dẫn (path) và phương thức (method), gắn cờ các thao tác bị thiếu hoặc thừa, đồng thời chỉ ra những điểm không khớp về tham số hoặc phản hồi làm ảnh hưởng đến phía client. Bạn không tự bịa ra các endpoint; bạn chỉ báo cáo những gì dữ liệu đầu vào thể hiện.

## Các tệp trong kỹ năng này

- `scripts/check_drift.py` - so sánh một đoạn đặc tả OpenAPI nhỏ với danh sách route và in ra báo cáo lệch chuẩn (chỉ dùng thư viện chuẩn Python 3)
- `references/openapi-checklist.md` - những điều cần xác minh trong đặc tả trước khi so sánh
- `references/common-drift.md` - các nguyên nhân gây lệch chuẩn thường gặp và cách khắc phục
- `templates/drift-report.md` - cấu trúc báo cáo dùng cho các PR và ghi chú phát hành (release notes)
- `examples/openapi-snippet.yaml` - đoạn mẫu OpenAPI dùng cho bản chạy thử (demo)

## Quy trình làm việc

### 1. Thu thập dữ liệu đầu vào
Yêu cầu cung cấp, hoặc suy luận từ ngữ cảnh:
- Tệp OpenAPI (YAML hoặc JSON) hoặc phần paths được dán trực tiếp
- Các route đã triển khai: danh sách phương thức + đường dẫn từ bộ định tuyến (router) của framework, được trích xuất từ mã nguồn, hoặc từ cấu hình gateway
- Liệu tên tham số đường dẫn có bắt buộc phải khớp chính xác từng chữ hay không (`{id}` so với `{userId}`)

### 2. Chuẩn hóa dữ liệu
Chuyển các phương thức về chữ thường. Cắt bỏ dấu gạch chéo ở cuối đường dẫn (trailing slash). Coi `/users/{id}` và `/users/{userId}` có cùng hình thái đường dẫn trừ khi nhóm phát triển yêu cầu khớp chính xác tên tham số.

### 3. Chạy công cụ kiểm tra
```
python3 scripts/check_drift.py --spec examples/openapi-snippet.yaml --routes examples/routes.txt
python3 scripts/check_drift.py --self-test
```
Nếu danh sách các route chưa có sẵn trong tệp, hãy giúp người dùng trích xuất chúng từ mã nguồn trước.

### 4. Phân loại các phát hiện
- **missing_in_code**: có trong OpenAPI, nhưng chưa được triển khai trong mã nguồn
- **missing_in_spec**: đã triển khai trong mã nguồn, nhưng không có trong OpenAPI
- **method_mismatch**: đường dẫn có tồn tại nhưng khác phương thức HTTP (verb)
- **param_shape_diff**: cùng mẫu đường dẫn, nhưng khác tên tham số (chế độ nghiêm ngặt tùy chọn)

### 5. Trả lời
Trả về bảng tóm tắt số lượng ngắn gọn, sau đó liệt kê từng phát hiện kèm mức độ nghiêm trọng (high/medium/low) và gợi ý cách khắc phục. Sử dụng `templates/drift-report.md` để viết bình luận vào PR.

## Quy tắc

- Ưu tiên cập nhật lại tài liệu theo thực tế khi mã nguồn được viết có chủ đích và bản đặc tả đã lỗi thời; ưu tiên viết bổ sung mã nguồn khi bản đặc tả là hợp đồng giao tiếp đã được công bố cho các client.
- Không bao giờ tuyên bố rằng đã thực hiện đánh giá bảo mật; chỉ báo cáo sự lệch chuẩn về mặt cấu trúc.
- Nếu không thể phân tích cú pháp (parse) tệp OpenAPI, hãy dừng lại và hiển thị lỗi phân tích cú pháp đó.
FILE:references/openapi-checklist.md
# Danh sách kiểm tra OpenAPI trước khi đối chiếu

Trước khi chạy kiểm tra lệch chuẩn, hãy xác nhận bản đặc tả có tính nhất quán:

1. Trường **openapi** là `3.0.x` hoặc `3.1.x`.
2. Phần **paths** có tồn tại và bắt đầu bằng dấu `/`.
3. Mọi thao tác (operation) đều có phương thức HTTP (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
4. Các tham số đường dẫn được khai báo trong đường dẫn (`{id}`) cũng xuất hiện dưới mục `parameters` với `in: path` và `required: true`.
5. Các đích tham chiếu `$ref` đều phân giải được (hoặc bạn đang so sánh một tài liệu đã được phân giải/đóng gói hoàn chỉnh).
6. Đã nắm rõ đường dẫn cơ sở (base path) trong URL của servers: nếu `servers[].url` bao gồm `/api/v1`, các route có thể được liệt kê kèm hoặc không kèm tiền tố đó—hãy chọn một quy ước thống nhất và giữ nguyên trong suốt quá trình so sánh.
7. Các thao tác không còn khuyên dùng (deprecated) vẫn phải được liệt kê cho đến khi bị xóa hoàn toàn khỏi mã nguồn; hãy đánh dấu chúng trong báo cáo thay vì bỏ qua.

## Cấu trúc paths tối thiểu

```yaml
paths:
  /pets:
    get:
      summary: List pets
    post:
      summary: Create pet
  /pets/{petId}:
    get:
      parameters:
        - name: petId
          in: path
          required: true
          schema: { type: string }
```
FILE:references/common-drift.md
# Các trường hợp lệch chuẩn OpenAPI thường gặp

| Triệu chứng | Nguyên nhân có thể | Cách khắc phục |
| --- | --- | --- |
| Đặc tả có `GET /users`, mã nguồn không có | Đặc tả được viết trước khi triển khai mã nguồn | Triển khai mã nguồn hoặc xóa khỏi bản đặc tả đã công bố |
| Mã nguồn có `POST /users/{id}/reset`, thiếu trong đặc tả | Bản vá nóng (hotfix) được đưa lên mà không cập nhật tài liệu | Thêm thao tác vào đặc tả; tăng phiên bản phụ (minor) nếu là API công khai |
| Đặc tả là `PUT`, mã nguồn là `PATCH` | Ngữ nghĩa cập nhật một phần đã thay đổi | Đồng bộ lại phương thức; lưu ý thay đổi gây lỗi tương thích (breaking change) nếu client đang gọi PUT |
| `{userId}` so với `{id}` | Framework tự động đặt tên so với đặc tả viết tay | Chuẩn hóa khi so sánh; ghi tài liệu quy tắc đặt tên |
| Chỉ có dấu gạch chéo cuối trong mã nguồn | Bộ định tuyến (router) chấp nhận linh hoạt dấu gạch chéo | Cắt bỏ khi so sánh; chọn dạng chuẩn tắc (canonical) trong đặc tả |
| Đường dẫn cơ sở `/api` chỉ có ở gateway | Mục servers trong đặc tả bỏ qua tiền tố của gateway | Cắt bỏ hoặc thêm cùng một tiền tố ở cả hai phía |
| Webhooks / callbacks chỉ có trong đặc tả | Không phải là các route HTTP thông thường | Loại trừ khỏi việc so sánh route |
| Route tải tệp lên chưa được ghi tài liệu | Bổ sung multipart vào giai đoạn muộn | Ghi tài liệu `multipart/form-data` vào requestBody |

## Hướng dẫn đánh giá mức độ nghiêm trọng

- **High**: các client công khai đã và đang gọi thao tác này; thiếu phương thức hoặc đường dẫn ở một trong hai phía.
- **Medium**: route chỉ dùng nội bộ bị thiếu trong đặc tả; đổi tên tham số nhưng có bí danh (alias).
- **Low**: dấu gạch chéo ở cuối, nhiễu từ các phương thức HEAD/OPTIONS tùy chọn, đã đánh dấu deprecated nhưng vẫn còn tồn tại.
FILE:templates/drift-report.md
# Báo cáo lệch chuẩn OpenAPI

- **Đặc tả (Spec)**: <path or URL>
- **Nguồn trích xuất route**: <router dump / gateway / manual>
- **Thời điểm so sánh**: <ISO timestamp>
- **Tóm tắt**: missing_in_code=<n>, missing_in_spec=<n>, method_mismatch=<n>, param_shape_diff=<n>

## Các phát hiện

| Mức độ nghiêm trọng | Loại | Đặc tả (Spec) | Mã nguồn (Code) | Ghi chú |
| --- | --- | --- | --- | --- |
| high | missing_in_code | `GET /pets/{petId}` | — | Triển khai hoặc gỡ khỏi tài liệu công bố |
| medium | missing_in_spec | — | `POST /pets/{petId}/adopt` | Bổ sung vào tài liệu trước khi phát hành |

## Quyết định

- [ ] Cập nhật OpenAPI
- [ ] Cập nhật mã nguồn
- [ ] Chấp nhận như một ngoại lệ đã biết (nêu rõ lý do)

## Các việc cần theo dõi tiếp

- [ ]
FILE:examples/openapi-snippet.yaml
openapi: 3.0.3
info:
  title: Pets demo
  version: 0.1.0
paths:
  /pets:
    get:
      summary: List pets
      operationId: listPets
    post:
      summary: Create pet
      operationId: createPet
  /pets/{petId}:
    get:
      summary: Get pet by id
      operationId: getPet
      parameters:
        - name: petId
          in: path
          required: true
          schema:
            type: string
    delete:
      summary: Delete pet
      operationId: deletePet
      parameters:
        - name: petId
          in: path
          required: true
          schema:
            type: string
  /health:
    get:
      summary: Health check
      operationId: health
FILE:scripts/check_drift.py
#!/usr/bin/env python3
"""Compare OpenAPI paths to an implemented route list.

Usage:
    python3 check_drift.py --spec openapi.yaml --routes routes.txt
    python3 check_drift.py --spec openapi.yaml --route 'GET /pets' --route 'POST /pets'
    python3 check_drift.py --self-test

Route file format: one "METHOD /path" per line. Comments (#) and blank lines ignored.
Python 3 standard library only.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from typing import Dict, List, Set, Tuple

try:
    import yaml  # type: ignore
except ImportError:
    yaml = None

PathKey = str  # normalized path template
Method = str


def normalize_path(path: str) -> str:
    path = path.strip()
    if not path.startswith("/"):
        path = "/" + path
    if len(path) > 1 and path.endswith("/"):
        path = path.rstrip("/")
    # unify parameter names to {}
    return re.sub(r"\{[^}]+\}", "{}", path)


def normalize_method(m: str) -> str:
    return m.strip().upper()


def parse_routes(lines: List[str]) -> Dict[PathKey, Set[Method]]:
    out: Dict[PathKey, Set[Method]] = {}
    for raw in lines:
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        parts = line.split()
        if len(parts) < 2:
            raise ValueError(f"Bad route line: {raw!r}")
        method, path = normalize_method(parts[0]), normalize_path(parts[1])
        out.setdefault(path, set()).add(method)
    return out


def _parse_openapi_json(text: str) -> dict:
    return json.loads(text)


def _parse_openapi_yaml_simple(text: str) -> dict:
    """Minimal YAML subset parser for simple OpenAPI path maps when PyYAML is absent.

    Supports the examples shipped with this skill (no anchors, no multiline).
    Prefer PyYAML when installed.
    """
    # Very small fallback: extract path keys and methods via regex for our demos.
    paths: Dict[str, dict] = {}
    current_path = None
    for raw in text.splitlines():
        if raw.startswith("  /") and raw.rstrip().endswith(":"):
            current_path = raw.strip()[:-1]
            paths[current_path] = {}
            continue
        if current_path and re.match(r"^    (get|post|put|patch|delete|head|options):", raw):
            method = raw.strip().split(":", 1)[0]
            paths[current_path][method] = {}
    if not paths:
        raise ValueError("Could not parse OpenAPI YAML without PyYAML; install pyyaml or pass JSON.")
    return {"paths": paths}


def load_spec(path: str) -> Dict[PathKey, Set[Method]]:
    text = open(path, encoding="utf-8").read()
    if path.endswith(".json"):
        data = _parse_openapi_json(text)
    else:
        if yaml is not None:
            data = yaml.safe_load(text)
        else:
            # try JSON first, then simple YAML
            try:
                data = _parse_openapi_json(text)
            except json.JSONDecodeError:
                data = _parse_openapi_yaml_simple(text)
    paths = data.get("paths") or {}
    out: Dict[PathKey, Set[Method]] = {}
    http = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
    for p, item in paths.items():
        if not isinstance(item, dict):
            continue
        methods = {normalize_method(m) for m in item if m.lower() in http}
        if methods:
            out[normalize_path(p)] = methods
    return out


def compare(spec: Dict[PathKey, Set[Method]], code: Dict[PathKey, Set[Method]]) -> List[dict]:
    findings = []
    all_paths = set(spec) | set(code)
    for p in sorted(all_paths):
        s = spec.get(p, set())
        c = code.get(p, set())
        if s and not c:
            for m in sorted(s):
                findings.append({"kind": "missing_in_code", "severity": "high", "method": m, "path": p})
        elif c and not s:
            for m in sorted(c):
                findings.append({"kind": "missing_in_spec", "severity": "medium", "method": m, "path": p})
        else:
            only_spec = s - c
            only_code = c - s
            for m in sorted(only_spec):
                findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "spec_only"})
            for m in sorted(only_code):
                findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "code_only"})
    return findings


def format_report(findings: List[dict]) -> str:
    counts: Dict[str, int] = {}
    for f in findings:
        counts[f["kind"]] = counts.get(f["kind"], 0) + 1
    lines = ["Drift summary: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))]
    if not findings:
        lines.append("No drift found.")
        return "\n".join(lines)
    for f in findings:
        side = f.get("side", "")
        extra = f" ({side})" if side else ""
        lines.append(f"- [{f['severity']}] {f['kind']}: {f['method']} {f['path']}{extra}")
    return "\n".join(lines)


def self_test() -> int:
    spec = {
        normalize_path("/pets"): {"GET", "POST"},
        normalize_path("/pets/{petId}"): {"GET", "DELETE"},
        normalize_path("/health"): {"GET"},
    }
    code = {
        normalize_path("/pets"): {"GET", "POST"},
        normalize_path("/pets/{id}"): {"GET"},  # same shape, missing DELETE
        normalize_path("/pets/{id}/adopt"): {"POST"},  # extra
    }
    findings = compare(spec, code)
    kinds = sorted(f["kind"] + ":" + f["method"] + ":" + f["path"] for f in findings)

    cases = [
        ("missing delete on pet", any(f["kind"] == "method_mismatch" and f["method"] == "DELETE" for f in findings), True),
        ("extra adopt route", any(f["kind"] == "missing_in_spec" and f["path"].endswith("/adopt") for f in findings), True),
        ("health missing in code", any(f["kind"] == "missing_in_code" and f["path"] == "/health" for f in findings), True),
        ("list pets matched", not any(f["path"] == "/pets" and f["kind"] == "missing_in_code" for f in findings), True),
        ("normalize params", normalize_path("/pets/{petId}") == normalize_path("/pets/{id}"), True),
        ("normalize slash", normalize_path("/pets/") == "/pets", True),
        ("parse routes", parse_routes(["GET /pets", "POST /pets # create"]) == {"/pets": {"GET", "POST"}}, True),
        ("empty compare", compare({"/x": {"GET"}}, {"/x": {"GET"}}) == [], True),
    ]

    # parse simple yaml fallback
    sample = """openapi: 3.0.3\npaths:\n  /pets:\n    get:\n      summary: x\n    post:\n      summary: y\n"""
    parsed = _parse_openapi_yaml_simple(sample)
    cases.append(("yaml fallback paths", "get" in parsed["paths"]["/pets"] and "post" in parsed["paths"]["/pets"], True))

    failed = 0
    for name, got, expected in cases:
        ok = got == expected
        failed += not ok
        print(f"{'PASS' if ok else 'FAIL'}  {name}: got={got!r} expected={expected!r}")
    # also assert finding count sanity
    ok = len(findings) >= 3
    failed += not ok
    print(f"{'PASS' if ok else 'FAIL'}  findings_count>={3}: got={len(findings)}")
    print(f"\n{len(cases)+1 - failed}/{len(cases)+1} passed")
    return 1 if failed else 0


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description="Compare OpenAPI paths to implemented routes.")
    p.add_argument("--spec", help="OpenAPI YAML or JSON file")
    p.add_argument("--routes", help="File with METHOD /path lines")
    p.add_argument("--route", action="append", default=[], help="Single METHOD /path (repeatable)")
    p.add_argument("--json", action="store_true", help="Print findings as JSON")
    p.add_argument("--self-test", action="store_true")
    a = p.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.spec:
        p.error("--spec is required (or use --self-test)")
    route_lines: List[str] = list(a.route)
    if a.routes:
        route_lines.extend(open(a.routes, encoding="utf-8").read().splitlines())
    if not route_lines:
        p.error("provide --routes and/or --route")
    spec = load_spec(a.spec)
    code = parse_routes(route_lines)
    findings = compare(spec, code)
    if a.json:
        print(json.dumps(findings, indent=2))
    else:
        print(format_report(findings))
    return 0 if not findings else 2


if __name__ == "__main__":
    sys.exit(main())
BackendWeb DevelopmentBest Practices+3
F@f
0
Trình Theo Dõi Nợ Kỹ Thuật Từ Bình Luận TODO
Kỹ năng

Quét codebase để tìm các bình luận TODO, FIXME, HACK và XXX, trích xuất người phụ trách, mã ticket và ngày đến hạn, đồng thời gắn cờ các mục không có người phụ trách, chưa được theo dõi, quá hạn hoặc mơ hồ kèm điểm ưu tiên. Sử dụng khi dọn dẹp nợ kỹ thuật, trước khi phát hành bản mới, hoặc khi cần trả lời câu hỏi "những TODO nào thực sự quan trọng?". Bao gồm công cụ quét bằng thư viện chuẩn Python với cờ --self-test và mã thoát thân thiện với CI.

---
name: todo-comment-debt-tracker
description: Quét codebase để tìm các bình luận TODO, FIXME, HACK và XXX, trích xuất người phụ trách, mã ticket và ngày đến hạn, đồng thời gắn cờ các mục không có người phụ trách, chưa được theo dõi, quá hạn hoặc mơ hồ kèm điểm ưu tiên. Sử dụng khi dọn dẹp nợ kỹ thuật, trước khi phát hành bản mới, hoặc khi cần trả lời câu hỏi "những TODO nào thực sự quan trọng?". Bao gồm công cụ quét bằng thư viện chuẩn Python với cờ --self-test và mã thoát thân thiện với CI.
---

# Trình Theo Dõi Nợ Kỹ Thuật Từ Bình Luận TODO

Bạn giúp các nhóm biến những bình luận TODO, FIXME, HACK và XXX nằm rải rác thành một danh sách nợ kỹ thuật ngắn gọn, có người phụ trách và được sắp xếp theo mức độ ưu tiên. Bạn quét mã nguồn, phân tích xem ai phụ trách từng mục, ticket nào đang theo dõi mục đó và thời hạn hoàn thành là khi nào, sau đó làm nổi bật những mục tiềm ẩn rủi ro hoặc bị lãng quên. Bạn không bao giờ tự ý xóa hoặc viết lại các bình luận; bạn chỉ đề xuất thay đổi và để nhóm tự quyết định.

## Các tệp trong kỹ năng này

- `scripts/todo_debt.py` - quét các tệp hoặc thư mục, phân tích người phụ trách/ticket/ngày đến hạn, chấm điểm từng mục và in ra bảng markdown hoặc JSON (chỉ dùng thư viện chuẩn Python 3)
- `references/comment-conventions.md` - định dạng TODO được khuyến nghị và cách áp dụng mà không gây xáo trộn
- `references/triage-rules.md` - cách quyết định nên sửa ngay, tạo ticket hay xóa bỏ
- `templates/debt-report.md` - mẫu báo cáo cho buổi đánh giá sprint, danh sách kiểm tra trước phát hành hoặc bình luận trên PR
- `examples/sample_service.py` - một tệp nhỏ chứa các TODO tốt và xấu điển hình để chạy thử nghiệm

## Quy trình làm việc

### 1. Xác định phạm vi quét
Hỏi xem những thư mục nào cần quan tâm và những gì cần bỏ qua (mã được sinh tự động, thư viện bên thứ ba, dữ liệu mẫu). Kịch bản đã mặc định bỏ qua `.git`, `node_modules`, `vendor`, `dist`, `build`, các môi trường ảo virtualenv và `target`.

### 2. Chạy công cụ quét
```
python3 scripts/todo_debt.py src/ --today 2026-10-18
python3 scripts/todo_debt.py src/ tests/ --ext .py,.ts --json > todo-debt.json
python3 scripts/todo_debt.py examples/sample_service.py --strict
python3 scripts/todo_debt.py --self-test
```
Trả về mã thoát 1 khi có một mục bị quá hạn hoặc một FIXME/HACK/XXX không có mã ticket (`--strict`: bất kỳ vấn đề nào). Hãy sử dụng công cụ này như một cổng kiểm tra CI mềm hoặc một tác vụ báo cáo hàng tuần.

### 3. Đọc các vấn đề được phát hiện
- **overdue**: đã qua ngày đến hạn. Ưu tiên cao nhất.
- **no_ticket**: không có gì theo dõi công việc này; nó sẽ bị lãng quên.
- **stale_hack**: một bình luận HACK hoặc XXX không có ticket, thường là một giải pháp tạm thời đã tồn tại vượt quá lý do ban đầu của nó.
- **no_owner**: không có ai chịu trách nhiệm.
- **vague**: ít hơn ba từ, chẳng hạn như "TODO: fix".

Điểm số: FIXME/HACK/XXX bắt đầu ở mức 3 điểm, TODO ở mức 1 điểm; +2 nếu quá hạn, +1 nếu không có ticket.

### 4. Phân loại cùng với nhóm
Áp dụng `references/triage-rules.md`: sửa ngay nếu mất dưới 30 phút, tạo ticket và thêm mã id của ticket vào nếu đó là công việc thực sự cần làm, xóa đi nếu nó đã lỗi thời. Sử dụng `git blame` trên dòng mã đó để tìm người có khả năng phụ trách nhất khi chưa ghi tên ai.

### 5. Báo cáo
Điền vào `templates/debt-report.md` các mục có điểm cao nhất, tổng số lượng theo nhãn đánh dấu và người phụ trách, cùng các hành động đã thống nhất. Đề xuất quy ước trong `references/comment-conventions.md` cho các bình luận mới.

## Quy tắc

- Không đếm các nhãn đánh dấu nằm bên trong chuỗi ký tự (string literals) hoặc văn bản thuần túy; chỉ đếm trong các bình luận mã nguồn.
- Không bao giờ tự bịa ra mã số ticket hoặc tên người phụ trách; hãy đánh dấu chúng là chưa xác định (unknown) và gợi ý cách tìm ra chúng.
- Không xóa hàng loạt bình luận mà không qua kiểm duyệt; thay vào đó hãy đề xuất một bản vá (patch).
- Tôn trọng mã được sinh tự động và mã thư viện bên thứ ba (vendored code): báo cáo riêng hoặc bỏ qua chúng.
FILE:references/comment-conventions.md
# Quy ước TODO được khuyến nghị

```
# TODO(owner, TICKET-123, 2026-12-01): việc cần làm và lý do
```

- **owner**: tên người dùng hoặc tên nhóm (`alice`, `@payments-team`). Một cái tên duy nhất, là người sẽ đưa ra quyết định xử lý.
- **ticket**: `#123`, `PROJ-123`, hoặc một URL issue đầy đủ. Bắt buộc đối với FIXME, HACK và XXX.
- **due date**: định dạng ISO `YYYY-MM-DD`, tùy chọn đối với TODO, được khuyến nghị cho các giải pháp tạm thời HACK.
- **text**: một hành động kèm theo lý do, ít nhất là một câu ngắn. "TODO: fix" là không hữu ích.

## Nên sử dụng nhãn đánh dấu nào
| Nhãn đánh dấu | Ý nghĩa | Bắt buộc có ticket? |
|---|---|---|
| TODO | Cải tiến đã lên kế hoạch, hiện tại chưa gây lỗi | Khuyến nghị |
| FIXME | Lỗi đã biết hoặc hành vi không chính xác | Có |
| HACK | Giải pháp tạm thời có chủ đích cần được gỡ bỏ sau này | Có, kèm theo ngày đến hạn |
| XXX | Nguy hiểm, đoạn mã gây bất ngờ, cần được xem xét kỹ | Có |

## Áp dụng mà không gây xáo trộn
1. Chạy công cụ quét và công bố báo cáo; không chỉnh sửa hàng loạt.
2. Áp dụng quy ước cho các bình luận mới thông qua quy trình duyệt mã (code review) hoặc quy tắc kiểm tra mã (lint rule).
3. Sửa các bình luận cũ khi bạn đằng nào cũng phải chạm vào đoạn mã đó.
4. Xem lại tổng số liệu hàng tháng; hướng tới mục tiêu "không có mục quá hạn, không có FIXME chưa được theo dõi".
FILE:references/triage-rules.md
# Quy tắc phân loại

Đối với từng mục, hãy đặt câu hỏi theo thứ tự sau:

1. **Nó có còn đúng không?** Đọc đoạn mã xung quanh và chạy `git log -L` cho các dòng đó. Nếu vấn đề không còn tồn tại, hãy xóa bình luận.
2. **Nó có mất dưới 30 phút không?** Sửa ngay trong một PR nhỏ đồng thời xóa luôn bình luận đó.
3. **Nó có phải là lỗi hay rủi ro không?** (FIXME/XXX, bảo mật, mất dữ liệu, tiền bạc) Tạo một ticket có gắn mức độ ưu tiên, thêm mã id của ticket vào bình luận và chỉ định người phụ trách.
4. **Nó có phải là một giải pháp tạm thời không?** (HACK) Ghi rõ điều kiện cho phép gỡ bỏ ("gỡ bỏ sau khi lên SDK nhà cung cấp bản 5.x") và ngày đến hạn.
5. **Nó có phải là một mong muốn viển vông không?** Nếu không ai định lên lịch làm nó trong hai quý tới, hãy xóa nó đi hoặc chuyển nó thành một ticket trong backlog.

## Gợi ý xếp hạng ưu tiên
- Ưu tiên các mục quá hạn trước, sau đó đến FIXME/HACK/XXX không có ticket, rồi đến các mục không có người phụ trách nằm trong các tệp nóng (thay đổi thường xuyên).
- Các mục nằm trong mã xác thực (auth), thanh toán (payments), di chuyển dữ liệu (data migrations) hoặc xử lý đồng thời (concurrency) xứng đáng được xem xét kỹ hơn bất kể điểm số.

## Tìm người phụ trách
- `git blame -L <line>,<line> <file>` cho biết ai đã thêm bình luận đó; người đó hoặc nhóm của họ mặc định là người phụ trách.
- Các tệp CODEOWNERS ánh xạ đường dẫn thư mục với các nhóm phụ trách.
- Nếu tác giả đã nghỉ việc, hãy giao cho người bảo trì hiện tại của mô-đun đó.
FILE:templates/debt-report.md
# Báo Cáo Nợ Kỹ Thuật TODO

** Kho lưu trữ (Repository):** <name>  **Phạm vi quét:** <paths>  **Ngày:** <YYYY-MM-DD>

## Tổng số lượng
| Nhãn đánh dấu | Số lượng | Quá hạn | Không có ticket | Không có người phụ trách |
|---|---|---|---|---|
| TODO | | | | |
| FIXME | | | | |
| HACK | | | | |
| XXX | | | | |

## 10 mục hàng đầu theo điểm số
| Điểm | Vị trí | Nhãn đánh dấu | Người phụ trách | Ticket | Hạn chót | Vấn đề | Hành động đề xuất |
|---|---|---|---|---|---|---|---|
| | | | | | | | |

## Theo người phụ trách
| Người phụ trách | Số mục | Quá hạn |
|---|---|---|

## Các hành động đã thống nhất
- [ ] Sửa ngay:
- [ ] Đã tạo ticket:
- [ ] Xóa bỏ (đã lỗi thời):

## Nhắc nhở quy ước
`# TODO(owner, TICKET-123, YYYY-MM-DD): hành động và lý do`
FILE:examples/sample_service.py
"""Sample order service with typical TODO comments for demos of todo_debt.py."""
import json

TAX_RATE = 0.2  # XXX(PAY-311): confirm tax rate per region with finance


def load_orders(path):
    # TODO(maria, 2026-03-01): switch to streaming parser once files exceed 50 MB (#88)
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def total(order):
    # FIXME: returns wrong total when a discount exceeds the subtotal
    subtotal = sum(item["price"] * item["qty"] for item in order["items"])
    discount = order.get("discount", 0)
    return round((subtotal - discount) * (1 + TAX_RATE), 2)


def notify(order):
    # HACK [2026-11-30] skip emails for test accounts until the staging mail stub is fixed
    if order.get("email", "").endswith("@test.local"):
        return False
    # TODO: retry
    return True


def label(order):
    note = "TODO: this string is not a comment and should be ignored"
    # TODO(@ken, OPS-19): move label printing to the warehouse service
    return f"{order['id']} {note[:0]}"
FILE:scripts/todo_debt.py
#!/usr/bin/env python3
"""Scan source files for TODO/FIXME/HACK/XXX comments and grade them (Python 3 standard library only).

Recognized forms (marker must follow a comment token: #  //  /*  *  --  ;  <!--):
  # TODO: plain note
  // TODO(alice): owner
  # FIXME(#123): issue number      # TODO(PROJ-42) ticket key      # TODO(https://...) link
  # TODO(bob, 2026-12-01): owner + due date   # HACK [2026-11-15] due date in brackets
Problems flagged per item:
  no_owner, no_ticket, overdue (due date before --today), stale_hack (HACK/XXX without ticket),
  vague (fewer than 3 words of text)
Exit code: 1 if any item is overdue or a FIXME/HACK/XXX has no ticket (with --strict: any problem).
"""
import argparse
import datetime as dt
import json
import os
import re
import sys

MARKERS = ("TODO", "FIXME", "HACK", "XXX")
SEVERITY = {"FIXME": 3, "HACK": 3, "XXX": 3, "TODO": 1}
COMMENT_RE = re.compile(
    r"(?:#|//|/\*|(?:^|\s)\*|--|;|<!--)\s*(?P<marker>TODO|FIXME|HACK|XXX)\b"
    r"(?:\s*\((?P<paren>[^)]*)\))?(?:\s*\[(?P<bracket>[^\]]*)\])?\s*:?\s*(?P<text>.*)$")
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
TICKET_RE = re.compile(r"(#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b|https?://\S+)")
OWNER_RE = re.compile(r"^@?([A-Za-z][\w.-]*)$")
DEFAULT_EXT = {".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt", ".c", ".h", ".cpp",
               ".cs", ".rb", ".php", ".swift", ".sh", ".sql", ".yaml", ".yml", ".toml", ".css", ".scss",
               ".html", ".vue", ".lua"}
SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__", "target"}


def parse_line(line, path="<text>", lineno=0, today=None):
    m = COMMENT_RE.search(line)
    if not m:
        return None
    marker = m.group("marker")
    meta = ",".join(x for x in (m.group("paren"), m.group("bracket")) if x)
    text = (m.group("text") or "").strip().rstrip("*/").rstrip("-->").strip()
    owner = None
    ticket = None
    due = None
    for part in [p.strip() for p in meta.split(",") if p.strip()]:
        if DATE_RE.fullmatch(part.replace("by ", "").replace("until ", "").strip()):
            due = DATE_RE.search(part).group(1)
        elif TICKET_RE.fullmatch(part):
            ticket = part
        elif OWNER_RE.fullmatch(part) and owner is None:
            owner = OWNER_RE.fullmatch(part).group(1)
    if ticket is None:
        t = TICKET_RE.search(text)
        if t:
            ticket = t.group(1)
    if due is None:
        d = re.search(r"\b(?:by|until|before|due)\s+(\d{4}-\d{2}-\d{2})\b", text)
        if d:
            due = d.group(1)
    problems = []
    if not owner:
        problems.append("no_owner")
    if not ticket:
        problems.append("no_ticket")
        if marker in ("HACK", "XXX"):
            problems.append("stale_hack")
    if due and today:
        try:
            if dt.date.fromisoformat(due) < today:
                problems.append("overdue")
        except ValueError:
            problems.append("bad_date")
    if len(re.findall(r"[A-Za-z]{2,}", text)) < 3:
        problems.append("vague")
    return {"file": path, "line": lineno, "marker": marker, "owner": owner, "ticket": ticket,
            "due": due, "text": text, "problems": problems,
            "score": SEVERITY[marker] + 2 * ("overdue" in problems) + ("no_ticket" in problems)}


def scan_text(text, path="<text>", today=None):
    items = []
    for i, line in enumerate(text.splitlines(), 1):
        it = parse_line(line, path, i, today)
        if it:
            items.append(it)
    return items


def iter_files(root, exts):
    if os.path.isfile(root):
        yield root
        return
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
        for fn in sorted(filenames):
            if os.path.splitext(fn)[1].lower() in exts:
                yield os.path.join(dirpath, fn)


def is_blocking(item, strict=False):
    if strict:
        return bool(item["problems"])
    return "overdue" in item["problems"] or (item["marker"] != "TODO" and "no_ticket" in item["problems"])


def summarize(items):
    s = {"total": len(items), "by_marker": {}, "by_owner": {}, "problems": {}}
    for it in items:
        s["by_marker"][it["marker"]] = s["by_marker"].get(it["marker"], 0) + 1
        o = it["owner"] or "(unowned)"
        s["by_owner"][o] = s["by_owner"].get(o, 0) + 1
        for p in it["problems"]:
            s["problems"][p] = s["problems"].get(p, 0) + 1
    return s


def render(items):
    out = ["| Score | Marker | Location | Owner | Ticket | Due | Problems | Text |", "|---|---|---|---|---|---|---|---|"]
    for it in sorted(items, key=lambda x: (-x["score"], x["file"], x["line"])):
        out.append(f"| {it['score']} | {it['marker']} | {it['file']}:{it['line']} | {it['owner'] or '-'} | "
                   f"{it['ticket'] or '-'} | {it['due'] or '-'} | {', '.join(it['problems']) or 'ok'} | {it['text'][:60]} |")
    return "\n".join(out)


SAMPLE = '''import os
# TODO(alice, 2026-01-15): remove legacy CSV export after #412 ships
def f():
    pass  # FIXME: crashes when list is empty
// HACK [2026-12-31] skip auth in local dev
/* XXX(PROJ-77): double-check rounding with finance */
-- TODO(@bob): add index
x = "TODO: this is a string, not a comment"
<!-- TODO(carol): https://example.com/issues/9 replace hero image -->
 * TODO update docs by 2025-05-01
# todo lowercase is ignored
'''


def self_test():
    today = dt.date(2026, 10, 18)
    items = scan_text(SAMPLE, "sample", today)
    by_line = {it["line"]: it for it in items}
    cases = [
        ("count", len(items), 7),
        ("owner_parsed", by_line[2]["owner"], "alice"),
        ("due_parsed", by_line[2]["due"], "2026-01-15"),
        ("overdue_flag", "overdue" in by_line[2]["problems"], True),
        ("ticket_from_text", by_line[2]["ticket"], "#412"),
        ("fixme_no_ticket", by_line[4]["problems"][:2], ["no_owner", "no_ticket"]),
        ("fixme_blocking", is_blocking(by_line[4]), True),
        ("hack_bracket_due_future", "overdue" in by_line[5]["problems"], False),
        ("hack_stale", "stale_hack" in by_line[5]["problems"], True),
        ("xxx_ticket_key", by_line[6]["ticket"], "PROJ-77"),
        ("at_owner", by_line[7]["owner"], "bob"),
        ("vague_short", "vague" in by_line[7]["problems"], True),
        ("string_not_comment", 8 in by_line, False),
        ("html_comment_link", by_line[9]["ticket"], "https://example.com/issues/9"),
        ("star_comment_due_text", by_line[10]["due"], "2025-05-01"),
        ("lowercase_ignored", 11 in by_line, False),
        ("todo_not_blocking_default", is_blocking(by_line[7]), False),
        ("todo_blocking_strict", is_blocking(by_line[7], strict=True), True),
        ("summary_markers", summarize(items)["by_marker"], {"TODO": 4, "FIXME": 1, "HACK": 1, "XXX": 1}),
        ("score_overdue_todo", by_line[2]["score"], 3),
    ]
    failed = 0
    for name, got, expected in cases:
        ok = got == expected
        failed += not ok
        print(f"{'PASS' if ok else 'FAIL'}  {name}: got={got!r} expected={expected!r}")
    print(f"\n{len(cases) - failed}/{len(cases)} passed")
    return 1 if failed else 0


def main(argv=None):
    p = argparse.ArgumentParser(description="Find and grade TODO/FIXME/HACK/XXX comments.")
    p.add_argument("paths", nargs="*", default=[], help="files or directories to scan")
    p.add_argument("--today", help="YYYY-MM-DD for overdue checks (default: today)")
    p.add_argument("--ext", help="comma-separated extensions to include, e.g. .py,.ts")
    p.add_argument("--json", action="store_true")
    p.add_argument("--strict", action="store_true", help="exit 1 on any problem")
    p.add_argument("--self-test", action="store_true")
    a = p.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.paths:
        p.error("give files/directories to scan, or --self-test")
    today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
    exts = {e if e.startswith(".") else "." + e for e in a.ext.split(",")} if a.ext else DEFAULT_EXT
    items = []
    for root in a.paths:
        for path in iter_files(root, exts):
            try:
                with open(path, encoding="utf-8", errors="replace") as fh:
                    items += scan_text(fh.read(), path, today)
            except OSError as e:
                print(f"warn: {path}: {e}", file=sys.stderr)
    if a.json:
        print(json.dumps({"summary": summarize(items), "items": items}, indent=2))
    else:
        print(render(items))
        s = summarize(items)
        print(f"\nTotal {s['total']}; markers {s['by_marker']}; problems {s['problems']}")
    return 1 if any(is_blocking(it, a.strict) for it in items) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonCode ReviewQuality+2
F@f
0
Quy trình Git taste
Văn bản

Các ưu tiên mang tính định hướng rõ ràng cho việc phân nhánh, commit, tạo pull request và phát hành trong quy trình làm việc Git dựa trên nhánh chính (trunk-based).

# Phong cách (taste) quy trình làm việc Git của một đội ngũ phát triển theo mô hình trunk-based
- Làm việc theo mô hình trunk-based: tạo các nhánh có vòng đời ngắn tách ra từ nhánh main và gộp (merge) lại trong vòng hai ngày. Độ tin cậy: 0.90
- Đặt tên nhánh theo cấu trúc <type>/<short-topic>, ví dụ: fix/login-redirect. Độ tin cậy: 0.75
- Viết tiêu đề commit ở thức mệnh lệnh (imperative mood) và giữ độ dài dưới 72 ký tự. Độ tin cậy: 0.90
- Sử dụng các tiền tố theo chuẩn Conventional Commits (feat, fix, chore, docs, refactor) để có thể tự động tạo nhật ký thay đổi (changelog). Độ tin cậy: 0.80
- Giải thích lý do tại sao thay đổi được thực hiện trong phần thân (body) của commit, chứ không nhắc lại những gì bản so sánh (diff) đã hiển thị. Độ tin cậy: 0.85
- Giữ cho mỗi commit luôn có thể biên dịch/build thành công và chỉ tập trung vào một thay đổi hợp lý duy nhất. Độ tin cậy: 0.85
- Rebase nhánh của bạn lên trên nhánh main thay vì merge nhánh main vào nhánh của bạn. Độ tin cậy: 0.80
- Thực hiện squash-merge đối với các pull request để nhánh main chỉ có một commit duy nhất cho mỗi thay đổi. Độ tin cậy: 0.75
- Giữ cho các pull request có dưới khoảng 400 dòng thay đổi; chia nhỏ các công việc lớn hơn thành chuỗi các PR xếp chồng nhau (stacked PRs). Độ tin cậy: 0.85
- Đưa phần mô tả ngắn gọn gồm làm gì (what), tại sao (why) và cách kiểm thử (how-to-test) vào mọi phần mô tả của pull request. Độ tin cậy: 0.85
- Yêu cầu tối thiểu một đánh giá chấp thuận (approving review) và hệ thống CI báo xanh trước khi gộp vào nhánh main. Độ tin cậy: 0.90
- Bảo vệ nhánh main: không cho phép đẩy mã trực tiếp (direct push) và không cho phép ép đẩy mã (force push). Độ tin cậy: 0.95
- Chỉ thực hiện force push trên nhánh của chính bạn và luôn đi kèm cờ --force-with-lease. Độ tin cậy: 0.90
- Ẩn các tính năng chưa hoàn thiện đằng sau các cờ tính năng (feature flags) thay vì duy trì các nhánh tính năng kéo dài. Độ tin cậy: 0.85
- Gắn thẻ (tag) các bản phát hành bằng thẻ phiên bản ngữ nghĩa (semantic version) như v1.4.2 trên nhánh main. Độ tin cậy: 0.85
- Không bao giờ commit thông tin bí mật (secrets); hãy chạy công cụ quét bí mật trong cả pre-commit hook và hệ thống CI. Độ tin cậy: 0.95
- Giữ cho các pre-commit hook chạy nhanh (dưới năm giây) và để các bước kiểm tra tốn thời gian cho hệ thống CI. Độ tin cậy: 0.80
- Sử dụng tệp .gitattributes để chuẩn hóa ký tự xuống dòng (line endings) và đánh dấu các tệp được sinh tự động. Độ tin cậy: 0.70
- Lưu trữ các tệp nhị phân dung lượng lớn trong Git LFS hoặc dịch vụ lưu trữ đối tượng (object storage), không bao giờ đưa trực tiếp vào kho mã nguồn (repo). Độ tin cậy: 0.85
- Sử dụng git bisect kết hợp với kịch bản kiểm thử tự động để truy tìm nguyên nhân gây lỗi hồi quy (regressions). Độ tin cậy: 0.70
- Hoàn tác (revert) một commit gây lỗi trên nhánh main trước tiên rồi mới tiến hành điều tra nguyên nhân sau. Độ tin cậy: 0.85
- Thêm tệp CODEOWNERS để hệ thống tự động gửi yêu cầu đánh giá đến đúng người phụ trách. Độ tin cậy: 0.75
- Tự động xóa các nhánh sau khi chúng đã được gộp thành công. Độ tin cậy: 0.90
Best PracticesWorkflowCode Review+2
F@f
0
Đánh giá Mã nguồn (Code Review) taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để viết, đánh giá và hợp nhất (merge) các pull request.

# Đánh giá Mã nguồn (Code Review) taste của một kỹ sư cấp cao (Staff Engineer)
- Giữ cho các pull request đủ nhỏ để có thể đánh giá xong trong một lần ngồi xem, lý tưởng nhất là dưới 400 dòng thay đổi; tách biệt việc tái cấu trúc mã (refactor) khỏi các thay đổi về hành vi. Độ tin cậy: 0.90
- Viết mô tả PR nêu rõ lý do (why), cách tiếp cận, cách thức đã kiểm thử và những gì nằm ngoài phạm vi (out of scope). Độ tin cậy: 0.90
- Tự đánh giá (self-review) bản diff trước khi yêu cầu người khác review và để lại bình luận ở những phần không hiển nhiên. Độ tin cậy: 0.85
- Để các công cụ định dạng (formatters), kiểm tra lỗi cú pháp (linters) và kiểm tra kiểu (type checkers) xử lý vấn đề quy chuẩn trình bày trong CI; con người không bao giờ bình luận về khoảng trắng hay thứ tự import. Độ tin cậy: 0.95
- Đánh giá tính đúng đắn, thiết kế và khả năng bảo trì trước tiên, việc đặt tên và tính dễ đọc xếp thứ hai. Độ tin cậy: 0.85
- Thêm tiền tố chỉ mức độ quan trọng vào đầu mỗi bình luận (nit:, suggestion:, question:, blocking:) để tác giả biết điều gì bắt buộc phải thay đổi. Độ tin cậy: 0.85
- Đặt câu hỏi thay vì ra mệnh lệnh ("Điều gì sẽ xảy ra nếu danh sách này trống?"). Độ tin cậy: 0.80
- Phê duyệt (Approve) kèm theo các góp ý nhỏ (minor nits) thay vì chặn (blocking); tin tưởng rằng tác giả sẽ xử lý chúng trước khi hợp nhất (merge). Độ tin cậy: 0.80
- Phản hồi các yêu cầu đánh giá trong vòng một ngày làm việc; một PR bị ngâm lâu gây tốn kém hơn nhiều so với một giờ làm việc bị gián đoạn. Độ tin cậy: 0.85
- Yêu cầu phải có bài kiểm thử cho mọi bản sửa lỗi (bug fix) mà bài kiểm thử đó sẽ thất bại nếu chạy trước khi sửa. Độ tin cậy: 0.90
- Kiểm tra các luồng lỗi, thời gian chờ (timeouts), cơ chế thử lại (retries) và việc ghi log, chứ không chỉ kiểm tra luồng thành công lý tưởng (happy path). Độ tin cậy: 0.85
- Đánh dấu bất kỳ thư viện phụ thuộc (dependency) mới nào và đặt câu hỏi liệu nó có còn được bảo trì, cấp phép đúng quy định và xứng đáng với dung lượng tăng thêm hay không. Độ tin cậy: 0.80
- Coi các bản di trú cơ sở dữ liệu (database migrations), thay đổi về xác thực (auth) và thay đổi API công khai là rủi ro cao và yêu cầu phải có người đánh giá thứ hai. Độ tin cậy: 0.85
- Kéo nhánh (branch) về và chạy thử trên máy cục bộ đối với các thay đổi về giao diện (UI) hoặc hành vi mà ảnh chụp màn hình không thể chứng minh được. Độ tin cậy: 0.70
- Đề xuất đoạn mã cụ thể bằng tính năng gợi ý (suggestion feature) thay vì chỉ mô tả thay đổi bằng văn xuôi. Độ tin cậy: 0.75
- Giải quyết các chuỗi thảo luận qua lại kéo dài bằng một cuộc gọi ngắn, sau đó ghi lại kết quả thống nhất vào trong PR. Độ tin cậy: 0.80
- Tác giả chỉ đóng (resolve) các cuộc thảo luận sau khi mối bận tâm của người đánh giá đã được giải quyết hoặc được người đánh giá đồng ý bỏ qua một cách rõ ràng. Độ tin cậy: 0.70
- Sử dụng CODEOWNERS cho các đường dẫn quan trọng nhưng tránh biến một cá nhân thành điểm nghẽn (bottleneck) cho toàn bộ kho mã nguồn (repo). Độ tin cậy: 0.80
- Ưu tiên gộp commit khi hợp nhất (squash merges) kèm theo thông điệp conventional commit sạch sẽ để duy trì lịch sử tuyến tính. Độ tin cậy: 0.75
- Khen ngợi rõ ràng các giải pháp tốt; quá trình đánh giá mã nguồn cũng là cách để cả nhóm học hỏi thế nào là một mã nguồn tốt. Độ tin cậy: 0.75
- Không bao giờ tự hợp nhất (merge) PR của chính mình mà không qua đánh giá trên các nhánh dùng chung, ngoại trừ các bản vá khẩn cấp (emergency hotfixes) đã được ghi nhận thành tài liệu và sẽ được đánh giá bổ sung ngay sau đó. Độ tin cậy: 0.85
Best PracticesProductivityCollaboration+2
F@f
0
Trình Đánh Giá Thiết Kế Lược Đồ SQL (SQL Schema Design Reviewer)
Kỹ năng

Đánh giá các bảng PostgreSQL và MySQL (tệp schema.sql, bản dump hoặc lệnh CREATE TABLE trong pull request) để tìm ra các lỗi thiết kế có thể sửa dễ dàng và ít tốn kém trước khi có dữ liệu thực tế - thiếu khóa chính, lưu tiền tệ bằng cột số thực (float), khóa ngoại lệch kiểu dữ liệu hoặc thiếu chỉ mục (index), ID 32-bit, dấu thời gian (timestamp) không kèm múi giờ, các cờ (flags) cho phép NULL, trạng thái dạng văn bản tự do, đặt tên trùng từ khóa dành riêng và email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường. Trả về mã DDL đã được hiệu chỉnh. Bao gồm một công cụ linter viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: sql-schema-design-reviewer
description: Đánh giá các bảng PostgreSQL và MySQL (tệp schema.sql, bản dump hoặc lệnh CREATE TABLE trong pull request) để tìm ra các lỗi thiết kế có thể sửa dễ dàng và ít tốn kém trước khi có dữ liệu thực tế - thiếu khóa chính, lưu tiền tệ bằng cột số thực (float), khóa ngoại lệch kiểu dữ liệu hoặc thiếu chỉ mục (index), ID 32-bit, dấu thời gian (timestamp) không kèm múi giờ, các cờ (flags) cho phép NULL, trạng thái dạng văn bản tự do, đặt tên trùng từ khóa dành riêng và email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường. Trả về mã DDL đã được hiệu chỉnh. Bao gồm một công cụ linter viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Trình Đánh Giá Thiết Kế Lược Đồ SQL (SQL Schema Design Reviewer)

Bạn đánh giá các lược đồ cơ sở dữ liệu (database schemas) theo đúng cách mà một chuyên gia mô hình hóa dữ liệu giàu kinh nghiệm sẽ làm ở giai đoạn thiết kế: ngay trước khi dòng dữ liệu đầu tiên được ghi vào, khi việc thay đổi một kiểu dữ liệu hay một khóa chỉ là sửa một dòng mã thay vì phải chạy migration vất vả suốt cả dịp cuối tuần. Bạn xem xét cấu trúc hình thái của dữ liệu (khóa, kiểu dữ liệu, ràng buộc, cách đặt tên, chỉ mục), giải thích rõ sự cố thực tế mà từng vấn đề sẽ gây ra, và trả lại mã DDL đã được sửa chuẩn chỉnh.

Kỹ năng này tập trung đánh giá thiết kế của các bảng. Nó không bàn về việc triển khai thay đổi lên một cơ sở dữ liệu đang chạy thực tế (khóa bảng, điền dữ liệu ngược - backfill, triển khai mở rộng/thu hẹp - expand/contract); đối với nhu cầu đó, hãy sử dụng quy trình đánh giá độ an toàn của migration.

## Các tệp trong kỹ năng này

- `scripts/schema_lint.py` - phân tích cú pháp các câu lệnh `CREATE TABLE`, `CREATE INDEX` và `ALTER TABLE ... ADD`, đồng thời báo cáo các vấn đề về khóa, kiểu dữ liệu, ràng buộc, cách đặt tên và chỉ mục kèm theo mức độ nghiêm trọng và cách khắc phục; tự động nhận diện PostgreSQL hoặc MySQL (chỉ sử dụng thư viện chuẩn Python 3, không cần kết nối cơ sở dữ liệu)
- `references/design-rules.md` - toàn bộ mã lỗi phát hiện (finding codes) kèm theo sự cố mà nó gây ra, trường hợp nào có thể chấp nhận giữ nguyên và cách khắc phục
- `references/type-choices.md` - hướng dẫn chọn kiểu dữ liệu nào cho ID, tiền tệ, thời gian, văn bản, cờ (flags), trạng thái và JSON trong PostgreSQL và MySQL
- `templates/schema-review.md` - mẫu báo cáo đánh giá để bàn giao lại
- `examples/shop-schema.sql` - một lược đồ cửa hàng trực tuyến nhỏ chứa các lỗi thiết kế được cài cắm có chủ đích
- `examples/shop-schema-fixed.sql` - cùng lược đồ đó sau khi được đánh giá và sửa lỗi; vượt qua kiểm tra lint hoàn toàn sạch sẽ

## Quy trình làm việc

### 1. Thu thập lược đồ và ngữ cảnh
Yêu cầu cung cấp mã DDL (`schema.sql`, `pg_dump --schema-only`, `mysqldump --no-data`, hoặc câu lệnh `CREATE TABLE` từ pull request), hệ quản trị cơ sở dữ liệu (engine) và phiên bản, số lượng dòng dự kiến của mỗi bảng trong một năm, và cách dữ liệu được sử dụng (ứng dụng OLTP, báo cáo, đa tổ chức - multi-tenant, sao chép dữ liệu - replicated, hay truy xuất thông qua ORM). Hãy hỏi xem nhóm đã có sẵn những quy tắc nội bộ nào chưa (dùng UUID hay `bigint` cho ID, xóa mềm - soft deletes, quy tắc đặt tên).

### 2. Chạy công cụ linter
```
python3 scripts/schema_lint.py examples/shop-schema.sql
python3 scripts/schema_lint.py schema.sql --dialect mysql --min-severity MEDIUM
python3 scripts/schema_lint.py db/*.sql --ignore NO_CREATED_AT --json > schema-review.json
python3 scripts/schema_lint.py --self-test
```
Mã thoát (exit code) 0 có nghĩa là không có phát hiện mức HIGH nào, 1 có nghĩa là có ít nhất một lỗi mức HIGH (dùng làm cổng chặn CI trên các tệp schema), 2 có nghĩa là dữ liệu đầu vào không hợp lệ. Công cụ linter hoạt động theo hướng suy nghiệm (heuristic): nó đọc tên cột (ví dụ cột `price` dùng kiểu float, cột `order_id` không có khóa ngoại), vì vậy hãy đối chiếu xác nhận từng phát hiện với ý đồ thực sự của người dùng.

### 3. Đánh giá những điểm mà script không thể nhìn thấy
Sau đó, hãy tự đọc kỹ lược đồ (xem thêm `references/design-rules.md`):
- Mỗi bảng có mang một ý nghĩa rõ ràng duy nhất và có một khóa tự nhiên duy nhất (natural unique key) bên cạnh ID tự tăng (surrogate id) hay không (ví dụ: `sku`, `email`, `provider + provider_id`)?
- Các mối quan hệ đã chuẩn xác chưa: quan hệ một-nhiều (one-to-many) so với nhiều-nhiều (many-to-many), hành vi `ON DELETE` (`RESTRICT` làm mặc định; chỉ dùng `CASCADE` cho các bảng con phụ thuộc hoàn toàn), khóa ngoại chỉ cho phép `NULL` ở những nơi mối quan hệ thực sự là tùy chọn (optional)?
- Các bất biến dữ liệu (invariants) có được đặt trong ràng buộc `CHECK` (`quantity > 0`, `ends_at > starts_at`) thay vì chỉ kiểm tra trong mã nguồn ứng dụng hay không?
- Đa tổ chức (Multi-tenant): mọi bảng thuộc sở hữu của tenant có mang cột `tenant_id` không, cột đó có nằm trong các khóa duy nhất (unique keys) và đứng ở vị trí cột đầu tiên của chỉ mục (leading index column) không?
- Xóa mềm (Soft deletes): các ràng buộc duy nhất có phải là ràng buộc cục bộ có điều kiện (`WHERE deleted_at IS NULL`) không?
- Dữ liệu cá nhân: những cột nào chứa thông tin định danh cá nhân (PII), và chúng có cần quy tắc kiểm soát truy cập hoặc thời hạn lưu trữ riêng biệt không?

### 4. Bàn giao kết quả
Điền vào mẫu `templates/schema-review.md`: kết luận tổng quan (verdict), các phát hiện được xếp hạng theo mức độ ảnh hưởng kèm theo sự cố thực tế mà từng lỗi gây ra, tiếp theo là mã DDL đã được hiệu chỉnh (xem `examples/shop-schema-fixed.sql`) và danh sách các quyết định dành quyền tự chọn cho nhóm. Nếu các bảng đã chứa dữ liệu thực tế, hãy chỉ rõ những sửa đổi nào hiện cần viết kịch bản di chuyển dữ liệu (migration) và gắn cờ chuyển sang bước đánh giá an toàn migration thay vì viết lại trực tiếp các bảng đang chạy.

## Ví dụ kết quả
`examples/shop-schema.sql` (PostgreSQL, 5 bảng): 20 phát hiện, 4 lỗi mức HIGH. Bảng `order_items` không có khóa chính (có thể bị trùng lặp dòng); `products.price` dùng kiểu `double precision` và `payments.amount` dùng kiểu `real` (gây sai lệch từng xu khi tính toán); `payments.order_id` là `integer` nhưng lại tham chiếu tới `"order".id` (`bigserial`). Mức MEDIUM: dùng ID kiểu `serial` trên bảng `customers` và `products`, dùng `timestamp without time zone` trên `customers.created_at` và `placed_at`, đặt tên bảng là `"order"` (trùng từ khóa dành riêng của SQL), khóa ngoại trên `"order".customer_id` và `order_items.order_id` không có chỉ mục (index). Mức LOW: email duy nhất (unique) nhưng phân biệt chữ hoa chữ thường, thói quen dùng `varchar(255)`, cột cờ `is_verified` cho phép NULL, cột `status` lưu văn bản tự do, `product_id` không có ràng buộc khóa ngoại, dư thừa một chỉ mục trên bảng `payments`, lưu dữ liệu JSON trong cột kiểu `text`. Tệp `examples/shop-schema-fixed.sql` khắc phục toàn bộ các lỗi trên (đổi tên thành `orders`, dùng `bigint identity`, `numeric(12,2)`, `timestamptz`, `citext`, ràng buộc `CHECK` trên cột `status`, khóa chính tổ hợp trên `order_items`) và vượt qua kiểm tra lint hoàn toàn sạch sẽ.

## Quy tắc

- Giải thích từng phát hiện dưới góc độ sự cố thực tế mà nó gây ra (sai lệch tổng tiền, bản ghi mồ côi, đăng ký trùng lặp tài khoản, sập hệ thống khi chạm ngưỡng 2,1 tỷ ID), chứ không phải như một quy tắc bắt bẻ về mặt hình thức.
- Giữ nguyên các quy ước của người dùng (dùng UUID làm ID, quy tắc đặt tên) trừ khi chúng gây ra vấn đề thực sự; hãy nói rõ khi nào một phát hiện chỉ mang tính chất cân nhắc đánh đổi (judgement call).
- Ưu tiên đặt các ràng buộc (constraints) ngay trong cơ sở dữ liệu hơn là chỉ kiểm tra trong tầng ứng dụng; cơ sở dữ liệu là tuyến phòng thủ cuối cùng.
- Tiền tệ phải chính xác tuyệt đối: dùng `numeric`/`DECIMAL` hoặc số nguyên biểu thị đơn vị tiền tệ nhỏ nhất (như xu/cents), không bao giờ dùng `float`, và phải lưu kèm loại tiền tệ (currency).
- Thời gian là một thời điểm xác định (instant): dùng `timestamptz` trong PostgreSQL, và lưu theo giờ chuẩn UTC ở mọi nơi trong MySQL.
- Không đề xuất sửa đổi trực tiếp các bảng đã chứa dữ liệu trên môi trường production mà không chỉ rõ rằng mỗi thay đổi đó đều đòi hỏi một quy trình migration với những rủi ro riêng.
FILE:references/design-rules.md
# Quy tắc thiết kế và mã lỗi phát hiện (finding codes)

| Mã lỗi | Mức độ | Sự cố xảy ra là gì | Chấp nhận được khi nào | Cách khắc phục |
|---|---|---|---|---|
| NO_PRIMARY_KEY | HIGH | không thể phân biệt các dòng trùng lặp hoặc xóa từng dòng một; sao chép logic (logical replication) và CDC cần có khóa; các ORM hoạt động sai lệch | không bao giờ chấp nhận cho các bảng OLTP (có thể tạm chấp nhận cho bảng tạm staging chỉ nạp vào rồi xóa ngay trong một job) | thêm khóa đại diện `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` hoặc khóa tự nhiên tổ hợp (`order_id`, `product_id`) |
| FLOAT_MONEY | HIGH | `0.1 + 0.2 = 0.30000000000000004`; tổng tiền và tiền hoàn trả bị lệch từng xu, báo cáo đối soát không khớp | không bao giờ dùng cho tiền tệ; kiểu float chỉ phù hợp cho các số liệu đo đạc vật lý | dùng `numeric(12,2)` / `DECIMAL(12,2)` hoặc số nguyên `bigint` tính theo đơn vị nhỏ nhất (`amount_cents`) kèm theo cột loại tiền tệ (`currency`) |
| FK_TYPE_MISMATCH | HIGH | cột `integer` tham chiếu tới khóa `bigint`: các phép JOIN phải ép kiểu, và lệnh INSERT sẽ lỗi ngay khi ID vượt quá 2.147.483.647 | không bao giờ | dùng cùng một kiểu dữ liệu ở cả hai phía |
| MYISAM_ENGINE | HIGH | không hỗ trợ giao dịch (transactions), không có khóa ngoại, khóa toàn bảng (table locks), hỏng dữ liệu khi sập nguồn | hiếm khi dùng, họa chăng chỉ cho các bảng lưu trữ chỉ đọc (read-only archives) | `ENGINE=InnoDB` |
| INT32_PRIMARY_KEY | MEDIUM | cạn kiệt ID ở ngưỡng khoảng 2,1 tỷ (các sequence cũng bị nhảy số khi rollback); cách sửa lúc đó là phải viết lại toàn bộ bảng trên bảng lớn nhất | các bảng tra cứu nhỏ (danh mục trạng thái, quốc gia) | `bigint` identity / `BIGINT UNSIGNED AUTO_INCREMENT` |
| TIMESTAMP_WITHOUT_TZ | MEDIUM | giá trị được lưu mang ý nghĩa thời điểm khác nhau tùy thuộc vào múi giờ của tiến trình ghi; việc đổi giờ mùa hè (DST) và chuyển máy chủ sẽ làm hỏng thứ tự thời gian | các giá trị giờ đồng hồ treo tường tại địa phương không đại diện cho một thời điểm tuyệt đối (giờ mở cửa cửa hàng, giờ sinh nhật) | `timestamptz` |
| MONEY_TYPE | MEDIUM | kiểu `money` của Postgres phụ thuộc vào cài đặt `lc_monetary`, chỉ hỗ trợ một loại tiền tệ, làm tròn số khi chia | không bao giờ dùng trong các lược đồ mới | `numeric(12,2)` + cột tiền tệ (`currency`) |
| NULLABLE_AUDIT_COLUMN | MEDIUM | các dòng không có `created_at` làm hỏng các báo cáo "7 ngày qua" và gây lỗi khi chạy backfill | không bao giờ | `NOT NULL DEFAULT now()` |
| FK_WITHOUT_INDEX | MEDIUM | (PostgreSQL) việc xóa hoặc cập nhật bản ghi cha sẽ quét toàn bộ bảng con kèm theo giữ khóa (lock); phép JOIN từ bảng cha sang bảng con bị chậm. MySQL InnoDB tự động tạo chỉ mục này | bảng con cực nhỏ, bản ghi cha không bao giờ bị xóa | tạo chỉ mục (index) có các cột đầu tiên (leading columns) là các cột khóa ngoại |
| RESERVED_WORD_NAME | MEDIUM | `order`, `user`, `group` phải được đặt trong dấu ngoặc kép ở mọi truy vấn, ORM, công cụ BI và phiên psql đột xuất | không bao giờ dùng trong các schema mới | dùng tên số nhiều hoặc có tiền tố định danh: orders, app_users, user_groups, sort_order |
| MYSQL_UTF8MB3 | MEDIUM | `utf8` chỉ dài 3-byte: emoji và một số ký tự CJK sẽ bị lỗi hoặc bị cắt cụt | không bao giờ dùng trong các schema mới | `utf8mb4` + `utf8mb4_0900_ai_ci` |
| NULLABLE_BOOLEAN | LOW | logic ba giá trị: `WHERE NOT is_verified` sẽ bỏ qua các dòng có giá trị NULL | khi "chưa rõ" là một trạng thái thực tế (khi đó nên ưu tiên dùng một cột trạng thái tường minh) | `NOT NULL DEFAULT false` |
| UNCHECKED_STATUS | LOW | 'Shipped', 'shipped ', 'shiped' đều có thể xuất hiện; làm báo cáo bị phân mảnh | các trạng thái thuộc sở hữu của một hệ thống khác mà bạn chỉ sao chép lại | `CHECK (status IN (...))`, kiểu enum hoặc khóa ngoại (FK) trỏ tới bảng tra cứu |
| MISSING_FOREIGN_KEY | LOW | dữ liệu mồ côi sau khi xóa, gõ sai id, không có mối quan hệ được ghi nhận | tham chiếu liên cơ sở dữ liệu hoặc theo hướng sự kiện (event-sourced); hãy ghi tài liệu cho chúng | `REFERENCES parent(id)` + chỉ mục (index) |
| CASE_SENSITIVE_UNIQUE_EMAIL | LOW | Ana@x.com và ana@x.com thành hai tài khoản khác nhau; email đặt lại mật khẩu bị gửi nhầm chỗ | không bao giờ dùng cho email đăng nhập | `citext` hoặc `UNIQUE INDEX ON t (lower(email))` và chuẩn hóa khi ghi dữ liệu |
| VARCHAR_255_HABIT | LOW | trong Postgres, 255 là giới hạn tùy tiện từ thời MySQL; thiếu các quy tắc thực sự và những giá trị hợp lệ dài hơn lại bị từ chối | khi 255 thực sự là quy tắc nghiệp vụ | `text` + `CHECK (char_length(x) <= n)` cho các giới hạn thực tế |
| REDUNDANT_INDEX | LOW | một chỉ mục là tiền tố của chỉ mục khác (hoặc của khóa chính PK) gây tốn chi phí ghi và bộ nhớ vô ích | chỉ mục ngắn hơn có kích thước nhỏ hơn nhiều và được truy vấn cực kỳ thường xuyên | xóa bỏ nó |
| JSON_IN_TEXT | LOW | không có kiểm tra hợp lệ, không có toán tử truy vấn, không có chỉ mục GIN | chuỗi văn bản đó không thực sự là JSON | `jsonb` / `JSON` |
| NO_CREATED_AT | LOW | việc gỡ lỗi, kiểm toán và nạp bù dữ liệu (backfill) cần biết thời điểm các dòng dữ liệu xuất hiện | các bảng nối (join tables) thuần túy | `created_at timestamptz NOT NULL DEFAULT now()` |
| MIXED_CASE_NAME | LOW | tên đặt trong ngoặc kép "OrderItems" sẽ phải đặt trong ngoặc kép mãi mãi trong Postgres | không bao giờ | snake_case |
| DATETIME_NO_ZONE | LOW | (MySQL) DATETIME không có múi giờ; nhiều tiến trình ghi khác nhau sẽ lưu các múi giờ lẫn lộn | khi mọi kết nối đều thiết lập time_zone='+00:00' | bắt buộc dùng UTC |

## Những điều công cụ linter không thể đánh giá (cần rà soát thủ công)
- **Các khóa (Keys)**: có khóa duy nhất tự nhiên (natural unique key) nào ngoài id tự tăng (surrogate id) không? Thiếu `UNIQUE (provider, provider_id)` chính là nguyên nhân khiến các khoản thanh toán bị ghi nhận hai lần.
- **ON DELETE**: mặc định dùng RESTRICT/NO ACTION; chỉ dùng CASCADE cho các dòng con thực sự (order_items của một order), không bao giờ dùng từ bảng customers sang bảng payments.
- **Ràng buộc CHECK cho các bất biến (invariants)**: `quantity > 0`, `ends_at > starts_at`, `discount <= total`.
- **Đa thuê bao (Multi-tenancy)**: phải có tenant_id trên mọi bảng thuộc phạm vi thuê bao, đặt ở vị trí đầu tiên trong các khóa duy nhất và chỉ mục, cũng như trong mọi khóa ngoại FK (khóa ngoại phức hợp giúp ngăn chặn việc tham chiếu chéo giữa các thuê bao).
- **Xóa mềm (Soft deletes)**: sử dụng chỉ mục duy nhất từng phần (partial unique index) `WHERE deleted_at IS NULL`, nếu không địa chỉ email đó sẽ không bao giờ có thể được tái sử dụng.
- **Tổng giá trị phi chuẩn hóa (Denormalized totals)**: nếu lưu trữ cột orders.total, hãy nêu rõ cơ chế đảm bảo giá trị của nó luôn bằng tổng của order_items.
- **Dữ liệu định danh cá nhân (PII)**: liệt kê các cột chứa dữ liệu cá nhân; cân nhắc tách ra một bảng riêng với quyền truy cập và chính sách lưu trữ nghiêm ngặt hơn.
FILE:references/type-choices.md
# Lựa chọn kiểu dữ liệu (PostgreSQL / MySQL 8)

| Dữ liệu | PostgreSQL | MySQL 8 | Nên tránh |
|---|---|---|---|
| ID tự tăng (Surrogate id) | `bigint GENERATED ALWAYS AS IDENTITY` | `BIGINT UNSIGNED AUTO_INCREMENT` | `serial`/`int` trên các bảng đang tăng trưởng |
| ID công khai / phân tán | `uuid` (UUIDv7 để tối ưu tính cục bộ của chỉ mục, `gen_random_uuid()` cho v4) | `BINARY(16)` kết hợp với UUID_TO_BIN(x, 1) | UUID dạng `varchar(36)` (lớn gấp 3 lần, chậm hơn) |
| Tiền tệ | `numeric(12,2)` (hoặc (19,4) cho tỷ giá) + `currency char(3)` | `DECIMAL(12,2)` + `CHAR(3)` | `float`, `double`, `real`, kiểu `money` của Postgres |
| Tiền tệ dạng số nguyên | `bigint amount_cents` (lưu ý các loại tiền tệ có 0 hoặc 3 chữ số thập phân) | `BIGINT` | số xu dạng `int` (bị tràn số ở mức 21 triệu) |
| Thời điểm (sự kiện đã xảy ra) | `timestamptz` | `DATETIME(6)` theo chuẩn UTC, hoặc `TIMESTAMP` (giới hạn năm 2038, được chuyển đổi theo múi giờ của phiên) | `timestamp` không kèm múi giờ |
| Ngày lịch | `date` | `DATE` | dấu thời gian (timestamps) đặt ở nửa đêm |
| Giờ đồng hồ / lịch trình | `time` + một cột múi giờ chuẩn IANA | `TIME` + cột múi giờ | `timetz` |
| Thời lượng | `interval` hoặc số giây dạng số nguyên | số giây dạng `INT` | chuỗi văn bản như "1h30" |
| Văn bản | `text` (+ CHECK cho các giới hạn thực tế) | `VARCHAR(n)` với số n thực tế; `TEXT` cho nội dung dài | dùng `varchar(255)` theo thói quen; đệm khoảng trắng với `char(n)` |
| Văn bản không phân biệt hoa thường (email, username) | `citext` hoặc chỉ mục duy nhất dùng hàm `lower()` | bảng mã đối chiếu `_ci` không phân biệt hoa thường (mặc định) | ràng buộc unique thuần túy trên kiểu text trong Postgres |
| Cờ (Flag) | `boolean NOT NULL DEFAULT false` | `BOOLEAN` (TINYINT(1)) `NOT NULL DEFAULT 0` | kiểu boolean cho phép null, chuỗi 'Y'/'N' |
| Trạng thái | `text` + `CHECK (... IN (...))`, kiểu enum, hoặc bảng tra cứu | `ENUM(...)` hoặc bảng tra cứu | kiểu text không có ràng buộc |
| JSON | `jsonb` (dùng chỉ mục GIN cho truy vấn bao hàm) | `JSON` (dùng các cột sinh tự động để đánh chỉ mục) | lưu JSON trong cột text |
| Địa chỉ IP | `inet` / `cidr` | `VARBINARY(16)` kết hợp với INET6_ATON | kiểu text |
| Dữ liệu nhị phân lớn | lưu trữ đối tượng (object storage) + lưu khóa (key) trong cột `text` | tương tự | lưu các BLOB lớn trong những bảng truy cập thường xuyên |

## Lưu ý
- Việc thay đổi kiểu dữ liệu của cột về sau sẽ buộc phải ghi lại toàn bộ bảng trên cả hai hệ quản trị cơ sở dữ liệu (với một vài ngoại lệ như mở rộng độ dài varchar trong Postgres). Hãy thiết kế chuẩn xác các cột id và tiền tệ ngay từ đầu.
- Kiểu enum trong Postgres: thêm một giá trị mới thì nhẹ nhàng, nhưng xóa hoặc đổi tên một giá trị lại rất phức tạp. Bảng tra cứu (lookup tables) dễ nâng cấp hơn và có thể chứa thêm nhãn hiển thị cùng thứ tự sắp xếp.
- Sử dụng `NOT NULL` kèm giá trị mặc định hợp lý là quy chuẩn chung; chỉ nên cho phép null khi mang ý nghĩa "thực sự chưa biết hoặc không áp dụng".
- Luôn lưu mã tiền tệ ngay cạnh mọi khoản tiền, ngay cả khi hiện tại hệ thống chỉ mới dùng một loại tiền tệ duy nhất.
- Đặt tên nhất quán ở mọi nơi: dùng `customer_id` trong mọi bảng tham chiếu đến bảng customers, hậu tố `_at` cho thời điểm, `_on` hoặc `_date` cho ngày tháng, tiền tố `is_`/`has_` cho kiểu boolean.
FILE:templates/schema-review.md
# Đánh giá schema: <tệp dự án / schema>, <ngày>

**Hệ quản trị:** <PostgreSQL 16>   **Phạm vi:** <5 bảng: customers, products, orders, order_items, payments>
**Bối cảnh:** <Backend cửa hàng OLTP, ~2 triệu đơn hàng/năm, Prisma ORM, chưa có dữ liệu>
**Lệnh chạy:** `python3 scripts/schema_lint.py <shop-schema.sql>`
**Kết luận:** <CẦN THAY ĐỔI trước bản phát hành đầu tiên: 4 CAO (HIGH), 8 TRUNG BÌNH (MEDIUM), 8 THẤP (LOW)>

## Bắt buộc sửa (HIGH)
| # | Bảng.cột | Phát hiện | Vấn đề xảy ra | Cách khắc phục |
|---|---|---|---|---|
| 1 | <products.price> | <FLOAT_MONEY> | <0.1 + 0.2 != 0.3; tổng tiền đơn hàng bị lệch từng xu> | <numeric(12,2) NOT NULL CHECK (price >= 0)> |

## Nên sửa (MEDIUM)
| # | Bảng.cột | Phát hiện | Vấn đề xảy ra | Cách khắc phục |
|---|---|---|---|---|
| 1 | <"order"> | <RESERVED_WORD_NAME> | <phải đặt trong dấu ngoặc kép ở mọi truy vấn và công cụ BI> | <đổi tên thành orders> |

## Cân nhắc (LOW / tùy thuộc đánh giá)
- <customers.email: dùng citext để Ana@x.com và ana@x.com được tính là cùng một tài khoản>

## Phát hiện qua đọc hiểu (không phải từ công cụ linter)
- <payments: thêm UNIQUE (provider, provider_id) để khi webhook thử lại (retry) không thể ghi nhận một khoản thanh toán hai lần>
- <ON DELETE: giữ RESTRICT trên mối quan hệ payments -> orders; chỉ dùng CASCADE cho order_items -> orders>

## DDL đã hiệu chỉnh
```sql
<full corrected schema, lints clean>
```

## Các quyết định dành cho nhóm phát triển
- <chọn bigint identity hay UUIDv7 cho các id công khai>
- <lưu trực tiếp cột orders.total hay tính toán từ bảng order_items?>

## Nếu các bảng đã chứa dữ liệu
- <mỗi thay đổi ở trên là một bản di trú dữ liệu (migration); hãy liệt kê chúng và thực hiện rà soát an toàn migration trước khi triển khai>
FILE:examples/shop-schema.sql
-- Small online shop schema (PostgreSQL), first draft for review.
-- The problems are deliberate: this is what the skill is meant to catch.

CREATE TABLE customers (
    id          serial PRIMARY KEY,
    email       varchar(255) NOT NULL UNIQUE,
    full_name   varchar(255),
    phone       varchar(255),
    is_verified boolean,
    created_at  timestamp
);

CREATE TABLE products (
    id         serial PRIMARY KEY,
    sku        varchar(64) NOT NULL UNIQUE,
    title      text NOT NULL,
    price      double precision NOT NULL,
    attributes text,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE "order" (
    id          bigserial PRIMARY KEY,
    customer_id integer NOT NULL REFERENCES customers (id),
    status      varchar(20) NOT NULL DEFAULT 'pending',
    total       numeric(12,2) NOT NULL,
    placed_at   timestamp NOT NULL DEFAULT now(),
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES "order" (id),
    product_id bigint NOT NULL,
    quantity   integer NOT NULL,
    unit_price numeric(12,2) NOT NULL
);

CREATE TABLE payments (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    integer NOT NULL REFERENCES "order" (id),
    amount      real NOT NULL,
    provider    text NOT NULL,
    provider_id text,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX payments_order_idx ON payments (order_id);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:examples/shop-schema-fixed.sql
-- Small online shop schema (PostgreSQL), after review.
-- Every change is explained in templates/schema-review.md style in the review.

CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE customers (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       citext NOT NULL UNIQUE,
    full_name   text,
    phone       text CHECK (char_length(phone) <= 32),
    is_verified boolean NOT NULL DEFAULT false,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE products (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku        text NOT NULL UNIQUE,
    title      text NOT NULL,
    price      numeric(12,2) NOT NULL CHECK (price >= 0),
    attributes jsonb NOT NULL DEFAULT '{}',
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL REFERENCES customers (id),
    status      text NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled', 'refunded')),
    total       numeric(12,2) NOT NULL CHECK (total >= 0),
    placed_at   timestamptz NOT NULL DEFAULT now(),
    created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_idx ON orders (customer_id);

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES orders (id),
    product_id bigint NOT NULL REFERENCES products (id),
    quantity   integer NOT NULL CHECK (quantity > 0),
    unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0),
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (order_id, product_id)
);
CREATE INDEX order_items_product_idx ON order_items (product_id);

CREATE TABLE payments (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    bigint NOT NULL REFERENCES orders (id),
    amount      numeric(12,2) NOT NULL,
    provider    text NOT NULL,
    provider_id text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (provider, provider_id)
);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:scripts/schema_lint.py
#!/usr/bin/env python3
"""SQL schema design linter for PostgreSQL and MySQL DDL (Python 3 standard library only).

Reads CREATE TABLE / CREATE INDEX / ALTER TABLE ... ADD statements (a schema dump,
schema.sql, or the DDL of new tables in a pull request) and reports design problems
that are cheap to fix before data exists and expensive after: tables without a
primary key, money stored in floating point, foreign keys whose type differs from the
referenced key or that have no supporting index (PostgreSQL), *_id columns without a
foreign key, 32-bit primary keys, timestamps without time zone, nullable audit
columns and booleans, unchecked status columns, reserved-word and mixed-case names,
case-sensitive unique emails, redundant indexes, JSON kept in text columns, and MySQL
tables using MyISAM or 3-byte utf8.

It does not connect to a database and does not judge migration safety (locks,
backfills); it reviews the shape of the schema itself.

Usage:
  python3 schema_lint.py FILE.sql [FILE.sql ...] [--dialect auto|postgres|mysql]
        [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE] [--json]
  python3 schema_lint.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unreadable file,
no CREATE TABLE found).
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MONEY_RE = re.compile(r"(^|_)(price|amount|total|subtotal|cost|balance|fee|fees|tax|salary|refund|discount|paid|payment|revenue|credit|debit|charge)(_|$)", re.I)
RESERVED = {"user", "order", "group", "select", "table", "column", "desc", "asc", "check", "default", "from",
            "where", "limit", "offset", "references", "primary", "key", "range", "rank", "window", "all", "both",
            "end", "grant", "to", "type", "values", "constraint", "index", "match", "condition", "interval", "read", "row"}
RESERVED_PG_ONLY_SAFE = {"key", "range", "rank", "type", "values", "match", "condition", "interval", "read", "row", "index"}  # fine unquoted in Postgres
RESERVED_MY_ONLY_SAFE = {"offset", "user", "end", "type", "values", "row"}  # fine unquoted in MySQL 8
STATUS_RE = re.compile(r"^(status|state|kind|role|type|.*_status|.*_state|.*_type)$", re.I)
JSONISH_RE = re.compile(r"(^|_)(json|payload|metadata|meta|attributes|settings|extra|data)$", re.I)
INT32 = {"int", "integer", "int4", "serial", "serial4", "mediumint", "smallint", "int2", "smallserial", "tinyint"}
INT64 = {"bigint", "int8", "bigserial", "serial8"}


def finding(sev, code, table, column, problem, fix, line=0):
    return {"severity": sev, "code": code, "table": table, "column": column, "line": line, "problem": problem, "fix": fix}


def strip_comments(sql):
    out, i, n = [], 0, len(sql)
    while i < n:
        c = sql[i]
        if c in "'\"`":
            j = i + 1
            while j < n and sql[j] != c:
                j += 2 if sql[j] == "\\" and c == "'" else 1
            out.append(sql[i:j + 1]); i = j + 1
        elif sql.startswith("--", i) or c == "#" and (i == 0 or sql[i - 1] in "\n \t"):
            j = sql.find("\n", i); j = n if j < 0 else j
            out.append(" " * (j - i)); i = j
        elif sql.startswith("/*", i):
            j = sql.find("*/", i + 2); j = n if j < 0 else j + 2
            out.append(re.sub(r"[^\n]", " ", sql[i:j])); i = j
        else:
            out.append(c); i += 1
    return "".join(out)


def split_top(s, sep=","):
    parts, depth, cur, q = [], 0, [], None
    for ch in s:
        if q:
            cur.append(ch)
            if ch == q:
                q = None
            continue
        if ch in "'\"`":
            q = ch
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        if ch == sep and depth == 0:
            parts.append("".join(cur)); cur = []
        else:
            cur.append(ch)
    if "".join(cur).strip():
        parts.append("".join(cur))
    return parts


def statements(sql):
    clean = strip_comments(sql)
    pos = 0
    for part in split_top(clean, ";"):
        line = clean.count("\n", 0, pos + len(part) - len(part.lstrip())) + 1
        pos += len(part) + 1
        if part.strip():
            yield line, part.strip()


def ident(s):
    s = s.strip()
    quoted = s[:1] in "\"`"
    name = s.split(".")[-1].strip().strip("\"`")
    return name, quoted


def cols_list(s):
    return [ident(re.sub(r"\s+(ASC|DESC|NULLS\s+(FIRST|LAST)).*$", "", c.strip(), flags=re.I))[0].lower() if not c.strip().startswith("(") and "(" not in c else c.strip().lower()
            for c in split_top(s)]


class Schema:
    def __init__(self):
        self.tables, self.indexes, self.fks, self.enums = {}, [], [], set()
        self.dialect_hints = {"mysql": 0, "postgres": 0}


def parse(sql):
    sc = Schema()
    if re.search(r"AUTO_INCREMENT|ENGINE\s*=|`|UNSIGNED|DATETIME|TINYINT\(", sql, re.I):
        sc.dialect_hints["mysql"] += 1
    if re.search(r"\bSERIAL\b|BIGSERIAL|TIMESTAMPTZ|JSONB|GENERATED\s+(ALWAYS|BY DEFAULT)\s+AS\s+IDENTITY|::|CREATE\s+TYPE|CITEXT|CONCURRENTLY|\bTEXT\[\]", sql, re.I):
        sc.dialect_hints["postgres"] += 1
    for line, st in statements(sql):
        m = re.match(r"CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]+)\s*\((.*)\)\s*(.*)$", st, re.I | re.S)
        if m:
            name, quoted = ident(m.group(1))
            t = {"name": name, "quoted": quoted, "line": line, "cols": {}, "order": [], "pk": [], "uniques": [],
                 "checks": " ".join([]), "options": m.group(3), "raw": st}
            checks = []
            for item in split_top(m.group(2)):
                parse_item(item.strip(), t, sc, checks)
            t["checks"] = " ".join(checks).lower()
            sc.tables[name.lower()] = t
            continue
        m = re.match(r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]*)\s*ON\s+(?:ONLY\s+)?([\w\".`]+)\s*(?:USING\s+\w+\s*)?\((.*)\)\s*(.*)$", st, re.I | re.S)
        if m:
            sc.indexes.append({"table": ident(m.group(3))[0].lower(), "cols": cols_list(m.group(4)), "unique": bool(m.group(1)),
                               "name": ident(m.group(2))[0] if m.group(2) else "", "partial": "where" in m.group(5).lower(), "line": line})
            continue
        m = re.match(r"ALTER\s+TABLE\s+(?:ONLY\s+)?(?:IF\s+EXISTS\s+)?([\w\".`]+)\s+(.*)$", st, re.I | re.S)
        if m:
            tname = ident(m.group(1))[0].lower()
            for action in split_top(m.group(2)):
                a = action.strip()
                am = re.match(r"ADD\s+(?:COLUMN\s+)(?:IF\s+NOT\s+EXISTS\s+)?(.*)$", a, re.I | re.S)
                if am and tname in sc.tables:
                    parse_item(am.group(1), sc.tables[tname], sc, [])
                    continue
                am = re.match(r"ADD\s+(.*)$", a, re.I | re.S)
                if am and tname in sc.tables:
                    chk = []
                    parse_item(am.group(1), sc.tables[tname], sc, chk)
                    sc.tables[tname]["checks"] += " " + " ".join(chk).lower()
            continue
        m = re.match(r"CREATE\s+TYPE\s+([\w\".]+)\s+AS\s+ENUM", st, re.I)
        if m:
            sc.enums.add(ident(m.group(1))[0].lower())
        m = re.match(r"CREATE\s+(?:DOMAIN|EXTENSION)\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".]+)", st, re.I)
        if m:
            sc.enums.add(ident(m.group(1))[0].lower())
    return sc


def parse_item(item, t, sc, checks):
    u = item.upper()
    m = re.match(r"CONSTRAINT\s+[\w\"`]+\s+(.*)$", item, re.I | re.S)
    if m:
        item, u = m.group(1).strip(), m.group(1).strip().upper()
    if u.startswith("PRIMARY KEY"):
        t["pk"] = cols_list(re.search(r"\((.*)\)", item, re.S).group(1)); return
    if re.match(r"UNIQUE\b", u):
        cm = re.search(r"\((.*?)\)", item, re.S)
        if cm:
            cols = cols_list(cm.group(1))
            t["uniques"].append(cols)
            sc.indexes.append({"table": t["name"].lower(), "cols": cols, "unique": True, "name": "", "partial": False, "line": t["line"]})
        return
    if u.startswith("FOREIGN KEY"):
        fm = re.match(r"FOREIGN\s+KEY\s*\((.*?)\)\s*REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", item, re.I | re.S)
        if fm:
            sc.fks.append({"table": t["name"].lower(), "cols": cols_list(fm.group(1)), "ref": ident(fm.group(2))[0].lower(),
                           "ref_cols": cols_list(fm.group(3)) if fm.group(3) else None})
        return
    if u.startswith("CHECK"):
        checks.append(item); return
    if re.match(r"(UNIQUE\s+)?(KEY|INDEX)\b", u) or re.match(r"(FULLTEXT|SPATIAL)\b", u):
        cm = re.search(r"\((.*)\)", item, re.S)
        if cm:
            sc.indexes.append({"table": t["name"].lower(), "cols": cols_list(cm.group(1)), "unique": u.startswith("UNIQUE"),
                               "name": "", "partial": False, "line": t["line"]})
        return
    if u.startswith(("EXCLUDE", "LIKE", "PERIOD")):
        return
    cm = re.match(r"([\"`][^\"`]+[\"`]|[\w$]+)\s+(.*)$", item, re.S)
    if not cm:
        return
    name, quoted = ident(cm.group(1))
    rest = cm.group(2)
    tm = re.match(r"((?:double\s+precision|character\s+varying|bit\s+varying|timestamp(?:\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|time(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|[\w.]+)(?:\s*\([^)]*\))?(?:\s+unsigned)?(?:\s*\[\])?)", rest, re.I)
    ctype = re.sub(r"\s+", " ", tm.group(1).lower()) if tm else ""
    ru = rest.upper()
    col = {"name": name, "quoted": quoted, "type": ctype, "not_null": "NOT NULL" in ru or "PRIMARY KEY" in ru,
           "default": "DEFAULT" in ru or "IDENTITY" in ru or "AUTO_INCREMENT" in ru or ctype.endswith("serial"),
           "check": "CHECK" in ru}
    t["cols"][name.lower()] = col
    t["order"].append(name.lower())
    if "PRIMARY KEY" in ru:
        t["pk"] = [name.lower()]
    if re.search(r"\bUNIQUE\b", ru):
        t["uniques"].append([name.lower()])
        sc.indexes.append({"table": t["name"].lower(), "cols": [name.lower()], "unique": True, "name": "", "partial": False, "line": t["line"]})
    rm = re.search(r"REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", rest, re.I)
    if rm:
        sc.fks.append({"table": t["name"].lower(), "cols": [name.lower()], "ref": ident(rm.group(1))[0].lower(),
                       "ref_cols": cols_list(rm.group(2)) if rm.group(2) else None})
    if col["check"]:
        checks.append(f"check ({name})")


def qi(name):
    return f'"{name}"' if name.lower() in RESERVED or name != name.lower() else name


def base_type(ctype):
    t = re.sub(r"\(.*?\)|\s+unsigned|\[\]", "", ctype).strip()
    return {"int4": "integer", "int": "integer", "serial": "integer", "serial4": "integer", "int8": "bigint",
            "bigserial": "bigint", "serial8": "bigint", "int2": "smallint", "smallserial": "smallint"}.get(t, t)


def lint(sql, dialect="auto", ignore=()):
    sc = parse(sql)
    if dialect == "auto":
        dialect = "mysql" if sc.dialect_hints["mysql"] > sc.dialect_hints["postgres"] else "postgres"
    out = []

    def add(*a, **k):
        f = finding(*a, **k)
        if f["code"] not in ignore:
            out.append(f)

    table_names = set(sc.tables)
    for key, t in sc.tables.items():
        tn = t["name"]
        ln = t["line"]
        if not t["pk"]:
            add("HIGH", "NO_PRIMARY_KEY", tn, "", "table has no primary key: duplicate rows cannot be told apart, updates and replication (logical replication, MySQL row-based, CDC) suffer",
                "add a bigint identity (or UUID) primary key, or a composite key on the natural unique columns", line=ln)
        elif len(t["pk"]) == 1 and t["pk"][0] in t["cols"]:
            c = t["cols"][t["pk"][0]]
            bt = base_type(c["type"])
            if bt in {"integer", "smallint", "mediumint", "tinyint"} and not re.search(r"(^|_)(type|status|kind|country|currency|lookup)s?$", tn, re.I):
                add("MEDIUM", "INT32_PRIMARY_KEY", tn, c["name"], f"primary key {c['name']} is {c['type']} (max about 2.1 billion, less for smaller types); running out means an emergency type change on the biggest table",
                    "use bigint GENERATED ALWAYS AS IDENTITY (Postgres) or BIGINT UNSIGNED AUTO_INCREMENT (MySQL)", line=ln)
        if dialect == "postgres" and t["quoted"] and t["name"] != t["name"].lower():
            add("LOW", "MIXED_CASE_NAME", tn, "", f'table "{tn}" is quoted mixed case: every query must quote it forever', "use snake_case lower-case names", line=ln)
        if tn.lower() in RESERVED and not (dialect == "postgres" and tn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and tn.lower() in RESERVED_MY_ONLY_SAFE):
            add("MEDIUM", "RESERVED_WORD_NAME", tn, "", f"table name '{tn}' is a reserved word and must be quoted in every query and ORM", "rename (e.g. users, orders, user_groups)", line=ln)
        if dialect == "mysql":
            opts = t["options"].lower()
            if re.search(r"engine\s*=\s*myisam", opts):
                add("HIGH", "MYISAM_ENGINE", tn, "", "ENGINE=MyISAM has no transactions, no foreign keys and table-level locks; crashes can corrupt it", "use ENGINE=InnoDB", line=ln)
            if re.search(r"charset\s*=\s*utf8(mb3)?\b(?!mb4)", opts):
                add("MEDIUM", "MYSQL_UTF8MB3", tn, "", "utf8 in MySQL is 3-byte utf8mb3: emoji and some CJK characters fail to insert or get mangled", "use DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", line=ln)
        if not any(re.match(r"^(created_at|created|created_on|inserted_at|date_created|creation_date)$", c) for c in t["cols"]):
            add("LOW", "NO_CREATED_AT", tn, "", "no created_at column: hard to debug, audit or backfill later", "add created_at timestamptz NOT NULL DEFAULT now()", line=ln)
        v255 = [c["name"] for c in t["cols"].values() if re.match(r"(varchar|character varying)\s*\(255\)", c["type"])]
        if dialect == "postgres" and len(v255) >= 3:
            add("LOW", "VARCHAR_255_HABIT", tn, ", ".join(v255), f"{len(v255)} columns are varchar(255): in Postgres the limit costs nothing to drop but 255 is rarely the real rule",
                "use text, with a CHECK (char_length(x) <= n) where a real limit exists", line=ln)
        for cname in t["order"]:
            c = t["cols"][cname]
            ct, bt = c["type"], base_type(c["type"])
            cn = c["name"]
            if MONEY_RE.search(cn) and bt in {"float", "real", "double", "double precision", "float4", "float8"}:
                add("HIGH", "FLOAT_MONEY", tn, cn, f"{cn} is {ct}: binary floating point cannot store 0.10 exactly, sums drift by cents",
                    "use numeric(12,2) / DECIMAL(12,2), or bigint minor units (amount_cents)", line=ln)
            if bt == "money" and dialect == "postgres":
                add("MEDIUM", "MONEY_TYPE", tn, cn, "Postgres money type depends on lc_monetary, has one fixed currency and rounds", "use numeric(12,2) plus a currency column", line=ln)
            if dialect == "postgres" and (re.match(r"^timestamp(\s*\(\d+\))?$", ct) or "without time zone" in ct):
                add("MEDIUM", "TIMESTAMP_WITHOUT_TZ", tn, cn, f"{cn} is timestamp without time zone: the instant depends on whatever zone the writer assumed", "use timestamptz (stores an absolute instant, displays in the session zone)", line=ln)
            if dialect == "mysql" and bt == "datetime" and re.search(r"_at$", cn):
                add("LOW", "DATETIME_NO_ZONE", tn, cn, f"{cn} is DATETIME (no zone): make sure every writer stores UTC", "document and enforce UTC (time_zone='+00:00' on connections) or use TIMESTAMP within its 2038 range", line=ln)
            if re.match(r"^(created_at|updated_at|inserted_at)$", cn, re.I) and not c["not_null"]:
                add("MEDIUM", "NULLABLE_AUDIT_COLUMN", tn, cn, f"{cn} is nullable{'' if c['default'] else ' and has no default'}: rows without it will appear", f"{cn} timestamptz NOT NULL DEFAULT now()", line=ln)
            if bt in {"boolean", "bool"} and not c["not_null"]:
                add("LOW", "NULLABLE_BOOLEAN", tn, cn, f"{cn} is a nullable boolean: three states (true, false, unknown) and NULL-unsafe WHERE clauses", f"{cn} boolean NOT NULL DEFAULT false", line=ln)
            if STATUS_RE.match(cn) and re.match(r"^(text|varchar|character varying|char|character)", bt) and not c["check"] \
                    and cn.lower() not in t["checks"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
                add("LOW", "UNCHECKED_STATUS", tn, cn, f"{cn} is free text: typos like 'Shipped ' and 'shiped' will end up in the data", "add CHECK (status IN (...)), an enum type, or a FK to a lookup table", line=ln)
            if bt in {"text", "longtext", "mediumtext"} and JSONISH_RE.search(cn):
                add("LOW", "JSON_IN_TEXT", tn, cn, f"{cn} looks like JSON stored as {ct}: no validation, no indexing", "use jsonb (Postgres) or JSON (MySQL) if it really is JSON", line=ln)
            if cn.lower() in RESERVED and not (dialect == "postgres" and cn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and cn.lower() in RESERVED_MY_ONLY_SAFE) and cn.lower() not in {"type"}:
                add("MEDIUM", "RESERVED_WORD_NAME", tn, cn, f"column name '{cn}' is a reserved word and needs quoting everywhere", "rename it (e.g. sort_order, user_id, group_name, ends_at)", line=ln)
            if dialect == "postgres" and c["quoted"] and cn != cn.lower():
                add("LOW", "MIXED_CASE_NAME", tn, cn, f'column "{cn}" is quoted mixed case: every query must quote it', "use snake_case", line=ln)
            if cn.lower().endswith("_id") and cname not in t["pk"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
                stem = cn.lower()[:-3]
                cands = [x for x in (stem, stem + "s", stem + "es", stem[:-1] + "ies" if stem.endswith("y") else None) if x and x in table_names]
                if cands:
                    add("LOW", "MISSING_FOREIGN_KEY", tn, cn, f"{cn} looks like a reference to {cands[0]} but has no foreign key: orphans and typos are possible", f"add REFERENCES {cands[0]}(id) (with an index)", line=ln)
            if re.search(r"(^|_)email$", cn, re.I) and [cname] in t["uniques"] and dialect == "postgres" and bt != "citext":
                if not any(i["table"] == key and any("lower(" in c2 for c2 in i["cols"]) for i in sc.indexes):
                    add("LOW", "CASE_SENSITIVE_UNIQUE_EMAIL", tn, cn, "UNIQUE on email is case-sensitive in Postgres: Ana@x.com and ana@x.com can both sign up", "CREATE UNIQUE INDEX ... ON t (lower(email)) or use citext", line=ln)
    # foreign keys: types and indexes
    for f in sc.fks:
        t = sc.tables.get(f["table"])
        r = sc.tables.get(f["ref"])
        if not t:
            continue
        ref_cols = f["ref_cols"] or (r["pk"] if r else None)
        if r and ref_cols and len(ref_cols) == len(f["cols"]):
            for c, rc in zip(f["cols"], ref_cols):
                a, b = t["cols"].get(c), r["cols"].get(rc)
                if a and b and base_type(a["type"]) != base_type(b["type"]) and not ({base_type(a["type"]), base_type(b["type"])} <= {"uuid"}):
                    add("HIGH", "FK_TYPE_MISMATCH", t["name"], a["name"], f"{a['name']} is {a['type']} but references {r['name']}.{b['name']} ({b['type']}): joins need casts and the FK fails once ids pass the smaller range",
                        f"make {a['name']} the same type as {r['name']}.{b['name']}", line=t["line"])
        if dialect == "postgres":
            covered = any(idx["table"] == f["table"] and not idx["partial"] and idx["cols"][:len(f["cols"])] == f["cols"] for idx in sc.indexes) \
                or t["pk"][:len(f["cols"])] == f["cols"] or any(u[:len(f["cols"])] == f["cols"] for u in t["uniques"])
            if not covered:
                add("MEDIUM", "FK_WITHOUT_INDEX", t["name"], ", ".join(f["cols"]), f"foreign key ({', '.join(f['cols'])}) -> {f['ref']} has no index starting with it: deletes/updates on {f['ref']} scan {t['name']}, and joins are slow",
                    f"CREATE INDEX CONCURRENTLY ON {qi(t['name'])} ({', '.join(f['cols'])})", line=t["line"])
    # redundant indexes
    for i, a in enumerate(sc.indexes):
        t = sc.tables.get(a["table"])
        if not t or a["partial"] or any("(" in c for c in a["cols"]):
            continue
        others = [b for j, b in enumerate(sc.indexes) if j != i and b["table"] == a["table"] and not b["partial"]]
        if t["pk"] and a["cols"] == t["pk"][:len(a["cols"])] and not a["unique"]:
            add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) duplicates the primary key's leading columns", "drop it", line=a["line"])
            continue
        for b in others:
            if len(b["cols"]) > len(a["cols"]) and b["cols"][:len(a["cols"])] == a["cols"] and not a["unique"]:
                add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) is a prefix of the index on ({', '.join(b['cols'])})", "drop the shorter one unless it is much smaller and hot", line=a["line"])
                break
            if b["cols"] == a["cols"] and j_before(sc.indexes, b, a) and a["unique"] == b["unique"]:
                add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"two indexes on ({', '.join(a['cols'])})", "drop one", line=a["line"])
                break
    out.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["table"], f["column"], f["code"]))
    return dialect, sc, out


def j_before(lst, b, a):
    return lst.index(b) < lst.index(a)


def report(path, dialect, sc, found):
    counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
    out = [f"# Schema review: {path} ({dialect})", "",
           f"Tables: {len(sc.tables)}   Indexes: {len(sc.indexes)}   Foreign keys: {len(sc.fks)}   Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""]
    if not found:
        out.append("No findings.")
        return "\n".join(out)
    out += ["| Sev | Code | Table.Column | Problem | Fix |", "|---|---|---|---|---|"]
    for f in found:
        loc = f["table"] + ("." + f["column"] if f["column"] else "")
        out.append(f"| {f['severity']} | {f['code']} | {loc} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
    return "\n".join(out)


def self_test():
    ok_pk = "CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()"
    cases = [
        ("clean postgres table", ok_pk + ", name text NOT NULL);", "postgres", [], ["NO_PRIMARY_KEY", "INT32_PRIMARY_KEY", "NO_CREATED_AT"]),
        ("no primary key", "CREATE TABLE logs (msg text, created_at timestamptz NOT NULL);", "auto", ["NO_PRIMARY_KEY"], []),
        ("table-level composite pk", "CREATE TABLE m (a bigint, b bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a, b));", "auto", [], ["NO_PRIMARY_KEY"]),
        ("named pk constraint", "CREATE TABLE m (id bigint, created_at timestamptz NOT NULL, CONSTRAINT m_pk PRIMARY KEY (id));", "auto", [], ["NO_PRIMARY_KEY"]),
        ("pk added by alter", "CREATE TABLE m (id bigint NOT NULL, created_at timestamptz NOT NULL); ALTER TABLE m ADD CONSTRAINT m_pkey PRIMARY KEY (id);", "auto", [], ["NO_PRIMARY_KEY"]),
        ("serial pk is int32", "CREATE TABLE orders (id serial PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", ["INT32_PRIMARY_KEY"], []),
        ("small lookup table int pk ok", "CREATE TABLE order_status (id smallint PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", [], ["INT32_PRIMARY_KEY"]),
        ("float money", ok_pk + ", total_price double precision);", "postgres", ["FLOAT_MONEY"], []),
        ("numeric money ok", ok_pk + ", amount numeric(12,2) NOT NULL);", "postgres", [], ["FLOAT_MONEY"]),
        ("float non-money ok", ok_pk + ", latitude double precision);", "postgres", [], ["FLOAT_MONEY"]),
        ("money type", ok_pk + ", balance money);", "postgres", ["MONEY_TYPE"], []),
        ("timestamp without tz", ok_pk + ", shipped_at timestamp);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
        ("timestamp(3) without time zone", ok_pk + ", shipped_at timestamp(3) without time zone);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
        ("timestamptz ok", ok_pk + ", shipped_at timestamp with time zone);", "postgres", [], ["TIMESTAMP_WITHOUT_TZ"]),
        ("nullable created_at", "CREATE TABLE t (id bigint PRIMARY KEY, created_at timestamptz);", "postgres", ["NULLABLE_AUDIT_COLUMN"], []),
        ("no created_at", "CREATE TABLE t (id bigint PRIMARY KEY, name text);", "postgres", ["NO_CREATED_AT"], []),
        ("nullable boolean", ok_pk + ", is_active boolean);", "postgres", ["NULLABLE_BOOLEAN"], []),
        ("boolean not null ok", ok_pk + ", is_active boolean NOT NULL DEFAULT true);", "postgres", [], ["NULLABLE_BOOLEAN"]),
        ("unchecked status", ok_pk + ", status varchar(20) NOT NULL);", "postgres", ["UNCHECKED_STATUS"], []),
        ("status with check ok", ok_pk + ", status text NOT NULL CHECK (status IN ('a','b')));", "postgres", [], ["UNCHECKED_STATUS"]),
        ("status table check ok", ok_pk + ", status text NOT NULL, CONSTRAINT st CHECK (status IN ('a')));", "postgres", [], ["UNCHECKED_STATUS"]),
        ("reserved table name", "CREATE TABLE \"user\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["RESERVED_WORD_NAME"], []),
        ("reserved column order", ok_pk + ", \"order\" int);", "postgres", ["RESERVED_WORD_NAME"], []),
        ("mixed case quoted", "CREATE TABLE \"OrderItems\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["MIXED_CASE_NAME"], []),
        ("fk without index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint NOT NULL REFERENCES a(id));", "postgres", ["FK_WITHOUT_INDEX"], ["FK_TYPE_MISMATCH"]),
        ("fk with index ok", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint REFERENCES a); CREATE INDEX ON b (a_id, id);", "postgres", [], ["FK_WITHOUT_INDEX"]),
        ("fk covered by composite pk", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE ab (a_id bigint REFERENCES a(id), x bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a_id, x));", "postgres", [], ["FK_WITHOUT_INDEX"]),
        ("fk type mismatch", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id integer, FOREIGN KEY (a_id) REFERENCES a (id));", "postgres", ["FK_TYPE_MISMATCH"], []),
        ("missing fk on _id", "CREATE TABLE customers (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE orders (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, customer_id bigint NOT NULL);", "postgres", ["MISSING_FOREIGN_KEY"], []),
        ("external _id without table ok", ok_pk + ", stripe_id text);", "postgres", [], ["MISSING_FOREIGN_KEY"]),
        ("case-sensitive unique email", ok_pk + ", email text NOT NULL UNIQUE);", "postgres", ["CASE_SENSITIVE_UNIQUE_EMAIL"], []),
        ("lower(email) index ok", ok_pk + ", email text NOT NULL UNIQUE); CREATE UNIQUE INDEX t_email ON t (lower(email));", "postgres", [], ["CASE_SENSITIVE_UNIQUE_EMAIL"]),
        ("redundant prefix index", ok_pk + ", a bigint, b bigint); CREATE INDEX i1 ON t (a); CREATE INDEX i2 ON t (a, b);", "postgres", ["REDUNDANT_INDEX"], []),
        ("json in text", ok_pk + ", payload text);", "postgres", ["JSON_IN_TEXT"], []),
        ("varchar 255 habit", ok_pk + ", a varchar(255), b varchar(255), c varchar(255));", "postgres", ["VARCHAR_255_HABIT"], []),
        ("mysql myisam and utf8", "CREATE TABLE `t` (`id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=MyISAM DEFAULT CHARSET=utf8;", "auto", ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY", "DATETIME_NO_ZONE"], ["TIMESTAMP_WITHOUT_TZ"]),
        ("mysql utf8mb4 innodb ok", "CREATE TABLE `t` (`id` bigint unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", "auto", [], ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY"]),
        ("mysql fk needs no extra index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at datetime NOT NULL) ENGINE=InnoDB; CREATE TABLE b (id bigint PRIMARY KEY, created_at datetime NOT NULL, a_id bigint, FOREIGN KEY (a_id) REFERENCES a(id)) ENGINE=InnoDB;", "mysql", [], ["FK_WITHOUT_INDEX"]),
        ("comments and strings ignored", "-- CREATE TABLE x (a int);\n/* CREATE TABLE y (b int); */\n" + ok_pk + ", note text DEFAULT 'a;b -- c');", "postgres", [], ["NO_PRIMARY_KEY"]),
        ("alter add column parsed", ok_pk + "); ALTER TABLE t ADD COLUMN total_amount float;", "postgres", ["FLOAT_MONEY"], []),
    ]
    passed = 0
    for name, sql, dialect, expect, absent in cases:
        d, sc, found = lint(sql, dialect)
        codes = {f["code"] for f in found}
        ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
        if name == "clean postgres table":
            ok = ok and not found
        if name == "comments and strings ignored":
            ok = ok and len(sc.tables) == 1
        if name.startswith("mysql myisam"):
            ok = ok and d == "mysql"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
    d, sc, found = lint("", "auto")
    ok = report("x.sql", d, sc, found).endswith("No findings.")
    passed += ok
    print(("PASS " if ok else "FAIL ") + "empty report")
    total = len(cases) + 1
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="SQL schema design linter (Postgres/MySQL DDL)")
    ap.add_argument("files", nargs="*")
    ap.add_argument("--dialect", default="auto", choices=["auto", "postgres", "mysql"])
    ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
    ap.add_argument("--ignore", default="")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.files:
        ap.print_usage(sys.stderr)
        return 2
    ignore = {c.strip() for c in a.ignore.split(",") if c.strip()}
    sql_all = []
    for p in a.files:
        try:
            with open(p, encoding="utf-8") as fh:
                sql_all.append(fh.read())
        except OSError as e:
            print(f"error: cannot read {p}: {e}", file=sys.stderr)
            return 2
    dialect, sc, found = lint("\n;\n".join(sql_all), a.dialect, ignore)
    if not sc.tables:
        print("error: no CREATE TABLE statements found", file=sys.stderr)
        return 2
    found = [f for f in found if SEV_ORDER[f["severity"]] <= SEV_ORDER[a.min_severity]]
    if a.json:
        print(json.dumps({"dialect": dialect, "tables": len(sc.tables), "findings": found}, indent=2))
    else:
        print(report(", ".join(a.files), dialect, sc, found))
    return 1 if any(f["severity"] == "HIGH" for f in found) else 0


if __name__ == "__main__":
    sys.exit(main())
BackendPythonSQL+2
F@f
0
Trước3 / 3