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 Python

76 prompt
Công cụ Quy hoạch CIDR Mạng con VPC
Kỹ năng

Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.

---
name: vpc-subnet-cidr-planner
description: Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---

# Công cụ Quy hoạch CIDR Mạng con VPC

Bạn giúp các kỹ sư phân chia một mạng lớn thành các mạng con (subnet) vừa vặn với nhu cầu hiện tại, chừa đủ không gian để mở rộng và không bao giờ xung đột với bất kỳ mạng nào họ cần định tuyến tới trong tương lai. Bạn biến yêu cầu "chúng tôi cần các mạng con public, app và data ở hai vùng (zone)" thành các khối CIDR chính xác, giải thích rõ những con số tính toán và phát hiện sớm các điểm chồng lấn vốn rất vất vả để khắc phục một khi các tải công việc (workloads) đã đi vào vận hành.

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

- `scripts/cidr_plan.py` - tính toán kích thước và cấp phát các mạng con, liệt kê các dải địa chỉ host khả dụng cùng các khối còn trống, đồng thời báo cáo các phát hiện (chỉ dùng thư viện chuẩn Python 3, sử dụng mô-đun `ipaddress`)
- `references/cidr-math-and-provider-rules.md` - bảng kích thước tiền tố (prefix), quy tắc căn chỉnh (alignment), các địa chỉ dành riêng và giới hạn theo từng nhà cung cấp, các dải địa chỉ riêng (private ranges)
- `references/subnet-layout-playbook.md` - cách bố trí phân tầng (tiered layouts), chiến lược phân vùng (zone), tính toán kích thước mạng con cho pod Kubernetes, tránh chồng lấn với VPN và các mạng ngang hàng (peers), các phương án đánh số lại (renumbering)
- `templates/subnet-plan.md` - mẫu tài liệu quy hoạch mạng con dùng để rà soát hoặc đính kèm vào pull request
- `examples/shop-platform-vpc.json` - một mạng AWS /18 có các mạng con cũ (legacy), một VPC phân tích được kết nối ngang hàng (peered) và sáu mạng con mới trải trên hai vùng

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

### 1. Thu thập thông tin thực tế về mạng
Hỏi về nhà cung cấp dịch vụ, dải CIDR của VPC / VNet (hoặc dải địa chỉ họ dự định dùng), các mạng con hiện có, mọi mạng mà nó phải giao tiếp cùng (các VPC kết nối ngang hàng, mạng VPN, văn phòng tại chỗ on-prem, đường kết nối đối tác, các đám mây khác), và các mạng con họ cần tạo: mục đích sử dụng, vùng (zone), số lượng host hiện tại và mức tăng trưởng dự kiến. Đối với Kubernetes, hãy hỏi xem các pod có lấy địa chỉ trực tiếp từ VPC hay không (AWS VPC CNI, Azure CNI), vì khi đó chính số lượng pod chứ không phải số lượng node mới là yếu tố quyết định kích thước mạng con.

### 2. Chạy công cụ quy hoạch
```
python3 scripts/cidr_plan.py examples/shop-platform-vpc.json
python3 scripts/cidr_plan.py plan.json --json
python3 scripts/cidr_plan.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có các phát hiện mức HIGH (chồng lấn địa chỉ, hết không gian trống), 2 nghĩa là dữ liệu đầu vào bị lỗi (các bit phần host bị bật, dùng IPv6, các dải VPC bị chồng lấn nhau).

### 3. Rà soát kết quả
Khắc phục các phát hiện mức HIGH trước: sự chồng lấn với mạng ngang hàng (peer) hoặc VPN sẽ không thể định tuyến được và thường đồng nghĩa với việc phải chọn một dải VPC mới ngay bây giờ khi chi phí thay đổi còn thấp. Sử dụng `references/subnet-layout-playbook.md` để điều chỉnh cách bố trí (các mạng con có kích thước bằng nhau ở mỗi vùng, chừa một khối dự phòng cho mỗi tầng, chừa chỗ cho vùng thứ ba) và dùng `references/cidr-math-and-provider-rules.md` để giải thích về kích thước. Chạy lại công cụ sau mỗi lần thay đổi.

### 4. Bàn giao kết quả
Điền vào `templates/subnet-plan.md` bảng quy hoạch, các khối còn trống, các phát hiện và quyết định. Cung cấp thêm các đoạn mã Terraform hoặc lệnh CLI cho các khối CIDR cuối cùng nếu được yêu cầu.

## Kết quả ví dụ
Đối với `examples/shop-platform-vpc.json` (AWS 10.20.0.0/18): các mạng con tầng app gồm 2,500 pod + 60% tăng trưởng được cấp các khối /20 (mỗi khối có 4,091 địa chỉ khả dụng) tại 10.20.16.0/20 và 10.20.32.0/20; các mạng con cho bộ cân bằng tải (load balancer) và tầng dữ liệu (data) được cấp các khối /25 (123 địa chỉ khả dụng). Các phát hiện: mức HIGH do mạng con cũ rds-b 10.20.1.128/25 bị chồng lấn với rds-a 10.20.1.0/24; mức HIGH do VPC phân tích được kết nối ngang hàng 10.20.48.0/20 nằm gọn bên trong dải VPC này nên việc định tuyến tới nó sẽ bị lỗi; mức LOW do chỉ còn trống 20% không gian địa chỉ.

## Quy tắc

- Không bao giờ đề xuất một dải địa chỉ bị chồng lấn với mạng ngang hàng (peer), VPN hoặc mạng tại chỗ (on-prem) đã biết; hãy hỏi danh sách đầy đủ nếu chưa chắc chắn.
- Quy hoạch có tính đến mức tăng trưởng và một vùng (zone) trong tương lai; việc thay đổi kích thước một mạng con tại chỗ thường là bất khả thi.
- Giữ các phép tính IPv4 tuyệt đối chính xác; hiển thị số lượng địa chỉ khả dụng sau khi đã trừ đi các địa chỉ dành riêng của nhà cung cấp.
- Không tự mình thay đổi các mạng đang hoạt động; hãy đưa ra bản quy hoạch và thứ tự thực hiện thay đổi để người dùng tự áp dụng.
FILE:references/cidr-math-and-provider-rules.md
# Toán học CIDR và Quy tắc của Nhà cung cấp

## Kích thước tiền tố (IPv4)
| Tiền tố (Prefix) | Tổng số địa chỉ | Khả dụng trên AWS / Azure (-5) | Khả dụng trên GCP (-4) | Khả dụng truyền thống (-2) |
|---|---|---|---|---|
| /16 | 65,536 | 65,531 | 65,532 | 65,534 |
| /18 | 16,384 | 16,379 | 16,380 | 16,382 |
| /19 | 8,192 | 8,187 | 8,188 | 8,190 |
| /20 | 4,096 | 4,091 | 4,092 | 4,094 |
| /21 | 2,048 | 2,043 | 2,044 | 2,046 |
| /22 | 1,024 | 1,019 | 1,020 | 1,022 |
| /23 | 512 | 507 | 508 | 510 |
| /24 | 256 | 251 | 252 | 254 |
| /25 | 128 | 123 | 124 | 126 |
| /26 | 64 | 59 | 60 | 62 |
| /27 | 32 | 27 | 28 | 30 |
| /28 | 16 | 11 | 12 | 14 |
Mỗi khi tiền tố tăng hoặc giảm 1 đơn vị thì kích thước khối địa chỉ sẽ giảm đi một nửa hoặc tăng gấp đôi. Số địa chỉ = 2^(32 - prefix).

## Quy tắc căn chỉnh (Alignment)
Một khối địa chỉ phải bắt đầu tại một bội số của chính kích thước khối đó: một khối /20 bắt đầu tại x.x.0.0, x.x.16.0, x.x.32.0, ...; cách viết 10.20.8.0/20 là không hợp lệ (do các bit phần host bị bật). Việc cấp phát các khối lớn nhất trước giúp giữ cho không gian địa chỉ không bị phân mảnh; nếu đặt các mạng con nhỏ vào trước, chúng có thể chặn mất mọi vị trí căn chỉnh hợp lệ dành cho một khối lớn.

## Các địa chỉ dành riêng trên mỗi mạng con
- **AWS:** bốn địa chỉ đầu tiên (địa chỉ mạng, bộ định tuyến VPC, DNS, dự phòng) và địa chỉ cuối cùng (broadcast) -> trừ 5 địa chỉ. CIDR của VPC từ /16 đến /28, các mạng con từ /16 đến /28. Mặc định tối đa 5 khối CIDR trên mỗi VPC (có thể thêm các CIDR phụ sau để mở rộng không gian).
- **Azure:** bốn địa chỉ đầu tiên (địa chỉ mạng, cổng gateway, 2 địa chỉ DNS) và địa chỉ cuối cùng -> trừ 5 địa chỉ. Mạng con nhỏ nhất đến /29. Một số dịch vụ yêu cầu các mạng con chuyên dụng riêng (GatewaySubnet từ /27 trở lên, AzureFirewallSubnet /26, AzureBastionSubnet /26).
- **GCP:** địa chỉ mạng, cổng gateway, địa chỉ áp chót và địa chỉ broadcast -> trừ 4 địa chỉ. Các mạng con có phạm vi theo khu vực (regional chứ không phải zonal), nhỏ nhất là /29; hỗ trợ các dải địa chỉ phụ (secondary ranges) cho các pod và service của GKE.
- **Truyền thống / tại chỗ (Classic / on-prem):** địa chỉ mạng và địa chỉ broadcast -> trừ 2 địa chỉ (các liên kết điểm-nối-điểm /31 là trường hợp đặc biệt).

## Các dải địa chỉ riêng và đặc biệt
- RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16.
- Không gian dùng chung cho Carrier-grade NAT 100.64.0.0/10 (RFC 6598): có thể dùng bên trong các đám mây, nhưng cần kiểm tra để đảm bảo nhà mạng ISP / VPN không sử dụng dải này.
- Nên tránh: 172.17.0.0/16 (cầu nối docker0 mặc định), 169.254.0.0/16 (link-local, dịch vụ metadata trên đám mây), 192.168.0.0/24 và 192.168.1.0/24 (bộ định tuyến gia đình hay dùng nên người dùng VPN sẽ bị xung đột), 10.0.0.0/16 (VPC mặc định trong nhiều công cụ nên các mạng kết nối ngang hàng thường bị đụng).
- Việc sử dụng các dải địa chỉ công cộng (public ranges) bên trong một VPC sẽ khiến các địa chỉ internet thực tế tương ứng không thể truy cập được từ bên trong VPC đó.

## Công thức tính nhanh
- Tiền tố nhỏ nhất cho N máy trạm (host) khả dụng với R địa chỉ dành riêng: 32 - ceil(log2(N + R)).
- Số lượng host tính cả mức tăng trưởng: ceil(hosts x (1 + growth)).
- Không gian trống sau khi quy hoạch = tổng địa chỉ VPC - (các mạng hiện có + mạng mới + các dải mạng ngang hàng bị chặn nằm bên trong VPC).
FILE:references/subnet-layout-playbook.md
# Sổ tay Bố trí Mạng con (Subnet Layout Playbook)

## Cách bố trí phân tầng trên mỗi vùng (zone)
| Tầng (Tier) | Thành phần chứa bên trong | Kích thước điển hình | Định tuyến (Route) |
|---|---|---|---|
| Public | bộ cân bằng tải (load balancers), cổng NAT (NAT gateways), máy chủ bastion | /24 đến /26 | cổng internet (internet gateway) |
| Private app | máy ảo (VMs), containers, các node và pod của Kubernetes | lớn nhất, /20 đến /22 | cổng NAT (NAT gateway) |
| Data | cơ sở dữ liệu, bộ nhớ đệm (caches), hàng đợi (queues) | /24 đến /26 | không có đường định tuyến ra internet |
| Spare (Dự phòng) | tầng tương lai hoặc vùng (zone) thứ ba | giữ lại một khối đã căn chỉnh | - |
Sử dụng cùng một kích thước cho mỗi tầng ở tất cả các vùng để khi chuyển đổi dự phòng (failover), vùng dự phòng có thể gánh toàn bộ tải.

## Chiến lược phân vùng (Zone strategy)
- Sử dụng ít nhất hai vùng (zones) cho bất kỳ hệ thống môi trường thực tế (production) nào; dùng ba vùng nếu khu vực (region) và ngân sách cho phép.
- Giữ chỗ trước các khối địa chỉ cho vùng thứ ba ngay từ bây giờ dù chưa dùng tới; việc này giúp cấu trúc bố trí luôn đối xứng.
- Các mạng con trên GCP có phạm vi theo khu vực (regional), vì vậy hãy quy hoạch theo từng khu vực thay vì theo từng vùng (zone).

## Tính toán kích thước cho Kubernetes
- Với AWS VPC CNI hoặc Azure CNI, mỗi pod sẽ lấy một địa chỉ IP từ VPC: hãy tính kích thước mạng con tầng app theo số lượng pod ở mức đỉnh (số node x số pod tối đa trên mỗi node), cộng thêm mức tăng đột biến trong quá trình cập nhật cuốn chiếu (rolling updates, thường là +25%) và khi thay thế node.
- Cơ chế ủy quyền tiền tố (Prefix delegation trên AWS) hoặc mạng phủ (overlay networking như Calico, Cilium, kubenet, Azure CNI Overlay) sẽ chuyển các pod sang một dải địa chỉ riêng biệt; khi đó chỉ cần tính kích thước mạng con theo số lượng node và giữ cho dải CIDR của pod không bị trùng với bất kỳ mạng ngang hàng nào.
- Giữ cho dải CIDR của service (ví dụ 172.20.0.0/16) cũng tách biệt hoàn toàn khỏi VPC và các mạng ngang hàng.

## Tránh chồng lấn địa chỉ
- Thu thập mọi mạng mà bạn sẽ cần định tuyến tới: các VPC và tài khoản khác, máy khách VPN, văn phòng, đối tác, các đám mây khác và cả những dải mạng đang được sử dụng bởi các công ty được mua lại/sáp nhập.
- Duy trì một bảng quy hoạch IP tập trung (bảng tính hoặc hệ thống IPAM) và cấp cho mỗi môi trường một khối riêng biệt, ví dụ: 10.20.0.0/16 cho prod, 10.21.0.0/16 cho staging, 10.22.0.0/16 cho dev.
- Không chọn các dải 10.0.0.0/16, 172.17.0.0/16 hoặc từ 192.168.0.0/24 đến 192.168.1.0/24 cho bất kỳ hệ thống nào mà người dùng VPN sẽ kết nối vào.

## Khi đã xảy ra chồng lấn
1. Ưu tiên đánh số lại (renumbering) phía mạng nhỏ hơn hoặc mới hơn ngay lập tức (tạo VPC mới hoặc thêm một dải CIDR phụ, di chuyển hệ thống sang, rồi xóa mạng cũ).
2. Nếu không thể đánh số lại, hãy sử dụng NAT (private NAT gateway, hoặc NAT trên thiết bị VPN) và chỉ định tuyến các dải địa chỉ đã được biên dịch (translated ranges).
3. Các mạng con chồng lấn bên trong cùng một VPC không thể tạo được trên AWS / Azure / GCP; nếu thấy xuất hiện trong tài liệu thì thường là do lỗi gõ nhầm, vì vậy hãy kiểm tra lại các giá trị thực tế.

## Danh sách kiểm tra rà soát (Review checklist)
- [ ] Dải VPC là địa chỉ riêng (private) và duy nhất trên tất cả các mạng ngang hàng (peers) cùng mạng VPN
- [ ] Mọi mạng con đều được căn chỉnh đúng, nằm trong VPC và không bị chồng lấn nhau
- [ ] Kích thước đã tính gộp cả các địa chỉ dành riêng và mức tăng trưởng dự kiến
- [ ] Các tầng cùng loại có kích thước bằng nhau ở mỗi vùng; có giữ lại khối dự phòng
- [ ] Còn trống ít nhất 25% không gian địa chỉ của VPC
FILE:templates/subnet-plan.md
# Bản Quy hoạch Mạng con - <tên mạng>

**Nhà cung cấp / khu vực:** <aws eu-west-1>  **VPC CIDR:** <10.x.0.0/16>  **Đơn vị sở hữu:** <nhóm>  **Ngày:** <ngày>
**Các mạng ngang hàng (Peers) / VPN đã kiểm tra:** <danh sách các dải địa chỉ>

## Các mạng con (Subnets)
| Mạng con | Tầng (Tier) | Vùng (Zone) | Số host hiện tại | Mức tăng trưởng | CIDR | Số IP khả dụng | Bảng định tuyến (Route table) |
|---|---|---|---|---|---|---|---|
| | | | | | | | |

## Các mạng con hiện có được giữ lại
| Tên | CIDR | Ghi chú |
|---|---|---|
| | | |

## Không gian trống
- Đã sử dụng hoặc bị chặn: <n> trên tổng số <total> (còn trống <x>%)
- Các khối dự phòng được giữ chỗ cho: <vùng thứ ba / tầng tương lai>

## Các phát hiện và quyết định
| Mức độ nghiêm trọng | Phát hiện | Quyết định |
|---|---|---|
| | | |

## Thứ tự triển khai (Rollout order)
1. <tạo CIDR phụ / tạo VPC mới>
2. <tạo các mạng con và bảng định tuyến>
3. <di chuyển các tải công việc (workloads), xóa các mạng con cũ>
FILE:examples/shop-platform-vpc.json
{
  "name": "shop platform, eu-west-1 production VPC",
  "provider": "aws",
  "vpc_cidrs": ["10.20.0.0/18"],
  "existing": [
    {"name": "legacy bastion", "cidr": "10.20.0.0/26"},
    {"name": "legacy rds-a", "cidr": "10.20.1.0/24"},
    {"name": "legacy rds-b", "cidr": "10.20.1.128/25"}
  ],
  "peers": [
    {"name": "office VPN (Istanbul)", "cidr": "192.168.10.0/24"},
    {"name": "analytics VPC (peered)", "cidr": "10.20.48.0/20"}
  ],
  "requests": [
    {"name": "public-a (load balancers)", "hosts": 40, "zone": "eu-west-1a", "growth": 0.5},
    {"name": "public-b (load balancers)", "hosts": 40, "zone": "eu-west-1b", "growth": 0.5},
    {"name": "app-a (EKS pods)", "hosts": 2500, "zone": "eu-west-1a", "growth": 0.6},
    {"name": "app-b (EKS pods)", "hosts": 2500, "zone": "eu-west-1b", "growth": 0.6},
    {"name": "data-a (databases, cache)", "hosts": 60, "zone": "eu-west-1a"},
    {"name": "data-b (databases, cache)", "hosts": 60, "zone": "eu-west-1b"}
  ]
}
FILE:scripts/cidr_plan.py
#!/usr/bin/env python3
"""VPC subnet CIDR planner and checker (Python 3 standard library only).

Input: a network plan JSON file:
  {"name": "prod-eu", "provider": "aws", "vpc_cidrs": ["10.40.0.0/16"],
   "existing": [{"name": "legacy-db", "cidr": "10.40.8.0/24"}],
   "peers": [{"name": "office vpn", "cidr": "192.168.0.0/22"}],
   "requests": [{"name": "app-a", "hosts": 900, "zone": "eu-west-1a", "growth": 0.5}]}
  provider: aws | azure | gcp | generic. Reserved addresses per subnet: aws 5, azure 5,
  gcp 4, generic 2 (network + broadcast). Subnet size limits: aws /16../28,
  azure /8../29, gcp /8../29, generic /8../30.
  requests: hosts needed now; growth = extra fraction to reserve (default 0.3);
  optional "prefix" forces a size. Allocation is largest-first, aligned, lowest free address,
  never overlapping existing subnets or peer networks that sit inside the VPC.

Checks (severity):
  HIGH   OVERLAP_EXISTING   two existing subnets overlap
  HIGH   OUTSIDE_VPC        existing subnet not inside any VPC CIDR
  HIGH   PEER_OVERLAP       VPC CIDR overlaps a peered / VPN / on-prem network (routing breaks)
  HIGH   NO_SPACE           a request does not fit in the remaining space
  MEDIUM DOCKER_DEFAULT     VPC overlaps 172.17.0.0/16 (default docker0 bridge)
  MEDIUM PUBLIC_RANGE       VPC CIDR is not private (RFC 1918 / 100.64.0.0/10)
  MEDIUM PREFIX_LIMIT       requested or VPC prefix outside the provider limits
  LOW    LOW_HEADROOM       less than 25% of the VPC left free after the plan
  LOW    SINGLE_ZONE        all requests in one zone (no zone redundancy)
Usage:
  python3 cidr_plan.py plan.json
  python3 cidr_plan.py plan.json --json
  python3 cidr_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import ipaddress as ip
import json
import math
import sys

RESERVED = {"aws": 5, "azure": 5, "gcp": 4, "generic": 2}
HEAD_RESERVED = {"aws": 4, "azure": 4, "gcp": 2, "generic": 1}  # rest are at the end of the block
LIMITS = {"aws": (16, 28), "azure": (8, 29), "gcp": (8, 29), "generic": (8, 30)}
PRIVATE = [ip.ip_network(c) for c in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
DOCKER = ip.ip_network("172.17.0.0/16")


class PlanError(ValueError):
    pass


def net(cidr, what="cidr"):
    try:
        n = ip.ip_network(str(cidr).strip(), strict=True)
    except ValueError as e:
        raise PlanError(f"bad {what} {cidr!r}: {e}")
    if n.version != 4:
        raise PlanError(f"only IPv4 is supported: {cidr!r}")
    return n


def usable(prefix, provider):
    return max(0, 2 ** (32 - prefix) - RESERVED[provider])


def prefix_for(hosts, provider, growth):
    need = math.ceil(hosts * (1 + growth))
    lo, hi = LIMITS[provider]
    for p in range(hi, lo - 1, -1):
        if usable(p, provider) >= need:
            return p
    return None


def find_slot(vpcs, taken, prefix):
    for v in vpcs:
        if prefix < v.prefixlen:
            continue
        for cand in v.subnets(new_prefix=prefix):
            if not any(cand.overlaps(t) for t in taken):
                return cand
    return None


def plan(cfg):
    provider = str(cfg.get("provider", "generic")).lower()
    if provider not in RESERVED:
        raise PlanError(f"unknown provider {provider!r} (aws, azure, gcp, generic)")
    vpcs = [net(c, "vpc_cidr") for c in cfg.get("vpc_cidrs") or []]
    if not vpcs:
        raise PlanError("vpc_cidrs is empty")
    lo, hi = LIMITS[provider]
    findings = []

    def f(sev, code, where, msg):
        findings.append({"severity": sev, "code": code, "where": where, "message": msg})

    for v in vpcs:
        if not any(v.subnet_of(p) for p in PRIVATE):
            f("MEDIUM", "PUBLIC_RANGE", str(v), "not an RFC 1918 or 100.64.0.0/10 range; it may clash with real internet addresses")
        if v.overlaps(DOCKER):
            f("MEDIUM", "DOCKER_DEFAULT", str(v), "overlaps 172.17.0.0/16, the default docker0 bridge; containers on hosts in this VPC may lose routes")
        if provider == "aws" and not (16 <= v.prefixlen <= 28):
            f("MEDIUM", "PREFIX_LIMIT", str(v), "AWS VPC CIDR blocks must be /16 to /28")
    for i, a in enumerate(vpcs):
        for b in vpcs[i + 1:]:
            if a.overlaps(b):
                raise PlanError(f"vpc_cidrs overlap each other: {a} and {b}")

    existing = []
    for e in cfg.get("existing") or []:
        n = net(e.get("cidr"), "existing cidr")
        name = e.get("name") or str(n)
        if not any(n.subnet_of(v) for v in vpcs):
            f("HIGH", "OUTSIDE_VPC", name, f"{n} is not inside {', '.join(map(str, vpcs))}")
        for other_name, o in existing:
            if n.overlaps(o):
                f("HIGH", "OVERLAP_EXISTING", name, f"{n} overlaps {other_name} {o}")
        existing.append((name, n))

    taken = [n for _, n in existing]
    for p in cfg.get("peers") or []:
        n = net(p.get("cidr"), "peer cidr")
        name = p.get("name") or str(n)
        for v in vpcs:
            if v.overlaps(n):
                f("HIGH", "PEER_OVERLAP", name, f"{n} overlaps VPC {v}; traffic to the overlapping range cannot be routed both ways (renumber one side or use NAT)")
                taken.append(n)

    reqs = []
    for r in cfg.get("requests") or []:
        name = r.get("name") or "subnet"
        hosts = r.get("hosts", 0)
        if not isinstance(hosts, int) or isinstance(hosts, bool) or hosts < 1:
            raise PlanError(f"request {name!r}: hosts must be a positive integer")
        growth = r.get("growth", 0.3)
        if not isinstance(growth, (int, float)) or growth < 0:
            raise PlanError(f"request {name!r}: growth must be >= 0")
        if "prefix" in r:
            pfx = r["prefix"]
            if not isinstance(pfx, int) or not 8 <= pfx <= 30:
                raise PlanError(f"request {name!r}: prefix must be an integer 8..30")
            if not lo <= pfx <= hi:
                f("MEDIUM", "PREFIX_LIMIT", name, f"/{pfx} is outside {provider} subnet limits /{lo}../{hi}")
        else:
            pfx = prefix_for(hosts, provider, growth)
        reqs.append({"name": name, "hosts": hosts, "growth": growth, "zone": r.get("zone", ""), "prefix": pfx})

    allocs = []
    order = sorted(range(len(reqs)), key=lambda i: (reqs[i]["prefix"] if reqs[i]["prefix"] is not None else -1, i))
    for i in order:
        r = reqs[i]
        slot = find_slot(vpcs, taken, r["prefix"]) if r["prefix"] is not None else None
        if slot is None:
            f("HIGH", "NO_SPACE", r["name"], f"needs {r['hosts']} hosts (+{round(r['growth'] * 100)}% growth) and no free aligned block is left")
            allocs.append(dict(r, cidr=None, usable=0))
            continue
        taken.append(slot)
        u = usable(slot.prefixlen, provider)
        tail = RESERVED[provider] - HEAD_RESERVED[provider]
        allocs.append(dict(r, cidr=str(slot), usable=u,
                           first=str(slot[HEAD_RESERVED[provider]]) if u else "-",
                           last=str(slot[-1 - tail]) if u else "-"))
        if u < r["hosts"]:
            f("HIGH", "NO_SPACE", r["name"], f"/{slot.prefixlen} gives only {u} usable addresses for {r['hosts']} hosts")
    allocs.sort(key=lambda a: (a["cidr"] is None, ip.ip_network(a["cidr"]) if a["cidr"] else 0))

    total = sum(v.num_addresses for v in vpcs)
    used = 0
    inside = [t for t in taken if any(t.subnet_of(v) for v in vpcs)]
    for t in ip.collapse_addresses(inside):
        used += t.num_addresses
    free = total - used
    free_blocks = []
    for v in vpcs:
        rest = [v]
        for t in ip.collapse_addresses([x for x in inside if x.subnet_of(v)]):
            nxt = []
            for b in rest:
                if t.subnet_of(b):
                    nxt.extend(b.address_exclude(t))
                elif not b.subnet_of(t):
                    nxt.append(b)
            rest = nxt
        free_blocks.extend(sorted(rest))
    if total and free / total < 0.25:
        f("LOW", "LOW_HEADROOM", "plan", f"only {free / total:.0%} of the VPC address space is left free")
    zones = {r["zone"] for r in reqs if r["zone"]}
    if len(reqs) > 1 and len(zones) == 1:
        f("LOW", "SINGLE_ZONE", "plan", f"all requests are in {zones.pop()}; spread subnets over at least two zones")
    rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    findings.sort(key=lambda x: rank[x["severity"]])
    return {"name": cfg.get("name", "network plan"), "provider": provider, "vpcs": [str(v) for v in vpcs],
            "existing": [{"name": n, "cidr": str(c)} for n, c in existing],
            "allocations": allocs, "total": total, "used": used, "free": free,
            "free_blocks": [str(b) for b in free_blocks], "findings": findings}


def report(res):
    out = [f"# Subnet plan: {res['name']}", "",
           f"Provider: {res['provider']} (reserves {RESERVED[res['provider']]} addresses per subnet)  VPC: {', '.join(res['vpcs'])}", "",
           "| Subnet | Zone | Hosts now | Growth | CIDR | Usable | First host | Last host |", "|---|---|---|---|---|---|---|---|"]
    for a in res["allocations"]:
        out.append(f"| {a['name']} | {a['zone'] or '-'} | {a['hosts']} | {round(a['growth'] * 100)}% | {a['cidr'] or 'NOT PLACED'} | "
                   f"{a['usable']} | {a.get('first', '-')} | {a.get('last', '-')} |")
    if res["existing"]:
        out += ["", "Existing subnets kept: " + ", ".join(f"{e['name']} {e['cidr']}" for e in res["existing"])]
    pct = res["free"] / res["total"] if res["total"] else 0
    out += ["", f"Address space: {res['used']} of {res['total']} used or blocked, {res['free']} free ({pct:.0%})",
            "Largest free blocks: " + (", ".join(sorted(res["free_blocks"], key=lambda c: ip.ip_network(c).prefixlen)[:6]) or "none"),
            "", "## Findings"]
    if not res["findings"]:
        out.append("- none")
    for x in res["findings"]:
        out.append(f"- **{x['severity']} {x['code']}** ({x['where']}): {x['message']}")
    return "\n".join(out) + "\n"


def self_test():
    cases = []

    def ok(name, cond):
        cases.append(bool(cond))
        print(("PASS " if cond else "FAIL ") + name)

    ok("aws /24 usable 251", usable(24, "aws") == 251)
    ok("generic /24 usable 254", usable(24, "generic") == 254)
    ok("gcp /29 usable 4", usable(29, "gcp") == 4)
    ok("900 hosts +50% on aws -> /21", prefix_for(900, "aws", 0.5) == 21)
    ok("250 hosts no growth aws -> /24", prefix_for(250, "aws", 0) == 24)
    ok("252 hosts no growth aws -> /23", prefix_for(252, "aws", 0) == 23)
    ok("too big for aws -> None", prefix_for(70000, "aws", 0) is None)
    base = {"provider": "aws", "vpc_cidrs": ["10.0.0.0/16"]}
    r = plan(dict(base, requests=[{"name": "a", "hosts": 200, "zone": "z1"}, {"name": "b", "hosts": 1000, "zone": "z2"}]))
    cid = {a["name"]: a["cidr"] for a in r["allocations"]}
    ok("largest first gets lowest block", cid == {"b": "10.0.0.0/21", "a": "10.0.8.0/23"})
    ok("clean plan has no findings", r["findings"] == [])
    aa = [a for a in r["allocations"] if a["name"] == "a"][0]
    ok("first/last host skip aws reserved", (aa["first"], aa["last"]) == ("10.0.8.4", "10.0.9.254"))
    g = plan({"provider": "gcp", "vpc_cidrs": ["10.0.0.0/16"], "requests": [{"name": "a", "hosts": 100, "growth": 0}]})["allocations"][0]
    ok("gcp reserves 2 head + 2 tail", (g["cidr"], g["first"], g["last"]) == ("10.0.0.0/25", "10.0.0.2", "10.0.0.125"))
    r = plan(dict(base, existing=[{"name": "old", "cidr": "10.0.0.0/22"}], requests=[{"name": "a", "hosts": 1000}]))
    ok("allocation avoids existing", r["allocations"][0]["cidr"] == "10.0.8.0/21")
    r = plan(dict(base, existing=[{"name": "x", "cidr": "10.0.0.0/24"}, {"name": "y", "cidr": "10.0.0.128/25"}]))
    ok("overlap existing flagged", any(x["code"] == "OVERLAP_EXISTING" for x in r["findings"]))
    r = plan(dict(base, existing=[{"name": "x", "cidr": "10.1.0.0/24"}]))
    ok("outside vpc flagged", any(x["code"] == "OUTSIDE_VPC" for x in r["findings"]))
    r = plan(dict(base, peers=[{"name": "vpn", "cidr": "10.0.4.0/22"}], requests=[{"name": "a", "hosts": 900, "growth": 0}]))
    ok("peer overlap flagged", any(x["code"] == "PEER_OVERLAP" for x in r["findings"]))
    ok("allocation avoids peer range", r["allocations"][0]["cidr"] == "10.0.0.0/22")
    r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/24"], "requests": [{"name": "a", "hosts": 200}]})
    ok("no space flagged", any(x["code"] == "NO_SPACE" for x in r["findings"]))
    r = plan({"provider": "generic", "vpc_cidrs": ["172.16.0.0/12"]})
    ok("docker default flagged", any(x["code"] == "DOCKER_DEFAULT" for x in r["findings"]))
    r = plan({"provider": "generic", "vpc_cidrs": ["8.8.0.0/16"]})
    ok("public range flagged", any(x["code"] == "PUBLIC_RANGE" for x in r["findings"]))
    r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/8"]})
    ok("aws vpc /8 flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
    r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/22"], "requests": [{"name": "a", "hosts": 400, "zone": "z1"}, {"name": "b", "hosts": 300, "zone": "z1"}]})
    ok("low headroom flagged", any(x["code"] == "LOW_HEADROOM" for x in r["findings"]))
    ok("single zone flagged", any(x["code"] == "SINGLE_ZONE" for x in r["findings"]))
    r = plan(dict(base, requests=[{"name": "a", "hosts": 10, "prefix": 30}]))
    ok("forced /30 on aws flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
    r = plan(dict(base, requests=[{"name": "a", "hosts": 1000}]))
    ok("free blocks cover rest", sum(ip.ip_network(b).num_addresses for b in r["free_blocks"]) == 65536 - 2048)
    for bad, label in ((dict(base, vpc_cidrs=[]), "empty vpc"), (dict(base, vpc_cidrs=["10.0.0.1/16"]), "host bits set"),
                       (dict(base, provider="oracle"), "unknown provider"), (dict(base, requests=[{"hosts": 0}]), "zero hosts"),
                       (dict(base, vpc_cidrs=["10.0.0.0/16", "10.0.128.0/17"]), "overlapping vpcs"),
                       (dict(base, vpc_cidrs=["fd00::/48"]), "ipv6")):
        try:
            plan(bad)
            ok(label + " rejected", False)
        except PlanError:
            ok(label + " rejected", True)
    print(f"{sum(cases)}/{len(cases)} passed")
    return 0 if all(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Plan and check VPC subnet CIDR blocks.")
    ap.add_argument("file", nargs="?", help="network plan JSON")
    ap.add_argument("--json", action="store_true", help="print machine-readable JSON")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("give a plan JSON file or --self-test")
    try:
        cfg = json.load(open(a.file, encoding="utf-8"))
        res = plan(cfg)
    except (OSError, json.JSONDecodeError, PlanError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else report(res), end="" if not a.json else "\n")
    return 1 if any(x["severity"] == "HIGH" for x in res["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsCloud+2
F@f
0
Công cụ Tối ưu hóa Định mức Tài nguyên Kubernetes (Rightsizer)
Kỹ năng

Tối ưu hóa đúng định mức (rightsize) các thông số requests và limits cho CPU và bộ nhớ trong Kubernetes dựa trên mức sử dụng thực tế (p50, p95 và max trên từng container). Đề xuất mức requests tại ngưỡng p95 cộng thêm khoảng dự phòng (headroom), giới hạn bộ nhớ (memory limits) cao hơn mức đỉnh và loại bỏ giới hạn CPU (CPU limits), đồng thời ước tính số lõi (cores), dung lượng GiB và chi phí tiết kiệm được. Cảnh báo các lỗi OOM kill, mức dùng bộ nhớ vượt quá request, hiện tượng bóp băng thông CPU (CPU throttling), thiếu thông số requests hoặc memory limits, cấp phát dư thừa (overprovisioning) và khoảng thời gian thu thập dữ liệu quá ngắn. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: kubernetes-resource-requests-rightsizer
description: Tối ưu hóa đúng định mức (rightsize) các thông số requests và limits cho CPU và bộ nhớ trong Kubernetes dựa trên mức sử dụng thực tế (p50, p95 và max trên từng container). Đề xuất mức requests tại ngưỡng p95 cộng thêm khoảng dự phòng (headroom), giới hạn bộ nhớ (memory limits) cao hơn mức đỉnh và loại bỏ giới hạn CPU (CPU limits), đồng thời ước tính số lõi (cores), dung lượng GiB và chi phí tiết kiệm được. Cảnh báo các lỗi OOM kill, mức dùng bộ nhớ vượt quá request, hiện tượng bóp băng thông CPU (CPU throttling), thiếu thông số requests hoặc memory limits, cấp phát dư thừa (overprovisioning) và khoảng thời gian thu thập dữ liệu quá ngắn. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Công cụ Tối ưu hóa Định mức Tài nguyên Kubernetes (Rightsizer)

Bạn giúp các kỹ sư nền tảng (platform engineers), kỹ sư SRE và nhà phát triển thiết lập thông số requests và limits cho các container Kubernetes dựa trên bằng chứng số liệu thực tế thay vì phỏng đoán. Từ các phân vị mức sử dụng (usage percentiles) được xuất ra từ Prometheus, Datadog hoặc trình đề xuất VPA, bạn đề xuất thông số requests và limits mới cho từng container, giải thích các rủi ro nguy hiểm trước tiên (lỗi OOM kill, nghẽn CPU do throttling, nguy cơ bị trục xuất pod - eviction), ước tính lượng tài nguyên mà cụm (cluster) tiết kiệm được, và lập kế hoạch triển khai an toàn.

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

- `scripts/rightsize.py` - đọc tệp CSV chứa các thiết lập hiện tại và phân vị mức sử dụng của từng container, sau đó in ra thông số requests và limits được khuyến nghị, các phát hiện rủi ro và lượng tài nguyên requests được giải phóng (chỉ dùng thư viện chuẩn của Python 3)
- `references/sizing-method.md` - vai trò của requests và limits, quy tắc tính toán định mức, các chỉ số cần thu thập (kèm câu truy vấn PromQL), và cách đọc số liệu của các container đang bị bóp CPU (throttled) hoặc bị OOM kill
- `references/rollout-and-guardrails.md` - thứ tự áp dụng thay đổi, quy trình triển khai (rollout) và hoàn tác (rollback) an toàn, LimitRange, ResourceQuota, VPA, cảnh báo (alerts), và những trường hợp không nên cắt giảm tài nguyên
- `templates/rightsizing-plan.md` - mẫu kế hoạch tối ưu hóa định mức tài nguyên để chia sẻ với nhóm hoặc đính kèm vào phiếu yêu cầu thay đổi (change ticket)
- `examples/shop-cluster-usage.csv` - sáu container của một hệ thống cửa hàng trực tuyến: một API được cấp phát quá dư thừa, container sidecar đi kèm của nó, một dịch vụ tìm kiếm vừa bị OOM kill vừa bị bóp CPU, một worker không đặt giới hạn bộ nhớ, Redis và một trình đẩy log (log shipper) không khai báo requests

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

### 1. Thu thập số liệu sử dụng
Hãy yêu cầu dữ liệu thống kê theo từng container trong ít nhất 7 ngày (tốt nhất là 14-28 ngày): số lượng bản sao (replicas), thông số requests và limits hiện tại, phân vị p50/p95/max của CPU tính bằng millicores, phân vị p50/p95/max của bộ nhớ làm việc (memory working set) tính bằng MiB, tỷ lệ phần trăm bị bóp CPU (throttled percentage) và số lần bị OOM kill. Tệp `references/sizing-method.md` có sẵn câu truy vấn PromQL cho từng chỉ số này. Hãy hỏi thêm về nhiệm vụ của từng workload, đặc điểm biến thiên lưu lượng truy cập (đỉnh điểm hàng tuần, đợt khuyến mãi, tác vụ chạy theo lô batch jobs) và liệu có đang dùng HPA để tự động co giãn cho nó hay không.

### 2. Chạy công cụ tối ưu hóa định mức
```
python3 scripts/rightsize.py examples/shop-cluster-usage.csv --core-month 24 --gib-month 3.2
python3 scripts/rightsize.py usage.csv --headroom 25 --keep-cpu-limits --json
python3 scripts/rightsize.py --self-test
```
Thông số đơn giá là tùy chọn và được tính trên mỗi lõi (core) hoặc mỗi GiB được yêu cầu (requested) mỗi tháng. Mã thoát (exit code) bằng 1 nghĩa là có phát hiện rủi ro mức CRITICAL hoặc HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Giải thích và lập kế hoạch
Bắt đầu với các container có mức cảnh báo CRITICAL và HIGH: nêu rõ vấn đề gì đang xảy ra hiện nay, bằng chứng số liệu cụ thể và thay đổi cần áp dụng. Đối với các container đang bị bóp CPU hoặc bị OOM kill, hãy lưu ý rằng các con số sử dụng hiện tại đã bị chạm trần giới hạn và cần lên kế hoạch đo đạc lại lần thứ hai sau khi khắc phục. Tiếp theo, liệt kê các khoản tiết kiệm lớn nhất (số lượng replicas sẽ nhân bội mức tiết kiệm này lên). Kiểm tra các trường hợp ngoại lệ trong `references/rollout-and-guardrails.md` mà việc cắt giảm tài nguyên là không an toàn, đồng thời nhắc nhở việc tinh chỉnh lại ngưỡng HPA khi thay đổi thông số requests.

### 4. Bàn giao kết quả
Điền vào mẫu `templates/rightsizing-plan.md` với bảng tổng hợp, bản vá YAML (YAML patch) cho từng workload, thứ tự triển khai, các chỉ số cần theo dõi và ngày hẹn đo đạc lại.

## Ví dụ kết quả
Đối với tệp `examples/shop-cluster-usage.csv`: container shop/catalog-search/app ở mức CRITICAL (bị OOM kill 3 lần tại ngưỡng giới hạn 1536Mi, mức bộ nhớ p95 là 1180Mi vượt quá mức request 1024Mi, bị bóp CPU 31% tại ngưỡng giới hạn 500m) và được đề xuất mức requests mới là 570m / 1360Mi, giới hạn bộ nhớ 1872Mi và gỡ bỏ hoàn toàn giới hạn CPU; ops/log-shipper hoàn toàn không có thông số requests nào (mức HIGH, chạy 8 replicas); shop/image-resizer cần bổ sung giới hạn bộ nhớ và tăng thêm bộ nhớ; shop/checkout-api/app giảm từ 1000m / 2048Mi xuống còn 485m / 912Mi trên 6 replicas. Tổng cộng giải phóng được 1.76 cores và 4.06 GiB tài nguyên requests, tương đương tiết kiệm khoảng 55 mỗi tháng nếu tính theo đơn giá 24 trên mỗi core và 3.2 trên mỗi GiB.

## Quy tắc

- Luôn khắc phục các rủi ro về độ tin cậy trước khi tối ưu tiết kiệm chi phí, và tuyệt đối không bao giờ khuyến nghị gỡ bỏ giới hạn bộ nhớ (memory limit).
- Luôn nêu rõ mỗi con số đề xuất được tính từ phân vị nào, khoảng thời gian dữ liệu bao lâu và tỷ lệ dự phòng (headroom) là bao nhiêu, đồng thời lưu ý rằng các đợt tăng vọt hiếm gặp nằm ngoài khoảng thời gian thu thập dữ liệu sẽ chưa được tính tới.
- Coi mức sử dụng của các container đang bị bóp CPU (throttled) hoặc bị OOM kill chỉ là mức sàn tối thiểu (floor), chứ chưa phải nhu cầu thực tế của chúng.
- Cung cấp các thay đổi dưới dạng bản vá tệp manifest hoặc Helm values để đội ngũ kỹ thuật xem xét; không tự ý chạy các lệnh `kubectl` trực tiếp lên cụm đang hoạt động.
- Báo cáo mức tiết kiệm dưới dạng "tài nguyên requests được giải phóng" cho tới khi các máy chủ nút (nodes) thực sự được thu hồi giảm bớt.
FILE:references/sizing-method.md
# Phương pháp Tính toán Định mức (Sizing Method)

## Vai trò của requests và limits
- **CPU request:** lượng tài nguyên mà bộ lập lịch (scheduler) đặt chỗ trước trên một node và là tỷ trọng chia sẻ CPU của container đó khi node rơi vào trạng thái bận. Đặt quá thấp: pod bị xếp vào các node chật chội và chạy chậm đi khi tải cao. Đặt quá cao: các node trông có vẻ đã đầy trên sổ sách trong khi thực tế lại đang nhàn rỗi, khiến bạn phải trả tiền cho những lõi CPU bỏ không.
- **CPU limit:** mức trần cứng được thực thi bởi hạn ngạch CFS (CFS quota) theo từng chu kỳ 100 ms. Một container chạm tới ngưỡng này sẽ bị bóp băng thông (throttled), ngay cả khi node đó đang dư thừa CPU nhàn rỗi. Các dịch vụ nhạy cảm với độ trễ (API, proxy, ứng dụng JVM và chương trình Go sử dụng nhiều luồng) sẽ chịu ảnh hưởng nặng nề nhất.
- **Memory request:** lượng bộ nhớ mà bộ lập lịch đặt chỗ trước. Các pod sử dụng nhiều bộ nhớ hơn mức request của chúng sẽ là đối tượng đầu tiên bị trục xuất (evicted) khi một node cạn kiệt bộ nhớ.
- **Memory limit:** mức trần cứng; vượt quá ngưỡng này đồng nghĩa với việc bị OOM kill ngay lập tức (mã thoát 137, lý do `OOMKilled`). Bộ nhớ không thể bị làm chậm (throttled) như CPU.
- **Các hạng QoS (QoS classes):** Guaranteed (requests = limits cho mọi container trong pod), Burstable (có thiết lập một số thông số requests), BestEffort (không thiết lập gì cả, sẽ bị trục xuất đầu tiên khi thiếu tài nguyên).

## Các quy tắc được sử dụng bởi `scripts/rightsize.py`
| Thông số | Quy tắc tính | Lý do |
|---|---|---|
| CPU request | p95 x (1 + headroom), làm tròn lên theo bậc 5m, tối thiểu 10m | đáp ứng tốt các giai đoạn bận rộn thông thường; phần nhu cầu đột biến còn lại sẽ tận dụng nguồn CPU nhàn rỗi dùng chung |
| CPU limit | gỡ bỏ (hoặc nếu dùng cờ `--keep-cpu-limits`: max(max x 1.2, 2 x request)) | giới hạn CPU gây nghẽn các đợt xử lý đột biến ngay cả trên các node đang rảnh |
| Memory request | p95 x (1 + headroom), làm tròn lên theo bậc 16Mi, tối thiểu 32Mi | giữ cho pod nằm ngoài danh sách ưu tiên bị trục xuất (eviction) |
| Memory limit | max(max x 1.25, request), làm tròn lên theo bậc 16Mi | tạo khoảng trống cho các đỉnh tải; không bao giờ được gỡ bỏ vì một lỗi rò rỉ bộ nhớ (memory leak) không được phép đánh sập cả node |

Tỷ lệ dự phòng (headroom) mặc định là 15%. Hãy nâng lên mức 25-30% đối với các lưu lượng có tính chất tăng vọt đột ngột (spiky traffic), các tác vụ chạy theo lô (batch jobs) hoặc khi khoảng thời gian thu thập dữ liệu quá ngắn.

## Các chỉ số cần thu thập
Trên từng container, trong khoảng thời gian ít nhất 7 ngày (tốt nhất là 14-28 ngày để bắt được các đỉnh tải hàng tuần và cuối tháng):
- CPU tính bằng millicores: giá trị p50, p95 và max của `rate(container_cpu_usage_seconds_total[5m]) * 1000`
- Bộ nhớ tính bằng MiB: giá trị p50, p95 và max của `container_memory_working_set_bytes / 2^20` (working set chính là chỉ số mà trình OOM killer và cơ chế trục xuất eviction theo dõi, chứ không phải RSS hay cache)
- Tỷ lệ bóp CPU (Throttling): `container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total * 100`
- Số lần OOM kill: `kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}` hoặc số lần khởi động lại (restarts) với mã thoát 137
- Thiết lập hiện tại: dùng lệnh `kubectl get deploy -o jsonpath` hoặc chỉ số `kube_pod_container_resource_requests` / `_limits`

## Cách đọc kết quả
- **Các container bị bóp CPU (Throttled containers)** sẽ báo cáo mức sử dụng bị chặn đứng tại ngưỡng limit, do đó chỉ số p95 thu được thấp hơn nhu cầu thực tế. Hãy nâng cao hoặc gỡ bỏ giới hạn CPU trước, đo đạc lại trong vài ngày, rồi mới tính toán định mức chuẩn.
- **Các lỗi OOM kill** có nghĩa là mức đỉnh thực sự vẫn chưa xác định được (vì container đã bị giết ngay khi chạm ngưỡng limit). Hãy tăng giới hạn bộ nhớ lên thêm 50% hoặc nhiều hơn, tìm nguyên nhân rò rỉ bộ nhớ hoặc truy vấn nặng gây tốn RAM, sau đó đo đạc lại.
- **Ứng dụng JVM, Node.js và Go** đọc kích thước vùng nhớ heap từ cấu hình khởi chạy: hãy thay đổi các cờ `-Xmx`, `--max-old-space-size` hoặc `GOMEMLIMIT` đồng bộ với giới hạn bộ nhớ của container (giữ vùng nhớ heap ở mức khoảng 70-75% so với memory limit).
- **Các container phụ trợ (Sidecars)** (như istio-proxy, trình đẩy log) cần được tính toán định mức riêng biệt; thông số của chúng sẽ được nhân lên theo từng bản sao (replica) của ứng dụng.
- **HPA dựa trên CPU** thực hiện co giãn dựa trên tỷ lệ giữa mức sử dụng thực tế chia cho thông số request. Việc hạ thấp thông số request sẽ khiến HPA kích hoạt nhân bản (scale out) sớm hơn, vì vậy bạn có thể cần hạ mục tiêu `averageUtilization` hoặc số lượng bản sao sẽ thay đổi. Hãy tối ưu hóa định mức tài nguyên trước, sau đó tinh chỉnh lại HPA.

## Giới hạn của phương pháp này
- Các chỉ số phân vị trong một khung thời gian sẽ bỏ sót các sự kiện hiếm gặp: tác vụ tổng hợp dữ liệu hàng tháng, đợt siêu khuyến mãi, hoặc quá trình dựng lại bộ nhớ đệm (cache rebuild) ngay sau khi triển khai bản mới.
- Mức sử dụng trên mỗi container thường được tính trung bình qua các bản sao (replicas); một phân mảnh nóng (hot shard) hoặc một pod đóng vai trò nút trưởng (leader pod) cần được tách thành một dòng dữ liệu riêng.
- Khả năng xếp khối trên node (node bin-packing), phần tài nguyên tiêu hao cho các DaemonSet và dung lượng bộ nhớ thực tế có thể cấp phát (allocatable memory) mới là yếu tố quyết định mức tiết kiệm thực sự; lượng tài nguyên requests được giải phóng chỉ thực sự chuyển hóa thành tiền tiết kiệm được khi số lượng node giảm xuống.
FILE:references/rollout-and-guardrails.md
# Triển khai và Các Rào chắn An toàn (Rollout and Guardrails)

## Thứ tự thực hiện thay đổi
1. **Khắc phục rủi ro trước tiên:** xử lý các trường hợp bị OOM kill, mức bộ nhớ p95 vượt quá request, bị bóp CPU nặng, hoặc thiếu khai báo requests. Những lỗi này trực tiếp gây ra sự cố hệ thống; việc tiết kiệm chi phí có thể đợi sau.
2. **Sau đó mới cắt giảm lãng phí:** xử lý các container bị cấp phát dư thừa (`OVERPROVISIONED`), ưu tiên những dịch vụ có số lượng bản sao (replica) lớn nhất trước vì mức tiết kiệm sẽ được nhân lên theo số bản sao.
3. **Thực hiện từng workload một,** áp dụng trên môi trường thử nghiệm (staging) trước nếu nơi đó có lưu lượng truy cập sát thực tế, sau đó mới đưa lên môi trường chính thức (production) vào khung giờ thấp điểm.

## Các bước triển khai an toàn
1. Thay đổi thông số requests và limits thông qua Git (trong tệp Helm values, bản vá Kustomize hoặc tệp manifest), tuyệt đối không dùng lệnh `kubectl edit` trực tiếp trên môi trường production.
2. Duy trì tốc độ triển khai cuốn chiếu từ từ: thiết lập `maxUnavailable: 0`, `maxSurge: 25%` kèm theo một `PodDisruptionBudget`, để các pod mới với kích thước tài nguyên mới khởi chạy thành công trước khi các pod cũ bị hủy.
3. Theo dõi sát sao trong 24-48 giờ: độ trễ p95, tỷ lệ lỗi, tỷ lệ bóp CPU (throttling), số lần OOM kill, số lần khởi động lại, số lần bị trục xuất (evictions) và số lượng bản sao do HPA điều chỉnh.
4. Hoàn tác (Rollback) = đảo ngược lại bản commit (revert commit). Việc hạ thấp thông số request không bao giờ tự giết chết các pod đang chạy, nhưng các pod sẽ được tạo lại mới trong quá trình triển khai (rollout).

## Ví dụ bản vá cấu hình (patch)
```yaml
resources:
  requests:
    cpu: 485m
    memory: 912Mi
  limits:
    memory: 1136Mi   # no cpu limit on purpose: avoid CFS throttling
```

## Các rào chắn bảo vệ cụm (Cluster guardrails)
- **LimitRange** trên từng namespace: áp đặt mức requests mặc định và giới hạn bộ nhớ mặc định, nhằm ngăn chặn sự xuất hiện của các pod thuộc hạng BestEffort.
- **ResourceQuota** trên từng namespace: khống chế tổng lượng tài nguyên requests tối đa để một nhóm đơn lẻ không thể chiếm hết toàn bộ tài nguyên của cụm.
- **Vertical Pod Autoscaler (VPA)** ở chế độ `updateMode: "Off"` giúp đưa ra các con số khuyến nghị để đối chiếu với kết quả của kỹ năng này mà không tự động khởi động lại các pod. Không được để VPA (chế độ Auto) và HPA theo dõi CPU hoặc bộ nhớ cùng lúc điều khiển chung một workload.
- **Cảnh báo (Alerts):** có sự kiện OOMKilled trong vòng một giờ qua, tỷ lệ bóp CPU vượt quá 25% kéo dài trong 15 phút, bộ nhớ làm việc (working set) vượt quá 90% ngưỡng limit, hoặc có các pod bị treo ở trạng thái Pending do thiếu tài nguyên.

## Khi nào không nên cắt giảm
- Các workload có những đợt tăng tải đột biến hiếm gặp đã biết trước nhưng chưa xuất hiện trong khoảng thời gian lấy mẫu dữ liệu (chốt sổ cuối tháng, Black Friday, chạy báo cáo quý).
- Các pod có giai đoạn khởi động tiêu tốn nhiều CPU hơn hẳn so với trạng thái chạy ổn định (ví dụ quá trình khởi động làm nóng của JVM): hãy giữ mức request cao hơn hoặc sử dụng cơ chế kiểm tra khởi động (startup probe) với độ trễ rộng rãi.
- Các hệ quản trị cơ sở dữ liệu và bộ nhớ đệm đã được cấu hình cứng kích thước bộ nhớ bên trong (như `maxmemory` của Redis, `shared_buffers` của PostgreSQL): giới hạn bộ nhớ (memory limit) của container bắt buộc phải luôn cao hơn kích thước đã cấu hình cộng với phần bộ nhớ phụ trợ (overhead).
- Các trường hợp chủ đích cần hạng chất lượng dịch vụ Guaranteed QoS (ứng dụng đòi hỏi độ trễ cực thấp, ghim cố định lõi CPU với chính sách static CPU manager): hãy giữ nguyên thông số requests bằng đúng với limits.

## Chuyển hóa lượng requests giải phóng thành tiền tiết kiệm thực tế
Lượng tài nguyên requests được giải phóng chỉ thực sự làm giảm hóa đơn chi phí khi bộ tự động co giãn cụm (cluster autoscaler hoặc Karpenter) có thể gom pod và gỡ bỏ bớt các node dư thừa. Hãy kiểm tra hiệu suất sử dụng node theo tỷ lệ requests sau khi áp dụng thay đổi và chờ khoảng một ngày để quá trình dồn nén (consolidation) diễn ra. Hãy luôn báo cáo mức tiết kiệm là "lượng requests được giải phóng" cho đến khi số lượng node thực sự giảm xuống.
FILE:templates/rightsizing-plan.md
# Kế hoạch Tối ưu hóa Định mức Tài nguyên (Rightsizing Plan) - <cluster / namespace> - <ngày>

**Nguồn dữ liệu:** <Prometheus / Datadog / khác>, từ <ngày đầu> đến <ngày cuối> (<n> ngày)  **Dự phòng (Headroom):** <15>%  **Người phụ trách:** <tên>

## Các rủi ro cần khắc phục trước tiên
| Container | Phát hiện | Bằng chứng | Thay đổi | Hạn hoàn thành |
|---|---|---|---|---|
| | | | | |

## Thông số Requests và Limits
| Container | Replicas | CPU req (hiện tại -> mới) | CPU limit | Mem req (hiện tại -> mới) | Mem limit (hiện tại -> mới) | Trạng thái |
|---|---|---|---|---|---|---|
| | | | | | | |

## Mức tiết kiệm ước tính
- Tài nguyên requests giải phóng được: <cores> cores, <GiB> GiB
- Mức tiết kiệm hàng tháng ước tính: <số tiền> (tính theo đơn giá <giá mỗi core> / <giá mỗi GiB>); chỉ thành tiền thực tế sau khi cụm thu giảm số lượng node

## Lộ trình triển khai (Rollout)
| Bước | Workload | Môi trường | Ngày | Chỉ số theo dõi | Phương án hoàn tác (Rollback) |
|---|---|---|---|---|---|
| 1 | | staging | | độ trễ, OOM, throttling | revert commit |

## Các bước theo dõi tiếp theo
- Đo đạc lại vào ngày <ngày> với số liệu phân vị mới
- Rào chắn an toàn (Guardrails): thiết lập mặc định LimitRange <...>, cảnh báo <...>
FILE:examples/shop-cluster-usage.csv
namespace,workload,container,replicas,cpu_request_m,cpu_limit_m,mem_request_mi,mem_limit_mi,cpu_p50_m,cpu_p95_m,cpu_max_m,mem_p50_mi,mem_p95_mi,mem_max_mi,throttled_pct,oom_kills,days
shop,checkout-api,app,6,1000,2000,2048,2048,180,420,1350,610,780,905,0,0,14
shop,checkout-api,istio-proxy,6,100,2000,128,1024,35,90,240,70,95,120,0,0,14
shop,catalog-search,app,4,500,500,1024,1536,310,495,500,900,1180,1490,31,3,14
shop,image-resizer,worker,3,250,1000,512,,120,380,950,340,610,1720,4,0,14
shop,cart-redis,redis,1,200,,768,1024,40,75,160,690,712,730,0,0,14
ops,log-shipper,fluent-bit,8,,,,,25,60,180,45,70,95,0,0,5
FILE:scripts/rightsize.py
#!/usr/bin/env python3
"""Kubernetes resource requests rightsizer (Python 3 standard library only).

Input: CSV with a header row, one row per container, columns
  namespace,workload,container,replicas,
  cpu_request_m,cpu_limit_m,mem_request_mi,mem_limit_mi,
  cpu_p50_m,cpu_p95_m,cpu_max_m,mem_p50_mi,mem_p95_mi,mem_max_mi,
  throttled_pct,oom_kills,days
CPU in millicores (m), memory in MiB (Mi). Empty request/limit cells mean "not set".
throttled_pct = share of CFS periods throttled (0-100); oom_kills and days may be empty (0 / unknown).

Recommendation per container (headroom default 15%, --headroom):
  cpu request  = p95 x (1 + headroom), rounded up to 5m, at least 10m
  cpu limit    = none (recommended), unless --keep-cpu-limits: max(cpu max x 1.2, 2 x request), 50m steps
  mem request  = p95 x (1 + headroom), rounded up to 16Mi, at least 32Mi
  mem limit    = max(mem max x 1.25, request), rounded up to 16Mi (never removed)
Findings (severity):
  CRITICAL OOM_RISK           OOM kills seen, or memory max >= 95% of the memory limit
  HIGH     NO_REQUESTS        cpu or memory request missing (BestEffort / poor scheduling)
  HIGH     MEM_UNDER_REQUEST  memory p95 above the memory request (eviction risk under node pressure)
  HIGH     CPU_THROTTLED      throttled_pct >= 25
  MEDIUM   CPU_THROTTLED      throttled_pct 10-25
  MEDIUM   CPU_UNDER_REQUEST  cpu p95 above 1.2 x cpu request (noisy neighbours, slow under load)
  MEDIUM   NO_MEMORY_LIMIT    memory limit missing (one leak can take the node down)
  LOW      OVERPROVISIONED    recommended cpu or memory request < 50% of current
  LOW      SHORT_WINDOW       under 7 days of data (misses weekly peaks; trust less)
Savings: (current - recommended requests) x replicas, totals in cores and GiB, and a monthly
estimate when --core-month and --gib-month prices are given.
Usage:
  python3 rightsize.py usage.csv
  python3 rightsize.py usage.csv --headroom 25 --core-month 24 --gib-month 3.2 --json
  python3 rightsize.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import csv
import io
import json
import math
import sys

COLUMNS = ("namespace", "workload", "container", "replicas", "cpu_request_m", "cpu_limit_m",
           "mem_request_mi", "mem_limit_mi", "cpu_p50_m", "cpu_p95_m", "cpu_max_m",
           "mem_p50_mi", "mem_p95_mi", "mem_max_mi", "throttled_pct", "oom_kills", "days")
OPTIONAL = {"cpu_request_m", "cpu_limit_m", "mem_request_mi", "mem_limit_mi", "throttled_pct", "oom_kills", "days"}
SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}


class DataError(ValueError):
    pass


def up(x, step):
    return int(math.ceil(x / step - 1e-9) * step)


def num(row, col, line):
    v = row.get(col, "")
    if v == "":
        if col in OPTIONAL:
            return None
        raise DataError(f"line {line}: {col} is required")
    try:
        x = float(v)
    except ValueError:
        raise DataError(f"line {line}: {col} must be a number, got {v!r}")
    if x < 0:
        raise DataError(f"line {line}: {col} must be >= 0")
    return x


def read_rows(text):
    rd = csv.DictReader(io.StringIO(text))
    if not rd.fieldnames:
        raise DataError("empty CSV")
    missing = [c for c in COLUMNS if c not in {f.strip() for f in rd.fieldnames}]
    if missing:
        raise DataError("CSV header is missing: " + ", ".join(missing))
    rows = []
    for n, raw in enumerate(rd, start=2):
        row = {k.strip(): (v or "").strip() for k, v in raw.items() if k}
        if not any(row.values()):
            continue
        r = {c: row[c] for c in ("namespace", "workload", "container")}
        for c in COLUMNS[3:]:
            r[c] = num(row, c, n)
        if r["replicas"] < 1:
            raise DataError(f"line {n}: replicas must be >= 1")
        if r["cpu_p95_m"] > r["cpu_max_m"] or r["mem_p95_mi"] > r["mem_max_mi"]:
            raise DataError(f"line {n}: p95 cannot be above max")
        rows.append(r)
    if not rows:
        raise DataError("no data rows")
    return rows


def analyze(r, headroom=0.15, keep_cpu_limits=False):
    h = 1 + headroom
    rec = {
        "cpu_request_m": max(10, up(r["cpu_p95_m"] * h, 5)),
        "mem_request_mi": max(32, up(r["mem_p95_mi"] * h, 16)),
    }
    rec["cpu_limit_m"] = max(up(r["cpu_max_m"] * 1.2, 50), up(2 * rec["cpu_request_m"], 50)) if keep_cpu_limits else None
    rec["mem_limit_mi"] = max(rec["mem_request_mi"], up(r["mem_max_mi"] * 1.25, 16))
    findings = []

    def f(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    cr, cl, mr, ml = r["cpu_request_m"], r["cpu_limit_m"], r["mem_request_mi"], r["mem_limit_mi"]
    oom = r["oom_kills"] or 0
    if oom > 0:
        f("CRITICAL", "OOM_RISK", f"{oom:g} OOM kill(s); memory max {r['mem_max_mi']:g}Mi vs limit {fmt(ml, 'Mi')}")
    elif ml and r["mem_max_mi"] >= 0.95 * ml:
        f("CRITICAL", "OOM_RISK", f"memory max {r['mem_max_mi']:g}Mi is {r['mem_max_mi'] / ml:.0%} of the {ml:g}Mi limit")
    if cr is None or mr is None:
        f("HIGH", "NO_REQUESTS", "missing " + " and ".join(x for x, v in (("cpu request", cr), ("memory request", mr)) if v is None))
    if mr and r["mem_p95_mi"] > mr:
        f("HIGH", "MEM_UNDER_REQUEST", f"memory p95 {r['mem_p95_mi']:g}Mi > request {mr:g}Mi")
    th = r["throttled_pct"] or 0
    if th >= 25:
        f("HIGH", "CPU_THROTTLED", f"{th:g}% of CPU periods throttled at limit {fmt(cl, 'm')}; usage is capped, so real demand is higher")
    elif th >= 10:
        f("MEDIUM", "CPU_THROTTLED", f"{th:g}% of CPU periods throttled at limit {fmt(cl, 'm')}")
    if cr and r["cpu_p95_m"] > 1.2 * cr:
        f("MEDIUM", "CPU_UNDER_REQUEST", f"cpu p95 {r['cpu_p95_m']:g}m > 1.2 x request {cr:g}m")
    if ml is None:
        f("MEDIUM", "NO_MEMORY_LIMIT", "no memory limit set")
    over = []
    if cr and rec["cpu_request_m"] < 0.5 * cr:
        over.append(f"cpu {cr:g}m -> {rec['cpu_request_m']}m")
    if mr and rec["mem_request_mi"] < 0.5 * mr:
        over.append(f"memory {mr:g}Mi -> {rec['mem_request_mi']}Mi")
    if over:
        f("LOW", "OVERPROVISIONED", "; ".join(over))
    if r["days"] is not None and r["days"] < 7:
        f("LOW", "SHORT_WINDOW", f"only {r['days']:g} days of data")
    findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
    status = findings[0]["severity"] if findings and findings[0]["severity"] != "LOW" else "OK"
    n = r["replicas"]
    d_cpu = ((cr or 0) - rec["cpu_request_m"]) * n
    d_mem = ((mr or 0) - rec["mem_request_mi"]) * n
    return {"id": f"{r['namespace']}/{r['workload']}/{r['container']}", "replicas": int(n), "current": {
        "cpu_request_m": cr, "cpu_limit_m": cl, "mem_request_mi": mr, "mem_limit_mi": ml},
        "recommended": rec, "delta_cpu_m": d_cpu, "delta_mem_mi": d_mem, "status": status, "findings": findings}


def run(text, headroom=0.15, keep_cpu_limits=False, core_month=None, gib_month=None):
    res = [analyze(r, headroom, keep_cpu_limits) for r in read_rows(text)]
    res.sort(key=lambda x: (SEV_ORDER.get(x["status"], 4), x["id"]))
    tot_cpu = sum(x["delta_cpu_m"] for x in res) / 1000
    tot_mem = sum(x["delta_mem_mi"] for x in res) / 1024
    out = {"containers": res, "freed_cores": round(tot_cpu, 3), "freed_gib": round(tot_mem, 3)}
    if core_month is not None and gib_month is not None:
        out["monthly_saving"] = round(tot_cpu * core_month + tot_mem * gib_month, 2)
    return out


def fmt(v, unit):
    return "none" if v is None else f"{v:g}{unit}"


def report(out, headroom):
    L = ["# Kubernetes rightsizing report", "",
         f"Requests = p95 + {headroom:.0%} headroom; CPU limits removed unless kept; memory limit = max x 1.25.", "",
         "| Container | Replicas | Status | CPU req | CPU limit | Mem req | Mem limit |",
         "|---|---|---|---|---|---|---|"]
    for x in out["containers"]:
        c, r = x["current"], x["recommended"]
        L.append(f"| {x['id']} | {x['replicas']} | {x['status']} | {fmt(c['cpu_request_m'], 'm')} -> {r['cpu_request_m']}m"
                 f" | {fmt(c['cpu_limit_m'], 'm')} -> {fmt(r['cpu_limit_m'], 'm')}"
                 f" | {fmt(c['mem_request_mi'], 'Mi')} -> {r['mem_request_mi']}Mi"
                 f" | {fmt(c['mem_limit_mi'], 'Mi')} -> {r['mem_limit_mi']}Mi |")
    L += ["", "## Findings"]
    any_f = False
    for x in out["containers"]:
        for f in x["findings"]:
            any_f = True
            L.append(f"- {f['severity']} {f['code']} {x['id']}: {f['message']}")
    if not any_f:
        L.append("- none")
    L += ["", "## Requests freed (negative = more needed)",
          f"- CPU: {out['freed_cores']:g} cores", f"- Memory: {out['freed_gib']:g} GiB"]
    if "monthly_saving" in out:
        L.append(f"- Estimated monthly saving: {out['monthly_saving']:,.2f}")
    return "\n".join(L) + "\n"


def self_test():
    cases = []

    def ok(name, cond):
        cases.append(bool(cond))
        print(("PASS " if cond else "FAIL ") + name)

    hdr = ",".join(COLUMNS) + "\n"

    def row(**kw):
        base = dict(namespace="ns", workload="w", container="c", replicas=1, cpu_request_m=500, cpu_limit_m=1000,
                    mem_request_mi=512, mem_limit_mi=1024, cpu_p50_m=100, cpu_p95_m=200, cpu_max_m=300,
                    mem_p50_mi=200, mem_p95_mi=300, mem_max_mi=400, throttled_pct=0, oom_kills=0, days=14)
        base.update(kw)
        return ",".join("" if base[c] is None else str(base[c]) for c in COLUMNS) + "\n"

    def one(**kw):
        return run(hdr + row(**kw))["containers"][0]

    def codes(x):
        return {(f["severity"], f["code"]) for f in x["findings"]}

    x = one()
    ok("cpu request = p95 x 1.15 rounded to 5m (230)", x["recommended"]["cpu_request_m"] == 230)
    ok("mem request = 300 x 1.15 = 345 -> 352Mi", x["recommended"]["mem_request_mi"] == 352)
    ok("mem limit = 400 x 1.25 = 500 -> 512Mi", x["recommended"]["mem_limit_mi"] == 512)
    ok("cpu limit removed by default", x["recommended"]["cpu_limit_m"] is None)
    ok("healthy container is OK", x["status"] == "OK")
    ok("headroom 0.25 changes cpu request (250)", run(hdr + row(), headroom=0.25)["containers"][0]["recommended"]["cpu_request_m"] == 250)
    k = run(hdr + row(), keep_cpu_limits=True)["containers"][0]["recommended"]["cpu_limit_m"]
    ok("kept cpu limit = max(360->400, 460->500) = 500", k == 500)
    ok("minimums 10m / 32Mi", one(cpu_p50_m=0, cpu_p95_m=1, cpu_max_m=2, mem_p50_mi=1, mem_p95_mi=2, mem_max_mi=3)["recommended"]
       == {"cpu_request_m": 10, "mem_request_mi": 32, "cpu_limit_m": None, "mem_limit_mi": 32})
    ok("OOM kills -> CRITICAL", ("CRITICAL", "OOM_RISK") in codes(one(oom_kills=2)) and one(oom_kills=2)["status"] == "CRITICAL")
    ok("max at 98% of limit -> OOM_RISK", ("CRITICAL", "OOM_RISK") in codes(one(mem_max_mi=1004, mem_p95_mi=500)))
    ok("missing requests -> HIGH NO_REQUESTS", ("HIGH", "NO_REQUESTS") in codes(one(cpu_request_m=None)))
    ok("mem p95 above request -> HIGH", ("HIGH", "MEM_UNDER_REQUEST") in codes(one(mem_request_mi=256)))
    ok("throttling 30% -> HIGH", ("HIGH", "CPU_THROTTLED") in codes(one(throttled_pct=30)))
    ok("throttling 12% -> MEDIUM", ("MEDIUM", "CPU_THROTTLED") in codes(one(throttled_pct=12)))
    ok("cpu p95 > 1.2 x request -> MEDIUM", ("MEDIUM", "CPU_UNDER_REQUEST") in codes(one(cpu_request_m=150)))
    ok("no memory limit -> MEDIUM", ("MEDIUM", "NO_MEMORY_LIMIT") in codes(one(mem_limit_mi=None)))
    big = one(cpu_request_m=2000, mem_request_mi=4096)
    ok("overprovisioned flagged LOW, status stays OK", ("LOW", "OVERPROVISIONED") in codes(big) and big["status"] == "OK")
    ok("short window flagged", ("LOW", "SHORT_WINDOW") in codes(one(days=3)))
    two = run(hdr + row(replicas=3) + row(workload="b", replicas=2, cpu_request_m=100, mem_request_mi=256),
              core_month=20, gib_month=4)
    # w: (500-230)*3=810m, (512-352)*3=480Mi ; b: (100-230)*2=-260m, (256-352)*2=-192Mi
    ok("freed cores = (810-260)/1000 = 0.55", two["freed_cores"] == 0.55)
    ok("freed GiB = (480-192)/1024 = 0.281", two["freed_gib"] == 0.281)
    ok("monthly saving = 0.55 x 20 + 0.28125 x 4 = 12.125", abs(two["monthly_saving"] - 12.125) < 0.01)
    ok("sorted by severity", two["containers"][0]["id"] == "ns/b/c")
    for bad, label in (("a,b\n1,2\n", "missing columns"), (hdr, "no rows"), (hdr + row(cpu_p95_m="x"), "bad number"),
                       (hdr + row(cpu_p95_m=900), "p95 above max"), (hdr + row(replicas=0), "zero replicas"),
                       (hdr + row(cpu_p95_m=None), "missing usage")):
        try:
            run(bad)
            ok(label + " rejected", False)
        except DataError:
            ok(label + " rejected", True)
    print(f"{sum(cases)}/{len(cases)} passed")
    return 0 if all(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Rightsize Kubernetes container requests and limits from usage percentiles.")
    ap.add_argument("file", nargs="?", help="CSV with the columns listed in the module docstring")
    ap.add_argument("--headroom", type=float, default=15, help="percent added to p95 for requests (default 15)")
    ap.add_argument("--keep-cpu-limits", action="store_true", help="recommend CPU limits instead of removing them")
    ap.add_argument("--core-month", type=float, help="price of one requested core per month")
    ap.add_argument("--gib-month", type=float, help="price of one requested GiB of memory per month")
    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.file:
        ap.error("give a CSV file or --self-test")
    try:
        out = run(open(a.file, encoding="utf-8").read(), a.headroom / 100, a.keep_cpu_limits, a.core_month, a.gib_month)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(out, indent=2) if a.json else report(out, a.headroom / 100), end="\n" if a.json else "")
    return 1 if any(x["status"] in ("CRITICAL", "HIGH") for x in out["containers"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsCloud+2
F@f
0
Trình Kiểm tra Robots.txt và Sitemap
Kỹ năng

Kiểm tra ngoại tuyến tệp robots.txt và XML sitemap của trang web theo đúng cách Google đọc chúng. Chọn nhóm trình thu thập dữ liệu (crawler), phân giải quy tắc Allow và Disallow có độ dài khớp lớn nhất (longest-match) kèm ký tự đại diện (wildcards), đồng thời kiểm thử các URL bắt buộc phải cho phép thu thập hoặc bị chặn. Cảnh báo lỗi chặn toàn bộ trang web, các URL trong sitemap bị robots.txt chặn, chặn tệp CSS và JS, các dòng noindex và crawl-delay không được hỗ trợ, cũng như các mục nhập sitemap nằm ngoài trang (off-site), trùng lặp hoặc không hợp lệ. Bao gồm một công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: robots-txt-and-sitemap-checker
description: Kiểm tra ngoại tuyến tệp robots.txt và XML sitemap của trang web theo đúng cách Google đọc chúng. Chọn nhóm trình thu thập dữ liệu (crawler), phân giải quy tắc Allow và Disallow có độ dài khớp lớn nhất (longest-match) kèm ký tự đại diện (wildcards), đồng thời kiểm thử các URL bắt buộc phải cho phép thu thập hoặc bị chặn. Cảnh báo lỗi chặn toàn bộ trang web, các URL trong sitemap bị robots.txt chặn, chặn tệp CSS và JS, các dòng noindex và crawl-delay không được hỗ trợ, cũng như các mục nhập sitemap nằm ngoài trang (off-site), trùng lặp hoặc không hợp lệ. Bao gồm một công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Trình Kiểm tra Robots.txt và Sitemap

Bạn giúp chủ sở hữu trang web, chuyên gia SEO và nhà phát triển web đảm bảo các công cụ tìm kiếm có thể thu thập dữ liệu (crawl) những trang quan trọng và bỏ qua những trang không cần thiết. Từ tệp robots.txt, XML sitemap và danh sách các URL quan trọng, bạn chỉ rõ quy tắc nào quyết định trạng thái của từng URL, tìm ra những mâu thuẫn giữa robots.txt và sitemap, giải thích từng vấn đề bằng ngôn ngữ dễ hiểu, đồng thời đề xuất bản robots.txt đã sửa lỗi cùng các chỉnh sửa cho sitemap.

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

- `scripts/crawl_check.py` - phân tích cú pháp robots.txt và một sitemap, quyết định trạng thái cho phép (allowed) hay chặn (blocked) trên từng URL giống hệt Googlebot, đồng thời in ra bảng URL cùng các phát hiện lỗi (chỉ dùng thư viện chuẩn của Python 3, không dùng mạng)
- `references/robots-txt-rules.md` - các nhóm (groups), quy tắc khớp dài nhất (longest-match) và ký tự đại diện (wildcard) kèm ví dụ, các chỉ thị được hỗ trợ, những sai lầm phổ biến và cách kiểm thử
- `references/sitemap-rules-and-fixes.md` - định dạng và giới hạn của sitemap, những gì nên liệt kê, thẻ lastmod, và cách khắc phục cho từng phát hiện lỗi
- `templates/crawl-audit-report.md` - mẫu báo cáo kiểm toán thu thập dữ liệu kèm bản đề xuất robots.txt để bàn giao cho nhà phát triển
- `examples/outdoor-shop-crawl-check.json` - một cửa hàng đồ dã ngoại sau khi ra mắt lại giao diện: phần blog và tệp JS vô tình bị chặn, có một dòng noindex, đường dẫn quản trị (admin) bị bỏ ngỏ, cùng một sitemap chứa các URL bị chặn, nằm ngoài tên miền (off-site), bị trùng lặp và ghi sai định dạng ngày tháng

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

### 1. Thu thập các tệp
Hãy yêu cầu tệp robots.txt đang chạy thực tế (`https://<host>/robots.txt`), tệp sitemap hoặc sitemap index, địa chỉ gốc chuẩn tắc (canonical origin gồm giao thức scheme và tên miền host), cùng hai danh sách ngắn: các URL bắt buộc phải thu thập được (trang chủ, danh mục, sản phẩm hoặc bài viết hàng đầu, các gói CSS/JS) và các URL cần phải chặn (giỏ hàng, thanh toán, tìm kiếm nội bộ, bộ lọc, trang quản trị admin). Hỏi xem trình thu thập dữ liệu (crawler) nào là quan trọng nhất đối với họ (mặc định là Googlebot).

### 2. Chạy công cụ kiểm tra
```
python3 scripts/crawl_check.py examples/outdoor-shop-crawl-check.json --today 2026-10-23
python3 scripts/crawl_check.py --robots robots.txt --sitemap sitemap.xml --site https://www.example.com --allow /product/x --block /cart/
python3 scripts/crawl_check.py --self-test
```
Một tệp JSON đóng gói sẽ chứa thông tin trang web, tên crawler, các dòng trong robots.txt và sitemap cùng các danh sách URL. Mã thoát (exit code) bằng 1 nghĩa là có phát hiện lỗi mức CRITICAL hoặc HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Giải thích và khắc phục
Bắt đầu với các lỗi mức CRITICAL và HIGH: chặn toàn bộ trang web, chặn các trang quan trọng, các URL có trong sitemap nhưng bị robots.txt chặn, các mục nhập trong sitemap trỏ ra ngoài tên miền (off-site). Với mỗi lỗi, hãy nêu rõ dòng quy tắc quyết định và đưa ra quy tắc đã sửa lại cho đúng. Giải thích rằng robots.txt không xóa các trang khỏi kết quả tìm kiếm và cũng không bảo vệ các trang riêng tư (`references/robots-txt-rules.md`), đồng thời chỉ dẫn tới bảng cách khắc phục trong `references/sitemap-rules-and-fixes.md`.

### 4. Bàn giao kết quả
Điền vào mẫu `templates/crawl-audit-report.md` với bảng kiểm tra URL, các phát hiện lỗi, toàn bộ nội dung tệp robots.txt hoàn chỉnh đã sửa và các thay đổi cần thiết cho bộ tạo sitemap (sitemap generator), sau đó chạy lại công cụ kiểm tra trên các tệp đã sửa để xác nhận.

## Ví dụ kết quả
Đối với tệp `examples/outdoor-shop-crawl-check.json`, trạng thái đánh giá là HIGH: chỉ thị `Disallow: /blog/` (dòng 9) đã chặn đường dẫn /blog/trail-running-shoes-review và trang /blog/spring-sale-recap đang được liệt kê trong sitemap, `Disallow: /assets/js/` chặn gói bundle của ứng dụng, và đường dẫn http://trailgear.example/backpacks/ trong sitemap bị tính là khác nguồn (off-site). Dòng `Noindex: /search/` bị Google bỏ qua, vì vậy /search/ và /admin/ vẫn có thể bị thu thập dữ liệu; ngoài ra sitemap còn có một đường dẫn /tents/ bị trùng lặp, một thẻ lastmod ghi năm 2027 và một thẻ ghi theo định dạng `18.10.2026`.

## Quy tắc

- Luôn nêu chính xác số dòng trong tệp robots.txt quyết định trạng thái của một URL, cùng với tên crawler đã được dùng để đánh giá.
- Tuyệt đối không bao giờ khuyên dùng robots.txt để ẩn các trang riêng tư hoặc nhạy cảm; hãy yêu cầu dùng cơ chế xác thực đăng nhập (authentication), và dùng thẻ `noindex` cho những trang cần loại khỏi kết quả tìm kiếm.
- Luôn cung cấp một tệp robots.txt hoàn chỉnh đã sửa lỗi chứ không chỉ đưa ra các đoạn trích rời rạc, đồng thời giữ nguyên các quy tắc mà chủ sở hữu trang web đã chủ đích thêm vào (ví dụ như chặn AhrefsBot).
- Công cụ kiểm tra không trực tiếp tải các URL qua mạng; hãy nói rõ rằng việc kiểm tra mã trạng thái HTTP (status codes), chuyển hướng (redirects), thẻ canonical và thẻ noindex cần phải thực hiện bằng trình thu thập thực tế hoặc qua Search Console.
FILE:references/robots-txt-rules.md
# Các quy tắc robots.txt (RFC 9309 và Google)

## Vị trí và bản chất
- Nằm ở thư mục gốc của từng tên miền (host) và giao thức (protocol): `https://www.example.com/robots.txt` chỉ có hiệu lực cho đúng tên miền và giao thức đó. Các biến thể `example.com`, `shop.example.com` và `http://` đều cần có tệp riêng của mình.
- Định dạng văn bản thuần túy (plain text), bảng mã UTF-8. Google chỉ đọc 500 KiB đầu tiên. Mã lỗi 404 đồng nghĩa với "cho phép thu thập mọi thứ"; mã lỗi máy chủ 5xx kéo dài có thể khiến Google ngừng thu thập dữ liệu toàn bộ trang web.
- Tệp robots.txt kiểm soát việc **thu thập dữ liệu (crawling)**, chứ không kiểm soát việc **lập chỉ mục (indexing)**. Một URL bị chặn vẫn có thể xuất hiện trên kết quả tìm kiếm (nhưng không có đoạn trích mô tả snippet) nếu có các trang khác đặt liên kết trỏ tới nó. Để giữ một trang không xuất hiện trên kết quả tìm kiếm, hãy cho phép thu thập dữ liệu và trả về chỉ thị `noindex`; để giữ trang ở chế độ riêng tư, hãy yêu cầu đăng nhập.

## Các nhóm (Groups)
```
User-agent: Googlebot
User-agent: Bingbot
Disallow: /tmp/

User-agent: *
Disallow: /cart/
```
- Các dòng `User-agent` nằm liền kề nhau sẽ dùng chung một nhóm. Các quy tắc bên dưới thuộc về nhóm ngay phía trên chúng.
- Một trình thu thập dữ liệu chỉ sử dụng **duy nhất một** nhóm: nhóm có tên khớp cụ thể nhất (`googlebot-image` được ưu tiên hơn `googlebot`), nếu không có mới dùng nhóm `*`. Các quy tắc từ nhóm `*` không được cộng gộp vào nhóm có tên cụ thể, vì vậy một lỗi rất phổ biến là tạo nhóm `Googlebot` riêng nhưng lại quên lặp lại các quy tắc chặn cơ bản đã viết trong nhóm `*`.
- Các nhóm có cùng tên sẽ được gộp lại với nhau.

## Cơ chế so khớp (Matching)
- Đường dẫn có phân biệt chữ hoa chữ thường (case-sensitive) và được so khớp tính từ đầu đường dẫn (bao gồm cả chuỗi truy vấn query string).
- Ký tự `*` khớp với bất kỳ chuỗi ký tự nào; ký tự `$` neo cố định điểm kết thúc của URL.
- Quy tắc khớp có **độ dài lớn nhất** (longest match) sẽ chiến thắng; nếu có độ dài bằng nhau thì chỉ thị `Allow` sẽ chiến thắng. Nếu không có quy tắc nào khớp thì mặc định là được phép thu thập (allowed).
- Chỉ thị `Disallow:` để trống giá trị đồng nghĩa với việc cho phép tất cả.

| Quy tắc | Chặn | Không chặn |
|---|---|---|
| `Disallow: /shop` | /shop, /shop/, /shopping | /Shop |
| `Disallow: /shop/` | /shop/shoes | /shop |
| `Disallow: /*?sort=` | /tents/?sort=price | /tents/?color=red&sort=price (cần dùng `/*sort=`) |
| `Disallow: /*.pdf$` | /docs/a.pdf | /docs/a.pdf?v=2 |
| `Disallow: /blog/` + `Allow: /blog/guides/` | /blog/news | /blog/guides/x |

## Các chỉ thị (Directives)
| Chỉ thị | Google hỗ trợ | Ghi chú |
|---|---|---|
| user-agent, allow, disallow | có | các quy tắc cốt lõi |
| sitemap | có | URL tuyệt đối, không giới hạn số lượng, đặt bên ngoài các nhóm |
| crawl-delay | không | Bing và Yandex có tuân thủ; đối với Google, hãy giảm tốc độ thu thập thông qua mã phản hồi từ máy chủ (429/503) |
| noindex, nofollow | không (đã ngừng hỗ trợ từ 2019) | hãy dùng thẻ `<meta name="robots" content="noindex">` hoặc tiêu đề HTTP `X-Robots-Tag` |
| host, clean-param | không | chỉ dành cho Yandex |

## Những sai lầm phổ biến
- Dòng `Disallow: /` còn sót lại từ môi trường thử nghiệm (staging) sau khi trang web chính thức ra mắt (khiến toàn bộ trang web biến mất khỏi luồng thu thập dữ liệu).
- Chặn các thư mục chứa CSS, JS hoặc hình ảnh: Google cần dựng hình (render) trang web và bắt buộc phải tải các tệp này; việc chặn tài nguyên có thể khiến trang web trông như bị lỗi giao diện hoặc bị đánh giá là không thân thiện với thiết bị di động.
- Chặn một trang đang gắn thẻ `noindex`: trình thu thập dữ liệu không bao giờ vào được trang để đọc thấy thẻ noindex, dẫn đến việc URL đó vẫn tiếp tục nằm lại trong chỉ mục tìm kiếm.
- Dùng robots.txt để giấu trang quản trị (admin) hoặc các trang riêng tư: tệp robots.txt hoàn toàn công khai và vô tình liệt kê chính xác vị trí của các trang nhạy cảm đó cho mọi người thấy.
- Để ngỏ các URL phân mảnh theo bộ lọc (sắp xếp, lọc thuộc tính, mã phiên session id), gây lãng phí ngân sách thu thập dữ liệu (crawl budget) vào vô số tổ hợp URL trùng lặp.
- Lỗi chính tả (ví dụ `Disalow`) sẽ bị lặng lẽ bỏ qua mà không có cảnh báo.

## Kiểm thử
- Google Search Console: báo cáo robots.txt (kiểm tra trạng thái tải tệp và các quy tắc đã phân tích) cùng công cụ Kiểm tra URL (URL Inspection - xem có bị chặn hay không).
- Trên máy cục bộ: `python3 scripts/crawl_check.py --robots robots.txt --site https://www.example.com --allow /product/x --block /cart/`.
FILE:references/sitemap-rules-and-fixes.md
# Các quy tắc và cách khắc phục lỗi XML Sitemap

## Định dạng
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://www.example.com/tents/ultralight-2p</loc>
    <lastmod>2026-10-18T09:30:00+03:00</lastmod>
  </url>
</urlset>
```
- Tối đa **50,000 URLs** và dung lượng khi chưa nén tối đa **50 MB** trên mỗi tệp. Các trang web lớn hơn cần sử dụng tệp chỉ mục sơ đồ trang web (sitemap index dùng thẻ `<sitemapindex>` chứa các mục `<sitemap><loc>`), cho phép quản lý tối đa 50,000 tệp sitemap con.
- Bảng mã UTF-8, bắt buộc mã hóa thực thể (entity-escape) cho các ký tự `&`, `<`, `>`, `'`, `"` nằm bên trong thẻ `<loc>`.
- Giá trị trong `<loc>` phải là đường dẫn tuyệt đối, sử dụng cùng giao thức (protocol) và tên miền (host) với chính tệp sitemap đó (trừ khi được gửi chéo thông qua Search Console cho một tên miền đã xác minh quyền sở hữu).

## Những gì cần đưa vào danh sách
- Chỉ liệt kê các URL chuẩn tắc (canonical), có thể lập chỉ mục và trả về mã 200: không đưa vào các đường dẫn chuyển hướng (redirects), lỗi 404, các trang gắn `noindex`, các URL bị chặn bởi robots.txt, hoặc các URL trùng lặp do tham số.
- Phải khớp chính xác dạng thức chuẩn tắc: cùng giao thức (https), cùng tên miền (có www hoặc không có www), cùng quy ước dấu gạch chéo cuối câu (trailing-slash) giống hệt như trong thẻ `rel=canonical`.
- Mỗi URL chỉ xuất hiện duy nhất một lần.

## Thẻ lastmod
- Định dạng ngày giờ theo chuẩn W3C datetime: `2026-10-18`, `2026-10-18T09:30+03:00` hoặc `2026-10-18T06:30:00Z`. Các định dạng như `18.10.2026` hay `10/18/2026` đều không hợp lệ.
- Hãy đặt giá trị này theo thời điểm có thay đổi nội dung **thực sự có ý nghĩa** lần cuối cùng, chứ không phải thời điểm chạy tiến trình xuất bản (build time). Google chỉ sử dụng `lastmod` khi thấy giá trị này chính xác một cách nhất quán; việc để ngày tháng ở tương lai hoặc cập nhật lại ngày mới cho mọi URL mỗi ngày sẽ khiến Google học cách phớt lờ hoàn toàn trường thông tin này.
- Hai thẻ `<changefreq>` và `<priority>` bị Google bỏ qua hoàn toàn; chúng không gây hại gì nhưng không đáng tốn công duy trì.

## Cách thông báo sitemap cho các trình thu thập dữ liệu
- Khai báo dòng `Sitemap: https://www.example.com/sitemap.xml` trong tệp robots.txt (dùng URL tuyệt đối).
- Gửi trực tiếp trong Google Search Console và Bing Webmaster Tools, sau đó theo dõi số lượng trang "Đã phát hiện" (Discovered) và "Đã lập chỉ mục" (Indexed) cùng các thông báo lỗi.

## Cách khắc phục theo từng mã phát hiện lỗi
| Mã phát hiện | Cách khắc phục |
|---|---|
| SITEMAP_URL_BLOCKED | Hãy quyết định rõ: nếu trang đó cần được xếp hạng tìm kiếm, hãy gỡ bỏ dòng Disallow; nếu không cần, hãy xóa nó khỏi tệp sitemap |
| SITEMAP_OFF_SITE | Viết lại giá trị `<loc>` theo đúng giao thức và tên miền chuẩn tắc; kiểm tra lại thiết lập URL gốc (base URL) trong công cụ tạo sitemap |
| SITEMAP_DUPLICATE | Khử trùng lặp ngay trong công cụ tạo sitemap (thường do cùng một trang bị xuất ra ở cả danh sách danh mục và danh sách thẻ tag) |
| LASTMOD_INVALID / LASTMOD_FUTURE | Xuất định dạng chuẩn ISO 8601 lấy từ trường thời gian cập nhật thực tế của nội dung; kiểm tra lại múi giờ và đồng hồ của máy chủ |
| SITEMAP_TOO_BIG | Chia nhỏ theo chuyên mục hoặc theo mốc thời gian thành nhiều tệp con nằm dưới một tệp sitemap index duy nhất |
| SITEMAP_PARSE_ERROR | Mã hóa ký tự `&` trong các URL, xóa ký tự BOM ở đầu tệp hoặc khắc phục lỗi máy chủ trả về trang báo lỗi HTML thay vì tệp XML, kiểm tra lại khai báo namespace |

## Những yếu tố nằm ngoài phạm vi công cụ này
Tập lệnh hoạt động ngoại tuyến trên các tệp bạn cung cấp. Bạn cần kiểm tra thêm bằng trình thu thập dữ liệu thực tế hoặc Search Console đối với: mã trạng thái HTTP thực tế của các URL trong danh sách, chuỗi chuyển hướng (redirects), thẻ canonical, thẻ noindex và các liên kết ngôn ngữ thay thế hreflang.
FILE:templates/crawl-audit-report.md
# Báo cáo Kiểm toán Thu thập Dữ liệu (Crawl Audit) - <trang web> - <ngày>

**Đã kiểm tra:** robots.txt (<tải ngày / phiên bản>), <URL của sitemap>  **Trình thu thập:** <Googlebot>  **Người đánh giá:** <tên>

## Tóm tắt
- Trạng thái: <CRITICAL / HIGH / OK>
- robots.txt: <n> nhóm, <n> quy tắc áp dụng cho <crawler>
- Sitemap: <n> URL, <n> bị chặn, <n> khác tên miền (off-site), <n> bị trùng lặp

## Các URL quan trọng
| URL | Trạng thái mong muốn | Kết quả thực tế | Quy tắc quyết định | Cách khắc phục |
|---|---|---|---|---|
| | crawlable | | | |
| | blocked | | | |

## Các phát hiện lỗi và cách khắc phục
| Mức độ nghiêm trọng | Phát hiện | Chi tiết | Cách khắc phục | Người phụ trách |
|---|---|---|---|---|
| | | | | |

## Đề xuất tệp robots.txt mới
```
User-agent: *
Disallow: ...
Allow: ...

Sitemap: https://...
```

## Các bước theo dõi tiếp theo
- Triển khai tệp robots.txt và sitemap mới vào ngày <ngày>; kiểm thử lại trong Search Console (báo cáo robots.txt, công cụ Kiểm tra URL)
- Kiểm tra lại độ phủ chỉ mục (coverage) và thống kê thu thập dữ liệu (crawl stats) vào ngày <ngày>
FILE:examples/outdoor-shop-crawl-check.json
{
  "site": "https://www.trailgear.example",
  "user_agent": "Googlebot",
  "robots_txt": [
    "# robots.txt for www.trailgear.example (after the October relaunch)",
    "User-agent: *",
    "Disallow: /cart/",
    "Disallow: /checkout/",
    "Disallow: /account/",
    "Disallow: /*?sort=",
    "Disallow: /*?sessionid=",
    "Disallow: /assets/js/",
    "Disallow: /blog/",
    "Allow: /blog/guides/",
    "Crawl-delay: 10",
    "Noindex: /search/",
    "",
    "User-agent: AhrefsBot",
    "Disallow: /",
    "",
    "Sitemap: https://www.trailgear.example/sitemap.xml"
  ],
  "sitemap_xml": [
    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
    "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">",
    "  <url><loc>https://www.trailgear.example/</loc><lastmod>2026-10-20</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/tents/</loc><lastmod>2026-10-18</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/tents/ultralight-2p</loc><lastmod>2026-10-18T09:30:00+03:00</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/tents/</loc><lastmod>2026-10-18</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/blog/spring-sale-recap</loc><lastmod>2026-04-02</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/blog/guides/layering-for-hiking</loc><lastmod>2026-09-12</lastmod></url>",
    "  <url><loc>http://trailgear.example/backpacks/</loc><lastmod>2026-10-01</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/sleeping-bags/</loc><lastmod>18.10.2026</lastmod></url>",
    "  <url><loc>https://www.trailgear.example/stoves/</loc><lastmod>2027-03-01</lastmod></url>",
    "</urlset>"
  ],
  "must_crawl": [
    "/tents/ultralight-2p",
    "/blog/trail-running-shoes-review",
    "/blog/guides/layering-for-hiking",
    "/assets/js/app.js",
    "/tents/?color=green"
  ],
  "must_block": [
    "/checkout/payment",
    "/tents/?sort=price",
    "/search/?q=stove",
    "/admin/"
  ]
}
FILE:scripts/crawl_check.py
#!/usr/bin/env python3
"""robots.txt and XML sitemap checker (Python 3 standard library only, works offline).

Input, either:
  a JSON bundle  {"site": "https://www.example.com", "user_agent": "Googlebot",
                  "robots_txt": [lines] or "text", "sitemap_xml": [lines] or "text",
                  "must_crawl": [urls or paths], "must_block": [urls or paths]}
  or files       --robots robots.txt [--sitemap sitemap.xml] --site https://... [--allow URL] [--block URL]
Matching follows RFC 9309 / Google: the group whose user-agent token matches the crawler name
(case-insensitive, most specific wins, else "*"), then the longest matching Allow/Disallow path
(* = any characters, $ = end of URL); on a tie Allow wins; no match = allowed.
Findings (severity):
  CRITICAL BLOCK_ALL               "Disallow: /" applies to the crawler with no Allow rules
  HIGH     MUST_CRAWL_BLOCKED      a URL that must be crawlable is disallowed
  HIGH     SITEMAP_URL_BLOCKED     a URL listed in the sitemap is disallowed
  HIGH     SITEMAP_PARSE_ERROR     sitemap is not well-formed XML or not urlset/sitemapindex
  HIGH     SITEMAP_TOO_BIG         more than 50,000 URLs in one sitemap
  HIGH     SITEMAP_OFF_SITE        a sitemap URL is on another host or scheme than the site
  MEDIUM   MUST_BLOCK_CRAWLABLE    a URL that should be blocked is crawlable (robots.txt is not access control)
  MEDIUM   ASSETS_BLOCKED          CSS, JS or image paths disallowed (pages cannot render for the crawler)
  MEDIUM   UNSUPPORTED_RULE        noindex / nofollow / host lines (ignored by Google)
  MEDIUM   SITEMAP_DUPLICATE       the same <loc> appears more than once
  MEDIUM   LASTMOD_FUTURE          <lastmod> after --today
  LOW      CRAWL_DELAY_IGNORED     crawl-delay (Googlebot ignores it; Bing and Yandex honour it)
  LOW      RULE_OUTSIDE_GROUP      allow/disallow before any user-agent line
  LOW      UNKNOWN_DIRECTIVE       unknown line or missing colon
  LOW      SITEMAP_RELATIVE        Sitemap: line is not an absolute URL
  LOW      NO_SITEMAP_LINE         robots.txt has no Sitemap: line
  LOW      LASTMOD_INVALID         <lastmod> is not W3C datetime (YYYY-MM-DD[Thh:mm[:ss]TZD])
Usage:
  python3 crawl_check.py bundle.json [--today 2026-10-23] [--json]
  python3 crawl_check.py --robots robots.txt --sitemap sitemap.xml --site https://www.example.com --allow /shop/
  python3 crawl_check.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import re
import sys
import urllib.parse
import xml.etree.ElementTree as ET

SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
KNOWN = {"user-agent", "allow", "disallow", "sitemap", "crawl-delay", "noindex", "nofollow", "host", "clean-param"}
ASSET_RE = re.compile(r"(\.css|\.js|\.png|\.jpe?g|\.webp|\.svg|\.gif|/static/|/assets/|/_next/|/wp-includes/|/css/|/js/)", re.I)
LASTMOD_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$")
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"


class DataError(ValueError):
    pass


def text_of(v):
    if v is None:
        return None
    return "\n".join(v) if isinstance(v, list) else str(v)


def parse_robots(text):
    """-> (groups [{agents, rules[(kind, path, line)]}], sitemaps [(url, line)], issues [(sev, code, msg)])"""
    groups, sitemaps, issues = [], [], []
    cur, last_was_agent = None, False
    for n, raw in enumerate(text.splitlines(), start=1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        if ":" not in line:
            issues.append(("LOW", "UNKNOWN_DIRECTIVE", f"line {n}: no colon in {line!r}"))
            continue
        key, val = (x.strip() for x in line.split(":", 1))
        key = key.lower()
        if key == "user-agent":
            if not last_was_agent or cur is None:
                cur = {"agents": [], "rules": []}
                groups.append(cur)
            cur["agents"].append(val.lower())
            last_was_agent = True
            continue
        last_was_agent = False
        if key in ("allow", "disallow"):
            if cur is None:
                issues.append(("LOW", "RULE_OUTSIDE_GROUP", f"line {n}: {key} before any user-agent line is ignored"))
            elif val:
                cur["rules"].append((key, val, n))
        elif key == "sitemap":
            sitemaps.append((val, n))
            if not re.match(r"^https?://", val, re.I):
                issues.append(("LOW", "SITEMAP_RELATIVE", f"line {n}: Sitemap must be an absolute URL, got {val!r}"))
        elif key == "crawl-delay":
            issues.append(("LOW", "CRAWL_DELAY_IGNORED", f"line {n}: crawl-delay {val} is ignored by Googlebot"))
        elif key in ("noindex", "nofollow", "host", "clean-param"):
            issues.append(("MEDIUM", "UNSUPPORTED_RULE", f"line {n}: '{key}' in robots.txt is not supported by Google; use meta robots / X-Robots-Tag or redirects"))
        else:
            issues.append(("LOW", "UNKNOWN_DIRECTIVE", f"line {n}: unknown directive {key!r}"))
    return groups, sitemaps, issues


def pick_group(groups, agent):
    """Rules for the crawler: merge all groups with the longest matching agent token, else '*'."""
    a = agent.lower()
    best, rules = -1, []
    for g in groups:
        for tok in g["agents"]:
            if tok != "*" and tok and a.startswith(tok) and len(tok) > best:
                best, rules = len(tok), list(g["rules"])
            elif tok != "*" and tok and a.startswith(tok) and len(tok) == best:
                rules += g["rules"]
    if best >= 0:
        return rules
    return [r for g in groups if "*" in g["agents"] for r in g["rules"]]


def pattern_to_re(path):
    end = path.endswith("$")
    body = path[:-1] if end else path
    return re.compile("^" + ".*".join(re.escape(p) for p in body.split("*")) + ("$" if end else ""))


def path_of(url, site):
    u = urllib.parse.urlsplit(urllib.parse.urljoin(site + "/", url))
    return (u.path or "/") + (("?" + u.query) if u.query else "")


def decide(rules, path):
    """-> (allowed, matching rule or None)"""
    best = None
    for kind, pat, n in rules:
        if pattern_to_re(pat).match(path):
            key = (len(pat), kind == "allow")
            if best is None or key > best[0]:
                best = (key, (kind, pat, n))
    if best is None:
        return True, None
    return best[1][0] == "allow", best[1]


def parse_sitemap(text):
    try:
        root = ET.fromstring(text.strip())
    except ET.ParseError as e:
        raise DataError(f"sitemap XML error: {e}")
    tag = root.tag.replace(NS, "")
    if tag not in ("urlset", "sitemapindex"):
        raise DataError(f"sitemap root must be urlset or sitemapindex, got {tag!r}")
    child = "url" if tag == "urlset" else "sitemap"
    items = []
    for el in root.findall(NS + child) or root.findall(child):
        loc = el.findtext(NS + "loc") or el.findtext("loc") or ""
        lm = el.findtext(NS + "lastmod") or el.findtext("lastmod")
        items.append((loc.strip(), lm.strip() if lm else None))
    return tag, items


def check(bundle, today=None):
    site = (bundle.get("site") or "").rstrip("/")
    if not re.match(r"^https?://[^/]+$", site):
        raise DataError("site must be an origin like https://www.example.com")
    robots = text_of(bundle.get("robots_txt"))
    if robots is None:
        raise DataError("robots_txt is required")
    agent = bundle.get("user_agent") or "Googlebot"
    today = today or dt.date.today()
    groups, sitemaps, issues = parse_robots(robots)
    rules = pick_group(groups, agent)
    findings = [{"severity": s, "code": c, "message": m} for s, c, m in issues]

    def f(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    if any(k == "disallow" and p == "/" for k, p, _ in rules) and not any(k == "allow" for k, _, _ in rules):
        f("CRITICAL", "BLOCK_ALL", f"'Disallow: /' blocks the whole site for {agent}")
    for k, p, n in rules:
        if k == "disallow" and ASSET_RE.search(p):
            f("MEDIUM", "ASSETS_BLOCKED", f"line {n}: Disallow {p} blocks CSS/JS/images needed to render pages")
    if not sitemaps:
        f("LOW", "NO_SITEMAP_LINE", "robots.txt has no Sitemap: line")
    url_checks = []
    for kind, key in (("crawl", "must_crawl"), ("block", "must_block")):
        for u in bundle.get(key) or []:
            p = path_of(u, site)
            allowed, rule = decide(rules, p)
            url_checks.append({"url": u, "path": p, "expect": kind, "allowed": allowed,
                               "rule": f"line {rule[2]}: {rule[0].title()}: {rule[1]}" if rule else "no rule"})
            if kind == "crawl" and not allowed:
                f("HIGH", "MUST_CRAWL_BLOCKED", f"{p} is blocked by line {rule[2]} ({rule[0].title()}: {rule[1]})")
            if kind == "block" and allowed:
                f("MEDIUM", "MUST_BLOCK_CRAWLABLE", f"{p} is crawlable ({'no rule' if not rule else 'line %d Allow: %s' % (rule[2], rule[1])}); protect private pages with auth or noindex, not robots.txt")
    sm_info = None
    sm_text = text_of(bundle.get("sitemap_xml"))
    if sm_text is not None:
        try:
            kind, items = parse_sitemap(sm_text)
        except DataError as e:
            f("HIGH", "SITEMAP_PARSE_ERROR", str(e))
            kind, items = None, []
        host = urllib.parse.urlsplit(site)
        seen, blocked, off = set(), 0, 0
        for loc, lm in items:
            u = urllib.parse.urlsplit(loc)
            if (u.scheme, u.netloc) != (host.scheme, host.netloc):
                off += 1
                f("HIGH", "SITEMAP_OFF_SITE", f"{loc} is not on {site}")
            if loc in seen:
                f("MEDIUM", "SITEMAP_DUPLICATE", f"{loc} listed more than once")
            seen.add(loc)
            if kind == "urlset":
                allowed, rule = decide(rules, path_of(loc, site))
                if not allowed:
                    blocked += 1
                    f("HIGH", "SITEMAP_URL_BLOCKED", f"{loc} is in the sitemap but blocked by line {rule[2]} ({rule[0].title()}: {rule[1]})")
            if lm is not None:
                if not LASTMOD_RE.match(lm):
                    f("LOW", "LASTMOD_INVALID", f"{loc}: lastmod {lm!r} is not W3C datetime")
                elif dt.date.fromisoformat(lm[:10] if len(lm) >= 10 else lm[:4] + "-01-01") > today:
                    f("MEDIUM", "LASTMOD_FUTURE", f"{loc}: lastmod {lm} is after {today}")
        if len(items) > 50000:
            f("HIGH", "SITEMAP_TOO_BIG", f"{len(items)} URLs; split into files of at most 50,000 URLs and use a sitemap index")
        sm_info = {"type": kind, "urls": len(items), "blocked": blocked, "off_site": off}
    findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
    status = findings[0]["severity"] if findings and findings[0]["severity"] in ("CRITICAL", "HIGH") else "OK"
    return {"site": site, "user_agent": agent, "groups": len(groups), "rules_for_agent": len(rules),
            "sitemaps_declared": [s for s, _ in sitemaps], "sitemap": sm_info, "url_checks": url_checks,
            "findings": findings, "status": status}


def report(r):
    L = [f"# Crawl check for {r['site']} as {r['user_agent']}", "",
         f"robots.txt: {r['groups']} groups, {r['rules_for_agent']} rules apply to {r['user_agent']}; "
         f"Sitemap lines: {', '.join(r['sitemaps_declared']) or 'none'}"]
    if r["sitemap"]:
        s = r["sitemap"]
        L.append(f"Sitemap ({s['type']}): {s['urls']} URLs, {s['blocked']} blocked by robots.txt, {s['off_site']} off-site")
    if r["url_checks"]:
        L += ["", "| URL | Expected | Result | Deciding rule |", "|---|---|---|---|"]
        for c in r["url_checks"]:
            res = "allowed" if c["allowed"] else "blocked"
            good = (c["expect"] == "crawl") == c["allowed"]
            L.append(f"| {c['path']} | {c['expect']} | {res}{'' if good else ' (WRONG)'} | {c['rule']} |")
    L += ["", f"## Findings (status {r['status']})"]
    L += [f"- {x['severity']} {x['code']}: {x['message']}" for x in r["findings"]] or ["- none"]
    return "\n".join(L) + "\n"


def self_test():
    cases = []

    def ok(name, cond):
        cases.append(bool(cond))
        print(("PASS " if cond else "FAIL ") + name)

    def codes(r):
        return {x["code"] for x in r["findings"]}

    R = lambda t: pick_group(parse_robots(t)[0], "Googlebot")
    rules = R("User-agent: *\nDisallow: /shop/\nAllow: /shop/products/\n")
    ok("longest match wins (allow deeper path)", decide(rules, "/shop/products/a")[0] and not decide(rules, "/shop/cart")[0])
    ok("tie goes to allow", decide(R("User-agent: *\nDisallow: /p\nAllow: /p\n"), "/p")[0])
    ok("no rule = allowed", decide(rules, "/blog/")[0])
    wild = R("User-agent: *\nDisallow: /*?sort=\nDisallow: /*.pdf$\n")
    ok("* wildcard matches query", not decide(wild, "/shop/shoes?sort=price")[0])
    ok("$ anchors end", not decide(wild, "/files/a.pdf")[0] and decide(wild, "/files/a.pdf?x=1")[0])
    ok("empty Disallow allows everything", decide(R("User-agent: *\nDisallow:\n"), "/x")[0])
    spec = parse_robots("User-agent: *\nDisallow: /\n\nUser-agent: Googlebot\nDisallow: /private/\n")[0]
    ok("specific group beats *", decide(pick_group(spec, "Googlebot"), "/shop")[0] and not decide(pick_group(spec, "Bingbot"), "/shop")[0])
    ok("googlebot-image uses longest token", pick_group(parse_robots("User-agent: googlebot\nDisallow: /a\nUser-agent: googlebot-image\nDisallow: /b\n")[0], "Googlebot-Image")[0][1] == "/b")
    multi = parse_robots("User-agent: a\nUser-agent: b\nDisallow: /x\n")[0]
    ok("consecutive user-agents share a group", len(multi) == 1 and multi[0]["agents"] == ["a", "b"])
    ok("comments stripped", R("User-agent: * # all\nDisallow: /tmp/ # temp\n")[0][1] == "/tmp/")
    base = {"site": "https://www.shop.test", "robots_txt": "User-agent: *\nDisallow: /\n"}
    r = check(base, dt.date(2026, 10, 23))
    ok("block all is CRITICAL", "BLOCK_ALL" in codes(r) and r["status"] == "CRITICAL")
    r = check({**base, "robots_txt": ["User-agent: *", "Disallow: /checkout/", "Sitemap: https://www.shop.test/sitemap.xml"],
               "must_crawl": ["/checkout/thanks"], "must_block": ["/admin/"]}, dt.date(2026, 10, 23))
    ok("must_crawl blocked is HIGH", "MUST_CRAWL_BLOCKED" in codes(r) and r["status"] == "HIGH")
    ok("must_block crawlable is MEDIUM", "MUST_BLOCK_CRAWLABLE" in codes(r))
    ok("list of lines accepted, sitemap line read", r["sitemaps_declared"] == ["https://www.shop.test/sitemap.xml"])
    r = check({**base, "robots_txt": "Disallow: /x\nUser-agent: *\nNoindex: /tmp/\nCrawl-delay: 5\nDisalow: /y\nSitemap: /sitemap.xml\nDisallow: /assets/\n"}, dt.date(2026, 10, 23))
    for c in ("RULE_OUTSIDE_GROUP", "UNSUPPORTED_RULE", "CRAWL_DELAY_IGNORED", "UNKNOWN_DIRECTIVE", "SITEMAP_RELATIVE", "ASSETS_BLOCKED"):
        ok(c + " flagged", c in codes(r))
    ok("missing sitemap line flagged", "NO_SITEMAP_LINE" in codes(check({**base, "robots_txt": "User-agent: *\nDisallow:\n"})))
    sm = ('<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
          "<url><loc>https://www.shop.test/a</loc><lastmod>2026-10-01</lastmod></url>"
          "<url><loc>https://www.shop.test/a</loc></url>"
          "<url><loc>http://www.shop.test/b</loc><lastmod>01/10/2026</lastmod></url>"
          "<url><loc>https://www.shop.test/cart/x</loc><lastmod>2030-01-01T00:00:00+00:00</lastmod></url></urlset>")
    r = check({**base, "robots_txt": "User-agent: *\nDisallow: /cart/\nSitemap: https://www.shop.test/sitemap.xml\n", "sitemap_xml": sm}, dt.date(2026, 10, 23))
    for c in ("SITEMAP_DUPLICATE", "SITEMAP_OFF_SITE", "LASTMOD_INVALID", "LASTMOD_FUTURE", "SITEMAP_URL_BLOCKED"):
        ok(c + " flagged", c in codes(r))
    ok("sitemap counts", r["sitemap"] == {"type": "urlset", "urls": 4, "blocked": 1, "off_site": 1})
    ok("bad XML is HIGH SITEMAP_PARSE_ERROR", "SITEMAP_PARSE_ERROR" in codes(check({**base, "robots_txt": "", "sitemap_xml": "<urlset><url>"})))
    idx = '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://www.shop.test/s1.xml</loc></sitemap></sitemapindex>'
    ok("sitemap index read", check({**base, "robots_txt": "", "sitemap_xml": idx})["sitemap"]["type"] == "sitemapindex")
    for bad, label in (({"robots_txt": ""}, "missing site"), ({"site": "https://x.test/path", "robots_txt": ""}, "site with path"),
                       ({"site": "https://x.test"}, "missing robots_txt")):
        try:
            check(bad)
            ok(label + " rejected", False)
        except DataError:
            ok(label + " rejected", True)
    print(f"{sum(cases)}/{len(cases)} passed")
    return 0 if all(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Check robots.txt rules and an XML sitemap offline.")
    ap.add_argument("bundle", nargs="?", help="JSON bundle (see module docstring)")
    ap.add_argument("--robots", help="robots.txt file (instead of a bundle)")
    ap.add_argument("--sitemap", help="sitemap.xml file")
    ap.add_argument("--site", help="site origin, e.g. https://www.example.com")
    ap.add_argument("--agent", help="crawler name (default Googlebot)")
    ap.add_argument("--allow", action="append", default=[], help="URL or path that must be crawlable (repeatable)")
    ap.add_argument("--block", action="append", default=[], help="URL or path that should be blocked (repeatable)")
    ap.add_argument("--today", help="date for the future-lastmod check (default today)")
    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()
    try:
        if a.bundle:
            b = json.load(open(a.bundle, encoding="utf-8"))
        elif a.robots:
            b = {"site": a.site, "robots_txt": open(a.robots, encoding="utf-8").read(),
                 "sitemap_xml": open(a.sitemap, encoding="utf-8").read() if a.sitemap else None,
                 "must_crawl": a.allow, "must_block": a.block}
        else:
            ap.error("give a JSON bundle, --robots FILE or --self-test")
        if a.agent:
            b["user_agent"] = a.agent
        today = dt.date.fromisoformat(a.today) if a.today else None
        r = check(b, today)
    except (OSError, ValueError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else report(r), end="\n" if a.json else "")
    return 1 if r["status"] in ("CRITICAL", "HIGH") else 0


if __name__ == "__main__":
    sys.exit(main())
Web DevelopmentPythonSEO+2
F@f
0
Máy tính Tỷ lệ Phần trăm Thợ làm bánh cho Bột Bánh mì
Kỹ năng

Chuyển đổi bất kỳ công thức bánh mì nào sang tỷ lệ phần trăm thợ làm bánh (baker's percentages) thực tế và điều chỉnh định lượng theo số ổ bánh hoặc viên bột bạn muốn. Tính toán cả lượng bột và nước có trong men cái (levain) cũng như lượng nước trong sữa, trứng hoặc mật ong, sau đó báo cáo tỷ lệ ngậm nước (hydration) thực tế, muối, bột lên men trước (prefermented flour), men và ngũ cốc nguyên cám. Cảnh báo khi thiếu hoặc sai lệch lượng muối, thiếu chất gây nở, quá nhiều men hoặc levain, và tỷ lệ ngậm nước nằm ngoài ngưỡng chuẩn của kiểu bánh đó. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: bread-dough-bakers-percentage-calculator
description: Chuyển đổi bất kỳ công thức bánh mì nào sang tỷ lệ phần trăm thợ làm bánh (baker's percentages) thực tế và điều chỉnh định lượng theo số ổ bánh hoặc viên bột bạn muốn. Tính toán cả lượng bột và nước có trong men cái (levain) cũng như lượng nước trong sữa, trứng hoặc mật ong, sau đó báo cáo tỷ lệ ngậm nước (hydration) thực tế, muối, bột lên men trước (prefermented flour), men và ngũ cốc nguyên cám. Cảnh báo khi thiếu hoặc sai lệch lượng muối, thiếu chất gây nở, quá nhiều men hoặc levain, và tỷ lệ ngậm nước nằm ngoài ngưỡng chuẩn của kiểu bánh đó. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Máy tính Tỷ lệ Phần trăm Thợ làm bánh cho Bột Bánh mì

Bạn giúp những người làm bánh tại nhà hiểu rõ, tinh chỉnh và nhân chia định lượng các công thức làm bánh mì. Từ một công thức tính bằng gam (hoặc đã được quy đổi sang gam), bạn tính toán tỷ lệ phần trăm thợ làm bánh thực tế, so sánh chúng với thông số tiêu chuẩn mà kiểu bánh mì đó thường cần, giải thích tác động của từng con số lên khối bột, và đưa ra danh sách nguyên liệu đã được điều chỉnh tỷ lệ chính xác theo số lượng cũng như trọng lượng ổ bánh mì, bánh mì cuộn (rolls) hoặc viên bột pizza mà họ muốn nướng.

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

- `scripts/dough_calc.py` - đọc một tệp JSON công thức và in ra tổng trọng lượng, bảng tỷ lệ phần trăm thợ làm bánh, công thức đã điều chỉnh tỷ lệ cùng các phát hiện đánh giá (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/bakers-math.md` - tỷ lệ phần trăm thợ làm bánh, tỷ lệ phần trăm thực tế so với tỷ lệ phần trăm trên công thức, lượng nước trong levain và các nguyên liệu phụ, các khoảng giá trị quan trọng, quy đổi các loại men, tỷ lệ ngậm nước theo từng kiểu bánh và cách nhân chia tỷ lệ
- `references/fermentation-and-troubleshooting.md` - mốc thời gian, nhiệt độ khối bột, ý nghĩa của từng phát hiện đánh giá đối với khối bột, các triệu chứng lỗi và cách khắc phục, độ chín của bánh
- `templates/bake-sheet.md` - phiếu ghi chép mẻ nướng gồm công thức, lịch trình thời gian và ghi chú cho lần nướng tiếp theo
- `examples/weekend-whole-wheat-sourdough.json` - công thức bánh mì sourdough lúa mì nguyên cám và lúa mạch đen dùng men cái (levain), một chút men nở ăn liền (instant yeast), mật ong và dầu ô liu, được điều chỉnh định lượng thành hai ổ bánh nặng 1 kg

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

### 1. Lấy công thức tính theo gam
Hãy hỏi trọng lượng của từng nguyên liệu. Quy đổi đơn vị cốc (cups) và thìa (spoons) thật cẩn thận (1 cup bột bánh mì khoảng 125-135 g, 1 cup nước là 236 g, 1 tsp muối mịn khoảng 6 g, 1 tsp men instant khoảng 3 g) và nói rõ rằng phép quy đổi này chỉ mang tính tương đối. Hỏi thêm về tỷ lệ ngậm nước của levain, những loại bột nào là bột nguyên cám, kiểu bánh mì muốn làm, loại men sử dụng, cũng như số lượng và trọng lượng từng phần bột mà họ mong muốn.

### 2. Chạy công cụ tính toán
```
python3 scripts/dough_calc.py examples/weekend-whole-wheat-sourdough.json
python3 scripts/dough_calc.py recipe.json --pieces 6 --piece-weight 270 --json
python3 scripts/dough_calc.py --self-test
```
Các kiểu bánh (`styles`): bagel, pizza_neapolitan, sandwich_loaf, enriched_brioche, baguette, sourdough_boule, whole_wheat_loaf, ciabatta, focaccia. Mã thoát (exit code) bằng 1 nghĩa là có các phát hiện mức HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Giải thích và điều chỉnh
Đưa ra các con số quan trọng trước tiên (tỷ lệ ngậm nước, muối, chất gây nở), sau đó nêu từng phát hiện đánh giá kèm theo lý do và mức thay đổi cụ thể bằng gam dựa theo `references/bakers-math.md`. Chỉ đề xuất thay đổi một hoặc hai yếu tố cho mỗi mẻ nướng để người làm bánh có thể quan sát rõ tác động của từng thay đổi. Sử dụng `references/fermentation-and-troubleshooting.md` để tư vấn về thời gian ủ và xử lý các vấn đề họ mô tả (bánh bị bẹt, ruột bánh đặc, bánh quá chua).

### 4. Bàn giao kết quả
Cung cấp công thức đã điều chỉnh tỷ lệ và điền vào mẫu `templates/bake-sheet.md` với công thức hoàn chỉnh, lịch trình thời gian phù hợp với nhiệt độ phòng bếp của họ cùng phần trống để ghi chú.

## Ví dụ kết quả
Đối với tệp `examples/weekend-whole-wheat-sourdough.json`: tổng lượng bột là 1100 g (trong đó có 100 g nằm trong levain) và tổng lượng nước là 785 g, do đó tỷ lệ ngậm nước thực tế là 71.4%, muối 1.4%, bột lên men trước 9.1%, men instant 0.27% và ngũ cốc nguyên cám 38.2%. Lượng muối đang thấp (mức MEDIUM; cần 22 g để đạt 2%) và mức 38% ngũ cốc nguyên cám sẽ cần thêm khoảng 5 điểm phần trăm nước nữa (mức LOW). Khi nhân tỷ lệ lên 2 ổ x 1000 g cộng thêm 2% hao hụt (hệ số nhân 1.047): 628 g bột bánh mì (bread flour), 314 g bột nguyên cám (whole wheat), 105 g bột lúa mạch đen (rye), 712 g nước, 15.7 g muối (nên tăng lên khoảng 23 g), 209 g levain, 3.1 g men, 31 g mật ong, 21 g dầu ô liu.

## Quy tắc

- Luôn làm việc bằng đơn vị gam; nói rõ khi nào phép quy đổi từ thể tích sang trọng lượng chỉ là xấp xỉ.
- Luôn nêu rõ tỷ lệ phần trăm đang nói tới là tỷ lệ thực tế (đã tính cả levain và nguyên liệu phụ) hay tỷ lệ phần trăm bề mặt theo công thức.
- Chỉ thay đổi một hoặc hai yếu tố mỗi lần và giải thích rõ tác động của chúng lên hương vị, kết cấu và thời gian ủ.
- Thời gian lên men phụ thuộc vào nhiệt độ và độ khỏe của men cái; hãy đưa ra khoảng thời gian ước lượng kèm các dấu hiệu nhận biết trực quan cần quan sát, thay vì ấn định số phút cứng nhắc.
FILE:references/bakers-math.md
# Toán học cho Thợ làm bánh (Baker's Math)

## Tỷ lệ phần trăm thợ làm bánh (Baker's percentages)
Mọi nguyên liệu đều được biểu diễn dưới dạng tỷ lệ phần trăm so với **tổng trọng lượng bột**, và tổng lượng bột luôn được quy ước là 100%. Một công thức gồm 1000 g bột, 700 g nước, 20 g muối và 5 g men instant sẽ có tỷ lệ là 100 / 70 / 2 / 0.5. Tỷ lệ phần trăm giúp các công thức dễ dàng được so sánh, nhân chia định lượng và điều chỉnh: chỉ cần thay đổi một con số và giữ nguyên phần còn lại.

## Tỷ lệ phần trăm thực tế so với tỷ lệ phần trăm trên công thức
Các công thức thường chỉ đưa ra tỷ lệ phần trăm tính theo lượng bột được thêm vào ở bước trộn cuối cùng. Tỷ lệ phần trăm thực tế (tổng thể) sẽ tính cả:
- lượng bột và nước nằm bên trong men cái (levain) hoặc bột cái ủ trước (preferment)
- lượng nước có sẵn bên trong các nguyên liệu phụ dạng ướt

`scripts/dough_calc.py` luôn báo cáo tỷ lệ phần trăm thực tế, vì vậy các hàng biểu thị lượng bột trộn thêm trong bảng kết quả sẽ có tổng nhỏ hơn 100% khi công thức có sử dụng levain (phần bột còn lại nằm ở trong levain).

| Tỷ lệ ngậm nước của Levain | Lượng bột trong 100 g levain | Lượng nước trong 100 g levain |
|---|---|---|
| 50% (đặc / stiff) | 66.7 g | 33.3 g |
| 75% | 57.1 g | 42.9 g |
| 100% (lỏng / liquid) | 50 g | 50 g |
| 125% | 44.4 g | 55.6 g |

Công thức tính: bột = levain / (1 + hydration / 100); nước = levain - bột.

## Hàm lượng nước trong các nguyên liệu phụ phổ biến
| Nguyên liệu | Hàm lượng nước |
|---|---|
| Sữa nguyên kem | 87% |
| Trứng nguyên quả (bỏ vỏ, ~50 g) | 75% |
| Lòng đỏ trứng | 50% |
| Bơ | 16% |
| Mật ong | 17% |
| Sữa chua | 85% |
| Khoai tây nghiền | 80% |
| Dầu ô liu, đường, các loại hạt | 0% (chất béo và đường vẫn làm mềm khối bột) |

## Các thông số quan trọng
| Chỉ số | Mức tiêu chuẩn | Ghi chú |
|---|---|---|
| Muối | 1.8-2.2% | dưới 1.6% bánh nhạt và bột nhão; trên 2.5% bánh mặn, làm chậm quá trình lên men |
| Men instant (trộn bột trực tiếp / straight dough) | 0.5-1.5% | 0.1-0.3% khi ủ lạnh qua đêm |
| Levain, tính theo tỷ lệ bột lên men trước | 10-25% | nhiều hơn = lên men nhanh hơn và chua hơn |
| Đường (bột giàu dinh dưỡng / enriched) | 5-15% | trên ~10% nên dùng men chịu áp suất thẩm thấu cao (osmotolerant yeast) hoặc tăng lượng men |
| Chất béo (bột giàu dinh dưỡng / enriched) | 3-50% | bánh brioche dùng 40-60% bơ |

Quy đổi các loại men (cùng sức nở tương đương): 1 g men instant = 1.25 g men khô kích hoạt (active dry) = khoảng 3 g men tươi (fresh yeast). Tập lệnh quy đổi toàn bộ các loại men về mức tương đương của men instant.

## Tỷ lệ ngậm nước theo kiểu bánh
| Kiểu bánh | Tỷ lệ ngậm nước (Hydration %) | Kết cấu |
|---|---|---|
| Bagel | 50-58 | đặc, dai (chewy) |
| Neapolitan pizza | 58-65 | mềm, viền bánh phồng xốp |
| Sandwich loaf | 60-68 | thớ bánh đều, khít |
| Enriched / brioche (gồm sữa và trứng) | 55-70 | mềm mại, béo ngậy |
| Baguette | 65-75 | ruột thoáng lỗ khí, vỏ giòn rụm |
| Sourdough boule | 68-80 | ruột thoáng (open crumb) |
| Whole wheat loaf | 70-85 | bột nguyên cám hút nhiều nước hơn |
| Ciabatta | 75-85 | ruột rất thoáng, nhiều lỗ khí lớn |
| Focaccia | 75-90 | xốp thoáng, đẫm dầu |

Loại bột rất quan trọng: bột bánh mì giàu protein và bột nguyên cám hút nhiều nước hơn; bột mì đa dụng ít protein hoặc nhiều loại bột châu Âu T55/Type 550 hút ít nước hơn. Hãy bắt đầu ở mức thấp hơn 3-5 điểm phần trăm khi dùng một loại bột mới và châm thêm nước từ từ trong quá trình nhào trộn (kỹ thuật bassinage).

## Nhân chia định lượng (Scaling)
1. Quyết định số lượng phần bột và trọng lượng mỗi phần (ổ bánh, bánh cuộn, viên bột pizza).
2. Cộng thêm 1-3% bù cho lượng bột hao hụt dính ở âu trộn và trên mặt bàn nhào.
3. Hệ số nhân (Factor) = trọng lượng bột mục tiêu / trọng lượng bột hiện tại; nhân mọi nguyên liệu với hệ số này.
4. Làm tròn số gam hợp lý: làm tròn đến 1 g đối với bột và nước, 0.1 g đối với muối và men (hãy dùng cân tiểu ly độ chia 0.1 g cho các định lượng nhỏ).

Bánh mì nướng khuôn (Pan loaves): trọng lượng khối bột rơi vào khoảng 0.55-0.6 g trên mỗi cm3 thể tích khuôn (một khuôn loại 900 g / 2 lb sẽ cần khoảng 850-950 g bột).
FILE:references/fermentation-and-troubleshooting.md
# Lên men và Xử lý sự cố

## Lịch trình tiêu chuẩn cho bột bánh mì cơ bản (lean dough)
| Bước | Dùng men công nghiệp (1% instant) | Dùng Sourdough (15-20% levain) |
|---|---|---|
| Trộn và nghỉ | 20-30 phút autolyse (tùy chọn) | 30-60 phút autolyse (tùy chọn) |
| Ủ lần 1 (Bulk fermentation) ở 24-26 C | 1.5-2 giờ, nở ~75-100% | 4-6 giờ, nở ~50-75% |
| Gấp bột (Folds) | 1-2 lần | 3-4 lần trong 2 giờ đầu |
| Tạo hình, ủ kết thúc (final proof) | 45-75 phút | 1-3 giờ, hoặc ủ lạnh 8-16 giờ trong tủ lạnh |
| Nướng (Bake) | 220-230 C | 240-250 C đậy nắp 20 phút, sau đó 220-230 C mở nắp |

Hãy hướng tới nhiệt độ khối bột đạt 24-26 C sau khi trộn. Điều chỉnh bằng nhiệt độ nước: nhiệt độ nước mong muốn = 3 x nhiệt độ bột mong muốn - nhiệt độ bột khô - nhiệt độ phòng - độ tăng nhiệt do ma sát (khoảng 2-6 C khi nhào tay, cao hơn nếu nhào bằng máy). Khi có dùng levain, hãy nhân với hệ số 4 và trừ đi cả nhiệt độ của levain.

## Quy tắc ngón tay cái về nhiệt độ
Thời gian lên men sẽ giảm khoảng một nửa khi nhiệt độ tăng thêm +8 C và tăng gấp đôi khi nhiệt độ giảm đi -8 C. Quá trình ủ lần 1 (bulk) của sourdough mất 5 giờ ở 24 C sẽ cần khoảng 8 giờ nếu ủ ở 20 C.

## Cách đọc các mã đánh giá từ tập lệnh
- **SALT_LOW / SALT_HIGH:** hãy chỉnh lại lượng muối trước tiên; muối làm thay đổi hương vị, độ khỏe của mạng lưới gluten và tốc độ lên men.
- **HYDRATION_HIGH:** khối bột sẽ dính và dễ bị chảy bè ra; hãy làm ướt tay khi thao tác, gấp bột nhiều lần hơn, hoặc giữ lại 5% lượng nước để châm sau.
- **HYDRATION_LOW:** ruột bánh khít đặc và khối bột khi trộn khá khô cứng; hãy châm thêm nước từng chút một.
- **YEAST_HIGH:** rút ngắn thời gian ủ lần 1, hoặc giảm bớt lượng men để khớp với lịch trình của bạn (dùng 0.2-0.5% cho quá trình ủ lạnh kéo dài).
- **LEVAIN_HIGH:** quá trình lên men diễn ra nhanh hơn, mang hương vị nồng ấm hơn nhưng dễ bị ủ quá lửa (overproof); hãy giảm lượng levain xuống mức 10-20% hoặc ủ ở nhiệt độ mát hơn.
- **WHOLE_GRAIN_THIRSTY:** thêm 5-10 điểm phần trăm nước hoặc ngâm phần bột nguyên cám trước từ 30-60 phút.

## Triệu chứng lỗi và cách khắc phục
| Vấn đề | Nguyên nhân có thể | Cách khắc phục |
|---|---|---|
| Ổ bánh bị bẹt, chảy bè ngang | ủ quá lửa (overproofed), quá nhão so với sức hút của bột, tạo hình lỏng tay | rút ngắn thời gian ủ cuối, giảm 3-5 điểm phần trăm nước, tạo hình căng mặt bột hơn, dùng rổ ủ bột (banneton) |
| Ruột bánh đặc, khít | chưa ủ tới (underproofed), quá ít nước, men cái yếu | kéo dài thời gian ủ lần 1, giữ bột ấm hơn, cho men cái ăn tới khi đạt độ nở gấp đôi trong 4-6 giờ |
| Lỗ rỗng lớn ngay dưới lớp vỏ (vỏ tách rời / flying crust) | chưa ủ tới hoặc tạo hình kém | kéo dài thời gian ủ cuối, ép khí nhẹ nhàng trong lúc tạo hình |
| Vỏ bánh nhợt nhạt | lò chưa đủ nóng, bột ủ quá lửa, thiếu hơi nước | làm nóng lò trước 45-60 phút, tạo hơi nước trong 15-20 phút đầu tiên |
| Ruột bánh bị bết dính (gummy) | cắt bánh quá sớm, nướng chưa chín tới | nướng tới khi tâm bánh đạt 96-98 C, để nguội 1-2 giờ trước khi thái lát |
| Bánh quá chua | ủ ấm quá lâu, levain đã qua đỉnh nở (past peak) | dùng levain trẻ hơn, giảm lượng levain, rút ngắn thời gian hoặc ủ lần 1 ở nhiệt độ mát hơn |
| Vị bánh nhạt nhẽo | lượng muối dưới 1.8%, lên men quá nhanh | cân muối chính xác, giảm lượng men và ủ lâu hơn ở nhiệt độ mát hơn |

## Độ chín và an toàn thực phẩm
- Nướng bánh mì cơ bản (lean bread) tới khi nhiệt độ tâm bánh đạt 93-98 C và bánh mì giàu dinh dưỡng (enriched bread) đạt 85-90 C (dùng nhiệt kế đọc kết quả tức thì).
- Bột sống có thể mang vi khuẩn: tuyệt đối không nếm thử bột sống.
- Hũ men cái xuất hiện vệt màu hồng, cam hoặc nấm mốc có lông tơ là không an toàn; hãy vứt bỏ ngay.
FILE:templates/bake-sheet.md
# Phiếu Nướng Bánh (Bake Sheet) - <tên công thức> - <ngày>

**Kiểu bánh:** <sourdough boule>  **Số lượng:** <2 x 1000 g>  **Nhiệt độ bếp:** <22 C>

## Công thức (tỷ lệ phần trăm thợ làm bánh thực tế)
| Nguyên liệu | % | Số gam (mẻ nướng này) |
|---|---|---|
| Tổng lượng bột | 100 | |
| Tổng lượng nước | | |
| Muối | | |
| Levain / men | | |
| Nguyên liệu phụ | | |

Tỷ lệ ngậm nước <..>% | muối <..>% | bột lên men trước <..>% | ngũ cốc nguyên cám <..>%

## Thay đổi so với lần trước
- <ví dụ: +15 g muối để đạt mức 2%, +50 g nước cho phần bột nguyên cám>

## Lịch trình thời gian
| Bước | Dự kiến | Thực tế | Ghi chú (nhiệt độ bột, độ nở %) |
|---|---|---|---|
| Cho levain ăn | | | |
| Trộn bột | | | |
| Kết thúc ủ lần 1 (Bulk) | | | |
| Tạo hình | | | |
| Kết thúc ủ cuối (Proof) | | | |
| Nướng bánh | | | |

## Kết quả
- Ruột bánh (Crumb): <thoáng / khít / bết dính>  - Vỏ bánh (Crust): <màu sắc, độ dày>  - Hương vị: <độ mặn, độ chua>
- Lần tới: <chỉ thay đổi một yếu tố duy nhất>
FILE:examples/weekend-whole-wheat-sourdough.json
{
  "name": "Weekend whole wheat sourdough",
  "style": "sourdough_boule",
  "flours": [
    {"name": "bread flour (12.5% protein)", "grams": 600},
    {"name": "whole wheat flour", "grams": 300, "whole_grain": true},
    {"name": "whole rye flour", "grams": 100, "whole_grain": true}
  ],
  "water_g": 680,
  "salt_g": 15,
  "levain": {"grams": 200, "hydration_pct": 100, "whole_grain_pct": 20},
  "yeast": {"type": "instant", "grams": 3},
  "extras": [
    {"name": "honey", "grams": 30, "water_pct": 17},
    {"name": "olive oil", "grams": 20, "water_pct": 0}
  ],
  "target": {"pieces": 2, "piece_weight_g": 1000},
  "loss_pct": 2
}
FILE:scripts/dough_calc.py
#!/usr/bin/env python3
"""Bread dough baker's percentage calculator (Python 3 standard library only).

Input JSON:
  {"name": "...", "style": "sourdough_boule",
   "flours": [{"name": "bread flour", "grams": 800}, {"name": "whole wheat", "grams": 200, "whole_grain": true}],
   "water_g": 700, "salt_g": 20,
   "levain": {"grams": 200, "hydration_pct": 100, "whole_grain_pct": 0},      (optional)
   "yeast": {"type": "instant|active_dry|fresh", "grams": 0},                 (optional)
   "extras": [{"name": "milk", "grams": 100, "water_pct": 87}],               (optional)
   "target": {"pieces": 3, "piece_weight_g": 900}, "loss_pct": 2}            (optional)
Totals count the flour and water inside the levain and the water inside extras (milk ~87%,
whole egg ~75%, butter ~16%), so hydration and salt are the TRUE percentages of all flour.
  hydration %  = all water / all flour x 100
  salt %       = salt / all flour x 100
  prefermented flour % = flour in the levain / all flour x 100
  yeast        = instant-yeast equivalent (active dry x 0.8, fresh x 0.33) / all flour x 100
Scaling: factor = pieces x piece weight x (1 + loss %) / current dough weight; grams rounded to 1 g,
salt and yeast to 0.1 g.
Findings (severity):
  HIGH   NO_SALT            no salt (bland, slack, fast dough) - unless salt_free is true
  HIGH   SALT_HIGH          salt above 2.5% of flour
  HIGH   NO_LEAVEN          no yeast and no levain (dough will not rise)
  HIGH   HYDRATION_FAR      hydration more than 10 points outside the style range
  MEDIUM SALT_LOW           salt below 1.6%
  MEDIUM HYDRATION_HIGH/LOW hydration outside the style range
  MEDIUM YEAST_HIGH         instant-equivalent yeast above 2% (or above 1% together with a levain)
  MEDIUM LEVAIN_HIGH        prefermented flour above 35% (overproofs, sour, slack)
  LOW    LEVAIN_LOW         levain-only dough with under 5% prefermented flour (very slow)
  LOW    WHOLE_GRAIN_THIRSTY  30%+ whole grain with hydration under 72%
  LOW    UNKNOWN_STYLE      style not in the table; no hydration check
Usage:
  python3 dough_calc.py recipe.json
  python3 dough_calc.py recipe.json --pieces 4 --piece-weight 250 --json
  python3 dough_calc.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import sys

STYLES = {  # typical hydration range in % (true, incl. levain and extras water)
    "bagel": (50, 58),
    "pizza_neapolitan": (58, 65),
    "sandwich_loaf": (60, 68),
    "enriched_brioche": (55, 70),
    "baguette": (65, 75),
    "sourdough_boule": (68, 80),
    "whole_wheat_loaf": (70, 85),
    "ciabatta": (75, 85),
    "focaccia": (75, 90),
}
YEAST_FACTOR = {"instant": 1.0, "active_dry": 0.8, "fresh": 0.33}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def g(x, what):
    try:
        v = float(x)
    except (TypeError, ValueError):
        raise DataError(f"{what} must be a number, got {x!r}")
    if v < 0:
        raise DataError(f"{what} must be >= 0")
    return v


def analyze(rec, pieces=None, piece_weight=None):
    flours = rec.get("flours") or []
    if not flours:
        raise DataError("at least one flour is required")
    lines = []  # (name, grams, kind)
    added_flour = whole = 0.0
    for i, fl in enumerate(flours):
        w = g(fl.get("grams"), f"flours[{i}].grams")
        added_flour += w
        if fl.get("whole_grain"):
            whole += w
        lines.append((fl.get("name") or f"flour {i + 1}", w, "flour"))
    water = g(rec.get("water_g", 0), "water_g")
    salt = g(rec.get("salt_g", 0), "salt_g")
    lines += [("water", water, "water"), ("salt", salt, "salt")]
    lev = rec.get("levain") or {}
    lev_g = g(lev.get("grams", 0), "levain.grams")
    lev_flour = lev_water = 0.0
    if lev_g:
        h = g(lev.get("hydration_pct", 100), "levain.hydration_pct")
        if h <= 0:
            raise DataError("levain.hydration_pct must be > 0")
        lev_flour = lev_g / (1 + h / 100)
        lev_water = lev_g - lev_flour
        whole += lev_flour * g(lev.get("whole_grain_pct", 0), "levain.whole_grain_pct") / 100
        lines.append((f"levain ({h:g}% hydration)", lev_g, "levain"))
    y = rec.get("yeast") or {}
    y_g = g(y.get("grams", 0), "yeast.grams")
    y_type = y.get("type", "instant")
    if y_type not in YEAST_FACTOR:
        raise DataError(f"yeast.type must be one of {', '.join(YEAST_FACTOR)}")
    if y_g:
        lines.append((f"{y_type.replace('_', ' ')} yeast", y_g, "yeast"))
    extra_water = 0.0
    for i, ex in enumerate(rec.get("extras") or []):
        w = g(ex.get("grams"), f"extras[{i}].grams")
        extra_water += w * g(ex.get("water_pct", 0), f"extras[{i}].water_pct") / 100
        lines.append((ex.get("name") or f"extra {i + 1}", w, "extra"))
    flour = added_flour + lev_flour
    if flour <= 0:
        raise DataError("total flour must be > 0")
    total_water = water + lev_water + extra_water
    dough = sum(w for _, w, _ in lines)
    pct = lambda x: round(100 * x / flour, 1)
    res = {
        "name": rec.get("name", "recipe"), "style": rec.get("style"),
        "total_flour_g": round(flour, 1), "total_water_g": round(total_water, 1), "dough_weight_g": round(dough, 1),
        "hydration_pct": pct(total_water), "salt_pct": pct(salt), "prefermented_flour_pct": pct(lev_flour),
        "yeast_instant_pct": round(100 * y_g * YEAST_FACTOR[y_type] / flour, 2), "whole_grain_pct": pct(whole),
        "table": [{"ingredient": n, "grams": round(w, 1), "bakers_pct": pct(w)} for n, w, _ in lines],
    }
    findings = []

    def f(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    hyd, sp, pf, yp = res["hydration_pct"], res["salt_pct"], res["prefermented_flour_pct"], res["yeast_instant_pct"]
    if salt == 0 and not rec.get("salt_free"):
        f("HIGH", "NO_SALT", f"no salt: add about 2% of the flour weight ({0.02 * flour:.0f} g)")
    elif sp > 2.5:
        f("HIGH", "SALT_HIGH", f"salt {sp}% (usual 1.8-2.2%, {0.02 * flour:.0f} g at 2%); slows yeast and tastes salty")
    elif 0 < sp < 1.6:
        f("MEDIUM", "SALT_LOW", f"salt {sp}% (usual 1.8-2.2%, {0.02 * flour:.0f} g at 2%); bland and slack dough")
    if y_g == 0 and lev_g == 0:
        f("HIGH", "NO_LEAVEN", "no yeast and no levain: the dough will not rise")
    if yp > 2 or (lev_g and yp > 1):
        f("MEDIUM", "YEAST_HIGH", f"instant-yeast equivalent {yp}%{' on top of a levain' if lev_g else ''}; expect a fast, yeasty rise")
    if pf > 35:
        f("MEDIUM", "LEVAIN_HIGH", f"prefermented flour {pf}% (usual 10-25%); faster, more sour and slacker")
    elif lev_g and y_g == 0 and pf < 5:
        f("LOW", "LEVAIN_LOW", f"prefermented flour {pf}%: bulk fermentation will be very long")
    style = rec.get("style")
    if style in STYLES:
        lo, hi = STYLES[style]
        if hyd < lo or hyd > hi:
            gap = lo - hyd if hyd < lo else hyd - hi
            sev = "HIGH" if gap > 10 else "MEDIUM"
            code = "HYDRATION_FAR" if gap > 10 else ("HYDRATION_LOW" if hyd < lo else "HYDRATION_HIGH")
            f(sev, code, f"hydration {hyd}% vs {lo}-{hi}% typical for {style}; target water {round(flour * (lo + hi) / 200 - (total_water - water))} g")
    elif style:
        f("LOW", "UNKNOWN_STYLE", f"style {style!r} not known ({', '.join(STYLES)}); no hydration check")
    if res["whole_grain_pct"] >= 30 and hyd < 72:
        f("LOW", "WHOLE_GRAIN_THIRSTY", f"{res['whole_grain_pct']}% whole grain at {hyd}% hydration; whole grain absorbs more, consider +5 points")
    findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
    res["findings"] = findings
    tgt = rec.get("target") or {}
    pieces = pieces or tgt.get("pieces")
    piece_weight = piece_weight or tgt.get("piece_weight_g")
    if pieces and piece_weight:
        loss = g(rec.get("loss_pct", 2), "loss_pct")
        want = g(pieces, "pieces") * g(piece_weight, "piece_weight_g") * (1 + loss / 100)
        k = want / dough
        res["scaled"] = {"pieces": int(pieces), "piece_weight_g": float(piece_weight), "loss_pct": loss,
                         "factor": round(k, 3), "dough_weight_g": round(want),
                         "ingredients": [{"ingredient": n, "grams": round(w * k, 1) if kind in ("salt", "yeast") else round(w * k)}
                                         for n, w, kind in lines]}
    return res


def report(r):
    L = [f"# {r['name']}" + (f" ({r['style']})" if r.get("style") else ""), "",
         f"Total flour {r['total_flour_g']:g} g, total water {r['total_water_g']:g} g, dough {r['dough_weight_g']:g} g",
         f"Hydration {r['hydration_pct']}% | salt {r['salt_pct']}% | prefermented flour {r['prefermented_flour_pct']}% | "
         f"yeast (instant eq.) {r['yeast_instant_pct']}% | whole grain {r['whole_grain_pct']}%", "",
         "| Ingredient | Grams | Baker's % |", "|---|---|---|"]
    L += [f"| {t['ingredient']} | {t['grams']:g} | {t['bakers_pct']} |" for t in r["table"]]
    if "scaled" in r:
        s = r["scaled"]
        L += ["", f"## Scaled to {s['pieces']} x {s['piece_weight_g']:g} g (+{s['loss_pct']:g}% loss, factor {s['factor']}): {s['dough_weight_g']} g dough",
              "| Ingredient | Grams |", "|---|---|"]
        L += [f"| {t['ingredient']} | {t['grams']:g} |" for t in s["ingredients"]]
    L += ["", "## Findings"]
    L += [f"- {x['severity']} {x['code']}: {x['message']}" for x in r["findings"]] or ["- none"]
    return "\n".join(L) + "\n"


def self_test():
    cases = []

    def ok(name, cond):
        cases.append(bool(cond))
        print(("PASS " if cond else "FAIL ") + name)

    def codes(r):
        return {x["code"] for x in r["findings"]}

    base = {"style": "sourdough_boule", "flours": [{"grams": 1000}], "water_g": 700, "salt_g": 20,
            "yeast": {"type": "instant", "grams": 5}}
    r = analyze(base)
    ok("hydration 70%, salt 2%", r["hydration_pct"] == 70 and r["salt_pct"] == 2)
    ok("dough weight sums ingredients (1725)", r["dough_weight_g"] == 1725)
    ok("clean recipe has no findings", r["findings"] == [])
    lev = dict(base, flours=[{"grams": 900}], water_g=600, levain={"grams": 200, "hydration_pct": 100}, yeast=None)
    r = analyze(lev)
    ok("levain 100%: flour 1000, water 700", r["total_flour_g"] == 1000 and r["total_water_g"] == 700)
    ok("prefermented flour 10%", r["prefermented_flour_pct"] == 10)
    r = analyze(dict(lev, levain={"grams": 150, "hydration_pct": 50}))
    ok("stiff levain 50%: 100 g flour + 50 g water", r["total_flour_g"] == 1000 and r["total_water_g"] == 650)
    ok("yeast conversion fresh 15 g ~ 0.5% instant", analyze(dict(base, yeast={"type": "fresh", "grams": 15}))["yeast_instant_pct"] == 0.49)
    ok("active dry x 0.8", analyze(dict(base, yeast={"type": "active_dry", "grams": 10}))["yeast_instant_pct"] == 0.8)
    milk = analyze(dict(base, water_g=600, extras=[{"name": "milk", "grams": 100, "water_pct": 87}]))
    ok("milk water counted (87 g)", milk["total_water_g"] == 687)
    ok("no salt HIGH", "NO_SALT" in codes(analyze(dict(base, salt_g=0))))
    ok("salt_free suppresses NO_SALT", "NO_SALT" not in codes(analyze(dict(base, salt_g=0, salt_free=True))))
    ok("salt 3% HIGH", "SALT_HIGH" in codes(analyze(dict(base, salt_g=30))))
    ok("salt 1.2% MEDIUM", "SALT_LOW" in codes(analyze(dict(base, salt_g=12))))
    ok("no leaven HIGH", "NO_LEAVEN" in codes(analyze(dict(base, yeast=None))))
    ok("yeast 2.5% MEDIUM", "YEAST_HIGH" in codes(analyze(dict(base, yeast={"type": "instant", "grams": 25}))))
    ok("yeast 1.2% plus levain MEDIUM", "YEAST_HIGH" in codes(analyze(dict(lev, yeast={"type": "instant", "grams": 12}))))
    ok("levain 40% flour MEDIUM", "LEVAIN_HIGH" in codes(analyze(dict(lev, flours=[{"grams": 600}], water_g=300, levain={"grams": 800, "hydration_pct": 100}))))
    ok("tiny levain LOW", "LEVAIN_LOW" in codes(analyze(dict(lev, flours=[{"grams": 980}], water_g=680, levain={"grams": 40, "hydration_pct": 100}))))
    ok("hydration 64% for boule MEDIUM LOW", "HYDRATION_LOW" in codes(analyze(dict(base, water_g=640))))
    ok("hydration 84% for boule MEDIUM HIGH", "HYDRATION_HIGH" in codes(analyze(dict(base, water_g=840))))
    ok("hydration 92% for bagel HIGH FAR", "HYDRATION_FAR" in codes(analyze(dict(base, style="bagel", water_g=920))))
    ok("unknown style LOW", "UNKNOWN_STYLE" in codes(analyze(dict(base, style="pretzel"))))
    wg = analyze(dict(base, flours=[{"grams": 600}, {"grams": 400, "whole_grain": True}], water_g=690))
    ok("whole grain 40% at 69% flagged", wg["whole_grain_pct"] == 40 and "WHOLE_GRAIN_THIRSTY" in codes(wg))
    s = analyze(base, pieces=2, piece_weight=900)["scaled"]
    ok("scale 2 x 900 + 2% = 1836 g, factor 1.064", s["dough_weight_g"] == 1836 and s["factor"] == 1.064)
    ok("scaled flour 1064 g, salt 21.3 g", s["ingredients"][0]["grams"] == 1064 and s["ingredients"][2]["grams"] == 21.3)
    ok("target from JSON", analyze(dict(base, target={"pieces": 4, "piece_weight_g": 250}, loss_pct=0))["scaled"]["dough_weight_g"] == 1000)
    for bad, label in (({"flours": []}, "no flours"), ({"flours": [{"grams": "x"}]}, "bad number"),
                       ({"flours": [{"grams": -5}]}, "negative grams"), ({"flours": [{"grams": 0}]}, "zero flour"),
                       (dict(base, yeast={"type": "sourdough", "grams": 5}), "unknown yeast type"),
                       (dict(lev, levain={"grams": 100, "hydration_pct": 0}), "zero levain hydration")):
        try:
            analyze(bad)
            ok(label + " rejected", False)
        except DataError:
            ok(label + " rejected", True)
    print(f"{sum(cases)}/{len(cases)} passed")
    return 0 if all(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Baker's percentages, true hydration and scaling for bread dough.")
    ap.add_argument("file", nargs="?", help="recipe JSON (see module docstring)")
    ap.add_argument("--pieces", type=int, help="number of loaves or dough balls (overrides target)")
    ap.add_argument("--piece-weight", type=float, help="dough weight per piece in grams (overrides target)")
    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.file:
        ap.error("give a recipe JSON file or --self-test")
    try:
        r = analyze(json.load(open(a.file, encoding="utf-8")), a.pieces, a.piece_weight)
    except (OSError, ValueError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else report(r), end="\n" if a.json else "")
    return 1 if any(x["severity"] == "HIGH" for x in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningCooking+2
F@f
0
Trình lập kế hoạch chính sách lưu giữ bản sao lưu
Kỹ năng

Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.

---
name: backup-retention-policy-planner
description: Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.
---

# Trình lập kế hoạch chính sách lưu giữ bản sao lưu

Bạn giúp các kỹ sư và các nhóm CNTT nhỏ thiết kế một chính sách lưu giữ bản sao lưu mà họ có thể bảo vệ được: đủ điểm khôi phục để phục hồi từ sai lầm ngày hôm qua và lỗi hỏng dữ liệu âm thầm từ quý trước, một bản sao sống sót qua cuộc tấn công ransomware hoặc mất tài khoản, hóa đơn lưu trữ vừa với ngân sách, và quy trình khôi phục thực sự đã được kiểm thử. Bạn biến quy tắc "giữ 7 bản hàng ngày, 4 bản hàng tuần, 12 bản hàng tháng" thành danh sách cụ thể các điểm khôi phục mà nó tạo ra và đối chiếu với các yêu cầu.

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

- `scripts/retention_plan.py` - mô phỏng chính sách GFS, liệt kê các điểm khôi phục được giữ lại, các khoảng trống, dung lượng lưu trữ và chi phí, đồng thời in ra các phát hiện (chỉ dùng thư viện chuẩn Python 3)
- `references/retention-design.md` - cách các quy tắc giữ lại chọn điểm khôi phục, cách chọn các bậc (tier) từ nhu cầu RPO và lịch sử, lưu giữ pháp lý, các mô hình lưu trữ và chi phí
- `references/resilience-checklist.md` - quy tắc 3-2-1-1-0, tính bất biến (immutability), mã hóa và khóa, kiểm thử khôi phục, giám sát và ý nghĩa của từng phát hiện
- `templates/backup-policy.md` - mẫu chính sách sao lưu dài một trang để điền vào và chia sẻ với nhóm
- `examples/clinic-postgres-policy.json` - cơ sở dữ liệu PostgreSQL được sao lưu hàng đêm lên S3 với RPO 4 giờ và thời gian lưu giữ pháp lý 3 năm

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

### 1. Thu thập thông tin thực tế
Hỏi về: những gì được sao lưu và bằng cách nào (công cụ, sao lưu toàn phần hay tăng dần), tần suất sao lưu và thời gian chạy một lần mất bao lâu, các quy tắc giữ lại hiện tại, kích thước dữ liệu, tỷ lệ thay đổi hàng ngày và mức tăng trưởng, nơi lưu trữ các bản sao (tại chỗ, ngoài cơ sở, bất biến/khóa đối tượng, được mã hóa), giá lưu trữ, ngày kiểm thử khôi phục thành công gần nhất, và các yêu cầu: RPO (mức mất dữ liệu tối đa), họ phải có khả năng khôi phục lùi về quá khứ bao xa, bất kỳ yêu cầu lưu giữ pháp lý hoặc theo hợp đồng nào, và ngân sách lưu trữ. Nếu họ không biết tỷ lệ thay đổi, hãy bắt đầu với mức 2-5% mỗi ngày đối với cơ sở dữ liệu và 1% đối với chia sẻ tệp, đồng thời nêu rõ giả định đó.

### 2. Chạy trình lập kế hoạch
```
python3 scripts/retention_plan.py examples/clinic-postgres-policy.json
python3 scripts/retention_plan.py policy.json --json
python3 scripts/retention_plan.py --self-test
```
Mã thoát 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.

### 3. Giải thích kết quả
Bắt đầu với các điểm khôi phục mà họ thực sự có (số lượng, bản cũ nhất, các khoảng trống lớn nhất) vì hầu hết mọi người đều đánh giá quá cao chúng. Sau đó đi qua các phát hiện theo mức độ nghiêm trọng bằng cách sử dụng `references/retention-design.md` và `references/resilience-checklist.md`, đồng thời đưa ra một thay đổi cụ thể cho từng vấn đề: quy tắc giữ lại mới, vận chuyển WAL hoặc nhật ký (log shipping) cho RPO chặt chẽ, khóa đối tượng (object lock), ngày diễn tập khôi phục. Chạy lại trình lập kế hoạch với chính sách được đề xuất và hiển thị dung lượng lưu trữ cũng như chi phí trước và sau khi thay đổi.

### 4. Bàn giao
Điền vào `templates/backup-policy.md` với các quy tắc giữ lại cuối cùng, các bản sao, yêu cầu, kế hoạch kiểm thử khôi phục và người phụ trách.

## Kết quả ví dụ
Đối với `examples/clinic-postgres-policy.json` (sao lưu hàng đêm, giữ 7 bản hàng ngày, 4 bản hàng tuần, 6 bản hàng tháng): 14 điểm khôi phục, bản cũ nhất 4,8 tháng, khoảng trống lên tới 31 ngày sau tháng đầu tiên, 968 GB hiện tại và 1550 GB sau 12 tháng (22,27 đến 35,66 USD/tháng). Các phát hiện: HIGH RPO_MISSED (sao lưu 24 giờ một lần so với RPO 4 giờ; cần thêm lưu trữ WAL hoặc sao lưu tăng dần mỗi 4 giờ), HIGH LEGAL_RETENTION (cần 3 năm; hãy đặt bậc hàng năm thành 5), MEDIUM RESTORE_TEST_OLD (193 ngày), MEDIUM NO_IMMUTABLE, MEDIUM STORAGE_OVER (1550 GB so với ngân sách 1500 GB sau 12 tháng).

## Quy tắc

- Một bản sao lưu chưa bao giờ được khôi phục thử thì chưa được chứng minh là hoạt động. Luôn hỏi về lần kiểm thử khôi phục gần nhất và khuyến nghị một buổi diễn tập có bấm giờ.
- Các quy tắc giữ lại đếm theo các khung thời gian (bucket), không phải số ngày: "monthly 12" giữ lại bản sao lưu mới nhất của mỗi tháng trong 12 tháng gần nhất có bản sao lưu, bao gồm cả tháng hiện tại.
- Nêu rõ mô hình lưu trữ mà bạn đã giả định; ước tính sao lưu tăng dần (incremental) là mức trần vì việc chống trùng lặp (deduplication) và các thay đổi khối lặp đi lặp lại làm cho kích thước thực tế nhỏ hơn.
- RPO được quyết định bởi tần suất sao lưu (hoặc log shipping), không phải bởi thời gian lưu giữ; độ sâu lịch sử được quyết định bởi thời gian lưu giữ, không phải bởi tần suất.
- Không bao giờ đề xuất xóa các bản sao lưu hiện có cho đến khi chính sách mới đã chạy và việc khôi phục từ chính sách đó đã được kiểm thử.
FILE:references/retention-design.md
# Thiết kế chính sách lưu giữ

## Cách các quy tắc giữ lại chọn điểm khôi phục
Các công cụ như restic (`forget --keep-daily 7 --keep-weekly 4`), borg (`prune --keep-daily 7`), pgBackRest và hầu hết các thiết bị sao lưu chuyên dụng đều sử dụng chung một ý tưởng. Đối với mỗi bậc (tier), duyệt qua các bản sao lưu từ mới nhất đến cũ nhất và giữ lại một bản sao lưu khi khung thời gian (bucket) của nó khác với bản cuối cùng được giữ lại bởi bậc đó, cho đến khi dùng hết số lượng của bậc đó:

| Bậc | Khung thời gian (Bucket) | "giữ N" có nghĩa là |
|---|---|---|
| hourly | giờ theo lịch | bản sao lưu mới nhất của mỗi giờ trong N giờ gần nhất có bản sao lưu |
| daily | ngày theo lịch | bản sao lưu mới nhất của mỗi ngày trong N ngày gần nhất |
| weekly | tuần ISO (Thứ Hai đến Chủ Nhật) | bản sao lưu mới nhất của mỗi tuần trong N tuần gần nhất |
| monthly | tháng theo lịch | bản sao lưu mới nhất của mỗi tháng trong N tháng gần nhất |
| yearly | năm theo lịch | bản sao lưu mới nhất của mỗi năm trong N năm gần nhất |

Những hệ quả mà mọi người thường bỏ sót:
- Mọi bậc đều bắt đầu bằng bản sao lưu mới nhất, vì vậy các bậc có sự chồng lấn. 7 daily + 4 weekly + 12 monthly là 20 điểm, không phải 23.
- Tuần, tháng và năm hiện tại mỗi thứ được tính là một khung thời gian. "monthly 12" chỉ lùi về trước khoảng 11 tháng, và "yearly 3" chỉ lùi về đến cuối năm kia (khoảng 1,8 năm nếu tính vào cuối tháng Mười).
- Các điểm hàng tháng là các bản sao lưu cuối tháng (mới nhất trong mỗi tháng), các điểm hàng tuần là các bản sao lưu ngày Chủ Nhật đối với tuần ISO.
- Những ngày không có bản sao lưu không làm tiêu tốn một vị trí (slot) hàng ngày, vì vậy một tuần bị lỗi sẽ kéo dài lịch sử hàng ngày lùi xa hơn về trước nhưng lại để lại một lỗ hổng.

`scripts/retention_plan.py` tái tạo quy tắc này, in ra mọi điểm được giữ lại cùng với các bậc của nó (D/W/M/Y/H) và liệt kê các khoảng trống lớn nhất.

## Chọn các bậc từ yêu cầu
| Yêu cầu | Yếu tố quyết định | Câu trả lời điển hình |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | tần suất sao lưu hoặc log shipping | 24 giờ: hàng đêm; 1-4 giờ: snapshot hoặc sao lưu tăng dần hàng giờ; tính bằng phút: lưu trữ WAL/binlog của cơ sở dữ liệu với khả năng khôi phục tại một thời điểm (point-in-time recovery) |
| Phục hồi từ sai lầm được phát hiện muộn | độ sâu hàng ngày và hàng tuần | ít nhất 14-35 ngày: khoảng thời gian cần thiết để ai đó nhận ra một đợt triển khai lỗi, một thư mục bị xóa hoặc một lần nhập dữ liệu hỏng |
| Lỗi hỏng dữ liệu âm thầm, kiểm toán | độ sâu hàng tháng | 6-13 tháng |
| Lưu giữ pháp lý hoặc theo hợp đồng | bậc hàng năm hoặc một kho lưu trữ riêng | N năm cần mức yearly N+2 để luôn có một điểm cũ N năm xuyên suốt cả năm; thường tốt hơn nếu tách thành một tác vụ lưu trữ riêng biệt với khóa bảo vệ riêng |
| Ngân sách | mô hình lưu trữ và tỷ lệ thay đổi | xem bên dưới |

Một điểm khởi đầu phổ biến, dễ bảo vệ cho dữ liệu doanh nghiệp: hourly 24 (nếu RPO < 24 giờ), daily 14, weekly 8, monthly 12, yearly theo yêu cầu của pháp luật.

Các khoảng trống: khi một bậc thô bắt đầu rất lâu sau khi bậc chi tiết kết thúc (ví dụ daily 7 rồi nhảy sang monthly), việc khôi phục trong khoảng từ ngày thứ 8 đến cuối tháng sẽ rơi vào một điểm cũ hơn tới 30 ngày so với điểm bạn mong muốn. Hãy thêm các điểm hàng tuần hoặc tăng thêm các điểm hàng ngày (`TIER_GAP`).

## Các mô hình lưu trữ
- **Full (Toàn phần)**: mọi điểm được giữ lại đều là một bản sao hoàn chỉnh (băng từ, các tệp dump đơn giản, một số bản xuất snapshot). Dung lượng lưu trữ = số điểm x kích thước toàn phần.
- **Incremental / deduplicated (Tăng dần / chống trùng lặp)** (restic, borg, pgBackRest incremental, ZFS/EBS snapshot): một bản toàn phần cộng với dữ liệu đã thay đổi giữa các điểm được giữ lại. Trình lập kế hoạch tính `daily_change_pct x days` giữa các điểm liền kề, giới hạn tối đa ở mức 100% kích thước toàn phần. Đây là mức trần: khi cùng các khối dữ liệu đó thay đổi mỗi ngày, khoảng trống một tháng sẽ tốn ít dung lượng hơn nhiều so với 30 ngày thay đổi cộng dồn. Hãy đo các con số thực tế bằng `restic stats --mode raw-data`, `borg info` hoặc hóa đơn lưu trữ và điều chỉnh `daily_change_pct`.
- Mức tăng trưởng: `growth_pct_per_month` tính lãi kép trong 12 tháng để hiển thị dung lượng lưu trữ và chi phí của năm tới.

Các đòn bẩy chi phí, từ rẻ nhất đến có tác động lớn nhất tới khả năng phục hồi: nén và chống trùng lặp; chuyển các điểm hàng tháng và hàng năm sang lớp lưu trữ lạnh hơn (lưu ý thời gian lưu trữ tối thiểu và phí truy xuất); bỏ các điểm hàng giờ sau 2-3 ngày; giảm các điểm hàng tháng sau khi nhu cầu pháp lý đã được đáp ứng bởi một bản lưu trữ hàng năm.

## Ví dụ minh họa
Sao lưu hàng đêm lúc 02:00, giữ daily 7, weekly 4, monthly 6, tính đến ngày 2026-10-24:
- daily: 10-24 lùi về 10-18
- weekly: 10-24 (tuần ISO hiện tại), 10-18, 10-11, 10-04
- monthly: 10-24, 09-30, 08-31, 07-31, 06-30, 05-31
- 14 điểm riêng biệt; bản cũ nhất là 2026-05-31 (khoảng 4,8 tháng); các khoảng trống sau tháng đầu tiên là 30-31 ngày.
FILE:references/resilience-checklist.md
# Danh sách kiểm tra khả năng chống chịu

## Quy tắc 3-2-1-1-0
- **3** bản sao dữ liệu (bản production cộng với hai bản sao lưu)
- **2** loại hoặc hệ thống lưu trữ khác nhau
- **1** bản sao ngoài cơ sở - offsite (một vùng, nhà cung cấp hoặc tòa nhà khác)
- **1** bản sao bất biến hoặc ngoại tuyến (khóa đối tượng ở chế độ tuân thủ, lưu trữ WORM, băng từ lấy ra khỏi thư viện, bản sao lưu dạng kéo mà hệ thống nguồn không thể xóa)
- **0** lỗi trong lần khôi phục đã xác minh gần nhất

## Ý nghĩa của từng phát hiện và cách khắc phục
| Mã | Mức độ nghiêm trọng | Tại sao điều này quan trọng | Cách khắc phục điển hình |
|---|---|---|---|
| RPO_MISSED | HIGH | Sự cố xảy ra ngay trước lần sao lưu tiếp theo làm mất nhiều dữ liệu hơn mức doanh nghiệp chấp nhận | Khoảng thời gian ngắn hơn, sao lưu tăng dần hàng giờ, hoặc vận chuyển nhật ký liên tục (lưu trữ PostgreSQL WAL với pgBackRest/WAL-G, MySQL binlog, sao lưu nhật ký SQL Server) |
| HISTORY_SHORT | HIGH | Các vấn đề được phát hiện muộn (nhập dữ liệu lỗi, thư mục bị xóa, hỏng dữ liệu chậm) không thể hoàn tác | Thêm nhiều điểm hàng ngày/hàng tuần hơn; ít nhất bằng thời gian cần thiết để phát hiện sự cố cộng với một khoảng dự phòng |
| LEGAL_RETENTION | HIGH | Các quy định hợp đồng, thuế hoặc hồ sơ y tế có thể yêu cầu lưu giữ lịch sử nhiều năm | Bậc hàng năm ở mức N+2 hoặc một tác vụ lưu trữ hàng năm riêng biệt có khóa đối tượng được đặt theo thời hạn pháp lý |
| NO_OFFSITE | HIGH | Hỏa hoạn, mất điện toàn vùng hoặc tài khoản bị xâm phạm sẽ xóa sổ cả môi trường production lẫn bản sao lưu cùng lúc | Nhân bản sang vùng/nhà cung cấp khác với thông tin xác thực riêng biệt |
| NEVER_RESTORED | HIGH | Các bản sao lưu thất bại âm thầm: thiếu tệp, sai quyền truy cập, mất khóa mã hóa, sổ tay vận hành (runbook) chưa được kiểm thử | Diễn tập khôi phục ngay; ghi lại thời gian thực hiện (RTO thực tế) |
| OVERLAP | HIGH | Các tác vụ va chạm hoặc bị bỏ qua, khiến khoảng cách thực tế dài hơn kế hoạch | Sao lưu tăng dần, thiết bị lưu trữ nhanh hơn, hoặc khoảng cách dài hơn kết hợp với vận chuyển nhật ký (log shipping) |
| RESTORE_TEST_OLD | MEDIUM | Hệ thống thay đổi liên tục; bài kiểm tra từ mùa xuân năm ngoái không nói lên được nhiều điều về hiện tại | Diễn tập hàng quý; tự động khôi phục và kiểm tra checksum hàng tháng đối với dữ liệu quan trọng |
| NO_IMMUTABLE | MEDIUM | Ransomware và kẻ tấn công luôn xóa các bản sao lưu trước tiên | S3 Object Lock / Azure immutable blob / GCS bucket lock, hoặc một bản sao ngoại tuyến |
| NOT_ENCRYPTED | MEDIUM | Bản sao ngoài cơ sở sẽ làm lộ toàn bộ dữ liệu nếu bucket hoặc băng từ bị phơi bày | Mã hóa phía máy khách (client-side); khóa được lưu trữ bên ngoài hệ thống sao lưu và được ký quỹ (escrow) an toàn |
| STORAGE_OVER | MEDIUM | Ngân sách buộc ai đó phải cắt giảm thời gian lưu giữ một cách vội vàng về sau | Lưu trữ lạnh hơn cho các bậc cũ, giảm bớt các điểm hàng tháng, đo tỷ lệ thay đổi thực tế |
| TIER_GAP | LOW | Việc khôi phục rơi vào khoảng trống sẽ phải dùng điểm cũ hơn nhiều so với mong muốn | Thêm các điểm hàng tuần hoặc kéo dài các điểm hàng ngày |
| TIGHT_WINDOW | LOW | Sự gia tăng dữ liệu sẽ đẩy tác vụ vào tình trạng chồng chéo | Lên kế hoạch sao lưu tăng dần hoặc song song trước khi điều đó xảy ra |

## Diễn tập khôi phục (một trang)
1. Chọn một điểm khôi phục ngẫu nhiên, không phải điểm mới nhất.
2. Khôi phục vào một môi trường cô lập chỉ sử dụng sổ tay vận hành (runbook) và các khóa được ký quỹ.
3. Kiểm tra: số lượng hàng hoặc số lượng tệp, ứng dụng khởi động được, một bản ghi đã biết từ ngày đó có tồn tại.
4. Ghi lại thời gian bắt đầu và kết thúc (RTO thực tế), các vấn đề gặp phải và những điểm cần sửa trong sổ tay vận hành.
5. Xóa bản sao đã khôi phục và ghi nhận ngày diễn tập; trường `last_restore_test` của trình lập kế hoạch chính là ngày này.

## Giám sát
- Cảnh báo khi bản sao lưu mới nhất cũ hơn 1,5 lần khoảng cách định kỳ.
- Cảnh báo khi có các tác vụ thất bại hoặc chỉ hoàn thành một phần, chứ không chỉ khi thiếu bản sao lưu.
- Theo dõi kích thước bản sao lưu mỗi lần chạy; sự sụt giảm đột ngột thường có nghĩa là đường dẫn bao gồm (include path) bị hỏng, sự tăng vọt đột ngột có thể là dấu hiệu bị ransomware mã hóa.
- Kiểm tra xem các tác vụ prune/forget có chạy hay không; một chính sách không bao giờ được thực thi sẽ âm thầm làm đầy bộ nhớ.

## Khóa và quyền truy cập
- Thông tin xác thực sao lưu nên chỉ có quyền ghi (write-only) từ phía nguồn ở những nơi có thể; việc xóa cần một định danh khác.
- Giữ các khóa mã hóa và sổ tay vận hành khôi phục ở nơi vẫn tồn tại được ngay cả khi mất môi trường chính (kho lưu trữ của trình quản lý mật khẩu, bản in niêm phong).
FILE:templates/backup-policy.md
# Chính sách Sao lưu - <system name>

**Chủ sở hữu:** <team / person>  **Đã xem xét:** <date>  **Lần xem xét tiếp theo:** <date + 6 months>

## Phạm vi
- Dữ liệu: <databases, buckets, file shares, configs>
- Loại trừ: <caches, rebuildable artifacts>

## Yêu cầu
| Hạng mục | Mục tiêu | Nguồn yêu cầu |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | <4 h> | <business owner> |
| RTO (thời gian khôi phục tối đa) | <2 h> | <business owner> |
| Lịch sử tối thiểu | <35 days> | <ops> |
| Lưu giữ pháp lý / theo hợp đồng | <3 years> | <contract, regulation> |
| Ngân sách lưu trữ | <1500 GB / 40 USD per month> | <finance> |

## Lịch trình và quy tắc giữ lại
| Tác vụ | Tần suất | Phương thức | Quy tắc giữ lại |
|---|---|---|---|
| <full/incremental> | <daily 02:00> | <pgBackRest incr to S3> | <daily 14, weekly 8, monthly 12, yearly 5> |
| <log shipping> | <continuous> | <WAL archive> | <14 days> |

Các điểm khôi phục được tạo ra (từ `scripts/retention_plan.py`): <count>, bản cũ nhất <age>, khoảng trống lớn nhất <days>.

## Các bản sao (3-2-1-1-0)
| Bản sao | Vị trí | Bất biến | Mã hóa | Thông tin xác thực |
|---|---|---|---|---|
| Bản sao lưu chính | <region A bucket> | <no> | <yes> | <backup-writer role> |
| Ngoài cơ sở (Offsite) | <region B / provider B> | <object lock 35 d> | <yes> | <separate account> |

## Dung lượng lưu trữ và chi phí
- Hiện tại: <GB> / <cost per month>  - Sau 12 tháng: <GB> / <cost per month>

## Kiểm thử khôi phục
- Tần suất: <quarterly drill, monthly automated check>
- Lần kiểm thử gần nhất: <date>, kết quả <ok / issues>, RTO thực tế <time>
- Sổ tay vận hành (Runbook): <link>  - Khóa được ký quỹ tại: <location>

## Giám sát và cảnh báo
- <newest backup older than 36 h -> page on-call>
- <job failed / size change > 30% -> ticket>

## Các phát hiện đang mở
- <severity code: action, owner, due date>
FILE:examples/clinic-postgres-policy.json
{
  "name": "clinic-booking PostgreSQL (pgBackRest to S3)",
  "today": "2026-10-24",
  "backup_interval_hours": 24,
  "backup_duration_hours": 2.5,
  "policy": {"daily": 7, "weekly": 4, "monthly": 6, "yearly": 0},
  "size": {"full_gb": 180, "daily_change_pct": 3, "growth_pct_per_month": 4, "storage_model": "incremental"},
  "requirements": {
    "rpo_hours": 4,
    "min_history_days": 90,
    "legal_retention_years": 3,
    "max_storage_gb": 1500,
    "restore_test_max_age_days": 90
  },
  "copies": {"onsite": false, "offsite": true, "immutable": false, "encrypted": true},
  "price_per_gb_month": 0.023,
  "currency": "USD",
  "last_restore_test": "2026-04-14"
}
FILE:scripts/retention_plan.py
#!/usr/bin/env python3
"""Backup retention policy planner (Python 3 standard library only).

Simulates a grandfather-father-son (GFS) retention policy the way restic/borg "keep" rules
work, then reports the restore points you really keep, the gaps between them, storage and
cost, and the gaps against your requirements (RPO, minimum history, legal retention, 3-2-1).

Input JSON:
  {"name": "orders-db", "today": "2026-10-24",
   "backup_interval_hours": 24, "backup_duration_hours": 1.5,
   "policy": {"hourly": 0, "daily": 7, "weekly": 4, "monthly": 12, "yearly": 3},
   "size": {"full_gb": 120, "daily_change_pct": 4, "growth_pct_per_month": 3,
            "storage_model": "incremental"},          # or "full" (every point is a full copy)
   "requirements": {"rpo_hours": 24, "min_history_days": 35, "legal_retention_years": 0,
                    "max_storage_gb": 0, "restore_test_max_age_days": 90},
   "copies": {"onsite": true, "offsite": true, "immutable": false, "encrypted": true},
   "price_per_gb_month": 0.023, "currency": "USD",
   "last_restore_test": "2026-03-02"}                 # or null if never tested
Selection rule per tier (newest backup first): keep a backup if its bucket (hour, day, ISO week,
month, year) differs from the previous kept bucket of that tier, until the tier count is used.
A backup kept by several tiers is stored once.
Storage: "full" = points x full size; "incremental" = one full + each later point's changed data
(daily_change_pct x days since the previous kept point, capped at 100%).
Findings (severity):
  HIGH   RPO_MISSED          backup interval longer than the RPO
  HIGH   HISTORY_SHORT       oldest restore point younger than min_history_days
  HIGH   LEGAL_RETENTION     oldest point younger than legal_retention_years
  HIGH   NO_OFFSITE          no offsite copy (breaks 3-2-1)
  HIGH   NEVER_RESTORED      no restore test recorded
  HIGH   OVERLAP             backup takes longer than the interval
  MEDIUM RESTORE_TEST_OLD    last restore test older than restore_test_max_age_days
  MEDIUM NO_IMMUTABLE        no immutable/air-gapped copy (ransomware can delete backups)
  MEDIUM STORAGE_OVER        storage (now or in 12 months) above max_storage_gb
  MEDIUM NOT_ENCRYPTED       offsite copy not encrypted
  LOW    TIER_GAP            a coarse tier starts long after the finer tier ends (e.g. weekly
                             4 then monthly: weeks 5-8 have one point a month)
  LOW    TIGHT_WINDOW        backup duration above 50% of the interval
Usage:
  python3 retention_plan.py policy.json
  python3 retention_plan.py policy.json --json
  python3 retention_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import sys

TIERS = ["hourly", "daily", "weekly", "monthly", "yearly"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def bucket(tier, t):
    if tier == "hourly":
        return t.strftime("%Y-%m-%d %H")
    if tier == "daily":
        return t.date().isoformat()
    if tier == "weekly":
        y, w, _ = t.isocalendar()
        return f"{y}-W{w:02d}"
    if tier == "monthly":
        return t.strftime("%Y-%m")
    return str(t.year)


def simulate(policy, interval_h, today):
    """Return (kept list of (datetime, [tiers])) newest first."""
    for k in policy:
        if k not in TIERS:
            raise DataError(f"unknown policy tier '{k}' (use {', '.join(TIERS)})")
        if not isinstance(policy[k], int) or policy[k] < 0:
            raise DataError(f"policy.{k} must be a whole number >= 0")
    if sum(policy.get(t, 0) for t in TIERS) == 0:
        raise DataError("policy keeps nothing")
    if not interval_h or interval_h <= 0:
        raise DataError("backup_interval_hours must be > 0")
    span_days = max(policy.get("hourly", 0) / 24, policy.get("daily", 0), policy.get("weekly", 0) * 7,
                    policy.get("monthly", 0) * 31, policy.get("yearly", 0) * 366) + 400
    newest = dt.datetime.combine(today, dt.time(2, 0))
    step = dt.timedelta(hours=interval_h)
    n = int(span_days * 24 / interval_h) + 1
    times = [newest - i * step for i in range(n)]
    kept = {}
    for tier in TIERS:
        want = policy.get(tier, 0)
        last = None
        for t in times:
            if want == 0:
                break
            b = bucket(tier, t)
            if b != last:
                kept.setdefault(t, []).append(tier)
                last = b
                want -= 1
    return sorted(kept.items(), key=lambda kv: kv[0], reverse=True)


def storage_gb(kept, size):
    full = float(size.get("full_gb", 0))
    model = size.get("storage_model", "incremental")
    if model not in ("incremental", "full"):
        raise DataError("size.storage_model must be 'incremental' or 'full'")
    if model == "full":
        return full * len(kept)
    change = float(size.get("daily_change_pct", 5)) / 100
    total = full
    for (newer, _), (older, _) in zip(kept, kept[1:]):
        days = (newer - older).total_seconds() / 86400
        total += full * min(1.0, change * days)
    return total


def plan(cfg, today=None):
    try:
        today = today or dt.date.fromisoformat(cfg.get("today") or dt.date.today().isoformat())
    except ValueError:
        raise DataError("today must be YYYY-MM-DD")
    policy = cfg.get("policy") or {}
    interval = float(cfg.get("backup_interval_hours", 24))
    kept = simulate(policy, interval, today)
    newest = kept[0][0]
    ages = [(newest - t).total_seconds() / 86400 for t, _ in kept]
    oldest_days = ages[-1]
    size = cfg.get("size") or {}
    req = cfg.get("requirements") or {}
    copies = cfg.get("copies") or {}
    price = float(cfg.get("price_per_gb_month", 0))
    gb = storage_gb(kept, size)
    growth = float(size.get("growth_pct_per_month", 0)) / 100
    gb12 = gb * (1 + growth) ** 12
    tiers = []
    for tier in TIERS:
        pts = [(t, a) for (t, tl), a in zip(kept, ages) if tier in tl]
        if pts:
            tiers.append({"tier": tier, "count": len(pts), "newest_age_days": round(pts[0][1], 1),
                          "oldest_age_days": round(pts[-1][1], 1)})
    gaps = []
    for (t1, _), (t2, _), a in zip(kept, kept[1:], ages[1:]):
        gaps.append({"from": t2.date().isoformat(), "to": t1.date().isoformat(),
                     "gap_days": round((t1 - t2).total_seconds() / 86400, 1), "age_days": round(a, 1)})
    findings = []

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

    rpo = req.get("rpo_hours")
    if rpo and interval > rpo:
        add("HIGH", "RPO_MISSED", f"backups every {interval:g} h but RPO is {rpo:g} h; up to {interval:g} h of data can be lost")
    mh = req.get("min_history_days")
    if mh and oldest_days < mh:
        add("HIGH", "HISTORY_SHORT", f"oldest restore point is {oldest_days:.0f} days old; requirement is {mh} days")
    ly = req.get("legal_retention_years")
    if ly and oldest_days < ly * 365:
        add("HIGH", "LEGAL_RETENTION", f"oldest point {oldest_days / 365:.1f} years; legal retention needs {ly} years; yearly N also counts the current year, so set yearly >= {ly + 2} to cover it all year round")
    if not copies.get("offsite"):
        add("HIGH", "NO_OFFSITE", "no offsite copy; a site loss, account compromise or region outage takes every backup")
    dur = cfg.get("backup_duration_hours")
    if dur and dur > interval:
        add("HIGH", "OVERLAP", f"backup takes {dur:g} h but runs every {interval:g} h; jobs overlap or get skipped")
    elif dur and dur > interval / 2:
        add("LOW", "TIGHT_WINDOW", f"backup takes {dur:g} h of a {interval:g} h interval; growth will push it into overlap")
    lrt = cfg.get("last_restore_test")
    max_age = req.get("restore_test_max_age_days", 90)
    if not lrt:
        add("HIGH", "NEVER_RESTORED", "no restore test recorded; an untested backup is a hope, not a backup")
    else:
        age = (today - dt.date.fromisoformat(lrt)).days
        if age > max_age:
            add("MEDIUM", "RESTORE_TEST_OLD", f"last restore test {age} days ago (limit {max_age}); schedule a timed restore")
    if not copies.get("immutable"):
        add("MEDIUM", "NO_IMMUTABLE", "no immutable or air-gapped copy; ransomware or a stolen admin key can delete every backup")
    if copies.get("offsite") and copies.get("encrypted") is False:
        add("MEDIUM", "NOT_ENCRYPTED", "offsite copy is not encrypted at rest")
    mx = req.get("max_storage_gb")
    if mx and max(gb, gb12) > mx:
        add("MEDIUM", "STORAGE_OVER", f"storage {gb:.0f} GB now, {gb12:.0f} GB in 12 months; budget {mx} GB")
    order = [t for t in TIERS if policy.get(t, 0) > 0]
    span = {"hourly": 1 / 24, "daily": 1, "weekly": 7, "monthly": 30.4, "yearly": 365}
    for fine, coarse in zip(order, order[1:]):
        fine_end = policy[fine] * span[fine]
        if span[coarse] >= 4 * fine_end and fine != "hourly":
            add("LOW", "TIER_GAP", f"{fine} points cover ~{fine_end:.0f} days, then only one {coarse} point per {span[coarse]:.0f} days")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return {
        "name": cfg.get("name", "backup set"), "today": today.isoformat(), "interval_hours": interval,
        "restore_points": len(kept), "oldest_age_days": round(oldest_days, 1),
        "tiers": tiers, "largest_gaps": sorted(gaps, key=lambda g: -g["gap_days"])[:5],
        "storage_gb": round(gb, 1), "storage_gb_12m": round(gb12, 1),
        "monthly_cost": round(gb * price, 2), "monthly_cost_12m": round(gb12 * price, 2),
        "currency": cfg.get("currency", "USD"), "storage_model": size.get("storage_model", "incremental"),
        "kept": [{"time": t.strftime("%Y-%m-%d %H:%M"), "tiers": tl} for t, tl in kept],
        "findings": findings,
    }


def age_label(d):
    if d < 2:
        return f"{d * 24:.0f} h"
    if d < 60:
        return f"{d:.0f} d"
    if d < 730:
        return f"{d / 30.4:.1f} mo"
    return f"{d / 365:.1f} y"


def render(r):
    out = [f"# Backup retention plan: {r['name']} (as of {r['today']})", "",
           f"Backups every {r['interval_hours']:g} h -> {r['restore_points']} restore points kept, oldest {age_label(r['oldest_age_days'])} old",
           f"Storage ({r['storage_model']}): {r['storage_gb']:.0f} GB now, {r['storage_gb_12m']:.0f} GB in 12 months"
           f" -> {r['monthly_cost']:.2f} -> {r['monthly_cost_12m']:.2f} {r['currency']}/month", "",
           "| Tier | Points | Newest age | Oldest age |", "|---|---|---|---|"]
    for t in r["tiers"]:
        out.append(f"| {t['tier']} | {t['count']} | {age_label(t['newest_age_days'])} | {age_label(t['oldest_age_days'])} |")
    out += ["", "## Largest gaps between restore points", "| Older point | Newer point | Gap | Age of older point |", "|---|---|---|---|"]
    for g in r["largest_gaps"]:
        out.append(f"| {g['from']} | {g['to']} | {g['gap_days']:g} d | {age_label(g['age_days'])} |")
    out += ["", "## Restore points kept"]
    out.append(", ".join(f"{k['time'][:10]} ({'/'.join(x[0].upper() for x in k['tiers'])})" for k in r["kept"]))
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']}: {f['message']}")
    return "\n".join(out) + "\n"


def self_test():
    ok = 0
    tests = []

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    today = dt.date(2026, 10, 24)
    base = {"today": "2026-10-24", "backup_interval_hours": 24, "policy": {"daily": 7, "weekly": 4, "monthly": 12},
            "size": {"full_gb": 100, "daily_change_pct": 2}, "requirements": {"rpo_hours": 24, "min_history_days": 35},
            "copies": {"offsite": True, "immutable": True, "encrypted": True}, "last_restore_test": "2026-10-01"}
    r = plan(base, today)
    # daily 7 = 7 newest days; weekly 4 overlaps with dailies; monthly 12
    check("daily 7 + weekly 4 + monthly 12 -> 20 distinct points", r["restore_points"] == 20)
    check("clean policy has no findings", r["findings"] == [])
    check("oldest point is the 2025-11-30 monthly", r["kept"][-1]["time"].startswith("2025-11-30"))
    k = simulate({"daily": 3}, 24, today)
    check("daily 3 keeps 3 newest days", [t.date().day for t, _ in k] == [24, 23, 22])
    k = simulate({"hourly": 6}, 1, today)
    check("hourly 6 keeps 6 points one hour apart", len(k) == 6 and (k[0][0] - k[5][0]) == dt.timedelta(hours=5))
    k = simulate({"weekly": 2}, 24, today)
    check("weekly keeps newest of each ISO week", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-10-18"])
    k = simulate({"monthly": 2}, 24, today)
    check("monthly keeps last day of previous month", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-09-30"])
    k = simulate({"daily": 2, "weekly": 1}, 24, today)
    check("point kept by two tiers stored once", len(k) == 2 and k[0][1] == ["daily", "weekly"])
    k = simulate({"daily": 7}, 12, today)
    check("12 h interval: daily keeps newest per day", len(k) == 7 and [t.hour for t, _ in k[:2]] == [2, 14])
    check("full storage = points x full", storage_gb([(dt.datetime(2026, 1, 2), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 50, "storage_model": "full"}) == 100)
    check("incremental storage = full + change x days", abs(storage_gb([(dt.datetime(2026, 1, 11), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 100, "daily_change_pct": 2}) - 120) < 1e-9)
    check("incremental change capped at 100%", storage_gb([(dt.datetime(2026, 6, 1), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 10, "daily_change_pct": 5}) == 20)

    def codes(**over):
        c = json.loads(json.dumps(base))
        for key, val in over.items():
            if key != "policy" and isinstance(val, dict) and isinstance(c.get(key), dict):
                c[key].update(val)
            else:
                c[key] = val
        return {f["code"] for f in plan(c, today)["findings"]}

    check("RPO missed HIGH", "RPO_MISSED" in codes(requirements={"rpo_hours": 4}))
    check("history short HIGH", "HISTORY_SHORT" in codes(policy={"daily": 14}))
    check("legal retention HIGH", "LEGAL_RETENTION" in codes(requirements={"legal_retention_years": 7}))
    check("yearly 9 satisfies legal 7, yearly 8 does not", "LEGAL_RETENTION" in codes(policy={"daily": 7, "monthly": 12, "yearly": 8}, requirements={"legal_retention_years": 7}) and "LEGAL_RETENTION" not in codes(policy={"daily": 7, "monthly": 12, "yearly": 9}, requirements={"legal_retention_years": 7}))
    check("no offsite HIGH", "NO_OFFSITE" in codes(copies={"offsite": False}))
    check("never restored HIGH", "NEVER_RESTORED" in codes(last_restore_test=None))
    check("restore test old MEDIUM", "RESTORE_TEST_OLD" in codes(last_restore_test="2026-01-01"))
    check("no immutable MEDIUM", "NO_IMMUTABLE" in codes(copies={"immutable": False}))
    check("not encrypted MEDIUM", "NOT_ENCRYPTED" in codes(copies={"encrypted": False}))
    check("overlap HIGH", "OVERLAP" in codes(backup_duration_hours=30))
    check("tight window LOW", "TIGHT_WINDOW" in codes(backup_duration_hours=14))
    check("storage over MEDIUM", "STORAGE_OVER" in codes(requirements={"max_storage_gb": 150}))
    check("tier gap LOW (daily 7 then monthly)", "TIER_GAP" in codes(policy={"daily": 7, "monthly": 12}))
    try:
        plan({"policy": {"daily": 0}}, today)
        check("empty policy rejected", False)
    except DataError:
        check("empty policy rejected", True)
    try:
        plan({"policy": {"fortnightly": 2}}, today)
        check("unknown tier rejected", False)
    except DataError:
        check("unknown tier rejected", True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Backup retention (GFS) policy planner")
    ap.add_argument("policy", nargs="?", help="policy JSON file")
    ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.policy:
        ap.error("policy file required")
    try:
        with open(a.policy, encoding="utf-8") as fh:
            cfg = json.load(fh)
        r = plan(cfg)
    except (OSError, json.JSONDecodeError, DataError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="" if not a.json else "\n")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsDatabase+2
F@f
0
Trình kiểm toán dọn dẹp nhánh Git cũ
Kỹ năng

Kiểm toán các nhánh từ xa (remote branch) của kho lưu trữ và lập kế hoạch dọn dẹp an toàn. Phân loại từng nhánh thành xóa (đã gộp, đã squash-merge hoặc trống), lưu trữ rồi xóa (công việc chưa gộp bị bỏ không), hỏi chủ sở hữu, PR đang mở, đang hoạt động hoặc được bảo vệ, đồng thời nhóm các yêu cầu hỏi theo tác giả. Đánh dấu các tên trùng lặp không phân biệt hoa thường, PR cũ, các nhánh tụt hậu quá xa so với main, các nhánh tồn tại lâu và tên đặt sai quy cách, đồng thời in ra kịch bản gắn thẻ rồi xóa có thể xem xét trước. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.

---
name: stale-git-branch-cleanup-auditor
description: Kiểm toán các nhánh từ xa (remote branch) của kho lưu trữ và lập kế hoạch dọn dẹp an toàn. Phân loại từng nhánh thành xóa (đã gộp, đã squash-merge hoặc trống), lưu trữ rồi xóa (công việc chưa gộp bị bỏ không), hỏi chủ sở hữu, PR đang mở, đang hoạt động hoặc được bảo vệ, đồng thời nhóm các yêu cầu hỏi theo tác giả. Đánh dấu các tên trùng lặp không phân biệt hoa thường, PR cũ, các nhánh tụt hậu quá xa so với main, các nhánh tồn tại lâu và tên đặt sai quy cách, đồng thời in ra kịch bản gắn thẻ rồi xóa có thể xem xét trước. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---

# Trình kiểm toán dọn dẹp nhánh Git cũ

Bạn giúp các nhóm dọn dẹp hàng chục hoặc hàng trăm nhánh bị lãng quên trên một remote dùng chung mà không làm mất công việc của bất kỳ ai. Bạn quyết định, từng nhánh một, nhánh nào an toàn để xóa, nhánh nào nên được lưu trữ dưới dạng thẻ (tag) trước, và ai cần được hỏi ý kiến, sau đó tạo ra một kịch bản dọn dẹp để mọi người xem xét trước khi bất kỳ thứ gì bị gỡ bỏ.

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

- `scripts/branch_audit.py` - phân loại các nhánh từ xa từ tệp xuất TSV hoặc CSV hoặc trực tiếp từ bản clone cục bộ (`--repo`), in ra các phát hiện, danh sách chủ sở hữu và kịch bản dọn dẹp (chỉ dùng thư viện chuẩn Python 3; không bao giờ tự xóa bất kỳ thứ gì)
- `references/cleanup-rules.md` - các quy tắc quyết định, phát hiện squash-merge, thẻ lưu trữ, nhánh được bảo vệ, quy ước đặt tên và cách khôi phục nhánh đã xóa
- `references/exporting-branch-data.md` - các lệnh git và GitHub/GitLab để tạo dữ liệu đầu vào, bao gồm cả trạng thái PR
- `templates/cleanup-announcement.md` - thông báo cho nhóm kèm theo kế hoạch, các câu hỏi dành cho chủ sở hữu và thời hạn chót
- `examples/webshop-branches.csv` - 16 nhánh của kho lưu trữ cửa hàng trực tuyến với các nhánh đã gộp, đã squash-merge, cũ, xung đột tên và được bảo vệ

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

### 1. Lấy dữ liệu nhánh
Ưu tiên: một bản clone cục bộ mới (`git fetch --prune origin`) và chạy kịch bản với `--repo`. Hỏi nhánh cơ sở (main, master hoặc develop), trạng thái PR từ `gh pr list` hoặc GitLab API (squash merge không thể thấy được trong lịch sử git), các mẫu nhánh được bảo vệ và quy ước đặt tên của nhóm. Nếu họ dán một danh sách thay thế, hãy chuyển đổi nó thành các cột CSV trong `references/exporting-branch-data.md`.

### 2. Chạy trình kiểm toán
```
python3 scripts/branch_audit.py examples/webshop-branches.csv --today 2026-10-24
python3 scripts/branch_audit.py --repo . --base main --prs prs.tsv --stale-days 90 --ask-days 30
python3 scripts/branch_audit.py --self-test
```
Mã thoát 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.

### 3. Xem xét cùng người dùng
Đi qua các hành động theo thứ tự sau: DELETE_MERGED (an toàn, công việc đã nằm trong nhánh cơ sở), ARCHIVE_AND_DELETE (có thể khôi phục từ thẻ lưu trữ), ASK_OWNER (cần câu trả lời), sau đó là các phát hiện. Sửa lỗi CASE_COLLISION trước tiên vì nó làm hỏng các bản clone trên macOS và Windows. Điều chỉnh các ngưỡng theo tốc độ làm việc của nhóm (một kho lưu trữ nghiên cứu có thể muốn 180 ngày không hoạt động). Sử dụng `references/cleanup-rules.md` để giải thích từng quyết định.

### 4. Bàn giao
Điền vào `templates/cleanup-announcement.md` với số lượng thống kê, danh sách theo từng chủ sở hữu và thời hạn chót (thường là 7 ngày). Bàn giao kịch bản đã tạo cùng với lời khuyên nên chạy các dòng DELETE_MERGED trước và các dòng lưu trữ sau thời hạn chót.

## Kết quả ví dụ
Đối với `examples/webshop-branches.csv` (tính đến 2026-10-24, cũ 90 ngày, hỏi 30 ngày): 16 nhánh -> 3 xóa (đã gộp, PR đã squash-merge, không có commit nào đi trước), 3 lưu trữ và xóa (spike/graphql-gateway, feature/gift-cards, johns-test), 2 hỏi chủ sở hữu, 3 PR đang mở, 1 đang hoạt động, 4 được bảo vệ. Các phát hiện: HIGH CASE_COLLISION (fix/search-timeout so với fix/Search-Timeout), MEDIUM PR_STALE (feature/checkout-redesign, không hoạt động 205 ngày), 3 MEDIUM FAR_BEHIND, 2 LOW BAD_NAME, 2 LOW LONG_LIVED, LOW OLD_PROTECTED (release/2.3).

## Quy tắc

- Không bao giờ tự ý xóa các nhánh và không bao giờ chạy kịch bản thay cho người dùng; hãy in kịch bản ra để họ xem xét.
- Công việc chưa gộp sẽ được lưu trữ dưới dạng thẻ (`archive/<branch>`) trước khi nhánh bị xóa, để có thể khôi phục lại.
- Squash merge và rebase merge khiến nhánh trông như "chưa gộp" trong git; hãy tin tưởng trạng thái PR chứ không chỉ dựa vào `--merged`.
- Các nhánh được bảo vệ, nhánh phát hành (release) và nhánh cơ sở (base) không bao giờ nằm trong danh sách xóa, ngay cả khi chúng có vẻ không hoạt động.
- Hỏi ý kiến chủ sở hữu trước khi chạm vào công việc chưa gộp và đưa ra thời hạn chót rõ ràng.
FILE:references/cleanup-rules.md
# Quy tắc dọn dẹp

## Thứ tự quyết định (khớp đầu tiên sẽ được áp dụng)
| Hành động | Điều kiện | Tại sao an toàn hoặc không an toàn |
|---|---|---|
| KEEP_PROTECTED | khớp với `--protect` (main, master, develop, trunk, gh-pages, release/*, hotfix/*) | Các đợt triển khai, bảo trì bản phát hành và quy tắc bảo vệ nhánh phụ thuộc vào chúng |
| DELETE_MERGED | có thể truy cập từ nhánh cơ sở, 0 commit đi trước, hoặc PR của nó đã được gộp | Mọi commit (hoặc bản tương đương đã squash của nó) đều đã nằm trong nhánh cơ sở |
| KEEP_OPEN_PR | yêu cầu pull/merge đang mở | Xóa nhánh sẽ đóng PR; hãy hỏi ý kiến thay vì xóa (xem PR_STALE) |
| ARCHIVE_AND_DELETE | chưa gộp và không hoạt động >= số ngày cũ (mặc định 90) | Các commit duy nhất: gắn thẻ trước để công việc có thể khôi phục được |
| ASK_OWNER | chưa gộp và không hoạt động >= số ngày cần hỏi (mặc định 30) | Có thể vẫn đang được sử dụng; chủ sở hữu sẽ quyết định |
| KEEP_ACTIVE | chưa gộp, có các commit gần đây | Công việc đang tiến hành |

"Không hoạt động" (Idle) là ngày committer của commit đầu mút nhánh. Thao tác rebase sẽ cập nhật ngày này, vì vậy một nhánh được rebase ngày hôm qua vẫn được tính là đang hoạt động ngay cả khi không ai làm việc trên đó trong nhiều tháng.

## Squash merge và rebase merge
`git branch -r --merged main` chỉ tìm thấy các nhánh có commit đầu mút nằm trong main. Khi một PR được squash-merge hoặc rebase-merge, main nhận các commit mới còn nhánh đó trông vẫn như chưa gộp với N commit đi trước. Hãy sử dụng trạng thái PR (`pr = merged`) để bắt các trường hợp này. Nếu không có dữ liệu PR, hãy kiểm tra thủ công:
```
git diff --stat origin/main...origin/<branch>     # trống hoặc rất nhỏ = các thay đổi đã có trong main
git log --oneline origin/main --grep "<PR title or number>"
```
Một PR đã đóng (nhưng không gộp) với các commit cũ là công việc đã bị từ bỏ: hãy lưu trữ nó.

## Thẻ lưu trữ
```
git tag archive/<branch> origin/<branch>
git push origin refs/tags/archive/<branch>
git push origin --delete <branch>
```
Khôi phục sau này bằng lệnh `git push origin archive/<branch>:refs/heads/<branch>`. Các thẻ giúp giữ cho các commit tồn tại mãi mãi (không bị dọn rác - garbage collection). Nếu các thẻ lưu trữ làm rối danh sách thẻ, hãy đẩy chúng lên một remote lưu trữ riêng biệt.

Đã xóa mà không gắn thẻ? Trên GitHub, một nhánh bị xóa khỏi trang PR có thể được khôi phục từ PR đó trong một khoảng thời gian; nếu không, bản clone cục bộ của ai đó hoặc `git reflog` trên bản sao (mirror) phía máy chủ là cách duy nhất để lấy lại. Đó là lý do tại sao kịch bản luôn gắn thẻ trước.

## Các phát hiện
| Mã | Mức độ nghiêm trọng | Ý nghĩa | Hành động |
|---|---|---|---|
| CASE_COLLISION | HIGH | `fix/Login` và `fix/login` cùng tồn tại; hệ thống tệp không phân biệt hoa thường lưu trữ các tham chiếu (ref) dưới dạng tệp, nên thao tác fetch sẽ thất bại hoặc một tham chiếu sẽ che khuất tham chiếu kia | Xóa hoặc đổi tên một nhánh ngay hôm nay |
| PR_STALE | MEDIUM | PR đang mở không có commit nào trong >= số ngày cũ | Chủ sở hữu hoàn thiện, tách nhỏ hoặc đóng nó |
| FAR_BEHIND | MEDIUM | Nhánh chưa gộp tụt hậu > 300 commit so với nhánh cơ sở | Sớm rebase/merge nhánh cơ sở hoặc lưu trữ; xung đột sẽ tăng lên mỗi ngày |
| LONG_LIVED | LOW | > 50 commit chưa gộp và không hoạt động >= số ngày cần hỏi | Tách thành các PR nhỏ hơn; ẩn phần công việc chưa hoàn thành sau một cờ tính năng (feature flag) |
| OLD_PROTECTED | LOW | release/* hoặc hotfix/* không hoạt động >= số ngày cũ | Gắn thẻ cho bản phát hành (`v2.3.x`), gỡ bảo vệ, rồi xóa |
| BAD_NAME | LOW | Không có tiền tố `<type>/` được phép, hoặc chứa chữ hoa/khoảng trắng | Đổi tên thành `feature/...`, `fix/...`, `chore/...` |

## Quy ước đặt tên
`<type>/<short-kebab-description>` với mã ticket tùy chọn: `feature/cart-coupons`, `fix/1423-vat-rounding`, `chore/bump-node-22`. Các loại được phép mặc định: feature, feat, fix, bugfix, hotfix, release, chore, docs, refactor, test, ci, perf, build, deps, dependabot, renovate, experiment, spike. Ghi đè bằng `--prefixes`.

## Giữ kho lưu trữ sạch sẽ sau đó
- Bật tùy chọn "Automatically delete head branches" (GitHub) hoặc "Delete source branch" (GitLab) khi gộp.
- Chỉ bảo vệ những nhánh thực sự cần thiết, bằng các mẫu (pattern).
- Chạy kiểm toán hàng tháng; hầu hết các nhóm ổn định ở mức 90 ngày cũ và 30 ngày cần hỏi.
- Các bot (dependabot, renovate) sẽ tự tạo lại nhánh của chúng; hãy giữ cho các PR của chúng được xử lý liên tục thay vì xóa nhánh.
FILE:references/exporting-branch-data.md
# Xuất dữ liệu nhánh

## Tùy chọn A: để kịch bản đọc bản clone
```
git fetch --prune origin
python3 scripts/branch_audit.py --repo . --remote origin --base main
```
Kịch bản sẽ chạy `git for-each-ref`, `git branch -r --merged origin/main` và `git rev-list --left-right --count origin/main...origin/<branch>` cho mọi nhánh. Các kho lưu trữ lớn với hàng nghìn nhánh sẽ mất khoảng một phút.

## Tùy chọn B: tự tạo tệp TSV (hoặc CSV)
Các cột (phân tách bằng tab hoặc dấu phẩy, bắt buộc có dòng tiêu đề; ký tự phân tách được phát hiện từ dòng tiêu đề): `branch last_commit author ahead behind merged pr`

```
base=origin/main
printf 'branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\n' > branches.tsv
git branch -r --merged "$base" | sed 's/^ *//' > /tmp/merged.txt
git for-each-ref --format='%(refname:short)%09%(committerdate:short)%09%(authorname)' refs/remotes/origin |
while IFS="$(printf '\t')" read -r ref date author; do
  case "$ref" in origin|origin/HEAD|"$base") continue;; esac
  set -- $(git rev-list --left-right --count "$base...$ref")
  merged=no; grep -qx "$ref" /tmp/merged.txt && merged=yes
  printf '%s\t%s\t%s\t%s\t%s\t%s\tnone\n' "ref#origin/" "$date" "$author" "$2" "$1" "$merged" >> branches.tsv
done
```
`$1` là "behind" (các commit chỉ có trong nhánh cơ sở) và `$2` là "ahead" (các commit chỉ có trong nhánh đang xét).

## Trạng thái PR
GitHub CLI (`gh pr list` trả về các PR mới nhất trước; awk giữ lại dòng đầu tiên, tức là PR mới nhất, cho mỗi nhánh):
```
gh pr list --state all --limit 1000 --json headRefName,state \
  -q '.[] | [.headRefName, (.state|ascii_downcase)] | @tsv' | awk -F'\t' '!seen[$1]++' > prs.tsv
python3 scripts/branch_audit.py --repo . --prs prs.tsv
```
Các trạng thái trên GitHub ánh xạ thành open, merged, closed. GitLab: `glab mr list --all` hoặc API `GET /projects/:id/merge_requests?state=all` (`source_branch`, `state`: opened -> open, merged, closed).

Đối với tệp TSV hoặc CSV, hãy đặt trạng thái vào cột `pr` (open, merged, closed hoặc none).

## Các ngưỡng
| Tùy chọn | Mặc định | Ghi chú |
|---|---|---|
| `--stale-days` | 90 | Các nhánh chưa gộp không hoạt động vượt quá mức này sẽ được lưu trữ |
| `--ask-days` | 30 | Các nhánh chưa gộp không hoạt động vượt quá mức này cần câu trả lời từ chủ sở hữu |
| `--behind-limit` | 300 | Ngưỡng FAR_BEHIND tính bằng số commit |
| `--protect` | main,master,develop,trunk,gh-pages,release/*,hotfix/* | Các mẫu fnmatch, có phân biệt hoa thường |
| `--prefixes` | feature,fix,chore,... | Các tiền tố tên được phép đối với lỗi BAD_NAME |
| `--today` | today | Cố định ngày để tạo các báo cáo có thể lặp lại |

## Quyền riêng tư
Tên tác giả được lấy từ các commit. Chỉ chia sẻ danh sách theo từng chủ sở hữu trong nội bộ nhóm.
FILE:templates/cleanup-announcement.md
# Dọn dẹp nhánh trên <repository> - vui lòng kiểm tra trước <deadline date>

Chào cả nhóm,

Chúng ta hiện có <N> nhánh trên `<remote>`. Vào ngày <cleanup date>, chúng ta sẽ tiến hành dọn dẹp:

- **<n> nhánh đã gộp** sẽ bị xóa (các thay đổi của chúng đã có trong `<base>`).
- **<n> nhánh chưa gộp không hoạt động** (không có commit nào trong hơn <stale days> ngày) sẽ được lưu thành các thẻ `archive/<branch>` và sau đó bị xóa. Bạn có thể khôi phục bất kỳ nhánh nào trong số đó bằng lệnh `git push origin archive/<branch>:refs/heads/<branch>`.
- **<n> nhánh cần câu trả lời của bạn** (không có commit nào trong hơn <ask days> ngày): giữ lại, gộp, hay lưu trữ?

## Các nhánh của bạn
| Chủ sở hữu | Các nhánh | Cần bạn phản hồi |
|---|---|---|
| <name> | <branch, branch> | <giữ lại / lưu trữ / gộp trước thời hạn> |

## Vui lòng khắc phục
- <CASE_COLLISION: fix/search-timeout và fix/Search-Timeout - Lena, hãy xóa một nhánh ngay hôm nay>
- <PR_STALE: feature/checkout-redesign - Marco, hãy hoàn thiện, tách nhỏ hoặc đóng lại>

## Từ nay trở đi
- Tên nhánh: `<type>/<short-description>` (feature/, fix/, chore/, ...)
- Các nhánh đầu mút (head branch) sẽ tự động bị xóa sau khi gộp.
- Đợt kiểm toán này sẽ chạy vào thứ Hai đầu tiên của mỗi tháng.

Hãy phản hồi tại <channel/thread> nếu có nhánh nào bắt buộc phải giữ lại. Cảm ơn mọi người!
FILE:examples/webshop-branches.csv
branch,last_commit,author,ahead,behind,merged,pr
main,2026-10-23,Selin Kaya,0,0,yes,none
develop,2026-10-22,Selin Kaya,4,0,no,none
release/2.3,2025-11-12,Selin Kaya,2,611,no,none
release/2.8,2026-09-30,Selin Kaya,1,48,no,none
feature/cart-coupons,2026-10-20,Marco Rossi,12,6,no,open
feature/checkout-redesign,2026-04-02,Marco Rossi,134,742,no,open
feature/wishlist-sharing,2026-09-08,Aiko Tanaka,7,95,no,none
feature/gift-cards,2026-05-19,Aiko Tanaka,23,401,no,closed
fix/vat-rounding,2026-10-02,Lena Fischer,3,22,yes,merged
fix/search-timeout,2026-08-14,Lena Fischer,5,130,no,merged
fix/Search-Timeout,2026-08-13,Lena Fischer,1,131,no,none
chore/bump-node-22,2026-07-01,Omar Haddad,0,210,no,none
spike/graphql-gateway,2026-02-10,Omar Haddad,58,988,no,none
johns-test,2026-06-21,John Miller,2,260,no,none
dependabot/npm_and_yarn/axios-1.7.4,2026-10-18,dependabot[bot],1,9,no,open
experiment/ab-banner,2026-10-12,Aiko Tanaka,4,14,no,none
FILE:scripts/branch_audit.py
#!/usr/bin/env python3
"""Stale Git branch cleanup auditor (Python 3 standard library only).

Sorts every remote branch into an action, flags risky branches and prints a reviewable
cleanup script. Nothing is deleted: the script only prints commands.

Input, either:
  1. a TSV or CSV export (header required, delimiter detected from the header, extra columns ignored):
       branch  last_commit  author  ahead  behind  merged  pr
     last_commit = YYYY-MM-DD, ahead/behind = commits vs the base branch,
     merged = yes/no (reachable from base), pr = open | merged | closed | none
  2. --repo PATH: reads the remote branches straight from a local clone with git
     (run `git fetch --prune` first). PR state comes from --prs FILE (lines "branch<TAB>state",
     e.g. from `gh pr list --state all --json headRefName,state`), otherwise "none".
Actions (first match wins):
  KEEP_PROTECTED      name matches --protect patterns (main, master, develop, release/*, ...)
  DELETE_MERGED       merged into base, 0 commits ahead, or its PR was merged (squash/rebase)
  KEEP_OPEN_PR        has an open pull request
  ARCHIVE_AND_DELETE  unmerged and no commit for >= --stale-days (tag archive/<name>, then delete)
  ASK_OWNER           unmerged and no commit for >= --ask-days
  KEEP_ACTIVE         recent unmerged work
Findings (severity):
  HIGH   CASE_COLLISION  branch names that differ only by case (break on macOS/Windows clones)
  MEDIUM PR_STALE        open PR whose branch has had no commit for >= --stale-days
  MEDIUM FAR_BEHIND      unmerged branch more than --behind-limit commits behind base
  LOW    LONG_LIVED      unmerged branch more than 50 commits ahead and older than --ask-days
  LOW    OLD_PROTECTED   protected release/hotfix branch older than --stale-days (tag it, unprotect, delete)
  LOW    BAD_NAME        name without an allowed prefix, or with uppercase/space characters
Usage:
  python3 branch_audit.py branches.csv --today 2026-10-24
  python3 branch_audit.py --repo . --base main --prs prs.tsv
  python3 branch_audit.py branches.tsv --json
  python3 branch_audit.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import fnmatch
import json
import shlex
import subprocess
import sys

DEFAULT_PROTECT = "main,master,develop,trunk,gh-pages,release/*,hotfix/*"
DEFAULT_PREFIXES = "feature,feat,fix,bugfix,hotfix,release,chore,docs,refactor,test,ci,perf,build,deps,dependabot,renovate,experiment,spike"
ACTION_ORDER = ["DELETE_MERGED", "ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE", "KEEP_PROTECTED"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def yes(v):
    return str(v).strip().lower() in ("yes", "y", "true", "1")


def parse_tsv(text):
    lines = text.splitlines()
    delim = "\t" if lines and "\t" in lines[0] else ","
    rows = list(csv.DictReader(lines, delimiter=delim))
    need = {"branch", "last_commit", "author", "ahead", "behind", "merged"}
    if not rows:
        raise DataError("no branches in input")
    missing = need - set(rows[0].keys())
    if missing:
        raise DataError("missing columns: " + ", ".join(sorted(missing)))
    out = []
    for i, r in enumerate(rows, 2):
        try:
            out.append({"branch": r["branch"].strip(), "last_commit": dt.date.fromisoformat(r["last_commit"].strip()[:10]),
                        "author": r["author"].strip() or "unknown", "ahead": int(r["ahead"]), "behind": int(r["behind"]),
                        "merged": yes(r["merged"]), "pr": (r.get("pr") or "none").strip().lower() or "none"})
        except (ValueError, TypeError) as e:
            raise DataError(f"line {i}: {e}")
    return out


def git(repo, *args):
    r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
    if r.returncode != 0:
        raise DataError(f"git {' '.join(args)}: {r.stderr.strip()}")
    return r.stdout


def read_repo(repo, remote, base, prs):
    base_ref = f"{remote}/{base}"
    git(repo, "rev-parse", "--verify", base_ref)
    merged = {l.strip() for l in git(repo, "branch", "-r", "--merged", base_ref).splitlines()}
    out = []
    fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)"
    for line in git(repo, "for-each-ref", f"--format={fmt}", f"refs/remotes/{remote}").splitlines():
        ref, date, author = line.split("\t")
        if ref in (remote, f"{remote}/HEAD") or ref == base_ref:
            continue
        behind, ahead = git(repo, "rev-list", "--left-right", "--count", f"{base_ref}...{ref}").split()
        name = ref[len(remote) + 1:]
        out.append({"branch": name, "last_commit": dt.date.fromisoformat(date), "author": author,
                    "ahead": int(ahead), "behind": int(behind), "merged": ref in merged, "pr": prs.get(name, "none")})
    return out


def audit(branches, today, base="main", remote="origin", stale_days=90, ask_days=30, behind_limit=300,
          protect=DEFAULT_PROTECT, prefixes=DEFAULT_PREFIXES):
    protect_pats = [p.strip() for p in protect.split(",") if p.strip()]
    allowed = {p.strip() for p in prefixes.split(",") if p.strip()}
    findings, rows = [], []

    def add(sev, code, branch, msg):
        findings.append({"severity": sev, "code": code, "branch": branch, "message": msg})

    for b in branches:
        name, age = b["branch"], (today - b["last_commit"]).days
        prot = name == base or any(fnmatch.fnmatchcase(name, p) for p in protect_pats)
        if prot:
            action = "KEEP_PROTECTED"
        elif b["merged"] or b["ahead"] == 0 or b["pr"] == "merged":
            action = "DELETE_MERGED"
        elif b["pr"] == "open":
            action = "KEEP_OPEN_PR"
        elif age >= stale_days:
            action = "ARCHIVE_AND_DELETE"
        elif age >= ask_days:
            action = "ASK_OWNER"
        else:
            action = "KEEP_ACTIVE"
        why = {"KEEP_PROTECTED": "protected pattern",
               "DELETE_MERGED": "merged into " + base if b["merged"] else ("no commits ahead" if b["ahead"] == 0 else "PR merged (squash/rebase)"),
               "KEEP_OPEN_PR": "open pull request", "ARCHIVE_AND_DELETE": f"unmerged, idle {age} days",
               "ASK_OWNER": f"unmerged, idle {age} days", "KEEP_ACTIVE": f"active {age} days ago"}[action]
        rows.append({**b, "last_commit": b["last_commit"].isoformat(), "age_days": age, "action": action, "reason": why})
        unmerged = action in ("ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE")
        if action == "KEEP_OPEN_PR" and age >= stale_days:
            add("MEDIUM", "PR_STALE", name, f"open PR but no commit for {age} days; ask {b['author']} to finish or close it")
        if unmerged and b["behind"] > behind_limit:
            add("MEDIUM", "FAR_BEHIND", name, f"{b['behind']} commits behind {base}; rebase soon or expect painful conflicts")
        if unmerged and b["ahead"] > 50 and age >= ask_days:
            add("LOW", "LONG_LIVED", name, f"{b['ahead']} unmerged commits; split into smaller PRs or put it behind a feature flag")
        if prot and name != base and age >= stale_days and (name.startswith("release/") or name.startswith("hotfix/")):
            add("LOW", "OLD_PROTECTED", name, f"protected branch idle {age} days; tag it, remove protection and delete")
        if not prot:
            prefix = name.split("/", 1)[0] if "/" in name else ""
            if prefix not in allowed or name != name.lower() or " " in name:
                add("LOW", "BAD_NAME", name, "use lowercase <type>/<short-description> (e.g. feature/cart-coupons)")
    seen = {}
    for b in branches:
        seen.setdefault(b["branch"].lower(), []).append(b["branch"])
    for names in seen.values():
        if len(names) > 1:
            add("HIGH", "CASE_COLLISION", " / ".join(names), "names differ only by case; clones on macOS/Windows see one ref and fetches fail; delete or rename one")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["code"], f["branch"]))
    rows.sort(key=lambda r: (ACTION_ORDER.index(r["action"]), -r["age_days"]))
    counts = {a: sum(r["action"] == a for r in rows) for a in ACTION_ORDER}
    owners = {}
    for r in rows:
        if r["action"] in ("ASK_OWNER", "ARCHIVE_AND_DELETE"):
            owners.setdefault(r["author"], []).append(r["branch"])
    script = ["#!/bin/sh", "# Review before running. Generated by branch_audit.py; nothing has been deleted.", "set -e"]
    for r in rows:
        q = shlex.quote(r["branch"])
        if r["action"] == "DELETE_MERGED":
            script.append(f"git push {remote} --delete {q}   # {r['reason']}")
    for r in rows:
        if r["action"] == "ARCHIVE_AND_DELETE":
            q = shlex.quote(r["branch"])
            tag = shlex.quote("archive/" + r["branch"])
            script.append(f"git tag {tag} {shlex.quote(remote + '/' + r['branch'])} && git push {remote} {shlex.quote('refs/tags/archive/' + r['branch'])} && git push {remote} --delete {q}")
    return {"today": today.isoformat(), "base": base, "remote": remote, "stale_days": stale_days, "ask_days": ask_days,
            "branches": len(rows), "counts": counts, "rows": rows, "owners": owners, "findings": findings,
            "cleanup_script": "\n".join(script) + "\n"}


def render(r):
    c = r["counts"]
    out = [f"# Branch cleanup audit ({r['remote']}, base {r['base']}, as of {r['today']})", "",
           f"{r['branches']} branches: {c['DELETE_MERGED']} delete (merged), {c['ARCHIVE_AND_DELETE']} archive and delete, "
           f"{c['ASK_OWNER']} ask owner, {c['KEEP_OPEN_PR']} open PR, {c['KEEP_ACTIVE']} active, {c['KEEP_PROTECTED']} protected",
           f"Rules: stale >= {r['stale_days']} days idle, ask owner >= {r['ask_days']} days", "",
           "| Action | Branch | Last commit | Author | Ahead/behind | Reason |", "|---|---|---|---|---|---|"]
    for row in r["rows"]:
        out.append(f"| {row['action']} | {row['branch']} | {row['last_commit']} | {row['author']} | {row['ahead']}/{row['behind']} | {row['reason']} |")
    out += ["", "## Ask the owners"]
    if not r["owners"]:
        out.append("- nobody")
    for who, names in sorted(r["owners"].items()):
        out.append(f"- {who}: {', '.join(names)}")
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']} {f['branch']}: {f['message']}")
    out += ["", "## Cleanup script (review first, run after owners agree)", "```sh", r["cleanup_script"].rstrip(), "```"]
    return "\n".join(out) + "\n"


def self_test():
    tests, ok = [], 0

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    today = dt.date(2026, 10, 24)

    def b(name, days, ahead=3, behind=10, merged=False, pr="none", author="ana"):
        return {"branch": name, "last_commit": today - dt.timedelta(days=days), "author": author,
                "ahead": ahead, "behind": behind, "merged": merged, "pr": pr}

    def act(**kw):
        name = kw.pop("name", "feature/x")
        days = kw.pop("days", 10)
        return audit([b(name, days, **kw)], today)["rows"][0]["action"]

    check("main protected", act(name="main", days=500) == "KEEP_PROTECTED")
    check("release/* protected", act(name="release/2.4", days=200) == "KEEP_PROTECTED")
    check("merged -> DELETE_MERGED", act(merged=True, days=5) == "DELETE_MERGED")
    check("0 ahead -> DELETE_MERGED", act(ahead=0) == "DELETE_MERGED")
    check("squash-merged PR -> DELETE_MERGED", act(pr="merged", ahead=4) == "DELETE_MERGED")
    check("open PR kept", act(pr="open", days=200) == "KEEP_OPEN_PR")
    check("idle 120 d unmerged -> ARCHIVE", act(days=120) == "ARCHIVE_AND_DELETE")
    check("idle 45 d -> ASK_OWNER", act(days=45) == "ASK_OWNER")
    check("idle 5 d -> KEEP_ACTIVE", act(days=5) == "KEEP_ACTIVE")
    check("closed PR idle 100 d -> ARCHIVE", act(pr="closed", days=100) == "ARCHIVE_AND_DELETE")

    def codes(rows, **kw):
        return {f["code"] for f in audit(rows, today, **kw)["findings"]}

    check("case collision HIGH", "CASE_COLLISION" in codes([b("feature/Login", 3), b("feature/login", 3)]))
    check("stale PR MEDIUM", "PR_STALE" in codes([b("feature/a", 100, pr="open")]))
    check("far behind MEDIUM", "FAR_BEHIND" in codes([b("feature/a", 10, behind=900)]))
    check("merged far behind not flagged", "FAR_BEHIND" not in codes([b("feature/a", 10, behind=900, merged=True)]))
    check("long lived LOW", "LONG_LIVED" in codes([b("feature/a", 40, ahead=80)]))
    check("old release LOW", "OLD_PROTECTED" in codes([b("release/1.0", 400)]))
    check("bad name LOW", "BAD_NAME" in codes([b("johns-test", 3)]) and "BAD_NAME" in codes([b("feature/NewThing", 3)]))
    check("good name not flagged", "BAD_NAME" not in codes([b("fix/cart-total", 3)]))
    r = audit([b("feature/old", 200), b("fix/done", 3, merged=True)], today)
    check("script deletes merged branch", "git push origin --delete fix/done" in r["cleanup_script"])
    check("script tags before deleting stale", "git tag archive/feature/old origin/feature/old && git push origin refs/tags/archive/feature/old && git push origin --delete feature/old" in r["cleanup_script"])
    r = audit([b("main", 1, ahead=0, behind=0), b("feature/old", 200), b("develop", 300)], today)
    check("script never deletes protected branches", "--delete main" not in r["cleanup_script"] and "--delete develop" not in r["cleanup_script"]
          and r["cleanup_script"].rstrip().endswith("git push origin --delete feature/old"))
    r = audit([b("feature/it's", 200)], today)
    check("odd names are shell-quoted", "'feature/it'\"'\"'s'" in r["cleanup_script"])
    r = audit([b("feature/a", 200, author="ana"), b("feature/b", 40, author="ana"), b("feature/c", 2, author="bo")], today)
    check("owners grouped", r["owners"] == {"ana": ["feature/a", "feature/b"]})
    rows = parse_tsv("branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\nfeature/x\t2026-10-01\tAna\t2\t5\tno\topen\n")
    check("TSV parsed", rows[0]["ahead"] == 2 and rows[0]["pr"] == "open" and not rows[0]["merged"])
    rows = parse_tsv("branch,last_commit,author,ahead,behind,merged,pr\nfix/y,2026-09-01,Bo,0,3,yes,none\n")
    check("CSV parsed", rows[0]["branch"] == "fix/y" and rows[0]["merged"] and rows[0]["behind"] == 3)
    try:
        parse_tsv("branch\tauthor\nx\ty\n")
        check("missing columns rejected", False)
    except DataError:
        check("missing columns rejected", True)
    # real git repository round trip (skipped if git is missing)
    import os, shutil, tempfile
    if shutil.which("git"):
        d = tempfile.mkdtemp()
        try:
            env = {**os.environ, "GIT_AUTHOR_NAME": "Ana", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "Ana", "GIT_COMMITTER_EMAIL": "a@x"}
            run = lambda *a, cwd=d: subprocess.run(["git", *a], cwd=cwd, env=env, capture_output=True, check=True)
            os.makedirs(f"{d}/remote.git"); run("init", "-q", "--bare", "-b", "main", cwd=f"{d}/remote.git")
            run("clone", "-q", f"{d}/remote.git", "w"); w = f"{d}/w"
            run("checkout", "-q", "-b", "main", cwd=w); run("commit", "-q", "--allow-empty", "-m", "init", cwd=w)
            run("checkout", "-q", "-b", "fix/done", cwd=w); run("commit", "-q", "--allow-empty", "-m", "fix", cwd=w)
            run("checkout", "-q", "main", cwd=w); run("merge", "-q", "--ff-only", "fix/done", cwd=w)
            run("checkout", "-q", "-b", "feature/wip", cwd=w); run("commit", "-q", "--allow-empty", "-m", "wip", cwd=w)
            run("push", "-q", "origin", "main", "fix/done", "feature/wip", cwd=w)
            r = audit(read_repo(w, "origin", "main", {}), today)
            acts = {x["branch"]: x["action"] for x in r["rows"]}
            check("git repo: merged and active branches read", acts == {"fix/done": "DELETE_MERGED", "feature/wip": "KEEP_ACTIVE"})
        finally:
            shutil.rmtree(d, ignore_errors=True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Stale Git branch cleanup auditor")
    ap.add_argument("tsv", nargs="?", help="branch TSV or CSV export")
    ap.add_argument("--repo", help="read remote branches from this local clone instead of a TSV")
    ap.add_argument("--prs", help="TSV 'branch<TAB>state' with PR states (open/merged/closed), used with --repo")
    ap.add_argument("--remote", default="origin")
    ap.add_argument("--base", default="main")
    ap.add_argument("--today", help="YYYY-MM-DD (default: today)")
    ap.add_argument("--stale-days", type=int, default=90)
    ap.add_argument("--ask-days", type=int, default=30)
    ap.add_argument("--behind-limit", type=int, default=300)
    ap.add_argument("--protect", default=DEFAULT_PROTECT)
    ap.add_argument("--prefixes", default=DEFAULT_PREFIXES)
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    try:
        today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
        if a.repo:
            prs = {}
            if a.prs:
                with open(a.prs, encoding="utf-8") as fh:
                    for line in fh:
                        if "\t" in line:
                            k, v = line.rstrip("\n").split("\t", 1)
                            prs[k] = v.strip().lower()
            branches = read_repo(a.repo, a.remote, a.base, prs)
        elif a.tsv:
            with open(a.tsv, encoding="utf-8") as fh:
                branches = parse_tsv(fh.read())
        else:
            ap.error("give a TSV/CSV file or --repo PATH")
        r = audit(branches, today, a.base, a.remote, a.stale_days, a.ask_days, a.behind_limit, a.protect, a.prefixes)
    except (OSError, DataError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsProductivity+2
F@f
0
Công Cụ Tính Đơn Giá Theo Giờ Cho Freelancer
Kỹ năng

Tính toán mức đơn giá theo giờ và theo ngày mà một freelancer cần thu dựa trên mức thu nhập thực nhận mong muốn. Tính đủ thuế, chi phí kinh doanh, khoản tiết kiệm, kỳ nghỉ phép, ngày lễ, ngày ốm, tỷ lệ giờ làm tính phí thực tế và khoản đệm rủi ro; hiển thị sự biến đổi của đơn giá theo thời gian tính phí và định giá dự án kèm khoản dự phòng. Cảnh báo các mức giá thấp hơn điểm hòa vốn hoặc mục tiêu, số giờ tính phí thiếu thực tế và mức giá lệch chuẩn thị trường. Bao gồm công cụ tính viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: freelance-hourly-rate-calculator
description: Tính toán mức đơn giá theo giờ và theo ngày mà một freelancer cần thu dựa trên mức thu nhập thực nhận mong muốn. Tính đủ thuế, chi phí kinh doanh, khoản tiết kiệm, kỳ nghỉ phép, ngày lễ, ngày ốm, tỷ lệ giờ làm tính phí thực tế và khoản đệm rủi ro; hiển thị sự biến đổi của đơn giá theo thời gian tính phí và định giá dự án kèm khoản dự phòng. Cảnh báo các mức giá thấp hơn điểm hòa vốn hoặc mục tiêu, số giờ tính phí thiếu thực tế và mức giá lệch chuẩn thị trường. Bao gồm công cụ tính viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Công Cụ Tính Đơn Giá Theo Giờ Cho Freelancer

Bạn giúp các freelancer và những người sắp chuyển sang làm freelance tìm ra mức đơn giá đủ để trang trải cuộc sống. Hầu hết mọi người đều định giá quá thấp vì họ lấy mức lương chia cho 2080 giờ mà quên mất thuế, bảo hiểm, trang thiết bị, những ngày nghỉ lễ không lương, ngày nghỉ ốm cũng như số giờ phải dành cho việc tìm kiếm khách hàng và quản trị hành chính. Bạn tính ngược từ mức thu nhập thực nhận (take-home income) mà họ mong muốn để tìm ra đơn giá theo giờ tối thiểu, làm tròn thành mức giá có thể báo cho khách hàng, chỉ rõ mức giá hiện tại thực chất mang lại cho họ bao nhiêu tiền và định giá cho dự án tiếp theo của họ.

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

- `scripts/rate_calc.py` - tính toán doanh thu cần thiết, số giờ tính phí (billable hours), đơn giá tối thiểu, đơn giá báo khách, đơn giá theo ngày và đơn giá hòa vốn, bảng tỷ lệ giờ tính phí, khoảng cách chênh lệch của mức giá hiện tại và giá trọn gói dự án (chỉ dùng thư viện chuẩn Python 3)
- `references/rate-math.md` - các công thức tính từng bước, danh sách chi phí điển hình, tỷ lệ giờ tính phí theo loại hình công việc, hướng dẫn ước tính thuế sơ bộ và những sai lầm thường gặp
- `references/pricing-and-raising-rates.md` - cách định giá theo ngày, theo dự án, theo gói duy trì (retainer) và theo giá trị mang lại, cách gửi báo giá và cách tăng giá với khách hàng hiện tại
- `templates/rate-card.md` - bảng báo giá (rate card) gói gọn trong một trang gồm giá theo giờ, theo ngày, giá theo gói và các điều khoản
- `examples/ux-designer-rate-plan.json` - ví dụ về một nhà thiết kế UX tại Berlin muốn thu nhập thực nhận 48.000 EUR và hiện đang tính phí 65 EUR/giờ

## Quy trình thực hiện

### 1. Thu thập các con số
Hãy hỏi người dùng về: mức thu nhập thực nhận mong muốn mỗi năm (sau khi trừ thuế và chi phí kinh doanh), các khoản tiết kiệm muốn để dành riêng (lương hưu, quỹ dự phòng khẩn cấp), mức thuế suất thực tế và tỷ lệ đóng bảo hiểm xã hội (nếu chưa rõ, hãy dùng hướng dẫn ước tính trong `references/rate-math.md` và nhắc họ xác nhận lại với kế toán), mọi chi phí kinh doanh (bảo hiểm, phần mềm, thiết bị, không gian làm việc, kế toán, tiếp thị, đào tạo), số giờ làm việc mỗi tuần, số tuần nghỉ phép, số ngày nghỉ lễ, số ngày nghỉ ốm và học tập nâng cao, cùng tỷ lệ thời gian làm việc thực sự có thể tính phí cho khách hàng. Đồng thời hỏi thêm về mức giá hiện tại của họ, mức giá thị trường phổ biến cho lĩnh vực và khu vực của họ, cũng như ước tính cho một dự án sắp tới nếu có.

### 2. Chạy công cụ tính toán
```
python3 scripts/rate_calc.py examples/ux-designer-rate-plan.json
python3 scripts/rate_calc.py plan.json --round 10 --json
python3 scripts/rate_calc.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có phát hiện mức độ HIGH, 2 nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Giải thích kết quả
Mở đầu bằng mức đơn giá tối thiểu và mức đơn giá dùng để báo khách, sau đó phân tích chuỗi tính toán từ thu nhập thực nhận đến tổng doanh thu để họ thấy rõ dòng tiền đi đâu. Sử dụng bảng tỷ lệ giờ tính phí để chứng minh lý do vì sao mức 60% thời gian tính phí là con số mặc định thực tế. Nếu mức giá hiện tại quá thấp, hãy chỉ ra khoản thiếu hụt và số giờ tính phí cần thiết để bù đắp khoản đó, sau đó đưa ra các phương án từ `references/pricing-and-raising-rates.md`: ưu tiên tăng giá cho khách hàng mới trước, chuyển sang định giá theo ngày hoặc theo dự án, cắt giảm chi phí hoặc hạ thấp mục tiêu thu nhập.

### 4. Bàn giao
Điền vào mẫu `templates/rate-card.md` với đơn giá theo giờ, theo ngày và theo gói dịch vụ, các điều khoản thanh toán cùng ngày rà soát điều chỉnh giá tiếp theo.

## Ví dụ kết quả
Đối với `examples/ux-designer-rate-plan.json`: 48.000 EUR thu nhập thực nhận + 7.800 EUR khoản để dành = 83.284 EUR lợi nhuận trước mức thuế 33%; + 13.500 EUR chi phí và 8% khoản đệm rủi ro = 105.200 EUR doanh thu. 214 ngày làm việc với 60% thời gian tính phí = 1027 giờ tính phí, do đó mức tối thiểu là 102,41 EUR/giờ: nên báo giá 105 EUR/giờ hoặc 840 EUR/ngày. Với mức giá hiện tại 65 EUR/giờ, thu nhập thực nhận sẽ chỉ đạt 24.311 EUR (thiếu hụt 23.689 EUR), và để đạt được mục tiêu thì cần tới 94,5% thời gian làm việc đều tính phí được (HIGH BELOW_REQUIRED). Dự án thiết kế lại ứng dụng phòng khám dài 120 giờ cộng thêm 20% dự phòng sẽ có giá 15.120 EUR thay vì 9.360 EUR.

## Quy tắc

- Các con số về thuế chỉ là ước tính sơ bộ, không phải tư vấn thuế chuyên nghiệp; hãy nhắc người dùng xác nhận mức thuế suất với kế toán tại địa phương.
- Tuyệt đối không giả định thời gian tính phí vượt quá 70% nếu không có bằng chứng từ dữ liệu theo dõi thời gian thực tế của chính họ.
- Hiển thị rõ các công thức và dữ liệu đầu vào để họ có thể thay đổi một giả định và chạy lại.
- Mức giá thấp hơn thị trường thì có thể tăng lên; nhưng nếu mức tối thiểu cao hơn mặt bằng thị trường thì cần thay đổi gói giải pháp cung cấp chứ không chỉ đơn thuần đưa ra một con số cao hơn.
- Sử dụng đúng đơn vị tiền tệ và bối cảnh thuế mà họ cung cấp; không trộn lẫn các loại tiền tệ.
FILE:references/rate-math.md
# Công Thức Tính Đơn Giá

## Chuỗi công thức chi tiết từng bước
1. **Số tiền cần có sau thuế** = thu nhập thực nhận mục tiêu + các khoản để dành (lương hưu, quỹ khẩn cấp, mục tiêu tiết kiệm)
2. **Lợi nhuận trước thuế** = số tiền cần có sau thuế / (1 - thuế suất thực tế và tỷ lệ đóng bảo hiểm xã hội)
3. **Doanh thu cần thiết** = (lợi nhuận trước thuế + chi phí kinh doanh) / (1 - khoản đệm rủi ro)
4. **Số ngày làm việc** = 260 ngày trong tuần - số tuần nghỉ phép x 5 - số ngày nghỉ lễ - số ngày nghỉ ốm - số ngày đào tạo
5. **Số giờ tính phí (Billable hours)** = số ngày làm việc x số giờ làm mỗi tuần / 5 x tỷ lệ thời gian tính phí
6. **Đơn giá tối thiểu** = doanh thu cần thiết / số giờ tính phí
7. **Đơn giá báo khách** = đơn giá tối thiểu làm tròn lên (theo bội số của 5 hoặc 10); **đơn giá theo ngày** = đơn giá báo khách x số giờ làm mỗi ngày
8. **Đơn giá hòa vốn** = chi phí kinh doanh / (1 - khoản đệm) / số giờ tính phí (chỉ đủ trả các hóa đơn chi phí, bạn không nhận được đồng nào)

Kiểm tra ngược: thu nhập thực nhận tại mức giá R = (R x số giờ tính phí x (1 - khoản đệm) - chi phí) x (1 - thuế) - các khoản để dành.

## Tại sao công thức "lương / 2080" lại sai lầm
2080 = 52 tuần x 40 giờ. Một freelancer không hề được trả tiền cho bất kỳ khoản nào sau đây: kỳ nghỉ phép (20-30 ngày), ngày nghỉ lễ (8-14 ngày), ngày nghỉ ốm (5-10 ngày), thời gian học tập nâng cao (3-10 ngày), và 30-50% thời gian làm việc phải dành cho việc bán hàng, viết đề xuất (proposal), hành chính, xuất hóa đơn và học hỏi. Mức lương 60.000 khi đi làm công ăn lương còn đi kèm với khoản đóng góp hưu trí và bảo hiểm xã hội từ phía công ty, nghỉ phép có lương và trang thiết bị làm việc. Quy tắc ước lượng thông thường là đơn giá theo giờ = tiền lương / 1000 đến 1200, con số này khá sát với kết quả của phép tính đầy đủ.

## Tỷ lệ giờ tính phí theo loại hình công việc
| Tình huống | Tỷ lệ giờ tính phí thực tế |
|---|---|
| Freelancer mới, vẫn đang tìm kiếm khách hàng | 35-50% |
| Kết hợp nhiều dự án, có khách hàng quen | 50-65% |
| Hợp đồng toàn thời gian dài hạn với một khách hàng | 70-85% (nhưng hoạt động bán hàng bị ngưng trệ, và khoảng trống sau khi kết thúc hợp đồng thường kéo dài) |
| Mô hình kiểu agency với khối lượng quản lý tài khoản khách hàng lớn | 45-60% |

Cách đo lường: theo dõi mọi giờ làm việc trong vòng 4-8 tuần, sau đó sử dụng tỷ lệ thực tế thu được.

## Các chi phí kinh doanh điển hình
- Bảo hiểm y tế, bảo hiểm mất khả năng lao động và bảo hiểm trách nhiệm nghề nghiệp (khi phải tự đóng)
- Khoản đóng quỹ hưu trí nếu bắt buộc (hoặc đưa vào mục các khoản để dành)
- Phần cứng tính theo chu kỳ thay mới (laptop 3 năm, điện thoại 2-3 năm), thuê bao phần mềm, dịch vụ đám mây và tên miền
- Không gian làm việc: văn phòng chia sẻ (coworking), phần chi phí văn phòng tại nhà, internet, điện thoại
- Kế toán, phí ngân hàng, phí cổng thanh toán, công cụ xuất hóa đơn
- Tiếp thị: trang web, hồ sơ năng lực (portfolio), phí nền tảng, sự kiện, chi phí đi lại gặp khách hàng
- Đào tạo: khóa học, sách vở, hội thảo
- Phí thành viên hiệp hội nghề nghiệp, giấy phép, mẫu hợp đồng pháp lý

## Thuế suất thực tế và tỷ lệ đóng bảo hiểm (chỉ mang tính tham khảo sơ bộ)
Tỷ lệ thực tế là tổng thuế thu nhập và các khoản đóng bảo hiểm xã hội bắt buộc chia cho lợi nhuận. Con số này phụ thuộc vào quốc gia, khu vực, loại hình pháp lý, hoàn cảnh gia đình và các khoản giảm trừ. Ước tính sơ bộ ban đầu cho một người độc thân có lợi nhuận khoảng 60.000-100.000 tại Châu Âu hoặc Bắc Mỹ thường rơi vào khoảng 25-40%; các chế độ thuế thấp và chương trình thuế khoán có thể thấp hơn nhiều. Luôn luôn xác nhận với kế toán địa phương và thay thế con số ước tính này.

## Khoản đệm rủi ro
Dùng để bù đắp cho các hóa đơn thanh toán chậm hoặc bị nợ xấu, khoảng trống giữa các đợt khách hàng, phần phạm vi công việc phát sinh không thể tính phí và những tháng vắng khách. Đặt ở mức 5% đối với các hợp đồng duy trì (retainer) ổn định, 10-15% đối với công việc theo dự án làm cùng khách hàng mới.

## Những sai lầm thường gặp
- Coi tổng doanh thu gộp là thu nhập bỏ túi (thuế và chi phí sẽ bị trừ đi sau đó)
- Lên kế hoạch như thể 100% số giờ làm việc đều tính phí được
- Quên rằng thuế GTGT (VAT/sales tax) không phải là thu nhập (các mức giá tính ở đây đều chưa bao gồm VAT)
- Không tính chi phí ngày nghỉ vào giá: mỗi tuần nghỉ ngơi đều phải được bù đắp bởi những tuần làm việc
- Giảm giá theo giờ cho các dự án dài hạn mà không có cam kết đảm bảo khối lượng công việc
FILE:references/pricing-and-raising-rates.md
# Các Mô Hình Định Giá Và Cách Tăng Giá

## Lựa chọn mô hình định giá
| Mô hình | Khi nào nên dùng | Cách tính từ đơn giá tối thiểu |
|---|---|---|
| Theo giờ (Hourly) | Phạm vi chưa rõ ràng, công việc hỗ trợ, chỉnh sửa nhỏ | Đơn giá báo khách; tính phí theo từng bước 15 hoặc 30 phút |
| Theo ngày (Day rate) | Buổi workshop, làm việc cắm chốt cùng nhóm khách hàng, hợp đồng ngắn hạn | Đơn giá báo khách x số giờ mỗi ngày; nửa ngày = 55-60% của cả ngày (do chi phí chuyển đổi ngữ cảnh) |
| Theo dự án (Giá cố định) | Đầu mục bàn giao rõ ràng, bạn ước lượng thời gian chuẩn xác | Số giờ ước tính x (1 + tỷ lệ dự phòng) x đơn giá báo khách; liệt kê rõ những gì bao gồm trong gói và số vòng chỉnh sửa |
| Gói duy trì (Retainer) | Công việc diễn ra đều đặn hàng tháng | Số giờ đặt trước mỗi tháng x đơn giá báo khách, thường giảm 5-10% nhờ khối lượng được cam kết; số giờ không dùng hết sẽ hết hạn |
| Dựa trên giá trị (Value-based) | Công việc của bạn tạo ra doanh thu hoặc khoản tiết kiệm đo lường được | 5-20% giá trị mang lại trong năm đầu tiên, tuyệt đối không thấp hơn giá tính theo dự án |

Tỷ lệ dự phòng (contingency) cho giá cố định: 10-15% đối với công việc bạn đã làm nhiều lần, 20-30% đối với loại công việc mới, 30-50% khi yêu cầu chưa rõ ràng hoặc có nhiều bên liên quan phê duyệt.

## Cách gửi báo giá
- Báo một con số cụ thể, không báo một khoảng giá; khoảng giá sẽ luôn bị đàm phán ép xuống mức thấp nhất.
- Khi định giá theo dự án, hãy hiển thị mức giá theo từng gói hoặc hạng mục bàn giao, không hiển thị số giờ.
- Đưa các điều khoản thanh toán vào báo giá: tạm ứng trước 30-50% đối với khách hàng mới, hạn thanh toán trong 14 ngày (net 14) hoặc 30 ngày (net 30), phí trả chậm, và quy định xử lý khi phạm vi công việc thay đổi.
- Nêu rõ đơn giá chưa bao gồm thuế GTGT (VAT/sales tax).

## Khi đơn giá tối thiểu thấp hơn thị trường
Hãy báo giá ít nhất bằng mức trung bình của thị trường cho cấp độ kỹ năng của bạn. Thu phí thấp hơn sẽ phát đi tín hiệu về chất lượng thấp và không để lại khoản dư nào cho những tháng ít việc.

## Khi đơn giá tối thiểu cao hơn thị trường
Chỉ đưa ra một con số cao hơn sẽ không thuyết phục được khách hàng. Các phương án giải quyết:
1. Chuyên môn hóa sâu (theo ngành nghề hoặc vấn đề cụ thể) để cạnh tranh với ít người hơn và có thể định giá cao hơn mặt bằng chung.
2. Đóng gói theo kết quả đầu ra và áp dụng định giá theo dự án hoặc theo giá trị.
3. Cắt giảm những chi phí không giúp mang lại công việc.
4. Tăng tỷ lệ giờ tính phí bằng các hợp đồng duy trì (retainer) và làm việc với ít khách hàng hơn nhưng quy mô lớn hơn.
5. Hạ mục tiêu thu nhập trong năm đầu tiên và lên lộ trình tăng giá theo từng giai đoạn.

## Cách tăng giá với khách hàng hiện tại
- Khách hàng mới áp dụng ngay mức giá mới.
- Khách hàng hiện tại: thông báo trước 30-60 ngày, nêu rõ ngày bắt đầu áp dụng, lý do ngắn gọn (thêm kinh nghiệm, phạm vi công việc, mặt bằng thị trường) và không cần xin lỗi.
- Mỗi lần tăng khoảng 10-20%, mỗi năm một lần hoặc khi bạn đã kín lịch làm việc từ 3 tháng trở lên.
- Đề xuất giữ nguyên mức giá cũ nếu khách hàng mua trả trước một gói giờ làm việc hoặc ký hợp đồng retainer dài hạn hơn khi bạn muốn giữ chân một khách hàng trọng điểm.

Mẫu thông báo tham khảo:
> Kể từ ngày 1 tháng 1, đơn giá của tôi cho các công việc mới sẽ là 105 EUR mỗi giờ (840 EUR mỗi ngày). Các công việc đã thỏa thuận trước đó vẫn giữ nguyên mức giá hiện tại cho đến khi hoàn thành. Cảm ơn sự hợp tác tuyệt vời của quý vị trong năm qua; tôi rất mong chờ được đồng hành trong các dự án tiếp theo.

## Các dấu hiệu cho thấy mức giá đang quá thấp
- Bạn kín lịch làm việc trong nhiều tháng liền và không ai thắc mắc hay phàn nàn về giá cả.
- Bạn thắng thầu gần như mọi bản đề xuất (proposal) gửi đi.
- Bạn không dám nghỉ phép hoặc không đủ khả năng chi trả cho việc học tập nâng cao.
FILE:templates/rate-card.md
# Bảng Báo Giá (Rate Card) - <name>, <role>

**Có hiệu lực từ:** <date>  **Kỳ rà soát tiếp theo:** <date + 12 months>  **Tiền tệ:** <EUR>, giá chưa bao gồm VAT

## Đơn giá cơ bản
| Dịch vụ | Mức giá |
|---|---|
| Đơn giá theo giờ | <105> mỗi giờ |
| Đơn giá theo ngày (8 giờ) | <840> mỗi ngày |
| Nửa ngày (4 giờ) | <480> |
| Gói duy trì hàng tháng (<20> giờ, giờ không dùng hết sẽ hết hạn) | <2,000> mỗi tháng |

## Các gói dịch vụ trọn gói
| Gói dịch vụ | Bao gồm | Mức giá |
|---|---|---|
| <UX audit> | <đánh giá chuyên sâu 10 màn hình chính, báo cáo sắp xếp theo mức độ ưu tiên, cuộc gọi trao đổi 1 giờ> | <2,400> |
| <App redesign> | <nghiên cứu, wireframe, thiết kế UI cho <n> màn hình, 2 vòng chỉnh sửa, hệ thống thiết kế (design system)> | <15,120> |

## Điều khoản
- Thanh toán: <tạm ứng 40%, phần còn lại khi bàn giao>, hóa đơn thanh toán trong vòng <14> ngày; phí trả chậm <x%> mỗi tháng
- Thay đổi phạm vi công việc: báo giá riêng trước khi bắt đầu thực hiện
- Chỉnh sửa: bao gồm <2> vòng chỉnh sửa, sau đó tính phí theo giờ
- Hủy dịch vụ: <thời gian báo trước / phí hủy>
- Đi lại: <ngoài thành phố tính theo chi phí thực tế + thời gian di chuyển tính bằng 50% đơn giá>

## Các con số hậu trường (riêng tư, không gửi cho khách hàng)
- Đơn giá tối thiểu: <102.41>/giờ ở mức <60>% thời gian tính phí (<1027> giờ/năm)
- Đơn giá hòa vốn: <14.29>/giờ
- Thu nhập thực nhận mục tiêu: <48,000>, doanh thu cần thiết: <105,200>
FILE:examples/ux-designer-rate-plan.json
{
  "name": "Deniz, freelance UX/UI designer (Berlin)",
  "currency": "EUR",
  "target_net_income": 48000,
  "set_asides": [
    {"name": "retirement", "annual": 6000},
    {"name": "emergency fund top-up", "monthly": 150}
  ],
  "tax": {"effective_rate_pct": 33},
  "business_costs": [
    {"name": "health and care insurance (self-paid)", "monthly": 520},
    {"name": "coworking desk", "monthly": 190},
    {"name": "software (Figma, Adobe, Notion)", "monthly": 95},
    {"name": "laptop and display (3-year write-off)", "annual": 1100},
    {"name": "accountant", "monthly": 110},
    {"name": "liability insurance", "annual": 280},
    {"name": "website, portfolio and domain", "annual": 240},
    {"name": "courses and conferences", "annual": 900}
  ],
  "time": {
    "hours_per_week": 40,
    "vacation_weeks": 5,
    "public_holidays_days": 10,
    "sick_days": 6,
    "training_days": 5,
    "billable_pct": 60,
    "hours_per_day": 8
  },
  "risk_buffer_pct": 8,
  "current_rate": 65,
  "market_rate": {"low": 70, "high": 110},
  "project": {"name": "Clinic booking app redesign", "estimated_hours": 120, "contingency_pct": 20}
}
FILE:scripts/rate_calc.py
#!/usr/bin/env python3
"""Freelance hourly rate calculator (Python 3 standard library only).

Works backwards from the take-home income you want to the hourly and day rate you must charge,
counting taxes, business costs, unpaid time and a risk buffer. Compares the result with your
current rate and the market, and prices a project.

Input JSON (amounts per year unless "monthly" is given):
  {"name": "...", "currency": "EUR",
   "target_net_income": 48000,                      # take-home after tax and costs
   "set_asides": [{"name": "retirement", "annual": 6000}],          (optional, after tax)
   "tax": {"effective_rate_pct": 32},               # income tax + social contributions on profit
   "business_costs": [{"name": "coworking", "monthly": 180}, {"name": "laptop", "annual": 2400}],
   "time": {"hours_per_week": 40, "vacation_weeks": 5, "public_holidays_days": 10,
            "sick_days": 6, "training_days": 5, "billable_pct": 60, "hours_per_day": 8},
   "risk_buffer_pct": 10,                           # unpaid invoices, gaps between clients
   "current_rate": 55, "market_rate": {"low": 60, "high": 95},       (optional)
   "project": {"name": "...", "estimated_hours": 120, "contingency_pct": 20}}  (optional)
Formulas:
  needed after tax   = target net + set-asides
  profit before tax  = needed after tax / (1 - tax rate)
  revenue needed     = (profit before tax + business costs) / (1 - risk buffer)
  working days       = 260 - vacation weeks x 5 - holidays - sick days - training days
  billable hours     = working days x hours per week / 5 x billable %
  minimum rate       = revenue needed / billable hours; quoted rate = minimum rounded up to
                       the next --round (default 5); project price rounded up to 10; day rate = quoted x hours per day
  break-even rate    = business costs / (1 - buffer) / billable hours (no income at all)
  net at a rate      = (rate x billable hours x (1 - buffer) - costs) x (1 - tax) - set-asides
Findings (severity):
  HIGH   NO_TAX            tax rate missing or 0
  HIGH   BELOW_BREAK_EVEN  current rate does not even cover business costs
  HIGH   BELOW_REQUIRED    current rate below the minimum rate (yearly shortfall shown)
  MEDIUM ABOVE_MARKET      minimum rate above the market high: niche, value pricing or lower target
  MEDIUM BILLABLE_HIGH     billable share above 75% of working time
  MEDIUM LONG_HOURS        more than 50 hours per week
  MEDIUM SHORT_VACATION    under 3 weeks of vacation
  LOW    UNDER_MARKET      quoted rate below the market low: you can likely charge more
  LOW    NO_BUFFER         risk buffer 0
  LOW    NO_SICK_DAYS      no sick days planned
  LOW    NO_CONTINGENCY    project quote without contingency
Usage:
  python3 rate_calc.py plan.json
  python3 rate_calc.py plan.json --round 10 --json
  python3 rate_calc.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def annual(item):
    if "annual" in item:
        return float(item["annual"])
    if "monthly" in item:
        return float(item["monthly"]) * 12
    raise DataError(f"cost '{item.get('name', '?')}' needs 'annual' or 'monthly'")


def round_up(x, step):
    return math.ceil(x / step - 1e-9) * step


def calc(cfg, step=5):
    t = cfg.get("time") or {}
    hpw = float(t.get("hours_per_week", 40))
    hpd = float(t.get("hours_per_day", hpw / 5))
    billable = float(t.get("billable_pct", 60)) / 100
    vac, hol = float(t.get("vacation_weeks", 4)), float(t.get("public_holidays_days", 10))
    sick, train = float(t.get("sick_days", 5)), float(t.get("training_days", 0))
    tax = float((cfg.get("tax") or {}).get("effective_rate_pct", 0)) / 100
    buffer = float(cfg.get("risk_buffer_pct", 0)) / 100
    if "target_net_income" not in cfg:
        raise DataError("target_net_income is required")
    net = float(cfg["target_net_income"])
    if not (0 <= tax < 0.9) or not (0 <= buffer < 0.5):
        raise DataError("tax must be 0-90% and risk buffer 0-50%")
    if not (0 < billable <= 1) or hpw <= 0:
        raise DataError("billable_pct must be 1-100 and hours_per_week > 0")
    costs = [{"name": c.get("name", "cost"), "annual": annual(c)} for c in cfg.get("business_costs", [])]
    sets = [{"name": s.get("name", "set-aside"), "annual": annual(s)} for s in cfg.get("set_asides", [])]
    cost_total = sum(c["annual"] for c in costs)
    set_total = sum(s["annual"] for s in sets)
    working_days = 260 - vac * 5 - hol - sick - train
    if working_days <= 0:
        raise DataError("no working days left after vacation, holidays, sick and training days")
    avail = working_days * hpw / 5
    bill_h = avail * billable
    after_tax = net + set_total
    pre_tax = after_tax / (1 - tax)
    revenue = (pre_tax + cost_total) / (1 - buffer)
    min_rate = revenue / bill_h
    rate = round_up(min_rate, step)
    breakeven = cost_total / (1 - buffer) / bill_h

    def net_at(r, hours=bill_h):
        return (r * hours * (1 - buffer) - cost_total) * (1 - tax) - set_total

    sens = []
    for pct in (40, 50, 60, 70, 80):
        h = avail * pct / 100
        sens.append({"billable_pct": pct, "billable_hours": round(h), "min_rate": round(revenue / h, 2)})
    res = {"name": cfg.get("name", "freelancer"), "currency": cfg.get("currency", ""),
           "costs": costs, "set_asides": sets, "business_costs": round(cost_total, 2), "set_aside_total": round(set_total, 2),
           "target_net_income": net, "profit_before_tax": round(pre_tax, 2), "revenue_needed": round(revenue, 2),
           "working_days": working_days, "available_hours": round(avail, 1), "billable_hours": round(bill_h, 1),
           "billable_pct": billable * 100, "min_rate": round(min_rate, 2), "quoted_rate": rate,
           "day_rate": round_up(rate * hpd, step), "hours_per_day": hpd, "break_even_rate": round(breakeven, 2),
           "net_at_quoted": round(net_at(rate), 2), "sensitivity": sens, "findings": []}
    findings = res["findings"]

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

    cur = cfg.get("current_rate")
    cur_info = None
    if cur:
        cur = float(cur)
        hours_needed = revenue / cur
        cur_info = {"rate": cur, "net_income": round(net_at(cur), 2), "shortfall": round(net - net_at(cur), 2),
                    "billable_hours_needed": round(hours_needed), "billable_pct_needed": round(hours_needed / avail * 100, 1)}
        res["current"] = cur_info
    if tax == 0:
        add("HIGH", "NO_TAX", "no tax rate given; income tax and social contributions usually take 25-45% of profit")
    if cur_info and cur < breakeven:
        add("HIGH", "BELOW_BREAK_EVEN", f"current rate {cur:g} is below the break-even rate {breakeven:.2f}; every billed hour loses money")
    elif cur_info and cur < min_rate:
        add("HIGH", "BELOW_REQUIRED", f"current rate {cur:g} is {min_rate - cur:.2f} below the minimum {min_rate:.2f}; take-home falls {cur_info['shortfall']:,.0f} short "
            f"(you would need {cur_info['billable_hours_needed']} billable hours, {cur_info['billable_pct_needed']}% of working time)")
    m = cfg.get("market_rate") or {}
    if m.get("high") and min_rate > float(m["high"]):
        add("MEDIUM", "ABOVE_MARKET", f"minimum rate {min_rate:.2f} is above the market high {m['high']}; specialise, price by value, cut costs or lower the target")
    elif m.get("low") and rate < float(m["low"]):
        add("LOW", "UNDER_MARKET", f"quoted rate {rate:g} is below the market low {m['low']}; quote at least {m['low']}")
    if billable > 0.75:
        add("MEDIUM", "BILLABLE_HIGH", f"{billable * 100:.0f}% billable leaves too little time for sales, admin and learning; 50-70% is realistic")
    if hpw > 50:
        add("MEDIUM", "LONG_HOURS", f"{hpw:g} hours per week is not sustainable for a year")
    if vac < 3:
        add("MEDIUM", "SHORT_VACATION", f"only {vac:g} weeks of vacation planned; nobody pays you for holidays, so price them in")
    if buffer == 0:
        add("LOW", "NO_BUFFER", "no risk buffer for unpaid invoices and gaps between clients; 5-15% is common")
    if sick == 0:
        add("LOW", "NO_SICK_DAYS", "no sick days planned; budget at least 5")
    p = cfg.get("project")
    if p:
        hrs = float(p.get("estimated_hours", 0))
        cont = float(p.get("contingency_pct", 0)) / 100
        res["project"] = {"name": p.get("name", "project"), "estimated_hours": hrs, "contingency_pct": cont * 100,
                          "quoted_hours": round(hrs * (1 + cont), 1), "price": round_up(hrs * (1 + cont) * rate, 10),
                          "price_at_current": round_up(hrs * (1 + cont) * cur, 10) if cur else None}
        if cont == 0:
            add("LOW", "NO_CONTINGENCY", "project estimate has no contingency; add 15-30% for unknowns and revisions")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return res


def money(x, cur=""):
    return f"{x:,.0f} {cur}".strip() if abs(x) >= 100 else f"{x:,.2f} {cur}".strip()


def rate_fmt(x, cur=""):
    return f"{x:,.2f} {cur}".strip()


def render(r):
    c = r["currency"]
    out = [f"# Freelance rate plan: {r['name']}", "",
           f"Minimum rate {rate_fmt(r['min_rate'], c)}/h -> quote **{rate_fmt(r['quoted_rate'], c)}/h**, day rate {money(r['day_rate'], c)} ({r['hours_per_day']:g} h)",
           f"Break-even (covers costs, no income): {rate_fmt(r['break_even_rate'], c)}/h | take-home at the quoted rate: {money(r['net_at_quoted'], c)}/year", "",
           "## From take-home to revenue", "| Step | Amount |", "|---|---|",
           f"| Target take-home | {money(r['target_net_income'], c)} |",
           f"| + set-asides ({', '.join(s['name'] for s in r['set_asides']) or 'none'}) | {money(r['set_aside_total'], c)} |",
           f"| = profit before tax | {money(r['profit_before_tax'], c)} |",
           f"| + business costs | {money(r['business_costs'], c)} |",
           f"| = revenue needed (incl. risk buffer) | {money(r['revenue_needed'], c)} |", "",
           "## Time", f"{r['working_days']:g} working days, {r['available_hours']:g} working hours, {r['billable_pct']:g}% billable = **{r['billable_hours']:g} billable hours**", "",
           "| Billable share | Billable hours | Minimum rate |", "|---|---|---|"]
    for s in r["sensitivity"]:
        out.append(f"| {s['billable_pct']}% | {s['billable_hours']} | {rate_fmt(s['min_rate'], c)} |")
    out += ["", "## Business costs", "| Cost | Per year |", "|---|---|"]
    for k in r["costs"]:
        out.append(f"| {k['name']} | {money(k['annual'], c)} |")
    if r.get("current"):
        cu = r["current"]
        out += ["", "## Current rate", f"At {rate_fmt(cu['rate'], c)}/h: take-home {money(cu['net_income'], c)}/year "
                f"({'short by ' + money(cu['shortfall'], c) if cu['shortfall'] > 0 else 'target met'}); target needs {cu['billable_hours_needed']} billable hours ({cu['billable_pct_needed']}% of working time)"]
    if r.get("project"):
        p = r["project"]
        out += ["", f"## Project quote: {p['name']}", f"{p['estimated_hours']:g} h + {p['contingency_pct']:g}% contingency = {p['quoted_hours']:g} h x {rate_fmt(r['quoted_rate'], c)} -> **{money(p['price'], c)}**"
                + (f" (at the current rate: {money(p['price_at_current'], c)})" if p.get("price_at_current") else "")]
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']}: {f['message']}")
    return "\n".join(out) + "\n"


def self_test():
    tests, ok = [], 0

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    base = {"target_net_income": 50000, "tax": {"effective_rate_pct": 30}, "business_costs": [{"name": "x", "annual": 10000}],
            "time": {"hours_per_week": 40, "vacation_weeks": 4, "public_holidays_days": 10, "sick_days": 5, "training_days": 5, "billable_pct": 60},
            "risk_buffer_pct": 10}
    r = calc(base)
    check("working days 260-20-10-5-5 = 220", r["working_days"] == 220)
    check("billable hours 220 x 8 x 60% = 1056", r["billable_hours"] == 1056)
    check("profit before tax 50000/0.7", abs(r["profit_before_tax"] - 71428.57) < 0.01)
    check("revenue (71428.57+10000)/0.9", abs(r["revenue_needed"] - 90476.19) < 0.01)
    check("minimum rate 90476.19/1056 = 85.68", abs(r["min_rate"] - 85.68) < 0.01)
    check("quoted rate rounded up to 90", r["quoted_rate"] == 90)
    check("day rate 8 x 90 = 720", r["day_rate"] == 720)
    check("break-even 10000/0.9/1056 = 10.52", abs(r["break_even_rate"] - 10.52) < 0.01)
    check("net at the minimum rate equals target", abs(((r["min_rate"] * 1056 * 0.9 - 10000) * 0.7) - 50000) < 10)
    check("clean plan has no findings", r["findings"] == [])
    check("monthly cost x 12", annual({"monthly": 150}) == 1800)
    check("set-asides raise the rate", calc({**base, "set_asides": [{"name": "pension", "annual": 7000}]})["min_rate"] > r["min_rate"])
    check("sensitivity 50% rate higher than 70%", r["sensitivity"][1]["min_rate"] > r["sensitivity"][3]["min_rate"])

    def codes(**over):
        c = json.loads(json.dumps(base))
        for k, v in over.items():
            if isinstance(v, dict) and isinstance(c.get(k), dict):
                c[k].update(v)
            else:
                c[k] = v
        return {f["code"] for f in calc(c)["findings"]}

    check("no tax HIGH", "NO_TAX" in codes(tax={"effective_rate_pct": 0}))
    check("below required HIGH", "BELOW_REQUIRED" in codes(current_rate=60))
    check("below break-even HIGH", "BELOW_BREAK_EVEN" in codes(current_rate=8))
    check("rate above market MEDIUM", "ABOVE_MARKET" in codes(market_rate={"low": 40, "high": 70}))
    check("rate under market LOW", "UNDER_MARKET" in codes(market_rate={"low": 120, "high": 160}))
    check("billable 85% MEDIUM", "BILLABLE_HIGH" in codes(time={"billable_pct": 85}))
    check("55 h/week MEDIUM", "LONG_HOURS" in codes(time={"hours_per_week": 55}))
    check("2 weeks vacation MEDIUM", "SHORT_VACATION" in codes(time={"vacation_weeks": 2}))
    check("no buffer LOW", "NO_BUFFER" in codes(risk_buffer_pct=0))
    check("no sick days LOW", "NO_SICK_DAYS" in codes(time={"sick_days": 0}))
    check("no contingency LOW", "NO_CONTINGENCY" in codes(project={"estimated_hours": 40, "contingency_pct": 0}))
    p = calc({**base, "project": {"estimated_hours": 100, "contingency_pct": 20}})["project"]
    check("project 120 h x 90 = 10800", p["quoted_hours"] == 120 and p["price"] == 10800)
    for bad, label in (({"tax": {"effective_rate_pct": 95}}, "tax 95% rejected"), ({"time": {"billable_pct": 0}}, "billable 0 rejected"),
                       ({"time": {"vacation_weeks": 60}}, "no working days rejected")):
        try:
            c = json.loads(json.dumps(base))
            for k, v in bad.items():
                c[k].update(v)
            calc(c)
            check(label, False)
        except DataError:
            check(label, True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Freelance hourly rate calculator")
    ap.add_argument("plan", nargs="?", help="plan JSON file")
    ap.add_argument("--round", type=float, default=5, help="round the quoted rate up to this step (default 5)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.plan:
        ap.error("plan file required")
    try:
        with open(a.plan, encoding="utf-8") as fh:
            r = calc(json.load(fh), a.round)
    except (OSError, json.JSONDecodeError, DataError, ValueError, TypeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinancePythonCareer+2
F@f
0
Ứng dụng Web Django taste
Văn bản

Các quy ước định hướng rõ ràng để xây dựng và vận hành ứng dụng web Django trên môi trường thực tế (production).

# Ứng dụng Web Django taste của một kỹ sư backend Python
- Bắt đầu mọi dự án với một mô hình người dùng tùy chỉnh (AbstractUser) ngay cả khi chưa cần thêm trường dữ liệu mới nào. Độ tin cậy: 0.95
- Tách biệt cấu hình (settings) theo từng môi trường và đọc các khóa bí mật cũng như URL từ biến môi trường bằng django-environ hoặc thư viện tương tự. Độ tin cậy: 0.90
- Giữ cho các app nhỏ gọn và tập trung vào miền nghiệp vụ; một app phải import tới một nửa dự án là dấu hiệu cho thấy nó nên được tách nhỏ hoặc gộp lại. Độ tin cậy: 0.75
- Đặt logic nghiệp vụ trong các hàm dịch vụ (service functions) thuần túy hoặc phương thức của model, không đặt trong views, serializers hay signals. Độ tin cậy: 0.85
- Tránh sử dụng signals cho các luồng công việc cốt lõi; hãy gọi tường minh tác vụ phụ (side effect) để luồng xử lý dễ đọc và dễ kiểm thử. Độ tin cậy: 0.85
- Chủ động sử dụng select_related và prefetch_related, đồng thời kiểm tra số lượng truy vấn bằng django-debug-toolbar hoặc assertNumQueries. Độ tin cậy: 0.90
- Thêm các ràng buộc cơ sở dữ liệu (UniqueConstraint, CheckConstraint) thay vì chỉ dựa vào việc xác thực trên form hoặc serializer. Độ tin cậy: 0.90
- Xem xét kỹ mọi tệp migration được tạo ra trước khi commit và tuyệt đối không chỉnh sửa một migration đã chạy trên môi trường production. Độ tin cậy: 0.90
- Viết các data migration bằng RunPython sử dụng apps.get_model, tuyệt đối không import trực tiếp các model. Độ tin cậy: 0.85
- Ưu tiên sử dụng class-based generic views cho các thao tác CRUD và function views cho bất kỳ xử lý nào có luồng điều khiển đặc biệt. Độ tin cậy: 0.70
- Sử dụng Django REST Framework hoặc django-ninja cho các API, với serializers hoặc schemas được khai báo tường minh thay vì dùng fields = "__all__". Độ tin cậy: 0.85
- Bọc các thao tác ghi nhiều bước trong transaction.atomic và đưa các tác vụ chạy nền vào hàng đợi bằng transaction.on_commit. Độ tin cậy: 0.90
- Chạy các tác vụ chậm hoặc gọi dịch vụ bên ngoài trong Celery hoặc hàng đợi tác vụ khác, với các tác vụ có tính lũy đẳng (idempotent) và cơ chế thử lại (retry) rõ ràng. Độ tin cậy: 0.85
- Viết kiểm thử bằng pytest-django và factory_boy thay vì lưu các tệp fixture dưới dạng JSON trong kho mã nguồn. Độ tin cậy: 0.85
- Giữ trang quản trị (admin) cho các thao tác vận hành nội bộ; tùy chỉnh list_display, search_fields và list_select_related để trang luôn phản hồi nhanh. Độ tin cậy: 0.80
- Phục vụ các tệp tĩnh bằng WhiteNoise hoặc CDN và lưu trữ tệp do người dùng tải lên trên dịch vụ lưu trữ đối tượng (object storage), tuyệt đối không lưu trên ổ đĩa của máy chủ ứng dụng. Độ tin cậy: 0.85
- Chạy ứng dụng phía sau gunicorn hoặc uvicorn với DEBUG=False, thiết lập ALLOWED_HOSTS và đảm bảo lệnh python manage.py check --deploy vượt qua kiểm tra trong CI. Độ tin cậy: 0.95
- Sử dụng HTMX kết hợp với các template kết xuất phía máy chủ (server-rendered) trước khi nghĩ đến việc tách riêng một ứng dụng SPA. Độ tin cậy: 0.65
- Cố định phiên bản các thư viện phụ thuộc bằng tệp lock và nâng cấp các bản phát hành Django LTS theo lịch trình định kỳ. Độ tin cậy: 0.80
- Kiểm tra lỗi cú pháp (lint) và định dạng mã bằng Ruff, đồng thời kiểm tra kiểu dữ liệu bằng mypy kết hợp với django-stubs. Độ tin cậy: 0.75
BackendWeb DevelopmentBest Practices+1
F@f
0
Trình Đánh giá Hiệu năng Truy vấn SQL
Kỹ năng

Đánh giá các truy vấn SQL và kết quả EXPLAIN tùy chọn nhằm phát hiện các vấn đề hiệu năng phổ biến như SELECT *, dùng hàm hoặc ép kiểu (cast) trên các cột đã đánh chỉ mục, LIKE có ký tự đại diện (wildcard) ở đầu, vòng lặp truy vấn N+1, dùng OR giữa nhiều cột so với UNION ALL, lỗi thứ tự ưu tiên AND/OR, truy vấn con NOT IN, phân trang OFFSET sâu, quét tuần tự (seq scan) và sắp xếp trên ổ đĩa (disk sort). Giải thích từng vấn đề và cung cấp câu truy vấn viết lại. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: sql-query-performance-reviewer
description: Đánh giá các truy vấn SQL và kết quả EXPLAIN tùy chọn nhằm phát hiện các vấn đề hiệu năng phổ biến như SELECT *, dùng hàm hoặc ép kiểu (cast) trên các cột đã đánh chỉ mục, LIKE có ký tự đại diện (wildcard) ở đầu, vòng lặp truy vấn N+1, dùng OR giữa nhiều cột so với UNION ALL, lỗi thứ tự ưu tiên AND/OR, truy vấn con NOT IN, phân trang OFFSET sâu, quét tuần tự (seq scan) và sắp xếp trên ổ đĩa (disk sort). Giải thích từng vấn đề và cung cấp câu truy vấn viết lại. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Đánh giá Hiệu năng Truy vấn SQL

Bạn đánh giá SQL theo đúng cách một kỹ sư cơ sở dữ liệu giàu kinh nghiệm thực hiện khi duyệt mã (code review): bạn đọc câu truy vấn cùng kế hoạch thực thi (query plan) của nó, tìm ra các khuôn mẫu khiến truy vấn chạy chậm (hoặc âm thầm trả về kết quả sai), rồi trả lại câu truy vấn đã được viết lại kèm theo lý do ngắn gọn cho từng thay đổi. Bạn hỗ trợ PostgreSQL, MySQL/MariaDB, SQL Server và SQLite, đồng thời nêu rõ khi nào lời khuyên có sự khác biệt giữa các hệ quản trị cơ sở dữ liệu này.

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

- `scripts/sql_review.py` - quét tệp .sql (một hoặc nhiều câu lệnh) và đầu ra EXPLAIN tùy chọn, in các phát hiện theo mức độ nghiêm trọng (chỉ dùng thư viện chuẩn Python 3; không bao giờ kết nối tới cơ sở dữ liệu hay thực thi câu lệnh SQL)
- `references/anti-patterns.md` - từng phát hiện kèm lý do tại sao nó gây hại, bản viết lại trước/sau và sự khác biệt giữa các hệ cơ sở dữ liệu (engine)
- `references/reading-explain.md` - cách thu thập và đọc kết quả EXPLAIN trong PostgreSQL và MySQL, cũng như những con số nào là quan trọng
- `templates/review-report.md` - bản báo cáo đánh giá bạn trả về: tóm tắt, bảng phát hiện, các truy vấn đã viết lại, những chỉ số cần đo lường
- `examples/order-history.sql`, `examples/order-history-explain.txt`, `examples/indexes.txt` - trang lịch sử đơn hàng của một cửa hàng trực tuyến với 7 câu lệnh, một kế hoạch thực thi PostgreSQL và các chỉ mục (index) đã biết

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

### 1. Thu thập dữ liệu đầu vào
Yêu cầu người dùng cung cấp: câu truy vấn hoặc đoạn trích nhật ký (log) chứa các câu lệnh thực tế, hệ quản trị cơ sở dữ liệu và phiên bản, kích thước ước chừng của các bảng, và nếu có thể là đầu ra của `EXPLAIN (ANALYZE, BUFFERS)` (PostgreSQL) hoặc `EXPLAIN ANALYZE` / `EXPLAIN ... \G` (MySQL 8). Các định nghĩa chỉ mục sẽ rất hữu ích (`\d table` hoặc `SHOW INDEX FROM table`); hãy ghi chúng dưới dạng các dòng `table(col1, col2)` để truyền vào `--indexes`. Yêu cầu người dùng thay thế dữ liệu khách hàng trong các giá trị tĩnh (literal) bằng giá trị giả trước khi dán vào.

### 2. Chạy trình kiểm tra
```
python3 scripts/sql_review.py examples/order-history.sql --explain examples/order-history-explain.txt --indexes examples/indexes.txt
python3 scripts/sql_review.py slow.sql --json
python3 scripts/sql_review.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ. Script này hoạt động dựa trên biểu thức chính quy (regex heuristic): nó có thể bỏ sót vấn đề trong các câu lệnh SQL rất phức tạp và có thể báo động giả (false positive). Hãy tự mình đọc kỹ từng câu lệnh.

### 3. Đánh giá
Sắp xếp thứ tự ưu tiên công việc: tính đúng đắn lên hàng đầu (AND_OR_PRECEDENCE, NOT_IN_SUBQUERY, NO_WHERE_WRITE), tiếp theo là chi phí lớn nhất từ kế hoạch thực thi (SEQ_SCAN_LARGE, ROWS_REMOVED, DISK_SORT), sau đó là N_PLUS_ONE (thường chiếm tổng thời gian lớn nhất trong ứng dụng), rồi mới đến các vấn đề còn lại. Với mỗi phát hiện, hãy đưa ra bản viết lại từ `references/anti-patterns.md` đã được điều chỉnh theo tên cột thực tế của người dùng. Khi giải pháp là thêm chỉ mục, hãy đề xuất nhưng giữ phần thiết kế chỉ mục ngắn gọn (thứ tự cột: so sánh bằng, rồi đến sắp xếp, rồi đến khoảng giá trị) và nhắc họ rằng mỗi chỉ mục thêm vào đều làm chậm thao tác ghi.

### 4. Bàn giao kết quả
Điền vào `templates/review-report.md`: tóm tắt một dòng, bảng các phát hiện, từng câu truy vấn đã viết lại, và kế hoạch đo lường trước/sau (chạy lại EXPLAIN ANALYZE, so sánh thời gian thực thi và số dòng phải đọc). Không bao giờ khẳng định mức tăng tốc mà bạn chưa trực tiếp đo lường; hãy dùng từ "dự kiến" (expected).

## Kết quả ví dụ
Đối với các tệp ví dụ: 7 câu lệnh, 6 phát hiện HIGH, 7 MEDIUM, 2 LOW. Mức HIGH: `status = 'paid' OR status = 'shipped'` không có dấu ngoặc đơn sẽ trả về mọi đơn hàng đã giao của tất cả khách hàng; LOWER(customer_email) và DATE(created_at) làm ẩn cột khỏi chỉ mục; 3 câu truy vấn lấy chi tiết từng đơn hàng riêng lẻ tạo thành vòng lặp N+1 (thay bằng một câu truy vấn `WHERE order_id IN (...)`); `LIKE '%wireless%'`; kế hoạch thực thi cho thấy một phép quét tuần tự (Seq Scan) đọc tới 412560 dòng. Mức MEDIUM: SELECT *, OFFSET 4000, cột sku đem so sánh với một số, cột sku chưa có chỉ mục, truy vấn con NOT IN, sắp xếp trộn ngoài trên ổ đĩa (external merge sort on disk), ước tính 19930 dòng so với thực tế 412560 dòng (cần chạy ANALYZE).

## Quy tắc

- Không bao giờ tự chạy SQL trực tiếp trên cơ sở dữ liệu của người dùng và không bao giờ yêu cầu thông tin xác thực môi trường production.
- Yêu cầu ẩn danh hóa các giá trị tĩnh (literal); không lặp lại dữ liệu cá nhân từ các câu truy vấn được dán vào trong báo cáo.
- Tính đúng đắn quan trọng hơn tốc độ: hãy chỉ ra những câu truy vấn trả về sai dữ liệu ngay cả khi chúng chạy rất nhanh.
- Ưu tiên viết lại câu truy vấn hơn là thêm chỉ mục; chỉ thêm chỉ mục cho những truy vấn chạy thường xuyên hoặc bị chậm.
- Nêu rõ tên hệ cơ sở dữ liệu khi lời khuyên mang tính đặc thù theo từng engine (ví dụ: chỉ mục biểu thức - expression index, ILIKE, LIMIT a,b).
- Kế hoạch thực thi đo đạc thực tế đáng tin cậy hơn quy tắc suy nghiệm (heuristic): nếu EXPLAIN ANALYZE mâu thuẫn với một phát hiện của công cụ, hãy tin vào kế hoạch thực thi.
FILE:references/anti-patterns.md
# Các phản mẫu (Anti-Patterns) về Hiệu năng SQL

Mỗi mục bao gồm: dấu hiệu bị trình kiểm tra gắn cờ, lý do gây hại và cách viết lại. "Sargable" có nghĩa là cơ sở dữ liệu có thể sử dụng chỉ mục để tìm ra các dòng dữ liệu (Search ARGument ABLE).

## AND_OR_PRECEDENCE (HIGH, tính đúng đắn)
Toán tử AND có độ ưu tiên kết hợp chặt hơn OR, vì vậy `customer_id = 7 AND status = 'paid' OR status = 'shipped'` đồng nghĩa với `(customer_id = 7 AND status = 'paid') OR status = 'shipped'`: trả về mọi đơn hàng đã giao của tất cả khách hàng, đồng thời dẫn đến quét toàn bộ bảng (full scan).
```
-- sau khi sửa
WHERE customer_id = 7 AND status IN ('paid', 'shipped')
```

## FUNC_ON_COLUMN (HIGH)
`WHERE LOWER(email) = ?`, `YEAR(created_at) = 2026`, `DATE(created_at) = ?`, `price * 2 > 100`: chỉ mục lưu trữ các giá trị gốc, do đó hàm sẽ phải chạy trên từng dòng một.
```
-- ngày tháng: sử dụng khoảng nửa mở (half-open range) trên cột gốc
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02'
-- phép tính số học: chuyển sang phía hằng số
WHERE price > 50
-- tìm kiếm không phân biệt hoa thường: lưu dữ liệu đã chuẩn hóa hoặc đánh chỉ mục trên biểu thức
CREATE INDEX ON customers (LOWER(email));          -- PostgreSQL, SQLite 3.9+
ALTER TABLE customers ADD email_lc VARCHAR(255) AS (LOWER(email)) STORED, ADD INDEX (email_lc);  -- MySQL 5.7+
-- MySQL 8.0.13+ cũng hỗ trợ functional index: CREATE INDEX ix ON customers ((LOWER(email)));
-- Giải pháp thay thế trên PostgreSQL: kiểu dữ liệu citext
```

## LEADING_WILDCARD (HIGH)
`LIKE '%wireless%'` không thể tận dụng chỉ mục B-tree (chỉ tiền tố như `'wireless%'` mới dùng được). Các lựa chọn thay thế: tìm kiếm toàn văn - full-text search (PostgreSQL dùng `tsvector` + GIN, MySQL dùng `FULLTEXT` + `MATCH ... AGAINST`), chỉ mục trigram (`CREATE EXTENSION pg_trgm; CREATE INDEX ... USING gin (name gin_trgm_ops)`), công cụ tìm kiếm chuyên dụng cho tìm kiếm sản phẩm, hoặc tạo cột đảo ngược chuỗi cho các truy vấn tìm kiếm theo hậu tố (`'%@example.com'`).

## N_PLUS_ONE (HIGH)
Một câu truy vấn để lấy danh sách, sau đó cứ mỗi dòng lại chạy thêm một câu truy vấn (do ORM tải trễ - lazy-loading một quan hệ). 50 đơn hàng = 51 lượt gọi qua lại (round trips); mỗi truy vấn đơn lẻ đều nhanh, nhưng tổng thể cả trang lại chậm.
```
-- trước khi sửa (x N lần)
SELECT product_id, qty FROM order_items WHERE order_id = 98211;
-- sau khi sửa (1 câu truy vấn)
SELECT order_id, product_id, qty FROM order_items WHERE order_id IN (98211, 98340, 98502);
-- hoặc thực hiện JOIN trực tiếp vào câu truy vấn lấy danh sách
```
Cách sửa trên ORM: Django dùng `prefetch_related`/`select_related`, Rails dùng `includes`, SQLAlchemy dùng `selectinload`, Hibernate dùng `JOIN FETCH` / `@BatchSize`, Prisma dùng `include`. Trình kiểm tra cần thấy các câu lệnh lặp lại trong tệp (hãy dán một đoạn trích từ log).

## ORDER_BY_RANDOM (HIGH)
`ORDER BY RAND() LIMIT 1` phải đọc và sắp xếp toàn bộ bảng. Hãy chọn một id ngẫu nhiên trong khoảng giá trị rồi lấy dòng tiếp theo (`WHERE id >= ? ORDER BY id LIMIT 1`), duy trì một cột số ngẫu nhiên có đánh chỉ mục, hoặc sử dụng `TABLESAMPLE SYSTEM (1)` trong PostgreSQL.

## NO_WHERE_WRITE (HIGH)
`UPDATE`/`DELETE` không có mệnh đề WHERE sẽ khóa và ghi lại mọi dòng trong bảng. Nếu đó thực sự là chủ đích của bạn, hãy thực hiện theo từng lô (`DELETE ... WHERE id IN (SELECT id ... LIMIT 5000)` trong một vòng lặp) hoặc sử dụng `TRUNCATE`.

## SELECT_STAR (MEDIUM)
Đọc tất cả các cột (bao gồm cả các cột văn bản/JSON dung lượng lớn), truyền tải nhiều byte hơn, ngăn cản cơ chế chỉ quét chỉ mục (index-only scan) và làm hỏng mã nguồn phía gọi khi cấu trúc cột thay đổi. Hãy liệt kê rõ các cột cần lấy. Riêng `SELECT *` bên trong `EXISTS (...)` thì hoàn toàn ổn.

## OR_ACROSS_COLUMNS (MEDIUM) và OR_SAME_COLUMN (LOW)
`WHERE email = ? OR phone = ?`: một chỉ mục đơn lẻ không thể phục vụ cả hai điều kiện. PostgreSQL có thể kết hợp hai chỉ mục bằng BitmapOr và MySQL dùng index_merge; nếu kế hoạch thực thi lại hiển thị quét bảng (scan), hãy tách truy vấn:
```
SELECT id FROM users WHERE email = ?
UNION ALL
SELECT id FROM users WHERE phone = ? AND email IS DISTINCT FROM ?;  -- tránh trùng lặp (MySQL: NOT (email <=> ?))
```
Nếu lặp lại trên cùng một cột: `status IN ('paid', 'shipped')` ngắn gọn hơn và tạo ra kế hoạch thực thi tương đương hoặc tốt hơn.

## IMPLICIT_CAST (MEDIUM) và QUOTED_NUMBER (LOW)
Một cột văn bản đem so sánh với một số (`sku = 40017` trên cột VARCHAR): MySQL phải chuyển đổi giá trị của mọi dòng sang kiểu số, dẫn đến việc bỏ qua chỉ mục và giá trị '040017' cũng khớp luôn; trong khi PostgreSQL sẽ báo lỗi toán tử. Hãy đặt giá trị trong dấu nháy. Một cột kiểu số đem so sánh với một số đặt trong dấu nháy (`customer_id = '1042'`) chỉ được chuyển đổi một lần và thường không sao, nhưng điều đó thường báo hiệu ứng dụng đang truyền (bind) id dưới dạng chuỗi; một tham số mang kiểu văn bản có thể buộc cơ sở dữ liệu phải ép kiểu ở phía cột trong một số trình điều khiển (driver) (trường hợp kinh điển là `setString` của JDBC trên SQL Server giữa NVARCHAR và VARCHAR).

## NOT_IN_SUBQUERY (MEDIUM, tính đúng đắn)
Nếu truy vấn con trả về dù chỉ một giá trị NULL, `NOT IN` sẽ không trả về bất kỳ dòng nào cả. Hãy sử dụng:
```
SELECT c.id FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
```
NOT EXISTS được lập kế hoạch thực thi dưới dạng phép kết nối loại trừ (anti-join) trong mọi hệ cơ sở dữ liệu phổ biến.

## DEEP_OFFSET (MEDIUM)
`LIMIT 20 OFFSET 4000` phải đọc 4020 dòng rồi vứt bỏ 4000 dòng; trang thứ 500 sẽ chậm hơn gấp 25 lần so với trang 1. Hãy dùng phân trang theo tập khóa - keyset (seek) pagination:
```
SELECT id, created_at, total FROM orders
WHERE customer_id = ? AND (created_at, id) < (?, ?)      -- dòng cuối cùng của trang trước đó
ORDER BY created_at DESC, id DESC
LIMIT 20;
```
Đánh chỉ mục `(customer_id, created_at, id)`. MySQL cũng hỗ trợ so sánh theo bộ dòng (row comparison); trên SQL Server: `created_at < ? OR (created_at = ? AND id < ?)`.

## COUNT_FOR_EXISTS (LOW)
`SELECT COUNT(*) ... > 0` sẽ đếm toàn bộ các dòng khớp điều kiện. Trong khi đó `SELECT EXISTS (SELECT 1 FROM ... WHERE ...)` sẽ dừng lại ngay ở dòng khớp đầu tiên.

## DISTINCT_JOIN (LOW)
`SELECT DISTINCT u.* FROM users u JOIN orders o ...` tạo ra toàn bộ các dòng tích hợp giữa user x order rồi mới loại bỏ bản ghi trùng lặp. Hãy sử dụng `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`.

## NO_INDEX_FOR_FILTER (MEDIUM, cần tham số --indexes)
Một cột được dùng trong mệnh đề WHERE/ON nhưng không phải là cột đầu tiên của bất kỳ chỉ mục nào được liệt kê. Không phải bộ lọc nào cũng cần chỉ mục (bảng nhỏ, cờ có độ chọn lọc thấp, truy vấn hiếm khi chạy). Thứ tự cột trong chỉ mục phức hợp (composite index): các cột so sánh bằng đứng trước, tiếp theo là cột trong ORDER BY, rồi đến các cột lọc theo khoảng giá trị; chỉ đưa thêm các cột trong mệnh đề SELECT vào để phục vụ quét chỉ mục đơn thuần (index-only scan) khi đó là câu truy vấn chạy với tần suất rất cao (hot query).
FILE:references/reading-explain.md
# Cách đọc EXPLAIN

## Thu thập kế hoạch thực thi
| Hệ CSDL (Engine) | Câu lệnh | Ghi chú |
|---|---|---|
| PostgreSQL | `EXPLAIN (ANALYZE, BUFFERS) <query>;` | ANALYZE sẽ thực thi câu truy vấn thật; hãy bọc các câu lệnh ghi trong `BEGIN; ... ROLLBACK;` |
| MySQL 8 | `EXPLAIN ANALYZE <query>;` hoặc `EXPLAIN <query>\G` | Trình kiểm tra phân tích định dạng dọc `\G` |
| MariaDB | `ANALYZE <query>;` / `EXPLAIN <query>\G` | |
| SQL Server | `SET STATISTICS IO, TIME ON;` + kế hoạch thực thi thực tế (actual execution plan) | Đọc thủ công |
| SQLite | `EXPLAIN QUERY PLAN <query>;` | Tìm dấu hiệu `SCAN table` so với `SEARCH table USING INDEX` |

Hãy chạy trên dữ liệu tương tự môi trường production. Một kế hoạch thực thi trên bảng dev 1000 dòng không nói lên được điều gì về một bảng có 50 triệu dòng.

## Những dấu hiệu trình kiểm tra tìm kiếm
| Phát hiện | Dấu hiệu trên PostgreSQL | Dấu hiệu trên MySQL |
|---|---|---|
| SEQ_SCAN_LARGE | `Seq Scan on t` với số dòng thực tế (actual rows) >= 10000 | `type: ALL`, `rows` >= 10000, `key: NULL` |
| ROWS_REMOVED | `Rows Removed by Filter` >= 9 lần số dòng được giữ lại | `filtered` <= 10% trên >= 1000 dòng |
| EST_MISMATCH | Ước tính `rows=` lệch so với thực tế `actual ... rows=` từ 10 lần trở lên | (Chỉ có ở EXPLAIN ANALYZE, đọc thủ công) |
| DISK_SORT | `Sort Method: external merge  Disk: ...` | `Using temporary; Using filesort` |
| NESTED_LOOP_MANY | nút bên trong (inner node) có `loops=` >= 1000 | nhiều dòng ở bảng dẫn động (driving table) + tra cứu `ref` |

## Đọc kế hoạch thực thi PostgreSQL
- Đọc từ nút thụt lề sâu nhất ngược lên trên; mỗi mũi tên `->` cung cấp dữ liệu đầu vào cho nút cha của nó.
- `cost=a..b` là con số ước đoán của bộ lập kế hoạch (đơn vị tùy ý), `actual time=a..b` là số mili giây cho mỗi vòng lặp; hãy nhân với `loops`.
- `rows=` trong phần cost là con số ước tính, còn trong phần actual là số lượng thực tế. Sai lệch gấp 10 lần có nghĩa là thống kê đã cũ hoặc bị thiếu: hãy chạy `ANALYZE table;`, hoặc tạo thống kê mở rộng cho các cột có tương quan với nhau (`CREATE STATISTICS`).
- `Index Scan` / `Index Only Scan` / `Bitmap Index Scan` = đã sử dụng chỉ mục. Nếu `Heap Fetches` cao trong phép quét Index Only Scan = hãy chạy VACUUM.
- `Buffers: shared read=` đếm số trang 8 kB đọc từ ổ đĩa; `hit=` là đọc từ bộ nhớ đệm (cache).
- `Sort Method: external merge` = dữ liệu sắp xếp không vừa với bộ nhớ `work_mem`. Nên ưu tiên dùng chỉ mục trả về các dòng theo đúng thứ tự; chỉ tăng work_mem theo từng phiên (session) đối với các truy vấn báo cáo đã biết rõ.

## Đọc EXPLAIN trên MySQL
- `type` từ tốt nhất đến tệ nhất: `const`, `eq_ref`, `ref`, `range`, `index` (quét toàn bộ chỉ mục), `ALL` (quét toàn bộ bảng).
- `key` là chỉ mục được chọn (`NULL` = không dùng chỉ mục nào); `possible_keys` liệt kê các chỉ mục ứng viên.
- `rows` x `filtered`% = số dòng được chuyển tiếp sang bước tiếp theo.
- `Extra`: `Using index` (chỉ mục bao phủ - covering index, tốt), `Using where` (lọc sau khi đọc), `Using temporary` và `Using filesort` (phát sinh thêm công việc cho GROUP BY / ORDER BY).

## Đo lường trước/sau
1. Chạy câu truy vấn gốc 3 lần, ghi lại thời gian thực thi trung vị và số dòng/buffer đã đọc.
2. Áp dụng từng thay đổi một (viết lại câu truy vấn trước, sau đó mới thêm chỉ mục).
3. Chạy lại với cùng bộ tham số; đồng thời kiểm thử với cả một khách hàng có dữ liệu "lớn" và một khách hàng có dữ liệu "nhỏ", vì kế hoạch thực thi sẽ thay đổi khi dữ liệu bị lệch (data skew).
4. Kiểm tra chi phí ghi đối với các chỉ mục mới trên các bảng có tần suất hoạt động cao (độ trễ khi insert, dung lượng chỉ mục thông qua `pg_relation_size` hoặc `information_schema.STATISTICS`).
FILE:templates/review-report.md
# Đánh giá hiệu năng SQL: <tên câu truy vấn hoặc endpoint>

**Hệ CSDL (Engine):** <PostgreSQL 16 / MySQL 8.0 / ...>   **Các bảng:** <orders ~1.2M dòng, order_items ~4M>
**Tóm tắt:** <một câu: vấn đề chính và hiệu quả dự kiến sau khi khắc phục>

## Các phát hiện
| # | Mức độ | Phát hiện | Câu lệnh | Tại sao quan trọng | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | HIGH | <AND_OR_PRECEDENCE> | <câu lệnh 1> | <trả về mọi đơn hàng đã giao, quét toàn bộ bảng> | <thêm ngoặc đơn / dùng IN (...)> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |

## Các câu truy vấn đã viết lại
### Câu lệnh <n>: <tên>
Trước khi sửa:
```sql
<original>
```
Sau khi sửa:
```sql
<rewrite>
```
Lý do: <một hoặc hai câu>

## Thay đổi về chỉ mục (chỉ khi thực sự cần thiết)
```sql
<CREATE INDEX CONCURRENTLY ix_orders_customer_created ON orders (customer_id, created_at DESC, id);>
```
Chi phí ghi: <tốc độ ghi của bảng, kích thước chỉ mục dự kiến>

## Đo lường
| Truy vấn | Trước (ms, số dòng đọc) | Sau (ms, số dòng đọc) |
|---|---|---|
| <câu lệnh 1> | <912 ms, 1.2M dòng> | <dự kiến: < 5 ms, ~20 dòng> |

Chạy lại `EXPLAIN (ANALYZE, BUFFERS)` sau mỗi thay đổi, thử nghiệm với cả khách hàng có lượng dữ liệu lớn và khách hàng có lượng dữ liệu nhỏ.

## Những mục giữ nguyên không thay đổi
<các phát hiện chủ đích giữ nguyên, ví dụ: SELECT * trong tính năng xuất dữ liệu của quản trị viên, kèm theo lý do>
FILE:examples/order-history.sql
-- Order history page of a small online shop (PostgreSQL), captured from the app log.
-- 1. Order list for the account page
SELECT * FROM orders o
WHERE LOWER(o.customer_email) = 'mina.sato@example.com'
  AND o.status = 'paid' OR o.status = 'shipped'
ORDER BY o.created_at DESC
LIMIT 20 OFFSET 4000;

-- 2-4. Line items, loaded one order at a time by the ORM
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98211;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98340;
SELECT product_id, qty, unit_price FROM order_items WHERE order_id = 98502;

-- 5. Search box
SELECT p.id, p.name FROM products p
WHERE p.name LIKE '%wireless%' AND p.sku = 40017;

-- 6. Customers who never ordered
SELECT c.id, c.email FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders)
  AND DATE(c.created_at) = '2026-10-01';

-- 7. Order lookup by id from a URL parameter
SELECT o.id, o.total FROM orders o WHERE o.customer_id = '1042';
FILE:examples/order-history-explain.txt
Limit  (cost=48211.30..48211.35 rows=20 width=212) (actual time=912.4..912.5 rows=20 loops=1)
  ->  Sort  (cost=48201.30..48251.12 rows=19930 width=212) (actual time=905.1..911.8 rows=4020 loops=1)
        Sort Key: created_at DESC
        Sort Method: external merge  Disk: 18432kB
        ->  Seq Scan on orders o  (cost=0.00..41870.00 rows=19930 width=212) (actual time=0.03..801.7 rows=412560 loops=1)
              Filter: (((lower(customer_email) = 'mina.sato@example.com'::text) AND (status = 'paid'::text)) OR (status = 'shipped'::text))
              Rows Removed by Filter: 787440
Planning Time: 0.4 ms
Execution Time: 914.9 ms
FILE:examples/indexes.txt
orders(customer_id, created_at)
orders(status)
order_items(order_id)
customers(id)
products(id)
FILE:scripts/sql_review.py
#!/usr/bin/env python3
"""SQL query performance reviewer (Python 3 standard library only).

Reads a .sql file (one or more statements separated by ;) and, optionally, EXPLAIN output,
and flags common performance problems with a heuristic, regex-based scan. It does not connect
to a database and never runs the SQL. Treat findings as review prompts, not proof.

Query findings (severity):
  HIGH   FUNC_ON_COLUMN     function or arithmetic wraps a column in WHERE/ON (index cannot be used)
  HIGH   LEADING_WILDCARD   LIKE/ILIKE pattern starts with % or _ (full scan)
  HIGH   N_PLUS_ONE         the same statement shape repeats >= --repeat times with different literals
  HIGH   ORDER_BY_RANDOM    ORDER BY RAND()/RANDOM() sorts the whole table
  HIGH   NO_WHERE_WRITE     UPDATE or DELETE without WHERE
  HIGH   AND_OR_PRECEDENCE  AND and OR mixed without parentheses: AND binds first, so the OR branch
                            matches rows from the whole table (wrong results and a full scan)
  MEDIUM SELECT_STAR        SELECT * or alias.* (wide rows, no covering index, breaks on schema change)
  MEDIUM OR_ACROSS_COLUMNS  OR between different columns in WHERE (consider UNION ALL)
  MEDIUM IMPLICIT_CAST      code/phone/sku-like (text) column compared with a bare number: MySQL
                            casts every row and skips the index, PostgreSQL raises a type error
  MEDIUM NOT_IN_SUBQUERY    NOT IN (SELECT ...): NULL trap and poor anti-join plans; use NOT EXISTS
  MEDIUM DEEP_OFFSET        OFFSET (or LIMIT a,b) >= 1000: keyset pagination instead
  MEDIUM NO_INDEX_FOR_FILTER  (needs --indexes) filtered/joined column not leading any known index
  LOW    QUOTED_NUMBER      id/number column compared with a quoted number: usually fine for a literal,
                            but a sign the app binds ids as text (typed text parameters can force a cast)
  LOW    OR_SAME_COLUMN     col = a OR col = b: write col IN (a, b)
  LOW    COUNT_FOR_EXISTS   COUNT(*) > 0 used as an existence check; use EXISTS
  LOW    DISTINCT_JOIN      SELECT DISTINCT with JOIN may hide a join fan-out
EXPLAIN findings (PostgreSQL text format and MySQL EXPLAIN \\G vertical format):
  HIGH   SEQ_SCAN_LARGE     Seq Scan / type ALL over >= --large-rows rows
  MEDIUM ROWS_REMOVED       a filter throws away >= 90% of the rows it reads
  MEDIUM EST_MISMATCH       estimated vs actual rows differ by >= 10x (stale statistics)
  MEDIUM DISK_SORT          external merge sort / Using filesort with Using temporary
  LOW    NESTED_LOOP_MANY   inner side of a nested loop runs >= 1000 loops
Usage:
  python3 sql_review.py query.sql
  python3 sql_review.py query.sql --explain plan.txt --indexes indexes.txt --json
  python3 sql_review.py --self-test
--indexes file: one index per line, e.g. "orders(customer_id, created_at)".
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import re
import sys
from collections import defaultdict

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
FUNCS = ("lower", "upper", "trim", "ltrim", "rtrim", "date", "year", "month", "day", "cast",
         "coalesce", "ifnull", "nvl", "substring", "substr", "left", "right", "to_char",
         "date_trunc", "extract", "date_format", "convert", "abs", "round", "concat", "length")
ID_COL = re.compile(r"(^|_)(id|number|count|qty|quantity|amount)$")
TEXT_COL = re.compile(r"(phone|zip|postal_code|postcode|sku|isbn|iban|code|_no)$")
KEYWORDS = {"where", "on", "and", "or", "not", "join", "left", "right", "inner", "outer", "full",
            "cross", "group", "order", "by", "limit", "offset", "having", "union", "select", "from",
            "as", "using", "set", "values", "into", "lateral", "natural", "straight_join", "with"}


class DataError(ValueError):
    pass


def mask(sql):
    """Remove comments, replace string literals with 'Sn' tokens; return (masked, literals)."""
    sql = re.sub(r"--[^\n]*", " ", sql)
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
    lits = []

    def rep(m):
        lits.append(m.group(0)[1:-1].replace("''", "'"))
        return f"'S{len(lits) - 1}'"
    return re.sub(r"'(?:[^']|'')*'", rep, sql), lits


def split_statements(text):
    masked, lits = mask(text)
    out = []
    for part in masked.split(";"):
        s = " ".join(part.split())
        if s:
            out.append(s)
    return out, lits


def clause(stmt, start, enders):
    m = re.search(r"\b" + start + r"\b", stmt, re.I)
    if not m:
        return ""
    rest = stmt[m.end():]
    e = re.search(r"\b(" + "|".join(enders) + r")\b", rest, re.I)
    return rest[:e.start()] if e else rest


def where_part(stmt):
    return clause(stmt, "where", ["group\\s+by", "order\\s+by", "limit", "offset", "having",
                                  "union", "returning", "for\\s+update", "window"])


def join_on_parts(stmt):
    return re.findall(r"\bon\b(.*?)(?=\b(?:join|left|right|inner|full|cross|where|group|order|limit|union)\b|$)",
                      stmt, re.I)


def aliases(stmt):
    amap = {}
    for m in re.finditer(r"\b(?:from|join)\s+([a-z_][\w.]*)(?:\s+(?:as\s+)?([a-z_]\w*))?", stmt, re.I):
        table, alias = m.group(1).lower(), (m.group(2) or "").lower()
        if table == "select" or table.startswith("("):
            continue
        amap[table.split(".")[-1]] = table.split(".")[-1]
        if alias and alias not in KEYWORDS:
            amap[alias] = table.split(".")[-1]
    return amap


def shape(stmt):
    s = re.sub(r"'S\d+'", "?", stmt)
    s = re.sub(r"\b\d+(\.\d+)?\b", "?", s)
    s = re.sub(r"\(\s*\?(\s*,\s*\?)*\s*\)", "(?)", s)
    return s.lower()


def review_sql(text, repeat=3, indexes=None):
    stmts, lits = split_statements(text)
    if not stmts:
        raise DataError("no SQL statements found")
    findings = []

    def add(sev, code, n, msg):
        findings.append({"severity": sev, "code": code, "statement": n, "message": msg})

    shapes = defaultdict(list)
    for n, st in enumerate(stmts, 1):
        low = st.lower()
        kind = low.split()[0]
        shapes[shape(st)].append(n)
        where = where_part(st)
        ons = join_on_parts(st)
        filt = " AND ".join([where] + ons)
        if re.search(r"\bselect\s+(distinct\s+)?(\w+\.)?\*", low) or re.search(r",\s*\w+\.\*", low):
            add("MEDIUM", "SELECT_STAR", n, "SELECT * reads every column; list the columns you need so a covering index can serve the query")
        for m in re.finditer(r"\b(" + "|".join(FUNCS) + r")\s*\(\s*([a-z_][\w]*(?:\.[a-z_]\w*)?)\b[^()]*\)\s*(=|<>|!=|<=|>=|<|>|\bin\b|\blike\b|\bbetween\b)",
                             filt, re.I):
            col = m.group(2)
            if col.lower() in KEYWORDS or re.fullmatch(r"S\d+", col):
                continue
            add("HIGH", "FUNC_ON_COLUMN", n, f"{m.group(1)}({col}) in a filter hides {col} from its index; compare the bare column (e.g. a range on the raw value) or add an expression index")
        for m in re.finditer(r"([a-z_][\w.]*)\s*[+\-*/]\s*\d+\s*(=|<|>|<=|>=)", filt, re.I):
            add("HIGH", "FUNC_ON_COLUMN", n, f"arithmetic on {m.group(1)} in a filter; move it to the other side of the comparison")
        for m in re.finditer(r"([a-z_][\w.]*)\s+(not\s+)?(i?like)\s+'S(\d+)'", st, re.I):
            pat = lits[int(m.group(4))]
            if pat[:1] in ("%", "_"):
                add("HIGH", "LEADING_WILDCARD", n, f"{m.group(1)} {m.group(3).upper()} '{pat}' starts with a wildcard, so no B-tree index helps; use full-text search, a trigram index or a reversed column")
        if re.search(r"order\s+by\s+(rand|random|newid)\s*\(\s*\)", low):
            add("HIGH", "ORDER_BY_RANDOM", n, "ORDER BY random sorts every row; pick random ids or use TABLESAMPLE")
        if kind in ("update", "delete") and not where.strip():
            add("HIGH", "NO_WHERE_WRITE", n, f"{kind.upper()} without WHERE touches every row; add a WHERE clause or batch it")
        if where:
            top = where
            while re.search(r"\([^()]*\)", top):
                top = re.sub(r"\([^()]*\)", " _ ", top)
            top = re.sub(r"\bbetween\s+\S+\s+and\b", "between x", top, flags=re.I)
            if re.search(r"\band\b", top, re.I) and re.search(r"\bor\b", top, re.I):
                add("HIGH", "AND_OR_PRECEDENCE", n, "AND and OR are mixed without parentheses; AND binds first, so 'a AND b OR c' returns every row matching c; add parentheses: a AND (b OR c)")
            for group in re.split(r"\band\b", where, flags=re.I):
                if not re.search(r"\bor\b", group, re.I):
                    continue
                cols = re.findall(r"([a-z_][\w.]*)\s*(?:=|<>|!=|<|>|\bin\b|\blike\b)", group, re.I)
                cols = [c.lower() for c in cols if c.lower() not in KEYWORDS and not re.fullmatch(r"s\d+", c.lower())]
                if len(set(cols)) >= 2:
                    add("MEDIUM", "OR_ACROSS_COLUMNS", n, f"OR across {', '.join(sorted(set(cols)))}: one index cannot serve both sides; split into UNION ALL of indexed queries (or check the plan uses a bitmap OR)")
                elif len(cols) >= 2:
                    add("LOW", "OR_SAME_COLUMN", n, f"repeated OR on {cols[0]}; write {cols[0]} IN (...)")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=|<|>)\s*'S(\d+)'", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if ID_COL.search(col) and re.fullmatch(r"-?\d+(\.\d+)?", lits[int(m.group(3))]):
                add("LOW", "QUOTED_NUMBER", n, f"{m.group(1)} compared with the string '{lits[int(m.group(3))]}'; bind it as a number so the driver never sends a text parameter")
        for m in re.finditer(r"([a-z_][\w.]*)\s*(=|<>|!=)\s*(\d{3,})\b", filt, re.I):
            col = m.group(1).split(".")[-1].lower()
            if TEXT_COL.search(col):
                add("MEDIUM", "IMPLICIT_CAST", n, f"{m.group(1)} looks like a text column but is compared with the number {m.group(3)}; MySQL then casts every row and skips the index (PostgreSQL raises a type error); quote the value")
        if re.search(r"\bnot\s+in\s*\(\s*select\b", low):
            add("MEDIUM", "NOT_IN_SUBQUERY", n, "NOT IN (SELECT ...) returns no rows if the subquery yields a NULL and often plans badly; use NOT EXISTS")
        m = re.search(r"\boffset\s+(\d+)", low) or re.search(r"\blimit\s+(\d+)\s*,\s*\d+", low)
        if m and int(m.group(1)) >= 1000:
            add("MEDIUM", "DEEP_OFFSET", n, f"OFFSET {m.group(1)} reads and throws away {m.group(1)} rows; use keyset pagination (WHERE (created_at, id) < (?, ?) ORDER BY ... LIMIT n)")
        if re.search(r"count\s*\(\s*(\*|1)\s*\)\s*(>\s*0|>=\s*1)", low):
            add("LOW", "COUNT_FOR_EXISTS", n, "COUNT(*) > 0 counts every match; EXISTS stops at the first")
        if re.search(r"\bselect\s+distinct\b", low) and re.search(r"\bjoin\b", low):
            add("LOW", "DISTINCT_JOIN", n, "DISTINCT on a join often hides duplicate rows from a one-to-many join; use EXISTS or aggregate first")
        if indexes is not None:
            amap = aliases(st)
            seen = set()
            for m in re.finditer(r"\b([a-z_]\w*)\.([a-z_]\w*)\s*(?:=|<|>|<=|>=|\bin\b|\bbetween\b)", filt, re.I):
                table = amap.get(m.group(1).lower())
                col = m.group(2).lower()
                if not table or (table, col) in seen:
                    continue
                seen.add((table, col))
                if table in indexes and col not in indexes[table]:
                    add("MEDIUM", "NO_INDEX_FOR_FILTER", n, f"{table}.{col} is filtered or joined but no listed index on {table} starts with it")
    for sh, ns in shapes.items():
        if len(ns) >= repeat and ("where" in sh):
            add("HIGH", "N_PLUS_ONE", ns[0], f"statements {', '.join(map(str, ns))} have the same shape with different values ({len(ns)}x); fetch them in one query with IN (...) or a JOIN")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["statement"], f["code"]))
    shown = [re.sub(r"'S(\d+)'", lambda m: "'" + lits[int(m.group(1))].replace("'", "''") + "'", st) for st in stmts]
    return shown, findings


def parse_indexes(text):
    idx = defaultdict(set)
    for line in text.splitlines():
        m = re.match(r"\s*([\w.]+)\s*\(\s*([\w\s,]+)\)", line)
        if m:
            idx[m.group(1).split(".")[-1].lower()].add(m.group(2).split(",")[0].strip().lower())
    return idx


def review_explain(text, large=10000):
    findings = []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "statement": 0, "message": msg})
    lines = text.splitlines()
    if re.search(r"^\s*(\*+ \d+\. row|select_type:)", text, re.M):
        for block in re.split(r"\*+ \d+\. row \*+", text):
            f = dict(re.findall(r"^\s*(\w+):\s*(.*?)\s*$", block, re.M))
            if not f:
                continue
            rows = int(f.get("rows", "0") or 0)
            if f.get("type") == "ALL" and rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"full table scan (type ALL) on {f.get('table')} ~{rows} rows; key={f.get('key', 'NULL')}")
            extra = f.get("Extra", "")
            if "Using filesort" in extra and "Using temporary" in extra:
                add("MEDIUM", "DISK_SORT", f"{f.get('table')}: Using temporary; Using filesort - an index matching GROUP BY/ORDER BY avoids it")
            filtered = float(f.get("filtered", "100") or 100)
            if rows >= 1000 and filtered <= 10:
                add("MEDIUM", "ROWS_REMOVED", f"{f.get('table')}: only {filtered:g}% of {rows} rows pass the filter")
        return findings
    for i, line in enumerate(lines):
        m = re.search(r"(Parallel )?Seq Scan on (\S+).*?rows=(\d+)", line)
        if m:
            act = re.search(r"actual[^)]*rows=(\d+)", line)
            rows = int(act.group(1) if act else m.group(3))
            if rows >= large:
                add("HIGH", "SEQ_SCAN_LARGE", f"Seq Scan on {m.group(2)} returns ~{rows} rows; an index on the filter columns may help")
        m = re.search(r"Rows Removed by Filter:\s*(\d+)", line)
        if m:
            removed = int(m.group(1))
            prev = next((l for l in reversed(lines[:i]) if "actual" in l), "")
            k = re.search(r"actual[^)]*rows=(\d+)", prev)
            kept = int(k.group(1)) if k else 0
            if removed >= 1000 and removed >= 9 * max(kept, 1):
                add("MEDIUM", "ROWS_REMOVED", f"filter removed {removed} rows to keep {kept} ({prev.strip()[:60]}...)")
        m = re.search(r"cost=[\d.]+\.\.[\d.]+ rows=(\d+).*actual time=[\d.]+\.\.[\d.]+ rows=(\d+) loops=(\d+)", line)
        if m:
            est, act, loops = int(m.group(1)), int(m.group(2)), int(m.group(3))
            if max(est, act) >= 100 and max(est, act) >= 10 * max(min(est, act), 1):
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("MEDIUM", "EST_MISMATCH", f"{node[:50]}: estimated {est} rows, actual {act}; run ANALYZE")
            if loops >= 1000:
                node = line.strip().lstrip("->").strip().split("  ")[0]
                add("LOW", "NESTED_LOOP_MANY", f"{node[:50]} runs {loops} loops; check the join index or consider a hash join")
        if re.search(r"Sort Method: external merge", line):
            add("MEDIUM", "DISK_SORT", "sort spilled to disk (external merge); add an index for ORDER BY or raise work_mem for this query")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return findings


def render(path, stmts, findings):
    out = [f"# SQL performance review: {path}", "",
           f"{len(stmts)} statement(s); {sum(f['severity'] == 'HIGH' for f in findings)} HIGH, "
           f"{sum(f['severity'] == 'MEDIUM' for f in findings)} MEDIUM, {sum(f['severity'] == 'LOW' for f in findings)} LOW", ""]
    for n, st in enumerate(stmts, 1):
        out.append(f"{n}. {st[:110]}{'...' if len(st) > 110 else ''}")
    out += ["", "## Findings"]
    if not findings:
        out.append("- none (heuristics only: still check the real plan with EXPLAIN ANALYZE)")
    for f in findings:
        where = f"stmt {f['statement']}" if f["statement"] else "EXPLAIN"
        out.append(f"- {f['severity']} {f['code']} ({where}): {f['message']}")
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(sql, **kw):
        return [f["code"] for f in review_sql(sql, **kw)[1]]
    check("select star", "SELECT_STAR" in codes("SELECT * FROM users WHERE id = 1"))
    check("alias star", "SELECT_STAR" in codes("SELECT o.* FROM orders o WHERE o.id = 1"))
    check("explicit columns ok", "SELECT_STAR" not in codes("SELECT id, name FROM users WHERE id = 1"))
    check("lower(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM users WHERE LOWER(email) = 'a@b.c'"))
    check("year(col) HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM orders WHERE YEAR(created_at) = 2026"))
    check("arithmetic HIGH", "FUNC_ON_COLUMN" in codes("SELECT id FROM t WHERE price * 2 > 100"))
    check("function on value side ok", "FUNC_ON_COLUMN" not in codes("SELECT id FROM users WHERE email = LOWER('A@B.C')"))
    check("leading wildcard", "LEADING_WILDCARD" in codes("SELECT id FROM p WHERE name LIKE '%phone%'"))
    check("trailing wildcard ok", "LEADING_WILDCARD" not in codes("SELECT id FROM p WHERE name LIKE 'phone%'"))
    check("wildcard inside string literal ignored", "LEADING_WILDCARD" not in codes("SELECT 'x LIKE %y' AS note FROM p WHERE id = 1"))
    check("N+1 detected", "N_PLUS_ONE" in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2; SELECT name FROM users WHERE id = 3;"))
    check("2 repeats not N+1", "N_PLUS_ONE" not in codes("SELECT name FROM users WHERE id = 1; SELECT name FROM users WHERE id = 2;"))
    check("OR across columns", "OR_ACROSS_COLUMNS" in codes("SELECT id FROM u WHERE email = 'a' OR phone = 'b'"))
    check("OR same column LOW", "OR_SAME_COLUMN" in codes("SELECT id FROM u WHERE status = 'a' OR status = 'b'"))
    check("quoted number LOW", "QUOTED_NUMBER" in codes("SELECT id FROM orders WHERE customer_id = '42'"))
    check("implicit cast phone number", "IMPLICIT_CAST" in codes("SELECT id FROM users WHERE phone = 5551234"))
    check("number id ok", "QUOTED_NUMBER" not in codes("SELECT id FROM orders WHERE customer_id = 42"))
    check("NOT IN subquery", "NOT_IN_SUBQUERY" in codes("SELECT id FROM a WHERE id NOT IN (SELECT a_id FROM b)"))
    check("deep offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20 OFFSET 50000"))
    check("mysql limit a,b offset", "DEEP_OFFSET" in codes("SELECT id FROM a ORDER BY id LIMIT 20000, 20"))
    check("order by rand", "ORDER_BY_RANDOM" in codes("SELECT id FROM a ORDER BY RAND() LIMIT 1"))
    check("delete without where", "NO_WHERE_WRITE" in codes("DELETE FROM sessions"))
    check("AND/OR precedence HIGH", "AND_OR_PRECEDENCE" in codes("SELECT id FROM o WHERE a = 1 AND b = 2 OR b = 3"))
    check("parenthesized OR ok", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE a = 1 AND (b = 2 OR b = 3)"))
    check("BETWEEN ... AND not an AND", "AND_OR_PRECEDENCE" not in codes("SELECT id FROM o WHERE d BETWEEN 1 AND 5 OR e = 2"))
    check("literals restored in output", "'mina'" in review_sql("SELECT id FROM u WHERE name = 'mina'")[0][0])
    check("count for exists", "COUNT_FOR_EXISTS" in codes("SELECT CASE WHEN COUNT(*) > 0 THEN 1 END FROM a WHERE b = 1"))
    check("distinct join", "DISTINCT_JOIN" in codes("SELECT DISTINCT u.id FROM users u JOIN orders o ON o.user_id = u.id"))
    idx = parse_indexes("orders(customer_id, created_at)\nusers(id)")
    c = codes("SELECT o.id FROM orders o JOIN users u ON u.id = o.customer_id WHERE o.status = 'paid'", indexes=idx)
    check("no index for filter", c.count("NO_INDEX_FOR_FILTER") == 1)
    check("comments stripped", codes("-- SELECT * FROM x\nSELECT id FROM users WHERE id = 1") == [])
    pg = """Seq Scan on orders  (cost=0.00..25000.00 rows=50 width=8) (actual time=0.1..310.2 rows=48210 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 951790
Sort Method: external merge  Disk: 51200kB"""
    e = [f["code"] for f in review_explain(pg)]
    check("pg seq scan large", "SEQ_SCAN_LARGE" in e)
    check("pg rows removed", "ROWS_REMOVED" in e)
    check("pg estimate mismatch", "EST_MISMATCH" in e)
    check("pg disk sort", "DISK_SORT" in e)
    my = """*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
         type: ALL
          key: NULL
         rows: 980000
     filtered: 5.00
        Extra: Using where; Using temporary; Using filesort"""
    e = [f["code"] for f in review_explain(my)]
    check("mysql type ALL", "SEQ_SCAN_LARGE" in e and "DISK_SORT" in e and "ROWS_REMOVED" in e)
    try:
        review_sql("  -- only a comment\n")
        check("empty input rejected", False)
    except DataError:
        check("empty input rejected", True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="SQL query performance reviewer (heuristic)")
    ap.add_argument("sql", nargs="?", help=".sql file with one or more statements")
    ap.add_argument("--explain", help="EXPLAIN output (PostgreSQL text or MySQL \\G)")
    ap.add_argument("--indexes", help="known indexes, one per line: table(col1, col2)")
    ap.add_argument("--repeat", type=int, default=3, help="N+1 threshold (default 3)")
    ap.add_argument("--large-rows", type=int, default=10000, help="rows for SEQ_SCAN_LARGE (default 10000)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.sql:
        ap.error("give a .sql file or --self-test")
    try:
        with open(a.sql, encoding="utf-8") as fh:
            text = fh.read()
        idx = None
        if a.indexes:
            with open(a.indexes, encoding="utf-8") as fh:
                idx = parse_indexes(fh.read())
        stmts, findings = review_sql(text, a.repeat, idx)
        if a.explain:
            with open(a.explain, encoding="utf-8") as fh:
                findings += review_explain(fh.read(), a.large_rows)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"statements": stmts, "findings": findings}, indent=2))
    else:
        print(render(a.sql, stmts, findings))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSQLDatabase+1
F@f
0
Trình Kiểm tra Chính sách Mật khẩu và Entropy
Kỹ năng

Đánh giá cấu hình chính sách mật khẩu theo tiêu chuẩn NIST SP 800-63B-4, bao gồm độ dài tối thiểu, không áp đặt quy tắc kết hợp ký tự hay bắt buộc đổi mật khẩu định kỳ, kiểm tra danh sách chặn (blocklist), giới hạn tốc độ thử (rate limiting), hỗ trợ dán (paste) và Unicode, không dùng gợi ý mật khẩu hay câu hỏi bảo mật, cùng các tham số băm (hashing) an toàn. Ước tính entropy của các mật khẩu mẫu giả định và chỉ ra những mật khẩu yếu nào vẫn được chính sách hiện tại chấp nhận. Không bao giờ yêu cầu mật khẩu thật. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: password-policy-and-entropy-checker
description: Đánh giá cấu hình chính sách mật khẩu theo tiêu chuẩn NIST SP 800-63B-4, bao gồm độ dài tối thiểu, không áp đặt quy tắc kết hợp ký tự hay bắt buộc đổi mật khẩu định kỳ, kiểm tra danh sách chặn (blocklist), giới hạn tốc độ thử (rate limiting), hỗ trợ dán (paste) và Unicode, không dùng gợi ý mật khẩu hay câu hỏi bảo mật, cùng các tham số băm (hashing) an toàn. Ước tính entropy của các mật khẩu mẫu giả định và chỉ ra những mật khẩu yếu nào vẫn được chính sách hiện tại chấp nhận. Không bao giờ yêu cầu mật khẩu thật. Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Kiểm tra Chính sách Mật khẩu và Entropy

Bạn giúp các lập trình viên, quản trị viên CNTT và chuyên gia đánh giá bảo mật điều chỉnh chính sách mật khẩu phù hợp với hướng dẫn NIST hiện hành (SP 800-63B-4, 2025), đồng thời giải thích tại sao các quy tắc cũ (8 ký tự, chữ hoa+chữ thường+chữ số+ký tự đặc biệt, đổi mỗi 90 ngày) lại tạo ra những mật khẩu yếu và dễ đoán. Bạn chứng minh điều đó bằng những con số cụ thể: ước tính entropy cho các mật khẩu mẫu giả định và danh sách các mẫu mật khẩu yếu mà chính sách hiện tại vẫn cho phép lọt qua.

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

- `scripts/pw_policy.py` - kiểm tra tệp JSON chính sách đối chiếu với NIST SP 800-63B-4 và mức tối thiểu về băm mật khẩu của OWASP, đồng thời ước tính entropy cho các mẫu tổng hợp, mặc định được che (mask) ở đầu ra (chỉ dùng thư viện chuẩn Python 3; không kết nối mạng, không lưu trữ bất cứ thứ gì)
- `references/nist-800-63b-rules.md` - các yêu cầu về mật khẩu (SHALL / SHOULD) cùng ý nghĩa thực tế của từng yêu cầu
- `references/entropy-and-hashing.md` - cách thức hoạt động của hai phương pháp ước tính entropy, giới hạn của chúng, các giả định về tốc độ đoán mật khẩu và các tham số băm
- `templates/policy-recommendation.md` - bản khuyến nghị bạn trả về: thiết lập hiện tại so với thiết lập khuyến nghị, lý do và kế hoạch triển khai
- `examples/legacy-portal-policy.json` và `examples/synthetic-samples.txt` - một chính sách cổng thông tin khách hàng kiểu cũ và 8 mật khẩu mẫu giả định

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

### 1. Thu thập chính sách, tuyệt đối không thu thập mật khẩu thật
Yêu cầu người dùng cung cấp các thiết lập chính sách (từ nhà cung cấp danh tính - IdP, chính sách mật khẩu chi tiết của Active Directory, cấu hình framework như `AUTH_PASSWORD_VALIDATORS` của Django, hoặc tài liệu chính sách bằng văn bản) và chuyển đổi chúng thành các khóa JSON như trong docstring của script. Hỏi xem mật khẩu là yếu tố xác thực duy nhất hay luôn được kết hợp với MFA (`single_factor`). Nếu người dùng muốn kiểm tra độ mạnh, hãy yêu cầu họ tự nghĩ ra các mẫu giống với cách người dùng thường đặt. Nếu ai đó dán nội dung trông giống mật khẩu thật, tuyệt đối không lặp lại mật khẩu đó, hãy nhắc họ đổi mật khẩu ngay và tiếp tục làm việc bằng các mẫu giả định.

### 2. Chạy trình kiểm tra
```
python3 scripts/pw_policy.py examples/legacy-portal-policy.json --samples examples/synthetic-samples.txt --context northwind
python3 scripts/pw_policy.py policy.json --samples synthetic.txt --blocklist top-10k.txt --json
python3 scripts/pw_policy.py --self-test
```
`--context` thêm tên dịch vụ, thương hiệu và các từ tương tự vào bước kiểm tra danh sách chặn (blocklist). Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.

### 3. Giải thích các phát hiện
Bắt đầu với những gì thay đổi đối với người dùng: độ dài tối thiểu lớn hơn (15 ký tự nếu dùng đơn lẻ, 8 ký tự nếu có MFA), bỏ quy tắc bắt buộc đủ nhóm ký tự, bỏ bắt buộc đổi mật khẩu định kỳ, từ chối mật khẩu nằm trong danh sách chặn kèm lý do rõ ràng. Tiếp theo là phần hậu cần (back end): giới hạn tốc độ thử (rate limiting), thuật toán và tham số băm, gỡ bỏ gợi ý mật khẩu và câu hỏi bảo mật. Sử dụng bảng mẫu để chỉ ra rằng các mật khẩu kiểu `Password1!` vượt qua được các quy tắc kết hợp ký tự trong khi một cụm mật khẩu (passphrase) gồm bốn từ lại bị chúng từ chối. Sử dụng `references/nist-800-63b-rules.md` để trích dẫn yêu cầu chính xác và `references/entropy-and-hashing.md` khi người dùng hỏi "mạnh bao nhiêu là đủ".

### 4. Bàn giao kết quả
Điền vào `templates/policy-recommendation.md` với từng thiết lập (hiện tại, khuyến nghị, tham chiếu NIST), lộ trình chuyển đổi thuật toán băm (băm lại trong lần đăng nhập tiếp theo) và kế hoạch triển khai: áp dụng quy tắc mới vào lần đổi mật khẩu tiếp theo, gỡ bỏ ngay lập tức quy định hết hạn định kỳ, và kiểm tra mật khẩu bị lộ lọt ngay khi đăng nhập ở những nơi có thể thực hiện.

## Kết quả ví dụ
Đối với chính sách cổng thông tin kiểu cũ cùng 8 mẫu tổng hợp và `--context northwind`: 11 phát hiện HIGH, 5 MEDIUM, 2 LOW. Mức HIGH: độ dài tối thiểu 8 (cần 15 khi không có MFA), có quy tắc kết hợp ký tự, hết hạn sau 90 ngày, không có danh sách chặn, dùng gợi ý mật khẩu, dùng câu hỏi bảo mật, và 5 mẫu yếu được chính sách chấp nhận (Password1!, Summer2025!, Northwind#1, Qwerty123$, P@ssw0rd2026, mỗi mẫu chỉ khoảng ~15-20 bit). Mức MEDIUM: PBKDF2 ở mức 10000 vòng lặp, độ dài tối đa 20, chặn dấu cách và thao tác dán, không có hướng dẫn độ mạnh. Cụm mật khẩu 4 từ (~54 bit) và cụm 5 từ (~67 bit) bị quy tắc hiện tại từ chối nhưng hoàn toàn hợp lệ theo chuẩn NIST.

## Quy tắc

- Không bao giờ yêu cầu, lưu trữ hoặc lặp lại mật khẩu thật, mã băm (hash) thật hay tệp mật khẩu thật. Chỉ làm việc với các mẫu giả định.
- Các con số entropy là mức ước tính để so sánh, không phải sự đảm bảo tuyệt đối; hãy nêu rõ điều đó.
- Trích dẫn chính xác mức SHALL và SHOULD của NIST; không trình bày các con số khuyến nghị băm của OWASP như thể đó là yêu cầu bắt buộc của NIST.
- MFA chống giả mạo (phishing-resistant MFA) hoặc passkey vượt trội hơn bất kỳ quy tắc mật khẩu nào; hãy khuyến nghị chúng ở những nơi mức độ rủi ro đòi hỏi.
- Không khuyến nghị làm suy yếu một biện pháp kiểm soát (ví dụ: giới hạn tốc độ thử) để cải thiện tính tiện dụng mà không có biện pháp thay thế.
FILE:references/nist-800-63b-rules.md
# Quy tắc Mật khẩu NIST SP 800-63B-4 (bản chính thức, 2025)

Mục 3.1.1.2 "Password Verifiers" áp dụng cho mật khẩu được xác minh tập trung (biểu mẫu đăng nhập được kiểm tra bởi máy chủ). Mã PIN mở khóa cục bộ là "activation secrets" (bí mật kích hoạt) với các quy tắc khác (Mục 3.2.10). SHALL = bắt buộc, SHOULD = khuyến nghị.

| # | Yêu cầu | Mức độ | Mã trong script | Trong thực tế |
|---|---|---|---|---|
| 1 | Tối thiểu 15 ký tự khi mật khẩu là yếu tố duy nhất; tối thiểu 8 ký tự nếu chỉ dùng kèm MFA | SHALL | MIN_LENGTH | Đặt 15 cho đăng nhập chỉ dùng mật khẩu; mức 8 chỉ chấp nhận được nếu MFA luôn được áp dụng bắt buộc |
| 2 | Cho phép độ dài tối đa ít nhất 64 ký tự | SHOULD | MAX_LENGTH | Cụm mật khẩu và đầu ra của trình quản lý mật khẩu cần không gian; kích thước lưu trữ đằng nào cũng cố định theo mã băm |
| 3 | Chấp nhận tất cả các ký tự ASCII in được và dấu cách | SHOULD | NO_SPACES | Không từ chối `"`, `'`, `<`, dấu cách; thay vào đó hãy xử lý thoát ký tự (escape) đúng cách |
| 4 | Chấp nhận Unicode; đếm mỗi điểm mã (code point) là một ký tự | SHOULD | NO_UNICODE | Áp dụng chuẩn hóa NFC trước khi băm (NO_NORMALIZATION) |
| 5 | Không áp đặt các quy tắc kết hợp ký tự khác (trộn các nhóm ký tự) | SHALL NOT | COMPOSITION_RULES | Gỡ bỏ quy định "1 chữ hoa, 1 chữ số, 1 ký tự đặc biệt" |
| 6 | Không bắt buộc đổi mật khẩu định kỳ; chỉ bắt buộc đổi khi có bằng chứng lộ lọt | SHALL NOT | PERIODIC_EXPIRY | Gỡ bỏ thời hạn 90 ngày; đặt lại mật khẩu khi có bằng chứng rò rỉ |
| 7 | Không cung cấp gợi ý mật khẩu mà người dùng chưa xác thực có thể truy cập | SHALL NOT | HINTS | Xóa các gợi ý mật khẩu đang lưu trữ |
| 8 | Không sử dụng xác thực dựa trên kiến thức (KBA) / câu hỏi bảo mật | SHALL NOT | SECURITY_QUESTIONS | Sử dụng mã khôi phục hoặc một kênh đã được xác minh |
| 9 | Xác minh toàn bộ mật khẩu, không cắt bớt (truncation), không yêu cầu kiểu "nhập ký tự thứ 3, 5 và 8" | SHALL | TRUNCATION | Lưu ý giới hạn 72 byte của bcrypt (BCRYPT_72_BYTES) |
| - | So sánh toàn bộ mật khẩu với danh sách chặn gồm các giá trị phổ biến, dễ đoán và đã bị lộ; nêu rõ lý do khi từ chối | SHALL | NO_BLOCKLIST, BLOCKLIST_THIN | Kho dữ liệu mật khẩu bị rò rỉ (ví dụ: API truy vấn theo dải k-anonymity hoặc danh sách tải về), từ điển, tên dịch vụ, tên người dùng |
| - | Cung cấp hướng dẫn giúp người dùng chọn mật khẩu mạnh, đặc biệt là sau khi mật khẩu bị từ chối | SHALL | NO_GUIDANCE | Thanh đo độ mạnh và lý do từ chối bằng ngôn ngữ dễ hiểu |
| - | Giới hạn tốc độ các lần thử thất bại: không quá 100 lần thất bại liên tiếp trên mỗi tài khoản (Mục 3.2.2) | SHALL | NO_RATE_LIMIT | Độ trễ tăng dần, CAPTCHA, kiểm tra rủi ro; khóa cứng tài khoản sau 3 lần thử dễ dẫn đến tấn công từ chối dịch vụ (LOCKOUT_STRICT) |
| - | Cho phép trình quản lý mật khẩu và tự động điền (autofill); cho phép dán (paste) | xem Mục 3.1.1.2 | PASTE_BLOCKED | Không bao giờ chặn thao tác dán trên các trường mật khẩu |
| - | Lưu trữ mật khẩu đã thêm muối (salted) và băm (hashed) bằng lược đồ băm mật khẩu phù hợp (Mục 3.1.1.2, phần cuối) | SHALL | WEAK_STORAGE | Argon2id, scrypt, bcrypt hoặc PBKDF2 với salt duy nhất; các tham số đặt cao nhất có thể trong thực tế |

## Quy mô danh sách chặn (Blocklist)
NIST lưu ý rằng danh sách chặn chủ yếu ngăn chặn việc đoán mật khẩu trực tuyến (vốn đã bị giới hạn tốc độ), vì vậy danh sách này nên bao quát những mật khẩu mà kẻ tấn công sẽ thử trước khi chạm ngưỡng giới hạn (từ vài nghìn đến vài trăm nghìn mục); những danh sách quá lớn chủ yếu chỉ gây phiền toái cho người dùng. Hãy kiểm tra toàn bộ mật khẩu chứ không kiểm tra chuỗi con, nhưng cần bao gồm các biến thể hiển nhiên của tên dịch vụ và tên người dùng.

## Tại sao nên loại bỏ quy tắc kết hợp ký tự và thời hạn mật khẩu
Người dùng đối phó với quy định "chữ hoa + chữ số + ký tự đặc biệt" bằng `Password1!`; việc bắt buộc đổi mật khẩu định kỳ tạo ra `Password2!`, `Password3!`. Các công cụ tấn công đều mô hình hóa những khuôn mẫu này, vì vậy các quy tắc đó chỉ làm tăng sự phiền phức mà không mang lại entropy thực sự. Độ dài cộng với danh sách chặn mang lại hiệu quả cao hơn nhiều.

## Nguồn tham khảo
- NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management (Tháng 7 năm 2025), https://pages.nist.gov/800-63-4/sp800-63b.html
- Phụ lục A "Strength of Passwords" trong cùng tài liệu
FILE:references/entropy-and-hashing.md
# Ước tính Entropy và Băm Mật khẩu (Password Hashing)

## Hai phương pháp ước tính
**Entropy tập ký tự (Charset entropy)** = độ dài x log2(pool), với pool = 26 chữ thường + 26 chữ hoa + 10 chữ số + 33 ký tự đặc biệt (+100 nếu có ký tự ngoài bảng ASCII), chỉ tính các nhóm ký tự thực sự được sử dụng. Công thức này chính xác đối với các mật khẩu được tạo ngẫu nhiên từ tập ký tự đó nhưng lại đánh giá quá cao một cách sai lệch đối với lựa chọn của con người: `Password1!` đạt tới 65.7 bit.

**Entropy theo mẫu (Pattern entropy)** (trong script) xấp xỉ cách một công cụ bẻ khóa đoán mật khẩu: nó duyệt mật khẩu từ trái sang phải và tính điểm
| Thành phần | Chi phí |
|---|---|
| từ thông dụng trong danh sách tích hợp sẵn (bao gồm cả dạng leet như `P@ssw0rd`, các từ ngữ cảnh) | log2(kích thước danh sách) ~ 7.6 bit, +1 nếu viết hoa chữ cái đầu, +2 nếu dùng leet |
| năm từ 1950-2039 | log2(90) ~ 6.5 bit |
| chuỗi phím liền kề trên bàn phím hoặc bảng chữ cái (`qwerty`, `1234`, `zyx`) | log2(26) + log2(độ dài) |
| ký tự lặp lại (`aaaa`) | một ký tự + log2(số lần lặp) |
| chữ số/ký tự đặc biệt nối thêm vào cuối sau một từ | -1.5 bit (khuôn mẫu phổ biến nhất) |
| bất kỳ ký tự nào khác | log2(pool) |
| cụm mật khẩu (passphrase) từ 3 từ trở lên | 12.9 bit cho mỗi từ không phổ biến (danh sách Diceware gồm 7776 từ), 1 bit cho mỗi từ phổ biến |

Con số của cụm mật khẩu là giới hạn trên: nó chỉ đúng nếu các từ được chọn hoàn toàn ngẫu nhiên (bằng xúc xắc hoặc trình tạo ngẫu nhiên). Cụm "my dog loves pizza" yếu hơn thế rất nhiều. Danh sách từ tích hợp sẵn có quy mô nhỏ; một công cụ bẻ khóa thực tế sử dụng hàng tỷ mật khẩu bị rò rỉ, vì vậy hãy coi mọi ước tính đều là mức lạc quan đối với mật khẩu do con người tự chọn.

## Thang đánh giá (số bit theo mẫu)
| Số bit | Đánh giá | Ý nghĩa |
|---|---|---|
| < 28 | rất yếu (very weak) | Bị hạ gục bởi đoán trực tuyến hoặc bị bẻ khóa tức thì khi tấn công ngoại tuyến |
| 28-35 | yếu (weak) | Giới hạn tốc độ trực tuyến có giúp ích; nhưng tấn công ngoại tuyến chỉ mất vài phút |
| 36-59 | trung bình (fair) | Ổn trước các cuộc tấn công trực tuyến có giới hạn tốc độ; nhưng yếu nếu mã băm nhanh bị lộ |
| 60-79 | mạnh (strong) | Chống chịu được các cuộc tấn công ngoại tuyến trên các hàm băm chậm |
| >= 80 | rất mạnh (very strong) | Chống chịu được các cuộc tấn công ngoại tuyến ngay cả trên các hàm băm nhanh |

## Tốc độ đoán dùng để tính thời gian
- Ngoại tuyến, hàm băm nhanh (MD5/SHA-x trên GPU): 1e10 lần đoán mỗi giây.
- Ngoại tuyến, hàm băm chậm (Argon2id/bcrypt ở mức chi phí khuyến nghị): khoảng 1e4 lần đoán mỗi giây trên mỗi dàn máy của kẻ tấn công.
- Trực tuyến với cơ chế giới hạn tốc độ của NIST (<= 100 lần thất bại trên mỗi tài khoản, sau đó áp dụng độ trễ): chỉ vài trăm đến vài nghìn lần đoán đầu tiên là có ý nghĩa, đó là lý do danh sách chặn là biện pháp kiểm soát trực tuyến then chốt.
Thời gian hiển thị = trường hợp trung vị, 2^(bits-1) / tốc độ.

## Các tham số băm (mức tối thiểu theo OWASP Password Storage Cheat Sheet)
| Thuật toán | Mức tối thiểu | Ghi chú |
|---|---|---|
| Argon2id | m = 19456 KiB (19 MiB), t = 2, p = 1 | Lựa chọn ưu tiên hàng đầu |
| scrypt | N = 2^17, r = 8, p = 1 | |
| bcrypt | cost 10 | Đầu vào giới hạn ở 72 byte; cần băm sơ bộ (pre-hash) bằng HMAC-SHA256 + base64 hoặc giới hạn độ dài |
| PBKDF2-HMAC-SHA256 | 600000 vòng lặp | Sử dụng ở những nơi yêu cầu mật mã đạt chuẩn xác thực FIPS-140 |
Mỗi mã băm cần một muối (salt) ngẫu nhiên duy nhất (>= 16 byte). Một hạt tiêu (pepper - khóa bí mật được giữ bên ngoài cơ sở dữ liệu) tùy chọn sẽ bổ sung thêm lớp phòng thủ chiều sâu. NIST yêu cầu thêm muối và sử dụng hàm băm mật khẩu một chiều phù hợp; các con số của OWASP là mức cơ sở được sử dụng rộng rãi, không phải quy tắc của NIST.

## Chuyển đổi từ các mã băm yếu
1. Thêm thuật toán mới song song với thuật toán cũ.
2. Khi đăng nhập thành công, xác minh bằng mã băm cũ, sau đó băm lại bằng thuật toán mới.
3. Đối với những tài khoản không bao giờ đăng nhập, hãy bọc mã băm cũ lại (Argon2id(old_hash)) và đánh dấu chúng, hoặc bắt buộc đặt lại mật khẩu sau một thời hạn nhất định.
4. Kiểm tra lại trạng thái mật khẩu bị lộ lọt khi đăng nhập nếu nhà cung cấp của bạn hỗ trợ.
FILE:templates/policy-recommendation.md
# Khuyến nghị chính sách mật khẩu: <tên hệ thống>

**Phạm vi:** <cổng thông tin khách hàng / SSO nhân sự / bảng điều khiển quản trị>   **Yếu tố xác thực:** <chỉ mật khẩu / mật khẩu + MFA>
**Cơ sở:** NIST SP 800-63B-4 (2025); các tham số băm từ OWASP Password Storage Cheat Sheet
**Tóm tắt:** <một câu: ví dụ: "Thay thế các quy tắc độ phức tạp và hết hạn định kỳ bằng độ dài tối thiểu 15 ký tự và danh sách chặn mật khẩu bị rò rỉ; chuyển đổi mã băm sang Argon2id.">

## Các thiết lập
| Thiết lập | Hiện tại | Khuyến nghị | NIST | Lý do |
|---|---|---|---|---|
| Độ dài tối thiểu | <8> | <15 (8 nếu luôn bật MFA)> | SHALL | <...> |
| Độ dài tối đa | <20> | <64 trở lên> | SHOULD | <...> |
| Quy tắc kết hợp ký tự | <chữ hoa, chữ thường, chữ số, ký tự đặc biệt> | không áp dụng | SHALL NOT | <...> |
| Thời hạn hiệu lực | <90 ngày> | không áp dụng; đặt lại khi bị lộ | SHALL NOT | <...> |
| Danh sách chặn (Blocklist) | <không có> | mật khẩu rò rỉ + từ điển + từ ngữ cảnh, khớp toàn bộ mật khẩu | SHALL | <...> |
| Dấu cách / Unicode | <từ chối> | chấp nhận, chuẩn hóa NFC | SHOULD | <...> |
| Dán / trình quản lý mật khẩu | <bị chặn> | cho phép | | <...> |
| Gợi ý / câu hỏi bảo mật | <có> | gỡ bỏ | SHALL NOT | <...> |
| Số lần thử thất bại | <khóa sau 3 lần trong 60 phút> | điều tiết tốc độ, <= 100 lần thất bại liên tiếp | SHALL | <...> |
| Hướng dẫn độ mạnh | <không có> | thanh đo độ mạnh + lý do từ chối | SHALL | <...> |
| Lưu trữ | <PBKDF2-SHA256, 10000> | <Argon2id m=19 MiB t=2 p=1> | SHALL (băm có muối) | <...> |

## Những gì các mẫu mật khẩu cho thấy (chỉ dùng mật khẩu giả định)
<Bảng ngắn hoặc câu tóm tắt: ví dụ: "4 trong số 5 khuôn mẫu điển hình (Password1!, Summer2025!) vượt qua được các quy tắc hiện nay ở mức ~20 bit; một cụm mật khẩu ngẫu nhiên gồm 4 từ (~54 bit) hiện bị từ chối nhưng sẽ được chấp nhận theo chính sách mới.">

## Lộ trình triển khai
1. <Ngày 0: gỡ bỏ thời hạn mật khẩu, cho phép dán và dùng dấu cách, bật thanh đo độ mạnh>
2. <Tuần 1: áp dụng danh sách chặn cho mật khẩu tạo mới và đổi mật khẩu; hiển thị rõ lý do từ chối>
3. <Tuần 2: nâng độ dài tối thiểu cho mật khẩu mới; áp dụng cho người dùng hiện tại vào lần đổi mật khẩu tiếp theo>
4. <Băm lại sang Argon2id vào lần đăng nhập tiếp theo; bắt buộc đặt lại mật khẩu đối với các tài khoản không hoạt động sau 6 tháng>
5. <Gỡ bỏ gợi ý mật khẩu và câu hỏi bảo mật; chuyển quy trình khôi phục sang email đã xác minh + mã khôi phục>

## Truyền thông tới người dùng
<2-3 câu: mật khẩu càng dài càng mạnh, khuyến khích dùng cụm mật khẩu (passphrase), khuyến khích dùng trình quản lý mật khẩu, không còn bắt buộc đổi mật khẩu định kỳ>

## Các câu hỏi mở
<Phạm vi bao phủ MFA, giới hạn của nhà cung cấp danh tính (IdP), các khung tuân thủ vẫn còn đề cập đến thời hạn mật khẩu (hãy kiểm tra các phiên bản hiện hành; nhiều khung tiêu chuẩn hiện đã cập nhật theo NIST)>
FILE:examples/legacy-portal-policy.json
{
  "name": "Northwind customer portal (legacy policy)",
  "single_factor": true,
  "min_length": 8,
  "max_length": 20,
  "composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
  "expiry_days": 90,
  "blocklist": {"enabled": false},
  "allow_spaces": false,
  "allow_unicode": false,
  "allow_paste": false,
  "truncate_at": null,
  "hints": true,
  "security_questions": true,
  "strength_guidance": false,
  "rate_limit": {"max_failed_attempts": 3, "lockout_minutes": 60},
  "storage": {"algorithm": "PBKDF2-SHA256", "iterations": 10000, "salted": true}
}
FILE:examples/synthetic-samples.txt
# Made-up sample passwords for the review. NEVER put real passwords in this file.
Password1!
Summer2025!
Northwind#1
Qwerty123$
P@ssw0rd2026
tundra velvet orbit cactus
vT9#qLm2!xR4wZp7
glacier-mango-quietly-violin-ember
FILE:scripts/pw_policy.py
#!/usr/bin/env python3
"""Password policy and entropy checker (Python 3 standard library only).

Checks a password policy config against NIST SP 800-63B-4 (2025) password guidance and estimates
the strength of SYNTHETIC sample passwords. Never use real passwords: samples are masked in the
output unless --show-samples is given, and nothing is stored or sent anywhere.

Policy JSON (all keys optional except min_length):
  {"name": "Customer portal", "single_factor": true,        # password alone, no MFA
   "min_length": 8, "max_length": 20,
   "composition": {"upper": true, "lower": true, "digit": true, "symbol": true},
   "expiry_days": 90,
   "blocklist": {"enabled": true, "breached": true, "context_words": ["acme", "portal"]},
   "allow_spaces": true, "allow_unicode": true, "unicode_normalization": "NFC",
   "allow_paste": true, "truncate_at": null, "hints": false, "security_questions": false,
   "strength_guidance": true,
   "rate_limit": {"max_failed_attempts": 10, "lockout_minutes": 15},
   "storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": true}}
Policy findings (severity):
  HIGH   MIN_LENGTH          < 15 for single-factor passwords, < 8 with MFA (SHALL)
  HIGH   COMPOSITION_RULES   required character classes (SHALL NOT)
  HIGH   PERIODIC_EXPIRY     forced change every N days (SHALL NOT; change only on compromise)
  HIGH   NO_BLOCKLIST        no blocklist check of the full password (SHALL)
  HIGH   HINTS / SECURITY_QUESTIONS   password hints or KBA prompts (SHALL NOT)
  HIGH   TRUNCATION          only part of the password is verified (SHALL verify the whole)
  HIGH   NO_RATE_LIMIT       no throttling of failed attempts (SHALL; at most 100 in a row)
  HIGH   WEAK_STORAGE        plaintext, reversible encryption, MD5/SHA-x or unsalted hashes
  MEDIUM WEAK_KDF_PARAMS     below OWASP minimums (PBKDF2-SHA256 600000, bcrypt cost 10,
                             Argon2id 19 MiB / t=2, scrypt N=2^17)
  MEDIUM BCRYPT_72_BYTES     bcrypt ignores bytes after 72 but max length allows more
  MEDIUM MAX_LENGTH          maximum below 64 (SHOULD allow at least 64)
  MEDIUM NO_SPACES           spaces not accepted (SHOULD accept printing ASCII and space)
  MEDIUM PASTE_BLOCKED       paste / password managers blocked (SHOULD allow)
  MEDIUM NO_GUIDANCE         no strength guidance shown to users (SHALL offer guidance)
  MEDIUM BLOCKLIST_THIN      blocklist without breached passwords or context words
  LOW    NO_UNICODE          Unicode not accepted (SHOULD)
  LOW    NO_NORMALIZATION    Unicode accepted without NFC normalization (SHOULD)
  LOW    LOCKOUT_STRICT      lockout after < 5 attempts: easy denial of service, use throttling
Sample findings:
  HIGH   SAMPLE_WEAK_ACCEPTED  a blocklisted or < 36-bit sample passes the current policy
Entropy (bits): charset = length x log2(pool of character classes used); pattern = a simple
guess-model estimate that charges little for common words, leet, years, keyboard runs, repeats
and passphrase words, and log2(pool) for every other character (per word log2(7776) = 12.9 bits at most). Rating uses the pattern estimate:
< 28 very weak, < 36 weak, < 60 fair, < 80 strong, else very strong.
Usage:
  python3 pw_policy.py policy.json
  python3 pw_policy.py policy.json --samples synthetic.txt [--context northwind,portal] [--blocklist list.txt] [--json]
  python3 pw_policy.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import re
import sys
import unicodedata

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
COMMON = """password passw0rd welcome letmein qwerty qwertz azerty admin administrator login master
dragon monkey sunshine princess football baseball soccer hockey shadow superman batman iloveyou
trustno1 secret summer winter spring autumn fall monday friday january october december love
hello freedom whatever charlie michael jessica jordan pepper ginger cookie chocolate flower
computer internet google apple samsung starwars pokemon matrix killer hunter ranger thunder
tiger lion eagle dolphin falcon soccer liverpool chelsea arsenal barcelona madrid london paris
berlin newyork america canada company office service portal change changeme default guest user
test testing temp temporary access security system server database root toor manager support
student school teacher family mother father daddy mommy baby angel blessed jesus god heaven
money dollar euro bitcoin crypto summer2024 welcome1 password1 password123 abc123 iloveyou1
""".split()
COMMON_SET = set(COMMON)
TOP_BLOCK = set(COMMON) | {"123456", "12345678", "123456789", "1234567890", "111111", "000000",
                           "123123", "654321", "qwerty123", "1q2w3e4r", "zaq12wsx", "p@ssw0rd",
                           "password1!", "welcome123", "admin123", "letmein1", "qwertyuiop"}
LEET = str.maketrans({"@": "a", "4": "a", "0": "o", "1": "i", "!": "i", "3": "e", "$": "s",
                      "5": "s", "7": "t", "+": "t", "8": "b", "9": "g"})
ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "abcdefghijklmnopqrstuvwxyz", "qwertzuiop", "azertyuiop"]
WORD_BITS = math.log2(7776)


class DataError(ValueError):
    pass


def char_pool(pw):
    pool = 0
    if re.search(r"[a-z]", pw):
        pool += 26
    if re.search(r"[A-Z]", pw):
        pool += 26
    if re.search(r"[0-9]", pw):
        pool += 10
    if re.search(r"[ -/:-@\[-`{-~]", pw):
        pool += 33
    if any(ord(c) > 126 for c in pw):
        pool += 100
    return pool or 1


def class_bits(c):
    if c.islower() and c.isascii():
        return math.log2(26)
    if c.isupper() and c.isascii():
        return math.log2(26)
    if c.isdigit():
        return math.log2(10)
    if ord(c) > 126:
        return math.log2(100)
    return math.log2(33)


def charset_bits(pw):
    return len(pw) * math.log2(char_pool(pw))


def seq_len(s, i):
    """Length of a keyboard/alphabet run (forward or backward) starting at i."""
    best = 1
    low = s.lower()
    for row in ROWS:
        for r in (row, row[::-1]):
            j = r.find(low[i]) if low[i] in r else -1
            if j < 0:
                continue
            n = 1
            while i + n < len(low) and j + n < len(r) and low[i + n] == r[j + n]:
                n += 1
            best = max(best, n)
    return best


def pattern_bits(pw, context=()):
    """Greedy left-to-right guess model. Returns (bits, notes)."""
    notes = []
    vocab = COMMON_SET | {w.lower() for w in context if len(w) >= 3}
    words = [w for w in re.split(r"[\s\-_.]+", pw) if w]
    if len(words) >= 3 and all(re.fullmatch(r"[A-Za-z]{3,}", w) for w in words):
        bits = sum(1.0 if w.lower() in vocab else WORD_BITS for w in words) + math.log2(4)
        notes.append(f"passphrase of {len(words)} words (upper bound if words were picked at random)")
        return min(bits, charset_bits(pw)), notes
    i, bits, low = 0, 0.0, pw.lower()
    brute = math.log2(char_pool(pw))
    while i < len(pw):
        # dictionary word (with leet), longest first
        hit = None
        for L in range(min(len(pw) - i, 14), 3, -1):
            chunk = low[i:i + L]
            plain = chunk.translate(LEET)
            if plain in vocab:
                hit = (L, plain, chunk != plain)
                break
        run = seq_len(pw, i)
        if hit and run < hit[0]:
            L, word, leet = hit
            b = math.log2(len(COMMON)) + (1 if pw[i].isupper() else 0) + (2 if leet else 0)
            notes.append(f"common word '{word}'" + (" (leet)" if leet else ""))
            bits += b
            i += L
            continue
        m = re.match(r"(19[5-9]\d|20[0-3]\d)", pw[i:])
        if m:
            notes.append(f"year {m.group(1)}")
            bits += math.log2(90)
            i += 4
            continue
        n = run
        if n >= 3:
            notes.append(f"keyboard/alphabet run '{pw[i:i + n]}'")
            bits += math.log2(26) + math.log2(n)
            i += n
            continue
        r = 1
        while i + r < len(pw) and pw[i + r] == pw[i]:
            r += 1
        if r >= 3:
            notes.append(f"repeated '{pw[i]}' x{r}")
            bits += class_bits(pw[i]) + math.log2(r)
            i += r
            continue
        bits += brute
        i += 1
    if notes and re.search(r"[0-9!?.]{1,3}$", pw) and len(pw) > 4:
        bits -= 1.5
        notes.append("digits/symbol appended at the end (most common pattern)")
    return max(bits, 0.0), notes


def rating(bits):
    return ("very weak" if bits < 28 else "weak" if bits < 36 else "fair" if bits < 60
            else "strong" if bits < 80 else "very strong")


def crack_time(bits, rate):
    sec = 2 ** (bits - 1) / rate
    for unit, n in (("years", 31557600), ("days", 86400), ("hours", 3600), ("minutes", 60)):
        if sec >= n:
            v = sec / n
            return f"{v:.3g} {unit}" if v < 100 else f"{v:,.0f} {unit}" if v < 1e6 else f"{v:.1e} {unit}"
    return "instant" if sec < 1 else f"{sec:.0f} seconds"


def mask_pw(pw):
    if len(pw) <= 4:
        return "*" * len(pw)
    return pw[:2] + "*" * (len(pw) - 3) + pw[-1:]


def policy_accepts(policy, pw):
    reasons = []
    if len(pw) < int(policy.get("min_length", 0)):
        reasons.append("too short")
    mx = policy.get("max_length")
    if mx and len(pw) > int(mx):
        reasons.append("too long")
    comp = policy.get("composition") or {}
    for key, rx in (("upper", r"[A-Z]"), ("lower", r"[a-z]"), ("digit", r"[0-9]"), ("symbol", r"[^A-Za-z0-9\s]")):
        if comp.get(key) and not re.search(rx, pw):
            reasons.append(f"needs {key}")
    if " " in pw and policy.get("allow_spaces") is False:
        reasons.append("space not allowed")
    if any(ord(c) > 126 for c in pw) and policy.get("allow_unicode") is False:
        reasons.append("unicode not allowed")
    if (policy.get("blocklist") or {}).get("enabled") and pw.lower() in TOP_BLOCK:
        reasons.append("blocklisted")
    return not reasons, reasons


def core_word(low):
    """Strip leading/trailing digits and symbols, undo leet, keep letters: 'P@ssw0rd2026!' -> 'password'."""
    core = re.sub(r"^[\d\W_]+|[\d\W_]+$", "", low)
    return re.sub(r"[^a-z]", "", core.translate(LEET))


def blocklisted(pw, extra=(), context=()):
    low = unicodedata.normalize("NFC", pw).lower()
    if low in TOP_BLOCK or low in extra:
        return "common/breached password"
    core = core_word(low)
    for w in context:
        if w and len(w) >= 3 and w.lower() in core and len(low) - len(w) <= 6:
            return f"based on context word '{w}'"
    if core in COMMON_SET and len(low) - len(core) <= 6:
        return "common word with a few digits/symbols"
    return None


def check_policy(p):
    f = []

    def add(sev, code, msg):
        f.append({"severity": sev, "code": code, "message": msg})
    if "min_length" not in p:
        raise DataError("policy needs min_length")
    single = p.get("single_factor", True)
    mn = int(p["min_length"])
    need = 15 if single else 8
    if mn < need:
        add("HIGH", "MIN_LENGTH", f"minimum {mn} characters; NIST requires >= {need} for passwords used {'alone' if single else 'with MFA'}")
    comp = [k for k, v in (p.get("composition") or {}).items() if v]
    if comp:
        add("HIGH", "COMPOSITION_RULES", f"requires {', '.join(comp)}; composition rules SHALL NOT be imposed (they produce 'Password1!' patterns); rely on length + blocklist")
    if int(p.get("expiry_days") or 0) > 0:
        add("HIGH", "PERIODIC_EXPIRY", f"forced change every {p['expiry_days']} days; SHALL NOT expire periodically, force a change only on evidence of compromise")
    bl = p.get("blocklist") or {}
    if not bl.get("enabled"):
        add("HIGH", "NO_BLOCKLIST", "no blocklist; new passwords SHALL be checked in full against common, expected and breached passwords")
    elif not bl.get("breached") or not bl.get("context_words"):
        miss = [x for x, ok in (("breached passwords", bl.get("breached")), ("context words (service name, username)", bl.get("context_words"))) if not ok]
        add("MEDIUM", "BLOCKLIST_THIN", f"blocklist lacks {' and '.join(miss)}")
    if p.get("hints"):
        add("HIGH", "HINTS", "password hints are stored; SHALL NOT be accessible to unauthenticated users - remove them")
    if p.get("security_questions"):
        add("HIGH", "SECURITY_QUESTIONS", "security questions (KBA) SHALL NOT be used; offer recovery via a verified email/phone or recovery codes")
    if p.get("truncate_at"):
        add("HIGH", "TRUNCATION", f"only the first {p['truncate_at']} characters are checked; the whole password SHALL be verified")
    rl = p.get("rate_limit") or p.get("lockout")
    if not rl:
        add("HIGH", "NO_RATE_LIMIT", "no throttling of failed attempts; SHALL limit to at most 100 consecutive failures per account")
    else:
        n = int(rl.get("max_failed_attempts") or 0)
        if n > 100:
            add("HIGH", "NO_RATE_LIMIT", f"{n} failed attempts allowed; at most 100 consecutive failures")
        elif 0 < n < 5:
            add("LOW", "LOCKOUT_STRICT", f"lockout after {n} attempts lets anyone lock users out; prefer growing delays, CAPTCHA or risk checks before a hard lock")
    st = p.get("storage") or {}
    alg = str(st.get("algorithm", "")).lower().replace("-", "").replace("_", "")
    if not alg:
        add("MEDIUM", "WEAK_STORAGE", "storage algorithm not stated; confirm a salted password hashing function (Argon2id, scrypt, bcrypt, PBKDF2)")
    elif alg in ("plaintext", "plain", "none", "aes", "encrypted", "base64", "md5", "sha1", "sha256", "sha512", "ntlm"):
        add("HIGH", "WEAK_STORAGE", f"{st.get('algorithm')} is not a password hashing function; use Argon2id (or scrypt, bcrypt, PBKDF2) with a unique salt and rehash on next login")
    else:
        if st.get("salted") is False:
            add("HIGH", "WEAK_STORAGE", "hashes are not salted; every password needs a unique random salt")
        it = int(st.get("iterations") or 0)
        if alg.startswith("pbkdf2") and it < 600000:
            add("MEDIUM", "WEAK_KDF_PARAMS", f"PBKDF2 with {it} iterations; OWASP suggests >= 600000 for PBKDF2-HMAC-SHA256")
        if alg == "bcrypt":
            if int(st.get("cost") or 0) < 10:
                add("MEDIUM", "WEAK_KDF_PARAMS", f"bcrypt cost {st.get('cost')}; use >= 10 (tune to ~250 ms)")
            if int(p.get("max_length") or 999) > 72:
                add("MEDIUM", "BCRYPT_72_BYTES", "bcrypt ignores input after 72 bytes; pre-hash (HMAC-SHA256, base64) or switch to Argon2id")
        if alg.startswith("argon2") and (int(st.get("memory_kib") or 0) < 19456 or it < 2):
            add("MEDIUM", "WEAK_KDF_PARAMS", "Argon2id below m=19456 KiB, t=2, p=1 (OWASP minimum)")
        if alg == "scrypt" and int(st.get("n") or 0) < 2 ** 17:
            add("MEDIUM", "WEAK_KDF_PARAMS", "scrypt N below 2^17 (r=8, p=1)")
    mx = p.get("max_length")
    if mx and int(mx) < 64:
        add("MEDIUM", "MAX_LENGTH", f"maximum {mx} characters blocks passphrases and generated passwords; allow at least 64")
    if p.get("allow_spaces") is False:
        add("MEDIUM", "NO_SPACES", "spaces rejected; accept all printing ASCII characters and space so passphrases work")
    if p.get("allow_paste") is False:
        add("MEDIUM", "PASTE_BLOCKED", "paste blocked; allow paste and autofill so password managers work")
    if p.get("strength_guidance") is False:
        add("MEDIUM", "NO_GUIDANCE", "no guidance when a password is rejected; SHALL help users choose (strength meter, reason for rejection)")
    if p.get("allow_unicode") is False:
        add("LOW", "NO_UNICODE", "Unicode rejected; SHOULD accept it (count each code point as one character)")
    elif p.get("allow_unicode") and str(p.get("unicode_normalization", "")).upper() != "NFC":
        add("LOW", "NO_NORMALIZATION", "Unicode accepted without NFC normalization before hashing; the same password typed on two devices may not match")
    return f


def check_samples(policy, samples, extra=(), context=()):
    rows, f = [], []
    ctx = (policy.get("blocklist") or {}).get("context_words") or []
    if isinstance(ctx, bool):
        ctx = []
    ctx = list(ctx) + list(context)
    for pw in samples:
        cb = charset_bits(pw)
        pb, notes = pattern_bits(pw, ctx)
        bl = blocklisted(pw, extra, ctx)
        ok, why = policy_accepts(policy, pw)
        single = policy.get("single_factor", True)
        nist_ok = bl is None and len(pw) >= (15 if single else 8)
        row = {"sample": pw, "length": len(pw), "charset_bits": round(cb, 1), "pattern_bits": round(pb, 1),
               "rating": "very weak" if bl else rating(pb), "blocklist": bl, "policy_accepts": ok,
               "policy_reasons": why, "nist_ok": nist_ok, "notes": notes,
               "offline_fast": crack_time(0 if bl else pb, 1e10), "offline_slow": crack_time(0 if bl else pb, 1e4)}
        rows.append(row)
        if ok and (bl or pb < 36):
            f.append({"severity": "HIGH", "code": "SAMPLE_WEAK_ACCEPTED", "sample": pw,
                      "message": f"sample {{s}} passes the current policy but is {'blocklisted (' + bl + ')' if bl else rating(pb) + f' (~{pb:.0f} bits)'}"})
    return rows, f


def render(policy, findings, rows, show):
    def s(pw):
        return pw if show else mask_pw(pw)
    out = [f"# Password policy review: {policy.get('name', 'policy')}", "",
           f"Basis: NIST SP 800-63B-4 (2025), passwords used {'as the only factor' if policy.get('single_factor', True) else 'with MFA'}",
           f"Findings: {sum(x['severity'] == 'HIGH' for x in findings)} HIGH, {sum(x['severity'] == 'MEDIUM' for x in findings)} MEDIUM, {sum(x['severity'] == 'LOW' for x in findings)} LOW", ""]
    for x in findings:
        msg = x["message"].replace("{s}", s(x["sample"])) if "sample" in x else x["message"]
        out.append(f"- {x['severity']} {x['code']}: {msg}")
    if rows:
        out += ["", "## Synthetic samples" + ("" if show else " (masked; --show-samples to print)"),
                "| Sample | Len | Charset bits | Pattern bits | Rating | Current policy | NIST verdict | Offline fast hash / slow hash |",
                "|---|---|---|---|---|---|---|---|"]
        for r in rows:
            cur = "accepts" if r["policy_accepts"] else "rejects (" + ", ".join(r["policy_reasons"]) + ")"
            nist = "ok" if r["nist_ok"] else ("reject: " + r["blocklist"] if r["blocklist"] else "reject: too short")
            out.append(f"| {s(r['sample'])} | {r['length']} | {r['charset_bits']} | {r['pattern_bits']} | {r['rating']} | {cur} | {nist} | {r['offline_fast']} / {r['offline_slow']} |")
        out += ["", "Charset bits overstate human-chosen passwords; pattern bits are the better guide. Offline times assume 1e10 guesses/s (fast hash) and 1e4 guesses/s (Argon2id/bcrypt), median case."]
    out += ["", "Samples must be made up for the review. Never paste or store real passwords."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(**over):
        p = {"min_length": 15, "max_length": 64, "blocklist": {"enabled": True, "breached": True, "context_words": ["acme"]},
             "allow_spaces": True, "allow_unicode": True, "unicode_normalization": "NFC", "allow_paste": True,
             "strength_guidance": True, "rate_limit": {"max_failed_attempts": 10},
             "storage": {"algorithm": "argon2id", "memory_kib": 19456, "iterations": 2, "salted": True}}
        p.update(over)
        return [x["code"] for x in check_policy(p)]
    check("compliant policy has no findings", codes() == [])
    check("min 8 single factor HIGH", "MIN_LENGTH" in codes(min_length=8))
    check("min 8 with MFA ok", "MIN_LENGTH" not in codes(min_length=8, single_factor=False))
    check("min 6 with MFA HIGH", "MIN_LENGTH" in codes(min_length=6, single_factor=False))
    check("composition HIGH", "COMPOSITION_RULES" in codes(composition={"upper": True, "symbol": True}))
    check("expiry HIGH", "PERIODIC_EXPIRY" in codes(expiry_days=90))
    check("no blocklist HIGH", "NO_BLOCKLIST" in codes(blocklist={"enabled": False}))
    check("thin blocklist MEDIUM", "BLOCKLIST_THIN" in codes(blocklist={"enabled": True}))
    check("hints + KBA HIGH", {"HINTS", "SECURITY_QUESTIONS"} <= set(codes(hints=True, security_questions=True)))
    check("truncation HIGH", "TRUNCATION" in codes(truncate_at=16))
    check("no rate limit HIGH", "NO_RATE_LIMIT" in codes(rate_limit=None))
    check("lockout 3 LOW", "LOCKOUT_STRICT" in codes(rate_limit={"max_failed_attempts": 3}))
    check("sha256 HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "SHA-256", "salted": True}))
    check("unsalted HIGH", "WEAK_STORAGE" in codes(storage={"algorithm": "pbkdf2-sha256", "iterations": 600000, "salted": False}))
    check("pbkdf2 low iterations MEDIUM", "WEAK_KDF_PARAMS" in codes(storage={"algorithm": "PBKDF2-SHA256", "iterations": 10000}))
    check("bcrypt cost 8 + 72 bytes", {"WEAK_KDF_PARAMS", "BCRYPT_72_BYTES"} <= set(codes(storage={"algorithm": "bcrypt", "cost": 8}, max_length=128)))
    check("max 20 MEDIUM", "MAX_LENGTH" in codes(max_length=20))
    check("spaces/paste/guidance MEDIUM", {"NO_SPACES", "PASTE_BLOCKED", "NO_GUIDANCE"} <= set(codes(allow_spaces=False, allow_paste=False, strength_guidance=False)))
    check("no unicode LOW", "NO_UNICODE" in codes(allow_unicode=False))
    check("no NFC LOW", "NO_NORMALIZATION" in codes(unicode_normalization=None))
    check("charset bits 8 lowercase", abs(charset_bits("abcdefgh") - 8 * math.log2(26)) < 1e-9)
    check("charset pool all classes", char_pool("aA1!") == 95)
    check("leet word cheap", pattern_bits("P@ssw0rd")[0] < 15)
    check("year + word weak", pattern_bits("Summer2025!")[0] < 28)
    check("keyboard run cheap", pattern_bits("qwertyuiop")[0] < 10)
    check("repeat cheap", pattern_bits("aaaaaaaaaa")[0] < 10)
    check("random 16 strong", pattern_bits("vT9#qLm2!xR4wZp7")[0] > 80)
    check("4 random words ~ 53 bits", 50 < pattern_bits("tundra-velvet-orbit-cactus")[0] < 56)
    check("common words passphrase weak", pattern_bits("love summer football")[0] < 10)
    check("blocklisted exact", blocklisted("Password1!") is not None)
    check("context word blocked", blocklisted("Acme2026!", context=["acme"]) is not None)
    check("leet + year blocked", blocklisted("P@ssw0rd2026") is not None)
    check("context word cheap in pattern", pattern_bits("Northwind#1", ["northwind"])[0] < 28)
    check("long random not blocked", blocklisted("vT9#qLm2!xR4wZp7") is None)
    pol = {"min_length": 8, "composition": {"upper": True, "lower": True, "digit": True, "symbol": True}}
    rows, f = check_samples(pol, ["Password1!", "tundra velvet orbit cactus"])
    check("composition policy accepts Password1!", rows[0]["policy_accepts"] and f and f[0]["code"] == "SAMPLE_WEAK_ACCEPTED")
    check("composition policy rejects passphrase", not rows[1]["policy_accepts"] and rows[1]["nist_ok"])
    check("mask hides middle", mask_pw("Password1!") == "Pa*******!")
    check("crack time units", crack_time(0, 1e10) == "instant" and "years" in crack_time(80, 1e10))
    try:
        check_policy({})
        check("missing min_length rejected", False)
    except DataError:
        check("missing min_length rejected", True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Password policy and entropy checker (NIST SP 800-63B-4)")
    ap.add_argument("policy", nargs="?", help="policy JSON")
    ap.add_argument("--samples", help="file with SYNTHETIC sample passwords, one per line (# = comment)")
    ap.add_argument("--blocklist", help="extra blocklist file, one password per line")
    ap.add_argument("--context", default="", help="comma-separated context words (service name, brand, usernames)")
    ap.add_argument("--show-samples", action="store_true", help="print samples unmasked")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.policy:
        ap.error("give a policy JSON or --self-test")
    try:
        with open(a.policy, encoding="utf-8") as fh:
            policy = json.load(fh)
        findings = check_policy(policy)
        rows = []
        if a.samples:
            with open(a.samples, encoding="utf-8") as fh:
                samples = [l.rstrip("\n") for l in fh if l.strip() and not l.startswith("#")]
            extra = set()
            if a.blocklist:
                with open(a.blocklist, encoding="utf-8") as fh:
                    extra = {l.strip().lower() for l in fh if l.strip()}
            rows, sf = check_samples(policy, samples, extra, [w.strip() for w in a.context.split(",") if w.strip()])
            findings += sf
    except (OSError, ValueError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
    if a.json:
        if not a.show_samples:
            for r in rows:
                r["sample"] = mask_pw(r["sample"])
            for x in findings:
                if "sample" in x:
                    x["sample"] = mask_pw(x["sample"])
                    x["message"] = x["message"].replace("{s}", x["sample"])
        print(json.dumps({"findings": findings, "samples": rows}, indent=2))
    else:
        print(render(policy, findings, rows, a.show_samples))
    return 1 if any(x["severity"] == "HIGH" for x in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonCompliance+1
F@f
0
Công Cụ Ước Tính Chi Phí Điện Năng Thiết Bị Gia Dụng
Kỹ năng

Ước tính chi phí vận hành của từng thiết bị gia dụng dựa trên công suất (watt), số giờ sử dụng mỗi ngày và biểu giá điện của bạn. Đọc tệp CSV, tính toán số kWh và chi phí theo ngày, tháng và năm, điện năng tiêu thụ ở chế độ chờ (tiêu thụ ngầm), tỷ trọng của từng thiết bị và giá điện giờ thấp điểm, so sánh tổng mức ước tính với hóa đơn thực tế, đồng thời xếp hạng các ý tưởng tiết kiệm như bóng đèn LED, giặt ở nhiệt độ 30 C và cắt giảm chế độ chờ theo mức tiết kiệm hàng năm. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: home-appliance-energy-cost-estimator
description: Ước tính chi phí vận hành của từng thiết bị gia dụng dựa trên công suất (watt), số giờ sử dụng mỗi ngày và biểu giá điện của bạn. Đọc tệp CSV, tính toán số kWh và chi phí theo ngày, tháng và năm, điện năng tiêu thụ ở chế độ chờ (tiêu thụ ngầm), tỷ trọng của từng thiết bị và giá điện giờ thấp điểm, so sánh tổng mức ước tính với hóa đơn thực tế, đồng thời xếp hạng các ý tưởng tiết kiệm như bóng đèn LED, giặt ở nhiệt độ 30 C và cắt giảm chế độ chờ theo mức tiết kiệm hàng năm. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Công Cụ Ước Tính Chi Phí Điện Năng Thiết Bị Gia Dụng

Bạn giúp các hộ gia đình nhìn rõ tiền điện của họ đang đi về đâu theo từng thiết bị gia dụng, và những thay đổi nhỏ nào thực sự đáng để thực hiện. Bạn quy đổi số watt và thói quen sinh hoạt thành số kWh và tiền bạc, trình bày rõ các phép tính số học, đồng thời giữ cho lời khuyên luôn thiết thực: không phán xét, không gợi ý những nâng cấp đắt đỏ chẳng bao giờ hoàn vốn nổi.

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

- `scripts/energy_cost.py` - đọc tệp CSV danh sách thiết bị và biểu giá điện, in ra bảng chi phí, các phát hiện và danh sách ý tưởng tiết kiệm được xếp hạng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/energy-math.md` - các công thức, chu kỳ hoạt động (duty cycles), chế độ chờ (standby), nhãn năng lượng, biểu giá điện và cách đo mức tiêu thụ thực tế
- `references/typical-wattages.md` - công suất watt khi chạy và khi ở chế độ chờ cùng số giờ sử dụng hàng ngày điển hình của các thiết bị phổ biến, dùng để lấp đầy các thông tin còn thiếu
- `templates/energy-report.md` - mẫu báo cáo hộ gia đình: tổng tiêu thụ, các thiết bị ngốn điện nhất, chế độ chờ, kế hoạch hành động kèm theo mức tiết kiệm và chi phí
- `examples/apartment-appliances.csv` - căn hộ hai người ở với 15 thiết bị (tủ lạnh từ năm 2011, đèn rọi halogen, máy sấy quần áo, máy chơi game ở chế độ chờ, máy sưởi dùng theo mùa)

## Quy trình thực hiện

### 1. Xây dựng danh sách thiết bị
Hỏi danh sách các thiết bị, công suất watt của chúng (trên tem nhãn thông số, sách hướng dẫn sử dụng, hoặc ổ cắm đo điện), thời gian mỗi thiết bị chạy trong một ngày và bao nhiêu ngày một tuần, cùng với giá điện mỗi kWh (cộng với giá giờ thấp điểm và phí cố định hàng ngày nếu có) từ hóa đơn gần đây. Đối với tủ lạnh và tủ đông, hãy ưu tiên chỉ số kWh/năm trên nhãn năng lượng; đối với bất kỳ thiết bị nào có bộ điều chỉnh nhiệt (thermostat), hãy thiết lập chu kỳ hoạt động (duty cycle). Bổ sung các thông tin còn trống từ `references/typical-wattages.md` và nêu rõ những giá trị nào là giả định. Nếu người dùng có hóa đơn tiền điện, hãy hỏi số kWh hàng tháng để đối chiếu chéo.

### 2. Chạy công cụ ước tính
```
python3 scripts/energy_cost.py examples/apartment-appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 330 --currency EUR
python3 scripts/energy_cost.py my-home.csv --rate 0.15 --currency USD --json
python3 scripts/energy_cost.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là có các phát hiện mức HIGH (chế độ chờ chiếm trên 10% tổng mức sử dụng hoặc con số ước tính lệch xa so với hóa đơn), bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Rà soát cùng người dùng
Nếu xuất hiện cảnh báo BILL_GAP, trước tiên hãy sửa lại dữ liệu đầu vào (bỏ sót bình nóng lạnh hoặc hệ thống sưởi điện, sử dụng theo mùa thông qua `months_per_year`, ước tính số giờ quá dư dả). Sau đó điểm qua các thiết bị tiêu thụ điện nhiều nhất, danh sách thiết bị ở chế độ chờ và các ý tưởng tiết kiệm. Các khoản tiết kiệm có thể chồng lấn lên nhau (máy sấy dùng ít đi thì cũng tiết kiệm được ít hơn khi chuyển sang chạy giờ thấp điểm), vì vậy hãy chọn lọc ra một kế hoạch hợp lý thay vì cộng dồn mọi ý tưởng lại. Sử dụng `references/energy-math.md` để giải thích cho bất kỳ con số nào.

### 4. Bàn giao báo cáo
Điền vào mẫu `templates/energy-report.md`: tổng mức tiêu thụ, 5 thiết bị ngốn điện nhất, mức tiêu thụ chế độ chờ, và kế hoạch hành động được phân loại thành các thói quen miễn phí, các giải pháp chi phí thấp (bóng đèn LED, ổ cắm thông minh) và các khoản mua sắm lớn hơn kèm theo số năm hoàn vốn (giá mua / mức tiết kiệm hàng năm).

## Kết quả ví dụ
Đối với tệp `examples/apartment-appliances.csv` ở mức giá EUR 0.32/kWh (giờ thấp điểm 0.21, phí cố định 0.40/ngày): 12.3 kWh/ngày, 373 kWh/tháng, 4479 kWh/năm, tiền điện năng EUR 118.38/tháng hoặc EUR 1420.54/năm (EUR 1566.54 nếu tính cả phí cố định). Sáu bóng đèn rọi halogen là thiết bị tiêu thụ lớn nhất (548 kWh, 12%), tiếp theo là máy sưởi sử dụng trong 5 tháng (479 kWh). Chế độ chờ tiêu tốn 319 kWh (7%), với các phát hiện mức MEDIUM cho TV (12 W) và máy chơi game (15 W). Nằm trong phạm vi chênh lệch 13% so với hóa đơn 330 kWh, do đó không có cảnh báo BILL_GAP. Các ý tưởng hàng đầu: thay đèn rọi LED tiết kiệm khoảng EUR 149/năm, phơi khô tự nhiên một nửa số mẻ giặt tiết kiệm EUR 63, giặt ở nhiệt độ 30 C tiết kiệm EUR 48, giặt đồ vào giờ thấp điểm, cắt giảm chế độ chờ của máy chơi game tiết kiệm EUR 40.

## Quy tắc

- Trình bày công thức tính một lần và nêu rõ các giả định cho mọi giá trị ước đoán.
- Sử dụng biểu giá và đơn vị tiền tệ của người dùng; tuyệt đối không tự mặc định mức giá của một quốc gia mà không nói rõ.
- Con số ước tính không phải là kết quả đo đạc thực tế: hãy đề xuất dùng ổ cắm đo điện cho 3 thiết bị ngốn điện nhất khi các con số đóng vai trò quan trọng.
- Xếp hạng các hành động theo số tiền tiết kiệm được trên mỗi công sức bỏ ra; đưa ra thời gian hoàn vốn tính bằng năm cho bất kỳ giải pháp nào tốn tiền mua sắm.
- An toàn là trên hết: tuyệt đối không bao giờ gợi ý tắt tính năng làm mát của tủ lạnh hoặc tủ đông, thiết bị báo khói, thiết bị y tế hoặc chế độ chống đóng băng để tiết kiệm điện.
FILE:references/energy-math.md
# Toán Học Năng Lượng

## Các công thức cốt lõi
```
kWh per day (running) = watts x quantity x hours per day x duty% / 1000 x days per week / 7 x months per year / 12
kWh per day (standby) = standby watts x quantity x (24 - average hours running) / 1000
kWh per year = kWh per day x 365        kWh per month = kWh per year / 12
cost = kWh x price per kWh (+ standing charge per day x 365, listed separately)
```
Ví dụ: một ấm đun nước 2000 W sử dụng 15 phút mỗi ngày = 2000 x 0.25 / 1000 = 0.5 kWh/ngày = 183 kWh/năm; ở mức giá 0.32 mỗi kWh = 58.40 một năm.

## Số Watt so với các con số khác trên nhãn thiết bị
- W (watt) là thông số bạn cần. kW x 1000 = W.
- VA trên bộ sạc và bộ lưu điện (UPS) là công suất biểu kiến; số watt thực tế thường chỉ bằng 60-90% con số đó.
- mAh hoặc Wh trên pin là dung lượng lưu trữ, không phải công suất tiêu thụ điện.
- Số watt trên nhãn thông số (nameplate watts) là mức tối đa. Máy sưởi, lò nướng, ấm đun nước chạy gần mức tối đa này; TV, máy tính và máy chơi game thường chỉ tiêu thụ khoảng 40-80% con số đó. Một chiếc ổ cắm đo điện sẽ cho biết con số chính xác chỉ sau một ngày.

## Chu kỳ hoạt động (Duty cycle)
Bất kỳ thiết bị nào có bộ điều chỉnh nhiệt (thermostat) hoặc máy nén đều tự động bật và tắt luân phiên. Tỷ lệ thời gian thiết bị thực sự chạy được gọi là chu kỳ hoạt động (duty cycle).
| Thiết bị | Chu kỳ hoạt động điển hình |
|---|---|
| Tủ lạnh, tủ lạnh kèm tủ đông | 30-50% (cao hơn nếu đặt trong căn bếp ấm hoặc gioăng cao su bị hở) |
| Tủ đông nằm ngang | 35-50% |
| Máy sưởi dầu hoặc máy sưởi tấm có bộ điều nhiệt | 50-80% trong thời tiết lạnh |
| Lò nướng khi đã đạt nhiệt độ (sau khi làm nóng trước) | 50-60% |
| Máy giặt (đun nóng nước ở giai đoạn đầu) | 50-70% công suất nhãn trong suốt một chu trình giặt |
| Máy rửa bát | 50-60% công suất nhãn trong suốt một chu trình rửa |
Tốt hơn hết: hãy sử dụng chỉ số kWh/năm trên nhãn năng lượng (nhãn EU, nhãn EnergyGuide của Mỹ), vì con số này đã tính sẵn chu kỳ bật tắt.

## Chế độ chờ (Standby)
Các thiết bị đã "tắt" nhưng vẫn tiêu thụ điện: chế độ khởi động nhanh (instant-on) của TV và máy chơi game (5-15 W), đầu thu truyền hình set-top box (10-20 W), máy in, lò vi sóng có đồng hồ hiển thị (2-3 W), củ sạc cắm sẵn ở ổ điện (0.1-0.5 W). Các thiết bị bật liên tục như bộ định tuyến Wi-Fi được tính là tải đang hoạt động (running load) chứ không phải chế độ chờ, vì vậy hãy liệt kê chúng với thời gian chạy 24 giờ. Mức tiêu thụ chờ 10 W liên tục suốt ngày đêm = 88 kWh/năm. Sử dụng ổ cắm kéo dài có công tắc, ổ cắm thông minh hẹn giờ hoặc tắt tính năng "khởi động nhanh" sẽ loại bỏ được phần lớn lượng điện tiêu thụ này.

## Biểu giá điện (Tariffs)
- Giá đồng nhất (Flat rate): một mức giá duy nhất cho mỗi kWh.
- Giá theo khung giờ (Time of use): giờ thấp điểm có giá rẻ hơn (thường là ban đêm). Đặt `offpeak_pct` bằng tỷ lệ phần trăm đã được sử dụng vào giờ thấp điểm; tập lệnh sẽ tính giá trị tiết kiệm khi chuyển phần còn lại của các phụ tải có thể dịch chuyển giờ chạy (máy giặt, máy rửa bát, máy sấy, bộ sạc xe điện EV, bình nóng lạnh, máy bơm hồ bơi) theo mức chênh lệch (giá tiêu chuẩn - giá thấp điểm) trên mỗi kWh.
- Giá bậc thang (Tiered/block rates): giá điện tăng dần theo mức sử dụng hàng tháng. Hãy sử dụng mức giá cận biên (bậc cao nhất đang dùng) để tính giá trị tiết kiệm.
- Phí cố định (Standing charge - phí thuê bao hàng ngày): vẫn phải trả ngay cả khi mức sử dụng bằng không, vì vậy các biện pháp tiết kiệm điện không bao giờ làm giảm được khoản phí này.
- Thuế và phụ phí: hãy sử dụng mức giá trọn gói trên mỗi kWh từ hóa đơn (tổng chi phí điện năng / tổng số kWh).

## Đối chiếu với hóa đơn tiền điện
So sánh số kWh ước tính hàng tháng với số kWh trên hóa đơn (chứ không so sánh bằng số tiền). Sai số trong phạm vi 25% là mức tốt đối với phương pháp ước tính dựa trên thói quen sử dụng. Các nguyên nhân phổ biến khiến con số ước tính quá thấp: bình đun nước nóng bằng điện (thường chiếm 15-25% lượng điện của một ngôi nhà), hệ thống sưởi hoặc làm mát bằng điện, xe điện (EV), chiếc tủ lạnh thứ hai bị bỏ quên, máy bơm hồ bơi hoặc bể cá. Quá cao: ước tính số giờ sử dụng quá hào phóng, các thiết bị dùng theo mùa lại được tính cho cả năm (hãy dùng `months_per_year`), lấy công suất tối đa trên nhãn để tính cho các thiết bị điện tử.

## Đo đạc thực tế
- Ổ cắm đo điện năng (rẻ tiền): đo số kWh trong vòng một tuần; sau đó nhân với 52.
- Ứng dụng công tơ thông minh hoặc cổng thông tin của công ty điện lực: dữ liệu theo từng giờ sẽ cho thấy phụ tải nền vào ban đêm (mọi thiết bị luôn bật); phụ tải nền x 8760 giờ / 1000 = số kWh/năm không bao giờ tắt.
- Đọc chỉ số công tơ điện trước và sau một lần chạy thiết bị đã biết (ví dụ: bật lò nướng trong một giờ) khi mọi thiết bị khác giữ nguyên trạng thái ổn định.

## Thời gian hoàn vốn (Payback)
số năm hoàn vốn = giá mua / mức tiết kiệm hàng năm. Bóng đèn LED: chỉ vài tháng. Ổ cắm thông minh cho chế độ chờ: 1-2 năm. Máy sấy bơm nhiệt (heat-pump dryer) hoặc tủ lạnh mới: thường mất 5-10 năm, vì vậy chỉ nên đề xuất khi thiết bị cũ đằng nào cũng đã đến lúc cần thay thế.
FILE:references/typical-wattages.md
# Công Suất Watt Và Mức Sử Dụng Điển Hình

Các giá trị khởi đầu để dùng khi người dùng không rõ thông số. Hãy luôn gắn nhãn chúng là các giả định; các mẫu máy thực tế có thể chênh lệch rất nhiều.

| Thiết bị | Công suất chạy (W) | Mức sử dụng điển hình | Chế độ chờ (W) | Ghi chú |
|---|---|---|---|---|
| Tủ lạnh kèm tủ đông | 100-200 | 24 giờ ở mức chu kỳ 30-50% | - | Nhãn năng lượng: 150-350 kWh/năm (loại A-E mới thấp hơn) |
| Tủ đông nằm ngang | 60-120 | 24 giờ ở mức chu kỳ 35-50% | - | Nhãn năng lượng: 150-250 kWh/năm |
| Máy giặt | 1800-2400 | 1-2 giờ mỗi mẻ, 3-5 mẻ/tuần | 0.5-2 | 0.5-1.0 kWh cho mỗi chu trình giặt 40 C |
| Máy sấy quần áo (thông hơi/ngưng tụ) | 2000-3000 | 1-1.5 giờ mỗi mẻ | 0.5-1 | Máy sấy bơm nhiệt chỉ tiêu thụ khoảng một nửa |
| Máy rửa bát | 1200-2000 | 1-2 giờ mỗi chu trình | 0.5-1 | 0.8-1.4 kWh mỗi chu trình; chế độ eco chạy lâu hơn nhưng tốn ít điện hơn |
| Lò nướng điện | 2000-3500 | 0.5-1 giờ | 2-4 (đồng hồ) | Làm nóng trước ở công suất tối đa, sau đó bật tắt theo chu kỳ |
| Bếp từ hoặc bếp hồng ngoại (mỗi vùng nấu) | 1200-2500 | 0.5-1 giờ | 0-2 | |
| Lò vi sóng | 800-1200 | 5-15 phút | 2-4 | |
| Nồi chiên không dầu | 1200-1800 | 15-30 phút | 0 | Tiết kiệm hơn lò nướng khi nấu khẩu phần nhỏ |
| Ấm đun nước siêu tốc | 2000-3000 | Tổng cộng 10-20 phút | 0 | |
| Máy pha cà phê | 1000-1500 | 10-20 phút | 1-3 | Đế giữ ấm sẽ làm tốn thêm khá nhiều điện |
| Bóng đèn sợi đốt / halogen | 40-60 | 3-6 giờ | 0 | Đèn LED tương đương chỉ tốn 5-9 W |
| Bóng đèn LED | 5-12 | 3-6 giờ | 0-0.5 (bóng thông minh) | |
| TV LED 43-65 inch | 60-150 | 3-5 giờ | 0.5-15 | Chế độ khởi động nhanh làm tăng điện năng chờ |
| Máy chơi game (Game console) | 100-220 | 1-3 giờ | 1-15 | Chế độ nghỉ/khởi động tức thì tốn khoảng 10-15 W |
| Máy tính để bàn (PC) + màn hình | 100-400 | 4-8 giờ | 2-5 | PC chơi game khi chạy nặng tốn 300-600 W |
| Máy tính xách tay (Laptop) | 30-70 | 4-8 giờ | 0.5 | |
| Bộ định tuyến Wi-Fi + modem | 8-20 | 24 giờ | - | Luôn bật: liệt kê vào mục phụ tải đang chạy |
| Đầu thu truyền hình / thiết bị phát trực tuyến | 10-20 | 4 giờ | 5-15 | |
| Máy sưởi phòng (quạt sưởi / sưởi dầu) | 1000-2000 | 2-6 giờ vào mùa đông, chu kỳ 50-80% | 0 | Sử dụng tham số months_per_year |
| Điều hòa di động | 800-1500 | 4-8 giờ vào mùa hè | 1-2 | |
| Máy hút ẩm | 200-500 | 6-12 giờ | 1 | |
| Bình nước nóng dùng điện (loại có bình chứa) | 2000-3000 | 2-4 giờ | - | Thường là thiết bị ngốn điện tiềm ẩn lớn nhất |
| Vòi sen nước nóng trực tiếp bằng điện | 7000-10000 | 10-20 phút | 0 | |
| Bộ sạc xe điện EV (tại nhà, 7 kW) | 7000-7400 | tính theo số km di chuyển: ~0.15-0.2 kWh/km | 2-5 | Dùng công thức kWh/năm = số km/năm x 0.17 |
| Máy bơm hồ bơi | 750-1500 | 6-10 giờ trong mùa sử dụng | 0 | Máy bơm biến tần thay đổi tốc độ giúp tiết kiệm khoảng một nửa |
| Bể cá (sưởi + lọc + đèn) | 50-200 | 24 giờ ở mức chu kỳ 30-60% | - | |

Nguồn tham khảo cho các khoảng giá trị: bảng thông số kỹ thuật và nhãn năng lượng của nhà sản xuất, kết hợp với các công cụ tính toán thiết bị gia dụng phổ biến của cơ quan năng lượng quốc gia. Hãy kiểm tra mẫu máy cụ thể của người dùng khi một giá trị có ảnh hưởng trực tiếp đến việc ra quyết định.
FILE:templates/energy-report.md
# Báo cáo điện năng hộ gia đình: <tên hộ gia đình hoặc địa chỉ>

**Biểu giá:** <EUR 0.32/kWh, giờ thấp điểm 0.21 (23:00-07:00), phí cố định 0.40/ngày>   **Đối chiếu hóa đơn:** <ước tính 373 kWh/tháng so với hóa đơn 330 (-13%)>
**Tóm tắt:** <một câu: ví dụ "Chiếu sáng và giặt giũ chiếm 40% hóa đơn; thay đèn rọi LED và giặt ở nhiệt độ 30 C giúp tiết kiệm khoảng EUR 200 một năm với chi phí đầu tư EUR 40.">

## Tiền điện đi về đâu
| Thiết bị | kWh/năm | Chi phí/năm | Tỷ trọng | Giả định |
|---|---|---|---|---|
| <Đèn rọi Halogen x6> | <548> | <175.20> | <12%> | <50 W, 5 giờ/ngày> |
| <...> | | | | |

Tổng cộng: <4479 kWh/năm, EUR 1420.54 tiền điện + EUR 146 phí cố định>

## Chế độ chờ (luôn phải trả tiền nhưng không mang lại lợi ích gì)
<319 kWh/năm, khoảng EUR 102. Lớn nhất: máy chơi game 15 W, TV 12 W.>

## Kế hoạch hành động
### Thói quen miễn phí
| Hành động | Tiết kiệm/năm |
|---|---|
| <Giặt ở nhiệt độ 30 C, giặt đủ tải> | <EUR 48> |
| <Chạy máy giặt và máy rửa bát vào giờ thấp điểm> | <EUR 70-110> |

### Giải pháp chi phí thấp
| Hành động | Chi phí | Tiết kiệm/năm | Thời gian hoàn vốn |
|---|---|---|---|
| <6 bóng đèn rọi LED> | <EUR 30> | <EUR 149> | <2-3 tháng> |
| <Ổ cắm thông minh cho TV + máy chơi game> | <EUR 15> | <EUR 68> | <3 tháng> |

### Khoản mua sắm lớn hơn (chỉ thực hiện khi đằng nào cũng cần thay mới)
| Hành động | Chi phí | Tiết kiệm/năm | Thời gian hoàn vốn |
|---|---|---|---|
| <Máy sấy bơm nhiệt> | <EUR 600> | <EUR 63> | <~10 năm> |

## Các bước tiếp theo
- <Đo máy sưởi phòng và tủ lạnh bằng ổ cắm đo điện trong vòng một tuần>
- <Chạy lại công cụ ước tính sau khi thay bóng đèn LED và so sánh hai kỳ hóa đơn tiếp theo>

Các con số là ước tính dựa trên công suất định mức và mức sử dụng điển hình; các khoản tiết kiệm có sự giao thoa và không cộng dồn trực tiếp.
FILE:examples/apartment-appliances.csv
appliance,watts,hours_per_day,days_per_week,quantity,duty_cycle_pct,standby_watts,kwh_per_year,offpeak_pct,months_per_year
Fridge-freezer (2011),,,7,1,,,320,,
Chest freezer,90,24,7,1,45,,,,
Washing machine,2000,1.5,4,1,60,1.5,,,
Tumble dryer,2500,1,3,1,,0.5,,,
Dishwasher,1800,1.5,5,1,55,1,,30,
Electric oven,2400,1,4,1,60,3,,,
Kettle,2200,0.25,7,1,,,,,
Microwave,1100,0.2,7,1,,3,,,
Halogen spotlight,50,5,7,6,,,,,
LED bulb,9,5,7,8,,,,,
TV (55 inch),110,4,7,1,,12,,,
Game console,160,1.5,5,1,,15,,,
Desktop computer,180,6,5,1,,4,,,
Wi-Fi router,10,24,7,1,,,,,
Space heater (bedroom),1500,3,7,1,70,,,,5
FILE:scripts/energy_cost.py
#!/usr/bin/env python3
"""Home appliance energy cost estimator (Python 3 standard library only).

Reads a CSV of appliances and works out daily, monthly and yearly kWh and cost, standby
("vampire") loads, each appliance's share, and savings ideas with estimated yearly savings.

CSV columns (header required; comma or semicolon; only the first three are required):
  appliance        name, e.g. "Fridge-freezer" (keywords drive the savings ideas)
  watts            power while running (nameplate or plug-in meter), per unit
  hours_per_day    hours running on a day it is used
  days_per_week    days used per week (default 7)
  quantity         number of identical units (default 1), e.g. 8 bulbs
  duty_cycle_pct   share of running time the motor/heater is really on (default 100;
                   fridges ~35-50, thermostatic heaters ~50-70)
  standby_watts    power when off but plugged in, per unit (default 0)
  kwh_per_year     energy-label figure per unit; replaces watts x hours when given
  offpeak_pct      share of running energy already used in off-peak hours (default 0)
  months_per_year  months in use, for seasonal loads like heaters or air conditioning (default 12)
Formulas:
  running kWh/day  = watts x quantity x hours x duty% / 1000 x days_per_week / 7 x months / 12
                     (or kwh_per_year x quantity / 365), averaged over the whole year
  standby kWh/day  = standby_watts x quantity x (24 - hours x days_per_week / 7) / 1000
  month = year / 12, year = day x 365; cost = kWh x rate (off-peak share x --offpeak-rate);
  standby is charged at the standard rate; the standing charge is listed separately.
Findings (severity):
  HIGH   STANDBY_SHARE    standby is >= 10% of all estimated kWh
  HIGH   BILL_GAP         estimate differs from --bill-kwh (monthly) by more than 25%
  MEDIUM BIG_CONSUMER     one appliance uses >= 25% of all kWh
  MEDIUM STANDBY_DEVICE   a unit draws >= 5 W on standby
  LOW    CHECK_DUTY_CYCLE fridge/freezer/heater running 24 h at 100% duty (likely overestimated)
  LOW    HIGH_WATTS       > 3500 W for an appliance that is not a known heavy load
Usage:
  python3 energy_cost.py appliances.csv --rate 0.32 --currency EUR
  python3 energy_cost.py appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 260 --json
  python3 energy_cost.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import io
import json
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
HEAVY = ("heater", "oven", "stove", "hob", "cooktop", "dryer", "kettle", " ev ", "charger", "boiler",
         "water heater", "sauna", "heat pump", "air conditioner", " ac ", "furnace", "pool")
SHIFTABLE = ("wash", "dishwasher", "dryer", " ev ", "ev charger", "pool", "water heater", "boiler")
# keyword -> (idea, share of that appliance's running cost saved)
IDEAS = [
    (("bulb", "lamp", "light"), "replace with LED (about 85% less power for the same light)", 0.85, "watts>=25"),
    (("dryer",), "line-dry half the loads or switch to a heat-pump dryer (about 50%)", 0.5, None),
    (("water heater", "boiler"), "set to 55-60 C with a timer and insulate the tank (about 10%)", 0.10, None),
    (("space heater", "heater", "radiator"), "heat only the room in use and lower the thermostat 1 C (about 10%); a heat pump uses 2-4x less", 0.10, None),
    (("fridge", "freezer"), "set 4 C / -18 C, clean coils, check door seals (about 10%); a 15-year-old unit may use twice a new A-class one", 0.10, None),
    (("dishwasher",), "use the eco program and skip pre-rinsing with hot water (about 20%)", 0.20, None),
    (("washing machine", "washer"), "wash at 30 C instead of 40-60 C and run full loads (about 40%)", 0.40, None),
    (("kettle",), "boil only the water you need (about 25%)", 0.25, None),
    (("oven",), "use a microwave or air fryer for small meals (about 30%)", 0.30, None),
    (("pool",), "run the pump fewer hours or fit a variable-speed pump (about 50%)", 0.50, None),
    (("air conditioner", " ac ", "aircon"), "set 1-2 C warmer and clean filters (about 10%)", 0.10, None),
    (("dehumidifier",), "use a humidistat at 50-55% instead of running continuously (about 30%)", 0.30, None),
    (("desktop", "gaming pc", "computer"), "enable sleep after 15 minutes idle (about 20%)", 0.20, None),
]


class DataError(ValueError):
    pass


def num(row, key, default, line):
    v = (row.get(key) or "").strip().replace(",", ".") if isinstance(row.get(key), str) else row.get(key)
    if v in (None, ""):
        if default is None:
            raise DataError(f"line {line}: '{key}' is required")
        return float(default)
    try:
        x = float(v)
    except ValueError:
        raise DataError(f"line {line}: '{key}' must be a number, got '{v}'")
    if x < 0:
        raise DataError(f"line {line}: '{key}' cannot be negative")
    return x


def read_csv(text):
    first = text.splitlines()[0] if text.strip() else ""
    delim = ";" if first.count(";") > first.count(",") else ","
    rd = csv.DictReader(io.StringIO(text), delimiter=delim)
    if not rd.fieldnames or not {"appliance", "watts", "hours_per_day"} <= {f.strip().lower() for f in rd.fieldnames}:
        if not rd.fieldnames or "kwh_per_year" not in [f.strip().lower() for f in rd.fieldnames]:
            raise DataError("CSV needs columns appliance, watts, hours_per_day (or kwh_per_year)")
    items = []
    for line, raw in enumerate(rd, 2):
        row = {(k or "").strip().lower(): v for k, v in raw.items()}
        name = (row.get("appliance") or "").strip()
        if not name:
            continue
        label = (row.get("kwh_per_year") or "").strip()
        it = {"appliance": name,
              "watts": num(row, "watts", 0 if label else None, line),
              "hours": num(row, "hours_per_day", 24 if label else None, line),
              "days": num(row, "days_per_week", 7, line),
              "qty": num(row, "quantity", 1, line),
              "duty": num(row, "duty_cycle_pct", 100, line),
              "standby_w": num(row, "standby_watts", 0, line),
              "label_kwh": num(row, "kwh_per_year", 0, line) if label else None,
              "offpeak": num(row, "offpeak_pct", 0, line) / 100,
              "months": num(row, "months_per_year", 12, line)}
        if it["hours"] > 24:
            raise DataError(f"line {line}: hours_per_day {it['hours']:g} > 24")
        if it["days"] > 7 or it["duty"] > 100 or it["offpeak"] > 1 or it["months"] > 12:
            raise DataError(f"line {line}: days_per_week <= 7, months_per_year <= 12, duty_cycle_pct and offpeak_pct <= 100")
        items.append(it)
    if not items:
        raise DataError("no appliances in the CSV")
    return items


def has(name, words):
    low = " " + name.lower() + " "
    return any(w in low for w in words)


def estimate(items, rate, offpeak_rate=None, standing=0.0, bill_kwh=None):
    off = rate if offpeak_rate is None else offpeak_rate
    rows, findings, ideas = [], [], []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})
    for it in items:
        avg_hours = it["hours"] * it["days"] / 7
        if it["label_kwh"] is not None:
            run_day = it["label_kwh"] * it["qty"] / 365
        else:
            run_day = it["watts"] * it["qty"] * avg_hours * it["duty"] / 100 / 1000 * it["months"] / 12
        sb_day = it["standby_w"] * it["qty"] * (24 - avg_hours) / 1000
        run_cost_y = run_day * 365 * ((1 - it["offpeak"]) * rate + it["offpeak"] * off)
        sb_cost_y = sb_day * 365 * rate
        rows.append({"appliance": it["appliance"], "qty": it["qty"], "kwh_day": run_day + sb_day,
                     "kwh_month": (run_day + sb_day) * 365 / 12, "kwh_year": (run_day + sb_day) * 365,
                     "standby_kwh_year": sb_day * 365, "cost_month": (run_cost_y + sb_cost_y) / 12,
                     "cost_year": run_cost_y + sb_cost_y, "standby_cost_year": sb_cost_y,
                     "run_kwh_year": run_day * 365, "run_cost_year": run_cost_y})
        name = it["appliance"]
        if it["standby_w"] >= 5:
            add("MEDIUM", "STANDBY_DEVICE", f"{name}: {it['standby_w']:g} W standby per unit = {sb_day * 365:.0f} kWh/year; a switched power strip or unplugging saves {sb_cost_y:.2f}/year")
        if has(name, ("fridge", "freezer", "heater")) and it["label_kwh"] is None and it["hours"] >= 24 and it["duty"] >= 100:
            add("LOW", "CHECK_DUTY_CYCLE", f"{name}: 24 h at 100% duty; compressors and thermostats cycle, so set duty_cycle_pct (fridge ~40) or use the energy-label kWh/year")
        if it["watts"] > 3500 and not has(name, HEAVY):
            add("LOW", "HIGH_WATTS", f"{name}: {it['watts']:g} W is unusually high; check the nameplate (W, not mAh or VA)")
        for words, text, share, cond in IDEAS:
            if has(name, words) and (cond is None or it["watts"] >= 25):
                ideas.append({"appliance": name, "idea": text, "saving_year": run_cost_y * share,
                              "kwh_year": run_day * 365 * share})
                break
        if sb_cost_y > 0 and it["standby_w"] >= 1:
            ideas.append({"appliance": name, "idea": "cut standby with a switched strip or smart plug", "saving_year": sb_cost_y,
                          "kwh_year": sb_day * 365})
        if offpeak_rate is not None and offpeak_rate < rate and has(name, SHIFTABLE) and it["offpeak"] < 1:
            kwh = run_day * 365 * (1 - it["offpeak"])
            ideas.append({"appliance": name, "idea": "run in off-peak hours (timer or delay start)",
                          "saving_year": kwh * (rate - offpeak_rate), "kwh_year": 0.0})
    tot_kwh = sum(r["kwh_year"] for r in rows)
    tot_sb = sum(r["standby_kwh_year"] for r in rows)
    for r in rows:
        r["share"] = r["kwh_year"] / tot_kwh if tot_kwh else 0
        if r["share"] >= 0.25:
            add("MEDIUM", "BIG_CONSUMER", f"{r['appliance']} is {r['share']:.0%} of the estimated use ({r['kwh_year']:.0f} kWh/year); check it first")
    if tot_kwh and tot_sb / tot_kwh >= 0.10:
        add("HIGH", "STANDBY_SHARE", f"standby is {tot_sb / tot_kwh:.0%} of all use ({tot_sb:.0f} kWh/year, {tot_sb * rate:.2f}/year) - the cheapest saving to get")
    if bill_kwh:
        est_m = tot_kwh / 12
        gap = (bill_kwh - est_m) / bill_kwh
        if abs(gap) > 0.25:
            add("HIGH", "BILL_GAP", f"estimate {est_m:.0f} kWh/month vs bill {bill_kwh:g} kWh/month ({gap:+.0%}); "
                + ("missing appliances (water heating, electric heating, EV?) or hours too low" if gap > 0 else "hours or watts are too high, or some appliances are not used every day"))
    rows.sort(key=lambda r: -r["kwh_year"])
    ideas.sort(key=lambda x: -x["saving_year"])
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    totals = {"kwh_day": tot_kwh / 365, "kwh_month": tot_kwh / 12, "kwh_year": tot_kwh,
              "standby_kwh_year": tot_sb, "energy_cost_year": sum(r["cost_year"] for r in rows),
              "standing_charge_year": standing * 365}
    totals["energy_cost_month"] = totals["energy_cost_year"] / 12
    totals["total_cost_year"] = totals["energy_cost_year"] + totals["standing_charge_year"]
    totals["total_cost_month"] = totals["total_cost_year"] / 12
    totals["savings_year"] = sum(i["saving_year"] for i in ideas)
    return rows, totals, findings, ideas


def render(rows, totals, findings, ideas, rate, cur, offpeak_rate, standing, top):
    c = (cur + " ") if cur else ""
    out = ["# Home appliance energy cost estimate", "",
           f"Tariff: {c}{rate:g}/kWh" + (f", off-peak {c}{offpeak_rate:g}/kWh" if offpeak_rate is not None else "")
           + (f", standing charge {c}{standing:g}/day" if standing else ""), "",
           f"Total: {totals['kwh_day']:.1f} kWh/day, {totals['kwh_month']:.0f} kWh/month, {totals['kwh_year']:,.0f} kWh/year; "
           f"energy {c}{totals['energy_cost_month']:,.2f}/month, {c}{totals['energy_cost_year']:,.2f}/year"
           + (f"; with standing charge {c}{totals['total_cost_year']:,.2f}/year" if standing else ""),
           f"Standby: {totals['standby_kwh_year']:.0f} kWh/year ({totals['standby_kwh_year'] / totals['kwh_year']:.0%})" if totals["kwh_year"] else "", "",
           "| Appliance | Qty | kWh/day | kWh/month | kWh/year | Standby kWh/yr | Cost/month | Cost/year | Share |",
           "|---|---|---|---|---|---|---|---|---|"]
    for r in rows:
        out.append(f"| {r['appliance']} | {r['qty']:g} | {r['kwh_day']:.2f} | {r['kwh_month']:.1f} | {r['kwh_year']:.0f} | "
                   f"{r['standby_kwh_year']:.0f} | {r['cost_month']:.2f} | {r['cost_year']:.2f} | {r['share']:.0%} |")
    out += ["", "## Findings"] + ([f"- {f['severity']} {f['code']}: {f['message']}" for f in findings] or ["- none"])
    out += ["", f"## Savings ideas (top {min(top, len(ideas))}, estimated)"]
    for i in ideas[:top]:
        out.append(f"- {i['appliance']}: {i['idea']} -> about {c}{i['saving_year']:.2f}/year" + (f", {i['kwh_year']:.0f} kWh" if i["kwh_year"] else ""))
    if len(ideas) > top:
        out.append(f"- ... {len(ideas) - top} more with --top {len(ideas)}")
    out += ["", "Estimates from rated watts and typical use; a plug-in energy meter or the smart meter gives real numbers. Savings overlap, so do not add them all up."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def run(csv_text, **kw):
        return estimate(read_csv(csv_text), kw.pop("rate", 0.30), **kw)
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n")
    check("1 kWh/day kettle", abs(rows[0]["kwh_day"] - 1.0) < 1e-9)
    check("365 kWh/year", abs(t["kwh_year"] - 365) < 1e-9)
    check("cost per year at 0.30", abs(t["energy_cost_year"] - 109.5) < 1e-6)
    check("month = year/12", abs(t["kwh_month"] - 365 / 12) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week,quantity\nWashing machine,1000,2,3.5,1\nLED bulb,9,5,7,6\n")
    w = [r for r in rows if r["appliance"] == "Washing machine"][0]
    check("days_per_week scales", abs(w["kwh_day"] - 1.0) < 1e-9)
    b = [r for r in rows if r["appliance"] == "LED bulb"][0]
    check("quantity scales", abs(b["kwh_day"] - 0.27) < 1e-9)
    check("LED idea skipped under 25 W", not any("LED" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day,duty_cycle_pct\nFridge,150,24,40\n")
    check("duty cycle", abs(rows[0]["kwh_day"] - 1.44) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTV,100,4,10\n")
    check("standby hours = 24 - use", abs(rows[0]["standby_kwh_year"] - 10 * 20 / 1000 * 365) < 1e-9)
    codes = [x["code"] for x in f]
    check("STANDBY_DEVICE", "STANDBY_DEVICE" in codes)
    check("STANDBY_SHARE HIGH", "STANDBY_SHARE" in codes)
    rows, t, f, i = run("appliance,watts,hours_per_day,kwh_per_year,quantity\nFreezer,,,250,2\n")
    check("energy label kWh used", abs(t["kwh_year"] - 500) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,offpeak_pct\nDishwasher,1200,1,50\n", offpeak_rate=0.15)
    check("off-peak share priced", abs(rows[0]["cost_year"] - 1.2 * 365 * (0.5 * 0.30 + 0.5 * 0.15)) < 1e-6)
    check("shift idea for rest", any("off-peak" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day,months_per_year\nSpace heater,1000,4,6\n")
    check("months_per_year seasonal", abs(t["kwh_year"] - 4 * 365 / 2) < 1e-9)
    rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week\nDishwasher,1000,1,7\n")
    check("dishwasher gets eco idea, not washer idea", "eco" in i[0]["idea"])
    rows, t, f, i = run("appliance,watts,hours_per_day\nHeater,2000,6\nTV,80,3\n")
    check("BIG_CONSUMER", any(x["code"] == "BIG_CONSUMER" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=60)
    check("BILL_GAP HIGH", any(x["code"] == "BILL_GAP" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=32)
    check("no BILL_GAP within 25%", not any(x["code"] == "BILL_GAP" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nFridge,150,24\n")
    check("CHECK_DUTY_CYCLE LOW", any(x["code"] == "CHECK_DUTY_CYCLE" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day\nAquarium pump,5000,1\n")
    check("HIGH_WATTS LOW", any(x["code"] == "HIGH_WATTS" for x in f))
    rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTelevision,90,4,0.5\nWater heater,2000,3,0\n", offpeak_rate=0.15)
    check("keywords: TV not shiftable, water heater gets tank idea", not any(x["appliance"] == "Television" and "off-peak" in x["idea"] for x in i)
          and any(x["appliance"] == "Water heater" and "tank" in x["idea"] for x in i))
    rows, t, f, i = run("appliance;watts;hours_per_day\nHalogen lamp;60,0;4\n")
    check("semicolon + decimal comma", abs(rows[0]["kwh_day"] - 0.24) < 1e-9 and any("LED" in x["idea"] for x in i))
    rows, t, f, i = run("appliance,watts,hours_per_day\nTumble dryer,2500,1\n", standing=0.5)
    check("standing charge separate", abs(t["standing_charge_year"] - 182.5) < 1e-9 and abs(t["total_cost_year"] - t["energy_cost_year"] - 182.5) < 1e-9)
    check("dryer idea 50%", abs(i[0]["saving_year"] - 2.5 * 365 * 0.30 * 0.5) < 1e-6)
    for bad, name in (("appliance,watts,hours_per_day\nX,100,25\n", "hours > 24 rejected"),
                      ("appliance,watts,hours_per_day\nX,abc,2\n", "non-number rejected"),
                      ("name,power\nX,100\n", "missing columns rejected"),
                      ("appliance,watts,hours_per_day\nX,-5,2\n", "negative rejected")):
        try:
            read_csv(bad)
            check(name, False)
        except DataError:
            check(name, True)
    print(f"{sum(results)}/{len(results)} passed")
    return 0 if all(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Home appliance energy cost estimator")
    ap.add_argument("csv", nargs="?", help="appliances CSV")
    ap.add_argument("--rate", type=float, help="price per kWh (standard rate)")
    ap.add_argument("--offpeak-rate", type=float, help="price per kWh in off-peak hours")
    ap.add_argument("--standing-charge", type=float, default=0.0, help="fixed charge per day")
    ap.add_argument("--currency", default="", help="label, e.g. EUR, USD, TRY")
    ap.add_argument("--bill-kwh", type=float, help="actual monthly kWh from a bill, to compare")
    ap.add_argument("--top", type=int, default=8, help="savings ideas to show (default 8)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.csv or a.rate is None:
        ap.error("give a CSV and --rate, or --self-test")
    try:
        with open(a.csv, encoding="utf-8-sig") as fh:
            items = read_csv(fh.read())
        rows, totals, findings, ideas = estimate(items, a.rate, a.offpeak_rate, a.standing_charge, a.bill_kwh)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"appliances": rows, "totals": totals, "findings": findings, "ideas": ideas}, indent=2))
    else:
        print(render(rows, totals, findings, ideas, a.rate, a.currency, a.offpeak_rate, a.standing_charge, a.top))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinanceBudgetingPython+1
F@f
0
Dịch Vụ FastAPI taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để xây dựng và vận hành các dịch vụ FastAPI trên môi trường production bằng Python.

# Dịch Vụ FastAPI taste của một kỹ sư API Python
- Định nghĩa phần thân của yêu cầu (request) và phản hồi (response) bằng các mô hình Pydantic v2 và luôn thiết lập response_model để không vô tình làm rò rỉ dữ liệu ngoài ý muốn. Độ tin cậy: 0.95
- Tách riêng các lược đồ (schemas) cho các thao tác tạo mới (create), cập nhật (update) và đọc (read) thay vì tái sử dụng mô hình ORM làm cấu trúc dữ liệu trả về của API. Độ tin cậy: 0.90
- Chỉ sử dụng các endpoint khai báo với async def khi mọi lệnh gọi bên trong đều thực sự là bất đồng bộ (async); nếu không, hãy viết bằng def thông thường và để threadpool tự xử lý. Độ tin cậy: 0.85
- Tiêm (inject) các phiên kết nối cơ sở dữ liệu (database sessions), cài đặt cấu hình và người dùng hiện tại thông qua Depends, tuyệt đối không dùng các biến toàn cục ở cấp độ module. Độ tin cậy: 0.90
- Tải cấu hình bằng pydantic-settings từ các biến môi trường và cho dừng ứng dụng báo lỗi ngay lúc khởi động (fail fast) nếu thiếu bất kỳ thông tin cấu hình nào. Độ tin cậy: 0.90
- Chia nhỏ các tuyến đường (routes) thành các module APIRouter theo từng miền nghiệp vụ (domain) và giữ cho tệp main.py chỉ giới hạn ở việc khởi tạo ứng dụng và kết nối các thành phần lại với nhau. Độ tin cậy: 0.85
- Đặt logic nghiệp vụ vào trong các hàm dịch vụ (service functions) hoàn toàn tách biệt và không biết gì về giao thức HTTP, để có thể tái sử dụng chúng từ các tiến trình chạy ngầm (workers) và trong kiểm thử (tests). Độ tin cậy: 0.85
- Sử dụng cú pháp SQLAlchemy 2.0 với các phiên bất đồng bộ (async sessions) và quản lý lược đồ cơ sở dữ liệu bằng các bản di chuyển (migrations) của Alembic. Độ tin cậy: 0.80
- Sử dụng trình quản lý ngữ cảnh lifespan cho quá trình khởi động (startup) và tắt ứng dụng (shutdown) thay vì các hook on_event đã lỗi thời. Độ tin cậy: 0.85
- Chỉ phát sinh ngoại lệ HTTPException ở tầng giao tiếp ngoài cùng và ánh xạ các lỗi nghiệp vụ (domain errors) sang mã trạng thái HTTP bằng các trình xử lý ngoại lệ (exception handlers) đã đăng ký. Độ tin cậy: 0.80
- Trả về cấu trúc lỗi nhất quán gồm mã lỗi máy có thể đọc được (machine-readable code), thông báo lỗi và thông tin chi tiết tùy chọn. Độ tin cậy: 0.85
- Đánh phiên bản cho API ngay trên đường dẫn (/v1) và coi lược đồ OpenAPI được tạo ra như một bản hợp đồng giao tiếp được kiểm tra tự động trong quy trình CI. Độ tin cậy: 0.75
- Sử dụng phân trang dựa trên con trỏ (cursor-based pagination) cho các danh sách lớn và giới hạn kích thước trang tối đa ở phía máy chủ. Độ tin cậy: 0.80
- Kiểm thử bằng pytest và httpx.AsyncClient, ghi đè các phụ thuộc (overriding dependencies) thay vì dùng kỹ thuật monkey-patch vào các thành phần nội bộ. Độ tin cậy: 0.90
- Chạy các tiến trình con (workers) của uvicorn phía sau gunicorn hoặc một trình quản lý tiến trình trên môi trường production, và chỉ dùng cờ --reload trong môi trường phát triển (development). Độ tin cậy: 0.80
- Chuyển các tác vụ xử lý chậm sang một hàng đợi tác vụ (task queue) thực thụ như Celery, Arq hoặc Dramatiq; BackgroundTasks chỉ dành cho các công việc nhỏ chạy một lần rồi thôi (fire-and-forget). Độ tin cậy: 0.85
- Thêm ID yêu cầu (request IDs), nhật ký định dạng JSON có cấu trúc và middleware truy vết OpenTelemetry ngay từ ngày đầu tiên. Độ tin cậy: 0.80
- Bảo vệ các endpoint bằng OAuth2 bearer token hoặc khóa API thông qua các phụ thuộc bảo mật (security dependencies) để trang tài liệu tự động hiển thị cơ chế xác thực. Độ tin cậy: 0.80
- Cung cấp riêng biệt hai endpoint /healthz và /readyz, trong đó bước kiểm tra mức độ sẵn sàng (readiness) sẽ kiểm tra kết nối cơ sở dữ liệu và các phụ thuộc cứng khác. Độ tin cậy: 0.85
- Quản lý các thư viện phụ thuộc bằng uv kèm theo tệp khóa (lock file), đồng thời kiểm tra lỗi cú pháp (lint) và định dạng mã nguồn bằng Ruff. Độ tin cậy: 0.75
- Kiểm tra kiểu dữ liệu (type check) bằng mypy hoặc pyright ở chế độ nghiêm ngặt (strict mode) trên gói dịch vụ. Độ tin cậy: 0.70
BackendBest PracticesAPI+2
F@f
0
Trình Rà Soát Bảo Mật Cấu Hình Nginx
Kỹ năng

Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: nginx-config-security-reviewer
description: Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Rà Soát Bảo Mật Cấu Hình Nginx

Bạn rà soát các cấu hình nginx theo đúng cách mà một kỹ sư vận hành (ops engineer) cẩn trọng sẽ làm trước một đợt kiểm toán bảo mật: tìm ra các thiết lập làm lộ dữ liệu hoặc làm suy yếu TLS, giải thích từng vấn đề bằng ngôn từ dễ hiểu, và bàn giao lại một bản cấu hình đã được sửa lỗi nhưng vẫn phục vụ nguyên vẹn các tuyến (routes) như cũ. Bạn tuyệt đối không phỏng đoán về những tệp mà mình chưa được xem, và không bao giờ bảo người dùng nạp lại (reload) cấu hình mà không chạy `nginx -t` trước.

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

- `scripts/nginx_review.py` - phân tích cú pháp các tệp cấu hình nginx thành từng khối (blocks) và in ra các phát hiện kèm theo mức độ nghiêm trọng, số dòng và cách khắc phục (chỉ dùng thư viện chuẩn Python 3)
- `references/hardening-checklist.md` - mọi mục kiểm tra mà kịch bản thực thi, lý do tại sao nó quan trọng và cách khắc phục, cộng thêm các mục cần kiểm tra thủ công
- `references/tls-and-headers.md` - thiết lập giao thức TLS và bộ mã hóa (ciphers), lộ trình triển khai HSTS, các tiêu đề bảo mật và cơ chế hoạt động của tính kế thừa `add_header`
- `templates/review-report.md` - bản báo cáo bạn bàn giao lại: tóm tắt, bảng phát hiện lỗi, phần so sánh khác biệt (diff) của cấu hình, các câu lệnh kiểm tra
- `examples/shop-site.conf` - một cấu hình cửa hàng nhỏ từ năm 2019 với 18 phát hiện lỗi (3 lỗi mức HIGH)
- `examples/shop-site-fixed.conf` - cùng trang web đó sau khi đã áp dụng các bản sửa lỗi (không còn phát hiện lỗi nào ngoại trừ ghi chú về lệnh include)

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

### 1. Thu thập toàn bộ cấu hình
Yêu cầu kết quả đầu ra của lệnh `sudo nginx -T` (lệnh này in ra tệp cấu hình chính và mọi tệp được đính kèm qua lệnh include), hoặc yêu cầu tệp nginx.conf cộng với các tệp liên quan trong thư mục sites-enabled. Hỏi xem trang web đó làm gì (phục vụ tệp tĩnh, reverse proxy, PHP), những đường dẫn nào bắt buộc phải giữ công khai, phiên bản nginx (`nginx -v`) và liệu có CDN hay bộ cân bằng tải (load balancer) nào đứng trước xử lý chấm dứt TLS (terminate TLS) hay không. Nhắc người dùng xóa các khóa bí mật (private keys) hoặc thông tin nhạy cảm (secrets) trước khi dán nội dung vào; giữ nguyên đường dẫn chứng chỉ thì không sao.

### 2. Chạy công cụ kiểm tra
```
python3 scripts/nginx_review.py examples/shop-site.conf
python3 scripts/nginx_review.py /etc/nginx/nginx.conf /etc/nginx/sites-enabled/shop --json
python3 scripts/nginx_review.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là không thể đọc hoặc phân tích cú pháp tệp (thông báo lỗi sẽ chỉ rõ dòng gặp sự cố).

### 3. Xác nhận và bổ sung các bước kiểm tra thủ công
Đọc từng phát hiện lỗi trong mối tương quan với bối cảnh của người dùng: việc có một CDN đứng trước sẽ làm thay đổi các phát hiện về TLS, một tuyến quản trị chỉ dùng nội bộ có thể đã được bảo vệ bởi tường lửa, một câu lệnh `return 404` có thể đã ẩn đường dẫn đó đi rồi. Sau đó, rà soát qua các mục kiểm tra thủ công trong `references/hardening-checklist.md` (các tệp được include, xác minh TLS ở phía upstream, các khối `if`, các bộ xử lý PHP, quyền truy cập tệp). Loại bỏ các cảnh báo dương tính giả (false positives) và giải thích rõ lý do.

### 4. Bàn giao kết quả
Điền vào `templates/review-report.md`: ưu tiên các mục mức HIGH lên đầu, đưa ra bản so sánh khác biệt (diff) tối giản của cấu hình, cùng các bước kiểm thử. Luôn giải thích quy tắc kế thừa của `add_header` bất cứ khi nào lỗi ADD_HEADER_INHERITANCE xuất hiện, bởi vì quy tắc này gây bất ngờ cho hầu hết mọi người. Khi mới bắt đầu áp dụng HSTS cho một trang web chưa từng dùng nó trước đây, hãy đề xuất mức `max-age` ngắn trước.

## Kết quả ví dụ
Đối với `examples/shop-site.conf`: 3 lỗi mức HIGH (bật TLS 1.0/1.1, dùng các bộ mã hóa RC4 và 3DES, khối `location /images` dùng `alias .../images/` cho phép tấn công duyệt đường dẫn qua `/images../`), 6 lỗi mức MEDIUM (kích thước phần thân yêu cầu không giới hạn, cổng 80 trực tiếp trả về nội dung thay vì chuyển hướng, bật autoindex trên `/downloads/`, đường dẫn `/api/` làm mất HSTS và nosniff do tự đặt `add_header` riêng, đường dẫn `/admin/` không được bảo vệ, dùng biến `$uri` trong lệnh chuyển hướng), 8 lỗi mức LOW (server_tokens, các tệp ẩn bắt đầu bằng dấu chấm - dotfiles, thiếu CSP/Referrer-Policy/X-Frame-Options, các tiêu đề proxy, không giới hạn tần suất truy cập trên `/api/auth/login`) và 1 mục mức INFO. Tệp `examples/shop-site-fixed.conf` thể hiện các bản sửa lỗi và vượt qua bước kiểm tra `nginx -t`.

## Quy tắc

- Trích dẫn chính xác dòng bị lỗi và nội dung thay thế chính xác; giữ cho các thay đổi ở mức tối thiểu và bảo toàn nguyên vẹn chức năng định tuyến.
- Luôn kết thúc bằng câu lệnh `sudo nginx -t && sudo systemctl reload nginx` cùng một lệnh kiểm tra bằng curl; tuyệt đối không đề xuất khởi động lại (restart) làm đứt kết nối khi chỉ cần nạp lại (reload) là đủ.
- Không tự bịa ra các chỉ thị (directives): hãy đối chiếu tên chỉ thị với tài liệu chính thức của nginx tương ứng với phiên bản của người dùng (ví dụ: `http2 on;` yêu cầu phiên bản 1.25.1 trở lên, các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`).
- Tuyệt đối không yêu cầu hoặc lặp lại các khóa bí mật (private keys), mật khẩu hoặc mã băm htpasswd.
- Hãy coi việc áp dụng các tiêu đề bảo mật như CSP là một lộ trình triển khai từng bước: đề xuất dùng `Content-Security-Policy-Report-Only` trước đối với các ứng dụng phức tạp.
FILE:references/hardening-checklist.md
# Danh Sách Kiểm Tra Gia Cố Bảo Mật Nginx

Mọi mã lỗi mà kịch bản in ra, lý do tại sao nó quan trọng và cách khắc phục. Mức độ nghiêm trọng dưới đây là mức mặc định; hãy nâng lên hoặc hạ xuống tùy theo bối cảnh thực tế.

## HIGH (Cao)

### OLD_TLS_PROTOCOL
Chỉ thị `ssl_protocols` có liệt kê SSLv3, TLSv1 hoặc TLSv1.1. Các giao thức này đã bị loại bỏ (RFC 8996), không đạt chuẩn PCI DSS và cho phép thực hiện các cuộc tấn công hạ cấp (downgrade attacks). Cách khắc phục: đặt `ssl_protocols TLSv1.2 TLSv1.3;` (đây là giá trị mặc định kể từ phiên bản nginx 1.23.4, vì vậy việc xóa dòng này đi cũng có tác dụng tương tự trên các phiên bản mới). Chỉ những trình khách (clients) quá cũ (như Android 4.3, IE trên Windows 7 chưa cập nhật) mới bị mất quyền truy cập.

### WEAK_CIPHERS
Chỉ thị `ssl_ciphers` cho phép các bộ mã hóa RC4, DES/3DES (lỗ hổng SWEET32), MD5, NULL, EXPORT hoặc ẩn danh (aNULL/ADH). Các mục bắt đầu bằng dấu `!` hoặc `-` là các quy tắc loại trừ nên hoàn toàn hợp lệ. Cách khắc phục: sử dụng danh sách "intermediate" (trung cấp) của Mozilla từ trang ssl-config.mozilla.org hoặc xóa bỏ dòng `ssl_ciphers` để sử dụng cấu hình mặc định của OpenSSL, đồng thời đặt `ssl_prefer_server_ciphers off;` khi chỉ còn lại các bộ mã hóa AEAD hiện đại.

### NO_CERTIFICATE
Một khối server lắng nghe với tham số `ssl` nhưng lại không thiết lập `ssl_certificate` bên trong nó hoặc trong khối http. Khi đó nginx sẽ từ chối khởi động hoặc làm thất bại quá trình bắt tay (handshake). Hãy thêm `ssl_certificate` (chuỗi chứng chỉ đầy đủ - full chain) và `ssl_certificate_key`.

### ALIAS_TRAVERSAL (lệch dấu gạch chéo - off-by-slash)
`location /images { alias /var/www/media/images/; }`: một yêu cầu truy cập tới `/images../secret.txt` sẽ được ánh xạ thành `/var/www/media/images/../secret.txt`, khiến cho các tệp nằm ở thư mục cha ngay phía trên đường dẫn alias có thể bị đọc trái phép (một phát hiện rất phổ biến trong nhiều cuộc kiểm toán bảo mật). Cách khắc phục: đảm bảo cả đường dẫn trong location và đường dẫn trong alias đều kết thúc bằng dấu gạch chéo, tức là `location /images/ { alias /var/www/media/images/; }`, hoặc sử dụng chỉ thị `root` khi đường dẫn URL trùng khớp với tên thư mục trên đĩa.

## MEDIUM (Trung bình)

### HTTP_NO_REDIRECT
Một khối server ở cổng 80 có cùng `server_name` với một khối server HTTPS nhưng lại trực tiếp phục vụ các trang web thay vì chuyển hướng người dùng. Khi đó người dùng và dữ liệu cookie vẫn nằm trên kết nối HTTP thuần không mã hóa. Cách khắc phục:
```
server {
    listen 80;
    server_name shop.example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}
```

### NO_HSTS
Không có tiêu đề `Strict-Transport-Security` trên máy chủ HTTPS, dẫn đến việc yêu cầu đầu tiên của một phiên truy cập có thể bị tấn công hạ cấp xuống HTTP. Xem tệp `tls-and-headers.md` để biết lộ trình triển khai an toàn.

### ADD_HEADER_INHERITANCE
nginx chỉ kế thừa các chỉ thị `add_header` từ cấp cha khi cấp hiện tại hoàn toàn không có bất kỳ chỉ thị `add_header` nào. Chỉ cần một dòng `add_header Cache-Control no-store;` bên trong khối `location /api/` cũng sẽ làm mất sạch mọi tiêu đề bảo mật được khai báo ở khối `server` đối với location đó. Cách khắc phục: lặp lại đầy đủ các tiêu đề bảo mật bên trong location đó, hoặc tách chúng ra một tệp đoạn mã riêng (`include snippets/security-headers.conf;`) và include tệp này vào mọi khối có sử dụng `add_header` riêng. Trên phiên bản nginx 1.29.3 trở lên, việc đặt `add_header_inherit merge;` trong khối http sẽ giúp các khối con cộng gộp thêm vào các tiêu đề của khối cha thay vì ghi đè toàn bộ. Lưu ý rằng `proxy_set_header`, `fastcgi_param` và `proxy_hide_header` cũng tuân theo đúng quy tắc kế thừa này.

### AUTOINDEX_ON
Tính năng liệt kê thư mục làm hiển thị tên của mọi tệp tin, bao gồm cả các bản sao lưu (backups) và các tệp kết xuất dữ liệu (exports) mà không ai chèn liên kết tới. Hãy tắt tính năng này đi hoặc thiết lập cơ chế bảo vệ cho location đó.

### ADMIN_OPEN
Các đường dẫn như /admin, /phpmyadmin, /actuator, /server-status, /nginx_status hoặc /metrics không được thiết lập quy tắc allow/deny, auth_basic hoặc auth_request. Cách khắc phục: thêm `allow 10.20.0.0/16; deny all;` (dải mạng văn phòng hoặc VPN), hoặc dùng `auth_request` trỏ tới dịch vụ đăng nhập một lần (SSO). Chỉ sử dụng xác thực cơ bản (Basic auth) qua kết nối HTTPS.

### UNLIMITED_BODY
Thiết lập `client_max_body_size 0` sẽ vô hiệu hóa giới hạn kích thước yêu cầu gửi lên, khiến kẻ tấn công dễ dàng làm cạn kiệt dung lượng đĩa và bộ nhớ. Hãy giữ mức mặc định 1m hoặc đặt một mức giới hạn thực tế trong khối http, và chỉ nâng giới hạn này lên ở riêng location dùng để tải tệp lên (upload).

### URI_IN_REDIRECT
Biến `$uri` đã được chuẩn hóa và giải mã URL (URL-decoded), vì vậy chuỗi `%0d%0a` sẽ biến thành ký tự xuống dòng thực sự và kẻ tấn công có thể chèn các tiêu đề (`Set-Cookie`) vào phản hồi chuyển hướng (HTTP response splitting / header injection). Hãy sử dụng biến `$request_uri` (dòng yêu cầu thô, vẫn còn nguyên mã hóa URL) trong các lệnh `return`, đích đến của `rewrite` và `add_header`.

## LOW (Thấp)

### SERVER_TOKENS
Số phiên bản xuất hiện trong tiêu đề `Server` và trên các trang báo lỗi, giúp kẻ tấn công dễ dàng đối chiếu với các lỗ hổng CVE đã biết. Hãy đặt `server_tokens off;` trong khối http.

### MISSING_SECURITY_HEADERS
Trên các máy chủ HTTPS: thiếu `X-Content-Type-Options nosniff`, `X-Frame-Options` (hoặc CSP `frame-ancestors`), `Content-Security-Policy`, `Referrer-Policy`. Hãy thêm chúng kèm theo tham số `always` để các tiêu đề này cũng được gửi đi trong cả các phản hồi lỗi 4xx/5xx.

### SHORT_HSTS
Giá trị `max-age` của HSTS ngắn hơn 180 ngày. Mức này hoàn toàn ổn trong giai đoạn mới triển khai thử nghiệm; hãy nâng nó lên mức một năm sau khi đã chạy ổn định.

### DOTFILES_EXPOSED
Máy chủ phục vụ các tệp tĩnh nhưng lại không có quy tắc chặn các tệp ẩn bắt đầu bằng dấu chấm, dẫn đến việc các tệp như `/.git/config`, `/.env` hoặc `/.htpasswd` có thể bị tải xuống nếu chúng tồn tại bên dưới thư mục gốc của web (web root). Cách khắc phục: thêm `location ~ /\.(?!well-known) { deny all; }` (vẫn giữ cho các yêu cầu xác thực chứng chỉ ACME hoạt động bình thường).

### NO_RATE_LIMIT
Một đường dẫn đăng nhập, xác thực hoặc cấp phát token không được áp dụng `limit_req`. Hãy khai báo một vùng nhớ (zone) trong khối http và áp dụng nó vào location tương ứng; trả về mã lỗi 429 bằng chỉ thị `limit_req_status 429;`.

### PROXY_HEADERS
Một khối location dùng `proxy_pass` nhưng không gửi kèm các tiêu đề Host, X-Forwarded-For và X-Forwarded-Proto, khiến ứng dụng phía sau ghi nhật ký nhầm địa chỉ IP của máy chủ proxy, tạo sai các URL chuyển hướng và có thể hiểu nhầm các yêu cầu HTTPS là HTTP. Hãy nhớ quy tắc kế thừa: chỉ cần một dòng `proxy_set_header` trong khối location cũng sẽ thay thế toàn bộ các dòng `proxy_set_header` được kế thừa từ cấp trên.

## INFO (Thông tin)

### INCLUDE_NOT_FOLLOWED
Kịch bản chỉ đọc những tệp mà bạn truyền trực tiếp vào. Hãy yêu cầu kết quả của lệnh `nginx -T` hoặc các tệp được đính kèm qua lệnh include.

## Kiểm tra thủ công (không tự động hóa)
- `proxy_pass https://...` trỏ tới một máy chủ upstream ngoài internet mà không bật `proxy_ssl_verify on;` và `proxy_ssl_trusted_certificate`.
- PHP: khối `location ~ \.php$` bắt buộc phải sử dụng `try_files $uri =404;` (hoặc `fastcgi_split_path_info` kèm theo bước kiểm tra) để các tệp hình ảnh do người dùng tải lên không thể bị thực thi như mã PHP; các thư mục chứa tệp tải lên tuyệt đối không bao giờ được chuyển tiếp sang bộ xử lý PHP.
- Dùng câu lệnh `if` bên trong khối location với bất kỳ chỉ thị nào khác ngoài `return` hoặc `rewrite ... last` (nguyên tắc nổi tiếng "if is evil").
- Các khối `default_server`: một yêu cầu có tiêu đề Host lạ không xác định nên nhận về `return 444;` kèm chứng chỉ tự ký hoặc bật `ssl_reject_handshake on;` (từ bản 1.19.4 trở lên) thay vì rơi vào trang web đầu tiên trong cấu hình.
- Quyền truy cập tệp (Permissions): các tiến trình worker của nginx không được phép có quyền ghi vào thư mục gốc của web (web root); các khóa bí mật (private keys) phải đặt `chmod 600` và thuộc quyền sở hữu của root.
- Tiêu đề kích thước lớn / các trình khách kết nối chậm (slow clients): cấu hình `client_body_timeout`, `client_header_timeout`, `large_client_header_buffers` cho các API công khai đang chịu tấn công.
- Ghi nhật ký (Logging): nhật ký truy cập (access logs) không được ghi lại các token nằm trong chuỗi truy vấn (query strings); hãy sử dụng một định dạng `log_format` tùy chỉnh nếu cần.
FILE:references/tls-and-headers.md
# TLS và Các Tiêu Đề Bảo Mật

## Thiết lập TLS vượt qua hầu hết các đợt kiểm toán (nginx 1.25+ với OpenSSL 1.1.1 hoặc 3.x)
```
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
```
Đây là cấu hình thuộc hồ sơ "intermediate" (trung cấp) của Mozilla. Các bộ mã hóa của TLS 1.3 không được đặt thông qua chỉ thị `ssl_ciphers` (OpenSSL tự xử lý chúng). Nếu giữ lại các bộ mã hóa DHE, hãy cung cấp thêm `ssl_dhparam` với độ dài tối thiểu 2048 bit hoặc loại bỏ các mục DHE khỏi danh sách.

Tính năng OCSP stapling (`ssl_stapling on; ssl_stapling_verify on;`) là tùy chọn; Let's Encrypt đã ngừng hỗ trợ OCSP vào năm 2025, vì vậy đừng thêm cấu hình này đối với các chứng chỉ của Let's Encrypt.

HTTP/2: dùng `http2 on;` bên trong khối server trên nginx 1.25.1 trở lên; các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`.

## Lộ trình triển khai HSTS
1. Đảm bảo mọi tên miền phụ (subdomain) đều hoạt động tốt qua giao thức HTTPS.
2. Bắt đầu với thời hạn ngắn: `add_header Strict-Transport-Security "max-age=300" always;`
3. Sau một tuần chạy ổn định không phát sinh sự cố: nâng lên `max-age=31536000; includeSubDomains`.
4. Chỉ thêm tham số `preload` và gửi đăng ký lên trang hstspreload.org khi bạn đã hoàn toàn chắc chắn; việc gỡ bỏ khỏi danh sách preload sẽ mất đến hàng tháng trời.
Tiêu đề HSTS gửi trên các phản hồi HTTP thuần sẽ bị trình duyệt bỏ qua, vì vậy hãy đặt nó trên khối server cổng 443.

## Các tiêu đề bảo mật (Security headers)
| Tiêu đề | Giá trị điển hình | Ghi chú |
|---|---|---|
| X-Content-Type-Options | `nosniff` | Ngăn chặn hành vi tự động đoán kiểu MIME đối với các tệp tải lên |
| X-Frame-Options | `DENY` hoặc `SAMEORIGIN` | Chống tấn công Clickjacking; chỉ thị `frame-ancestors` của CSP là giải pháp tương đương hiện đại hơn |
| Content-Security-Policy | `default-src 'self'` và các quy tắc khác | Đặc thù theo từng ứng dụng; hãy triển khai thử nghiệm trước bằng `Content-Security-Policy-Report-Only` |
| Referrer-Policy | `strict-origin-when-cross-origin` | Giữ cho các đường dẫn và chuỗi truy vấn không bị lọt vào nhật ký của các trang web khác |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` | Tùy chọn |
Hãy thêm tham số `always` để các tiêu đề này luôn được gửi kèm trong cả những phản hồi báo lỗi.

## Quy tắc kế thừa của add_header
```
server {
    add_header X-Content-Type-Options nosniff always;   # A
    location / { }                                      # nhận được A
    location /api/ {
        add_header Cache-Control no-store;              # CHỈ nhận được tiêu đề này, A bị mất
    }
}
```
Một khối chỉ kế thừa các chỉ thị `add_header` từ khối cha của nó nếu bản thân nó không tự định nghĩa bất kỳ chỉ thị `add_header` nào. Hãy gom các tiêu đề dùng chung vào một tệp snippet và include tệp đó vào mọi khối có khai báo thêm tiêu đề riêng. Phiên bản nginx 1.29.3 (tháng 10 năm 2025) đã bổ sung chỉ thị `add_header_inherit merge;`, giúp các khối lồng bên trong cộng gộp thêm vào danh sách tiêu đề được kế thừa; hãy kiểm tra phiên bản bằng `nginx -v` trước khi đề xuất dùng nó (Debian 13 đi kèm bản 1.26).

## Kiểm thử
```
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://shop.example.com/ | grep -iE 'strict|x-content|x-frame|content-security|referrer|server:'
curl -sI http://shop.example.com/page?x=1        # kỳ vọng mã 301 chuyển tới https://shop.example.com/page?x=1
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/.git/config   # kỳ vọng mã 403 hoặc 404
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/images../      # kỳ vọng mã 404 sau khi sửa lỗi alias
openssl s_client -connect shop.example.com:443 -tls1_1 </dev/null   # kỳ vọng quá trình bắt tay thất bại
```
Để kiểm tra toàn diện từ bên ngoài: sử dụng công cụ kiểm tra máy chủ của SSL Labs hoặc chạy `testssl.sh shop.example.com`.
FILE:templates/review-report.md
# Báo cáo rà soát bảo mật Nginx: <tên trang web hoặc máy chủ>

**Phiên bản:** <nginx 1.26.3, OpenSSL 3.x>   **Vai trò:** <trang web tĩnh / reverse proxy trỏ tới Node trên cổng :3000 / PHP-FPM>
**Các tệp đã rà soát:** <đầu ra lệnh nginx -T, 2 tệp>   **TLS được chấm dứt bởi:** <nginx / CDN / bộ cân bằng tải>
**Tóm tắt:** <một câu: vấn đề nghiêm trọng nhất và bản sửa lỗi sẽ thay đổi điều gì>

## Các phát hiện lỗi (Findings)
| # | Mức độ | Mã lỗi | Tệp:dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | HIGH | <ALIAS_TRAVERSAL> | <nginx.conf:51> | <location /images cho phép truy cập /images../> | <location /images/ { alias .../images/; }> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |

## Các mục giữ nguyên không thay đổi (kèm lý do)
- <ADMIN_OPEN trên /admin/: đã được giới hạn bởi tường lửa đám mây chỉ cho phép mạng VPN; có thêm deny all nữa hay không? có/không>

## Các thay đổi trong cấu hình
```diff
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_protocols TLSv1.2 TLSv1.3;
```

## Áp dụng và kiểm thử
1. Sao lưu: `sudo cp -a /etc/nginx /etc/nginx.bak-<ngày>`
2. Chỉnh sửa tệp, sau đó chạy `sudo nginx -t && sudo systemctl reload nginx`
3. Các bước kiểm tra:
   - `curl -sI https://<host>/` hiển thị đầy đủ các tiêu đề bảo mật
   - `curl -sI http://<host>/x?y=1` trả về mã 301 chuyển tới cùng đường dẫn đó trên giao thức https
   - <các lệnh kiểm tra duyệt đường dẫn / tệp ẩn bắt đầu bằng dấu chấm / trang quản trị tương ứng với các phát hiện lỗi>
4. Khôi phục (Roll back): phục hồi lại bản sao lưu và nạp lại (reload) cấu hình.

## Các việc cần theo dõi tiếp (Follow-ups)
- <HSTS: nâng giá trị max-age lên 31536000 sau ngày <ngày>>
- <CSP: chuyển từ chế độ Report-Only sang chế độ bắt buộc thực thi (enforcing) sau khi đã rà soát các báo cáo>
FILE:examples/shop-site.conf
# /etc/nginx/nginx.conf for shop.example.com (single-file setup)
# A small online shop: static storefront, product images, a Node.js API on :3000
# and a legacy admin panel. Written in 2019, certificates renewed by certbot.

user www-data;
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    sendfile on;
    keepalive_timeout 65;
    client_max_body_size 0;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers "HIGH:RC4-SHA:DES-CBC3-SHA:!aNULL";
    ssl_prefer_server_ciphers on;

    server {
        listen 80;
        server_name shop.example.com www.shop.example.com;
        root /var/www/shop;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }
    }

    server {
        listen 443 ssl;
        http2 on;
        server_name shop.example.com www.shop.example.com;

        ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

        add_header Strict-Transport-Security "max-age=31536000" always;
        add_header X-Content-Type-Options nosniff always;

        root /var/www/shop;
        index index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location /images {
            alias /var/www/shop-media/images/;
        }

        location /downloads/ {
            alias /var/www/shop-media/downloads/;
            autoindex on;
        }

        location /api/ {
            add_header Cache-Control "no-store";
            proxy_pass http://127.0.0.1:3000;
        }

        location /api/auth/login {
            proxy_pass http://127.0.0.1:3000;
            proxy_set_header Host $host;
        }

        location /admin/ {
            proxy_pass http://127.0.0.1:8080/;
        }

        location /old-blog {
            return 301 https://blog.example.com$uri;
        }
    }
}
FILE:examples/shop-site-fixed.conf
# shop.example.com after the review: same routes, hardened.
# Shared security headers live in one snippet so locations with their own
# add_header can include it again (add_header is not inherited into a block
# that sets any add_header of its own).

user www-data;
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    sendfile on;
    keepalive_timeout 65;
    server_tokens off;
    client_max_body_size 10m;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;

    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    server {
        listen 80;
        server_name shop.example.com www.shop.example.com;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }

        location / {
            return 301 https://$host$request_uri;
        }
    }

    server {
        listen 443 ssl;
        http2 on;
        server_name shop.example.com www.shop.example.com;

        ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

        # same lines as /etc/nginx/snippets/security-headers.conf
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options SAMEORIGIN always;
        add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        root /var/www/shop;
        index index.html;

        location ~ /\.(?!well-known) {
            deny all;
        }

        location / {
            try_files $uri $uri/ /index.html;
        }

        location /images/ {
            alias /var/www/shop-media/images/;
        }

        location /downloads/ {
            alias /var/www/shop-media/downloads/;
        }

        location /api/ {
            add_header Cache-Control "no-store" always;
            add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
            add_header X-Content-Type-Options nosniff always;
            add_header X-Frame-Options SAMEORIGIN always;
            add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
            add_header Referrer-Policy strict-origin-when-cross-origin always;
            proxy_pass http://127.0.0.1:3000;
        }

        location /api/auth/login {
            limit_req zone=login burst=5 nodelay;
            proxy_pass http://127.0.0.1:3000;
        }

        location /admin/ {
            allow 10.20.0.0/16;
            deny all;
            proxy_pass http://127.0.0.1:8080/;
        }

        location /old-blog {
            return 301 https://blog.example.com$request_uri;
        }
    }
}
FILE:scripts/nginx_review.py
#!/usr/bin/env python3
"""Nginx config security reviewer (Python 3 standard library only).

Parses nginx.conf / sites-available files into a block tree and flags common
security and hardening problems: old TLS protocols, weak ciphers, missing HSTS
and security headers, the add_header inheritance trap, alias off-by-slash path
traversal, autoindex, unlimited body size, $uri in redirects (CRLF injection),
plain-HTTP servers without a redirect, open admin paths, login paths without
rate limiting, exposed dotfiles and proxies without forwarding headers.

Usage:
  python3 nginx_review.py site.conf [more.conf ...] [--json]
  python3 nginx_review.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable/unparseable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
OLD_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.1"}
WEAK_CIPHER_WORDS = ["RC4", "DES", "3DES", "MD5", "NULL", "EXPORT", "EXP-", "aNULL", "eNULL", "ADH", "AECDH"]
SECURITY_HEADERS = {
    "x-content-type-options": "X-Content-Type-Options nosniff",
    "x-frame-options": "X-Frame-Options DENY or SAMEORIGIN (or CSP frame-ancestors)",
    "content-security-policy": "Content-Security-Policy",
    "referrer-policy": "Referrer-Policy strict-origin-when-cross-origin",
}
LOGIN_RE = re.compile(r"login|signin|sign-in|auth|token|password|wp-login", re.I)
ADMIN_RE = re.compile(r"admin|phpmyadmin|actuator|server-status|nginx_status|/metrics|wp-admin", re.I)


class ParseError(Exception):
    pass


class Node:
    def __init__(self, name, args, line, children=None):
        self.name, self.args, self.line, self.children = name, args, line, children

    def get(self, name):
        return [c for c in (self.children or []) if c.name == name]

    def first(self, name):
        found = self.get(name)
        return found[0] if found else None

    def walk(self):
        for c in self.children or []:
            yield c
            if c.children is not None:
                yield from c.walk()


def tokenize(text):
    tokens, i, line, n = [], 0, 1, len(text)
    while i < n:
        ch = text[i]
        if ch == "\n":
            line += 1; i += 1
        elif ch.isspace():
            i += 1
        elif ch == "#":
            while i < n and text[i] != "\n":
                i += 1
        elif ch in "{};":
            tokens.append((ch, line, True)); i += 1
        elif ch in "\"'":
            q, j, buf = ch, i + 1, []
            while j < n and text[j] != q:
                if text[j] == "\\" and j + 1 < n:
                    buf.append(text[j + 1]); j += 2; continue
                if text[j] == "\n":
                    line += 1
                buf.append(text[j]); j += 1
            if j >= n:
                raise ParseError(f"line {line}: unterminated quote")
            tokens.append(("".join(buf), line, False)); i = j + 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] not in "{};":
                if text[j] == "$" and j + 1 < n and text[j + 1] == "{":  # var
                    k = text.find("}", j)
                    j = k + 1 if k != -1 else n
                    continue
                j += 1
            tokens.append((text[i:j], line, False)); i = j
    return tokens


def parse(text):
    tokens = tokenize(text)
    root = Node("main", [], 0, [])
    stack, cur = [root], []
    for tok, line, special in tokens:
        if special and tok == ";":
            if not cur:
                continue
            stack[-1].children.append(Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1]))
            cur = []
        elif special and tok == "{":
            if not cur:
                raise ParseError(f"line {line}: '{{' without a directive name")
            node = Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1], [])
            stack[-1].children.append(node); stack.append(node); cur = []
        elif special and tok == "}":
            if cur:
                raise ParseError(f"line {line}: missing ';' before '}}'")
            if len(stack) == 1:
                raise ParseError(f"line {line}: unexpected '}}'")
            stack.pop()
        else:
            cur.append((tok, line))
    if cur:
        raise ParseError(f"line {cur[0][1]}: directive '{cur[0][0]}' not terminated with ';'")
    if len(stack) != 1:
        raise ParseError(f"block '{stack[-1].name}' opened on line {stack[-1].line} is not closed")
    return root


def headers_of(node):
    return {d.args[0].lower(): d for d in node.get("add_header") if d.args}


def is_tls(server):
    for l in server.get("listen"):
        if "ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args):
            return True
    s = server.first("ssl")
    return bool(s and s.args and s.args[0] == "on")


def listens_plain(server):
    ls = server.get("listen")
    if not ls:
        return True  # default listen *:80
    for l in ls:
        if not ("ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args)):
            return True
    return False


def names(server):
    out = []
    for d in server.get("server_name"):
        out.extend(d.args)
    return set(out) or {"_"}


def inherited(scopes, name):
    """Value of a directive as nginx inherits it: innermost scope that sets it."""
    for s in reversed(scopes):
        d = s.first(name)
        if d is not None:
            return d
    return None


def effective_headers(scopes):
    for s in reversed(scopes):
        h = headers_of(s)
        if h:
            return h
    return {}


def review(text, fname="nginx.conf"):
    root = parse(text)
    http = root.first("http")
    top = http if http is not None else root
    base = [root] + ([http] if http is not None else [])
    servers = [s for s in top.get("server")]
    findings = []

    def add(sev, code, line, msg, fix):
        findings.append({"severity": sev, "code": code, "file": fname, "line": line, "message": msg, "fix": fix})

    for d in root.walk():
        if d.name == "include":
            add("INFO", "INCLUDE_NOT_FOLLOWED", d.line, f"include {' '.join(d.args)} is not read by this checker",
                "Review the included files too (pass them as extra arguments) or paste `nginx -T` output.")

    tokens = [d for d in root.walk() if d.name == "server_tokens"]
    if not any(d.args and d.args[0] == "off" for d in tokens):
        add("LOW", "SERVER_TOKENS", (http or root).line, "server_tokens is not off, so error pages and the Server header show the nginx version",
            "Add `server_tokens off;` in the http block.")

    tls_names = set()
    for s in servers:
        if is_tls(s):
            tls_names |= names(s)

    for s in servers:
        scopes = base + [s]
        label = " ".join(sorted(names(s)))
        tls = is_tls(s)
        if tls:
            if inherited(scopes, "ssl_certificate") is None:
                add("HIGH", "NO_CERTIFICATE", s.line, f"TLS server {label} has no ssl_certificate in scope",
                    "Add ssl_certificate and ssl_certificate_key (fullchain + key).")
            proto = inherited(scopes, "ssl_protocols")
            if proto is not None:
                old = [p for p in proto.args if p in OLD_PROTOCOLS]
                if old:
                    add("HIGH", "OLD_TLS_PROTOCOL", proto.line, f"{label}: ssl_protocols enables {', '.join(old)}",
                        "Use `ssl_protocols TLSv1.2 TLSv1.3;` (SSLv3/TLS 1.0/1.1 are deprecated, RFC 8996).")
            ciph = inherited(scopes, "ssl_ciphers")
            if ciph is not None and ciph.args:
                parts = re.split(r"[:\s]+", " ".join(ciph.args))
                weak = sorted({w for p in parts if p and not p.startswith("!") and not p.startswith("-")
                               for w in WEAK_CIPHER_WORDS if w.lower() in p.lower()})
                if weak:
                    add("HIGH", "WEAK_CIPHERS", ciph.line, f"{label}: ssl_ciphers allows weak suites ({', '.join(weak)})",
                        "Use the Mozilla 'intermediate' cipher list, or remove ssl_ciphers to use the safe OpenSSL default.")
            hdrs = effective_headers(scopes)
            if "strict-transport-security" not in hdrs:
                add("MEDIUM", "NO_HSTS", s.line, f"TLS server {label} sends no Strict-Transport-Security header",
                    'Add `add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;` (start with a short max-age).')
            else:
                hsts = hdrs["strict-transport-security"]
                m = re.search(r"max-age=(\d+)", " ".join(hsts.args), re.I)
                if m and int(m.group(1)) < 15552000:
                    add("LOW", "SHORT_HSTS", hsts.line, f"{label}: HSTS max-age {m.group(1)} is under 180 days",
                        "Raise max-age to 31536000 once HTTPS works everywhere.")
            missing = [v for k, v in SECURITY_HEADERS.items() if k not in hdrs]
            if "x-frame-options" not in hdrs and "content-security-policy" in hdrs and \
                    "frame-ancestors" in " ".join(hdrs["content-security-policy"].args):
                missing = [v for v in missing if not v.startswith("X-Frame")]
            if missing:
                add("LOW", "MISSING_SECURITY_HEADERS", s.line, f"{label}: missing {'; '.join(missing)}",
                    "Add them with `always` in the server block (check the inheritance rule for locations).")
        elif listens_plain(s) and names(s) & tls_names:
            redirects = any(r.args and r.args[0] in ("301", "302", "307", "308") and len(r.args) > 1 and r.args[1].startswith("https://")
                            for r in [d for d in s.walk() if d.name == "return"])
            rewrites = any(len(r.args) > 1 and r.args[1].startswith("https://") for r in [d for d in s.walk() if d.name == "rewrite"])
            if not (redirects or rewrites):
                add("MEDIUM", "HTTP_NO_REDIRECT", s.line, f"plain-HTTP server {label} serves content instead of redirecting to HTTPS",
                    "Use `return 301 https://$host$request_uri;` in the port-80 server (keep only /.well-known/acme-challenge/ if needed).")

        server_hdrs = effective_headers(scopes)
        has_dot_rule = False
        locations = [d for d in s.walk() if d.name == "location"]
        for loc in locations:
            path = loc.args[-1] if loc.args else ""
            mod = loc.args[0] if len(loc.args) > 1 else ""
            if mod in ("~", "~*") and (r"/\." in path or path.startswith(r"\.") or r"(^|/)\." in path):
                if loc.get("deny") or any(r.args and r.args[0] in ("403", "404") for r in loc.get("return")):
                    has_dot_rule = True
            own = headers_of(loc)
            hi = inherited(scopes + [loc], "add_header_inherit")
            merge = bool(hi and hi.args and hi.args[0] == "merge")  # nginx 1.29.3+
            if own and server_hdrs and not merge:
                lost = sorted(set(server_hdrs) - set(own))
                if lost:
                    add("MEDIUM", "ADD_HEADER_INHERITANCE", loc.line,
                        f"location {path} has its own add_header, so it silently drops {', '.join(lost)} set at server/http level",
                        "Repeat those headers in this location, include a shared headers snippet, or on nginx 1.29.3+ set `add_header_inherit merge;`.")
            al = loc.first("alias")
            if al and al.args and mod in ("", "^~") and not path.endswith("/") and al.args[0].endswith("/"):
                add("HIGH", "ALIAS_TRAVERSAL", al.line,
                    f"location {path} (no trailing slash) with alias {al.args[0]} (trailing slash) allows {path}../ path traversal",
                    f"Make both end with a slash: `location {path}/ {{ alias {al.args[0]}; }}`.")
            ai = loc.first("autoindex")
            if ai and ai.args and ai.args[0] == "on":
                add("MEDIUM", "AUTOINDEX_ON", ai.line, f"autoindex on in location {path} lists directory contents",
                    "Turn it off, or restrict the location with allow/deny or auth_basic.")
            if ADMIN_RE.search(path):
                guarded = loc.get("deny") or loc.get("auth_basic") or loc.get("auth_request") or loc.get("satisfy") \
                    or any(r.args and r.args[0] in ("403", "404", "444") for r in loc.get("return"))
                if not guarded:
                    add("MEDIUM", "ADMIN_OPEN", loc.line, f"location {path} looks like an admin/status endpoint with no allow/deny or auth",
                        "Add `allow <office/VPN range>; deny all;` or auth_basic/auth_request, or remove the route.")
            if LOGIN_RE.search(path) and not inherited(scopes + [loc], "limit_req"):
                add("LOW", "NO_RATE_LIMIT", loc.line, f"location {path} looks like a login/auth endpoint without limit_req",
                    "Define `limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;` in http and `limit_req zone=login burst=5 nodelay;` here.")
            if loc.first("proxy_pass"):
                ph = set()
                for sc in reversed(scopes + [loc]):  # like add_header: innermost level that sets any wins
                    if sc.get("proxy_set_header"):
                        ph = {d.args[0].lower() for d in sc.get("proxy_set_header") if d.args}
                        break
                miss = [h for h in ("host", "x-forwarded-for", "x-forwarded-proto") if h not in ph]
                if miss:
                    add("LOW", "PROXY_HEADERS", loc.line, f"location {path} proxies without {', '.join(miss)}",
                        "Set proxy_set_header Host $host; X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; (note: any proxy_set_header in a location replaces inherited ones).")
        serves_files = inherited(scopes, "root") or any(
            (l.first("root") or l.first("alias")) and not (l.args and l.args[-1].startswith("/.well-known/")) for l in locations)
        if serves_files and not has_dot_rule:
            add("LOW", "DOTFILES_EXPOSED", s.line, f"{label}: no rule blocks hidden files such as /.git/ or /.env",
                r"Add `location ~ /\.(?!well-known) { deny all; }`.")

    for d in root.walk():
        if d.name == "client_max_body_size" and d.args and d.args[0] in ("0", "0m", "0k"):
            add("MEDIUM", "UNLIMITED_BODY", d.line, "client_max_body_size 0 disables the request body limit",
                "Set a real limit (e.g. 10m, or larger only in the upload location).")
        if d.name in ("return", "rewrite", "add_header") and any(re.search(r"\$uri\b|\$document_uri\b", a) for a in d.args):
            add("MEDIUM", "URI_IN_REDIRECT", d.line, f"{d.name} uses $uri (decoded, may contain CR/LF), which allows header injection",
                "Use $request_uri (raw, still encoded) in redirects and headers.")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["line"]))
    return {"file": fname, "servers": [{"line": s.line, "names": sorted(names(s)), "tls": is_tls(s)} for s in servers],
            "findings": findings}


def to_markdown(results):
    out = ["# Nginx config security review", ""]
    allf = [f for r in results for f in r["findings"]]
    counts = {k: sum(1 for f in allf if f["severity"] == k) for k in SEV_ORDER}
    out.append("Findings: " + ", ".join(f"{v} {k}" for k, v in counts.items()))
    for r in results:
        out += ["", f"## {r['file']}", "", "| Server | Line | TLS |", "|---|---|---|"]
        for s in r["servers"]:
            out.append(f"| {' '.join(s['names'])} | {s['line']} | {'yes' if s['tls'] else 'no'} |")
        out += ["", "| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in r["findings"]:
            out.append(f"| {f['severity']} | {f['code']} | {f['line']} | {f['message']} | {f['fix']} |")
        if not r["findings"]:
            out.append("| - | - | - | no findings | - |")
    out += ["", "Always run `nginx -t` before reloading, and test with `curl -sI https://<host>/` afterwards."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(conf):
        return [f["code"] for f in review(conf)["findings"]]

    good = """
    http {
      server_tokens off;
      limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
      server { listen 80; server_name a.test; return 301 https://$host$request_uri; }
      server {
        listen 443 ssl; http2 on; server_name a.test;
        ssl_certificate /c.pem; ssl_certificate_key /k.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options nosniff always;
        add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        root /srv/www;
        location ~ /\\.(?!well-known) { deny all; }
        location /static/ { alias /srv/static/; }
        location /login { limit_req zone=login burst=5 nodelay; proxy_pass http://app;
          proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto $scheme; }
      }
    }"""
    check("clean config has no findings", codes(good) == [])
    check("comments and quotes parsed", parse('a "b c;" d; # x;\n e f;').children[0].args == ["b c;", "d"])
    check("var token kept", parse("set $x hosta;").children[0].args == ["$x", "hosta"])
    for bad, msg in [("server { listen 80;", "unclosed block"), ("a b", "missing ;"), ("}", "stray brace"), ('a "b;', "unterminated quote")]:
        try:
            parse(bad); check("parse error: " + msg, False)
        except ParseError:
            check("parse error: " + msg, True)
    check("SERVER_TOKENS when missing", "SERVER_TOKENS" in codes("http { server { listen 80; } }"))
    tls = "server { listen 443 ssl; server_name b.test; ssl_certificate /c; %s }"
    check("OLD_TLS_PROTOCOL", "OLD_TLS_PROTOCOL" in codes(tls % "ssl_protocols TLSv1 TLSv1.2;"))
    check("TLSv1.2 alone fine", "OLD_TLS_PROTOCOL" not in codes(tls % "ssl_protocols TLSv1.2;"))
    check("WEAK_CIPHERS", "WEAK_CIPHERS" in codes(tls % "ssl_ciphers 'HIGH:RC4-SHA:DES-CBC3-SHA';"))
    check("excluded !RC4 is fine", "WEAK_CIPHERS" not in codes(tls % "ssl_ciphers 'HIGH:!aNULL:!MD5:!RC4';"))
    check("NO_CERTIFICATE", "NO_CERTIFICATE" in codes("server { listen 443 ssl; }"))
    check("cert inherited from http", "NO_CERTIFICATE" not in codes("http { ssl_certificate /c; server { listen 443 ssl; } }"))
    check("NO_HSTS", "NO_HSTS" in codes(tls % ""))
    check("SHORT_HSTS", "SHORT_HSTS" in codes(tls % 'add_header Strict-Transport-Security "max-age=300";'))
    check("MISSING_SECURITY_HEADERS", "MISSING_SECURITY_HEADERS" in codes(tls % ""))
    inh = tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'
    r = review(inh)["findings"]
    check("ADD_HEADER_INHERITANCE names lost header",
          any(f["code"] == "ADD_HEADER_INHERITANCE" and "strict-transport-security" in f["message"] for f in r))
    check("headers inherited when location has none", "ADD_HEADER_INHERITANCE" not in codes(
        tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { proxy_pass http://x; }'))
    check("add_header_inherit merge respected", "ADD_HEADER_INHERITANCE" not in codes(
        tls % 'add_header_inherit merge; add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'))
    check("ALIAS_TRAVERSAL", "ALIAS_TRAVERSAL" in codes("server { location /img { alias /srv/img/; } }"))
    check("alias with matching slashes fine", "ALIAS_TRAVERSAL" not in codes("server { location /img/ { alias /srv/img/; } }"))
    check("regex location alias not flagged", "ALIAS_TRAVERSAL" not in codes(r"server { location ~ ^/img/(.*)$ { alias /srv/img/$1; } }"))
    check("AUTOINDEX_ON", "AUTOINDEX_ON" in codes("server { location /files/ { autoindex on; } }"))
    check("HTTP_NO_REDIRECT", "HTTP_NO_REDIRECT" in codes(
        "server { listen 80; server_name c.test; root /x; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
    check("no HTTP_NO_REDIRECT for unrelated name", "HTTP_NO_REDIRECT" not in codes(
        "server { listen 80; server_name d.test; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
    check("ADMIN_OPEN", "ADMIN_OPEN" in codes("server { location /admin/ { proxy_pass http://x; } }"))
    check("admin with deny fine", "ADMIN_OPEN" not in codes("server { location /admin/ { allow 10.0.0.0/8; deny all; } }"))
    check("NO_RATE_LIMIT", "NO_RATE_LIMIT" in codes("server { location /api/login { proxy_pass http://x; } }"))
    check("rate limit inherited from server", "NO_RATE_LIMIT" not in codes(
        "server { limit_req zone=z; location /api/login { proxy_pass http://x; } }"))
    check("PROXY_HEADERS", "PROXY_HEADERS" in codes("server { location / { proxy_pass http://x; } }"))
    check("proxy_set_header in location replaces inherited", "PROXY_HEADERS" in codes(
        "server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; "
        "proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_set_header Upgrade $http_upgrade; proxy_pass http://x; } }"))
    check("DOTFILES_EXPOSED", "DOTFILES_EXPOSED" in codes("server { root /srv; }"))
    check("ACME-only port 80 server not flagged for dotfiles", "DOTFILES_EXPOSED" not in codes(
        "server { listen 80; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }"))
    check("UNLIMITED_BODY", "UNLIMITED_BODY" in codes("http { client_max_body_size 0; }"))
    check("URI_IN_REDIRECT", "URI_IN_REDIRECT" in codes("server { return 301 https://$host$uri; }"))
    check("$request_uri fine", "URI_IN_REDIRECT" not in codes("server { return 301 https://$host$request_uri; }"))
    check("INCLUDE_NOT_FOLLOWED", "INCLUDE_NOT_FOLLOWED" in codes("http { include /etc/nginx/conf.d/*.conf; }"))
    check("findings sorted HIGH first", review("server { listen 443 ssl; location /files/ { autoindex on; } }")["findings"][0]["severity"] == "HIGH")
    passed = sum(results)
    print(f"{passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Nginx config security reviewer")
    ap.add_argument("files", nargs="*", help="nginx config files (nginx.conf, sites-available/*, or `nginx -T` output)")
    ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.files:
        ap.error("give at least one config file, or --self-test")
    results = []
    for f in a.files:
        try:
            with open(f, encoding="utf-8") as fh:
                results.append(review(fh.read(), f))
        except (OSError, UnicodeDecodeError, ParseError) as e:
            print(f"error: {f}: {e}", file=sys.stderr)
            return 2
    print(json.dumps(results, indent=2) if a.json else to_markdown(results))
    return 1 if any(f["severity"] == "HIGH" for r in results for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonDevOps+2
F@f
0
Trình Lập Kế Hoạch Thời Gian và Nước Uống Cho Tuyến Đi Bộ Đường Dài
Kỹ năng

Lập kế hoạch cho chuyến đi bộ đường dài (day hike) trong ngày theo từng chặng. Ước tính thời gian bằng quy tắc Naismith và các hiệu chỉnh xuống dốc của Langmuir, có điều chỉnh theo địa hình, thể lực, trọng lượng ba lô và thời gian nghỉ giải lao, đưa ra giờ đồng hồ cụ thể cho từng chặng, kiểm tra thời điểm kết thúc so với giờ mặt trời lặn và giờ xuất phát an toàn muộn nhất, đồng thời tính toán lượng nước cần mang theo giữa các điểm tiếp nước và lượng đồ ăn nhẹ cần chuẩn bị. Cảnh báo các trường hợp kết thúc sau khi trời tối, các chặng dài không có điểm tiếp nước và địa hình khó vào cuối ngày. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: hiking-route-time-and-water-planner
description: Lập kế hoạch cho chuyến đi bộ đường dài (day hike) trong ngày theo từng chặng. Ước tính thời gian bằng quy tắc Naismith và các hiệu chỉnh xuống dốc của Langmuir, có điều chỉnh theo địa hình, thể lực, trọng lượng ba lô và thời gian nghỉ giải lao, đưa ra giờ đồng hồ cụ thể cho từng chặng, kiểm tra thời điểm kết thúc so với giờ mặt trời lặn và giờ xuất phát an toàn muộn nhất, đồng thời tính toán lượng nước cần mang theo giữa các điểm tiếp nước và lượng đồ ăn nhẹ cần chuẩn bị. Cảnh báo các trường hợp kết thúc sau khi trời tối, các chặng dài không có điểm tiếp nước và địa hình khó vào cuối ngày. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Lập Kế Hoạch Thời Gian và Nước Uống Cho Tuyến Đi Bộ Đường Dài

Bạn giúp những người đi bộ đường dài (hikers) biến một tuyến đường thành một lịch trình thực tế: khi nào họ sẽ đến từng điểm, liệu họ có kết thúc chuyến đi khi trời còn sáng hay không, và cần mang theo bao nhiêu nước uống cũng như thức ăn. Bạn chỉ rõ quy tắc đằng sau mỗi con số, ưu tiên các ước tính thận trọng, và luôn chừa khoảng trống dự phòng cho một ngày di chuyển chậm hơn dự kiến. Bạn không phải là sự thay thế cho kiến thức bản địa, bản đồ hay dự báo thời tiết, và bạn luôn nêu rõ điều đó khi cần thiết.

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

- `scripts/hike_plan.py` - đọc tệp CSV danh sách các chặng (legs) và in ra lịch trình, kiểm tra ánh sáng ban ngày, lượng nước cho mỗi đoạn, thức ăn và các phát hiện cảnh báo (chỉ dùng thư viện chuẩn Python 3)
- `references/time-rules.md` - quy tắc Naismith, các hiệu chỉnh của Langmuir, các hệ số địa hình/thể lực/trọng lượng ba lô, thời gian nghỉ giải lao và cách hiệu chuẩn chúng
- `references/water-food-and-safety.md` - tốc độ uống nước, lập kế hoạch tiếp nước và xử lý nước, đồ ăn nhẹ, biên độ an toàn trước khi trời tối, thời điểm quay đầu và danh sách kiểm tra an toàn
- `templates/hike-plan.md` - bản kế hoạch để chia sẻ với nhóm và gửi lại cho người liên hệ ở nhà
- `examples/eagle-ridge-loop.csv` - tuyến đường vòng dài 22 km vào cuối tháng 10 có một đoạn leo bám đá (scramble) và một điểm tiếp nước

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

### 1. Chia tuyến đường thành các chặng
Hỏi thông tin về tuyến đường (từ ứng dụng bản đồ hoặc sách hướng dẫn), ngày đi, và thông tin về nhóm: tốc độ di chuyển, thể lực, thành viên nhỏ tuổi nhất hoặc chậm nhất, trọng lượng ba lô. Chia nhỏ tuyến đường tại các đỉnh núi, đèo, trạm nghỉ (huts), ngã rẽ và nguồn nước; đối với mỗi chặng, hãy lấy khoảng cách, độ cao leo lên (ascent) và độ cao xuống dốc (descent) (từ biểu đồ độ cao của ứng dụng) cùng loại địa hình (road - đường nhựa/lâm nghiệp, path - đường mòn tốt, rough - gồ ghề, scramble - leo bám đá, snow - tuyết, bushwhack - băng rừng rậm). Đánh dấu những nơi có thể tiếp thêm nước. Lấy giờ mặt trời lặn của ngày và địa điểm đó, cùng nhiệt độ dự báo.

### 2. Chạy công cụ lập kế hoạch
```
python3 scripts/hike_plan.py examples/eagle-ridge-loop.csv --start 09:30 --sunset 17:20 --temp 24 --fitness 1.1 --pack-kg 9
python3 scripts/hike_plan.py my-route.csv --start 07:00 --sunset 20:45 --temp 31 --break-min 15 --json
python3 scripts/hike_plan.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH (kết thúc sau khi mặt trời lặn), 2 có nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Điều chỉnh kế hoạch
Đối với AFTER_DARK hoặc SMALL_MARGIN: xuất phát sớm hơn (kịch bản sẽ in ra giờ xuất phát hợp lý muộn nhất), rút ngắn tuyến đường, hoặc chọn một điểm rút lui (bail-out point) và thời điểm bắt buộc quay đầu (turnaround time). Đối với LONG_DRY_STRETCH: thêm điểm tiếp nước, mang thêm dụng cụ chứa nước, hoặc chuyển đoạn leo dốc dài sang buổi sáng mát mẻ. Đối với HARD_TERRAIN_LATE: đảo ngược chiều đi của tuyến đường vòng để đoạn leo bám đá (scramble) được thực hiện trước. Giải thích các công thức từ `references/time-rules.md` khi được hỏi, và gợi ý hiệu chuẩn lại hệ số thể lực sau khi hoàn thành chặng đầu tiên.

### 4. Bàn giao kế hoạch
Điền vào `templates/hike-plan.md`: lịch trình kèm giờ đồng hồ cụ thể, thời điểm bắt buộc quay đầu, danh sách nước uống và thức ăn, các phương án rút lui và thông tin khẩn cấp để gửi lại cho người ở nhà.

## Kết quả ví dụ
Đối với `examples/eagle-ridge-loop.csv` (22.2 km, +1170 m / -1150 m) xuất phát lúc 09:30 với hệ số thể lực 1.1 và nhiệt độ 24 C: 7 giờ 08 phút di chuyển, 8 giờ 19 phút tính cả nghỉ giải lao, kết thúc vào khoảng 17:49, tức là muộn hơn 29 phút so với giờ mặt trời lặn lúc 17:20 (cảnh báo mức HIGH AFTER_DARK; giờ xuất phát hợp lý muộn nhất là 08:01). Bốn chặng đầu tiên không có nguồn nước, vì vậy phải mang theo 4.4 L nước (cảnh báo mức MEDIUM LONG_DRY_STRETCH). Nếu xuất phát lúc 08:00 sẽ kết thúc lúc 16:19, còn dư 61 phút trời sáng; đoạn đường khô hạn vẫn giữ nguyên, do đó kế hoạch bổ sung thêm một túi nước (bladder) 2 L hoặc giấu sẵn một chai nước dự trữ tại Pine Saddle.

## Quy tắc

- Các con số ước tính chỉ là điểm khởi đầu: hãy dặn nhóm so sánh thời gian thực tế ở chặng đầu tiên với kế hoạch và tính toán lại tỷ lệ cho các chặng sau.
- Luôn giữ ít nhất 60 phút dự phòng trước khi trời tối, và mỗi ba lô đều phải có một đèn đội đầu (headlamp) vào mọi thời điểm trong năm.
- Tuyệt đối không gợi ý uống nước chưa qua xử lý; luôn khuyến nghị lọc hoặc xử lý bất kỳ nguồn nước tự nhiên nào.
- Thời tiết, tuyết, việc vượt sông suối và điều kiện thực tế của tuyến đường luôn có quyền ưu tiên cao hơn bất kỳ lịch trình nào: hãy nhắc người dùng kiểm tra dự báo thời tiết và báo cáo thực địa, đồng thời quay đầu ngay khi điều kiện thay đổi xấu đi.
- Không đưa ra lời khuyên y tế vượt quá hướng dẫn cơ bản về bù nước và dinh dưỡng; hãy chỉ dẫn họ tham gia đào tạo sơ cứu và lưu số điện thoại cứu hộ tại địa phương.
FILE:references/time-rules.md
# Quy Tắc Tính Thời Gian

## Quy tắc Naismith (1892)
Dành 1 giờ cho mỗi 5 km trên đường bằng cộng thêm 1 giờ cho mỗi 600 m leo cao (bản gốc là 3 dặm một giờ cộng 1 giờ cho mỗi 2000 ft).
```
moving minutes = distance_km / pace_kmh * 60 + ascent_m / 10
```
Ví dụ: 4.2 km với 380 m leo cao = 50.4 + 38 = 88 phút trước khi áp dụng các hệ số điều chỉnh.

## Các hiệu chỉnh của Langmuir khi xuống dốc
Quy tắc Naismith coi việc xuống dốc giống như đi trên đường bằng. Langmuir đã tinh chỉnh lại như sau:
- dốc xuống thoải, từ 5 đến 12 độ (khoảng 9 đến 21 %): trừ đi 10 phút cho mỗi 300 m xuống dốc
- dốc xuống gắt, trên 12 độ: cộng thêm 10 phút cho mỗi 300 m xuống dốc
- dưới 5 độ: không thay đổi
Kịch bản ước tính góc xuống dốc từ độ cao giảm xuống và tỷ lệ quãng đường đi xuống trong chặng đó. Kịch bản không bao giờ để thời gian đi một chặng giảm xuống thấp hơn một nửa thời gian đi trên đường bằng của chặng đó.

## Các hệ số nhân được kịch bản sử dụng
| Hệ số | Giá trị | Khi nào áp dụng |
|---|---|---|
| Terrain road | 0.9 | Đường trải nhựa hoặc đường lâm nghiệp |
| Terrain path | 1.0 | Đường mòn đi bộ tốt |
| Terrain rough | 1.25 | Đường mòn nhiều đá, rễ cây, đất đá rời rạc hoặc lầy lội |
| Terrain scramble | 1.6 | Cần dùng tay bám, các bậc đá chênh vênh |
| Terrain snow | 1.5 | Tuyết mềm hoặc tuyết cũ chưa cần dùng đinh giày (crampon) |
| Terrain bushwhack | 1.6 | Không có đường mòn, phải vạch cây cối rậm rạp |
| Fitness (Thể lực) | 0.85 / 1.0 / 1.2 / 1.4 | Nhanh và khỏe / trung bình / thong thả hoặc nhóm hỗn hợp / có trẻ nhỏ |
| Pack (Ba lô) | +2 % mỗi kg vượt quá 10 kg | Đi qua đêm hoặc mang vác nặng |
| Breaks (Nghỉ giải lao) | 10 phút mỗi giờ (mặc định) | Dùng 15 đến 20 phút cho các chuyến đi chụp ảnh, gia đình, nhóm đông người |

Hệ số địa hình được nhân cho toàn bộ chặng, bao gồm cả thời gian leo dốc.

## Hiệu chuẩn (Calibrating)
Sau chặng đầu tiên, hãy lấy thời gian thực tế chia cho thời gian dự kiến. Nếu nhóm mất 1 giờ 50 phút cho một chặng dự kiến 1 giờ 37 phút, hệ số chênh lệch là 1.13: hãy chạy lại kịch bản với tham số `--fitness` nhân với hệ số đó và kiểm tra lại giờ kết thúc. Các nhóm luôn di chuyển theo tốc độ của người chậm nhất; hãy lập kế hoạch dựa theo người đó.

## Các quy tắc khác bạn có thể bắt gặp
- Hiệu chỉnh Tranter (Tranter's corrections): bảng tra cứu thể lực và độ mệt mỏi áp dụng thêm trên quy tắc Naismith, được dùng cho những ngày leo núi dài.
- Hàm đi bộ đường dài của Tobler (Tobler's hiking function): tốc độ = 6 * exp(-3.5 * |slope + 0.05|) km/h, được sử dụng trong các công cụ GIS; hàm này cho ra tổng thời gian tương tự trên các tuyến đường hỗn hợp.

## Thời điểm bắt buộc quay đầu (Turnaround time)
Đối với tuyến đường đi và về cùng một lối (out-and-back) hoặc tuyến đường có lối thoát rút lui (bail-out), hãy đặt ra một thời điểm quay đầu cố định: thời gian muộn nhất bạn có thể rời khỏi điểm xa nhất mà vẫn quay về kịp lúc còn một giờ ánh sáng ban ngày. Ví dụ: mặt trời lặn lúc 17:20, giữ dự phòng 60 phút, lượt về mất 3 giờ 10 phút -> bắt buộc phải quay đầu trước 13:10 dù bạn đang ở bất kỳ đâu.
FILE:references/water-food-and-safety.md
# Nước Uống, Thức Ăn và An Toàn

## Tốc độ uống nước được kịch bản sử dụng
| Nhiệt độ | Số lít mỗi giờ (tính trên tổng thời gian) |
|---|---|
| dưới 15 C | 0.4 |
| 15 đến 24 C | 0.5 |
| 25 đến 29 C | 0.75 |
| từ 30 C trở lên | 1.0 |
Cộng thêm 0.1 L cho mỗi 100 m leo cao, vì việc leo dốc khiến bạn đổ mồ hôi nhiều hơn. Nhu cầu thực tế rất khác nhau giữa mỗi người; những người ra nhiều mồ hôi, trẻ em và người ở độ cao lớn có thể cần nhiều nước hơn. Hãy uống từng ngụm nhỏ thường xuyên thay vì uống thật nhiều cùng một lúc, và bổ sung chất điện giải (viên muối, bột pha nước uống hoặc đồ ăn nhẹ có vị mặn) vào những ngày nắng nóng hoặc khi uống nhiều hơn khoảng 3 L nước.

## Lập kế hoạch mang nước
- Điểm xuất phát và mọi chặng được đánh dấu `water_at_end = yes` là các điểm tiếp nước.
- Lượng nước cần mang (Carry) = lượng nước lớn nhất cần dùng giữa hai điểm tiếp nước cộng với 0.5 L dự phòng.
- Mang nhiều hơn 3 L (3 kg) là khá nặng; các phương án xử lý bao gồm giấu sẵn nước dự trữ trên đường, chọn tuyến đường đi qua trạm nghỉ hoặc suối nước, hoặc chuyển đoạn leo dốc nóng bức nhất sang sáng sớm.
- Hãy xác nhận chắc chắn rằng các con suối và trạm nghỉ thực sự có nước vào thời điểm đó trong năm (nhiều trạm nghỉ đóng cửa vào cuối mùa thu; các con suối thường cạn khô vào cuối mùa hè).

## Xử lý nước
Xử lý tất cả các nguồn nước tự nhiên: dùng bộ lọc sợi rỗng (loại bỏ vi khuẩn và động vật nguyên sinh), viên nén chlorine dioxide (diệt được cả vi-rút, cần chờ đủ thời gian tác dụng, thường là 30 phút, và lên đến 4 giờ đối với Cryptosporidium), bút chiếu tia UV cho nước trong, hoặc đun sôi trong 1 phút (3 phút nếu ở độ cao trên 2000 m). Hãy lọc nước đục qua một lớp vải trước khi xử lý.

## Thức ăn
Khoảng 200 đến 300 kcal mỗi giờ khi đang di chuyển sẽ giúp duy trì năng lượng ổn định trong một ngày dài: các loại hạt, thanh năng lượng, trái cây sấy khô, phô mai, bánh mì kẹp. Kịch bản sử dụng mức 250 kcal cho mỗi giờ trên tổng thời gian. Hãy đóng gói dư ra một phần đồ ăn nhẹ cho mỗi người làm nguồn dự phòng khẩn cấp.

## Ánh sáng ban ngày
- Tra cứu giờ mặt trời lặn cho đúng địa điểm và ngày cụ thể (qua ứng dụng thời tiết hoặc thiên văn). Ở vùng núi, ánh nắng mặt trời sẽ khuất khỏi các thung lũng và các sườn dốc hướng đông sớm hơn so với giờ mặt trời lặn chính thức.
- Chạng vạng dân dụng (civil twilight) cung cấp khoảng 20 đến 40 phút ánh sáng có thể nhìn đường sau khi mặt trời lặn ở các vùng vĩ độ trung bình, và ngắn hơn ở vùng nhiệt đới. Tuyệt đối không lập kế hoạch dựa vào khoảng thời gian này.
- Luôn giữ 60 phút ánh sáng ban ngày dự phòng (giá trị `--margin` mặc định). Mọi ba lô đều phải trang bị một đèn đội đầu có pin đã sạc đầy.

## Danh sách kiểm tra an toàn
- Đã kiểm tra dự báo thời tiết vào tối hôm trước và sáng ngày khởi hành (giông bão, sức gió trên sống núi, độ cao đóng băng).
- Đã gửi lại kế hoạch tuyến đường, giờ xuất phát, thời điểm quay đầu và giờ về dự kiến cho một người ở nhà, kèm theo thông tin cần gọi cho ai và vào lúc nào.
- Đã tải bản đồ ngoại tuyến (offline) trên điện thoại, kèm theo sạc dự phòng; mang theo bản đồ giấy và la bàn đối với các tuyến đường hẻo lánh.
- Trang phục mặc nhiều lớp (layers) và áo khoác chống thấm nước, mũ và găng tay khi đi phía trên đường giới hạn cây gỗ (tree line) ngay cả vào mùa hè, cùng đồ chống nắng.
- Bộ sơ cứu nhỏ gọn, băng dán chống phồng rộp, chăn cứu sinh khẩn cấp, còi báo hiệu.
- Nắm rõ số điện thoại khẩn cấp tại địa phương (112 ở Châu Âu, 911 ở Bắc Mỹ) và quy trình gọi cứu hộ đường núi.
- Quy tắc bất di bất dịch: cả nhóm luôn đi cùng nhau và phải quay đầu khi đến giờ bắt buộc quay đầu, ngay khi thấy tia chớp đầu tiên, hoặc khi có bất kỳ ai cảm thấy không khỏe.
FILE:templates/hike-plan.md
# Kế hoạch đi bộ đường dài: <tên tuyến đường>, <ngày>

**Nhóm:** <tên / số lượng thành viên>   **Trưởng nhóm:** <tên, số điện thoại>
**Tuyến đường:** <vòng lặp 22.2 km, +1170 m / -1150 m, liên kết bản đồ>
**Xuất phát:** <08:00 tại bãi đỗ xe Eagle Ridge>   **Dự kiến kết thúc:** <16:19>   **Mặt trời lặn:** <17:20>
**Thời điểm bắt buộc quay đầu:** <13:00 tại đỉnh núi, bất kể tình huống nào>
**Thời hạn báo động muộn nhất cho người liên hệ ở nhà:** <19:00>

## Lịch trình
| Chặng | km | Lên / xuống m | Địa hình | Giờ bắt đầu dự kiến | Giờ kết thúc dự kiến | Tiếp nước |
|---|---|---|---|---|---|---|
| <Bãi đỗ xe đến Pine Saddle> | <4.2> | <+380 / -20> | <path> | <08:00> | <09:53> | <không> |

Các thiết lập đã dùng: <tốc độ 5 km/h, thể lực 1.1, nghỉ 10 phút/giờ, 24 C>. Kiểm tra lại sau chặng đầu tiên và tính lại tỷ lệ.

## Nước uống và thức ăn
- Mang theo từ lúc xuất phát: <4.4 L mỗi người> (không có điểm tiếp nước cho đến <Spring Hut>)
- Tiếp nước tại: <vòi nước ở Spring Hut (mở cửa đến hết tháng 10?), xử lý nước suối>
- Đồ ăn nhẹ: <2100 kcal mỗi người> + một phần dự phòng
- Chất điện giải: <có / không>

## Các phương án rút lui (Bail-out options)
- <Sau Pine Saddle: đi theo đường lâm nghiệp quay lại bãi đỗ xe, 3.5 km, 1 giờ>
- <Bỏ qua đoạn băng qua sống núi: đi xuống theo đường mòn phía bắc từ đỉnh núi>

## Trang bị mang theo (Kit)
- <đèn đội đầu, quần áo nhiều lớp, áo chống thấm, bản đồ ngoại tuyến + sạc dự phòng, bộ sơ cứu, còi, bộ lọc nước hoặc viên khử trùng>

## Thời tiết và điều kiện thực địa
- Dự báo: <đã kiểm tra vào ... : trời khô ráo, 24 C, gió 20 km/h trên sống núi>
- Ghi chú: <đoạn leo bám đá bị ướt sau cơn mưa; tránh đi nếu trời ướt>

## Khẩn cấp
- Số điện thoại khẩn cấp: <112>   Cứu hộ đường núi: <...>
- Người liên hệ ở nhà: <tên, số điện thoại>: nếu không nhận được tin nhắn trước <19:00>, hãy gọi số <số điện thoại> kèm theo bản kế hoạch này.
FILE:examples/eagle-ridge-loop.csv
leg,distance_km,ascent_m,descent_m,terrain,water_at_end
Car park to Pine Saddle,4.2,380,20,path,no
Pine Saddle to Eagle Ridge summit,3.1,520,40,rough,no
Eagle Ridge traverse,1.6,120,150,scramble,no
Ridge down to Spring Hut,3.8,30,560,path,yes
Spring Hut to Blue Lake,4.5,80,210,path,no
Blue Lake back to car park,5.0,40,170,road,yes
FILE:scripts/hike_plan.py
#!/usr/bin/env python3
"""Hiking route time and water planner (Python 3 standard library only).

Reads a CSV of route legs and estimates moving time per leg with Naismith's rule
(5 km/h plus 1 hour per 600 m of ascent) and Langmuir's descent corrections,
adjusted for terrain, fitness and pack weight, then adds breaks, gives clock
times for every leg, checks the finish against sunset, and works out how much
water to carry between refill points and how much food to pack.

CSV columns (header required; ; or , separated):
  leg, distance_km, ascent_m, descent_m, terrain, water_at_end
terrain: road | path | rough | scramble | snow | bushwhack   water_at_end: yes/no

Usage:
  python3 hike_plan.py route.csv --start 08:30 --sunset 17:10 --temp 24
  python3 hike_plan.py route.csv --start 07:00 --fitness 1.2 --pack-kg 14 --json
  python3 hike_plan.py --self-test
Exit codes: 0 = plan fine, 1 = HIGH findings (finish after sunset), 2 = bad input.
"""
import argparse, csv, io, json, math, sys

TERRAIN = {"road": 0.9, "path": 1.0, "trail": 1.0, "rough": 1.25, "scramble": 1.6, "snow": 1.5, "bushwhack": 1.6}
REQUIRED = ["leg", "distance_km", "ascent_m", "descent_m", "terrain", "water_at_end"]
SEV = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class InputError(Exception):
    pass


def num(v, field, row):
    try:
        x = float(str(v).strip().replace(",", "."))
    except ValueError:
        raise InputError(f"row {row}: {field} '{v}' is not a number")
    if x < 0:
        raise InputError(f"row {row}: {field} cannot be negative")
    return x


def hm(s):
    try:
        h, m = s.strip().split(":")
        h, m = int(h), int(m)
        assert 0 <= h < 24 and 0 <= m < 60
        return h * 60 + m
    except Exception:
        raise InputError(f"time '{s}' must be HH:MM")


def clock(mins):
    mins = int(round(mins))
    day, rem = divmod(mins, 1440)
    return f"{rem // 60:02d}:{rem % 60:02d}" + (f" (+{day}d)" if day else "")


def dur(mins):
    mins = int(round(mins))
    return f"{mins // 60}h{mins % 60:02d}"


def read_legs(text):
    sample = text[:2000]
    delim = ";" if sample.count(";") > sample.count(",") else ","
    rd = csv.DictReader(io.StringIO(text), delimiter=delim)
    if not rd.fieldnames:
        raise InputError("empty CSV")
    fields = [f.strip().lower() for f in rd.fieldnames]
    missing = [c for c in REQUIRED if c not in fields]
    if missing:
        raise InputError("missing columns: " + ", ".join(missing))
    legs = []
    for i, raw in enumerate(rd, start=2):
        r = {k.strip().lower(): (v or "").strip() for k, v in raw.items() if k}
        if not any(r.values()):
            continue
        t = r["terrain"].lower() or "path"
        if t not in TERRAIN:
            raise InputError(f"row {i}: terrain '{t}' must be one of {', '.join(TERRAIN)}")
        w = r["water_at_end"].lower()
        if w not in ("yes", "no", "y", "n", "1", "0", "true", "false", ""):
            raise InputError(f"row {i}: water_at_end must be yes or no")
        legs.append({"leg": r["leg"] or f"Leg {len(legs) + 1}",
                     "distance_km": num(r["distance_km"], "distance_km", i),
                     "ascent_m": num(r["ascent_m"], "ascent_m", i),
                     "descent_m": num(r["descent_m"], "descent_m", i),
                     "terrain": t, "water_at_end": w in ("yes", "y", "1", "true")})
    if not legs:
        raise InputError("no legs in CSV")
    return legs


def water_rate(temp_c):
    """Litres per hour of total time, before the ascent extra."""
    if temp_c < 15:
        return 0.4
    if temp_c < 25:
        return 0.5
    if temp_c < 30:
        return 0.75
    return 1.0


def leg_minutes(leg, pace_kmh):
    d, up, down = leg["distance_km"], leg["ascent_m"], leg["descent_m"]
    flat = d / pace_kmh * 60
    climb = up / 10.0  # Naismith: 60 min per 600 m
    descent_adj = 0.0
    steep = False
    if down > 0 and d > 0:
        horiz = d * 1000 * (down / (up + down))  # share of the leg spent descending
        angle = math.degrees(math.atan(down / horiz)) if horiz > 0 else 90
        if 5 <= angle <= 12:
            descent_adj = -down / 300 * 10   # Langmuir: gentle descent is faster
        elif angle > 12:
            descent_adj = down / 300 * 10    # steep descent is slower
        steep = angle > 14
    if up > 0 and d > 0:
        horiz_up = d * 1000 * (up / (up + down))
        steep = steep or math.degrees(math.atan(up / horiz_up)) > 14  # about 25 %
    return max(flat + climb + descent_adj, flat * 0.5), steep


def plan(legs, start, sunset=None, pace=5.0, fitness=1.0, pack_kg=8.0, break_min=10.0, temp=18.0, margin=60):
    if pace <= 0 or fitness <= 0:
        raise InputError("pace and fitness must be positive")
    pack_factor = 1.0 + max(0.0, pack_kg - 10) * 0.02  # +2 % per kg above 10 kg
    rate = water_rate(temp)
    t = start
    rows, findings = [], []
    stretch, stretches, stretch_legs = 0.0, [], []
    for leg in legs:
        base, steep = leg_minutes(leg, pace)
        moving = base * TERRAIN[leg["terrain"]] * fitness * pack_factor
        total = moving * (1 + break_min / 60)
        water = total / 60 * rate + leg["ascent_m"] / 100 * 0.1
        t_start, t = t, t + total
        rows.append({**leg, "moving_min": round(moving), "with_breaks_min": round(total),
                     "start": clock(t_start), "end": clock(t), "end_min": t, "water_l": round(water, 2), "steep": steep})
        stretch += water
        stretch_legs.append(leg["leg"])
        if leg["water_at_end"]:
            stretches.append((stretch, stretch_legs)); stretch, stretch_legs = 0.0, []
        if steep:
            findings.append(("LOW", "STEEP_LEG", f"{leg['leg']}: average gradient over 25 %; expect slower going than the formula and take care on the descent"))
    if stretch_legs:
        stretches.append((stretch, stretch_legs))
    moving_total = sum(r["moving_min"] for r in rows)
    total = t - start
    carry = max(s for s, _ in stretches) + 0.5
    for s, names in stretches:
        if s + 0.5 > 3.0:
            findings.append(("MEDIUM", "LONG_DRY_STRETCH", f"{' -> '.join(names)}: {s + 0.5:.1f} L to carry with no refill; add a refill, cache water or start earlier in the cool"))
    summary = {
        "distance_km": round(sum(l["distance_km"] for l in legs), 1),
        "ascent_m": round(sum(l["ascent_m"] for l in legs)),
        "descent_m": round(sum(l["descent_m"] for l in legs)),
        "moving_min": round(moving_total), "total_min": round(total),
        "start": clock(start), "finish": clock(t),
        "water_total_l": round(sum(r["water_l"] for r in rows), 1),
        "carry_max_l": round(carry, 1),
        "food_kcal": int(round(total / 60 * 250, -1)),
        "water_rate_l_per_h": rate, "pack_factor": round(pack_factor, 2),
    }
    if sunset is not None:
        left = sunset - t
        summary["sunset"] = clock(sunset)
        summary["minutes_before_sunset"] = round(left)
        summary["latest_start"] = clock(sunset - margin - total)
        if left < 0:
            findings.append(("HIGH", "AFTER_DARK", f"finish {clock(t)} is {dur(-left)} after sunset {clock(sunset)}; start by {summary['latest_start']}, shorten the route or plan a bail-out"))
        elif left < margin:
            findings.append(("MEDIUM", "SMALL_MARGIN", f"finish {clock(t)} leaves only {round(left)} min before sunset (target {margin}); start by {summary['latest_start']} and carry a headlamp"))
        for r in rows:
            if r["terrain"] in ("scramble", "snow") and r["end_min"] > sunset - 120:
                findings.append(("MEDIUM", "HARD_TERRAIN_LATE", f"{r['leg']} ({r['terrain']}) ends at {r['end']}, within 2 h of sunset; tired legs and fading light on hard ground"))
    if moving_total > 8 * 60:
        findings.append(("LOW", "LONG_DAY", f"{dur(moving_total)} moving time; check that everyone has done a day this long recently"))
    if temp >= 28 and any(l["ascent_m"] >= 300 for l in legs):
        findings.append(("MEDIUM", "HEAT", f"{temp:g} C with big climbs: start at first light, rest in shade, electrolytes with the water"))
    findings.sort(key=lambda f: SEV[f[0]])
    for r in rows:
        r.pop("end_min")
    return {"legs": rows, "summary": summary, "stretches": [{"legs": n, "litres": round(s + 0.5, 1)} for s, n in stretches],
            "findings": [{"severity": a, "code": b, "message": c} for a, b, c in findings]}


def to_markdown(p, args):
    s = p["summary"]
    out = ["# Hike plan", "",
           f"Settings: pace {args.pace:g} km/h, fitness x{args.fitness:g}, pack {args.pack_kg:g} kg (x{s['pack_factor']}), breaks {args.break_min:g} min per hour, {args.temp:g} C",
           "",
           f"Route: {s['distance_km']} km, +{s['ascent_m']} m / -{s['descent_m']} m; moving {dur(s['moving_min'])}, with breaks {dur(s['total_min'])}",
           f"Start {s['start']}, finish about {s['finish']}" + (f"; sunset {s['sunset']}, " + (f"{s['minutes_before_sunset']} min to spare" if s['minutes_before_sunset'] >= 0 else f"{dur(-s['minutes_before_sunset'])} AFTER sunset") + f"; latest sensible start {s['latest_start']}" if "sunset" in s else ""),
           "", "| Leg | km | Up m | Down m | Terrain | Moving | With breaks | Start | End | Water L | Refill at end |",
           "|---|---|---|---|---|---|---|---|---|---|---|"]
    for r in p["legs"]:
        out.append(f"| {r['leg']} | {r['distance_km']:g} | {r['ascent_m']:g} | {r['descent_m']:g} | {r['terrain']} | {dur(r['moving_min'])} | "
                   f"{dur(r['with_breaks_min'])} | {r['start']} | {r['end']} | {r['water_l']:.1f} | {'yes' if r['water_at_end'] else 'no'} |")
    out += ["", "## Water and food",
            f"- Drinking rate {s['water_rate_l_per_h']} L/h at this temperature plus 0.1 L per 100 m of climbing; total about {s['water_total_l']} L",
            f"- Carry at most {s['carry_max_l']} L (largest stretch between refills, incl. 0.5 L reserve); treat stream water"]
    for st in p["stretches"]:
        out.append(f"  - {' -> '.join(st['legs'])}: {st['litres']} L")
    out.append(f"- Food: about {s['food_kcal']} kcal of snacks (about 250 kcal per hour on the move), plus a spare")
    out += ["", "## Findings"]
    out += [f"- {f['severity']} {f['code']}: {f['message']}" for f in p["findings"]] or ["- none"]
    out += ["", "Estimates only: check them against your group's real pace on the first leg and adjust."]
    return "\n".join(out)


def self_test():
    res = []

    def check(name, cond):
        res.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def L(d, up, down, terrain="path", water="no", name="x"):
        return {"leg": name, "distance_km": d, "ascent_m": up, "descent_m": down, "terrain": terrain, "water_at_end": water == "yes"}

    check("flat 10 km at 5 km/h = 120 min", round(leg_minutes(L(10, 0, 0), 5)[0]) == 120)
    check("Naismith 600 m climb adds 60 min", round(leg_minutes(L(5, 600, 0), 5)[0]) == 120)
    g = leg_minutes(L(6, 0, 900), 5)[0]  # angle ~8.5 deg: gentle
    check("gentle descent is faster (Langmuir)", round(g) == 72 - 30)
    st = leg_minutes(L(2, 0, 600), 5)  # ~16.7 deg: steep
    check("steep descent is slower and flagged", round(st[0]) == 24 + 20 and st[1])
    check("steep ascent flagged", leg_minutes(L(1, 400, 0), 5)[1])
    check("descent never below half the flat time", leg_minutes(L(10, 0, 1500), 5)[0] >= 60)
    p = plan([L(10, 0, 0)], hm("08:00"), break_min=0)
    check("finish time no breaks", p["summary"]["finish"] == "10:00")
    p = plan([L(10, 0, 0)], hm("08:00"), break_min=10)
    check("10 min break per hour", p["summary"]["total_min"] == 140)
    check("terrain multiplier", plan([L(10, 0, 0, "scramble")], 0, break_min=0)["summary"]["moving_min"] == 192)
    check("fitness multiplier", plan([L(10, 0, 0)], 0, fitness=1.2, break_min=0)["summary"]["moving_min"] == 144)
    check("pack over 10 kg slows", plan([L(10, 0, 0)], 0, pack_kg=15, break_min=0)["summary"]["moving_min"] == 132)
    p = plan([L(10, 0, 0)], hm("15:00"), sunset=hm("16:30"), break_min=0)
    check("AFTER_DARK HIGH", p["findings"][0]["code"] == "AFTER_DARK" and p["findings"][0]["severity"] == "HIGH")
    check("latest start", p["summary"]["latest_start"] == "13:30")
    p = plan([L(10, 0, 0)], hm("14:00"), sunset=hm("16:30"), break_min=0)
    check("SMALL_MARGIN", [f["code"] for f in p["findings"]] == ["SMALL_MARGIN"])
    check("no finding with time to spare", plan([L(10, 0, 0)], hm("08:00"), sunset=hm("18:00"))["findings"] == [])
    check("HARD_TERRAIN_LATE", any(f["code"] == "HARD_TERRAIN_LATE" for f in
          plan([L(3, 0, 0, "scramble")], hm("13:00"), sunset=hm("15:00"))["findings"]))
    check("water rate by temperature", [water_rate(t) for t in (10, 20, 27, 33)] == [0.4, 0.5, 0.75, 1.0])
    p = plan([L(10, 0, 0, water="yes"), L(10, 0, 0)], 0, temp=20, break_min=0)
    check("water per leg", p["legs"][0]["water_l"] == 1.0)
    check("carry = largest stretch + reserve", p["summary"]["carry_max_l"] == 1.5)
    p = plan([L(10, 500, 0), L(10, 0, 500)], 0, temp=31, break_min=0)
    check("LONG_DRY_STRETCH and HEAT", {"LONG_DRY_STRETCH", "HEAT"} <= {f["code"] for f in p["findings"]})
    check("LONG_DAY", any(f["code"] == "LONG_DAY" for f in plan([L(45, 0, 0)], 0)["findings"]))
    check("food kcal", plan([L(10, 0, 0)], 0, break_min=0)["summary"]["food_kcal"] == 500)
    check("clock wraps past midnight", clock(1500) == "01:00 (+1d)")
    legs = read_legs("leg;distance_km;ascent_m;descent_m;terrain;water_at_end\nA;3,5;200;0;Path;yes\n")
    check("semicolon CSV + decimal comma", legs[0]["distance_km"] == 3.5 and legs[0]["water_at_end"])
    for bad, why in [("leg,distance_km\nA,1\n", "missing columns"),
                     ("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,x,0,0,path,no\n", "non-number"),
                     ("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,1,-5,0,path,no\n", "negative"),
                     ("leg,distance_km,ascent_m,descent_m,terrain,water_at_end\nA,1,0,0,lava,no\n", "unknown terrain")]:
        try:
            read_legs(bad); check("rejects " + why, False)
        except InputError:
            check("rejects " + why, True)
    try:
        hm("25:00"); check("rejects bad time", False)
    except InputError:
        check("rejects bad time", True)
    passed = sum(res)
    print(f"{passed}/{len(res)} passed")
    return 0 if passed == len(res) else 1


def main():
    ap = argparse.ArgumentParser(description="Hiking route time and water planner")
    ap.add_argument("csv", nargs="?", help="legs CSV")
    ap.add_argument("--start", help="start time HH:MM")
    ap.add_argument("--sunset", help="sunset time HH:MM")
    ap.add_argument("--pace", type=float, default=5.0, help="flat walking speed km/h (default 5)")
    ap.add_argument("--fitness", type=float, default=1.0, help="time multiplier: 0.85 fast, 1.0 average, 1.2 relaxed, 1.4 with young kids")
    ap.add_argument("--pack-kg", type=float, default=8.0, help="pack weight; +2%% time per kg above 10")
    ap.add_argument("--break-min", type=float, default=10.0, help="break minutes per hour of walking (default 10)")
    ap.add_argument("--temp", type=float, default=18.0, help="expected daytime temperature C")
    ap.add_argument("--margin", type=int, default=60, help="minutes of daylight to keep in hand (default 60)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.csv or not a.start:
        ap.error("give a CSV and --start, or --self-test")
    try:
        with open(a.csv, encoding="utf-8-sig") as fh:
            legs = read_legs(fh.read())
        p = plan(legs, hm(a.start), hm(a.sunset) if a.sunset else None, a.pace, a.fitness, a.pack_kg, a.break_min, a.temp, a.margin)
    except (OSError, InputError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(p, indent=2) if a.json else to_markdown(p, a))
    return 1 if any(f["severity"] == "HIGH" for f in p["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
TravelPythonPlanning+2
F@f
0
Trình Lập Thời Khóa Biểu Ôn Thi
Kỹ năng

Xây dựng thời khóa biểu ôn thi chi tiết từng ngày dựa trên ngày thi, trọng số môn học và mức độ tự tin của bạn. Chia nhỏ số giờ học thành các phiên tập trung, dành nhiều thời gian hơn cho các môn yếu và nặng, đan xen các môn học, chuyển sang làm câu hỏi luyện tập trong tuần cuối trước mỗi kỳ thi, xếp một bài giải đề các năm trước bấm giờ vào ngày hôm trước, đồng thời tôn trọng các ngày nghỉ ngơi và ngày bận. Cảnh báo khi có quá ít phiên học cho mỗi chủ đề và các kỳ thi diễn ra liên tiếp. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: exam-revision-timetable-planner
description: Xây dựng thời khóa biểu ôn thi chi tiết từng ngày dựa trên ngày thi, trọng số môn học và mức độ tự tin của bạn. Chia nhỏ số giờ học thành các phiên tập trung, dành nhiều thời gian hơn cho các môn yếu và nặng, đan xen các môn học, chuyển sang làm câu hỏi luyện tập trong tuần cuối trước mỗi kỳ thi, xếp một bài giải đề các năm trước bấm giờ vào ngày hôm trước, đồng thời tôn trọng các ngày nghỉ ngơi và ngày bận. Cảnh báo khi có quá ít phiên học cho mỗi chủ đề và các kỳ thi diễn ra liên tiếp. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Lập Thời Khóa Biểu Ôn Thi

Bạn giúp học sinh, sinh viên biến một mùa thi căng thẳng thành một kế hoạch thực tế: cần học môn nào trong mỗi phiên, mỗi ngày, cho đến kỳ thi cuối cùng. Bạn sử dụng các phương pháp dựa trên bằng chứng khoa học (lặp lại ngắt quãng - spacing, luyện tập truy hồi - retrieval practice, học đan xen - interleaving, giải đề các năm trước - past papers), giữ cho kế hoạch nhân văn với thời gian nghỉ ngơi và giấc ngủ hợp lý, đồng thời làm rõ các sự đánh đổi khi không có đủ thời gian cho mọi thứ.

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

- `scripts/revision_plan.py` - đọc tệp CSV danh sách môn học và in ra bảng môn học, thời khóa biểu từng ngày cùng các phát hiện cảnh báo (chỉ dùng thư viện chuẩn Python 3)
- `references/study-techniques.md` - những việc cần làm trong một phiên học: luyện tập truy hồi, lặp lại ngắt quãng, học đan xen, giải đề các năm trước và những điều cần tránh
- `references/scheduling-rules.md` - cách kịch bản (script) gán mức độ ưu tiên, chỉ tiêu, loại phiên học và các phát hiện cảnh báo, cũng như cách tinh chỉnh nó
- `templates/revision-plan.md` - bản kế hoạch bàn giao lại: tổng quan, thời khóa biểu hàng tuần, quy trình trong phiên học, các mốc rà soát
- `examples/winter-exams.csv` - năm kỳ thi đại học từ ngày 2 đến 11 tháng 12 kèm theo số lượng chủ đề

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

### 1. Thu thập thông tin thực tế
Hỏi thông tin cho từng kỳ thi: môn học, ngày thi, tỷ trọng điểm (số tín chỉ, phần trăm điểm tổng kết, hoặc mức độ quan trọng từ 1-5), mức độ tự tin từ 1 (mất gốc) đến 5 (nắm vững), và số lượng chủ đề hoặc chương. Sau đó hỏi về ngày bắt đầu ôn tập đầu tiên, số giờ thực tế có thể học vào các ngày trong tuần và cuối tuần (sau giờ lên lớp, làm việc, đi lại), thời lượng phiên học mong muốn (mặc định là 50/10, hoặc Pomodoro 25/5 nếu độ tập trung thấp), một ngày nghỉ ngơi hàng tuần và bất kỳ ngày nào đã có lịch bận từ trước.

### 2. Chạy công cụ lập kế hoạch
```
python3 scripts/revision_plan.py examples/winter-exams.csv --start 2026-11-16 --weekday-hours 3 --weekend-hours 5 --rest-day Sun --off 2026-11-21 --day-start 16:00
python3 scripts/revision_plan.py my-exams.csv --start 2026-05-04 --session-min 25 --break-min 5 --json
python3 scripts/revision_plan.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có phát hiện mức HIGH (số phiên học ít hơn số chủ đề), 2 có nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Xử lý các sự đánh đổi
Đối với UNDER_COVERED hoặc TIGHT: tăng thêm giờ học, bắt đầu sớm hơn, hạ chỉ tiêu cho các môn đã vững, hoặc thống nhất xem những chủ đề ít điểm nào có thể đọc lướt (dựa vào trọng số trong đề cương hoặc tần suất xuất hiện trong đề thi các năm trước). Đối với BACK_TO_BACK: đẩy phần luyện tập của môn thứ hai lên trước ngày thi của môn thứ nhất. Kiểm tra lại kế hoạch cùng với người học: liệu kế hoạch này có khả thi vào một ngày tồi tệ không? Hãy chừa khoảng trống dự phòng thay vì nhồi nhét kín mọi buổi tối.

### 4. Bàn giao kế hoạch
Điền vào `templates/revision-plan.md`: bảng tổng quan, thời khóa biểu theo tuần, quy trình phiên học từ `references/study-techniques.md`, và hai mốc ngày rà soát để đánh giá lại mức độ tự tin và chạy lại kịch bản.

## Kết quả ví dụ
Đối với `examples/winter-exams.csv` bắt đầu từ ngày 16 tháng 11 với 3 giờ vào các ngày trong tuần, 5 giờ vào thứ Bảy, nghỉ Chủ nhật và ngày 21 tháng 11: tổng cộng 25 ngày, 57 phiên học kéo dài 50 phút (47.5 giờ). Môn Hóa Hữu cơ (trọng số 5, độ tự tin 2) và Kinh tế Vi mô (4, 2) nhận được 15 và 16 phiên, Lịch sử Châu Âu (3, 4) nhận được 6 phiên. Mỗi môn học đều kết thúc bằng việc làm câu hỏi luyện tập và giải một đề thi các năm trước có bấm giờ vào ngày trước kỳ thi của môn đó. Các phát hiện cảnh báo: Xác suất Thống kê (10 phiên cho 8 chủ đề) và Lịch sử Châu Âu (6 phiên cho 6 chủ đề) ở mức TIGHT (sát nút), còn Lịch sử Châu Âu và Kinh tế Vi mô ở trạng thái BACK_TO_BACK (thi liên tiếp) vào ngày 7 và 8 tháng 12.

## Quy tắc

- Lập kế hoạch dựa trên số giờ học sinh thực sự có, chứ không phải số giờ họ ước mình có; bảo vệ giấc ngủ (không xếp các phiên học khuya trước ngày thi).
- Mọi phiên học đều phải kết thúc bằng hoạt động truy hồi kiến thức (trả lời câu hỏi, nhớ lại trên giấy trắng, thẻ ghi nhớ flashcards), chứ không chỉ đọc lại hay tô sáng (highlight).
- Giữ ít nhất một ngày nghỉ trọn vẹn hoặc hai nửa ngày nghỉ mỗi tuần; tình trạng kiệt sức (burnout) gây thiệt hại lớn hơn nhiều so với việc mất đi một ngày học.
- Lập lại kế hoạch sau mỗi đợt rà soát hoặc sau mỗi kỳ thi thay vì cố bám theo một kế hoạch đã vỡ.
- Không làm hộ bài tập có tính điểm cho học sinh; chỉ hỗ trợ lập kế hoạch và phương pháp học tập. Nếu học sinh có dấu hiệu căng thẳng nghiêm trọng hoặc hoảng loạn, hãy khuyến khích họ trò chuyện với bộ phận hỗ trợ sinh viên hoặc bác sĩ.
FILE:references/study-techniques.md
# Các Phương Pháp Học Tập

Những việc cần làm trong mỗi phiên học. Được sắp xếp theo độ mạnh của bằng chứng khoa học (Dunlosky và cộng sự, 2013, "Improving Students' Learning With Effective Learning Techniques", đã đánh giá việc kiểm tra thực hành và thực hành phân tán có mức độ hữu ích cao).

## Luyện tập truy hồi - Retrieval practice (cao)
Khơi gợi lại thông tin từ trí nhớ thay vì đọc lại tài liệu: trả lời các câu hỏi, viết ra mọi thứ bạn nhớ được lên một tờ giấy trắng, sau đó đối chiếu với ghi chú và điền vào những chỗ còn thiếu bằng mực màu khác. Thẻ ghi nhớ (flashcards) sẽ phát huy tác dụng nếu bạn tự trả lời trước khi lật thẻ. Việc trả lời sai trước rồi mới sửa lại cũng giúp ích rất nhiều cho quá trình ghi nhớ.

## Lặp lại ngắt quãng - Spacing (cao)
Trải đều các phiên học của một môn qua nhiều ngày thay vì dồn vào một khối thời gian dài duy nhất. Một nhịp độ ôn tập đơn giản cho mỗi chủ đề: vào ngày hôm sau, sau 3 ngày, sau một tuần, và trước kỳ thi. Thời khóa biểu sẽ đan xen các môn học qua các ngày; hãy bắt đầu mỗi phiên học bằng 5 phút tự nhớ lại nội dung của phiên học trước đó thuộc cùng môn học để tích hợp sẵn hiệu ứng lặp lại ngắt quãng.

## Học đan xen - Interleaving (trung bình)
Trộn lẫn các dạng bài tập trong cùng một môn học (đối với toán và các môn khoa học: kết hợp nhiều dạng bài toán khác nhau trong một bộ đề, thay vì làm 20 bài cùng một dạng) để bạn rèn luyện kỹ năng lựa chọn phương pháp giải, điều mà kỳ thi thực tế đòi hỏi.

## Giải đề các năm trước - Past papers (cao đối với kỳ thi)
Làm ít nhất một đề thi hoàn chỉnh trong điều kiện bấm giờ trước mỗi kỳ thi, sau đó tự chấm điểm theo biểu điểm chính thức và liệt kê số điểm bị mất theo từng chủ đề. Kịch bản sẽ xếp một buổi giải đề vào ngày trước mỗi kỳ thi; hãy bổ sung thêm các buổi giải đề sớm hơn nếu có sẵn nguồn đề.

## Đào sâu và tự giải thích - Elaboration and self-explanation (trung bình)
Đặt câu hỏi "tại sao cách này lại hiệu quả?" và "điều này liên hệ với X như thế nào?", giải thích thành tiếng một khái niệm như thể đang giảng bài cho người khác, vẽ sơ đồ khái niệm (concept map) từ trí nhớ.

## Hiệu quả thấp nếu dùng đơn lẻ
Việc đọc lại, tô sáng (highlighting) và chép lại ghi chú mang lại cảm giác đang làm việc năng suất nhưng thực chất đem lại rất ít lợi ích dài hạn. Chỉ sử dụng chúng như bước đọc lướt đầu tiên trước khi thực hiện truy hồi kiến thức.

## Quy trình một phiên học (mặc định 50/10)
1. 5 phút: tự nhớ lại từ trí nhớ những gì bạn đã làm trong lần học trước của môn này.
2. 30 phút: học hoặc luyện tập một chủ đề (Learn + recall) hoặc làm câu hỏi bài tập (Practice questions).
3. 10 phút: gấp sách lại và tự kiểm tra bản thân; đánh dấu những phần bạn còn bỏ sót.
4. 5 phút: viết ra ba câu hỏi bạn chưa trả lời được để dành cho lần học tiếp theo.
5. 10 phút nghỉ giải lao rời xa màn hình: vận động, uống nước.
Đối với những người có khả năng tập trung ngắn, hãy sử dụng các phiên 25/5 (`--session-min 25 --break-min 5`) và nghỉ giải lao dài hơn sau mỗi bốn phiên.

## Giấc ngủ, dinh dưỡng và ngày trước kỳ thi
Giấc ngủ giúp củng cố trí nhớ; việc thức trắng đêm học bài sẽ làm giảm kết quả thi. Hãy dành buổi tối trước ngày thi để ôn tập nhẹ nhàng và đi ngủ. Ăn uống đầy đủ đúng bữa, vận động mỗi ngày, và để điện thoại ở phòng khác trong suốt các phiên học.
FILE:references/scheduling-rules.md
# Quy Tắc Xếp Lịch

Cách `scripts/revision_plan.py` xây dựng thời khóa biểu.

## Số phiên học mỗi ngày
Số phiên học vừa vặn trong tổng số giờ học hàng ngày: `floor((hours * 60 + break) / (session + break))`. Với các phiên 50/10: 3 giờ -> 3 phiên, 5 giờ -> 5 phiên. Các ngày nghỉ (`--rest-day`) và các ngày bận (`--off`) không xếp phiên học nào. Vào ngày có lịch thi, chỉ một nửa số phiên được lên kế hoạch (ca thi chiếm phần thời gian còn lại), và chỉ dành cho các môn thi sau đó.

## Mức độ ưu tiên và chỉ tiêu
- priority = weight x (6 - confidence). Trọng số 5, độ tự tin 2 -> 20; trọng số 3, độ tự tin 4 -> 6.
- target sessions (số phiên chỉ tiêu) = tất cả các phiên hiện có trước kỳ thi đó x priority / tổng tất cả các priority (tối thiểu là 1).
Trọng số (weight) có thể là số tín chỉ hoặc điểm đánh giá độ quan trọng từ 1-5; chỉ có tỷ lệ tương quan mới quan trọng, vì vậy hãy sử dụng cùng một thang đo cho mọi môn học.

## Cách chọn môn cho từng phiên học
Đối với mỗi phiên học, kịch bản sẽ chọn trong số các môn có ngày thi vẫn còn ở phía trước và có số phiên học trong ngày hôm đó ít hơn `--max-same` (mặc định là 2):
1. ưu tiên các môn vẫn còn thấp hơn chỉ tiêu (target) trước;
2. sau đó chọn một môn khác với phiên ngay trước đó (học đan xen - interleaving);
3. tiếp theo là môn có nhu cầu còn lại tính trên mỗi ngày cho đến ngày thi ở mức cao nhất;
4. nếu bằng nhau thì ưu tiên môn có độ ưu tiên (priority) cao hơn, rồi đến môn thi sớm hơn.
Ngày trước kỳ thi luôn bắt đầu bằng một phiên giải đề các năm trước có bấm giờ cho môn thi đó.

## Các loại phiên học
- Learn + recall (Học + truy hồi): cho đến khi hoàn thành 60 % chỉ tiêu của môn học và cách ngày thi của môn đó hơn 7 ngày.
- Mixed review (Ôn tập tổng hợp): sau khi đạt 60 % chỉ tiêu, cách ngày thi hơn 7 ngày.
- Practice questions (Câu hỏi luyện tập): trong 7 ngày cuối cùng trước kỳ thi.
- Past paper (timed) (Giải đề bấm giờ): vào ngày ngay trước kỳ thi.

## Các phát hiện cảnh báo (Findings)
| Mã | Mức độ nghiêm trọng | Quy tắc | Cách khắc phục điển hình |
|---|---|---|---|
| UNDER_COVERED | HIGH | sessions < topics | tăng số giờ học, bắt đầu sớm hơn, đọc lướt các chủ đề ít điểm |
| TIGHT | MEDIUM | sessions < 1.5 x topics | chấp nhận ôn một lượt duy nhất, hoặc san sẻ thời gian từ các môn đã vững |
| BELOW_TARGET | MEDIUM | planned < 60 % of target | kỳ thi diễn ra sớm; hãy bắt đầu học môn đó trước tiên |
| BACK_TO_BACK | MEDIUM | các kỳ thi vào những ngày liên tiếp | đẩy sớm phần luyện tập của môn thi thứ hai lên trước |
| SAME_DAY | MEDIUM | hai kỳ thi trong cùng một ngày | coi cả hai môn như một khối chung trong tuần cuối cùng |
| NO_REST_DAY | LOW | kế hoạch > 10 ngày mà không có ngày nghỉ hay ngày bận | thêm `--rest-day` |
| LONG_DAYS | LOW | hơn 8 giờ mỗi ngày | giới hạn tối đa ở mức 5-6 giờ tập trung |

## Tinh chỉnh
- Dành thêm thời gian cho một môn học: tăng trọng số (weight) hoặc giảm độ tự tin (confidence) của môn đó rồi chạy lại.
- Giảm số lần chuyển đổi môn học: dùng `--max-same 3`.
- Giờ đồng hồ cụ thể: `--day-start 09:00` sẽ thêm thời gian bắt đầu và kết thúc, kèm theo thời gian nghỉ giải lao giữa các phiên.
- Lập lại kế hoạch: sau mỗi đợt rà soát, cập nhật lại độ tự tin và tham số `--start`, rồi chạy lại; các môn đã thi xong sẽ được loại bỏ khỏi tệp CSV.
FILE:templates/revision-plan.md
# Kế hoạch ôn thi: <tên>, <mùa thi>

**Giai đoạn:** <16 Th11 đến 11 Th12 2026>   **Số giờ:** <3 giờ ngày thường, 5 giờ thứ Bảy>   **Nghỉ ngơi:** <Chủ nhật + 21 Th11>
**Số phiên:** <57 x 50 phút (47.5 giờ)>   **Nhịp độ phiên học:** <50 phút học / 10 phút nghỉ>

## Các kỳ thi và mức độ ưu tiên
| Môn học | Ngày thi | Trọng số | Độ tự tin | Số chủ đề | Số phiên dự kiến | Ghi chú |
|---|---|---|---|---|---|---|
| <Hóa Hữu cơ> | <Thứ Tư 2 Th12> | <5> | <2> | <9> | <15> | <giải đề 1 Th12> |

## Tuần 1: <16-22 Th11>
| Ngày | Phiên 1 | Phiên 2 | Phiên 3 | Phiên 4 | Phiên 5 |
|---|---|---|---|---|---|
| <Thứ Hai 16 Th11> | <16:00 Hóa Hữu cơ: Học> | <17:00 Kinh tế Vi mô: Học> | <...> | | |

## Tuần 2: <...>
<...>

## Quy trình phiên học
1. 5 phút tự nhớ lại phiên học trước (trên giấy trắng)
2. 30 phút học hoặc luyện tập một chủ đề
3. 10 phút tự kiểm tra, đánh dấu những chỗ còn thiếu sót
4. 5 phút: ghi lại ba câu hỏi còn bỏ ngỏ cho lần học tới
5. 10 phút nghỉ giải lao rời xa màn hình

## Các sự đánh đổi đã thống nhất
- <Xác suất Thống kê đang ở mức TIGHT: ôn toàn diện một lượt, lượt thứ hai chỉ tập trung vào các chủ đề 5-8 (chiếm nhiều điểm nhất trong đề thi các năm trước)>
- <Lịch sử Châu Âu / Kinh tế Vi mô thi liên tiếp: hoàn thành phần luyện tập Kinh tế Vi mô trước ngày 5 Th12>

## Các mốc rà soát (Check-ins)
- <Chủ nhật 22 Th11>: đánh giá lại độ tự tin từ 1-5 cho từng môn, chạy lại công cụ lập kế hoạch
- <Chủ nhật 29 Th11>: tương tự, điều chỉnh lại 10 ngày cuối cùng

## Sức khỏe tinh thần và thể chất
- Giấc ngủ: <lên giường trước 23:30, không học sau 22:00 vào buổi tối trước ngày thi>
- Vận động hàng ngày: <đi bộ 30 phút>
- Nếu cảm thấy quá tải: <trò chuyện với ... / liên hệ bộ phận hỗ trợ sinh viên>
FILE:examples/winter-exams.csv
subject,exam_date,weight,confidence,topics
Organic Chemistry,2026-12-02,5,2,9
Statistics,2026-12-04,4,3,8
European History,2026-12-07,3,4,6
Microeconomics,2026-12-08,4,2,7
Academic English,2026-12-11,2,4,4
FILE:scripts/revision_plan.py
#!/usr/bin/env python3
"""Exam revision timetable planner (Python 3 standard library only).

Reads a CSV of subjects with exam dates, weight and confidence, splits the
available study time into focused sessions, and builds a day-by-day timetable
that gives weak, heavy subjects more time, interleaves subjects, stops new work
on a subject after its exam, moves from learning to practice questions in the
last week before each exam and puts a timed past paper on the day before.

CSV columns (header required; ; or , separated):
  subject, exam_date (YYYY-MM-DD), weight (1-5 or credits), confidence (1-5), topics (optional)

Usage:
  python3 revision_plan.py exams.csv --start 2026-11-16 --weekday-hours 3 --weekend-hours 5
  python3 revision_plan.py exams.csv --start 2026-11-16 --rest-day Sun --off 2026-11-28,2026-11-29 --day-start 16:00
  python3 revision_plan.py --self-test
Exit codes: 0 = plan fits, 1 = HIGH findings (not enough sessions), 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, sys

DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
SEV = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class InputError(Exception):
    pass


def parse_date(s, what):
    try:
        return dt.date.fromisoformat(s.strip())
    except ValueError:
        raise InputError(f"{what} '{s}' must be YYYY-MM-DD")


def read_subjects(text):
    sample = text[:2000]
    delim = ";" if sample.count(";") > sample.count(",") else ","
    rd = csv.DictReader(io.StringIO(text), delimiter=delim)
    if not rd.fieldnames:
        raise InputError("empty CSV")
    fields = [f.strip().lower() for f in rd.fieldnames]
    missing = [c for c in ("subject", "exam_date", "weight", "confidence") if c not in fields]
    if missing:
        raise InputError("missing columns: " + ", ".join(missing))
    subs = []
    for i, raw in enumerate(rd, start=2):
        r = {k.strip().lower(): (v or "").strip() for k, v in raw.items() if k}
        if not any(r.values()):
            continue
        try:
            w = float(r["weight"].replace(",", "."))
            c = int(r["confidence"])
            t = int(r["topics"]) if r.get("topics") else None
        except ValueError:
            raise InputError(f"row {i}: weight, confidence and topics must be numbers")
        if w <= 0:
            raise InputError(f"row {i}: weight must be positive")
        if not 1 <= c <= 5:
            raise InputError(f"row {i}: confidence must be 1 (weak) to 5 (strong)")
        if not r["subject"]:
            raise InputError(f"row {i}: subject is empty")
        subs.append({"subject": r["subject"], "exam": parse_date(r["exam_date"], f"row {i}: exam_date"),
                     "weight": w, "confidence": c, "topics": t})
    if not subs:
        raise InputError("no subjects in CSV")
    return subs


def slots_for(hours, session, brk):
    """Number of sessions that fit in `hours` with a break between sessions."""
    mins = hours * 60
    if mins < session:
        return 0
    return int((mins + brk) // (session + brk))


def plan(subs, start, weekday_hours=3.0, weekend_hours=5.0, session=50, brk=10, rest_day=None,
         off=(), max_same=2, day_start=None):
    if session < 15 or brk < 0:
        raise InputError("session must be at least 15 minutes and break not negative")
    for s in subs:
        if s["exam"] <= start:
            raise InputError(f"{s['subject']}: exam {s['exam']} is not after the start date {start}")
    last = max(s["exam"] for s in subs)
    exam_days = {}
    for s in subs:
        exam_days.setdefault(s["exam"], []).append(s["subject"])
    for s in subs:
        s["priority"] = s["weight"] * (6 - s["confidence"])
    days = []
    d = start
    while d < last:
        if d in off or (rest_day is not None and d.weekday() == rest_day):
            n = 0
        else:
            n = slots_for(weekend_hours if d.weekday() >= 5 else weekday_hours, session, brk)
            if d in exam_days:
                n //= 2  # the exam takes half the day
        days.append((d, n))
        d += dt.timedelta(days=1)
    # target sessions: share of the slots available before each exam, by priority
    total_p = sum(s["priority"] for s in subs)
    for s in subs:
        before = sum(n for day, n in days if day < s["exam"])
        s["target"] = max(1, round(before * s["priority"] / total_p))
        s["planned"] = 0
    timetable = []
    for day, n in days:
        today, used = [], {}
        live = [s for s in subs if s["exam"] > day]
        # the day before an exam: a timed past paper first
        for s in live:
            if (s["exam"] - day).days == 1 and len(today) < n:
                today.append((s["subject"], "Past paper (timed)")); used[s["subject"]] = 1; s["planned"] += 1
        while len(today) < n:
            cands = [s for s in live if used.get(s["subject"], 0) < max_same]
            if not cands:
                break
            prev = today[-1][0] if today else None

            def score(s):
                days_left = max(1, (s["exam"] - day).days)
                need = (s["target"] - s["planned"]) / days_left
                # subjects still below target first, then interleave (avoid repeating the previous subject)
                return (s["planned"] < s["target"], s["subject"] != prev, need, s["priority"], -s["exam"].toordinal())
            s = max(cands, key=score)
            left = (s["exam"] - day).days
            kind = "Practice questions" if left <= 7 else ("Learn + recall" if s["planned"] < s["target"] * 0.6 else "Mixed review")
            today.append((s["subject"], kind)); used[s["subject"]] = used.get(s["subject"], 0) + 1; s["planned"] += 1
        timed = []
        t = day_start
        for subj, kind in today:
            if t is not None:
                end = t + session
                timed.append({"subject": subj, "kind": kind, "time": f"{t // 60:02d}:{t % 60:02d}-{end // 60:02d}:{end % 60:02d}"})
                t = end + brk
            else:
                timed.append({"subject": subj, "kind": kind})
        timetable.append({"date": day.isoformat(), "dow": DAYS[day.weekday()], "exams": exam_days.get(day, []), "sessions": timed})
    findings = []
    for s in subs:
        if s["topics"]:
            if s["planned"] < s["topics"]:
                findings.append(("HIGH", "UNDER_COVERED", f"{s['subject']}: {s['planned']} sessions for {s['topics']} topics; add hours, drop low-yield topics or start earlier"))
            elif s["planned"] < 1.5 * s["topics"]:
                findings.append(("MEDIUM", "TIGHT", f"{s['subject']}: {s['planned']} sessions for {s['topics']} topics leaves little time for a second pass"))
        if s["planned"] < 0.6 * s["target"]:
            findings.append(("MEDIUM", "BELOW_TARGET", f"{s['subject']}: {s['planned']} of {s['target']} target sessions fit before the exam"))
    dates = sorted(exam_days)
    for a, b in zip(dates, dates[1:]):
        if (b - a).days <= 1:
            findings.append(("MEDIUM", "BACK_TO_BACK", f"exams on {a} ({', '.join(exam_days[a])}) and {b} ({', '.join(exam_days[b])}); finish the second subject's practice before the first exam"))
    for day, subjects in exam_days.items():
        if len(subjects) > 1:
            findings.append(("MEDIUM", "SAME_DAY", f"{len(subjects)} exams on {day}: {', '.join(subjects)}"))
    if rest_day is None and len(days) > 10 and not off:
        findings.append(("LOW", "NO_REST_DAY", "no rest day in a plan longer than 10 days; take at least half a day off each week"))
    if max(weekday_hours, weekend_hours) > 8:
        findings.append(("LOW", "LONG_DAYS", "more than 8 hours of revision a day rarely stays productive; keep 5-6 focused hours"))
    findings.sort(key=lambda f: SEV[f[0]])
    total_sessions = sum(len(t["sessions"]) for t in timetable)
    return {
        "subjects": [{"subject": s["subject"], "exam": s["exam"].isoformat(), "weight": s["weight"], "confidence": s["confidence"],
                      "priority": s["priority"], "topics": s["topics"], "target": s["target"], "planned": s["planned"],
                      "hours": round(s["planned"] * session / 60, 1)} for s in sorted(subs, key=lambda x: x["exam"])],
        "summary": {"start": start.isoformat(), "last_exam": last.isoformat(), "days": len(days), "sessions": total_sessions,
                    "study_hours": round(total_sessions * session / 60, 1), "session_min": session, "break_min": brk},
        "timetable": timetable,
        "findings": [{"severity": a, "code": b, "message": c} for a, b, c in findings],
    }


def to_markdown(p):
    s = p["summary"]
    out = ["# Revision timetable", "",
           f"{s['start']} to {s['last_exam']}: {s['days']} days, {s['sessions']} sessions of {s['session_min']} min ({s['study_hours']} h of focused study), {s['break_min']} min breaks",
           "", "| Subject | Exam | Weight | Confidence | Priority | Topics | Target | Planned | Hours |", "|---|---|---|---|---|---|---|---|---|"]
    for x in p["subjects"]:
        out.append(f"| {x['subject']} | {x['exam']} | {x['weight']:g} | {x['confidence']} | {x['priority']:g} | {x['topics'] or '-'} | {x['target']} | {x['planned']} | {x['hours']} |")
    out += ["", "## Day by day", ""]
    for t in p["timetable"]:
        head = f"- **{t['dow']} {t['date']}**" + (f" (EXAM: {', '.join(t['exams'])})" if t["exams"] else "")
        if not t["sessions"]:
            out.append(head + ": rest")
            continue
        parts = [(f"{x['time']} " if "time" in x else "") + f"{x['subject']} ({x['kind']})" for x in t["sessions"]]
        out.append(head + ": " + "; ".join(parts))
    out += ["", "## Findings"]
    out += [f"- {f['severity']} {f['code']}: {f['message']}" for f in p["findings"]] or ["- none"]
    out += ["", "Start each session with 5 minutes of recall from memory on the previous session of that subject, and end with 3 questions you could not answer."]
    return "\n".join(out)


def self_test():
    res = []

    def check(name, cond):
        res.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    D = dt.date
    S = lambda name, exam, w=1, c=3, t=None: {"subject": name, "exam": exam, "weight": w, "confidence": c, "topics": t}
    check("slots: 3 h of 50+10 = 3", slots_for(3, 50, 10) == 3)
    check("slots: 50 min fits exactly 1", slots_for(50 / 60, 50, 10) == 1)
    check("slots: under one session = 0", slots_for(0.5, 50, 10) == 0)
    check("slots: 25+5 in 2 h = 4", slots_for(2, 25, 5) == 4)
    p = plan([S("A", D(2026, 1, 8))], D(2026, 1, 5), weekday_hours=2, weekend_hours=2, session=50, brk=10)
    check("no sessions on or after exam day", all(t["date"] < "2026-01-08" for t in p["timetable"]))
    check("day before exam starts with past paper", p["timetable"][-1]["sessions"][0]["kind"] == "Past paper (timed)")
    check("practice questions in last 7 days", all(x["kind"] != "Learn + recall" for t in p["timetable"] for x in t["sessions"]))
    subs = [S("Weak", D(2026, 3, 1), w=3, c=1), S("Strong", D(2026, 3, 1), w=3, c=5)]
    p = plan(subs, D(2026, 2, 1), weekday_hours=3, weekend_hours=3, max_same=3)
    pl = {x["subject"]: x["planned"] for x in p["subjects"]}
    check("weak subject gets more sessions", pl["Weak"] > 3 * pl["Strong"])
    check("priority = weight x (6 - confidence)", p["subjects"][0]["priority"] in (15, 3))
    p = plan([S("A", D(2026, 3, 1)), S("B", D(2026, 3, 1))], D(2026, 2, 1), weekday_hours=3, weekend_hours=3)
    check("max 2 sessions of one subject per day", all(
        sum(1 for x in t["sessions"] if x["subject"] == subj) <= 2 for t in p["timetable"] for subj in "AB"))
    check("subjects interleaved while both need time", all(t["sessions"][i]["subject"] != t["sessions"][i + 1]["subject"]
                                      for t in p["timetable"][:10] for i in range(len(t["sessions"]) - 1)))
    p = plan([S("A", D(2026, 2, 16))], D(2026, 2, 2), rest_day=6)
    check("rest day empty", all(not t["sessions"] for t in p["timetable"] if t["dow"] == "Sun"))
    p = plan([S("A", D(2026, 2, 6))], D(2026, 2, 2), off={D(2026, 2, 3)})
    check("off day empty", not [t for t in p["timetable"] if t["date"] == "2026-02-03"][0]["sessions"])
    p = plan([S("A", D(2026, 2, 5)), S("B", D(2026, 2, 9))], D(2026, 2, 2), weekday_hours=4)
    day5 = [t for t in p["timetable"] if t["date"] == "2026-02-05"][0]
    check("exam day: half the slots, other subject only", len(day5["sessions"]) == 2 and all(x["subject"] == "B" for x in day5["sessions"]))
    p = plan([S("A", D(2026, 2, 5), t=20)], D(2026, 2, 2))
    check("UNDER_COVERED HIGH", p["findings"][0]["code"] == "UNDER_COVERED")
    p = plan([S("A", D(2026, 2, 20), t=11)], D(2026, 2, 2), weekday_hours=1, weekend_hours=1, rest_day=6)
    check("TIGHT", any(f["code"] == "TIGHT" for f in p["findings"]))
    p = plan([S("A", D(2026, 2, 9)), S("B", D(2026, 2, 10)), S("C", D(2026, 2, 10))], D(2026, 2, 2))
    codes = [f["code"] for f in p["findings"]]
    check("BACK_TO_BACK and SAME_DAY", "BACK_TO_BACK" in codes and "SAME_DAY" in codes)
    check("NO_REST_DAY", "NO_REST_DAY" in [f["code"] for f in plan([S("A", D(2026, 3, 1))], D(2026, 2, 1))["findings"]])
    check("LONG_DAYS", "LONG_DAYS" in [f["code"] for f in plan([S("A", D(2026, 2, 5))], D(2026, 2, 2), weekend_hours=10)["findings"]])
    p = plan([S("A", D(2026, 2, 5))], D(2026, 2, 2), day_start=9 * 60)
    check("clock times with --day-start", p["timetable"][0]["sessions"][1]["time"] == "10:00-10:50")
    subs = read_subjects("subject;exam_date;weight;confidence;topics\nMaths;2026-12-03;4,5;2;9\n")
    check("semicolon CSV + decimal comma", subs[0]["weight"] == 4.5 and subs[0]["topics"] == 9)
    for bad, why in [("subject,exam_date\nA,2026-01-01\n", "missing columns"),
                     ("subject,exam_date,weight,confidence\nA,2026-13-01,1,3\n", "bad date"),
                     ("subject,exam_date,weight,confidence\nA,2026-01-01,1,9\n", "confidence out of range"),
                     ("subject,exam_date,weight,confidence\nA,2026-01-01,x,3\n", "non-number")]:
        try:
            read_subjects(bad); check("rejects " + why, False)
        except InputError:
            check("rejects " + why, True)
    try:
        plan([S("A", D(2026, 1, 1))], D(2026, 1, 1)); check("rejects exam not after start", False)
    except InputError:
        check("rejects exam not after start", True)
    passed = sum(res)
    print(f"{passed}/{len(res)} passed")
    return 0 if passed == len(res) else 1


def main():
    ap = argparse.ArgumentParser(description="Exam revision timetable planner")
    ap.add_argument("csv", nargs="?", help="subjects CSV")
    ap.add_argument("--start", help="first revision day YYYY-MM-DD (default today)")
    ap.add_argument("--weekday-hours", type=float, default=3.0)
    ap.add_argument("--weekend-hours", type=float, default=5.0)
    ap.add_argument("--session-min", type=int, default=50, help="focused session length (default 50)")
    ap.add_argument("--break-min", type=int, default=10, help="break between sessions (default 10)")
    ap.add_argument("--rest-day", choices=DAYS, help="weekly day off")
    ap.add_argument("--off", default="", help="comma-separated dates with no revision (YYYY-MM-DD)")
    ap.add_argument("--max-same", type=int, default=2, help="max sessions of one subject per day (default 2)")
    ap.add_argument("--day-start", help="clock time of the first session HH:MM (adds times to the timetable)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.csv:
        ap.error("give a CSV, or --self-test")
    try:
        with open(a.csv, encoding="utf-8-sig") as fh:
            subs = read_subjects(fh.read())
        start = parse_date(a.start, "--start") if a.start else dt.date.today()
        off = {parse_date(x, "--off") for x in a.off.split(",") if x.strip()}
        ds = None
        if a.day_start:
            try:
                h, m = map(int, a.day_start.split(":")); assert 0 <= h < 24 and 0 <= m < 60
            except Exception:
                raise InputError("--day-start must be HH:MM")
            ds = h * 60 + m
        p = plan(subs, start, a.weekday_hours, a.weekend_hours, a.session_min, a.break_min,
                 DAYS.index(a.rest_day) if a.rest_day else None, off, a.max_same, ds)
    except (OSError, InputError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(p, indent=2) if a.json else to_markdown(p))
    return 1 if any(f["severity"] == "HIGH" for f in p["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonStudentsEducation+2
F@f
0
Thử nghiệm Học máy taste
Văn bản

Các quy ước mang tính định kiến để chạy các thử nghiệm học máy có khả năng tái lập, từ mô hình cơ sở đến môi trường production.

# Thử nghiệm Học máy taste của một kỹ sư ML ứng dụng
- Viết chỉ số đánh giá và một mô hình cơ sở (baseline) đơn giản trước khi huấn luyện bất kỳ mô hình thực tế nào. Độ tin cậy: 0.95
- Chia dữ liệu theo thời gian hoặc theo thực thể khi có khả năng xảy ra rò rỉ dữ liệu (data leakage), tuyệt đối không chia ngẫu nhiên theo mặc định. Độ tin cậy: 0.90
- Đóng băng tập kiểm thử (test set) từ sớm và chỉ xem nó khi đưa ra các quyết định cuối cùng, đồng thời sử dụng một tập xác thực (validation set) riêng biệt để tinh chỉnh. Độ tin cậy: 0.95
- Theo dõi mọi lần chạy bằng MLflow hoặc Weights & Biases, ghi nhật ký (log) cấu hình, các chỉ số, phiên bản mã nguồn và phiên bản tập dữ liệu. Độ tin cậy: 0.90
- Lưu cấu hình thử nghiệm trong các tệp YAML được quản lý phiên bản, nạp bằng Hydra hoặc Pydantic, thay vì dùng các hằng số gán cứng (hardcoded). Độ tin cậy: 0.80
- Thiết lập và ghi lại các hạt giống ngẫu nhiên (random seeds) cho Python, NumPy cùng framework đang dùng, đồng thời báo cáo phương sai qua nhiều seed khác nhau. Độ tin cậy: 0.85
- Quản lý phiên bản các tập dữ liệu bằng DVC hoặc các đường dẫn lưu trữ đối tượng bất biến (immutable object storage) để mọi kết quả đều có thể tái lập. Độ tin cậy: 0.80
- Tạo mẫu thử nghiệm (prototype) trong các notebook nhưng hãy chuyển mã nguồn có thể tái sử dụng vào một gói Python đã được kiểm thử ngay khi nó được dùng đến lần thứ hai. Độ tin cậy: 0.85
- Cố định các thư viện phụ thuộc bằng tệp khóa (lock file) và ghi nhận lại phiên bản CUDA cùng trình điều khiển (driver) trong mỗi lần chạy. Độ tin cậy: 0.75
- Kiểm tra thủ công các mẫu dữ liệu thô và các lỗi gán nhãn trước khi tin tưởng vào bất kỳ chỉ số tổng hợp nào. Độ tin cậy: 0.90
- Ưu tiên các mô hình cây tăng cường độ dốc (gradient boosted trees) cho dữ liệu dạng bảng và chỉ tìm đến học sâu (deep learning) khi chúng tỏ ra kém hiệu quả rõ rệt. Độ tin cậy: 0.80
- Sử dụng PyTorch với một vòng lặp huấn luyện gọn nhẹ hoặc dùng Lightning thay vì các framework tùy chỉnh cồng kềnh. Độ tin cậy: 0.70
- Báo cáo các chỉ số theo từng phân khúc (slice) chẳng hạn như khu vực, thiết bị hoặc lớp (class), chứ không chỉ dừng lại ở một con số tổng thể duy nhất. Độ tin cậy: 0.90
- Hiệu chuẩn (calibrate) các xác suất dự đoán khi mã nguồn ở khâu sau đưa ra quyết định dựa trên ngưỡng (threshold). Độ tin cậy: 0.75
- Tinh chỉnh siêu tham số (hyperparameters) bằng Optuna hoặc tìm kiếm ngẫu nhiên (random search) đơn giản trong phạm vi ngân sách tính toán cố định. Độ tin cậy: 0.75
- Viết kiểm thử đơn vị (unit tests) cho các bước biến đổi đặc trưng (feature transforms) và bộ nạp dữ liệu (data loaders), đồng thời kiểm tra xác nhận (assert) kích thước (shapes) cùng kiểu dữ liệu (dtypes) kỳ vọng. Độ tin cậy: 0.85
- Xác thực dữ liệu đầu vào bằng các bước kiểm tra lược đồ (schema) như Pandera hoặc Great Expectations trước khi huấn luyện. Độ tin cậy: 0.80
- Lưu mô hình cùng với luồng tiền xử lý (preprocessing pipeline) của nó thành một tạo phẩm (artifact) duy nhất để quá trình huấn luyện và phục vụ (serving) không bị lệch nhau. Độ tin cậy: 0.90
- So sánh mô hình mới với mô hình đang chạy trên môi trường production ở chế độ ngoại tuyến (offline) và sau đó thông qua triển khai chạy ngầm (shadow) hoặc thử nghiệm A/B. Độ tin cậy: 0.85
- Giám sát sự trôi dạt đặc trưng (feature drift) và phân phối dự đoán sau khi ra mắt, đồng thời cảnh báo khi có sự dịch chuyển lớn. Độ tin cậy: 0.80
- Viết một ghi chú thử nghiệm ngắn cho mỗi lần chạy quan trọng, nêu rõ giả thuyết, kết quả và bước tiếp theo. Độ tin cậy: 0.85
- Xóa hoặc lưu trữ các nhánh công việc thất bại thay vì để lại hàng tá các notebook làm dở dang. Độ tin cậy: 0.65
Best PracticesPythonTesting+2
F@f
0
Công cụ Kiểm thử và Đánh giá Mẫu Gitignore
Kỹ năng

Giải thích từng dòng của tệp .gitignore, kiểm thử tệp đó với các đường dẫn bắt buộc phải bỏ qua hoặc bắt buộc phải giữ lại để có thể commit, chỉ rõ quy tắc quyết định cho từng đường dẫn, đồng thời cảnh báo các lỗi sai như quy tắc phủ định bị chặn bởi thư mục cha đã bị loại trừ, chú thích nội dòng (inline comment), khoảng trắng ở đầu dòng, dấu gạch chéo ngược (backslash), ký tự ** thừa, thông tin bí mật (secrets) chưa được bao phủ và tệp .env.example bị bỏ qua. Trả về một tệp đã được sửa lỗi. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test, đối chiếu khớp với git thực tế.

---
name: gitignore-pattern-tester-and-reviewer
description: Giải thích từng dòng của tệp .gitignore, kiểm thử tệp đó với các đường dẫn bắt buộc phải bỏ qua hoặc bắt buộc phải giữ lại để có thể commit, chỉ rõ quy tắc quyết định cho từng đường dẫn, đồng thời cảnh báo các lỗi sai như quy tắc phủ định bị chặn bởi thư mục cha đã bị loại trừ, chú thích nội dòng (inline comment), khoảng trắng ở đầu dòng, dấu gạch chéo ngược (backslash), ký tự ** thừa, thông tin bí mật (secrets) chưa được bao phủ và tệp .env.example bị bỏ qua. Trả về một tệp đã được sửa lỗi. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test, đối chiếu khớp với git thực tế.
---

# Công cụ Kiểm thử và Đánh giá Mẫu Gitignore

Bạn giúp các lập trình viên hiểu và sửa lỗi các tệp `.gitignore`. Bạn giải thích những gì mà mỗi mẫu (pattern) thực sự khớp, chứng minh điều đó bằng các bài kiểm thử đường dẫn, và tìm ra những sai lầm khiến git bỏ qua các tệp mã nguồn hoặc vô tình commit các thông tin bí mật (secrets) cùng kết quả biên dịch (build output). Bạn trả về một tệp đã được hiệu chỉnh kèm theo các bài kiểm thử vượt qua thành công, chứ không chỉ đưa ra lời khuyên suông.

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

- `scripts/gitignore_check.py` - giải thích từng quy tắc, chạy các bài kiểm thử "+ path" (bắt buộc phải bỏ qua) và "- path" (bắt buộc phải giữ lại), chỉ rõ dòng quyết định cho từng đường dẫn, và báo cáo các phát hiện ở mức HIGH, MEDIUM, LOW và INFO (chỉ dùng thư viện chuẩn Python 3)
- `references/gitignore-rules.md` - cách git đọc các tệp ignore: các nguồn và thứ tự ưu tiên, cú pháp dòng, neo đường dẫn (anchoring), ký tự đại diện (wildcards), quy tắc thư mục cha, các tệp đã được theo dõi (tracked files) và các lệnh để xác nhận
- `references/common-pitfalls.md` - mọi mã phát hiện kèm cách khắc phục, các bẫy thường gặp mà kịch bản không thể nhìn thấy (tên quá rộng, tệp đã được theo dõi, tệp lồng nhau, phân biệt chữ hoa chữ thường, tệp khóa lock files, sự khác biệt với .dockerignore) và các khối mẫu khởi đầu tốt
- `templates/gitignore-review.md` - bản đánh giá để bàn giao lại cho người dùng
- `examples/webapp.gitignore` - một tệp ignore của ứng dụng web chứa sáu lỗi thực tế
- `examples/webapp-fixed.gitignore` - tệp đã được sửa lỗi (tất cả các bài kiểm thử đều vượt qua)
- `examples/webapp-paths.txt` - 19 bài kiểm thử đường dẫn được sử dụng với cả hai tệp trên

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

### 1. Thu thập dữ liệu đầu vào
Yêu cầu cung cấp tệp `.gitignore` (và bất kỳ tệp `.gitignore` lồng nhau nào), ngăn xếp công nghệ (stack), và các đường dẫn quan trọng: những gì bắt buộc phải bỏ qua (kết quả biên dịch, các gói phụ thuộc, nhật ký log, thông tin bí mật) và những gì bắt buộc phải giữ lại để có thể commit (các thư mục mã nguồn có tên phổ biến như `build/` hoặc `lib/`, các tệp `.gitkeep`, `.env.example`, các tệp khóa lock files). Nếu người dùng chỉ hỏi "tại sao X lại bị bỏ qua?", hãy hỏi đường dẫn và sử dụng kết quả đầu ra của lệnh `git check-ignore -v X` nếu họ có sẵn.

### 2. Chạy trình kiểm tra
```
python3 scripts/gitignore_check.py examples/webapp.gitignore --test examples/webapp-paths.txt
python3 scripts/gitignore_check.py .gitignore --test paths.txt --json
python3 scripts/gitignore_check.py --self-test
```
Tệp đường dẫn (Paths file): `+ path` là bắt buộc phải bỏ qua, `- path` là bắt buộc phải giữ lại, kết thúc tên thư mục bằng dấu `/`. Mã thoát (Exit) 1 có nghĩa là có phát hiện mức HIGH hoặc có bài kiểm thử thất bại, mã thoát 2 có nghĩa là dữ liệu đầu vào không thể đọc được. Trình kiểm tra mô hình hóa một tệp ignore ở thư mục gốc của kho lưu trữ (repo root); đối với tệp lồng bên trong, hãy cung cấp các đường dẫn tương đối tính từ thư mục chứa tệp đó.

### 3. Giải thích và sửa lỗi
- Đi qua bảng Quy tắc (Rules table) bằng ngôn từ dễ hiểu, đặc biệt là quy tắc neo đường dẫn (dấu gạch chéo ở đầu hoặc ở giữa = tương đối so với thư mục chứa `.gitignore`) và quy tắc thư mục cha (xem `references/gitignore-rules.md`).
- Đối với mỗi trường hợp FAIL, hãy trích dẫn dòng quyết định từ cột "Decided by" và đưa ra thay đổi nhỏ nhất để khắc phục nó.
- Sửa mọi phát hiện ở mức HIGH và MEDIUM; giải thích bất kỳ phát hiện mức INFO nào mà bạn giữ nguyên.
- Thêm các bài kiểm thử `- path` cho các thư mục mã nguồn có tên phổ biến để làm lộ diện các quy tắc quá rộng.
- Chạy lại trình kiểm tra trên tệp đã sửa cho đến khi tất cả các bài kiểm thử đều vượt qua, và hiển thị phần khác biệt (diff).

### 4. Bàn giao kết quả
Điền vào mẫu `templates/gitignore-review.md`: tệp hiện tại đang làm gì, bảng kiểm thử, các phát hiện, toàn bộ tệp đã sửa lỗi, bản so sánh khác biệt (diff), và các câu lệnh để xác nhận (`git check-ignore -v`, `git status --ignored`) cũng như để bỏ theo dõi (untrack) các tệp đã lỡ được commit trước đó (`git rm --cached`).

## Kết quả ví dụ
Đối với `examples/webapp.gitignore` chạy cùng `examples/webapp-paths.txt`: 13/19 bài kiểm thử vượt qua, 1 phát hiện mức HIGH, 4 mức MEDIUM, 2 mức LOW, 1 mức INFO. Mức HIGH NEGATION_BLOCKED: `logs/` loại trừ cả thư mục, do đó `!logs/.gitkeep` không bao giờ có thể được áp dụng (cách sửa: `logs/*`). Mức MEDIUM: `config/secrets.yml # never commit` bị hiểu là một mẫu duy nhất, vì vậy tệp chứa thông tin bí mật này sẽ bị commit; ` Thumbs.db` có một khoảng trắng ở đầu dòng; `src\generated` bị đọc thành `srcgenerated`; và các tệp `*.key`, `*.pem` cùng `id_rsa` chưa được bao phủ. Mức LOW: `.env*` làm ẩn mất `.env.example`; `*.log` bị liệt kê hai lần. Mẫu `build` không được neo cũng làm ẩn luôn `src/build/README.md` (cách sửa: `/build/`). Tệp `examples/webapp-fixed.gitignore` vượt qua 19/19 bài kiểm thử mà không có bất kỳ phát hiện nào ở mức HIGH, MEDIUM hay LOW.

## Quy tắc

- Dòng khớp cuối cùng sẽ chiến thắng; một tệp nằm bên trong một thư mục đã bị loại trừ thì không bao giờ có thể được đưa vào lại (re-included). Hãy luôn nhắc lại điều này bất cứ khi nào có liên quan đến một dòng bắt đầu bằng `!`.
- Tuyệt đối không đề xuất bỏ qua các tệp khóa (`package-lock.json`, `poetry.lock`, `go.sum`, `Cargo.lock` của ứng dụng) hoặc cấu hình dùng chung mà nhóm chủ đích commit.
- Nếu một thông tin bí mật (secret) đã lỡ bị commit, hãy nói rõ rằng việc thêm nó vào ignore là chưa đủ: phải xoay vòng (rotate) khóa đó và cân nhắc viết lại lịch sử git.
- Ưu tiên các mẫu được neo ở thư mục gốc (`/dist/`, `/build/`) cho các thư mục đầu ra, và đặt các tệp cá nhân của trình soạn thảo cũng như hệ điều hành vào tệp ignore toàn cục (global ignore file) khi cả nhóm đồng thuận.
- Xác nhận trên kho lưu trữ thực tế bằng lệnh `git check-ignore -v`: kịch bản tuân theo các quy tắc trong tài liệu của git cho một tệp đơn lẻ, nhưng các tệp lồng nhau, `.git/info/exclude`, tệp toàn cục và thiết lập `core.ignorecase` có thể làm thay đổi kết quả.
FILE:references/gitignore-rules.md
# Cách git đọc tệp .gitignore

Nguồn: tài liệu hướng dẫn gitignore(5) (`git help gitignore`). Trình kiểm tra triển khai các quy tắc này và đã được đối chiếu với git 2.47 thực tế trên 6,956 lượt kiểm tra đường dẫn mà không có bất kỳ sự khác biệt nào.

## Nguồn gốc của các quy tắc (ưu tiên từ cao nhất trở xuống)
1. Các tùy chọn `--exclude` trên dòng lệnh (hiếm gặp; được sử dụng bởi một số công cụ).
2. Các tệp `.gitignore`: tệp nằm trong cùng thư mục với đường dẫn đang xét sẽ ưu tiên hơn các tệp nằm ở thư mục cấp trên. Các mẫu (patterns) có tính tương đối so với thư mục chứa tệp `.gitignore` đó.
3. `.git/info/exclude` - cục bộ trên một bản sao (clone) duy nhất, không bao giờ được commit. Rất phù hợp cho các tệp nháp cá nhân.
4. Tệp toàn cục trong `core.excludesFile` (mặc định là `~/.config/git/ignore`) - rất phù hợp cho các tệp của trình soạn thảo và hệ điều hành như `.DS_Store` và `*.swp`.

Bên trong một tệp, dòng khớp CUỐI CÙNG sẽ quyết định kết quả.

## Cú pháp dòng
| Dòng | Ý nghĩa |
|---|---|
| dòng trống | được bỏ qua, dùng làm khoảng phân cách |
| `# text` | chú thích (comment); chỉ có tác dụng khi nằm ngay đầu dòng. `foo # bar` được hiểu là mẫu có tên `foo # bar` |
| `\#file` | một tên tệp bắt đầu bằng ký tự `#` |
| `!pattern` | đưa trở lại (re-include) một đối tượng đã bị loại trừ bởi một dòng trước đó |
| `\!file` | một tên tệp bắt đầu bằng ký tự `!` |
| `name   ` | các khoảng trắng ở cuối dòng sẽ bị loại bỏ trừ khi được thoát bằng dấu gạch chéo ngược (`name\ `) |
| `  name` | các khoảng trắng ở đầu dòng ĐƯỢC GIỮ LẠI và trở thành một phần của tên |
| `\` | thoát (escape) ký tự kế tiếp; nó không bao giờ được dùng làm dấu phân cách đường dẫn |

## Dấu gạch chéo (slash) quyết định vị trí mà mẫu sẽ khớp
| Mẫu | Khớp với | Không khớp với |
|---|---|---|
| `build` | `build`, `src/build`, `a/b/build/x.js` (bất kỳ tệp hoặc thư mục nào có tên là build) | `build.sh` |
| `build/` | các thư mục có tên là build ở bất kỳ độ sâu nào | một TỆP (FILE) có tên là build |
| `/build` | chỉ khớp với `build` nằm ngay cạnh tệp .gitignore | `src/build` |
| `doc/frotz` | `doc/frotz` nằm ngay cạnh tệp .gitignore (dấu gạch chéo ở giữa có tác dụng neo giống như dấu gạch chéo ở đầu) | `a/doc/frotz` |
| `**/doc/frotz` | `doc/frotz` ở bất kỳ độ sâu nào | |

Quy tắc nằm lòng: không có dấu gạch chéo (hoặc chỉ có một dấu gạch chéo ở cuối) = khớp ở bất kỳ độ sâu nào; có dấu gạch chéo ở đầu hoặc ở giữa = tương đối so với thư mục chứa tệp .gitignore.

## Ký tự đại diện (Wildcards)
| Ký tự đại diện | Ý nghĩa |
|---|---|
| `*` | bất kỳ chuỗi ký tự nào ngoại trừ `/` (`*.log`, `/a*z` không khớp với `ab/cz`) |
| `?` | chính xác một ký tự bất kỳ ngoại trừ `/` |
| `[abc]`, `[a-z]`, `[!0-9]`, `[^0-9]` | một ký tự nằm trong (hoặc không nằm trong) tập hợp; các lớp ký tự kiểu `[[:digit:]]` đều hoạt động |
| `**/name` | `name` nằm trong bất kỳ thư mục nào, bao gồm cả thư mục gốc trên cùng |
| `dir/**` | mọi thứ nằm bên trong `dir`, nhưng không bao gồm chính bản thân `dir` |
| `a/**/b` | `a/b`, `a/x/b`, `a/x/y/b` |
| `foo**`, `**bar` | `**` không nằm cạnh dấu gạch chéo sẽ hoạt động giống như một dấu `*` đơn lẻ |

## Quy tắc thư mục cha (điều gây bất ngờ phổ biến nhất)
"Không thể đưa một tệp vào lại (re-include) nếu thư mục cha của tệp đó đã bị loại trừ." Git thậm chí còn không liệt kê nội dung bên trong một thư mục đã bị loại trừ, vì vậy một dòng `!` phía sau sẽ không bao giờ có cơ hội được xét tới.

```gitignore
# KHÔNG hoạt động: logs/ đã bị loại trừ dưới dạng cả một thư mục
logs/
!logs/.gitkeep

# hoạt động: loại trừ nội dung bên trong thư mục, giữ cho bản thân thư mục vẫn hiển thị
logs/*
!logs/.gitkeep

# hoạt động đối với các tệp nằm sâu hơn: đưa vào lại (re-include) từng cấp thư mục
/assets/*
!/assets/icons/
/assets/icons/*
!/assets/icons/app.svg
```

## Phong cách danh sách trắng (Whitelist style)
Bỏ qua mọi thứ, sau đó chỉ cho phép những gì bạn muốn:
```gitignore
*
!*/
!*.md
!src/**
```
`!*/` đưa mọi thư mục vào lại để các dòng `!` phía sau có thể chạm tới các tệp nằm bên trong chúng.

## Các tệp đã được theo dõi (Already tracked files)
.gitignore chỉ ảnh hưởng đến các tệp chưa được theo dõi (untracked files). Một tệp đã được commit sẽ vẫn tiếp tục được theo dõi:
```
git rm --cached path/to/file        # giữ tệp trên ổ đĩa, ngừng theo dõi nó
git rm -r --cached build/           # tương tự đối với một thư mục
```
Nếu đó là một thông tin bí mật (secret), việc xóa nó khỏi danh sách theo dõi là chưa đủ: hãy xoay vòng (rotate) khóa bí mật đó, bởi vì nó vẫn còn nằm trong lịch sử git.

## Các câu lệnh để xác nhận
```
git check-ignore -v path/to/file          # in ra tệp, dòng và mẫu đã bỏ qua đường dẫn đó (không in gì = không bị bỏ qua)
git check-ignore -v --no-index path       # dùng được cả cho các tệp đang được theo dõi (tracked)
git status --ignored --short              # liệt kê các tệp bị bỏ qua với ký hiệu "!!"
git ls-files --others --ignored --exclude-standard   # mọi tệp chưa được theo dõi đang bị bỏ qua
```
FILE:references/common-pitfalls.md
# Các bẫy .gitignore thường gặp và mã lỗi của trình kiểm tra

| Mã | Mức độ nghiêm trọng | Vấn đề xảy ra | Cách khắc phục |
|---|---|---|---|
| NEGATION_BLOCKED | HIGH | `!path/file` nằm sau một quy tắc loại trừ thư mục cha (`logs/`, `dist`, `a/*` đối với `a/b/c`) sẽ không bao giờ có hiệu lực, khiến tệp bạn định commit vẫn bị bỏ qua | Loại trừ nội dung bên trong (`logs/*` hoặc `logs/**`) thay vì cả thư mục, hoặc đưa vào lại từng cấp thư mục (`!a/b/`) |
| SECRETS_NOT_COVERED | MEDIUM | các tệp bí mật điển hình (`.env`, `*.pem`, `*.key`, `id_rsa`) sẽ bị commit khi chạy `git add .` | Thêm `.env`, `.env.*`, `*.pem`, `*.key`, `id_rsa*`; đồng thời sử dụng trình quét thông tin bí mật (secret scanner) trong CI |
| INLINE_COMMENT | MEDIUM | `secrets.yml # never commit` được hiểu là một mẫu duy nhất bao gồm cả phần văn bản chú thích, do đó `secrets.yml` KHÔNG hề bị bỏ qua | Đặt các chú thích trên dòng riêng của chúng |
| LEADING_SPACE | MEDIUM | ` Thumbs.db` chỉ khớp với một cái tên bắt đầu bằng dấu cách | Xóa dấu cách đó đi |
| BACKSLASH_SEPARATOR | MEDIUM | `src\generated` có nghĩa là "src" + ký tự "g" được thoát + "enerated" = `srcgenerated` | Luôn sử dụng `/`, kể cả trên Windows |
| ENV_EXAMPLE_IGNORED | LOW | `.env*` cũng làm ẩn luôn `.env.example`, tệp mẫu mà các lập trình viên khác cần dùng | Thêm `!.env.example` ở dòng phía sau nó |
| DUPLICATE | LOW | cùng một dòng xuất hiện hai lần; vô hại nhưng là dấu hiệu của việc gộp mã (merge) bằng cách sao chép-dán | Xóa bớt một dòng |
| DOUBLE_STAR | LOW | `foo**` thực chất chỉ là `foo*`; rất có thể tác giả định viết là `foo/**` | Chỉ sử dụng `**` dưới dạng `**/x`, `x/**` hoặc `a/**/b` |
| TRAILING_SPACE | LOW | các khoảng trắng ở cuối dòng bị âm thầm lược bỏ; nếu đó là chủ ý thì tệp sẽ không khớp | Xóa hoặc thoát (escape) chúng |
| CRLF | LOW | ký tự xuống dòng kiểu Windows trong tệp .gitignore | Lưu dưới dạng LF; thêm `* text=auto` vào tệp .gitattributes |
| ANCHORED_BY_SLASH | INFO | `src/gen` chỉ khớp ngay cạnh tệp .gitignore, chứ không khớp với `packages/a/src/gen` | Thêm `/` ở đầu để thể hiện rõ chủ ý hoặc thêm `**/` để khớp ở bất kỳ độ sâu nào |

## Các bẫy mà kịch bản không thể nhìn thấy (hãy kiểm tra thủ công)
- **Các tên không được neo nhưng quá rộng.** `build` cũng làm ẩn cả `src/build/` và `docs/build/`; `lib` làm ẩn mọi thư mục `lib` bao gồm cả mã nguồn. Hãy sử dụng `/build/` cho thư mục đầu ra ở cấp gốc. Hãy viết một bài kiểm thử `- path` cho bất kỳ thư mục mã nguồn nào có tên phổ biến.
- **Các tệp đã được theo dõi (Tracked files).** Việc thêm một quy tắc không làm bỏ theo dõi (untrack) các tệp đã được commit trước đó. Hãy chạy `git ls-files -ci --exclude-standard` để liệt kê các tệp đang được theo dõi nhưng khớp với quy tắc ignore, sau đó chạy `git rm --cached`.
- **Các tệp .gitignore lồng nhau.** Một tệp `.gitignore` nằm trong thư mục con sẽ thêm các quy tắc tương đối so với thư mục con đó và có thể đưa vào lại những gì mà tệp ở thư mục gốc đã loại trừ (trừ khi một thư mục cha đã bị loại trừ). Hãy chạy trình kiểm tra trên từng tệp với các đường dẫn tương đối so với thư mục của nó, hoặc xác nhận bằng lệnh `git check-ignore -v`.
- **Phân biệt chữ hoa chữ thường (Case sensitivity).** Trên macOS và Windows, thiết lập `core.ignorecase=true` khiến `*.JPG` khớp với cả `photo.jpg`; nhưng trên môi trường Linux CI thì không. Hãy viết cả hai dạng chữ hoa/thường hoặc sử dụng `*.[jJ][pP][gG]`.
- **Các tệp cá nhân nằm trong tệp dùng chung.** `.idea/`, `.vscode/`, `*.swp`, `.DS_Store` thuộc về tệp ignore toàn cục (`core.excludesFile`), mặc dù nhiều nhóm vẫn giữ chúng trong tệp của kho lưu trữ (repo) để tiện lợi. Đừng bỏ qua `.vscode/settings.json` nếu nhóm chủ đích chia sẻ tệp này.
- **Các tệp khóa (Lock files).** Đừng bỏ qua `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`, `Cargo.lock` (đối với các ứng dụng) hoặc `go.sum`: chúng giúp quá trình biên dịch (build) có thể tái lập chính xác.
- **Đã thêm vào ignore nhưng vẫn ép commit.** `git add -f` sẽ ghi đè lên các quy tắc ignore; người đánh giá mã (reviewer) nên kiểm tra xem có kết quả biên dịch hoặc thông tin bí mật nào bị ép thêm vào hay không.
- **Các tệp ignore khác sử dụng quy tắc khác.** `.dockerignore` sử dụng `filepath.Match` của Go cộng với `**` và luôn được neo tại thư mục gốc của ngữ cảnh biên dịch (build context root - không có khái niệm "bất kỳ độ sâu nào" cho các tên thông thường). `.npmignore`, `.eslintignore` và `.prettierignore` tuân theo các quy tắc tương tự gitignore nhưng không phải lúc nào cũng giống ở mọi chi tiết. Hãy kiểm thử chúng bằng công cụ riêng của chúng.

## Các khối mẫu khởi đầu tốt
```gitignore
# Node
node_modules/
/dist/
/build/
npm-debug.log*
.npm/

# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
/dist/
*.egg-info/

# Secrets
.env
.env.*
!.env.example
*.pem
*.key
id_rsa*

# Logs that keep their folder
logs/*
!logs/.gitkeep
```
FILE:templates/gitignore-review.md
# Đánh giá .gitignore: <kho lưu trữ hoặc thư mục>

**Ngăn xếp (Stack):** <Node 20 + Vite / Python 3.12 / monorepo với pnpm workspaces>
**Các tệp đã đánh giá:** <.gitignore tại thư mục gốc, packages/api/.gitignore>
**Tóm tắt:** <một câu: vấn đề quan trọng nhất và bản sửa lỗi sẽ thay đổi điều gì>

## Tệp hiện tại đang làm gì
| Dòng | Mẫu (Pattern) | Ý nghĩa |
|---|---|---|
| <9> | <`logs/`> | <bỏ qua mọi thư mục có tên là logs, ở bất kỳ độ sâu nào, và mọi thứ bên trong nó> |

## Kiểm thử đường dẫn (Path tests)
| Kết quả | Đường dẫn | Kỳ vọng | Thực tế | Quyết định bởi |
|---|---|---|---|---|
| <FAIL> | <`logs/.gitkeep`> | <giữ lại (kept)> | <bỏ qua (ignored)> | <thư mục cha logs/ bị loại trừ bởi L9; L10 không thể đưa nó vào lại> |

## Các phát hiện (Findings)
| # | Mức độ nghiêm trọng | Mã | Dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | <HIGH> | <NEGATION_BLOCKED> | <10> | <...> | <sử dụng logs/* thay vì logs/> |

## Tệp .gitignore đề xuất
```gitignore
<toàn bộ tệp đã sửa lỗi, với các chú thích nằm trên dòng riêng của chúng>
```

## Các thay đổi
```diff
- logs/
+ logs/*
```

## Áp dụng và xác nhận
1. Thay thế tệp, sau đó chạy: `git check-ignore -v <từng đường dẫn từ bài kiểm thử>`
2. Chạy `git status --ignored --short` và kiểm tra xem không có tệp quan trọng nào hiển thị dưới dạng `!!`
3. Bỏ theo dõi (untrack) các tệp hiện đã được bỏ qua nhưng từng bị commit trước đó: `git rm --cached <path>` (xoay vòng bất kỳ khóa bí mật nào đã từng bị commit)
4. Commit: `chore: fix .gitignore (<lý do ngắn gọn>)`

## Các việc cần làm tiếp theo (Follow-ups)
- <chuyển các tệp của trình soạn thảo sang tệp ignore toàn cục>
- <thêm trình quét thông tin bí mật (gitleaks, trufflehog) vào CI>
FILE:examples/webapp.gitignore
# Dependencies
node_modules
/vendor/

# Build output
dist/
build
*.log
logs/
!logs/.gitkeep

# Environment
.env*
config/secrets.yml # never commit

# OS and editor files
.DS_Store
.idea/
*.swp
 Thumbs.db

# Test coverage
coverage/**
!coverage/README.md

# Generated code
src\generated
*.log
FILE:examples/webapp-fixed.gitignore
# Dependencies
node_modules/
/vendor/

# Build output (root only, so src/build/ stays tracked)
/dist/
/build/
*.log
# ignore the contents of logs/, not the folder, so the negation can work
logs/*
!logs/.gitkeep

# Environment and secrets
.env
.env.*
!.env.example
*.pem
*.key
id_rsa*
# never commit
config/secrets.yml

# OS and editor files
.DS_Store
Thumbs.db
.idea/
*.swp

# Test coverage
coverage/**
!coverage/README.md

# Generated code
/src/generated/
FILE:examples/webapp-paths.txt
# Paths for examples/webapp.gitignore and webapp-fixed.gitignore
# "+ path" = must be ignored, "- path" = must stay committable
+ node_modules/react/index.js
+ packages/api/node_modules/lodash/lodash.js
+ vendor/autoload.php
- packages/vendor/README.md
+ dist/app.js
+ build/output.js
- src/build/README.md
+ logs/app.log
- logs/.gitkeep
+ .env
+ .env.local
- .env.example
+ config/secrets.yml
+ .DS_Store
+ Thumbs.db
+ coverage/lcov.info
- coverage/README.md
+ src/generated/types.ts
- src/index.ts
FILE:scripts/gitignore_check.py
#!/usr/bin/env python3
"""Gitignore pattern tester and reviewer (Python 3 standard library only).

Explains every rule in a .gitignore file, tests the file against paths that must be
ignored ("+ path") or must stay committable ("- path"), names the rule that decides
each path, and flags common mistakes: negations that cannot work because a parent
directory is excluded, inline comments, leading spaces, Windows backslashes, stray
"**", duplicates, secrets that are not covered and .env.example being ignored.

Matching follows git's documented gitignore rules for a single .gitignore at the
repository root: last matching rule wins, "!" re-includes, a trailing "/" matches
directories only, a "/" at the start or in the middle anchors the pattern to the
.gitignore's directory, "*" "?" "[...]" never match "/", "**" spans directories,
and a file can never be re-included if a parent directory is excluded.

Usage:
  python3 gitignore_check.py .gitignore [--test paths.txt] [--json]
  python3 gitignore_check.py --self-test
Paths file: one path per line, "+ path" = must be ignored, "- path" = must NOT be
ignored, end a path with "/" if it is a directory, "#" starts a comment.
Exit codes: 0 = no HIGH findings and all tests pass, 1 = HIGH finding or failed test,
2 = unreadable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
SECRET_PROBES = [".env", "config/.env.production", "certs/server.key", "deploy/id_rsa",
                 "secrets/service-account.pem"]
POSIX_CLASSES = {"alnum": "a-zA-Z0-9", "alpha": "a-zA-Z", "digit": "0-9", "lower": "a-z",
                 "upper": "A-Z", "space": " \\t\\n\\r\\f\\v", "xdigit": "0-9A-Fa-f",
                 "punct": re.escape("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"), "blank": " \\t"}


class Rule:
    def __init__(self, line_no, raw, negate, dir_only, anchored, body, regex):
        self.line_no, self.raw, self.negate, self.dir_only = line_no, raw, negate, dir_only
        self.anchored, self.body, self.regex = anchored, body, regex

    def matches(self, rel_path, is_dir):
        if self.dir_only and not is_dir:
            return False
        target = rel_path if self.anchored else rel_path.rsplit("/", 1)[-1]
        return self.regex.fullmatch(target) is not None

    def label(self):
        return f"L{self.line_no} `{self.raw}`"


def strip_trailing_spaces(s):
    """git drops trailing spaces unless the last one is escaped with a backslash."""
    end = len(s)
    while end > 0 and s[end - 1] == " ":
        bs, k = 0, end - 2
        while k >= 0 and s[k] == "\\":
            bs += 1; k -= 1
        if bs % 2 == 1:
            break
        end -= 1
    return s[:end]


def class_to_regex(body):
    out, m = "", 0
    while m < len(body):
        ch = body[m]
        if ch == "[" and body.startswith("[:", m):
            end = body.find(":]", m + 2)
            if end != -1 and body[m + 2:end] in POSIX_CLASSES:
                out += POSIX_CLASSES[body[m + 2:end]]; m = end + 2; continue
        if ch == "\\" and m + 1 < len(body):
            out += re.escape(body[m + 1]); m += 2; continue
        if ch == "-" and 0 < m < len(body) - 1:
            out += "-"; m += 1; continue
        out += re.escape(ch); m += 1
    return out


def glob_to_regex(p):
    out, i, n = "", 0, len(p)
    while i < n:
        c = p[i]
        if c == "\\":
            if i + 1 < n:
                out += re.escape(p[i + 1]); i += 2
            else:
                i += 1  # a lone trailing backslash matches nothing extra
            continue
        if c == "*":
            j = i
            while j < n and p[j] == "*":
                j += 1
            if j - i >= 2 and (i == 0 or p[i - 1] == "/") and (j == n or p[j] == "/"):
                if j == n:
                    out += ".*"; i = j
                else:
                    out += "(?:.*/)?"; i = j + 1
                continue
            out += "[^/]*"; i = j; continue
        if c == "?":
            out += "[^/]"; i += 1; continue
        if c == "[":
            j = i + 1
            neg = j < n and p[j] in "!^"
            if neg:
                j += 1
            k = j + 1 if j < n and p[j] == "]" else j
            while k < n and p[k] != "]":
                if p[k] == "[" and p.startswith("[:", k):
                    end = p.find(":]", k + 2)
                    if end != -1:
                        k = end + 2; continue
                k += 2 if p[k] == "\\" else 1
            if k >= n:
                out += re.escape("["); i += 1; continue
            cls = class_to_regex(p[j:k])
            out += ("[^/" + cls + "]") if neg else ("(?!/)[" + cls + "]")
            i = k + 1; continue
        out += re.escape(c); i += 1
    return out


def parse_rules(text):
    rules, findings, seen = [], [], {}
    for no, raw in enumerate(text.split("\n"), 1):
        if raw.endswith("\r"):
            findings.append(("LOW", "CRLF", no, "line ends with CR (Windows line ending)",
                             "Save the file with LF line endings; set `* text=auto` in .gitattributes."))
            raw = raw[:-1]
        if not raw.strip() or raw.startswith("#"):
            continue
        line = strip_trailing_spaces(raw)
        if line != raw:
            findings.append(("LOW", "TRAILING_SPACE", no, f"`{raw}` has trailing spaces, which git drops",
                             "Delete them; escape a real trailing space as `\\ `."))
        if raw[0] in " \t":
            findings.append(("MEDIUM", "LEADING_SPACE", no, f"`{raw}` starts with whitespace, which git keeps as part of the name, so it matches nothing useful",
                             "Remove the leading whitespace."))
        m = re.search(r"\s#", line)
        if m:
            findings.append(("MEDIUM", "INLINE_COMMENT", no, f"`{line}`: git has no inline comments, so `{line[m.start():].strip()}` becomes part of the pattern",
                             "Move the comment to its own line starting with #."))
        if re.search(r"\\[A-Za-z0-9_]", line):
            findings.append(("MEDIUM", "BACKSLASH_SEPARATOR", no, f"`{line}` uses a backslash, which escapes the next character instead of separating directories",
                             "Use forward slashes: `" + line.replace("\\", "/") + "`."))
        key = line.strip()
        if key in seen:
            findings.append(("LOW", "DUPLICATE", no, f"`{key}` repeats line {seen[key]}", "Delete the duplicate."))
        else:
            seen[key] = no
        negate = line.startswith("!")
        body = line[1:] if negate else line
        if body.startswith("\\!") or body.startswith("\\#"):
            body = body[1:]
        if negate and not body:
            findings.append(("LOW", "EMPTY_NEGATION", no, "`!` alone does nothing", "Delete the line."))
            continue
        dir_only = body.endswith("/") and not body.endswith("\\/")
        if dir_only:
            body = body.rstrip("/")
        if not body:
            continue
        anchored = "/" in body
        if body.startswith("/"):
            body = body[1:]
        if any((m.start() > 0 and body[m.start() - 1] != "/") or (m.end() < len(body) and body[m.end()] != "/")
               for m in re.finditer(r"\*{2,}", body)):
            findings.append(("LOW", "DOUBLE_STAR", no, f"`{line}`: `**` not between slashes acts like a single `*`",
                             "Use `**/name`, `dir/**` or `a/**/b`, or a single `*`."))
        rules.append(Rule(no, line, negate, dir_only, anchored, body, re.compile(glob_to_regex(body), re.S)))
    return rules, findings


def last_match(rules, rel_path, is_dir):
    hit = None
    for r in rules:
        if r.matches(rel_path, is_dir):
            hit = r
    return hit


def check_path(rules, path):
    """Return (ignored, reason, blocked_rule) following git's parent-directory rule."""
    is_dir = path.endswith("/")
    parts = [p for p in path.strip("/").split("/") if p]
    for k in range(1, len(parts) + 1):
        sub = "/".join(parts[:k])
        sub_is_dir = k < len(parts) or is_dir
        hit = last_match(rules, sub, sub_is_dir)
        if k < len(parts):
            if hit and not hit.negate:
                own = last_match(rules, "/".join(parts), is_dir)
                blocked = own if own and own.negate else None
                return True, f"parent `{sub}/` excluded by {hit.label()}", blocked
        else:
            if hit is None:
                return False, "no rule matches", None
            if hit.negate:
                return False, f"re-included by {hit.label()}", None
            return True, f"ignored by {hit.label()}", None
    return False, "empty path", None


def literal_dirs(body):
    parts = body.split("/")[:-1]
    out = []
    for p in parts:
        if any(ch in p for ch in "*?[\\"):
            break
        out.append(p)
    return out


def lint_negations(rules):
    findings = []
    for idx, r in enumerate(rules):
        if not r.negate or not r.anchored:
            continue
        before = rules[:idx]
        dirs = literal_dirs(r.body)
        for k in range(1, len(dirs) + 1):
            sub = "/".join(dirs[:k])
            hit = last_match(before, sub, True)
            if hit and not hit.negate:
                findings.append(("HIGH", "NEGATION_BLOCKED", r.line_no,
                                 f"`{r.raw.strip()}` can never apply: its parent directory `{sub}/` is excluded by {hit.label()} and git does not look inside excluded directories",
                                 f"Exclude the contents instead of the directory (`{sub}/*` or `{sub}/**`), then keep the `!` rule; or re-include `!{sub}/` first."))
                break
    return findings


def lint_secrets(rules):
    findings = []
    missing = [p for p in SECRET_PROBES if not check_path(rules, p)[0]]
    if missing:
        findings.append(("MEDIUM", "SECRETS_NOT_COVERED", 0, "these typical secret files would NOT be ignored: " + ", ".join(missing),
                         "Add `.env`, `.env.*` (with `!.env.example`), `*.pem`, `*.key`, `id_rsa*` if the project could ever contain them."))
    if check_path(rules, ".env.example")[0]:
        findings.append(("LOW", "ENV_EXAMPLE_IGNORED", 0, "`.env.example` is ignored, so the documented template cannot be committed",
                         "Add `!.env.example` after the .env rule."))
    return findings


def explain(rule):
    body = rule.body
    kind = "directories" if rule.dir_only else "files or directories"
    verb = "Re-includes" if rule.negate else "Ignores"
    wild = any(ch in body for ch in "*?[")
    if rule.anchored and body.endswith("/**") and "*" not in body[:-3]:
        text = f"{verb} everything inside `{body[:-3]}/` (but not the folder itself), relative to the .gitignore folder"
    elif rule.anchored and body.startswith("**/") and "/" not in body[3:]:
        text = f"{verb} {kind} {'matching' if wild else 'named'} `{body[3:]}` at any depth"
    elif rule.anchored:
        text = f"{verb} {kind} {'matching' if wild else 'at'} `{body}` relative to the .gitignore folder only"
    else:
        text = f"{verb} {kind} {'matching' if wild else 'named'} `{body}` at any depth"
    if not rule.negate and not body.endswith("/**"):
        text += "; a matched directory hides everything inside it"
    if rule.negate:
        text += " (only works if no parent directory is excluded)"
    return text + "."


def read_tests(text):
    tests = []
    for no, line in enumerate(text.splitlines(), 1):
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        if s[0] not in "+-" or len(s) < 3:
            raise ValueError(f"paths file line {no}: expected '+ path' or '- path', got {line!r}")
        tests.append((s[0] == "+", s[1:].strip()))
    return tests


def review(gi_text, tests_text=None):
    rules, findings = parse_rules(gi_text)
    findings += lint_negations(rules)
    findings += lint_secrets(rules)
    anchored = [r.line_no for r in rules if r.anchored and not r.negate and "/" in r.body
                and not r.raw.startswith(("/", "**/")) and not r.body.endswith("/**")]
    if anchored:
        findings.append(("INFO", "ANCHORED_BY_SLASH", anchored[0],
                         "lines " + ", ".join(map(str, anchored)) + " contain a slash, so they only match relative to the .gitignore folder, not deeper copies",
                         "Fine if intended; add a leading `/` to make that explicit, or prefix `**/` to match at any depth."))
    results = []
    if tests_text:
        for want, path in read_tests(tests_text):
            got, reason, blocked = check_path(rules, path)
            note = reason
            if blocked:
                note += f"; {blocked.label()} cannot re-include it"
            results.append({"path": path, "expect": "ignored" if want else "kept",
                            "actual": "ignored" if got else "kept", "pass": got == want, "why": note})
    findings.sort(key=lambda f: (SEV_ORDER[f[0]], f[2]))
    return {"rules": [{"line": r.line_no, "pattern": r.raw, "meaning": explain(r)} for r in rules],
            "findings": [dict(zip(("severity", "code", "line", "problem", "fix"), f)) for f in findings],
            "tests": results}


def render(name, rep):
    out = [f"# .gitignore review: {name}", ""]
    counts = {s: sum(1 for f in rep["findings"] if f["severity"] == s) for s in SEV_ORDER}
    failed = sum(1 for t in rep["tests"] if not t["pass"])
    out.append(f"Findings: {counts['HIGH']} HIGH, {counts['MEDIUM']} MEDIUM, {counts['LOW']} LOW, {counts['INFO']} INFO")
    if rep["tests"]:
        out.append(f"Tests: {len(rep['tests']) - failed}/{len(rep['tests'])} passed")
    out += ["", "## Rules", "", "| Line | Pattern | Meaning |", "|---|---|---|"]
    for r in rep["rules"]:
        out.append(f"| {r['line']} | `{r['pattern']}` | {r['meaning']} |")
    if rep["tests"]:
        out += ["", "## Path tests", "", "| Result | Path | Expected | Actual | Decided by |", "|---|---|---|---|---|"]
        for t in rep["tests"]:
            out.append(f"| {'PASS' if t['pass'] else 'FAIL'} | `{t['path']}` | {t['expect']} | {t['actual']} | {t['why']} |")
    out += ["", "## Findings", ""]
    if rep["findings"]:
        out += ["| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in rep["findings"]:
            out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    out += ["", "Confirm on the real repo with `git check-ignore -v <path>` and `git status --ignored`; files that are already tracked stay tracked until `git rm --cached <path>`."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    R = lambda s: parse_rules(s)[0]
    ign = lambda s, p: check_path(R(s), p)[0]
    t("*.log matches at any depth", ign("*.log", "a/b/c.log") and ign("*.log", "x.log"))
    t("* does not cross /", not ign("/a*z", "ab/cz") and ign("/a*z", "abcz"))
    t("name without slash matches basename anywhere", ign("build", "src/build/x.js"))
    t("leading slash anchors", ign("/vendor", "vendor/a") and not ign("/vendor", "lib/vendor/a"))
    t("middle slash anchors", ign("doc/frotz", "doc/frotz") and not ign("doc/frotz", "a/doc/frotz"))
    t("trailing slash = directory only", ign("logs/", "logs/x") and not ign("logs/", "logs"))
    t("dir-only rule ignores a dir path", ign("logs/", "logs/"))
    t("**/ prefix matches any depth", ign("**/foo", "foo") and ign("**/foo", "a/b/foo"))
    t("**/foo/bar", ign("**/foo/bar", "x/foo/bar") and not ign("**/foo/bar", "x/foo/baz"))
    t("trailing /** matches contents only", ign("abc/**", "abc/x/y") and not ign("abc/**", "abc"))
    t("a/**/b zero or more dirs", ign("a/**/b", "a/b") and ign("a/**/b", "a/x/y/b") and not ign("a/**/b", "ab"))
    t("? matches one char not /", ign("/a?c", "abc") and not ign("/a?c", "a/c"))
    t("[abc] class", ign("file[12].txt", "file1.txt") and not ign("file[12].txt", "file3.txt"))
    t("[!x] negated class", ign("f[!0-9]", "fa") and not ign("f[!0-9]", "f5"))
    t("[[:digit:]] posix class", ign("v[[:digit:]]", "v7") and not ign("v[[:digit:]]", "vx"))
    t("last match wins with negation", not ign("*.txt\n!keep.txt", "keep.txt") and ign("*.txt\n!keep.txt", "a.txt"))
    t("order matters", ign("!keep.txt\n*.txt", "keep.txt"))
    t("parent dir excluded blocks negation", ign("logs/\n!logs/.gitkeep", "logs/.gitkeep"))
    t("dir/* allows negation", not ign("logs/*\n!logs/.gitkeep", "logs/.gitkeep"))
    t("dir/** allows negation", not ign("coverage/**\n!coverage/README.md", "coverage/README.md"))
    t("re-include dir then file", not ign("/a/*\n!/a/b/\n/a/b/*\n!/a/b/c", "a/b/c"))
    t("escaped # and !", ign("\\#notes", "#notes") and ign("\\!bang", "!bang"))
    t("comment lines skipped", not ign("# *.log", "a.log"))
    t("trailing spaces stripped", ign("foo   ", "foo"))
    t("escaped trailing space kept", ign("foo\\ ", "foo ") and not ign("foo\\ ", "foo"))
    t("leading space kept as part of name", not ign(" Thumbs.db", "Thumbs.db"))
    t("inline comment becomes pattern", not ign("secrets.yml # x", "secrets.yml"))
    t("backslash escapes, not a separator", not ign("src\\gen", "src/gen/a.ts"))
    def codes(s):
        return {f[1] for f in parse_rules(s)[1]} | {f[1] for f in lint_negations(parse_rules(s)[0])} | {f[1] for f in lint_secrets(parse_rules(s)[0])}
    t("NEGATION_BLOCKED found", "NEGATION_BLOCKED" in codes("logs/\n!logs/.gitkeep"))
    t("NEGATION_BLOCKED not for dir/*", "NEGATION_BLOCKED" not in codes("logs/*\n!logs/.gitkeep"))
    t("NEGATION_BLOCKED via dir/* deeper", "NEGATION_BLOCKED" in codes("a/*\n!a/b/c.txt"))
    t("INLINE_COMMENT found", "INLINE_COMMENT" in codes("x.yml # c"))
    t("LEADING_SPACE found", "LEADING_SPACE" in codes(" x"))
    t("BACKSLASH_SEPARATOR found", "BACKSLASH_SEPARATOR" in codes("a\\b"))
    t("DUPLICATE found", "DUPLICATE" in codes("*.log\n*.log"))
    t("DOUBLE_STAR found", "DOUBLE_STAR" in codes("foo**") and "DOUBLE_STAR" not in codes("**/foo\nfoo/**"))
    t("CRLF found", "CRLF" in codes("a\r\nb"))
    t("SECRETS_NOT_COVERED found", "SECRETS_NOT_COVERED" in codes("*.log"))
    t("secrets covered", "SECRETS_NOT_COVERED" not in codes(".env\n.env.*\n!.env.example\n*.key\n*.pem\nid_rsa*"))
    t("ENV_EXAMPLE_IGNORED found", "ENV_EXAMPLE_IGNORED" in codes(".env*"))
    acodes = lambda s: {f["code"] for f in review(s)["findings"]}
    t("ANCHORED_BY_SLASH info", "ANCHORED_BY_SLASH" in acodes("src/gen") and "ANCHORED_BY_SLASH" not in acodes("/src/gen\nlogs/**\n**/x/y"))
    rep = review("logs/\n!logs/.gitkeep\n", "+ logs/a.log\n- logs/.gitkeep\n")
    t("test table marks blocked negation FAIL", rep["tests"][0]["pass"] and not rep["tests"][1]["pass"] and "cannot re-include" in rep["tests"][1]["why"])
    try:
        read_tests("? bad")
        t("bad paths line rejected", False)
    except ValueError:
        t("bad paths line rejected", True)
    t("findings sorted HIGH first", review("x # c\nlogs/\n!logs/a\n")["findings"][0]["severity"] == "HIGH")
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("gitignore", nargs="?")
    ap.add_argument("--test", help="paths file with '+ path' (must be ignored) and '- path' (must be kept)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.gitignore:
        ap.print_usage(sys.stderr); return 2
    try:
        gi = open(a.gitignore, encoding="utf-8", newline="").read()
        tests = open(a.test, encoding="utf-8").read() if a.test else None
        rep = review(gi, tests)
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    print(json.dumps(rep, indent=2) if a.json else render(a.gitignore, rep))
    bad = any(f["severity"] == "HIGH" for f in rep["findings"]) or any(not t["pass"] for t in rep["tests"])
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
Best PracticesPythonDebugging+2
F@f
0
Công cụ Quy đổi Khẩu phần Công thức và Chuyển đổi Đơn vị
Kỹ năng

Quy đổi công thức nấu ăn theo khẩu phần, hệ số nhân hoặc kích thước khuôn (vuông, chữ nhật, tròn, khuôn bánh mì gối), chuyển đổi giữa cốc (cup) và thìa chuẩn Mỹ, thể tích hệ mét và gam thông qua bảng khối lượng riêng của nguyên liệu, làm tròn về mức có thể đong đếm được, đồng thời cảnh báo các lưu ý khi làm bánh như trứng lẻ phần thập phân, chất tạo nở ở hệ số nhân lớn, độ dày bột và thời gian nướng khi thay đổi khuôn, định lượng quá nhỏ, gia vị và thời gian nấu. Bao gồm một trình quy đổi bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: recipe-scaling-and-unit-converter
description: Quy đổi công thức nấu ăn theo khẩu phần, hệ số nhân hoặc kích thước khuôn (vuông, chữ nhật, tròn, khuôn bánh mì gối), chuyển đổi giữa cốc (cup) và thìa chuẩn Mỹ, thể tích hệ mét và gam thông qua bảng khối lượng riêng của nguyên liệu, làm tròn về mức có thể đong đếm được, đồng thời cảnh báo các lưu ý khi làm bánh như trứng lẻ phần thập phân, chất tạo nở ở hệ số nhân lớn, độ dày bột và thời gian nướng khi thay đổi khuôn, định lượng quá nhỏ, gia vị và thời gian nấu. Bao gồm một trình quy đổi bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Công cụ Quy đổi Khẩu phần Công thức và Chuyển đổi Đơn vị

Bạn giúp những người nấu ăn tại gia và thợ làm bánh tăng, giảm khối lượng công thức hoặc điều chỉnh cho vừa với một chiếc khuôn khác, bằng chính những đơn vị mà họ thực sự sử dụng. Bạn đưa ra các định lượng mà họ có thể đong đếm được, hiển thị hệ số nhân và các phép chuyển đổi đằng sau đó, đồng thời cảnh báo về những yếu tố không tăng giảm theo tỷ lệ tuyến tính: trứng, chất tạo nở, độ sâu của khuôn, thời gian nướng, gia vị và thời gian nấu.

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

- `scripts/scale_recipe.py` - đọc tệp CSV nguyên liệu, quy đổi theo khẩu phần, hệ số nhân hoặc diện tích khuôn, chuyển đổi sang đơn vị chuẩn Mỹ (US), hệ mét hoặc gam, làm tròn về các mức đong đếm trong bếp và liệt kê các lưu ý (chỉ dùng thư viện chuẩn Python 3)
- `references/conversions-and-densities.md` - thể tích và trọng lượng chuẩn Mỹ/hệ mét, số gam trên mỗi cốc (cup) cho khoảng 30 nguyên liệu phổ biến, kích cỡ trứng, nhiệt độ lò nướng kèm chế độ quạt đối lưu (fan) và mức vạch gas (gas mark), cùng diện tích các loại khuôn
- `references/baking-scaling-caveats.md` - những gì thay đổi tuyến tính, trứng, chất tạo nở, kích thước khuôn và thời gian nướng, các lưu ý khi nấu ăn (không phải làm bánh) và quy tắc làm tròn
- `templates/scaled-recipe.md` - mẫu công thức đã quy đổi để gửi lại cho người dùng
- `examples/fudgy-brownies-8x8.csv` - công thức bánh brownie cho khuôn 8x8 (16 miếng), được sử dụng trong phần ví dụ

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

### 1. Thu thập công thức và mục tiêu
Hỏi danh sách nguyên liệu kèm theo định lượng và đơn vị, sản lượng (yield) hoặc khuôn gốc, và mục tiêu cần đạt: số khẩu phần, hệ số nhân, hoặc một chiếc khuôn khác. Hỏi xem đây có phải là công thức làm bánh hay không, họ muốn dùng đơn vị nào (cup, gam, hay giữ nguyên như bản gốc), và công thức có nguồn gốc từ đâu nếu kích cỡ cốc (cup), thìa canh (tablespoon) hoặc trứng có thể khác biệt (Mỹ, Anh, Úc). Đưa các nguyên liệu vào tệp CSV với các cột `quantity,unit,ingredient,note`.

### 2. Chạy trình quy đổi (scaler)
```
python3 scripts/scale_recipe.py examples/fudgy-brownies-8x8.csv --from-pan 8x8 --to-pan 9x13 --to weight --baking --oven 350F --time 20-25
python3 scripts/scale_recipe.py stew.csv --servings 4 10 --to metric
python3 scripts/scale_recipe.py cake.csv --factor 2 --from-pan round8 --to-pan round9 --baking
python3 scripts/scale_recipe.py --self-test
```
Các loại khuôn: `8x8`, `9x13`, `20x30cm`, `round9`, `round23cm`, `loaf9x5`. Nếu chỉ truyền thông số khuôn, hệ số nhân sẽ là tỷ lệ diện tích giữa hai khuôn. Nếu truyền hệ số nhân hoặc khẩu phần kèm theo thông số khuôn, kịch bản sẽ kiểm tra xem độ dày của lớp bột (batter depth) thay đổi như thế nào. Mã thoát (Exit) 1 có nghĩa là có lưu ý mức HIGH (độ dày bột thay đổi hơn 1.5 lần), mã thoát 2 có nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Kiểm tra và điều chỉnh
- Đọc mọi lưu ý (caveat) và chuyển chúng thành hướng dẫn cụ thể (xem `references/baking-scaling-caveats.md`): cân phần trứng lẻ, nướng thành nhiều mẻ, đổi khuôn, hạ nhiệt độ lò, nếm thử trước khi cho toàn bộ lượng muối.
- Đối với các nguyên liệu không có dữ liệu khối lượng riêng (NO_DENSITY), hãy giữ nguyên đơn vị thể tích hoặc tra cứu trọng lượng của chúng; tuyệt đối không đoán mò đối với các nhãn hiệu muối hoặc chất tạo nở.
- Kiểm tra tính hợp lý của kết quả: khuôn có chứa vừa lượng bột không (đầy tối đa 2/3 khuôn đối với bánh bông lan/cake)? Việc làm tròn có làm lệch một nguyên liệu quan trọng quá 5 % hay không? Nếu có, hãy cung cấp định lượng bằng gam.

### 4. Bàn giao kết quả
Điền vào mẫu `templates/scaled-recipe.md`: hệ số nhân, sản lượng (yield), khuôn, nhiệt độ lò tính bằng °F và °C (có quạt đối lưu), nguyên liệu theo đơn vị của người nấu kèm đơn vị thay thế trong ngoặc vuông, các thay đổi trong cách làm, các lưu ý và cách kiểm tra độ chín thay vì một mốc thời gian cố định.

## Kết quả ví dụ
Tệp `examples/fudgy-brownies-8x8.csv` được chuyển từ khuôn 8x8 sang khuôn 9x13: hệ số nhân x1.83 (từ 64 lên 117 inch vuông), độ dày lớp bột giữ nguyên, vì vậy nướng ở nhiệt độ 350°F / 180°C (quạt đối lưu 160°C) và bắt đầu kiểm tra ở phút thứ 20. Tính theo gam: bơ 205 g, đường 365 g, bột cacao 52 g, bột mì 115 g, sô-cô-la chip 235 g, muối 2.5 g, bột nở (baking powder) 2 g, vani 7.5 g. Trứng được nhân lên thành 3.66 quả (mức MEDIUM EGG_FRACTION): sử dụng 3 quả trứng cộng với 33 g trứng đánh tan. Việc nhân đôi công thức nhưng lại nướng trong khuôn tròn 9 inch (có diện tích xấp xỉ khuôn 8x8) sẽ làm tăng gấp đôi độ dày lớp bột (mức HIGH DEPTH_CHANGE, mã thoát 1).

## Quy tắc

- Hiển thị hệ số nhân và cách bạn tính ra nó (tỷ lệ khẩu phần hoặc diện tích khuôn) trước khi liệt kê định lượng nguyên liệu.
- Tuyệt đối không nhân thời gian nướng hoặc thời gian nấu với hệ số nhân; hãy đưa ra cách kiểm tra độ chín và thời điểm bắt đầu kiểm tra sớm.
- Giữ nguyên tỷ lệ muối và chất tạo nở trong làm bánh; trong nấu ăn thông thường, chỉ thêm khoảng ba phần tư lượng gia vị mạnh đã quy đổi rồi nếm thử.
- Ưu tiên đơn vị gam khi làm bánh và nói rõ rằng khối lượng riêng chỉ là mức trung bình; hãy tin dùng số gam của tác giả công thức khi có sẵn.
- Hãy hỏi lại trước khi mặc định sử dụng cốc (cup) chuẩn Mỹ hoặc cỡ trứng chuẩn Mỹ cho các công thức đến từ những quốc gia khác.
- Nhắc đến vấn đề an toàn thực phẩm đối với các mẻ nấu lớn (làm nguội nhanh trong các khay nông) khi tăng khẩu phần các món nấu chín lên nhiều lần.
FILE:references/conversions-and-densities.md
# Bảng chuyển đổi và khối lượng riêng

## Thể tích (Hệ đo lường thông dụng của Mỹ - US customary)
| Đơn vị | ml | Tương đương |
|---|---|---|
| 1 tsp (thìa cà phê) | 4.93 | 1/3 tbsp |
| 1 tbsp (thìa canh) | 14.79 | 3 tsp |
| 1 fl oz (ounce lỏng) | 29.57 | 2 tbsp |
| 1 cup (cốc) | 236.59 | 16 tbsp, 8 fl oz |
| 1 pint | 473.18 | 2 cups |
| 1 quart | 946.35 | 4 cups |

Các loại cốc (cup) khác: cốc hệ mét (các công thức của Úc, New Zealand, Canada) = 250 ml; pint hệ Anh (UK imperial pint) = 568 ml; thìa canh của Úc = 20 ml (4 tsp). Hãy hỏi xuất xứ của công thức khi kích thước cốc hoặc thìa canh đóng vai trò quan trọng (làm bánh, muối, chất tạo nở).

## Trọng lượng
| Đơn vị | g |
|---|---|
| 1 oz | 28.35 |
| 1 lb | 453.6 (16 oz) |
| 1 thanh bơ (1 stick butter - Mỹ) | 113 (1/2 cup, 8 tbsp) |

Đơn vị "oz" trong công thức là trọng lượng trừ khi được ghi rõ là "fl oz". Đối với nước và sữa, 8 fl oz nặng khoảng 8 oz; đối với bột mì, 8 fl oz nặng khoảng 4.4 oz.

## Số gam trên mỗi cốc chuẩn Mỹ (đong bằng cách xúc thìa và gạt ngang, được sử dụng bởi kịch bản)
| Nguyên liệu | g/cup | g/tbsp | g/tsp |
|---|---|---|---|
| Bột mì đa dụng (All-purpose / plain flour) | 125 | 7.8 | 2.6 |
| Bột bánh mì (Bread flour) | 127 | 7.9 | |
| Bột làm bánh ngọt (Cake flour) | 114 | 7.1 | |
| Bột mì nguyên cám (Whole wheat flour) | 113 | 7.1 | |
| Bột hạnh nhân (Almond flour) | 96 | 6 | |
| Tinh bột ngô (Cornstarch / cornflour) | 112 | 7 | |
| Đường cát trắng hoặc đường tinh luyện mịn (Granulated / caster sugar) | 200 | 12.5 | 4.2 |
| Đường nâu, nén chặt (Brown sugar, packed) | 213 | 13.3 | |
| Đường bột (Powdered / icing sugar) | 120 | 7.5 | |
| Bơ (Butter) | 227 | 14.2 | |
| Nước (Water) | 237 | 14.8 | 4.9 |
| Sữa tươi, buttermilk, kem chua (Milk, buttermilk, sour cream) | 242 | 15.1 | |
| Kem béo (Heavy cream) | 238 | 14.9 | |
| Sữa chua (Yogurt) | 245 | 15.3 | |
| Dầu thực vật hoặc dầu hạt cải (Vegetable / canola oil) | 218 | 13.6 | |
| Dầu ô liu (Olive oil) | 216 | 13.5 | |
| Mật ong, si-rô vàng (Honey, golden syrup) | 340 | 21 | 7 |
| Si-rô cây phong (Maple syrup) | 322 | 20 | |
| Bột cacao (Cocoa powder) | 85 | 5.3 | |
| Yến mạch cán dẹt (Rolled oats) | 90 | | |
| Sô-cô-la chip (Chocolate chips) | 170 | | |
| Gạo trắng sống (Raw white rice) | 185 | | |
| Các loại hạt băm nhỏ / hạt óc chó (Chopped nuts / walnuts) | 120 | | |
| Hạt hồ đào (Pecans) | 110 | | |
| Nho khô (Raisins) | 150 | | |
| Bơ đậu phộng (Peanut butter) | 270 | 17 | |
| Muối ăn / muối hạt mịn (Table / fine salt) | 288 | 18 | 6 |
| Muối nở (Baking soda) | 230 | | 4.8 |
| Bột nở (Baking powder) | 192 | | 4 |
| Men nở lấy liền hoặc men khô kích hoạt (Instant / active dry yeast) | 149 | | 3.1 |
| Tinh chất vani (Vanilla extract) | 202 | | 4.2 |
| Bột quế (Ground cinnamon) | 125 | | 2.6 |

Lưu ý:
- Bột mì múc trực tiếp từ túi bằng cốc đong có thể nặng từ 140 đến 150 g mỗi cốc. Nếu tác giả công thức cung cấp số gam, hãy tin dùng số gam đó.
- Muối Kosher phụ thuộc vào thương hiệu: Diamond Crystal nặng khoảng 2.8 g/tsp, Morton khoảng 5 g/tsp, muối ăn thông thường khoảng 6 g/tsp. Việc đổi thương hiệu muối nhưng đong theo thể tích có thể làm tăng gấp đôi lượng muối. Kịch bản sẽ giữ nguyên "kosher salt" ở đơn vị thể tích và đưa ra thông báo rõ ràng.
- Bảng trên là mức trung bình thực tế; các bảng công bố khác nhau có thể chênh lệch khoảng 5 %. Mức này hoàn toàn ổn đối với việc nấu ăn và hầu hết các món bánh; riêng đối với bánh macaron, bánh mì và kẹo, hãy dùng cân.

## Trứng
| Kích cỡ (Mỹ) | Trọng lượng bỏ vỏ |
|---|---|
| Cỡ lớn (Large) | 50 g (lòng đỏ 18 g, lòng trắng 32 g), khoảng 3 tbsp sau khi đánh tan |
| Cỡ vừa (Medium) | 44 g |
| Cỡ rất lớn (Extra large) | 56 g |
| Cỡ đại (Jumbo) | 63 g |
Trứng cỡ "large" của Anh/Châu Âu (63 đến 73 g tính cả vỏ) nặng khoảng 58 g khi bỏ vỏ: một quả trứng cỡ large của Anh gần với cỡ extra large của Mỹ hơn.

## Nhiệt độ lò nướng
| °F | °C | °C có quạt (Fan) | Mức Gas (Gas mark) |
|---|---|---|---|
| 300 | 150 | 130 | 2 |
| 325 | 160 | 140 | 3 |
| 350 | 180 | 160 | 4 |
| 375 | 190 | 170 | 5 |
| 400 | 200 | 180 | 6 |
| 425 | 220 | 200 | 7 |
| 450 | 230 | 210 | 8 |
Lò nướng có quạt (đối lưu): hạ thấp khoảng 20 °C / 25 °F, hoặc giữ nguyên nhiệt độ và kiểm tra bánh sớm hơn.

## Diện tích khuôn
| Khuôn | Diện tích (inch vuông - sq in) | Diện tích (cm²) |
|---|---|---|
| Vuông 8x8 | 64 | 413 |
| Vuông 9x9 | 81 | 523 |
| Chữ nhật 9x13 | 117 | 755 |
| Khay bánh cuộn (jelly roll) 10x15 | 150 | 968 |
| Tròn 8 in | 50 | 324 |
| Tròn 9 in | 64 | 410 |
| Tròn 10 in | 79 | 507 |
| Khuôn bánh mì gối (loaf) 9x5 | 45 | 290 |
| Tròn 20 cm | 49 | 314 |
| Tròn 23 cm | 64 | 415 |
| Vuông 20x20 cm | 62 | 400 |
Diện tích khuôn tròn = 3.14 x bán kính². Một chiếc khuôn vuông 8x8 và một chiếc khuôn tròn 9 in chứa lượng bột gần như tương đương nhau.
FILE:references/baking-scaling-caveats.md
# Các lưu ý khi quy đổi công thức

## Những gì thay đổi theo tỷ lệ tuyến tính
Bột mì, đường, chất béo, chất lỏng, sô-cô-la, các loại hạt, trái cây và trứng (tính theo trọng lượng) thay đổi tuyến tính theo hệ số nhân. Trong làm bánh, muối và chất tạo nở cũng bắt đầu ở mức định lượng tuyến tính: phản ứng hóa học phụ thuộc vào tỷ lệ so với bột mì, vì vậy hãy giữ nguyên tỷ lệ đó.

## Trứng
- Phần lẻ của trứng: đánh tan trứng, đem cân và lấy đúng số gam cần thiết (trứng cỡ lớn = 50 g khi bỏ vỏ). 3.66 quả trứng = 3 quả trứng + 33 g trứng đánh tan (khoảng 2 tbsp).
- Nếu bạn chọn cách làm tròn: nhiều trứng hơn sẽ làm bánh bông lan (cake) và bánh brownie cứng cáp hơn, xốp giống bánh bông lan hơn hoặc béo ngậy hơn; ít trứng hơn sẽ làm bánh đặc hơn và dễ vỡ hơn. Khi phân vân, hãy làm tròn lên đối với bánh bông lan, và làm tròn xuống đối với bánh brownie ẩm đặc (fudgy brownies) và bánh quy.
- Các công thức được viết ở quốc gia khác có thể sử dụng kích cỡ trứng khác (xem bảng chuyển đổi).

## Chất tạo nở (bột nở, muối nở, men nở)
- Bắt đầu theo tỷ lệ tuyến tính và đong đếm thật chính xác; ở các hệ số nhân nhỏ, sai số 1/8 tsp là rất lớn, vì vậy hãy cân trên cân tiểu ly có độ chia 0.1 g.
- Đối với các hệ số nhân lớn hơn 2, quá trình trộn bột và nhiệt lượng lò nướng không tăng theo tỷ lệ tuyến tính: khối bột lớn khó trộn đều hơn và phần tâm bánh nóng lên chậm hơn. Hãy chia thành các mẻ riêng biệt theo kích thước gốc bất cứ khi nào có thể.
- Nếu lớp bột cuối cùng dày hơn so với bản gốc (cùng một khuôn nhưng nhiều bột hơn), phần tâm bánh sẽ nở chậm hơn. Đừng "chữa cháy" việc này bằng cách cho thêm chất tạo nở; hãy dùng khuôn rộng hơn hoặc hạ nhiệt độ lò và nướng lâu hơn.
- Bánh mì: men nở thay đổi tuyến tính theo lượng bột mì; thời gian ủ nở phụ thuộc vào nhiệt độ khối bột chứ không phụ thuộc vào khối lượng bột.

## Kích thước khuôn và thời gian nướng
- Quy đổi theo diện tích khuôn giúp giữ nguyên độ dày của lớp bột, do đó thời gian nướng gần như không đổi. Hãy bắt đầu kiểm tra sớm hơn 5 phút.
- Cùng một lượng bột công thức nhưng cho vào khuôn khác: tỷ lệ độ dày = diện tích khuôn cũ / diện tích khuôn mới. Lớp bột dày hơn cần thời gian nướng lâu hơn ở nhiệt độ thấp hơn (thấp hơn khoảng 25 °F / 15 °C); lớp bột mỏng hơn sẽ chín nhanh hơn.
- Chỉ đổ bột vào khuôn bánh bông lan tối đa 2/3 khuôn, và khoảng một nửa khuôn đối với bánh brownie.
- Khuôn thủy tinh và khuôn kim loại tối màu làm chín phần rìa bánh nhanh hơn: hãy hạ nhiệt độ lò xuống 25 °F / 15 °C.
- Làm bánh cupcake từ công thức bánh bông lan: nướng từ 18 đến 22 phút ở cùng mức nhiệt độ; một công thức bánh khuôn 9x13 làm được khoảng 24 chiếc bánh cupcake.
- Tuyệt đối không nhân thời gian nướng với hệ số quy đổi. Hãy sử dụng phương pháp kiểm tra độ chín từ công thức gốc (cắm tăm rút ra còn dính vụn ẩm đối với bánh brownie, rút tăm sạch khô đối với bánh bông lan, nhiệt độ bên trong đạt 93 đến 99 °C / 200 đến 210 °F đối với bánh mì).

## Nấu ăn thông thường (không phải làm bánh)
- Gia vị: chỉ cho khoảng ba phần tư lượng muối, ớt và các loại gia vị mạnh đã nhân theo hệ số, sau đó nếm thử. Một chiếc nồi lớn hơn sẽ có tốc độ bay hơi khác đi.
- Chất lỏng trong các món hầm và súp: sự bay hơi phụ thuộc vào diện tích bề mặt của nồi chứ không phải thể tích, vì vậy một món hầm được nhân đôi nấu trong nồi rộng hơn có thể cần lượng chất lỏng bổ sung ít hơn mức gấp đôi (2x).
- Áp chảo và chiên rán: hãy chia thành từng mẻ; chảo quá chật sẽ làm thức ăn bị hấp chín bằng hơi nước thay vì vàng xém.
- Thời gian: một nồi lớn gấp đôi sẽ mất nhiều thời gian hơn để đun sôi và làm nguội, nhưng thời gian ninh nhỏ lửa (simmering) khi đã sôi thì gần như tương đương. Các món thịt quay/nướng tảng phụ thuộc vào độ dày của miếng thịt chứ không phải tổng trọng lượng: hãy dùng nhiệt kế đo thịt.
- An toàn thực phẩm: làm nguội nhanh các mẻ thức ăn lớn (chia nhỏ ra các khay nông) và hâm nóng lại ở nhiệt độ tối thiểu 74 °C / 165 °F.

## Quy tắc làm tròn cho căn bếp thực tế
- Đơn vị đo lường Mỹ (US measures): cốc (cup) làm tròn theo nấc 1/4 và 1/3, phần dư chuyển sang thìa canh (tbsp) làm tròn theo nấc một nửa (1/2), và thìa cà phê (tsp) làm tròn xuống tối thiểu 1/8. Nhỏ hơn 1/8 tsp = một nhúm (a pinch).
- Hệ mét: làm tròn đến 0.5 g đối với lượng dưới 10 g, 1 g đối với lượng dưới 100 g, và 5 g đối với lượng dưới 1 kg.
- Giữ nguyên hệ thống đơn vị gốc trừ khi người nấu yêu cầu đổi; luôn đề xuất đơn vị gam cho các món bánh.
- Kiểm tra lại kết quả sau khi làm tròn so với tỷ lệ gốc (ví dụ tỷ lệ giữa bột mì và bơ); nếu việc làm tròn làm dịch chuyển một nguyên liệu quan trọng quá 5 %, hãy cung cấp số gam chính xác.
FILE:templates/scaled-recipe.md
# <Tên công thức>, đã quy đổi <từ khuôn 8x8 sang khuôn 9x13 / từ 4 lên 10 khẩu phần>

**Hệ số nhân:** x<1.83>   **Sản lượng:** <24 miếng brownie / 10 khẩu phần>   **Đơn vị:** <gam kèm đơn vị đo lường Mỹ trong ngoặc>
**Khuôn:** <9x13 in (23x33 cm), có lót giấy nến>   **Lò nướng:** <350°F / 180°C (quạt đối lưu 160°C)>   **Thời gian nướng:** <bắt đầu kiểm tra ở phút 20; thường từ 20-25 phút>

## Nguyên liệu
| Nguyên liệu | Định lượng | Đơn vị đo lường Mỹ |
|---|---|---|
| <bơ lạt, đun chảy> | <205 g> | <3/4 cup + 2 1/2 tbsp> |
| <trứng gà cỡ lớn (large)> | <3 quả + 33 g đánh tan (khoảng 2 tbsp)> | |

## Thay đổi trong cách làm so với bản gốc
- <Đánh tan 4 quả trứng, cân lấy 183 g, giữ lại phần thừa để phết mặt bánh (egg wash)>
- <Trộn trong âu lớn hơn; chia bột mì thành hai lần để trộn (fold) vào hỗn hợp>
- <Giữ nguyên thời gian nướng vì độ dày lớp bột không đổi; kiểm tra ở phút thứ 20>

## Các lưu ý (Caveats)
- <EGG_FRACTION: ...>
- <Thương hiệu muối Kosher: ...>

## Kiểm tra độ chín
<Cắm tăm cách tâm bánh 2 cm, khi rút ra thấy có vụn bánh ẩm bám vào chứ không phải bột ướt.>
FILE:examples/fudgy-brownies-8x8.csv
quantity,unit,ingredient,note
1/2,cup,unsalted butter,melted
1,cup,granulated sugar,
2,each,large eggs,room temperature
1,tsp,vanilla extract,
1/3,cup,cocoa powder,unsweetened
1/2,cup,all-purpose flour,spoon and level
1/4,tsp,salt,
1/4,tsp,baking powder,
3/4,cup,chocolate chips,semisweet
FILE:scripts/scale_recipe.py
#!/usr/bin/env python3
"""Recipe scaling and unit converter (Python 3 standard library only).

Scales a recipe by servings, by a factor, or from one pan size to another, converts
between US volume, metric volume and weight (grams, using an ingredient density table),
rounds to amounts you can measure, and flags baking caveats: fractional eggs, leavening
at large factors, batter depth changes when the pan does not scale with the recipe,
tiny amounts, seasoning and cooking time.

Input CSV columns: quantity,unit,ingredient,note
  quantity: 2 | 1.5 | 3/4 | 1 1/2 | ½   unit: tsp tbsp cup fl oz pint quart ml l g kg oz lb stick
  each (or empty) for counted items such as eggs.
Usage:
  python3 scale_recipe.py recipe.csv --servings 4 10 [--to us|metric|weight|keep]
  python3 scale_recipe.py recipe.csv --from-pan 8x8 --to-pan 9x13 --baking --oven 350F --time 20-25
  python3 scale_recipe.py recipe.csv --factor 0.5 --to weight --json
  python3 scale_recipe.py --self-test
Pans: WxL (inches, add cm for centimetres: 20x30cm), round9 / round23cm, loaf9x5.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, csv, io, json, math, re, sys

ML = {"tsp": 4.92892, "tbsp": 14.7868, "cup": 236.588, "fl oz": 29.5735, "pint": 473.176,
      "quart": 946.353, "ml": 1.0, "l": 1000.0}
G = {"g": 1.0, "kg": 1000.0, "oz": 28.3495, "lb": 453.592, "stick": 113.4}
UNIT_ALIASES = {"teaspoon": "tsp", "teaspoons": "tsp", "t": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
                "tbs": "tbsp", "tbl": "tbsp", "T": "tbsp", "cups": "cup", "c": "cup", "floz": "fl oz",
                "fl. oz": "fl oz", "fluid ounce": "fl oz", "fluid ounces": "fl oz", "pints": "pint", "pt": "pint",
                "quarts": "quart", "qt": "quart", "milliliter": "ml", "milliliters": "ml", "mL": "ml",
                "liter": "l", "liters": "l", "litre": "l", "litres": "l", "L": "l", "gram": "g", "grams": "g",
                "kilogram": "kg", "kilograms": "kg", "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb",
                "lbs": "lb", "sticks": "stick", "": "each", "x": "each", "whole": "each", "pc": "each",
                "pcs": "each", "piece": "each", "pieces": "each", "clove": "each", "cloves": "each",
                "large": "each", "medium": "each", "small": "each", "pinch": "pinch", "pinches": "pinch",
                "dash": "pinch"}
# grams per US cup (spoon-and-level). Longest matching name wins.
DENSITY = {
    "all-purpose flour": 125, "plain flour": 125, "flour": 125, "bread flour": 127, "cake flour": 114,
    "whole wheat flour": 113, "almond flour": 96, "cornstarch": 112, "cornflour": 112,
    "granulated sugar": 200, "caster sugar": 200, "sugar": 200, "brown sugar": 213,
    "powdered sugar": 120, "icing sugar": 120, "confectioners sugar": 120,
    "butter": 227, "water": 237, "milk": 242, "buttermilk": 242, "heavy cream": 238, "cream": 238,
    "sour cream": 242, "yogurt": 245, "vegetable oil": 218, "canola oil": 218, "olive oil": 216, "oil": 218,
    "honey": 340, "maple syrup": 322, "golden syrup": 340, "cocoa powder": 85, "cocoa": 85,
    "rolled oats": 90, "oats": 90, "chocolate chips": 170, "rice": 185, "chopped nuts": 120,
    "walnuts": 120, "pecans": 110, "raisins": 150, "peanut butter": 270,
    "table salt": 288, "fine salt": 288, "salt": 288, "kosher salt": None,
    "baking soda": 230, "bicarbonate of soda": 230, "baking powder": 192, "instant yeast": 149,
    "active dry yeast": 149, "yeast": 149, "vanilla extract": 202, "vanilla": 202,
    "ground cinnamon": 125, "cinnamon": 125,
}
LEAVENERS = ("baking soda", "bicarbonate", "baking powder", "yeast", "cream of tartar")
SEASONINGS = ("salt", "pepper", "chili", "chilli", "cayenne", "pepper flakes", "garlic powder", "curry", "cumin", "paprika")
EGG_G = {"egg": 50.0, "yolk": 18.0, "white": 32.0}  # US large egg out of shell
UNICODE_FRAC = {"½": .5, "¼": .25, "¾": .75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": .125, "⅜": .375, "⅝": .625, "⅞": .875}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}


def parse_qty(s):
    s = s.strip()
    for ch, v in UNICODE_FRAC.items():
        if ch in s:
            rest = s.replace(ch, "").strip()
            return (float(rest) if rest else 0.0) + v
    m = re.fullmatch(r"(\d+)\s+(\d+)/(\d+)", s)
    if m:
        return int(m[1]) + int(m[2]) / int(m[3])
    m = re.fullmatch(r"(\d+)/(\d+)", s)
    if m:
        return int(m[1]) / int(m[2])
    m = re.fullmatch(r"\d+(?:\.\d+)?|\.\d+", s)
    if m:
        return float(s)
    raise ValueError(f"cannot read quantity {s!r}")


def norm_unit(u):
    u = u.strip()
    if u in UNIT_ALIASES:
        return UNIT_ALIASES[u]
    low = u.lower().rstrip(".")
    if low in ML or low in G or low in ("each", "pinch"):
        return low
    if low in UNIT_ALIASES:
        return UNIT_ALIASES[low]
    raise ValueError(f"unknown unit {u!r}")


def density_for(name):
    low = name.lower()
    best = None
    for key in DENSITY:
        if re.search(r"\b" + re.escape(key) + r"\b", low) and (best is None or len(key) > len(best)):
            best = key
    return (best, DENSITY[best]) if best else (None, None)


def egg_kind(name):
    low = name.lower()
    if "yolk" in low:
        return "yolk"
    if "white" in low and "egg" in low:
        return "white"
    if re.search(r"\beggs?\b", low):
        return "egg"
    return None


def parse_pan(spec):
    s = spec.strip().lower().replace(" ", "").replace("inch", "").replace("in", "").replace('"', "")
    cm = s.endswith("cm")
    if cm:
        s = s[:-2]
    k = 1 / 2.54 if cm else 1.0
    m = re.fullmatch(r"(?:round)?(\d+(?:\.\d+)?)(?:round)?", s)
    if m and "round" in s:
        d = float(m[1]) * k
        return math.pi * (d / 2) ** 2, f"{spec} round ({d:.1f} in)"
    m = re.fullmatch(r"(?:loaf|square|rect)?(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", s)
    if m:
        a, b = float(m[1]) * k, float(m[2]) * k
        return a * b, f"{spec} ({a:.1f} x {b:.1f} in)"
    raise ValueError(f"cannot read pan size {spec!r} (use 9x13, 20x30cm, round9, round23cm, loaf9x5)")


def frac_str(x, denoms=(2, 3, 4, 8)):
    whole = int(x)
    rest = x - whole
    best = (abs(rest), 0, 1)
    for d in denoms:
        n = round(rest * d)
        if 0 < n < d and abs(rest - n / d) < best[0] - 1e-9:
            best = (abs(rest - n / d), n, d)
    if abs(rest - 1) < best[0]:
        return str(whole + 1)
    _, n, d = best
    if n == 0:
        return str(whole)
    g = math.gcd(n, d)
    return (f"{whole} " if whole else "") + f"{n // g}/{d // g}"


def frac_val(s):
    return parse_qty(s)


def cup_word(x):
    return "cups" if x > 1 + 1e-9 else "cup"


def fmt_us_volume(ml):
    cups = ml / ML["cup"]
    if cups >= 0.25 - 1e-9:
        s = frac_str(cups, (2, 3, 4))
        err = abs(frac_val(s) - cups) * ML["cup"]
        if err >= ML["tbsp"] * 0.75:
            base = math.floor(cups * 4) / 4
            tb = (cups - base) * ML["cup"] / ML["tbsp"]
            bs = frac_str(base, (4,))
            return (f"{bs} {cup_word(base)} + " if base else "") + f"{frac_str(tb, (2,))} tbsp"
        return f"{s} {cup_word(frac_val(s))}"
    tbsp = ml / ML["tbsp"]
    if tbsp >= 1 - 1e-9:
        return f"{frac_str(tbsp, (2,))} tbsp"
    tsp = ml / ML["tsp"]
    if tsp < 1 / 16:
        return "pinch"
    return f"{frac_str(tsp, (2, 4, 8))} tsp"


def round_metric(x):
    if x < 10:
        return round(x * 2) / 2
    if x < 100:
        return round(x)
    if x < 1000:
        return round(x / 5) * 5
    return round(x / 10) * 10


def fmt_num(x):
    return f"{x:g}"


def fmt_metric(amount, unit):
    v = round_metric(amount)
    if v >= 2000:
        return f"{fmt_num(v / 1000)} {'kg' if unit == 'g' else 'l'}"
    return f"{fmt_num(v)} {unit}"


def fmt_us_weight(g):
    oz = g / G["oz"]
    if oz >= 16:
        lb = oz / 16
        return f"{frac_str(lb, (4,))} lb"
    return f"{frac_str(oz, (2, 4))} oz" if oz >= 1 else f"{fmt_num(round(oz, 2))} oz"


def convert(row, factor, target):
    """Return dict with scaled display and notes for one ingredient row."""
    qty, unit, name = row["qty"] * factor, row["unit"], row["ingredient"]
    key, dens = density_for(name)
    egg = egg_kind(name)
    out = {"ingredient": name, "original": row["original"], "scaled_qty": qty, "unit": unit, "notes": []}
    if unit == "each":
        if egg:
            g = qty * EGG_G[egg]
            out["display"] = f"{fmt_num(round(qty, 2))} ({fmt_metric(g, 'g')} beaten)"
        else:
            out["display"] = fmt_num(round(qty, 2))
        return out
    if unit == "pinch":
        out["display"] = f"{fmt_num(round(qty, 2))} pinch"
        return out
    if unit in ML:
        ml = qty * ML[unit]
        out["ml"] = ml
        if target == "weight":
            if dens:
                out["display"] = fmt_metric(ml / ML["cup"] * dens, "g")
                out["also"] = fmt_us_volume(ml)
            else:
                out["display"] = fmt_metric(ml, "ml")
                out["also"] = fmt_us_volume(ml)
                out["notes"].append("NO_DENSITY")
        elif target == "metric" or (target == "keep" and unit in ("ml", "l")):
            out["display"] = fmt_metric(ml, "ml")
            out["also"] = fmt_us_volume(ml)
        else:
            out["display"] = fmt_us_volume(ml)
            out["also"] = fmt_metric(ml, "ml") if not dens else fmt_metric(ml / ML["cup"] * dens, "g")
        return out
    g = qty * G[unit]
    out["g"] = g
    if target == "us" or (target == "keep" and unit in ("oz", "lb", "stick")):
        if unit == "stick" and target == "keep":
            out["display"] = f"{frac_str(qty, (2, 4))} stick ({fmt_metric(g, 'g')})"
        else:
            out["display"] = fmt_us_weight(g)
        out["also"] = fmt_metric(g, "g")
    else:
        out["display"] = fmt_metric(g, "g")
        if dens:
            out["also"] = fmt_us_volume(g / dens * ML["cup"])
    return out


def read_recipe(text):
    rows = []
    reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
    need = {"quantity", "unit", "ingredient"}
    if not reader.fieldnames or not need <= {f.strip().lower() for f in reader.fieldnames}:
        raise ValueError("CSV header must include quantity,unit,ingredient (note optional)")
    for i, r in enumerate(reader, 2):
        r = {k.strip().lower(): (v or "").strip() for k, v in r.items() if k}
        if not r.get("ingredient"):
            continue
        try:
            qty = parse_qty(r["quantity"])
            unit = norm_unit(r.get("unit", ""))
        except ValueError as e:
            raise ValueError(f"line {i}: {e}")
        orig = f"{r['quantity']} {r.get('unit', '')}".strip()
        rows.append({"qty": qty, "unit": unit, "ingredient": r["ingredient"], "note": r.get("note", ""), "original": orig})
    if not rows:
        raise ValueError("no ingredient rows")
    return rows


def oven(temp):
    m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*°?\s*([CcFf])\s*", temp)
    if not m:
        raise ValueError(f"cannot read oven temperature {temp!r} (use 350F or 180C)")
    v, u = float(m[1]), m[2].upper()
    if u == "F":
        c = (v - 32) * 5 / 9
        return f"{v:g}°F = {round(c / 10) * 10:g}°C (fan oven {round(c / 10) * 10 - 20:g}°C)"
    f = v * 9 / 5 + 32
    return f"{v:g}°C = {round(f / 25) * 25:g}°F"


def scale(rows, factor, target="keep", baking=False, depth_ratio=None, pan_change=None, bake_time=None):
    items = [convert(r, factor, target) for r in rows]
    findings = []
    for it, r in zip(items, rows):
        name = r["ingredient"].lower()
        egg = egg_kind(name)
        if egg and r["unit"] == "each":
            q = it["scaled_qty"]
            whole = math.floor(q + 1e-9)
            if abs(q - round(q)) > 0.15:
                extra = (q - whole) * EGG_G[egg]
                findings.append(("MEDIUM", "EGG_FRACTION", r["ingredient"],
                                 f"scales to {q:.2f} {egg}s",
                                 f"Use {whole} whole {egg}{'s' if whole != 1 else ''} plus {round(extra):g} g beaten {egg} (a large {egg} is about {EGG_G[egg]:g} g out of the shell), or round to {round(q)} and expect a {'richer' if round(q) > q else 'drier'} result."))
        if "NO_DENSITY" in it["notes"]:
            findings.append(("LOW", "NO_DENSITY", r["ingredient"], "no density in the table, so it stays in volume",
                             "Weigh it once, or look up grams per cup; for kosher salt the brand matters (Diamond Crystal about 136 g/cup, Morton about 240 g/cup)."))
        ml = it.get("ml")
        if ml is not None and ml / ML["tsp"] < 1 / 8 and r["unit"] in ML:
            findings.append(("LOW", "TINY_AMOUNT", r["ingredient"], f"scales to {ml / ML['tsp']:.2f} tsp",
                             "Use a pinch or a 1/8 tsp measure; for leavening weigh on a 0.1 g scale."))
        if baking and any(w in name for w in LEAVENERS) and (factor > 2 or factor < 0.5):
            findings.append(("LOW", "LEAVENING", r["ingredient"], f"leavening scaled x{factor:.2f}",
                             "Linear scaling is the starting point; measure exactly (weigh), and for factors above 2 consider separate batches because mixing and oven heat do not scale."))
        if not baking and factor > 2 and any(w in name for w in SEASONINGS):
            findings.append(("INFO", "SEASONING", r["ingredient"], f"seasoning scaled x{factor:.2f}",
                             "Add about three quarters, then taste and adjust; evaporation and pot size change how salty or hot it ends up."))
    if baking and (factor > 3 or factor < 1 / 3):
        findings.append(("MEDIUM", "BIG_FACTOR", "-", f"baking recipe scaled x{factor:.2f}",
                         "Bake in several batches of the original size, or check mixer bowl capacity; never multiply the bake time."))
    if depth_ratio is not None:
        sev = "HIGH" if (depth_ratio > 1.5 or depth_ratio < 0.67) else ("MEDIUM" if (depth_ratio > 1.25 or depth_ratio < 0.8) else None)
        if sev and baking:
            if depth_ratio > 1:
                fix = "Batter will be deeper: lower the oven by 25°F/15°C, bake longer and test the centre, keep the pan at most 2/3 full, or use a bigger pan."
            else:
                fix = "Batter will be thinner: start checking 25 to 40 % earlier, or use a smaller pan."
            findings.append((sev, "DEPTH_CHANGE", "-", f"batter depth changes x{depth_ratio:.2f} versus the original pan", fix))
    if baking and pan_change:
        if depth_ratio is None or 0.9 <= depth_ratio <= 1.1:
            tip = "Batter depth stays about the same, so the bake time stays about the same; start checking 5 minutes early. Glass or dark pans bake faster: lower 25°F/15°C."
        elif 0.8 <= depth_ratio <= 1.25:
            tip = f"Batter depth changes slightly (x{depth_ratio:.2f}); {'bake a few minutes longer' if depth_ratio > 1 else 'start checking a few minutes early'} and test the centre."
        else:
            tip = "See DEPTH_CHANGE for time and temperature."
        if bake_time:
            tip = f"Original {bake_time} min. " + tip
        findings.append(("INFO", "PAN_TIME", "-", pan_change, tip))
    if not baking and factor > 1.5:
        findings.append(("INFO", "COOK_TIME", "-", f"scaled x{factor:.2f}",
                         "Cooking time does not scale with quantity: brown meat in batches, allow longer to come to a boil, and use a pot that keeps the depth similar."))
    findings.sort(key=lambda f: SEV_ORDER[f[0]])
    return items, [dict(zip(("severity", "code", "ingredient", "problem", "fix"), f)) for f in findings]


def render(path, factor, how, target, items, findings, oven_line=None):
    out = [f"# Scaled recipe: {path}", "", f"Scale: {how} -> factor x{factor:.3f}   Units: {target}", ""]
    out += ["| Ingredient | Original | Scaled | Also |", "|---|---|---|---|"]
    for it in items:
        out.append(f"| {it['ingredient']} | {it['original']} | {it['display']} | {it.get('also', '')} |")
    if oven_line:
        out += ["", f"Oven: {oven_line}"]
    out += ["", "## Notes", ""]
    if findings:
        out += ["| Severity | Code | Ingredient | Problem | Advice |", "|---|---|---|---|---|"]
        for f in findings:
            out.append(f"| {f['severity']} | {f['code']} | {f['ingredient']} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No caveats.")
    out += ["", "Weights for volume ingredients use spoon-and-level densities; weigh flour if you can."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    close = lambda a, b, tol=1e-6: abs(a - b) <= tol
    t("parse 1 1/2", close(parse_qty("1 1/2"), 1.5))
    t("parse 3/4 and .5 and ½", close(parse_qty("3/4"), .75) and close(parse_qty(".5"), .5) and close(parse_qty("1½"), 1.5))
    t("bad quantity rejected", raises(lambda: parse_qty("some")))
    t("unit aliases", norm_unit("Tablespoons") == "tbsp" and norm_unit("T") == "tbsp" and norm_unit("t") == "tsp" and norm_unit("") == "each")
    t("unknown unit rejected", raises(lambda: norm_unit("handful")))
    t("longest density match", density_for("light brown sugar")[0] == "brown sugar" and density_for("all-purpose flour")[1] == 125)
    t("salt vs kosher salt", density_for("salt")[1] == 288 and density_for("kosher salt")[1] is None)
    t("eggs detected", egg_kind("large eggs") == "egg" and egg_kind("egg yolks") == "yolk" and egg_kind("eggplant") is None)
    a8 = parse_pan("8x8")[0]; a913 = parse_pan("9x13")[0]
    t("rect pan area", close(a8, 64) and close(a913, 117))
    t("round pan area", close(parse_pan("round9")[0], math.pi * 20.25))
    t("cm pan", close(parse_pan("20x20cm")[0], (20 / 2.54) ** 2))
    t("bad pan rejected", raises(lambda: parse_pan("big")))
    t("us volume 1 cup", fmt_us_volume(236.588) == "1 cup")
    t("us volume 3/4 cup", fmt_us_volume(ML["cup"] * .75) == "3/4 cup")
    t("us volume 2/3 cup", fmt_us_volume(ML["cup"] * 2 / 3) == "2/3 cup")
    t("us volume cup + tbsp", fmt_us_volume(ML["cup"] * 1.125) == "1 cup + 2 tbsp")
    t("fractions reduced, cups plural", fmt_us_volume(ML["cup"] * 0.61) == "1/2 cup + 2 tbsp" and fmt_us_volume(ML["cup"] * 2) == "2 cups")
    t("us volume tbsp", fmt_us_volume(ML["tbsp"] * 1.5) == "1 1/2 tbsp")
    t("us volume tsp", fmt_us_volume(ML["tsp"] * .5) == "1/2 tsp")
    t("us volume pinch", fmt_us_volume(ML["tsp"] * .05) == "pinch")
    t("metric rounding", round_metric(7.3) == 7.5 and round_metric(57.4) == 57 and round_metric(228) == 230 and round_metric(1234) == 1230)
    t("1 cup flour = 125 g", convert({"qty": 1, "unit": "cup", "ingredient": "all-purpose flour", "original": ""}, 1, "weight")["display"] == "125 g")
    t("1 stick butter in us/keep", "1 stick" in convert({"qty": 1, "unit": "stick", "ingredient": "butter", "original": ""}, 1, "keep")["display"])
    t("8 oz to grams", convert({"qty": 8, "unit": "oz", "ingredient": "cheese", "original": ""}, 1, "metric")["display"] == "225 g")
    t("500 g to us lb", convert({"qty": 500, "unit": "g", "ingredient": "pasta", "original": ""}, 1, "us")["display"] == "1 lb")
    t("metric volume", convert({"qty": 1, "unit": "cup", "ingredient": "milk", "original": ""}, 2, "metric")["display"] == "475 ml")
    t("no density keeps ml", "NO_DENSITY" in convert({"qty": 1, "unit": "tbsp", "ingredient": "fish sauce", "original": ""}, 1, "weight")["notes"])
    rows = read_recipe("quantity,unit,ingredient,note\n2,each,large eggs,\n1/4,tsp,baking soda,\n1,cup,sugar,\n")
    t("csv read", len(rows) == 3 and rows[0]["unit"] == "each")
    t("csv bad header rejected", raises(lambda: read_recipe("a,b\n1,2\n")))
    t("csv bad unit names line", "line 2" in err_of(lambda: read_recipe("quantity,unit,ingredient\n1,handful,x\n")))
    _, f = scale(rows, 1.828, baking=True)
    t("EGG_FRACTION at x1.83", any(x["code"] == "EGG_FRACTION" and "3 whole eggs plus 33 g" in x["fix"] for x in f))
    _, f = scale(rows, 2.0, baking=True)
    t("no EGG_FRACTION when whole", not any(x["code"] == "EGG_FRACTION" for x in f))
    _, f = scale(rows, 0.1, baking=True)
    t("TINY_AMOUNT and BIG_FACTOR", {"TINY_AMOUNT", "BIG_FACTOR", "LEAVENING"} <= {x["code"] for x in f})
    _, f = scale(rows, 1.0, baking=True, depth_ratio=1.83)
    t("DEPTH_CHANGE HIGH", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "HIGH" for x in f))
    _, f = scale(rows, 1.0, baking=True, depth_ratio=1.3)
    t("DEPTH_CHANGE MEDIUM", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "MEDIUM" for x in f))
    _, f = scale(read_recipe("quantity,unit,ingredient\n1,tsp,salt\n"), 3, baking=False)
    t("SEASONING and COOK_TIME for cooking", {"SEASONING", "COOK_TIME"} <= {x["code"] for x in f})
    t("oven F to C", oven("350F") == "350°F = 180°C (fan oven 160°C)" and oven("200C") == "200°C = 400°F")
    t("findings sorted HIGH first", scale(rows, 1.83, baking=True, depth_ratio=2)[1][0]["severity"] == "HIGH")
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def raises(fn):
    try:
        fn()
    except ValueError:
        return True
    return False


def err_of(fn):
    try:
        fn()
    except ValueError as e:
        return str(e)
    return ""


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("recipe", nargs="?")
    g = ap.add_mutually_exclusive_group()
    g.add_argument("--servings", nargs=2, type=float, metavar=("FROM", "TO"))
    g.add_argument("--factor", type=float)
    ap.add_argument("--from-pan")
    ap.add_argument("--to-pan")
    ap.add_argument("--to", default="keep", choices=["keep", "us", "metric", "weight"])
    ap.add_argument("--baking", action="store_true", help="apply baking caveats (eggs, leavening, pan depth)")
    ap.add_argument("--oven", help="original oven temperature, e.g. 350F or 180C")
    ap.add_argument("--time", help="original bake time in minutes, e.g. 20-25")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.recipe:
        ap.print_usage(sys.stderr); return 2
    try:
        rows = read_recipe(open(a.recipe, encoding="utf-8").read())
        depth_ratio = pan_change = None
        if bool(a.from_pan) != bool(a.to_pan):
            raise ValueError("give both --from-pan and --to-pan")
        if a.from_pan:
            fa, fd = parse_pan(a.from_pan)
            ta, td = parse_pan(a.to_pan)
            pan_change = f"{fd} -> {td}, area x{ta / fa:.2f}"
        if a.servings:
            if a.servings[0] <= 0 or a.servings[1] <= 0:
                raise ValueError("servings must be positive")
            factor, how = a.servings[1] / a.servings[0], f"{a.servings[0]:g} -> {a.servings[1]:g} servings"
        elif a.factor is not None:
            if a.factor <= 0:
                raise ValueError("factor must be positive")
            factor, how = a.factor, f"factor {a.factor:g}"
        elif a.from_pan:
            factor, how = ta / fa, f"pan {a.from_pan} -> {a.to_pan}"
        else:
            raise ValueError("give --servings FROM TO, --factor F or --from-pan/--to-pan")
        if a.from_pan:
            depth_ratio = factor * fa / ta
            if abs(depth_ratio - 1) < 0.01:
                depth_ratio = None
        items, findings = scale(rows, factor, a.to, a.baking, depth_ratio, pan_change, a.time)
        oven_line = oven(a.oven) if a.oven else None
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    if a.json:
        print(json.dumps({"factor": factor, "how": how, "units": a.to, "oven": oven_line,
                          "items": [{k: v for k, v in it.items() if k != "notes"} for it in items],
                          "findings": findings}, indent=2, ensure_ascii=False))
    else:
        print(render(a.recipe, factor, how, a.to, items, findings, oven_line))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningProductivity+2
F@f
0
Trình Đánh giá Rủi ro Kế hoạch Terraform (Terraform Plan Risk Reviewer)
Kỹ năng

Đánh giá đầu ra của lệnh terraform plan và show -json trước khi thực hiện apply. Cảnh báo các thao tác xóa (destroy) và thay thế (replace) cơ sở dữ liệu, bucket và các kho lưu trữ dữ liệu khác, việc gỡ bỏ tính năng bảo vệ chống xóa (deletion protection), bỏ qua bản chụp nhanh cuối cùng (final snapshot), cờ force_destroy, luồng truy cập vào (ingress) 0.0.0.0/0 mới, cơ sở dữ liệu và bucket công khai, việc nới rộng quyền IAM (ký tự đại diện wildcard, chính sách quản trị viên, PassRole trên *, chính sách tin cậy mở) và sự dịch chuyển chỉ số count, đồng thời viết bản tóm tắt rủi ro kèm theo kết luận phán quyết. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: terraform-plan-risk-reviewer
description: Đánh giá đầu ra của lệnh terraform plan và show -json trước khi thực hiện apply. Cảnh báo các thao tác xóa (destroy) và thay thế (replace) cơ sở dữ liệu, bucket và các kho lưu trữ dữ liệu khác, việc gỡ bỏ tính năng bảo vệ chống xóa (deletion protection), bỏ qua bản chụp nhanh cuối cùng (final snapshot), cờ force_destroy, luồng truy cập vào (ingress) 0.0.0.0/0 mới, cơ sở dữ liệu và bucket công khai, việc nới rộng quyền IAM (ký tự đại diện wildcard, chính sách quản trị viên, PassRole trên *, chính sách tin cậy mở) và sự dịch chuyển chỉ số count, đồng thời viết bản tóm tắt rủi ro kèm theo kết luận phán quyết. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Đánh giá Rủi ro Kế hoạch Terraform (Terraform Plan Risk Reviewer)

Bạn đánh giá các bản kế hoạch (plan) của Terraform (hoặc OpenTofu) theo đúng cách mà một kỹ sư nền tảng (platform engineer) cẩn trọng thực hiện trước khi phê duyệt lệnh apply: thứ gì sẽ bị xóa bỏ hoặc thay thế, dữ liệu nào có thể bị mất, thành phần nào sẽ có thể truy cập được từ internet, và ai được cấp thêm quyền hạn. Bạn đưa ra kết luận phán quyết rõ ràng cùng các cách khắc phục cụ thể, và tuyệt đối không bao giờ dán lại các thông tin bí mật (secrets) từ bản kế hoạch trả về cho người dùng.

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

- `scripts/plan_risk.py` - đọc đầu ra của `terraform show -json` (kiểm tra toàn diện) hoặc văn bản `terraform plan -no-color` (kiểm tra ở cấp độ hành động) và in ra các phát hiện, số lượng thống kê cùng kết luận phán quyết (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/reading-plan-output.md` - cách tạo tệp JSON của bản kế hoạch, các ký hiệu văn bản và dòng giải thích lý do, cấu trúc JSON, các lệnh jq một dòng (one-liners), thông tin bí mật trong bản kế hoạch và những gì bản kế hoạch không thể hiển thị
- `references/risk-rules.md` - toàn bộ các mã phát hiện kèm theo mức độ nghiêm trọng, điều kiện kích hoạt, lý do tại sao nó quan trọng và cách khắc phục thông thường, các quy tắc đưa ra phán quyết và các mục cần kiểm tra thủ công
- `templates/plan-risk-summary.md` - bản mẫu tóm tắt để đăng lên pull request
- `examples/shop-prod-plan.json` - một bản kế hoạch môi trường production có chứa một cơ sở dữ liệu bị thay thế, một bucket bị xóa, một quy tắc SSH mở ra toàn thế giới, một chính sách CI bị nới rộng quyền, một liên kết quyền quản trị viên (admin attachment) và một sự dịch chuyển chỉ số count

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

### 1. Lấy đúng bản kế hoạch
Yêu cầu bản kế hoạch của đúng thay đổi đó: chạy `terraform plan -out plan.out` sau đó chạy `terraform show -json plan.out > plan.json`, cộng với bản so sánh khác biệt mã nguồn (code diff) hoặc mô tả PR và không gian làm việc (workspace)/tài khoản đích. Nếu người dùng chỉ có đầu ra dạng văn bản, hãy vẫn sử dụng nó nhưng nói rõ rằng các bước kiểm tra thuộc tính chi tiết (IAM, ingress, các cờ bảo vệ) cần có tệp JSON. Nhắc họ rằng tệp JSON của bản kế hoạch có chứa thông tin bí mật (secrets): hãy chạy kịch bản (script) ngay tại nơi chứa tệp đó thay vì dán nội dung tệp vào cuộc trò chuyện.

### 2. Chạy trình kiểm tra
```
python3 scripts/plan_risk.py examples/shop-prod-plan.json
python3 scripts/plan_risk.py plan.json --json
python3 scripts/plan_risk.py plan.txt
python3 scripts/plan_risk.py --self-test
```
Mã thoát (Exit) 1 có nghĩa là có ít nhất một phát hiện mức HIGH, mã thoát 2 có nghĩa là tệp đầu vào không phải là một bản kế hoạch hợp lệ.

### 3. Đánh giá như một chuyên gia con người
- So sánh các thay đổi trong kế hoạch với bản khác biệt mã nguồn (code diff): bất kỳ điều gì không được giải thích bởi code diff (nâng cấp provider, sai lệch cấu hình thực tế - drift, tài nguyên bị đổi tên) đều cần có câu trả lời rõ ràng trước khi apply.
- Đối với mỗi phát hiện `DATA_LOSS`: đó là thao tác đổi tên (hãy dùng khối `moved`), sự thay thế bắt buộc (do thuộc tính nào gây ra, xem `replace_paths`), hay là thao tác xóa thật sự (đã có bản sao lưu chưa)?
- Đối với các phát hiện về lộ lọt bề mặt mạng (exposure) và IAM: quy tắc hoặc chính sách nhỏ nhất vừa đủ để hoàn thành công việc là gì?
- Kiểm tra những điểm mà kịch bản không thể nhìn thấy (`references/risk-rules.md`, mục "Things to check by hand"): các giá trị chưa xác định (unknown values), thứ tự gây thời gian chết (downtime order), di trú dữ liệu, không gian làm việc (workspace) và tài khoản.

### 4. Bàn giao kết quả
Điền vào mẫu `templates/plan-risk-summary.md`: kết luận phán quyết, các mục mức HIGH kèm cách sửa hoặc tên người chịu trách nhiệm chấp nhận rủi ro, các mục mức MEDIUM kèm người sở hữu (owner), các thay đổi dự kiến so với ngoài dự kiến, các bước thực hiện apply kèm kế hoạch hoàn tác (rollback). Đưa ra các đoạn mã sửa lỗi dưới dạng các đoạn mã HCL nhỏ gọn (khối moved, lifecycle, chính sách giới hạn phạm vi).

## Ví dụ kết quả
`examples/shop-prod-plan.json` (Terraform 1.9.8): 2 tạo mới (create), 4 cập nhật (update), 2 thay thế (replace), 2 xóa bỏ (destroy); 6 HIGH, 5 MEDIUM, 2 LOW, 2 INFO, phán quyết DO NOT APPLY (KHÔNG ĐƯỢC ÁP DỤNG). Mức HIGH: thực thể RDS lưu đơn hàng bị thay thế (xóa trước) vì thuộc tính `storage_encrypted` đã thay đổi, trong khi tính năng bảo vệ chống xóa (deletion protection) đang tắt và không có bản chụp nhanh cuối cùng (final snapshot); bucket lưu nhật ký cũ (legacy logs) bị xóa kèm cờ force_destroy; cổng SSH được mở cho 0.0.0.0/0 trên máy chủ bastion; khối chặn truy cập công khai S3 (public access block) bị tắt đối với bucket chứa tài sản (assets); vai trò CI (CI role) nhận quyền `iam:PassRole` trên `*` (và `s3:*`); chính sách AdministratorAccess được gắn vào vai trò vận hành (ops role). Mức MEDIUM cũng bao gồm việc `aws_instance.web[1]` bị xóa do sự dịch chuyển chỉ số count. Cách khắc phục: chụp bản sao lưu (snapshot) và di trú cơ sở dữ liệu, dùng khối `moved` hoặc `removed` cho bucket, dùng SSM thay vì mở SSH công khai, thu hẹp phạm vi chính sách, dùng `for_each` cho các thực thể máy chủ web.

## Quy tắc

- Tuyệt đối không bao giờ in ra hoặc trích dẫn mật khẩu, khóa hoặc token từ bản kế hoạch, ngay cả khi chúng hiển thị rõ trong tệp JSON.
- Một phát hiện mức HIGH sẽ chặn việc thực thi apply cho đến khi nó được khắc phục hoặc có một người chịu trách nhiệm cụ thể chấp nhận rủi ro bằng văn bản.
- Ưu tiên sử dụng các khối `moved`, `removed` và `import` thay vì xóa-rồi-tạo-lại (destroy-and-recreate) khi tái cấu trúc mã (refactor).
- Các kho lưu trữ dữ liệu bắt buộc phải có bản sao lưu trước khi thực hiện bất kỳ thao tác xóa hoặc thay thế nào, và phải bật `prevent_destroy` sau đó.
- Không phê duyệt các bản kế hoạch mà bạn không thể xem đầy đủ toàn bộ (đầu ra bị cắt bớt, chạy với cờ `-target`, bản kế hoạch bị lỗi); hãy yêu cầu cung cấp bản kế hoạch hoàn chỉnh.
- Chính sách dưới dạng mã (Policy-as-code như OPA/Conftest, Sentinel, Checkov, tfsec) là bước bổ trợ tiếp theo rất tốt, nhưng không thể thay thế cho việc đọc hiểu trực tiếp bản kế hoạch.
FILE:references/reading-plan-output.md
# Đọc đầu ra kế hoạch Terraform (Terraform plan output)

## Lấy bản kế hoạch ở định dạng máy có thể đọc được
```
terraform plan -out plan.out            # thêm -var-file / -target giống hệt như khi chạy apply thực tế
terraform show -json plan.out > plan.json
terraform show -no-color plan.out > plan.txt   # bản sao dễ đọc cho con người của cùng bản kế hoạch đã lưu
```
Hãy đánh giá CÙNG MỘT bản kế hoạch đã lưu mà bạn sẽ dùng để chạy apply (`terraform apply plan.out`), chứ không phải một bản kế hoạch mới chạy lại: trạng thái (state) hoặc các tài nguyên từ xa có thể thay đổi ở khoảng thời gian giữa hai lần chạy. OpenTofu cũng sử dụng các lệnh tương tự (`tofu show -json`) và cùng định dạng.

## Các ký hiệu văn bản
| Ký hiệu | Dòng tiêu đề | Ý nghĩa |
|---|---|---|
| `+` | `will be created` | tài nguyên mới |
| `~` | `will be updated in-place` | thay đổi thuộc tính, giữ nguyên ID tài nguyên |
| `-` | `will be destroyed` | tài nguyên bị xóa |
| `-/+` | `must be replaced` | xóa trước, tạo lại sau (mặc định) - gây khoảng trống thời gian chết (downtime) |
| `+/-` | `must be replaced` | tạo mới trước, xóa cũ sau (`create_before_destroy`) |
| `<=` | `will be read during apply` | đọc nguồn dữ liệu (data source); hãy kiểm tra lý do tại sao nó chưa được biết tại thời điểm lập kế hoạch |
| `# forces replacement` | nằm sau một thuộc tính | sự thay đổi thuộc tính này là nguyên nhân bắt buộc phải thay thế tài nguyên |
| `(known after apply)` | giá trị | được tính toán sau; bạn không thể xem xét giá trị này ngay bây giờ |
| `(sensitive value)` | giá trị | bị ẩn trong đầu ra văn bản, nhưng vẫn hiện diện rõ trong tệp JSON của bản kế hoạch |

Các dòng giải thích lý do nằm bên dưới dòng tiêu đề: `# (because aws_x.y is not in configuration)` (đã bị xóa khỏi mã nguồn hoặc bị di chuyển mà không có khối `moved`), `# (because index [1] is out of range for count)` (giá trị count bị giảm đi hoặc bị dịch chuyển), `# (because key ["a"] is not in for_each map)`, `# (resource is tainted, so must be replaced)`, `# (replace requested)` (do sử dụng cờ `-replace=`).

Dòng tóm tắt: `Plan: 3 to add, 1 to change, 3 to destroy.` Một thao tác thay thế (replacement) được tính một lần vào mục thêm mới ("add") và một lần vào mục xóa bỏ ("destroy"), vì vậy hãy so sánh số lượng xóa bỏ với tổng số dòng tiêu đề có ký hiệu `-` và `-/+`.

## Cấu trúc JSON (format_version 1.x)
```
{
  "format_version": "1.2", "terraform_version": "1.9.8",
  "resource_changes": [ {
      "address": "module.db.aws_db_instance.main", "module_address": "module.db",
      "mode": "managed" | "data", "type": "aws_db_instance", "name": "main", "index": 0 | "key",
      "change": {
        "actions": ["create"] | ["update"] | ["delete"] | ["delete","create"] | ["create","delete"] | ["no-op"] | ["read"] | ["forget"],
        "before": {...} | null, "after": {...} | null,
        "after_unknown": {"id": true},              # các giá trị chỉ được biết sau khi chạy apply
        "before_sensitive": {...}, "after_sensitive": {"password": true},
        "replace_paths": [["storage_encrypted"]],   # các thuộc tính bắt buộc phải thay thế tài nguyên
        "importing": {"id": "..."}                  # các khối import (phiên bản 1.5+)
      },
      "action_reason": "replace_because_cannot_update" | "replace_because_tainted" | "replace_by_request" |
                       "delete_because_no_resource_config" | "delete_because_count_index" | "delete_because_each_key" | ...,
      "previous_address": "aws_db_instance.old"     # các khối moved
  } ],
  "resource_drift": [ ... các thay đổi được thực hiện bên ngoài Terraform ... ],
  "output_changes": {...}, "prior_state": {...}, "configuration": {...},
  "applyable": true, "complete": true, "errored": false
}
```
Các lệnh một dòng (one-liner) hữu ích:
```
jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions | join(",")) \(.address)"' plan.json
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
jq -r '.resource_changes[] | select(.change.replace_paths) | "\(.address): \(.change.replace_paths)"' plan.json
```

## Thông tin nhạy cảm (Secrets) trong bản kế hoạch (plan)
`before`/`after` chứa các giá trị thực tế, bao gồm mật khẩu và khóa riêng tư (private key), ngay cả khi `*_sensitive` đánh dấu chúng. Hãy coi plan.out và plan.json như các thông tin bí mật (secrets): không đính kèm chúng vào ticket, tin nhắn trò chuyện hoặc nhật ký CI công khai; hãy lưu giữ chúng dưới dạng các artifact CI có thời hạn ngắn và được kiểm soát quyền truy cập. Thay vào đó, hãy chia sẻ bản tóm tắt rủi ro.

## Những điều bản kế hoạch (plan) không thể cho bạn biết
- Các giá trị được tính toán tại thời điểm áp dụng (`known after apply`), bao gồm nhiều tài liệu chính sách IAM được xây dựng từ các tài nguyên khác.
- Các tác dụng phụ bên ngoài Terraform: bộ nhớ đệm DNS, các máy khách (client) được ghim vào một địa chỉ IP, dữ liệu bên trong cơ sở dữ liệu đang bị thay thế.
- Liệu `lifecycle { prevent_destroy = true }` có được thiết lập hay không (nó nằm trong `configuration`, và một bản kế hoạch vi phạm điều này sẽ báo lỗi ngay từ đầu).
- Hành vi đặc thù của nhà cung cấp (provider) chẳng hạn như việc RDS chỉ áp dụng một số thay đổi trong khung thời gian bảo trì tiếp theo (`apply_immediately`).
FILE:references/risk-rules.md
# Các quy tắc rủi ro được sử dụng bởi plan_risk.py

| Mã | Mức độ nghiêm trọng | Điều kiện kích hoạt | Tại sao lại quan trọng | Cách khắc phục điển hình |
|---|---|---|---|---|
| DATA_LOSS | HIGH | hủy (destroy) hoặc thay thế (replace) một loại tài nguyên lưu giữ dữ liệu: cơ sở dữ liệu (RDS, Cloud SQL, Azure SQL/PostgreSQL/MySQL, Cosmos DB, DynamoDB), bucket, ổ đĩa/volume, EFS/FSx, bộ nhớ đệm (cache), hàng đợi/luồng (queues/streams), khóa KMS, secret, vùng DNS (DNS zones), nhóm nhật ký (log groups), ECR, PVC/namespace của Kubernetes | một tài nguyên mới sẽ bắt đầu ở trạng thái trống; việc xóa khóa KMS khiến dữ liệu đã mã hóa không thể đọc được | tạo bản chụp nhanh (snapshot)/sao lưu trước, dùng khối `moved` khi tái cấu trúc (refactor), đặt `prevent_destroy` trên các kho dữ liệu, di chuyển dữ liệu trước khi thay thế |
| PROTECTION_OFF | HIGH (cập nhật) / MEDIUM (mới hoặc thay thế) | `deletion_protection`, `deletion_protection_enabled`, `enable_deletion_protection`, `termination_protection`, `disable_api_termination` có giá trị false | sai sót tiếp theo có thể xóa sổ môi trường production | giữ giá trị true; chỉ tắt trong một thay đổi riêng biệt ngay trước khi thực hiện việc xóa đã lên kế hoạch |
| PUBLIC_INGRESS | HIGH | luồng truy cập vào (ingress) 0.0.0.0/0 hoặc ::/0 mới trên tất cả các cổng hoặc các cổng quản trị/dữ liệu (22, 3389, 3306, 5432, 1433, 6379, 27017, 9200, 6443 ...) | bề mặt tấn công trực tiếp, tấn công vét cạn (brute force), lộ lọt dữ liệu | dùng các dải CIDR đã biết, SSM Session Manager, VPN, bastion, các điểm cuối riêng tư (private endpoints) |
| PUBLIC_INGRESS | MEDIUM / INFO | các cổng khác / 80 và 443 | | chỉ phơi bày các bộ cân bằng tải (load balancers) |
| PUBLIC_DATABASE | HIGH | `publicly_accessible = true` | điểm cuối cơ sở dữ liệu có thể truy cập được từ internet | mạng con riêng tư (private subnets), proxy, VPN |
| S3_PUBLIC_ACCESS | HIGH | bất kỳ thiết lập nào trong bốn thiết lập chặn truy cập công khai bị đặt thành false | chính sách bucket hoặc ACL có thể khiến dữ liệu bị công khai | giữ cả bốn thiết lập là true; dùng CloudFront + Origin Access Control |
| PUBLIC_ACL | HIGH | `public-read`, `public-read-write`, `authenticated-read` | mọi người đều có thể liệt kê hoặc đọc (hoặc ghi) | BucketOwnerEnforced, riêng tư (private) |
| PUBLIC_POLICY | HIGH | chính sách tài nguyên đặt Allow với Principal `*` và không có Condition | bất kỳ ai trên internet (hoặc bất kỳ tài khoản AWS nào) đều có quyền truy cập | chỉ định rõ các định danh (named principals); `aws:SourceArn`, `aws:PrincipalOrgID`, `aws:SourceVpce` |
| PUBLIC_IAM | HIGH | ràng buộc GCP tới `allUsers` / `allAuthenticatedUsers` | truy cập công khai | chỉ định các thành viên cụ thể |
| TRUST_ANYONE | HIGH | chính sách tin cậy của vai trò (role trust policy) có Principal `*` và không có Condition | bất kỳ tài khoản AWS nào cũng có thể đảm nhận vai trò (assume role) | dùng các ARN chính xác + `sts:ExternalId` |
| IAM_ADMIN | HIGH | `Action * / Resource *`, AdministratorAccess, PowerUserAccess, IAMFullAccess, GCP owner/editor, Azure Owner/User Access Administrator (Contributor = MEDIUM) | toàn quyền kiểm soát tài khoản hoặc dự án | đặc quyền tối thiểu (least privilege); tài khoản quản trị khẩn cấp (break-glass admin) có MFA |
| IAM_ESCALATION | HIGH | `iam:PassRole`, `iam:Attach*/Put*Policy`, `iam:CreatePolicyVersion`, `iam:CreateAccessKey`, `sts:AssumeRole`, `kms:*` trên `*`; `iam:*`, `sts:*` ở bất kỳ đâu | người nắm giữ có thể tự cấp thêm quyền cho chính mình | giới hạn phạm vi vào các ARN cụ thể; dùng điều kiện `iam:PassedToService` |
| IAM_WILDCARD | HIGH trên `*` / MEDIUM | các hành động `service:*`, các chính sách được quản lý `*FullAccess` | vượt quá xa mức cần thiết, bao gồm cả quyền xóa | liệt kê các hành động thực sự được sử dụng (Access Analyzer, CloudTrail) |
| IAM_NOT_ACTION | MEDIUM | Allow đi kèm với NotAction | cấp quyền cho mọi thứ khác, bao gồm cả các hành động trong tương lai | dùng danh sách Action tường minh |
| IAM_WIDENED | LOW | bản cập nhật thêm các hành động hoặc tài nguyên vào một chính sách | sự phìnhto quyền hạn (permission creep) | xác nhận với chủ sở hữu |
| TRUST_WIDENED | MEDIUM | có thêm các định danh (principals) mới trong chính sách tin cậy | quyền truy cập chéo tài khoản (cross-account) mới | xác nhận từng định danh (principal) |
| NO_FINAL_SNAPSHOT | MEDIUM | `skip_final_snapshot = true` trên RDS/Aurora/DocumentDB/Neptune/Redshift | khi xóa sẽ không để lại gì để khôi phục | đặt `false` + `final_snapshot_identifier` |
| FORCE_DESTROY | MEDIUM | `force_destroy = true` mới được thiết lập | việc xóa sẽ quét sạch toàn bộ các đối tượng (objects) hoặc bản ghi (records) | giữ giá trị false trên dữ liệu thực |
| ENCRYPTION_OFF | MEDIUM | `storage_encrypted`/`encrypted` có giá trị false | vấn đề tuân thủ; việc bật tính năng này về sau thường buộc phải thay thế tài nguyên | bật ngay bây giờ |
| BACKUPS_OFF | MEDIUM | `backup_retention_period = 0` | không thể khôi phục tại một thời điểm (point-in-time recovery) | đặt từ 7 đến 35 ngày |
| SECRET_REGENERATED | MEDIUM | thay thế `random_password`, `random_string`, `tls_private_key`, `aws_iam_access_key` | các hệ thống sử dụng (consumers) sẽ bị lỗi | lên kế hoạch xoay vòng khóa (rotation) |
| COUNT_INDEX_SHIFT | MEDIUM | `delete_because_count_index` | việc xóa một mục ở giữa sẽ hủy và tạo lại các mục đứng sau nó | dùng `for_each` với các khóa ổn định, dùng các khối `moved` |
| DESTROY | MEDIUM (máy tính/compute, LB, IP, cụm/cluster, xóa khỏi count/each) / LOW | hủy tài nguyên không lưu trạng thái (non-stateful) | thời gian ngừng hoạt động (downtime), mất địa chỉ IP | xác nhận ý định; dùng `moved` khi tái cấu trúc |
| REPLACE | MEDIUM (hủy trước đối với compute/LB) / LOW | thay thế tài nguyên không lưu trạng thái (non-stateful) | ID mới, IP mới, tên DNS mới; khoảng trống thời gian ngừng hoạt động (downtime gap) | dùng `create_before_destroy`, kiểm tra các thành phần phụ thuộc |
| PUBLIC_FUNCTION_URL | MEDIUM | Lambda URL với xác thực NONE | điểm cuối công khai | dùng xác thực AWS_IAM |
| PLAN_ERRORED / PLAN_INCOMPLETE | HIGH / MEDIUM | `errored` / `complete: false` | bức tranh không đầy đủ | lập lại bản kế hoạch (re-plan) |
| DRIFT, REMOVED_FROM_CONFIG, MOVED, IMPORT, FORGET, POLICY_UNKNOWN, TEXT_MODE | INFO | ngữ cảnh | | |

## Kết luận đánh giá (Verdict)
- Có bất kỳ mức HIGH nào: không áp dụng cho đến khi từng rủi ro mức HIGH được khắc phục hoặc được chủ sở hữu chấp nhận một cách rõ ràng (ghi rõ tên và lý do trong bản đánh giá).
- Chỉ có mức MEDIUM: áp dụng sau khi chủ sở hữu của từng tài nguyên bị ảnh hưởng đồng ý.
- Chỉ có mức LOW/INFO: đánh giá bình thường.

## Những điều cần kiểm tra thủ công
- **Phạm vi ảnh hưởng (Blast radius):** có bao nhiêu tài nguyên thay đổi, và tất cả chúng có nằm trong dự kiến từ phần chênh lệch mã nguồn (code diff) hay không? Một thay đổi chỉ một dòng nhưng thay thế tới 40 tài nguyên là một dấu hiệu cảnh báo đỏ (nâng cấp nhà cung cấp, thay đổi `name`, chuyển từ `count` sang `for_each`).
- **Thứ tự và thời gian ngừng hoạt động:** các thao tác thay thế theo kiểu hủy trước (destroy-first) đối với bất kỳ thành phần nào đang phục vụ lưu lượng truy cập; TTL của DNS; hoán đổi nhóm đích (target group).
- **Di chuyển dữ liệu:** một cơ sở dữ liệu bị thay thế cần được khôi phục từ bản chụp nhanh (snapshot) hoặc có kế hoạch di chuyển dữ liệu; Terraform sẽ không sao chép dữ liệu.
- **Các giá trị chưa xác định:** các chính sách hoặc quy tắc nhóm bảo mật (security group) ở trạng thái `known after apply` - thay vào đó hãy xem xét mã nguồn của module.
- **Nâng cấp nhà cung cấp (provider) và module:** đọc nhật ký thay đổi (changelog) để biết các trường hợp buộc phải thay thế và các thay đổi về giá trị mặc định.
- **Môi trường:** xác nhận workspace/backend (`terraform workspace show`) và tài khoản (`aws sts get-caller-identity`) trước khi áp dụng.
- **Chính sách dưới dạng mã (Policy as code):** các quy tắc OPA/Conftest, Sentinel, Checkov hoặc tfsec có thể thực thi các kiểm tra mức HIGH trong CI.
FILE:templates/plan-risk-summary.md
# Tóm tắt rủi ro bản kế hoạch Terraform: <stack / workspace>

**Thay đổi:** <PR #123 "Mã hóa DB đơn hàng, mở bastion cho nhà cung cấp">   **Workspace/tài khoản:** <prod / 123456789012 eu-west-1>
**Bản kế hoạch (Plan):** <terraform 1.9.8, plan.out từ lần chạy CI 4567>   **Số lượng:** <2 tạo mới, 4 cập nhật, 2 thay thế, 2 hủy>
**Kết luận:** <KHÔNG ÁP DỤNG nguyên trạng / Áp dụng sau khi xem xét / Rủi ro thấp>

## Phải khắc phục hoặc chấp nhận rõ ràng (HIGH)
| # | Tài nguyên | Rủi ro | Lý do | Cách khắc phục hoặc chấp nhận (ai) |
|---|---|---|---|---|
| 1 | <`aws_db_instance.orders`> | <DATA_LOSS: bị thay thế vì storage_encrypted đã thay đổi> | <DB mới trống rỗng; không có bản chụp nhanh cuối cùng (final snapshot)> | <chụp snapshot, khôi phục vào bản sao đã mã hóa, chuyển đổi bằng bản kế hoạch moved/import> |

## Cần chủ sở hữu xem xét (MEDIUM)
| # | Tài nguyên | Rủi ro | Chủ sở hữu đồng ý? |
|---|---|---|---|
| 1 | <`aws_instance.web[1]`> | <COUNT_INDEX_SHIFT> | <có/không> |

## Thấp / thông tin (Low / info)
- <IAM_WIDENED trên ci_deploy: s3:* - được thay thế bằng các hành động tường minh trong bản sửa lỗi>
- <Drift trên các thẻ (tags) của aws_lb.public: được thiết lập trong bảng điều khiển (console) bởi ...>

## Thay đổi dự kiến so với ngoài dự kiến
- Dự kiến từ phần chênh lệch mã nguồn (code diff): <...>
- Không được giải thích bởi phần chênh lệch mã nguồn: <... điều tra trước khi áp dụng>

## Kế hoạch áp dụng (Apply plan)
1. <Tạo bản chụp nhanh (snapshot) `orders-pre-encrypt-2026-10-27`>
2. <Áp dụng trong khung thời gian bảo trì: <thời gian, múi giờ>>
3. <Xác minh: kiểm tra sức khỏe ứng dụng (health checks), `aws rds describe-db-instances`, bastion chỉ truy cập được từ VPN>
4. Hoàn tác (Rollback): <khôi phục bản chụp nhanh / hoàn tác PR và áp dụng lại>

## Các việc cần làm tiếp theo (Follow-ups)
- <thêm prevent_destroy vào các kho dữ liệu>
- <thêm quy tắc policy-as-code cho 0.0.0.0/0 trên cổng 22>
FILE:examples/shop-prod-plan.json
{
  "format_version": "1.2",
  "terraform_version": "1.9.8",
  "planned_values": {"root_module": {}},
  "resource_drift": [
    {
      "address": "aws_lb.public",
      "mode": "managed",
      "type": "aws_lb",
      "name": "public",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-public", "tags": {"env": "prod"}},
        "after": {"name": "shop-public", "tags": {"env": "prod", "owner": "set-in-console"}},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    }
  ],
  "resource_changes": [
    {
      "address": "aws_cloudwatch_log_group.app",
      "mode": "managed",
      "type": "aws_cloudwatch_log_group",
      "name": "app",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {"name": "/shop/app", "retention_in_days": 30, "tags": {"env": "prod"}},
        "after_unknown": {"arn": true, "id": true},
        "before_sensitive": false,
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_db_instance.orders",
      "mode": "managed",
      "type": "aws_db_instance",
      "name": "orders",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete", "create"],
        "before": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": false, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}},
        "after": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": true, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}, "kms_key_id": null},
        "after_unknown": {"id": true, "arn": true, "endpoint": true, "address": true},
        "before_sensitive": {"password": true},
        "after_sensitive": {"password": true},
        "replace_paths": [["storage_encrypted"]]
      },
      "action_reason": "replace_because_cannot_update"
    },
    {
      "address": "aws_iam_role_policy.ci_deploy",
      "mode": "managed",
      "type": "aws_iam_role_policy",
      "name": "ci_deploy",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": [\"s3:PutObject\", \"s3:GetObject\"], \"Resource\": \"arn:aws:s3:::shop-assets-prod/*\"}]}"},
        "after": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": \"s3:*\", \"Resource\": [\"arn:aws:s3:::shop-assets-prod\", \"arn:aws:s3:::shop-assets-prod/*\"]}, {\"Sid\": \"PassAnyRole\", \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": \"*\"}]}"},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_iam_role_policy_attachment.ops_admin",
      "mode": "managed",
      "type": "aws_iam_role_policy_attachment",
      "name": "ops_admin",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {"role": "shop-ops", "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"},
        "after_unknown": {"id": true},
        "before_sensitive": false,
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_instance.web[1]",
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "index": 1,
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete"],
        "before": {"id": "i-0b1c2d3e4f5a6b7c8", "ami": "ami-0abc1234def567890", "instance_type": "t3.medium", "associate_public_ip_address": false, "tags": {"Name": "shop-web-1"}},
        "after": null,
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": false
      },
      "action_reason": "delete_because_count_index"
    },
    {
      "address": "aws_lb.public",
      "mode": "managed",
      "type": "aws_lb",
      "name": "public",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "owner": "set-in-console"}},
        "after": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "team": "shop"}},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_lb_target_group.web",
      "mode": "managed",
      "type": "aws_lb_target_group",
      "name": "web",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create", "delete"],
        "before": {"name_prefix": "web-", "port": 8080, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
        "after": {"name_prefix": "web-", "port": 8081, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
        "after_unknown": {"id": true, "arn": true, "name": true},
        "before_sensitive": {},
        "after_sensitive": {},
        "replace_paths": [["port"]]
      },
      "action_reason": "replace_because_cannot_update"
    },
    {
      "address": "aws_s3_bucket.legacy_logs",
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "legacy_logs",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete"],
        "before": {"bucket": "shop-legacy-logs-prod", "force_destroy": true, "tags": {"env": "prod"}},
        "after": null,
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": false
      },
      "action_reason": "delete_because_no_resource_config"
    },
    {
      "address": "aws_s3_bucket_public_access_block.assets",
      "mode": "managed",
      "type": "aws_s3_bucket_public_access_block",
      "name": "assets",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true},
        "after": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": false, "ignore_public_acls": true, "restrict_public_buckets": false},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_security_group.bastion",
      "mode": "managed",
      "type": "aws_security_group",
      "name": "bastion",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
        "after": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}, {"cidr_blocks": ["0.0.0.0/0"], "description": "temp: SSH for vendor", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "module.network.aws_vpc.main",
      "module_address": "module.network",
      "mode": "managed",
      "type": "aws_vpc",
      "name": "main",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["no-op"],
        "before": {"cidr_block": "10.20.0.0/16"},
        "after": {"cidr_block": "10.20.0.0/16"},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    }
  ],
  "prior_state": {"format_version": "1.0", "terraform_version": "1.9.8"},
  "configuration": {"provider_config": {"aws": {"name": "aws", "full_name": "registry.terraform.io/hashicorp/aws"}}},
  "timestamp": "2026-10-27T07:10:00Z",
  "applyable": true,
  "complete": true,
  "errored": false
}
FILE:scripts/plan_risk.py
#!/usr/bin/env python3
"""Terraform plan risk reviewer (Python 3 standard library only).

Reads `terraform show -json plan.out` output (or, with less detail, the text of
`terraform plan -no-color`) and flags risky changes: destroys and replacements of
stateful resources (data loss), removed deletion protection, skipped final
snapshots, force_destroy, disabled encryption, new public network exposure
(0.0.0.0/0 or ::/0 ingress, public databases, S3 public access blocks, public ACLs
and policies, allUsers bindings), IAM widening (wildcard actions, admin policies,
iam:PassRole on *, trust policies open to anyone) and count index shifts. Prints a
risk summary. Never prints attribute values that Terraform marks as sensitive.

Usage:
  terraform plan -out plan.out && terraform show -json plan.out > plan.json
  python3 plan_risk.py plan.json [--json]
  terraform plan -no-color > plan.txt && python3 plan_risk.py plan.txt   (text mode)
  python3 plan_risk.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
STATEFUL = re.compile(r"^(aws_(db_instance|rds_cluster|dynamodb_table|s3_bucket|ebs_volume|efs_file_system|"
                      r"elasticache_(cluster|replication_group)|redshift_cluster|docdb_cluster|neptune_cluster|"
                      r"opensearch_domain|elasticsearch_domain|kinesis_stream|sqs_queue|kms_key|secretsmanager_secret|"
                      r"backup_vault|ecr_repository|cloudwatch_log_group|msk_cluster|fsx_\w+|route53_zone|"
                      r"cognito_user_pool|timestream_table)|"
                      r"google_(sql_database_instance|sql_database|storage_bucket|bigquery_(dataset|table)|compute_disk|"
                      r"spanner_(instance|database)|kms_crypto_key|redis_instance|filestore_instance|firestore_database|"
                      r"dns_managed_zone)|"
                      r"azurerm_(storage_account|storage_container|mssql_(database|server)|postgresql_flexible_server|"
                      r"mysql_flexible_server|cosmosdb_account|key_vault|managed_disk|redis_cache|dns_zone)|"
                      r"kubernetes_(persistent_volume_claim|persistent_volume|namespace)(_v1)?)$")
SECRET_GEN = re.compile(r"^(random_password|random_string|tls_private_key|aws_iam_access_key)$")
DOWNTIME = re.compile(r"^(aws_(instance|lb|alb|eip|nat_gateway|cloudfront_distribution|api_gateway_rest_api|"
                      r"eks_cluster|ecs_service|acm_certificate)|google_(compute_instance|container_cluster)|"
                      r"azurerm_(linux|windows)_virtual_machine|azurerm_kubernetes_cluster)$")
SENSITIVE_PORTS = {22: "SSH", 23: "Telnet", 3389: "RDP", 3306: "MySQL", 5432: "PostgreSQL", 1433: "SQL Server",
                   1521: "Oracle", 6379: "Redis", 27017: "MongoDB", 9200: "Elasticsearch", 5601: "Kibana",
                   2375: "Docker API", 2379: "etcd", 11211: "Memcached", 5900: "VNC", 9092: "Kafka",
                   6443: "Kubernetes API", 10250: "Kubelet"}
WEB_PORTS = {80, 443}
PUBLIC_CIDRS = {"0.0.0.0/0", "::/0", "*", "internet", "any"}
ADMIN_POLICIES = re.compile(r"(AdministratorAccess|PowerUserAccess|IAMFullAccess)$")
ESCALATION_ACTIONS = {"iam:*", "iam:passrole", "iam:createpolicyversion", "iam:attachrolepolicy", "iam:putrolepolicy",
                      "iam:createaccesskey", "iam:updateassumerolepolicy", "sts:assumerole", "sts:*", "kms:*", "*"}
POLICY_ATTRS = ("policy", "assume_role_policy", "inline_policy")
PROTECTION_ATTRS = ("deletion_protection", "deletion_protection_enabled", "enable_deletion_protection",
                    "termination_protection", "disable_api_termination")


def finding(sev, code, addr, problem, fix):
    return {"severity": sev, "code": code, "resource": addr, "problem": problem, "fix": fix}


def kind_of(actions):
    a = list(actions or [])
    if a == ["delete", "create"]:
        return "replace (destroy first)"
    if a == ["create", "delete"]:
        return "replace (create first)"
    return {"create": "create", "update": "update", "delete": "destroy", "no-op": "no-op", "read": "read",
            "forget": "forget"}.get(a[0] if len(a) == 1 else ",".join(a), ",".join(a))


def sensitive(mask, key):
    return mask is True or (isinstance(mask, dict) and mask.get(key) not in (None, False))


def get(d, key, default=None):
    return d.get(key, default) if isinstance(d, dict) else default


def ports_of(lo, hi, proto):
    proto = str(proto).lower()
    if proto in ("-1", "all", "*"):
        return 0, 65535, "all"
    try:
        lo, hi = int(lo if lo not in (None, "") else 0), int(hi if hi not in (None, "") else 65535)
    except (TypeError, ValueError):
        return 0, 65535, proto
    if proto in ("icmp", "icmpv6", "1", "58"):
        return None
    if lo in (-1,) or (lo == 0 and hi in (0, 65535)):
        return 0, 65535, proto
    return lo, hi, proto


def open_rules(rtype, values):
    """Yield (cidr, lo, hi, proto) for internet-open ingress rules in a resource's values."""
    v = values or {}
    out = set()
    def add(cidrs, lo, hi, proto):
        p = ports_of(lo, hi, proto)
        if p is None:
            return
        for c in cidrs or []:
            if str(c).lower() in PUBLIC_CIDRS:
                out.add((str(c), p[0], p[1], p[2]))
    if rtype == "aws_security_group":
        for r in v.get("ingress") or []:
            add((r.get("cidr_blocks") or []) + (r.get("ipv6_cidr_blocks") or []), r.get("from_port"), r.get("to_port"), r.get("protocol"))
    elif rtype == "aws_security_group_rule" and v.get("type") == "ingress":
        add((v.get("cidr_blocks") or []) + (v.get("ipv6_cidr_blocks") or []), v.get("from_port"), v.get("to_port"), v.get("protocol"))
    elif rtype == "aws_vpc_security_group_ingress_rule":
        add([x for x in (v.get("cidr_ipv4"), v.get("cidr_ipv6")) if x], v.get("from_port"), v.get("to_port"), v.get("ip_protocol"))
    elif rtype == "google_compute_firewall" and str(v.get("direction", "INGRESS")).upper() == "INGRESS":
        for al in v.get("allow") or []:
            ports = al.get("ports") or ["0-65535"]
            for pr in ports:
                lo, _, hi = str(pr).partition("-")
                add(v.get("source_ranges"), lo, hi or lo, al.get("protocol", "all"))
    elif rtype == "azurerm_network_security_rule" and str(v.get("direction")).lower() == "inbound" and str(v.get("access")).lower() == "allow":
        src = [v.get("source_address_prefix")] + (v.get("source_address_prefixes") or [])
        rng = v.get("destination_port_range") or "*"
        for pr in [rng] + (v.get("destination_port_ranges") or []):
            if pr == "*":
                add(src, 0, 65535, v.get("protocol", "*"))
            else:
                lo, _, hi = str(pr).partition("-")
                add(src, lo, hi or lo, v.get("protocol", "*"))
    return out


def port_label(lo, hi, proto):
    if proto == "all" or (lo == 0 and hi == 65535):
        return "all ports"
    names = [f"{p} ({n})" for p, n in SENSITIVE_PORTS.items() if lo <= p <= hi]
    rng = str(lo) if lo == hi else f"{lo}-{hi}"
    return f"port {rng}" + (f" incl. {', '.join(names[:3])}" if names and lo != hi else (f" ({SENSITIVE_PORTS[lo]})" if lo == hi and lo in SENSITIVE_PORTS else ""))


def parse_policy(doc):
    if isinstance(doc, str):
        try:
            doc = json.loads(doc)
        except ValueError:
            return None
    if not isinstance(doc, dict):
        return None
    st = doc.get("Statement") or []
    return [st] if isinstance(st, dict) else st


def as_list(x):
    return x if isinstance(x, list) else ([] if x is None else [x])


def allow_actions(stmts):
    acts = set()
    for s in stmts or []:
        if s.get("Effect", "Allow") != "Allow":
            continue
        for a in as_list(s.get("Action")):
            for r in as_list(s.get("Resource")) or ["(none)"]:
                acts.add((str(a), str(r)))
    return acts


def check_policy(addr, attr, before, after, unknown):
    out = []
    if unknown:
        return [finding("INFO", "POLICY_UNKNOWN", addr, f"{attr} is only known after apply", "Review the rendered policy after apply or render it with a data source in a separate plan.")]
    b, a = parse_policy(before), parse_policy(after)
    if a is None:
        return out
    if attr == "assume_role_policy":
        for s in a:
            if s.get("Effect", "Allow") != "Allow":
                continue
            pr = s.get("Principal")
            vals = [pr] if isinstance(pr, str) else [v for x in (pr or {}).values() for v in as_list(x)]
            if "*" in vals and not s.get("Condition"):
                out.append(finding("HIGH", "TRUST_ANYONE", addr, "trust policy lets any AWS principal assume this role (Principal * without Condition)",
                                   "Name the exact account or role ARNs, and add sts:ExternalId or aws:PrincipalOrgID conditions."))
        bp = {v for s in (b or []) for x in (get(s, "Principal") or {}).values() for v in as_list(x)} if b and all(isinstance(get(s, "Principal"), dict) for s in b) else set()
        ap = {v for s in a if isinstance(s.get("Principal"), dict) for x in s["Principal"].values() for v in as_list(x)}
        new = sorted(ap - bp - {"*"})
        if b is not None and new:
            out.append(finding("MEDIUM", "TRUST_WIDENED", addr, "new principals can assume this role: " + ", ".join(new), "Confirm each new principal is expected (cross-account access?)."))
        return out
    for s in a:
        if s.get("Effect", "Allow") != "Allow":
            continue
        orig = [str(x) for x in as_list(s.get("Action"))]
        acts = [x.lower() for x in orig]
        res = [str(x) for x in as_list(s.get("Resource"))]
        pr = s.get("Principal")
        if pr is not None:
            vals = [pr] if isinstance(pr, str) else [v for x in pr.values() for v in as_list(x)]
            if "*" in vals and not s.get("Condition"):
                out.append(finding("HIGH", "PUBLIC_POLICY", addr, f"resource policy allows Principal * ({', '.join(acts)[:80]}) without a Condition",
                                   "Restrict Principal to known ARNs or add aws:SourceArn / aws:PrincipalOrgID / aws:SourceVpce conditions; use CloudFront OAC for public sites."))
        if s.get("NotAction"):
            out.append(finding("MEDIUM", "IAM_NOT_ACTION", addr, "Allow with NotAction grants everything except the listed actions", "List the allowed actions explicitly."))
        if "*" in acts and "*" in res:
            out.append(finding("HIGH", "IAM_ADMIN", addr, "Allow Action * on Resource * (full admin)", "Grant only the actions and resources this principal needs."))
            continue
        esc = sorted({o for o, x in zip(orig, acts) if x in ESCALATION_ACTIONS and x != "*" and ("*" in res or x in ("iam:*", "sts:*"))})
        if esc:
            out.append(finding("HIGH", "IAM_ESCALATION", addr, f"{', '.join(esc)} on {', '.join(res) or '*'} lets the holder gain more privileges",
                               "Scope iam:PassRole and sts:AssumeRole to specific role ARNs (and iam:PassedToService); avoid iam:* and kms:* wildcards."))
        wild = sorted({o for o, x in zip(orig, acts) if x.endswith(":*") and x not in ESCALATION_ACTIONS})
        if wild:
            sev = "HIGH" if "*" in res else "MEDIUM"
            out.append(finding(sev, "IAM_WILDCARD", addr, f"service wildcard {', '.join(wild)} on {', '.join(res)}",
                               "Replace with the specific actions used (check IAM Access Analyzer or CloudTrail for what is actually called)."))
    if b is not None:
        added = sorted({x[0] + (" on " + x[1] if x[1] not in ("*", "(none)") else " on *") for x in allow_actions(a) - allow_actions(b)})
        if added:
            out.append(finding("LOW", "IAM_WIDENED", addr, "policy grants new actions/resources: " + ", ".join(added[:8]) + (" ..." if len(added) > 8 else ""),
                               "Confirm the new permissions are needed; least privilege."))
    return out


def review_change(rc):
    out = []
    addr, rtype = rc.get("address", "?"), rc.get("type", "")
    ch = rc.get("change") or {}
    actions = ch.get("actions") or []
    kind = kind_of(actions)
    before, after = ch.get("before") or {}, ch.get("after") or {}
    unknown = ch.get("after_unknown") or {}
    reason = rc.get("action_reason", "")
    if rc.get("mode") == "data" or kind in ("no-op", "read"):
        return kind, out
    why = ""
    if ch.get("replace_paths"):
        why = " (forced by " + ", ".join(".".join(map(str, p)) for p in ch["replace_paths"]) + ")"
    elif reason:
        why = f" ({reason.replace('_', ' ')})"
    stateful = bool(STATEFUL.match(rtype))
    if kind == "forget":
        out.append(finding("INFO", "FORGET", addr, "removed from state but not destroyed (removed block)", "Make sure another configuration or team takes ownership of it."))
    if kind == "destroy" or kind.startswith("replace"):
        if stateful:
            extra = "; force_destroy = true, so everything in it is deleted too" if get(before, "force_destroy") is True else ""
            out.append(finding("HIGH", "DATA_LOSS", addr, f"{rtype} will be {'destroyed' if kind == 'destroy' else 'replaced, ' + kind[9:-1]}{why}; its data does not carry over{extra}",
                               "Back up or snapshot first; if this is a rename/refactor add a `moved` block or `terraform state mv`; add lifecycle prevent_destroy for data stores."))
        elif SECRET_GEN.match(rtype) and kind.startswith("replace"):
            out.append(finding("MEDIUM", "SECRET_REGENERATED", addr, f"{rtype} will be regenerated{why}; everything using the old value breaks",
                               "Check what consumes it (database password, keys) and plan the rotation."))
        elif kind == "destroy":
            sev = "MEDIUM" if DOWNTIME.match(rtype) or reason in ("delete_because_count_index", "delete_because_each_key") else "LOW"
            out.append(finding(sev, "DESTROY", addr, f"will be destroyed{why}",
                               "Confirm it is intended; for a rename/refactor use a `moved` block instead of destroy + create."))
        else:
            sev = "MEDIUM" if kind == "replace (destroy first)" and DOWNTIME.match(rtype) else "LOW"
            out.append(finding(sev, "REPLACE", addr, f"{kind}{why}",
                               "Expect a new ID/IP/DNS name; use create_before_destroy where possible and check dependents."))
        if reason in ("delete_because_count_index",):
            out.append(finding("MEDIUM", "COUNT_INDEX_SHIFT", addr, "destroyed because a count index moved (an item was removed from the middle of a list)",
                               "Switch to for_each with stable keys, or add moved blocks for the shifted indexes."))
        if reason == "delete_because_no_resource_config":
            out.append(finding("INFO", "REMOVED_FROM_CONFIG", addr, "resource no longer in configuration", "If it moved to another module or address, add a `moved` block; to keep it but stop managing it, use a `removed` block with destroy = false."))
    if kind in ("create", "update") or kind.startswith("replace"):
        bm, am = ch.get("before_sensitive") or {}, ch.get("after_sensitive") or {}
        for attr in PROTECTION_ATTRS:
            if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
                out.append(finding("HIGH" if kind == "update" else "MEDIUM", "PROTECTION_OFF", addr, f"{attr} {'turned off' if kind == 'update' else 'is false'}",
                                   f"Keep {attr} = true for production resources; turn it off only in a separate, reviewed change right before a planned delete."))
        if get(after, "skip_final_snapshot") is True and rtype in ("aws_db_instance", "aws_rds_cluster", "aws_docdb_cluster", "aws_neptune_cluster", "aws_redshift_cluster"):
            if kind != "update" or get(before, "skip_final_snapshot") is not True:
                out.append(finding("MEDIUM", "NO_FINAL_SNAPSHOT", addr, "skip_final_snapshot = true: a delete leaves no snapshot",
                                   "Set skip_final_snapshot = false with a final_snapshot_identifier."))
        if get(after, "force_destroy") is True and (kind != "update" or get(before, "force_destroy") is not True):
            out.append(finding("MEDIUM", "FORCE_DESTROY", addr, "force_destroy = true lets Terraform delete the bucket/zone with everything in it",
                               "Keep force_destroy = false on buckets and zones that hold real data."))
        for attr in ("storage_encrypted", "encrypted", "server_side_encryption"):
            if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
                out.append(finding("MEDIUM", "ENCRYPTION_OFF", addr, f"{attr} = false", "Enable encryption at rest (it usually forces replacement later, so do it now)."))
        if get(after, "backup_retention_period") == 0 and get(before, "backup_retention_period", 1) != 0:
            out.append(finding("MEDIUM", "BACKUPS_OFF", addr, "backup_retention_period = 0 disables automated backups", "Keep at least 7 days for production databases."))
        if get(after, "publicly_accessible") is True and get(before, "publicly_accessible") is not True:
            out.append(finding("HIGH", "PUBLIC_DATABASE", addr, "publicly_accessible = true gives the database a public endpoint",
                               "Keep databases private; connect through a bastion, VPN, SSM or RDS Proxy."))
        if rtype == "aws_s3_bucket_public_access_block":
            off = [k for k in ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
                   if get(after, k) is False and (kind != "update" or get(before, k) is not False)]
            if off:
                out.append(finding("HIGH", "S3_PUBLIC_ACCESS", addr, "S3 public access block turned off: " + ", ".join(off),
                                   "Keep all four settings true; serve public content through CloudFront with Origin Access Control."))
        if rtype in ("aws_s3_bucket_acl", "aws_s3_object") and get(after, "acl") in ("public-read", "public-read-write", "authenticated-read"):
            out.append(finding("HIGH", "PUBLIC_ACL", addr, f"acl = {after['acl']}", "Use private ACLs (BucketOwnerEnforced) and CloudFront for public files."))
        if rtype.startswith("google_") and "iam" in rtype:
            members = as_list(get(after, "member")) + as_list(get(after, "members"))
            if any(m in ("allUsers", "allAuthenticatedUsers") for m in members):
                out.append(finding("HIGH", "PUBLIC_IAM", addr, "grants a role to allUsers/allAuthenticatedUsers", "Grant to specific users, groups or service accounts."))
            if get(after, "role") in ("roles/owner", "roles/editor", "roles/iam.securityAdmin"):
                out.append(finding("HIGH", "IAM_ADMIN", addr, f"grants {after['role']}", "Use a narrower predefined or custom role."))
        if rtype == "azurerm_role_assignment" and get(after, "role_definition_name") in ("Owner", "User Access Administrator", "Contributor"):
            sev = "MEDIUM" if after["role_definition_name"] == "Contributor" else "HIGH"
            out.append(finding(sev, "IAM_ADMIN", addr, f"assigns {after['role_definition_name']}", "Use the narrowest built-in role at the smallest scope."))
        if rtype.startswith("aws_iam_") and rtype.endswith("policy_attachment"):
            arn = get(after, "policy_arn") or ""
            if ADMIN_POLICIES.search(arn) and get(before, "policy_arn") != arn:
                out.append(finding("HIGH", "IAM_ADMIN", addr, f"attaches {arn.split('/')[-1]}", "Attach a least-privilege policy instead; keep admin roles for break-glass use with MFA."))
            elif arn.endswith("FullAccess") and get(before, "policy_arn") != arn:
                out.append(finding("MEDIUM", "IAM_WILDCARD", addr, f"attaches {arn.split('/')[-1]}", "Prefer a scoped customer-managed policy."))
        if rtype == "aws_lambda_function_url" and get(after, "authorization_type") == "NONE":
            out.append(finding("MEDIUM", "PUBLIC_FUNCTION_URL", addr, "Lambda function URL with authorization_type NONE is public", "Use AWS_IAM auth or validate requests in the function."))
        new_open = open_rules(rtype, after) - (open_rules(rtype, before) if kind == "update" else set())
        for cidr, lo, hi, proto in sorted(new_open, key=lambda x: (x[1], x[2])):
            label = port_label(lo, hi, proto)
            if proto == "all" or (lo == 0 and hi == 65535) or any(lo <= p <= hi for p in SENSITIVE_PORTS):
                sev = "HIGH"
            elif lo in WEB_PORTS and hi == lo:
                sev = "INFO"
            else:
                sev = "MEDIUM"
            out.append(finding(sev, "PUBLIC_INGRESS", addr, f"new ingress from {cidr} on {label}",
                               "Restrict the source to known CIDRs or a load balancer security group; reach admin ports through SSM, VPN or a bastion." if sev != "INFO" else "Expected for a public web endpoint; make sure only the load balancer is exposed."))
        for attr in POLICY_ATTRS:
            if attr in after or get(unknown, attr):
                if sensitive(am, attr):
                    continue
                out += check_policy(addr, attr, get(before, attr), get(after, attr), get(unknown, attr) is True)
    return kind, out


def review_json(plan):
    if not isinstance(plan, dict) or "format_version" not in plan:
        raise ValueError("not a terraform show -json document (no format_version)")
    changes, findings = [], []
    for rc in plan.get("resource_changes") or []:
        kind, f = review_change(rc)
        if kind not in ("no-op", "read") and rc.get("mode") != "data":
            changes.append((kind, rc.get("address", "?")))
        findings += f
        if (rc.get("change") or {}).get("importing"):
            findings.append(finding("INFO", "IMPORT", rc.get("address", "?"), "will be imported", "Check the plan shows no further changes after import."))
        if rc.get("previous_address"):
            findings.append(finding("INFO", "MOVED", rc.get("address", "?"), f"moved from {rc['previous_address']}", "No action; moved blocks avoid destroy + create."))
    drift = [d.get("address", "?") for d in plan.get("resource_drift") or []]
    if drift:
        findings.append(finding("INFO", "DRIFT", ", ".join(drift[:5]) + (" ..." if len(drift) > 5 else ""),
                                f"{len(drift)} resource(s) changed outside Terraform", "Find out who changed them; the plan may revert manual fixes."))
    if plan.get("errored"):
        findings.append(finding("HIGH", "PLAN_ERRORED", "-", "the plan errored", "Fix the errors and re-plan before applying anything."))
    elif plan.get("complete") is False:
        findings.append(finding("MEDIUM", "PLAN_INCOMPLETE", "-", "plan is not complete (deferred or -target changes)", "Run a full plan after this apply."))
    return changes, findings, plan.get("terraform_version", "?")


TEXT_RE = re.compile(r"^\s*# (\S+) (will be created|will be destroyed|will be updated in-place|must be replaced|will be read during apply|will no longer be managed by Terraform)")


def review_text(text):
    text = re.sub(r"\x1b\[[0-9;]*m", "", text)
    lines = text.splitlines()
    changes, findings = [], []
    i = 0
    while i < len(lines):
        m = TEXT_RE.match(lines[i])
        if not m:
            i += 1; continue
        addr, verb = m.group(1), m.group(2)
        rtype = re.sub(r"^(module\.[^.]+\.)*", "", addr).split(".")[0]
        if addr.startswith("data.") or ".data." in addr or "read during apply" in verb:
            i += 1; continue
        block, j = [], i + 1
        while j < len(lines) and not TEXT_RE.match(lines[j]) and not lines[j].startswith("Plan:"):
            block.append(lines[j]); j += 1
        first = next((l.strip() for l in block if l.strip() and not l.strip().startswith("#")), "")
        reason = next((l.strip()[3:-1] for l in block[:3] if l.strip().startswith("# (")), "")
        kind = {"will be created": "create", "will be destroyed": "destroy", "will be updated in-place": "update",
                "will no longer be managed by Terraform": "forget"}.get(verb)
        if verb == "must be replaced":
            kind = "replace (create first)" if first.startswith("+/-") else "replace (destroy first)"
        forced = [re.sub(r"^[~+\-/ ]+", "", l.strip()).split(" ")[0] for l in block if "# forces replacement" in l]
        rc = {"address": addr, "type": rtype, "change": {"actions": {"create": ["create"], "update": ["update"], "destroy": ["delete"], "forget": ["forget"],
              "replace (create first)": ["create", "delete"], "replace (destroy first)": ["delete", "create"]}[kind],
              "replace_paths": [[f] for f in forced]}}
        if "out of range for count" in reason or "count index" in reason:
            rc["action_reason"] = "delete_because_count_index"
        elif "not in for_each map" in reason:
            rc["action_reason"] = "delete_because_each_key"
        elif "not in configuration" in reason:
            rc["action_reason"] = "delete_because_no_resource_config"
        k, f = review_change(rc)
        changes.append((k, addr))
        findings += f
        i = j
    if not changes and "No changes." not in text:
        raise ValueError("no resource changes found; is this terraform plan output?")
    findings.append(finding("INFO", "TEXT_MODE", "-", "text plan: only actions were checked, not attribute values (IAM, ingress, protection flags)",
                            "Run `terraform show -json plan.out > plan.json` and review the JSON for full checks."))
    return changes, findings, "version unknown (text plan)"


def summarize(changes, findings):
    counts = {}
    for k, _ in changes:
        key = "replace" if k.startswith("replace") else k
        counts[key] = counts.get(key, 0) + 1
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["resource"]))
    sev = {s: sum(1 for f in findings if f["severity"] == s) for s in SEV_ORDER}
    if sev["HIGH"]:
        verdict = "DO NOT APPLY as is: resolve or explicitly accept every HIGH finding"
    elif sev["MEDIUM"]:
        verdict = "Apply after review: MEDIUM findings need an owner's OK"
    else:
        verdict = "Low risk"
    return counts, sev, verdict


def render(path, version, changes, findings):
    counts, sev, verdict = summarize(changes, findings)
    out = [f"# Terraform plan risk review: {path}", "",
           f"Terraform {version}. Changes: " + ", ".join(f"{counts.get(k, 0)} {k}" for k in ("create", "update", "replace", "destroy")) +
           (f", {counts['forget']} forget" if counts.get("forget") else ""),
           f"Findings: {sev['HIGH']} HIGH, {sev['MEDIUM']} MEDIUM, {sev['LOW']} LOW, {sev['INFO']} INFO",
           f"Verdict: {verdict}", "", "## Findings", ""]
    if findings:
        out += ["| Severity | Code | Resource | Problem | What to do |", "|---|---|---|---|---|"]
        for f in findings:
            out.append(f"| {f['severity']} | {f['code']} | `{f['resource']}` | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    out += ["", "## All changes", "", "| Action | Resource |", "|---|---|"]
    order = {"destroy": 0, "replace (destroy first)": 1, "replace (create first)": 2, "update": 3, "create": 4, "forget": 5}
    for k, a in sorted(changes, key=lambda x: (order.get(x[0], 9), x[1])):
        out.append(f"| {k} | `{a}` |")
    out += ["", "Plan JSON can contain secrets in plain text: do not paste it into tickets or chats; share this summary instead."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    def codes(rc):
        return {f["code"]: f["severity"] for f in review_change(rc)[1]}
    def rc(rtype, actions, before=None, after=None, **kw):
        d = {"address": f"{rtype}.x", "mode": "managed", "type": rtype, "change": {"actions": actions, "before": before, "after": after, **kw.pop("change", {})}}
        d.update(kw)
        return d
    t("kind names", kind_of(["delete", "create"]) == "replace (destroy first)" and kind_of(["create", "delete"]) == "replace (create first)" and kind_of(["delete"]) == "destroy")
    t("db destroy = HIGH DATA_LOSS", codes(rc("aws_db_instance", ["delete"], {"id": "a"}, None)).get("DATA_LOSS") == "HIGH")
    t("bucket replace = HIGH DATA_LOSS", codes(rc("aws_s3_bucket", ["create", "delete"], {}, {})).get("DATA_LOSS") == "HIGH")
    t("stateless destroy LOW", codes(rc("aws_iam_role", ["delete"], {}, None)).get("DESTROY") == "LOW")
    t("instance destroy-first replace MEDIUM", codes(rc("aws_instance", ["delete", "create"], {}, {})).get("REPLACE") == "MEDIUM")
    t("create-first replace LOW", codes(rc("aws_lb_target_group", ["create", "delete"], {}, {})).get("REPLACE") == "LOW")
    t("replace_paths shown", "forced by engine" in review_change(rc("aws_instance", ["delete", "create"], {}, {}, change={"replace_paths": [["engine"]]}))[1][0]["problem"])
    t("random_password regenerated", codes(rc("random_password", ["delete", "create"], {}, {})).get("SECRET_REGENERATED") == "MEDIUM")
    t("count index shift", "COUNT_INDEX_SHIFT" in codes(rc("aws_instance", ["delete"], {}, None, action_reason="delete_because_count_index")))
    t("no-op ignored", review_change(rc("aws_vpc", ["no-op"], {}, {}))[1] == [])
    t("deletion_protection turned off HIGH", codes(rc("aws_db_instance", ["update"], {"deletion_protection": True}, {"deletion_protection": False})).get("PROTECTION_OFF") == "HIGH")
    t("skip_final_snapshot MEDIUM", "NO_FINAL_SNAPSHOT" in codes(rc("aws_db_instance", ["create"], None, {"skip_final_snapshot": True})))
    t("force_destroy MEDIUM", "FORCE_DESTROY" in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": False}, {"force_destroy": True})))
    t("unchanged force_destroy not re-flagged", "FORCE_DESTROY" not in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": True}, {"force_destroy": True})))
    t("encryption off", "ENCRYPTION_OFF" in codes(rc("aws_ebs_volume", ["create"], None, {"encrypted": False})))
    t("publicly_accessible HIGH", codes(rc("aws_db_instance", ["update"], {"publicly_accessible": False}, {"publicly_accessible": True})).get("PUBLIC_DATABASE") == "HIGH")
    sg_b = {"ingress": [{"cidr_blocks": ["10.0.0.0/8"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
    sg_a = {"ingress": sg_b["ingress"] + [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
    t("new SSH from 0.0.0.0/0 HIGH", codes(rc("aws_security_group", ["update"], sg_b, sg_a)).get("PUBLIC_INGRESS") == "HIGH")
    t("existing open rule not re-flagged", "PUBLIC_INGRESS" not in codes(rc("aws_security_group", ["update"], sg_a, sg_a)))
    t("443 from internet INFO", codes(rc("aws_vpc_security_group_ingress_rule", ["create"], None, {"cidr_ipv4": "0.0.0.0/0", "from_port": 443, "to_port": 443, "ip_protocol": "tcp"})).get("PUBLIC_INGRESS") == "INFO")
    t("all protocols HIGH", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "cidr_blocks": ["0.0.0.0/0"], "from_port": 0, "to_port": 0, "protocol": "-1"})).get("PUBLIC_INGRESS") == "HIGH")
    t("ipv6 ::/0 8080 MEDIUM", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "ipv6_cidr_blocks": ["::/0"], "from_port": 8080, "to_port": 8080, "protocol": "tcp"})).get("PUBLIC_INGRESS") == "MEDIUM")
    t("gcp firewall 3389", codes(rc("google_compute_firewall", ["create"], None, {"direction": "INGRESS", "source_ranges": ["0.0.0.0/0"], "allow": [{"protocol": "tcp", "ports": ["3389"]}]})).get("PUBLIC_INGRESS") == "HIGH")
    t("azure nsg * HIGH", codes(rc("azurerm_network_security_rule", ["create"], None, {"direction": "Inbound", "access": "Allow", "source_address_prefix": "*", "destination_port_range": "*", "protocol": "Tcp"})).get("PUBLIC_INGRESS") == "HIGH")
    t("S3 public access block off", codes(rc("aws_s3_bucket_public_access_block", ["update"], {"block_public_policy": True}, {"block_public_policy": False})).get("S3_PUBLIC_ACCESS") == "HIGH")
    t("public-read ACL", "PUBLIC_ACL" in codes(rc("aws_s3_bucket_acl", ["create"], None, {"acl": "public-read"})))
    pol = lambda *st: json.dumps({"Version": "2012-10-17", "Statement": list(st)})
    t("admin policy HIGH", codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})})).get("IAM_ADMIN") == "HIGH")
    t("PassRole on * HIGH", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"})})).get("IAM_ESCALATION") == "HIGH")
    t("s3:* on bucket MEDIUM", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"})})).get("IAM_WILDCARD") == "MEDIUM")
    t("IAM_WIDENED lists new actions", "s3:DeleteObject on arn:aws:s3:::b/*" in next(f["problem"] for f in review_change(rc("aws_iam_role_policy", ["update"],
        {"policy": pol({"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})},
        {"policy": pol({"Effect": "Allow", "Action": ["s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::b/*"})}))[1] if f["code"] == "IAM_WIDENED"))
    t("Deny statements ignored", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Deny", "Action": "*", "Resource": "*"})})))
    t("trust anyone HIGH", codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"})})).get("TRUST_ANYONE") == "HIGH")
    t("trust with condition ok", "TRUST_ANYONE" not in codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-1"}}})})))
    t("bucket policy Principal * HIGH", codes(rc("aws_s3_bucket_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})})).get("PUBLIC_POLICY") == "HIGH")
    t("unknown policy INFO", "POLICY_UNKNOWN" in codes(rc("aws_iam_policy", ["create"], None, {}, change={"after_unknown": {"policy": True}})))
    t("sensitive policy skipped", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})}, change={"after_sensitive": {"policy": True}})))
    t("AdministratorAccess attachment HIGH", codes(rc("aws_iam_role_policy_attachment", ["create"], None, {"policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"})).get("IAM_ADMIN") == "HIGH")
    t("gcp allUsers HIGH", "PUBLIC_IAM" in codes(rc("google_storage_bucket_iam_member", ["create"], None, {"member": "allUsers", "role": "roles/storage.objectViewer"})))
    t("not a plan rejected", raises(lambda: review_json({"foo": 1})))
    ch, fs, _ = review_json({"format_version": "1.2", "resource_changes": [rc("aws_db_instance", ["delete"], {}, None)], "resource_drift": [{"address": "aws_lb.a"}], "errored": False, "complete": True})
    t("json review with drift", ch == [("destroy", "aws_db_instance.x")] and {"DATA_LOSS", "DRIFT"} <= {f["code"] for f in fs})
    txt = """  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine = "postgres" -> "mysql" # forces replacement
    }

  # aws_instance.web[1] will be destroyed
  # (because index [1] is out of range for count)
  - resource "aws_instance" "web" {
    }

  # aws_lb_target_group.a must be replaced
+/- resource "aws_lb_target_group" "a" {
    }

Plan: 2 to add, 0 to change, 3 to destroy.
"""
    ch, fs, _ = review_text(txt)
    c = {f["code"] for f in fs}
    t("text mode actions", ch == [("replace (destroy first)", "aws_db_instance.main"), ("destroy", "aws_instance.web[1]"), ("replace (create first)", "aws_lb_target_group.a")])
    t("text mode findings", {"DATA_LOSS", "DESTROY", "REPLACE", "TEXT_MODE", "COUNT_INDEX_SHIFT"} <= c and "forced by engine" in next(f["problem"] for f in fs if f["code"] == "DATA_LOSS"))
    t("text mode rejects junk", raises(lambda: review_text("hello")))
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def raises(fn):
    try:
        fn()
    except ValueError:
        return True
    return False


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("plan", nargs="?", help="plan JSON (terraform show -json) or plan text (terraform plan -no-color)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.plan:
        ap.print_usage(sys.stderr); return 2
    try:
        raw = open(a.plan, encoding="utf-8").read()
        if raw.lstrip().startswith("{"):
            changes, findings, version = review_json(json.loads(raw))
        else:
            changes, findings, version = review_text(raw)
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    counts, sev, verdict = summarize(changes, findings)
    if a.json:
        print(json.dumps({"terraform_version": version, "counts": counts, "severity": sev, "verdict": verdict,
                          "findings": findings, "changes": [{"action": k, "address": x} for k, x in changes]}, indent=2))
    else:
        print(render(a.plan, version, changes, findings))
    return 1 if sev["HIGH"] else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonDevOps+2
F@f
0
Công cụ Rà soát Độ an toàn của Shell Script
Kỹ năng

Rà soát các kịch bản (script) bash và POSIX sh trước khi chúng chạy trong CI, cron hoặc môi trường production. Phát hiện và gắn cờ việc thiếu chế độ nghiêm ngặt (strict mode), lệnh rm -rf trên các biến có thể bị rỗng, truyền kết quả curl qua pipe vào shell, lệnh eval, mở rộng biến không đặt trong dấu ngoặc kép, lệnh cd không kiểm tra lỗi, phân tích cú pháp kết quả lệnh ls, chmod 777, bí mật (secret) gắn cứng trong mã, tệp /tmp có tên dễ đoán trước, lệnh ssh nuốt mất dữ liệu của vòng lặp read, mã thoát (exit code) bị che khuất và các cú pháp riêng của bash (bashism) chạy dưới /bin/sh, sau đó trả về bản script đã được sửa lỗi. Bao gồm một bộ kiểm tra mã nguồn (linter) bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: shell-script-safety-reviewer
description: Rà soát các kịch bản (script) bash và POSIX sh trước khi chúng chạy trong CI, cron hoặc môi trường production. Phát hiện và gắn cờ việc thiếu chế độ nghiêm ngặt (strict mode), lệnh rm -rf trên các biến có thể bị rỗng, truyền kết quả curl qua pipe vào shell, lệnh eval, mở rộng biến không đặt trong dấu ngoặc kép, lệnh cd không kiểm tra lỗi, phân tích cú pháp kết quả lệnh ls, chmod 777, bí mật (secret) gắn cứng trong mã, tệp /tmp có tên dễ đoán trước, lệnh ssh nuốt mất dữ liệu của vòng lặp read, mã thoát (exit code) bị che khuất và các cú pháp riêng của bash (bashism) chạy dưới /bin/sh, sau đó trả về bản script đã được sửa lỗi. Bao gồm một bộ kiểm tra mã nguồn (linter) bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Công cụ Rà soát Độ an toàn của Shell Script

Bạn rà soát các shell script theo đúng cách một kỹ sư SRE cẩn trọng sẽ làm trước khi cho phép chúng chạy tự động không người giám sát: các script triển khai (deploy), tác vụ định kỳ (cron job), các bước CI, trình cài đặt và các entrypoint của Docker. Bạn tìm ra những dòng lệnh có nguy cơ xóa nhầm dữ liệu, làm lộ bí mật (secret), thực thi mã không đáng tin cậy hoặc gặp lỗi âm thầm mà không báo, giải thích từng vấn đề bằng ngôn ngữ dễ hiểu, và trả lại một bản script đã được sửa lỗi hoàn chỉnh mà vẫn thực hiện đúng công việc ban đầu.

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

- `scripts/shell_lint.py` - bộ kiểm tra mã nguồn (linter) theo từng dòng dành cho bash và POSIX sh có khả năng hiểu dấu ngoặc kép, chú thích, heredoc, `[[ ]]`, `(( ))`, phép gán và các từ khóa trong cấu trúc case; in ra bảng các phát hiện kèm cách khắc phục, hoặc xuất ra định dạng JSON (chỉ sử dụng thư viện chuẩn Python 3)
- `references/shell-pitfalls.md` - mọi quy tắc mà linter kiểm tra: điều gì sẽ xảy ra khi lỗi, ví dụ tối thiểu gây lỗi và cách khắc phục
- `references/safe-patterns.md` - phần đầu tệp (header) bật chế độ nghiêm ngặt, cách tạo tệp tạm an toàn và dọn dẹp, vòng lặp duyệt tệp an toàn, phân tích đối số đầu vào, xử lý bí mật, tải tệp và ghi nhật ký (logging)
- `templates/shell-review.md` - mẫu báo cáo rà soát để bàn giao lại
- `examples/deploy.sh` - một script triển khai thực tế chứa 20 phát hiện lỗi (trong đó có 3 lỗi mức HIGH)
- `examples/deploy-fixed.sh` - chính script đó sau khi được rà soát và sửa lỗi; vượt qua hoàn toàn bước kiểm tra của linter và ShellCheck

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

### 1. Thu thập ngữ cảnh
Hãy hỏi xem cái gì sẽ chạy script này (CI, cron, systemd, hay con người chạy trực tiếp), chạy trên shell và hệ điều hành nào (bash 5 trên Ubuntu, sh trên Alpine/busybox, hay macOS bash 3.2), ai có thể kiểm soát các dữ liệu đầu vào của nó (đối số dòng lệnh, biến môi trường, tên tệp, dữ liệu mạng) và liệu nó có chạy dưới quyền root hay không. Hãy yêu cầu cung cấp toàn bộ script chứ không chỉ một đoạn cắt rời: chế độ nghiêm ngặt (strict mode) và các bẫy sự kiện (trap) thường nằm ở ngay đầu tệp.

### 2. Chạy bộ kiểm tra (linter)
```
python3 scripts/shell_lint.py examples/deploy.sh
python3 scripts/shell_lint.py deploy.sh entrypoint.sh --min-severity MEDIUM
python3 scripts/shell_lint.py install.sh --json --ignore BACKTICKS
python3 scripts/shell_lint.py --self-test
```
Mã thoát (Exit) 1 có nghĩa là có ít nhất một phát hiện mức HIGH, 2 nghĩa là không thể đọc được tệp. Bỏ qua cảnh báo cho một dòng chủ đích bằng chú thích `# shell-lint: disable=CODE`. Nếu trên máy có sẵn ShellCheck, hãy chạy cả công cụ đó (`shellcheck -f gcc script.sh`) rồi gộp kết quả lại; bộ linter này bổ sung thêm những bước kiểm tra mà ShellCheck không làm (lộ bí mật, `curl | sh`, `chmod 777`, tên tệp `/tmp` cố định, tắt kiểm tra TLS).

### 3. Rà soát thủ công
Bộ linter chỉ là bước sàng lọc đầu tiên. Hãy đọc kỹ script từ trên xuống dưới và kiểm tra những điểm mà biểu thức chính quy (regex) không thể phát hiện được (xem `references/shell-pitfalls.md`):
- Những đầu vào nào là không đáng tin cậy, và chúng có đi tới lệnh `rm`, `eval`, `ssh`, câu lệnh `sql` hay đường dẫn tệp nào không?
- Mọi bước có tính chất xóa/phá hủy dữ liệu có chỉ được chạy sau khi đã thỏa mãn các điều kiện tiên quyết (đúng thư mục, biến không rỗng, đã xác nhận máy chủ đích) hay không?
- Script có đảm bảo tính lũy đẳng (idempotent) không: có thể chạy hai lần liên tiếp, hoặc chạy tiếp sau khi bị dừng giữa chừng do lỗi không?
- Các lỗi có hiển thị rõ ràng không: khi có lỗi xảy ra trong đường ống (pipe), subshell hoặc hàm, script có dừng lại ngay và để lại thông báo rõ ràng không?
- Script có dọn dẹp sạch các tệp tạm, tệp khóa (lock) và các tiến trình chạy ngầm khi thoát bình thường cũng như khi nhấn Ctrl+C không?

### 4. Sửa lỗi và bàn giao
Điền đầy đủ vào `templates/shell-review.md`: kết luận tổng quan, bảng danh sách phát hiện (mức độ nghiêm trọng, dòng, vấn đề, cách sửa), toàn bộ mã nguồn script đã sửa, bản so sánh (diff) những thay đổi quan trọng, và hướng dẫn cách kiểm thử an toàn (chạy thử dry run, dùng `bash -n`, chạy trong thư mục hoặc container dùng một lần, bật `set -x`). Giữ nguyên hành vi nghiệp vụ của script; nêu rõ bất kỳ thay đổi hành vi nào.

## Ví dụ kết quả
`examples/deploy.sh`: 20 phát hiện, 3 lỗi mức HIGH. Một token Slack bị gắn cứng trong mã (HARDCODED_SECRET, cần xoay vòng/đổi token mới), một trình cài đặt được truyền thẳng từ lệnh curl qua pipe vào sudo bash (CURL_PIPE_SHELL), và lệnh `rm -rf $WEB_ROOT/*` sẽ xóa sạch toàn bộ `/*` nếu biến WEB_ROOT vô tình bị rỗng (RM_EMPTY_VAR). Script không có `set -e`, nên nếu lệnh `cd $BUILD_DIR` thất bại thì các lệnh rm và cp phía sau sẽ chạy ngay tại thư mục không mong muốn; lệnh `ssh` nằm bên trong vòng lặp `while read host` nuốt mất luồng dữ liệu của tệp hosts.txt, khiến chỉ có máy chủ đầu tiên được khởi động lại (SSH_STDIN_LOOP); cú pháp `local count=$(...)` làm che khuất trạng thái lỗi. Tệp `examples/deploy-fixed.sh` đã khắc phục toàn bộ các lỗi trên và vượt qua bước kiểm tra linter không còn cảnh báo nào.

## Quy tắc

- Xếp hạng theo mức độ ảnh hưởng: nguy cơ mất dữ liệu, thực thi mã tùy ý và lộ bí mật phải đặt lên hàng đầu, tiếp theo là các lỗi âm thầm (silent failures), cuối cùng mới đến phong cách viết mã (style).
- Mặc định luôn đặt biến trong dấu ngoặc kép; giải thích rõ tại vị trí duy nhất nào mà việc tách từ (word splitting) là có chủ đích và hãy sử dụng mảng (array) tại đó.
- Không bao giờ chỉ đề xuất mỗi `set -e` làm giải pháp duy nhất: phải kiểm tra cả các đường ống (pipefail), subshell, cú pháp `local x=$(...)` và các ngữ cảnh `if cmd` nơi chế độ tự thoát khi lỗi (errexit) bị vô hiệu hóa.
- Yêu cầu xoay vòng (rotate) bất kỳ bí mật nào tìm thấy trong script, ngay cả khi kho mã nguồn (repo) ở chế độ riêng tư, và phải nêu rõ điều đó trong báo cáo.
- Ưu tiên sử dụng cú pháp chuẩn POSIX khi dòng shebang là `/bin/sh`; nếu không thì phải đổi dòng shebang thành `bash`.
- Không được chạy script đang rà soát trực tiếp trên các hệ thống thật để "kiểm thử"; hãy dùng `bash -n`, chạy trong container hoặc dùng cờ chạy thử (dry-run).
FILE:references/shell-pitfalls.md
# Các bẫy thường gặp trong Shell được kiểm tra bởi shell_lint.py

Mức độ nghiêm trọng: HIGH = có thể xóa nhầm dữ liệu, chạy mã không đáng tin cậy hoặc làm lộ bí mật. MEDIUM = trả về kết quả sai hoặc gặp lỗi âm thầm với dữ liệu đầu vào thực tế. LOW = mã dễ vỡ hoặc dùng cú pháp cũ không còn khuyến khích.

## Mức HIGH

### RM_EMPTY_VAR / RM_ROOT
Lệnh `rm -rf $DIR/*` sẽ biến thành `rm -rf /*` khi biến DIR bị rỗng hoặc chưa được khởi tạo (do gõ sai tên biến, thiếu đối số truyền vào, hoặc lệnh thay thế `$(...)` bị lỗi). Trình khách (client) Steam trên Linux từng phát hành bản cập nhật dính chính xác lỗi này vào năm 2015.
```bash
rm -rf -- "?DIR is empty"/*      # aborts with a message if DIR is empty
```
Chỉ dùng mỗi `set -u` sẽ không giúp ích được gì khi biến đó đã được khởi tạo nhưng mang giá trị là một chuỗi rỗng.

### CURL_PIPE_SHELL
Lệnh `curl https://x/install.sh | bash` sẽ thực thi bất cứ thứ gì mà máy chủ (hoặc kẻ tấn công đứng giữa - MITM) gửi về, và nếu kết nối mạng bị đứt giữa chừng thì bash sẽ chạy một nửa script bị cắt cụt. Hãy tải về thành một tệp, kiểm tra mã băm SHA-256 cố định hoặc chữ ký số, đọc nội dung tệp, rồi mới chạy nó.

### EVAL_VAR
Lệnh `eval "$cmd"` sẽ thực thi bất kỳ ký tự `;`, `$(...)` hoặc dấu huyền (backtick) nào nằm bên trong dữ liệu. Hãy xây dựng câu lệnh dưới dạng mảng: `cmd=(rsync -a -- "$src" "$dst"); "cmd[@]"`. Để điều hướng lệnh (dispatch), hãy dùng cấu trúc `case`.

### HARDCODED_SECRET
Một chuỗi token, mật khẩu hoặc khóa viết trực tiếp trong script sẽ nằm lại trong lịch sử git, nhật ký CI (khi bật `set -x`) và kết quả lệnh `ps`. Hãy đọc bí mật từ biến môi trường hoặc trình quản lý bí mật (secret manager) và cho dừng sớm nếu thiếu: `TOKEN="?"`. Hãy xoay vòng (đổi mới) giá trị đã bị lộ; việc xóa dòng mã đó đi không làm nó biến mất khỏi lịch sử git.

## Mức MEDIUM

### NO_STRICT_MODE / NO_PIPEFAIL
Nếu không có `set -e`, một câu lệnh bị lỗi sẽ bị bỏ qua và câu lệnh tiếp theo vẫn cứ chạy bình thường. Nếu không có `pipefail`, câu lệnh `build | tee log` vẫn báo thành công ngay cả khi bước build thất bại. Hãy dùng `set -euo pipefail` trong bash và `set -eu` trong POSIX sh. Lưu ý các lỗ hổng của nó: chế độ tự thoát khi lỗi (errexit) bị bỏ qua bên trong điều kiện `if`, `while`, các chuỗi lệnh nối bằng `&&`/`||` cùng các hàm được gọi từ bên trong chúng, và cả cú pháp `local x=$(cmd)` (xem bên dưới).

### UNQUOTED_VAR
Các biểu thức `$var` và `$(cmd)` không đặt trong dấu ngoặc kép sẽ bị tách từ dựa theo biến `IFS` (khoảng trắng, tab, xuống dòng) và mỗi mảnh tách ra lại tiếp tục bị mở rộng theo ký tự đại diện (glob expansion). Lệnh `cp $src $dst` với `src="My File.txt"` sẽ sao chép nhầm hai tệp không tồn tại; lệnh `rm $f` với `f='*'` sẽ xóa sạch mọi thứ. Hãy đặt mọi biểu thức mở rộng vào trong dấu ngoặc kép; dùng `"$@"` cho toàn bộ đối số và `"arr[@]"` cho mảng. Những vị trí an toàn khi không có dấu ngoặc kép: vế phải của phép gán `x=$y`, bên trong `[[ ]]` (ngoại trừ vế phải của toán tử `==`/`=~` vì ở đó nó sẽ trở thành mẫu so khớp pattern), bên trong `(( ))`, và trong cấu trúc `case $x in`.

### CD_UNCHECKED
Nếu lệnh `cd build` thất bại, toàn bộ phần còn lại của script sẽ chạy ngay tại thư mục hiện hành: `cd build; rm -rf *`. Hãy dùng `cd -- "$dir" || exit 1` hoặc bật chế độ nghiêm ngặt. Trong các vòng lặp, hãy dùng subshell: `(cd -- "$d" && make)`.

### LS_PARSING
Vòng lặp `for f in $(ls *.log)` sẽ bị lỗi khi tên tệp có chứa khoảng trắng hoặc ký tự xuống dòng, đồng thời làm biểu thức đại diện (glob) bị chạy hai lần. Hãy dùng trực tiếp biểu thức glob: `for f in ./*.log; do [ -e "$f" ] || continue; ...; done`, hoặc dùng `find . -name '*.log' -print0 | while IFS= read -r -d '' f; do ...; done`.

### CHMOD_777
Các tệp và thư mục cho phép mọi người dùng đều có quyền ghi (world-writable) sẽ tạo điều kiện cho bất kỳ người dùng cục bộ hoặc dịch vụ bị xâm nhập nào thay thế mã nguồn hoặc dữ liệu. Hãy dùng quyền 755/644, hoặc 700/600 cho dữ liệu riêng tư, hoặc dùng cú pháp `u=rwX,g=rX,o=`.

### PREDICTABLE_TMP / NO_TRAP_CLEANUP (LOW)
Một đường dẫn cố định như `/tmp/app.log` có thể bị một người dùng khác tạo sẵn từ trước dưới dạng liên kết tượng trưng (symlink) (khiến bạn vô tình ghi đè lên tệp đích của họ dưới quyền root) và gây xung đột khi có nhiều tiến trình chạy song song. Hãy dùng `mktemp`/`mktemp -d` và xóa tệp tạm khi kết thúc bằng `trap '...' EXIT`.

### LOCAL_MASKS_STATUS
Cú pháp `local out=$(cmd)` trả về mã trạng thái của lệnh `local` (luôn là 0), vì vậy `set -e` và biến `$?` không bao giờ phát hiện được việc `cmd` đã thất bại. Điều tương tự cũng xảy ra với `export` và `readonly`. Hãy khai báo biến trước, rồi thực hiện phép gán ở dòng tiếp theo.

### SSH_STDIN_LOOP
Vòng lặp `while read host; do ssh "$host" cmd; done < hosts.txt` sẽ dừng lại ngay sau máy chủ đầu tiên bởi vì lệnh `ssh` đã đọc hết phần còn lại của luồng đầu vào chuẩn (stdin). Hãy dùng `ssh -n` hoặc `< /dev/null`. Nguyên tắc tương tự cũng áp dụng cho `ffmpeg`, `docker exec -i` và bất kỳ công cụ nào khác tự động đọc dữ liệu từ stdin.

### TLS_DISABLED
Các tùy chọn `curl -k`, `wget --no-check-certificate` và `StrictHostKeyChecking=no` sẽ tắt bỏ bước kiểm tra xác nhận bạn đang giao tiếp với đúng máy chủ đích. Hãy sửa lại gói chứng chỉ CA (CA bundle), ghim cố định khóa máy chủ (host key) vào tệp `known_hosts`, hoặc chỉ dùng `StrictHostKeyChecking=accept-new` cho lần kết nối đầu tiên trên các mạng nội bộ đáng tin cậy.

### BASHISM_IN_SH
Khi chạy dưới `#!/bin/sh` (trỏ tới dash trên Debian/Ubuntu, hoặc busybox trên Alpine), những cú pháp sau đây sẽ gây lỗi: `[[ ]]`, toán tử `==` bên trong `[ ]`, từ khóa `function f`, mảng (arrays), lệnh `source`, `<<<`, `&>`, `$'...'`. Hãy chuyển dòng shebang sang `bash` hoặc sử dụng các cú pháp chuẩn POSIX tương ứng là `[ ]`, `=`, `.`, và các tham số vị trí.

## Mức LOW

- NO_SHEBANG: shell hiện tại của người gọi sẽ được dùng để chạy script; hành vi sẽ khác nhau giữa bash, zsh và sh.
- READ_WITHOUT_R: lệnh `read` không có cờ `-r` sẽ nuốt mất các dấu gạch chéo ngược (backslash); nếu không có `IFS=` thì nó sẽ tự cắt bỏ khoảng trắng ở đầu và cuối dòng.
- BACKTICKS: dấu huyền rất khó lồng nhau và khó kết hợp với dấu ngoặc kép; hãy dùng `$(...)`.

## Các vấn đề bộ linter không quét được (cần rà soát thủ công)
- Đầu vào không đáng tin cậy đi tới câu lệnh sql, ssh hoặc đường dẫn tệp (nguy cơ tiêm lệnh injection, duyệt thư mục ngược `../`); tên tệp bắt đầu bằng dấu gạch ngang `-` (hãy dùng `--`).
- Điều kiện tranh chấp (race condition) trên các tệp khóa (lock files); hãy dùng `flock`.
- Thiếu lệnh `wait` cho các tiến trình chạy ngầm, và các tiến trình mồ côi không được dọn dẹp khi script thoát.
- Sự khác biệt về thiết lập vùng (locale) và bộ công cụ giữa các hệ điều hành (GNU so với BSD ở các lệnh `sed -i`, `date -d`, `readlink -f` trên macOS).
- Lộ bí mật vào nhật ký khi bật `set -x`; hãy tắt chế độ xtrace xung quanh các đoạn mã xử lý bí mật.
FILE:references/safe-patterns.md
# Các mẫu viết mã an toàn cho bash và sh script

## Phần đầu tệp (Header)
```bash
#!/usr/bin/env bash
# What it does, how to call it, what it needs (env vars, tools).
set -euo pipefail
IFS=$'\n\t'                      # optional: no splitting on spaces
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "BASH_SOURCE[0]")" && pwd)"
```
Đối với POSIX sh: dùng `set -eu` (tùy chọn `pipefail` có hỗ trợ trong dash 0.5.11+ và busybox, nhưng không phải mọi môi trường đều có).

## Dừng chương trình kèm thông báo lỗi
```bash
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || die "jq is required"
[ -d "$TARGET" ] || die "no such directory: $TARGET"
```

## Xử lý đối số đầu vào
```bash
usage() { echo "usage: $0 [-n] <release>" >&2; exit 2; }
dry_run=0
while getopts ":n" opt; do
  case $opt in
    n) dry_run=1 ;;
    *) usage ;;
  esac
done
shift $((OPTIND - 1))
release="-"; [ -n "$release" ] || usage
```

## Tệp tạm và dọn dẹp
```bash
tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT
trap 'exit 130' INT TERM          # EXIT trap still runs
```

## Các bước xóa/phá hủy dữ liệu
```bash
target="?WEB_ROOT is empty"
case $target in /|/usr|/etc|"$HOME") die "refusing to clean $target" ;; esac
run() { if [ "$dry_run" -eq 1 ]; then echo "+ $*"; else "$@"; fi; }
run rm -rf -- "$target"/*
```

## Vòng lặp duyệt qua tệp và dòng văn bản
```bash
for f in "$dir"/*.csv; do [ -e "$f" ] || continue; process "$f"; done
find "$dir" -type f -name '*.log' -print0 | while IFS= read -r -d '' f; do gzip -- "$f"; done
while IFS= read -r line || [ -n "$line" ]; do echo "$line"; done < input.txt   # last line without newline
```

## Biểu diễn câu lệnh dưới dạng dữ liệu (mảng)
```bash
args=(--archive --delete -- "$src/" "$dst/")
[ "$dry_run" -eq 1 ] && args=(--dry-run "args[@]")
rsync "args[@]"
```

## Xử lý bí mật (Secrets)
- Đọc từ biến môi trường hoặc từ tệp được phân quyền 600: `token="$(<"$CRED_FILE")"`.
- Truyền vào các công cụ thông qua luồng stdin hoặc biến môi trường, tuyệt đối không truyền qua đối số dòng lệnh (vì sẽ bị lộ khi xem bằng lệnh `ps`): `curl -H @- <<<"Authorization: Bearer $token"`.
- Đặt `set +x` bao quanh đoạn mã xử lý bí mật.

## Tải tệp từ mạng
```bash
curl -fsSL --retry 3 -o "$tmpdir/tool.tgz" "$URL"
echo "$SHA256  $tmpdir/tool.tgz" | sha256sum -c -
```
Cờ `-f` giúp báo lỗi ngay khi gặp mã lỗi HTTP thay vì lưu trang thông báo lỗi thành tệp kết quả.

## Khóa tiến trình (Locking) và tính lũy đẳng (Idempotency)
```bash
exec 9>/var/lock/deploy.lock
flock -n 9 || die "another deploy is running"
mkdir -p -- "$dir"                 # no error if it exists
ln -sfn -- "$release_dir" current  # atomic switch of a symlink
```

## Ghi nhật ký (Logging)
```bash
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
```
Hãy gửi nhật ký ra luồng lỗi chuẩn (stderr) để luồng đầu ra chuẩn (stdout) luôn sạch sẽ dành cho dữ liệu.

## Kiểm thử script một cách an toàn
1. Chạy `bash -n script.sh` (chỉ kiểm tra cú pháp), `shellcheck script.sh`, `python3 scripts/shell_lint.py script.sh`.
2. Chạy với cờ chạy thử (dry-run), sau đó chạy trong một container dùng một lần: `docker run --rm -v "$PWD":/w -w /w bash:5 bash script.sh`.
3. Chạy theo dõi luồng thực thi một lần bằng `bash -x script.sh` (sau khi đã tắt/bỏ các bí mật thật).
FILE:templates/shell-review.md
# Báo cáo rà soát Shell script: <tên script>

**Môi trường chạy:** <cron trên máy chủ web-01 dưới quyền root / GitHub Actions ubuntu-24.04 / Docker entrypoint (Alpine, busybox sh)>
**Shell:** <bash 5.2 / sh (dash)>   **Đầu vào không đáng tin cậy:** <đối số $1 thẻ release, tệp hosts.txt, tên các tệp trong thư mục uploads/>
**Kết luận:** <CHẶN (BLOCK): 3 phát hiện mức HIGH / ĐẠT sau khi sửa lỗi / ĐẠT (OK)>
**Tóm tắt:** <một hoặc hai câu: rủi ro nghiêm trọng nhất là gì và bản sửa lỗi thay đổi điều gì>

## Danh sách phát hiện (Findings)
| # | Mức độ | Mã lỗi | Dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | <HIGH> | <RM_EMPTY_VAR> | <15> | <rm -rf $WEB_ROOT/* sẽ xóa sạch /* nếu WEB_ROOT bị rỗng> | <rm -rf -- "?"/*> |

## Ghi chú rà soát thủ công
- <các đầu vào đi tới rm/ssh/sql/đường dẫn, và cách chúng được kiểm tra hợp lệ>
- <tính lũy đẳng: điều gì xảy ra nếu script chạy hai lần hoặc bị dừng giữa chừng>
- <dọn dẹp tài nguyên khi thoát bình thường và khi nhấn Ctrl+C>

## Bản script đã sửa lỗi
```bash
<full corrected script>
```

## Các thay đổi chính
```diff
- rm -rf $WEB_ROOT/*
+ rm -rf -- "?"/*
```

## Thay đổi về hành vi
- <hiện tại script sẽ thoát ngay tại câu lệnh đầu tiên gặp lỗi; trước đây vẫn tiếp tục chạy>

## Cách kiểm thử an toàn
1. Chạy `bash -n <script>` và `python3 scripts/shell_lint.py <script>` (kỳ vọng 0 lỗi HIGH)
2. Chạy `shellcheck <script>` nếu có sẵn trên máy
3. Chạy thử (dry run) hoặc chạy trong một container dùng một lần: `<câu lệnh>`
4. <xoay vòng/đổi mới bí mật đã bị lộ: đổi ở đâu và ai phụ trách>
FILE:examples/deploy.sh
#!/bin/bash
# Deploy the web app build to /srv/www and restart the service.
# Usage: deploy.sh <release-tag>

RELEASE=$1
BUILD_DIR=/srv/builds/$RELEASE
WEB_ROOT=/srv/www
SLACK_TOKEN="xoxb-0000-EXAMPLE-not-real"

curl -fsSL https://get.example-tools.dev/install.sh | sudo bash

cd $BUILD_DIR
echo "Deploying $RELEASE" > /tmp/deploy.log

rm -rf $WEB_ROOT/*
cp -r dist/* $WEB_ROOT/

for f in $(ls $BUILD_DIR/migrations); do
  psql -f $BUILD_DIR/migrations/$f app_db
done

chmod -R 777 $WEB_ROOT/uploads

while read host; do
  ssh -o StrictHostKeyChecking=no deploy@$host "sudo systemctl restart web"
done < hosts.txt

cleanup() {
  local count=$(ls /srv/builds | wc -l)
  echo "Builds on disk: $count"
}
cleanup

curl -s -X POST -H "Authorization: Bearer $SLACK_TOKEN" \
  -d "text=Deployed `date`" https://slack.com/api/chat.postMessage
FILE:examples/deploy-fixed.sh
#!/usr/bin/env bash
# Deploy the web app build to /srv/www and restart the service.
# Usage: SLACK_TOKEN=... deploy.sh <release-tag>
set -euo pipefail

RELEASE="?usage: deploy.sh <release-tag>"
BUILD_DIR="/srv/builds/RELEASE"
WEB_ROOT="/srv/www"
SLACK_TOKEN="?set SLACK_TOKEN in the environment"

# Installer is pinned and checked instead of curl | bash.
installer="$(mktemp)"
log="$(mktemp)"
trap 'rm -f -- "$installer" "$log"' EXIT
curl -fsSL -o "$installer" https://get.example-tools.dev/install-1.4.2.sh
echo "3b1f0c9e2d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344  $installer" | sha256sum -c -
sudo bash "$installer"

cd -- "$BUILD_DIR"
echo "Deploying RELEASE" > "$log"

rm -rf -- "?"/*
cp -r dist/. "$WEB_ROOT/"

for f in "$BUILD_DIR"/migrations/*.sql; do
  [ -e "$f" ] || continue
  psql -v ON_ERROR_STOP=1 -f "$f" app_db
done

chmod -R u=rwX,g=rX,o= "$WEB_ROOT/uploads"

while IFS= read -r host; do
  ssh -n -o StrictHostKeyChecking=yes "deploy@host" "sudo systemctl restart web"
done < hosts.txt

report_builds() {
  local count
  count="$(find /srv/builds -mindepth 1 -maxdepth 1 -type d | wc -l)"
  echo "Builds on disk: count"
}
report_builds

curl -fsS -X POST -H "Authorization: Bearer SLACK_TOKEN" \
  --data-urlencode "text=Deployed RELEASE at $(date -u +%FT%TZ)" https://slack.com/api/chat.postMessage
FILE:scripts/shell_lint.py
#!/usr/bin/env python3
"""Shell script safety linter (Python 3 standard library only).

Reads a bash or POSIX sh script and flags the mistakes that cause data loss, security
holes or silent failures: no strict mode, rm -rf on a variable that may be empty,
curl | sh, eval on variables, unquoted expansions (word splitting and globbing),
unchecked cd, parsing ls, chmod 777, hardcoded secrets, predictable /tmp files,
mktemp without a cleanup trap, read without -r, ssh eating a while-read loop's input,
local/export masking a failed command substitution, TLS or host-key checks turned
off, backticks and bashisms in /bin/sh.

It is a fast first pass, not a full parser: heredoc bodies are skipped, quotes,
comments, [[ ]], (( )), assignments and case words are understood, and every finding
names the line and a concrete fix. Silence a finding on one line with a trailing
comment "# shell-lint: disable=CODE[,CODE]".

Usage:
  python3 shell_lint.py script.sh [more.sh ...] [--json] [--min-severity MEDIUM]
                        [--ignore CODE,CODE]
  python3 shell_lint.py --self-test
Exit codes: 0 = no HIGH findings, 1 = at least one HIGH finding, 2 = unreadable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
SECRET_NAME = re.compile(r"[A-Z0-9_]*(PASSWORD|PASSWD|TOKEN|SECRET|API_?KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*")
FIXES = {
    "NO_SHEBANG": "Add '#!/usr/bin/env bash' (or '#!/bin/sh' for POSIX) as line 1 so the right shell runs it.",
    "NO_STRICT_MODE": "Add 'set -euo pipefail' after the shebang (POSIX sh: 'set -eu') so failures stop the script.",
    "NO_PIPEFAIL": "Add 'set -o pipefail' so 'cmd | tee log' fails when cmd fails.",
    "RM_EMPTY_VAR": "Guard the variable: rm -rf -- \"?VAR is empty/\"... and quote it; never build rm paths from unchecked input.",
    "RM_ROOT": "Never rm the filesystem root or /*; point at a specific directory.",
    "CURL_PIPE_SHELL": "Download to a file, verify a checksum or signature, read it, then run it.",
    "EVAL_VAR": "Avoid eval; use arrays for commands (cmd=(ls -l \"$dir\"); \"cmd[@]\") or a case statement.",
    "UNQUOTED_VAR": "Quote expansions: \"$var\", \"arr[@]\", \"$(cmd)\" to stop word splitting and globbing.",
    "CD_UNCHECKED": "Use 'cd \"$dir\" || exit 1' (or enable set -e) so later commands do not run in the wrong directory.",
    "LS_PARSING": "Use a glob (for f in ./*.log) or find -print0 | while IFS= read -r -d '' f instead of parsing ls/find output.",
    "BACKTICKS": "Use $(...) instead of backticks; it nests and quotes predictably.",
    "CHMOD_777": "Grant only what is needed (755 for dirs/executables, 644 for files, 600 for secrets).",
    "HARDCODED_SECRET": "Read the secret from the environment or a secret manager (\"?\"), rotate the exposed value.",
    "PREDICTABLE_TMP": "Use tmp=$(mktemp) or tmpdir=$(mktemp -d) instead of a fixed /tmp name (symlink and race attacks).",
    "NO_TRAP_CLEANUP": "Add trap 'rm -rf -- \"$tmpdir\"' EXIT right after mktemp.",
    "READ_WITHOUT_R": "Use 'read -r' (and IFS= read -r line) so backslashes and leading spaces survive.",
    "LOCAL_MASKS_STATUS": "Split it: 'local out; out=$(cmd)' so set -e and $? see the command's failure.",
    "TLS_DISABLED": "Remove -k/--insecure/--no-check-certificate/StrictHostKeyChecking=no; fix the CA bundle or pin the host key.",
    "SSH_STDIN_LOOP": "Use 'ssh -n' (or ssh ... < /dev/null) inside while-read loops; otherwise ssh eats the rest of the input.",
    "BASHISM_IN_SH": "Either change the shebang to bash or use the POSIX form ([ ], =, . file, no arrays).",
}


class Finding:
    def __init__(self, sev, code, line, snippet, problem):
        self.sev, self.code, self.line, self.snippet, self.problem = sev, code, line, snippet, problem

    def as_dict(self):
        return {"severity": self.sev, "code": self.code, "line": self.line,
                "snippet": self.snippet, "problem": self.problem, "fix": FIXES.get(self.code, "")}


def strip_comment(line):
    """Remove an unquoted comment. '#' starts a comment only at the start of a word."""
    state, i = None, 0
    while i < len(line):
        c = line[i]
        if state is None:
            if c == "\\":
                i += 2; continue
            if c in "'\"`":
                state = c
            elif c == "#" and (i == 0 or line[i - 1] in " \t;|&("):
                return line[:i].rstrip()
        elif state == "'":
            if c == "'":
                state = None
        else:
            if c == "\\":
                i += 2; continue
            if c == state:
                state = None
        i += 1
    return line


def logical_lines(text):
    """Yield (line_no, code_without_comment, raw_line, disabled_codes); heredoc bodies skipped."""
    lines = text.split("\n")
    pending = []  # heredoc terminators waiting, in order
    out = []
    for no, raw in enumerate(lines, 1):
        if pending:
            delim, dash = pending[0]
            body = raw.lstrip("\t") if dash else raw
            if body == delim:
                pending.pop(0)
            continue
        m = re.search(r"#\s*shell-lint:\s*disable=([A-Z_,\s]+)", raw)
        disabled = {c.strip() for c in m.group(1).split(",")} if m else set()
        code = strip_comment(raw)
        for hm in re.finditer(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2", code):
            if code[hm.start():hm.start() + 3] == "<<<":
                continue
            pending.append((hm.group(3), hm.group(1) == "-"))
        out.append((no, code, raw, disabled))
    return out


def match_paren(s, i):
    """s[i] == '(' ; return index of the matching ')' respecting quotes, or len(s)."""
    depth, state, j = 0, None, i
    while j < len(s):
        c = s[j]
        if state is None:
            if c == "\\":
                j += 2; continue
            if c in "'\"":
                state = c
            elif c == "(":
                depth += 1
            elif c == ")":
                depth -= 1
                if depth == 0:
                    return j
        elif c == state:
            state = None
        elif c == "\\" and state == '"':
            j += 1
        j += 1
    return len(s)


def unquoted_expansions(code):
    """Return names of $var / ... / $(...) / $@ expansions that are not inside quotes
    and not in a context where splitting cannot happen ([[ ]], (( )), assignments, case word)."""
    found = []
    stripped = code.strip()
    if re.match(r"case\s", stripped):
        return found
    state, i, dbl_brackets, arith = None, 0, 0, 0
    n = len(code)

    def word_start(k):
        while k > 0 and code[k - 1] not in " \t;|&(":
            k -= 1
        return k

    while i < n:
        c = code[i]
        if state == "'":
            if c == "'":
                state = None
            i += 1; continue
        if c == "\\":
            i += 2; continue
        if state is None and c == "'":
            state = "'"; i += 1; continue
        if c == '"':
            state = None if state == '"' else '"'
            i += 1; continue
        if state is None and code.startswith("[[", i) and (i == 0 or code[i - 1] in " \t;&|!("):
            dbl_brackets += 1; i += 2; continue
        if state is None and code.startswith("]]", i) and dbl_brackets:
            dbl_brackets -= 1; i += 2; continue
        if state is None and code.startswith("((", i) and (i == 0 or code[i - 1] in " \t;&|!"):
            end = match_paren(code, i)
            i = end + 1; continue
        if c == "`":
            end = code.find("`", i + 1)
            i = (end if end != -1 else n) + 1; continue
        if c == "$" and i + 1 < n:
            nxt = code[i + 1]
            if code.startswith("$((", i):
                end = match_paren(code, i + 1)
                i = end + 1; continue
            if nxt == "(":
                end = match_paren(code, i + 1)
                inner = code[i + 2:end]
                found.extend(unquoted_expansions(inner))
                if state is None and not dbl_brackets and not arith:
                    ws = word_start(i)
                    if not re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1]):
                        found.append("$(" + inner.strip()[:20] + ")")
                i = end + 1; continue
            m = re.match(r"\$(\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*|[@*]|[0-9])", code[i:])
            if m:
                tok = m.group(0)
                if state is None and not dbl_brackets:
                    ws = word_start(i)
                    is_assign = re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1])
                    if not is_assign and not tok.startswith("found.append(tok)
                i += len(tok); continue
        i += 1
    return found


def commands(code):
    """Split a line into simple commands on ; && || | (outside quotes)."""
    parts, cur, state, i = [], "", None, 0
    while i < len(code):
        c = code[i]
        if state is None and c in "'\"":
            state = c
        elif state and c == state:
            state = None
        if state is None and c in ";|&":
            parts.append(cur); cur = ""
            while i + 1 < len(code) and code[i + 1] in ";|&":
                i += 1
            i += 1; continue
        cur += c; i += 1
    parts.append(cur)
    return [p.strip() for p in parts if p.strip()]


def lint(text, name="script"):
    findings = []
    lines = logical_lines(text)
    first = text.split("\n", 1)[0]
    shebang = first if first.startswith("#!") else ""
    is_posix_sh = bool(re.search(r"(^#!\s*/bin/sh\b|/env\s+sh\b|/bin/dash\b)", shebang))
    all_code = "\n".join(c for _, c, _, _ in lines)
    strict_e = bool(re.search(r"(^|\n)\s*set\s+(-[a-zA-Z]*e[a-zA-Z]*\b|-o\s+errexit)", all_code)) or bool(re.search(r"\s-[a-z]*e", shebang))
    pipefail = "pipefail" in all_code
    uses_pipes = "|" in all_code.replace("||", "")
    uses_mktemp = "mktemp" in all_code
    has_trap = bool(re.search(r"(^|\n)\s*trap\s", all_code))

    def add(sev, code, no, snippet, problem, disabled=frozenset()):
        if code not in disabled:
            findings.append(Finding(sev, code, no, snippet.strip()[:80], problem))

    if not shebang:
        add("LOW", "NO_SHEBANG", 1, first, "no shebang line; the caller's shell decides how this runs")
    if not strict_e:
        add("MEDIUM", "NO_STRICT_MODE", 1, shebang or first, "no 'set -e' (errexit): a failed command does not stop the script")
    elif uses_pipes and not pipefail and not is_posix_sh:
        add("LOW", "NO_PIPEFAIL", 1, shebang, "pipes are used but pipefail is off: only the last command's status counts")
    if uses_mktemp and not has_trap:
        no = next((n for n, c, _, _ in lines if "mktemp" in c), 1)
        add("LOW", "NO_TRAP_CLEANUP", no, next(c for n, c, _, _ in lines if n == no), "mktemp is used but nothing removes the temp file on exit or error")

    read_loop = 0
    for no, code, raw, dis in lines:
        if not code.strip():
            continue
        if re.search(r"(^|[;&|\s])while\b.*\bread\b", code):
            read_loop += 1
        if read_loop and re.search(r"(^|[;&|\s])ssh\s", code) and not re.search(r"\bssh\s+(-\w+\s+)*-\w*n\b|<\s*/dev/null", code):
            add("MEDIUM", "SSH_STDIN_LOOP", no, code, "ssh inside a while-read loop reads the loop's stdin, so the loop stops after the first item", dis)
        if read_loop and re.search(r"(^|[;&|\s])done\b", code):
            read_loop -= 1
        # curl | sh
        if re.search(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+(-\S+\s+)*)?(ba|z|da|k)?sh\b", code):
            add("HIGH", "CURL_PIPE_SHELL", no, code, "remote script piped straight into a shell: no review, no integrity check, partial downloads run", dis)
        if re.search(r"\b(curl\b[^|;]*\s(-k|--insecure)\b|wget\b[^|;]*--no-check-certificate|StrictHostKeyChecking=no)", code):
            add("MEDIUM", "TLS_DISABLED", no, code, "certificate or host-key verification is turned off (man-in-the-middle risk)", dis)
        if re.search(r"(^|[;&|(\s])eval\s+.*\$", code):
            add("HIGH", "EVAL_VAR", no, code, "eval on a string built from variables runs any shell code an attacker or odd filename puts in it", dis)
        if re.search(r"\bchmod\s+(-[A-Za-z]+\s+)*0?777\b|\bchmod\s+(-[A-Za-z]+\s+)*[augo]*\+rwx\b", code) and re.search(r"777|a\+rwx|o\+rwx", code):
            add("MEDIUM", "CHMOD_777", no, code, "world-writable permissions let any local user change the files", dis)
        if "`" in code:
            add("LOW", "BACKTICKS", no, code, "legacy backtick command substitution", dis)
        m = re.match(r"\s*(export\s+|readonly\s+|local\s+|declare\s+(-\w+\s+)*)?(" + SECRET_NAME.pattern + r")=(['\"]?)([^$'\"\s][^'\"\s]{3,)", code)
        if m:
            val = m.group(6)
            masked = val[:2] + "*" * max(3, len(val) - 4) + val[-2:]
            add("HIGH", "HARDCODED_SECRET", no, code.replace(val, masked), f"{m.group(3)} has a literal value in the script (ends up in git, logs and ps output)", dis)
        if re.match(r"\s*(local|export|declare|readonly)\s+(-\w+\s+)*[A-Za-z_][A-Za-z0-9_]*=\"?\$\(", code):
            add("MEDIUM", "LOCAL_MASKS_STATUS", no, code, "the builtin's exit status (0) hides a failure of the command substitution, even under set -e", dis)
        if re.search(r"(^|[^\w.-])/tmp/[A-Za-z0-9_.${}-]+", code) and "mktemp" not in code:
            add("MEDIUM", "PREDICTABLE_TMP", no, code, "fixed /tmp path: another user can pre-create or symlink it, and parallel runs collide", dis)
        if re.search(r"\bfor\s+\w+\s+in\s+(\$\(|`)\s*(ls|find)\b", code) or re.search(r"\bls\b[^|]*\|\s*(while|xargs)\b", code):
            add("MEDIUM", "LS_PARSING", no, code, "parsing ls/find output breaks on spaces, newlines and glob characters in filenames", dis)
        for cmd in commands(code):
            words = cmd.split()
            if not words:
                continue
            w0 = words[0]
            while w0 in ("sudo", "command", "builtin", "exec", "!", "while", "until", "if", "then",
                         "do", "else", "elif", "{", "(") and len(words) > 1:
                words = words[1:]; w0 = words[0]
            if w0 == "rm":
                args = words[1:]
                rec = any(re.match(r"-[a-zA-Z]*[rR]", a) or a == "--recursive" for a in args)
                if any(a.strip("'\"") in ("/", "/*") for a in args):
                    add("HIGH", "RM_ROOT", no, cmd, "rm targets / or /*", dis)
                for a in args:
                    if a.startswith("-") and a != "--":
                        continue
                    if "$" in a and not re.search(r"\$\{[A-Za-z_][A-Za-z0-9_]*:\?", a):
                        a_clean = a.strip("'\"")
                        if re.match(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(/|\"?$)", a_clean) or rec:
                            add("HIGH" if rec else "MEDIUM", "RM_EMPTY_VAR", no, cmd,
                                f"rm{' -r' if rec else ''} on {a}: if the variable is empty or unset this deletes the wrong path (e.g. '/')", dis)
                            break
            if w0 == "cd" and not strict_e:
                if not re.search(re.escape(cmd) + r"\s*(\|\||&&)", code) and not re.search(r"(\|\||&&)\s*" + re.escape(cmd), code):
                    add("MEDIUM", "CD_UNCHECKED", no, cmd, "cd may fail and the following commands then run in the wrong directory", dis)
            if w0 == "read":
                opts = [a for a in words[1:] if a.startswith("-")]
                if not any("r" in o for o in opts):
                    add("LOW", "READ_WITHOUT_R", no, cmd, "read without -r mangles backslashes", dis)
        exps = unquoted_expansions(code)
        if exps:
            uniq = list(dict.fromkeys(exps))
            shown = ", ".join(uniq[:4]) + (" ..." if len(uniq) > 4 else "")
            only_echo = re.match(r"\s*(echo|printf)\s+[^|>]*$", code) and not any(e in ("$@", "$*") for e in uniq)
            if only_echo:
                add("LOW", "UNQUOTED_VAR", no, code, "unquoted " + shown + " in echo/printf: spacing collapses and * expands", dis)
            else:
                add("MEDIUM", "UNQUOTED_VAR", no, code, "unquoted " + shown + ": word splitting and glob expansion", dis)
        if is_posix_sh:
            bash = []
            if re.search(r"(^|[\s;&|!])\[\[\s", code): bash.append("[[ ]]")
            if re.search(r"(^|[\s;&|])\[\s[^]]*\s==\s", code): bash.append("== in [ ]")
            if re.match(r"\s*function\s+\w+", code): bash.append("function keyword")
            if re.search(r"\b[A-Za-z_]\w*=\(", code): bash.append("array")
            if re.match(r"\s*source\s", code): bash.append("source")
            if "<<<" in code: bash.append("here-string")
            if re.search(r"&>", code): bash.append("&>")
            if re.search(r"\$'", code): bash.append("$'...'")
            if bash:
                add("MEDIUM", "BASHISM_IN_SH", no, code, "bash-only syntax under #!/bin/sh (" + ", ".join(bash) + "); dash and busybox sh will fail", dis)
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.line, f.code))
    return findings


def render_md(name, findings):
    counts = {s: sum(1 for f in findings if f.sev == s) for s in SEV_ORDER}
    out = [f"# Shell lint: {name}", "",
           f"Findings: {len(findings)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""]
    if not findings:
        out.append("No findings.")
        return "\n".join(out)
    out += ["| Severity | Code | Line | Snippet | Problem |", "|---|---|---|---|---|"]
    for f in findings:
        snip = f.snippet.replace("|", "\\|").replace("`", "'")
        out.append(f"| {f.sev} | {f.code} | {f.line} | `{snip}` | {f.problem.replace('|', '/')} |")
    out += ["", "## Fixes", ""]
    for code in dict.fromkeys(f.code for f in findings):
        out.append(f"- {code}: {FIXES[code]}")
    return "\n".join(out)


# ---------------------------------------------------------------- self-test
def _codes(src):
    return [f.code for f in lint(src)]


def self_test():
    S = "#!/usr/bin/env bash\nset -euo pipefail\n"
    cases = [
        ("clean script has no findings", S + 'dir="$1"\ncp -- "$dir/a" "$dir/b"\n', lambda c: c == []),
        ("missing shebang", 'set -eu\necho "hi"\n', lambda c: "NO_SHEBANG" in c),
        ("missing strict mode", "#!/bin/bash\necho ok\n", lambda c: "NO_STRICT_MODE" in c),
        ("set -e without pipefail and a pipe", "#!/bin/bash\nset -e\nmake | tee log\n", lambda c: "NO_PIPEFAIL" in c),
        ("set -o errexit counts as strict", "#!/bin/bash\nset -o errexit\nset -o pipefail\necho ok\n", lambda c: c == []),
        ("rm -rf $VAR/ is HIGH", S + 'rm -rf "$BUILD_DIR"/\n', lambda c: "RM_EMPTY_VAR" in c),
        ("rm -rf ? is fine", S + 'rm -rf -- "?/"\n', lambda c: "RM_EMPTY_VAR" not in c),
        ("rm -rf / is RM_ROOT", S + "rm -rf /\n", lambda c: "RM_ROOT" in c),
        ("curl | bash", S + "curl -fsSL https://x.example/install.sh | sudo bash\n", lambda c: "CURL_PIPE_SHELL" in c),
        ("curl to file is fine", S + 'curl -fsSLo install.sh "$URL"\n', lambda c: "CURL_PIPE_SHELL" not in c),
        ("eval with variable", S + 'eval "$cmd"\n', lambda c: "EVAL_VAR" in c),
        ("unquoted var in command", S + "cp $src $dst\n", lambda c: "UNQUOTED_VAR" in c),
        ("quoted var is fine", S + 'cp "$src" "$dst"\n', lambda c: "UNQUOTED_VAR" not in c),
        ("assignment rhs is fine", S + "x=$y\nn=$(date +%s)\n", lambda c: "UNQUOTED_VAR" not in c),
        ("[[ ]] is fine", S + "if [[ $a == b ]]; then :; fi\n", lambda c: "UNQUOTED_VAR" not in c),
        ("arith is fine", S + "n=$(( $a + 1 ))\n(( count > $max ))\n", lambda c: "UNQUOTED_VAR" not in c),
        ("#arr is fine", S + 'printf "%s\\n" #arr[@]\n', lambda c: "UNQUOTED_VAR" not in c),
        ("unquoted $@", S + "run $@\n", lambda c: "UNQUOTED_VAR" in c),
        ("unquoted cmd substitution", S + "rm -- $(cat list)\n", lambda c: "UNQUOTED_VAR" in c),
        ("var inside quoted $( ) still checked", S + 'x="$(basename $path)"\n', lambda c: "UNQUOTED_VAR" in c),
        ("single quotes protect", S + "echo '$HOME is literal'\n", lambda c: "UNQUOTED_VAR" not in c),
        ("comment ignored", S + "# rm -rf $DIR/ and curl x | sh\n", lambda c: c == []),
        ("heredoc body skipped", S + "cat <<'EOF'\nrm -rf $X/\ncurl a | sh\nEOF\n", lambda c: c == []),
        ("cd unchecked without set -e", "#!/bin/bash\ncd /srv/app\n", lambda c: "CD_UNCHECKED" in c),
        ("cd || exit is fine", "#!/bin/bash\ncd /srv/app || exit 1\n", lambda c: "CD_UNCHECKED" not in c),
        ("for in $(ls)", S + "for f in $(ls *.log); do :; done\n", lambda c: "LS_PARSING" in c),
        ("backticks", S + "d=`date`\n", lambda c: "BACKTICKS" in c),
        ("chmod 777", S + "chmod -R 777 /srv/app\n", lambda c: "CHMOD_777" in c),
        ("hardcoded token, value masked", S + 'export API_TOKEN="sk_live_abcdef123456"\n',
         lambda c: "HARDCODED_SECRET" in c),
        ("secret from env is fine", S + 'API_TOKEN="?"\n', lambda c: "HARDCODED_SECRET" not in c),
        ("predictable tmp", S + 'echo "x" > /tmp/deploy.lock\n', lambda c: "PREDICTABLE_TMP" in c),
        ("mktemp without trap", S + "tmp=$(mktemp)\n", lambda c: "NO_TRAP_CLEANUP" in c),
        ("mktemp with trap fine", S + "tmp=$(mktemp)\ntrap 'rm -f -- \"$tmp\"' EXIT\n", lambda c: "NO_TRAP_CLEANUP" not in c),
        ("read without -r", S + 'while read line; do :; done < "$f"\n', lambda c: "READ_WITHOUT_R" in c),
        ("local masks status", S + "f() {\n  local out=$(git rev-parse HEAD)\n}\n", lambda c: "LOCAL_MASKS_STATUS" in c),
        ("curl -k", S + 'curl -k "$URL" -o x\n', lambda c: "TLS_DISABLED" in c),
        ("bashisms under /bin/sh", "#!/bin/sh\nset -eu\nif [[ -n \"$x\" ]]; then source ./env; fi\n",
         lambda c: "BASHISM_IN_SH" in c),
        ("bash allows [[ ]]", S + 'if [[ -n "$x" ]]; then source ./env; fi\n', lambda c: "BASHISM_IN_SH" not in c),
        ("disable comment", S + "cp $a $b  # shell-lint: disable=UNQUOTED_VAR\n", lambda c: "UNQUOTED_VAR" not in c),
        ("ssh in while-read loop", S + 'while IFS= read -r h; do\n  ssh "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" in c),
        ("ssh -n in loop is fine", S + 'while IFS= read -r h; do\n  ssh -n "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" not in c),
        ("case word fine", S + 'case $1 in\n  a) echo "a" ;;\nesac\n', lambda c: "UNQUOTED_VAR" not in c),
    ]
    passed = 0
    for name, src, check in cases:
        got = _codes(src)
        ok = check(got)
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  -> {got}"))
    # masking check and exit code check
    f = [x for x in lint(S + 'export API_TOKEN="sk_live_abcdef123456"\n') if x.code == "HARDCODED_SECRET"][0]
    ok = "abcdef123456" not in f.snippet
    passed += ok; print(("PASS " if ok else "FAIL ") + "secret value masked in output")
    ok = render_md("t", []).endswith("No findings.")
    passed += ok; print(("PASS " if ok else "FAIL ") + "empty report")
    total = len(cases) + 2
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main():
    ap = argparse.ArgumentParser(description="Shell script safety linter (stdlib only)")
    ap.add_argument("files", nargs="*")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
    ap.add_argument("--ignore", default="", help="comma-separated codes to skip")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.files:
        ap.error("give at least one script or --self-test")
    ignore = {c.strip() for c in a.ignore.split(",") if c.strip()}
    worst, reports, js = 0, [], {}
    for path in a.files:
        try:
            with open(path, encoding="utf-8") as fh:
                text = fh.read()
        except (OSError, UnicodeDecodeError) as e:
            print(f"error: cannot read {path}: {e}", file=sys.stderr)
            return 2
        fs = [f for f in lint(text, path) if f.code not in ignore and SEV_ORDER[f.sev] <= SEV_ORDER[a.min_severity]]
        if any(f.sev == "HIGH" for f in fs):
            worst = 1
        js[path] = [f.as_dict() for f in fs]
        reports.append(render_md(path, fs))
    print(json.dumps(js, indent=2) if a.json else "\n\n".join(reports))
    return worst


if __name__ == "__main__":
    sys.exit(main())
Best PracticesSecurityPython+2
F@f
0
Công cụ Kiểm tra Đối chiếu và So sánh Khác biệt CSV
Kỹ năng

So sánh hai bản xuất CSV của cùng các bản ghi (cũ và mới, hệ thống thanh toán và sổ cái, nguồn và kho dữ liệu) theo khóa sau khi chuẩn hóa tiêu đề, dấu phân cách, định dạng tiền tệ, số âm kế toán, ngày tháng và chữ hoa/thường. Báo cáo các khóa được thêm, bị xóa và trùng lặp, lệch định dạng khóa, giá trị thay đổi theo từng cột kèm ngưỡng dung sai, các trường hợp có khả năng đổi khóa và bảng cầu nối tổng kiểm soát, sau đó xuất báo cáo đối chiếu. Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: csv-diff-and-reconciliation-checker
description: So sánh hai bản xuất CSV của cùng các bản ghi (cũ và mới, hệ thống thanh toán và sổ cái, nguồn và kho dữ liệu) theo khóa sau khi chuẩn hóa tiêu đề, dấu phân cách, định dạng tiền tệ, số âm kế toán, ngày tháng và chữ hoa/thường. Báo cáo các khóa được thêm, bị xóa và trùng lặp, lệch định dạng khóa, giá trị thay đổi theo từng cột kèm ngưỡng dung sai, các trường hợp có khả năng đổi khóa và bảng cầu nối tổng kiểm soát, sau đó xuất báo cáo đối chiếu. 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 Đối chiếu và So sánh Khác biệt CSV

Bạn giúp các chuyên viên phân tích, đội ngũ tài chính và kỹ sư dữ liệu trả lời câu hỏi "hai tệp này có khớp nhau không, và nếu không thì chính xác là tại sao?". Bạn đối sánh các dòng theo khóa nghiệp vụ, bỏ qua những khác biệt chỉ nằm ở mặt định dạng, và chuyển các khác biệt thực sự thành một danh sách ngắn gọn các nguyên nhân kèm theo số tiền mà mỗi nguyên nhân giải thích được, để các con số tổng khớp nhau đến từng xu.

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

- `scripts/csv_recon.py` - đối sánh hai tệp CSV theo khóa (đơn hoặc phức hợp), chuẩn hóa giá trị, liệt kê các khác biệt về lược đồ (schema), khóa trùng lặp và khóa trống, các dòng được thêm, bị xóa và thay đổi, lệch định dạng khóa và các trường hợp có khả năng đổi khóa, đồng thời xây dựng bảng cầu nối tổng kiểm soát (chỉ sử dụng thư viện chuẩn Python 3)
- `references/matching-and-normalization.md` - cách chọn khóa, chuẩn hóa tiêu đề và giá trị, định dạng số và ngày tháng, ngưỡng dung sai, cùng ý nghĩa của từng mã phát hiện
- `references/reconciliation-playbook.md` - các nguyên nhân gây lệch phổ biến (chênh lệch thời điểm, trùng lặp, quy ước dấu, làm tròn, bộ lọc, bảng mã ký tự) và cách chứng minh từng nguyên nhân
- `templates/reconciliation-report.md` - mẫu báo cáo để bàn giao lại
- `examples/billing-export-sept.csv` - hóa đơn tháng Chín từ hệ thống thanh toán (14 dòng, phân cách bằng dấu phẩy)
- `examples/ledger-export-sept.csv` - dữ liệu cùng tháng từ sổ cái chung (15 dòng, dấu chấm phẩy, số tiền dạng $1,200.00, ngày tháng kiểu Mỹ, có các cột bị đổi tên và cột dư thêm)

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

### 1. Tìm hiểu hai tệp dữ liệu
Hãy hỏi xem mỗi tệp là gì, hệ thống và bộ lọc nào đã tạo ra nó (khoảng thời gian, trạng thái, pháp nhân, loại tiền tệ), bên nào là nguồn dữ liệu chuẩn (source of truth), khóa đối sánh là gì, và những khác biệt nào là quan trọng (số tiền chính xác đến từng xu, trạng thái, ngày tháng). Hãy hỏi số lượng dòng và các con số tổng mà người dùng đang kỳ vọng sẵn. Xem qua những dòng đầu tiên của cả hai tệp trước khi chạy bất kỳ công cụ nào.

### 2. Chạy công cụ kiểm tra
```
python3 scripts/csv_recon.py examples/billing-export-sept.csv examples/ledger-export-sept.csv --key invoice_id --map customer=customer_name --tol amount=0.01 --sum amount --ignore-case --dates invoice_date
python3 scripts/csv_recon.py orders_old.csv orders_new.csv --key order_id,line_no --ignore updated_at --out-csv diff.csv
python3 scripts/csv_recon.py a.csv b.csv --key id --json
python3 scripts/csv_recon.py --self-test
```
Các tiêu đề chỉ khác nhau về chữ hoa/thường hoặc khoảng trắng sẽ tự động khớp với nhau; các cột bị đổi tên cần dùng tùy chọn `--map old=new`. Mã thoát (Exit) 0 có nghĩa là hai tệp khớp nhau hoàn toàn, 1 nghĩa là có sự khác biệt, 2 nghĩa là đầu vào không hợp lệ (thiếu tệp hoặc thiếu cột khóa).

### 3. Giải thích mọi điểm khác biệt
Xử lý lần lượt từng phát hiện từ trên xuống (xem `references/reconciliation-playbook.md`): kiểm tra các khóa trùng lặp và khóa trống trước tiên (vì chúng làm sai lệch mọi thứ khác), tiếp đến là các dòng được thêm vào và bị xóa đi (do thời điểm ghi nhận, bộ lọc, đổi khóa), sau đó là các giá trị bị thay đổi được nhóm theo cột và theo quy luật (cùng mức chênh lệch, đảo dấu, đảo vị trí chữ số, làm tròn). Sử dụng công thức cầu nối: tổng cũ + thêm mới - đã xóa + thay đổi - trùng lặp = tổng mới, không để sót khoản nào không giải thích được. Kiểm tra thủ công một vài dòng mẫu trên cả hai hệ thống nguồn.

### 4. Bàn giao kết quả
Điền đầy đủ vào `templates/reconciliation-report.md`: phạm vi và dữ liệu đầu vào, kết quả đối chiếu, số liệu thống kê tóm tắt, bảng cầu nối, từng khoản lệch kèm nguyên nhân, số tiền, người phụ trách và cách khắc phục, cũng như những gì cần thay đổi để tháng sau có thể đối chiếu tự động (khóa ổn định, dùng chung bộ lọc, thống nhất định dạng xuất dữ liệu). Đính kèm kết quả đầu ra từ `--out-csv` cho những người trực tiếp sửa dữ liệu từng dòng.

## Ví dụ kết quả
Hệ thống thanh toán so với sổ cái trong tháng Chín: 14 so với 15 dòng, 13 dòng khớp khóa (INV-1009 khớp với `inv-1009` dưới dạng KEY_FORMAT_DRIFT), 11 dòng không thay đổi giá trị. Các khoản lệch: INV-1011 bị hạch toán hai lần vào sổ cái (HIGH DUPLICATE_KEY, +880.00), INV-1004 có giá trị 450.00 so với 405.00 (bị đảo chữ số, -45.00), INV-1012 đang ở trạng thái mở (open) trên hệ thống thanh toán nhưng đã Thanh toán (Paid) trong sổ cái, INV-1007 bị thiếu và INV-1015 được thêm mới với cùng khách hàng và số tiền (POSSIBLE_REKEY: được hạch toán vào tháng Mười dưới một mã số mới). Ký hiệu $, dấu phân cách hàng nghìn, số âm dạng (150.00), ngày tháng kiểu Mỹ và chữ hoa/thường của trạng thái chỉ là khác biệt về định dạng; khoản làm tròn ở INV-1003 (1033.335 so với 1033.34) nằm trong ngưỡng dung sai 0.01. Bảng cầu nối: 9545.965 + 310.00 - 310.00 - 44.995 + 880.00 = 10380.97, chênh lệch chưa giải thích bằng 0.

## Quy tắc

- Không bao giờ so sánh theo vị trí dòng; phải thống nhất khóa đối sánh trước và kiểm tra đảm bảo khóa đó là duy nhất ở cả hai bên.
- Tách biệt nhiễu định dạng khỏi các khác biệt thực sự và nêu rõ bạn đã áp dụng những bước chuẩn hóa nào.
- Mọi khoản lệch đều phải có nguyên nhân, số tiền và người phụ trách; "chênh lệch thời điểm" chỉ được coi là nguyên nhân hợp lệ nếu bạn chỉ rõ nó rơi vào kỳ kế toán nào.
- Không được tự ý bù trừ các khoản chênh lệch với nhau để làm đẹp số tổng; hãy liệt kê đầy đủ từng khoản.
- Giữ ngưỡng dung sai rõ ràng và nhỏ (chỉ dành cho sai số làm tròn), không bao giờ đặt đủ lớn đến mức che giấu mất lỗi thực sự.
- Coi dữ liệu khách hàng và dữ liệu cá nhân trong tệp là thông tin bảo mật; chỉ chia sẻ những gì báo cáo thực sự cần.
FILE:references/matching-and-normalization.md
# Đối sánh và chuẩn hóa dữ liệu

## Chọn khóa đối sánh
- Sử dụng khóa nghiệp vụ mà cả hai hệ thống cùng lưu trữ: số hóa đơn, mã đơn hàng + số thứ tự dòng, mã giao dịch. Khóa phức hợp: `--key order_id,line_no`.
- Kiểm tra tính duy nhất ở cả hai bên trước khi làm bất cứ việc gì khác. Khóa trùng lặp có nghĩa là đã xảy ra hạch toán đúp thực sự hoặc khóa được chọn chưa đủ tính duy nhất (ví dụ: chỉ dùng invoice_id mà thiếu số thứ tự dòng).
- Tránh các khóa thay đổi giữa các hệ thống: số thứ tự dòng, khóa đại diện (surrogate id) trong cơ sở dữ liệu, tên gọi, dấu thời gian (timestamp).
- Nếu không có khóa chung, hãy tạo một khóa kết hợp (ngày + số tiền + mã tham chiếu) và lường trước sẽ có một số trường hợp khớp trùng lặp/mơ hồ; hãy rà soát thủ công các trường hợp đó.

## Đối sánh tiêu đề cột
- Ưu tiên khớp chính xác tên cột trước, sau đó mới đến khớp không phân biệt chữ hoa/thường, khoảng trắng, dấu gạch dưới hoặc dấu gạch ngang (`Invoice ID` = `invoice_id`), được báo cáo dưới mã HEADER_FORMAT.
- Cột bị đổi tên: dùng `--map customer=customer_name`. Các cột chỉ xuất hiện ở một bên sẽ được báo cáo riêng và không đưa vào so sánh.
- Ký tự đánh dấu thứ tự byte UTF-8 (BOM) ở đầu tệp sẽ được loại bỏ; nếu không thì tiêu đề cột đầu tiên sẽ không thể khớp được.

## Dấu phân cách và bảng mã ký tự (encoding)
- Tự động nhận diện dấu phẩy, dấu chấm phẩy, ký tự tab và dấu gạch đứng (pipe). Các bản xuất Excel kiểu châu Âu thường dùng `;` kèm dấu `,` làm dấu phân cách thập phân; công cụ kiểm tra này yêu cầu dấu thập phân là `.`, vì vậy hãy chuyển đổi chúng trước (hoặc xuất lại tệp với dấu `.`).
- Đọc tệp theo bảng mã UTF-8. Nếu bạn thấy `é` thay vì `é`, tệp đó đang dùng bảng mã Windows-1252: hãy chuyển đổi bằng lệnh `iconv -f cp1252 -t utf-8`.

## Chuẩn hóa giá trị (áp dụng cho mọi ô được đem ra so sánh)
| Giá trị thô | Được xử lý thành |
|---|---|
| khoảng trắng ở đầu/cuối, nhiều khoảng trắng liên tiếp ở giữa | cắt bỏ khoảng trắng thừa ở hai đầu, gộp thành một khoảng trắng đơn |
| `1,234.50`, `$1,234.50`, `1234.5`, `1234.50 USD` | số 1234.5 |
| `(150.00)`, `150.00-`, `-$150.00` | số -150 |
| `12.5%` | số 12.5 (không phải 0.125) |
| để trống, `null`, `None`, `N/A`, `NA`, `-` | để trống |
| `Paid` so với `paid` | khác nhau, trừ khi dùng tùy chọn `--ignore-case` |
| `09/01/2026`, `2026/09/01`, `01.09.2026` | 2026-09-01 đối với các cột trong `--dates` (mặc định tháng đứng trước kiểu Mỹ trừ khi có `--dayfirst`) |

Các con số được so sánh dưới dạng số thập phân chính xác (decimal) chứ không phải số thực dấu phẩy động (float), do đó lỗi kiểu `0.1 + 0.2` sẽ không xảy ra. Các chữ số 0 đứng đầu trong những mã trông giống số (`00123` so với `123`) sẽ được coi là bằng nhau khi so sánh dưới dạng số; nếu điều đó không đúng với một cột mã định danh, hãy thêm cột đó vào `--ignore` và so sánh riêng.

## Ngưỡng dung sai
- Dùng `--tol amount=0.01` cho từng cột riêng lẻ, hoặc `--tol 0.01` cho mọi cột số.
- Sử dụng đơn vị nhỏ nhất của loại tiền tệ cho các chênh lệch do làm tròn; không bao giờ dùng ngưỡng dung sai để che giấu một lỗi đã biết.
- Các giá trị nằm trong ngưỡng dung sai được tính là FORMAT_ONLY và vẫn xuất hiện trong bảng cầu nối dưới dạng các khoản thay đổi.

## Các mã phát hiện (Finding codes)
| Mã | Mức độ nghiêm trọng | Ý nghĩa |
|---|---|---|
| DUPLICATE_KEY | HIGH | cùng một khóa xuất hiện nhiều hơn một lần ở một bên; chỉ dòng đầu tiên được đưa vào so sánh |
| MISSING_KEY | HIGH | các dòng có khóa bị bỏ trống; không thể đối sánh các dòng này |
| TOTAL_UNEXPLAINED | HIGH | bảng cầu nối không khớp tổng (do có ô không phải dạng số, hoặc có khóa trống) |
| COLUMN_ONLY_IN_OLD / NEW | MEDIUM | khác biệt về lược đồ (schema); hãy ánh xạ cột nếu nó đã bị đổi tên |
| ROWS_ADDED / ROWS_REMOVED | MEDIUM | các khóa chỉ xuất hiện ở một bên |
| VALUE_CHANGED | MEDIUM | theo từng cột, các dòng có giá trị sau chuẩn hóa khác nhau |
| RAGGED_ROWS | MEDIUM | các dòng có số ô nhiều hơn hoặc ít hơn dòng tiêu đề (lỗi dấu ngoặc kép, dấu phân cách nằm bên trong dữ liệu) |
| NON_NUMERIC | MEDIUM | các ô trong cột `--sum` không phải là số |
| HEADER_FORMAT | LOW | các cột chỉ khớp sau khi bỏ qua chữ hoa/thường và khoảng trắng |
| KEY_FORMAT_DRIFT | LOW | các khóa bằng nhau sau khi bỏ qua chữ hoa/thường, khoảng trắng, dấu phân cách và chữ số 0 đứng đầu; được so sánh như cùng một dòng |
| POSSIBLE_REKEY | LOW | một dòng bị xóa và một dòng được thêm mới có cùng các giá trị ở những cột không phải khóa và không phải ngày tháng |
| FORMAT_ONLY, DELIMITER, TOTAL_DIFF | INFO | thông tin ngữ cảnh: bằng nhau sau khi chuẩn hóa, khác dấu phân cách, tổng số tiền thay đổi |

## Bảng cầu nối tổng kiểm soát (Control-total bridge)
```
Tổng OLD
+ số tiền của các dòng chỉ có trong NEW
- số tiền của các dòng chỉ có trong OLD
+ (NEW - OLD) trên các dòng đã khớp khóa
- các dòng trùng lặp dư ra hoặc có khóa trống trong OLD
+ các dòng trùng lặp dư ra hoặc có khóa trống trong NEW
= Tổng NEW            (phần chưa giải thích phải bằng 0)
```
FILE:references/reconciliation-playbook.md
# Sổ tay đối chiếu: nguyên nhân gây lệch và cách chứng minh

Thực hiện theo đúng thứ tự này: các khóa trùng lặp và khóa trống, tiếp đến là các dòng chỉ xuất hiện ở một bên, sau đó là các giá trị bị thay đổi, cuối cùng là số tổng.

## Khóa trùng lặp (Duplicates)
- **Hạch toán đúp (Double posting)** (cùng khóa, cùng giá trị xuất hiện hai lần): do cơ chế tự động thử lại (retry), chạy lệnh nhập dữ liệu (import) hai lần, hoặc nhập tay lại. Cách chứng minh: các dòng giống hệt nhau, thường có cùng dấu thời gian tạo hoặc cùng mã lô (batch id). Cách khắc phục: đảo (reverse) một bút toán hạch toán; bổ sung khóa chống trùng lặp (idempotency key) vào quy trình nhập dữ liệu.
- **Khóa không duy nhất (Non-unique key)** (cùng khóa, khác giá trị): khóa hiện tại cần kết hợp thêm một cột khác (số thứ tự dòng, loại tiền tệ, pháp nhân). Hãy chạy lại công cụ với khóa phức hợp.

## Các dòng chỉ xuất hiện ở một bên
- **Chênh lệch thời điểm / cắt kỳ (Timing / cut-off)**: bản ghi tồn tại trên cả hai hệ thống nhưng nằm ở hai kỳ kế toán khác nhau (hóa đơn ghi ngày 30/9, nhưng hạch toán ngày 1/10). Cách chứng minh: tìm thấy bản ghi đó trong tệp xuất dữ liệu của kỳ kế tiếp hoặc kỳ trước đó. Dấu hiệu điển hình: mã POSSIBLE_REKEY kèm theo ngày tháng nằm ngay sát ranh giới chuyển giao kỳ.
- **Khác bộ lọc (Different filters)**: một bản xuất đã loại trừ các bản nháp (drafts), bản ghi đã hủy (voids), tài khoản thử nghiệm, số tiền bằng 0, hoặc khác pháp nhân hay loại tiền tệ. Cách chứng minh: so sánh câu truy vấn xuất dữ liệu hoặc thiết lập báo cáo; đếm số lượng các trạng thái đã bị loại trừ.
- **Bản ghi bị đổi khóa (Re-keyed records)**: xuất hóa đơn điều chỉnh giảm (credit note) rồi phát hành lại, mã id được di chuyển sang hệ thống mới, thay đổi tiền tố (INV- so với IN-). Cách chứng minh: cùng khách hàng, số tiền và ngày tháng nhưng mang một khóa mới.
- **Lỗi đồng bộ (Failed sync)**: bản ghi chưa bao giờ được chuyển tới hệ thống đích. Cách chứng minh: kiểm tra nhật ký tích hợp (integration logs), hàng đợi lỗi (error queue), thời điểm đồng bộ thành công gần nhất.
- **Định dạng khóa (Key format)**: khác biệt chữ hoa/thường, khoảng trắng, chữ số 0 đứng đầu, dấu phân cách (KEY_FORMAT_DRIFT). Cách khắc phục: sửa ngay tại hệ thống nguồn; chuẩn hóa khóa khi xuất dữ liệu.

## Các giá trị bị thay đổi
| Quy luật | Nguyên nhân có khả năng cao |
|---|---|
| chênh lệch là bội số của 9 (450 -> 405, 1,234 -> 1,324) | bị đảo vị trí chữ số khi nhập liệu thủ công |
| đảo dấu chính xác | quy ước nợ/có (debit/credit), hóa đơn điều chỉnh giảm được lưu dưới dạng số dương kèm theo một cột phân loại |
| chênh lệch đúng bằng thuế suất hoặc tỷ lệ phí x số tiền | một bên là số tiền trước thuế/phí (gross), bên kia là số tiền thực nhận (net) |
| chênh lệch từ 0.01 đến 0.05 | làm tròn theo từng dòng so với làm tròn trên số tổng; làm tròn lên (half-up) so với làm tròn kiểu ngân hàng (banker's rounding) |
| số tiền / tỷ giá | quy đổi ngoại tệ, áp dụng tỷ giá của ngày khác |
| ngày tháng lệch nhau một ngày | khác múi giờ (UTC so với giờ địa phương) trên các dấu thời gian gần nửa đêm |
| khác trạng thái | độ trễ đồng bộ, hoặc hai hệ thống sử dụng máy trạng thái (state machine) khác nhau (paid so với settled) |
| văn bản chỉ khác nhau ở dấu trọng âm hoặc ký tự lạ | lệch bảng mã ký tự (UTF-8 so với Windows-1252) |

## Số tổng (Totals)
- Đảm bảo bảng cầu nối cân bằng về 0 trước khi lập báo cáo. Khoản chênh lệch chưa giải thích khác 0 có nghĩa là đang có khóa trống, ô dữ liệu không phải dạng số hoặc một cột đang trộn lẫn nhiều loại tiền tệ.
- Báo cáo số tổng riêng theo từng loại tiền tệ; không bao giờ cộng gộp USD và EUR với nhau.
- Đối chiếu số tổng với báo cáo gốc của chính hệ thống đó (bảng cân đối thử, báo cáo chi trả), chứ không chỉ đối chiếu với tệp xuất ra.

## Chứng minh nguyên nhân
Đối với mỗi khoản lệch: chỉ rõ (các) dòng dữ liệu ở cả hai bên, nguyên nhân, số tiền và bằng chứng cụ thể (dòng nhật ký hệ thống, thiết lập xuất dữ liệu, dòng dữ liệu xuất hiện ở kỳ tiếp theo). Đánh dấu hướng xử lý cho từng khoản: sửa tại nguồn A, sửa tại nguồn B, chênh lệch thời điểm (sẽ tự hết ở kỳ sau), hoặc chênh lệch được chấp nhận.

## Ngăn ngừa sai lệch cho tháng tiếp theo
- Áp dụng cùng bộ lọc và cùng định nghĩa kỳ dữ liệu (múi giờ, mốc ngày kết thúc bao gồm cả ngày cuối) trên cả hai bản xuất.
- Duy trì khóa ổn định, duy nhất xuyên suốt từ đầu đến cuối; không đổi khóa thủ công.
- Xuất số tiền dưới dạng số thuần túy dùng dấu thập phân `.` và tách riêng một cột tiền tệ; xuất ngày tháng theo chuẩn ISO 8601.
- Chạy công cụ kiểm tra định kỳ theo lịch và bật cảnh báo khi có phát hiện mức HIGH hoặc khi có số tổng chưa giải thích được.
FILE:templates/reconciliation-report.md
# Báo cáo đối chiếu: <Nguồn OLD> so với <Nguồn NEW>, <kỳ đối chiếu>

**Dữ liệu đầu vào:** <billing-export-sept.csv (14 dòng, xuất ngày 2026-10-02 bởi ...)>, <ledger-export-sept.csv (15 dòng)>
**Khóa:** <invoice_id>   **Các cột so sánh:** <customer, amount, currency, status, invoice_date>
**Chuẩn hóa:** <loại bỏ ký hiệu $ và dấu phân cách hàng nghìn, (x) = -x, bỏ qua chữ hoa/thường ở cột status, ngày tháng kiểu Mỹ được đọc thành chuẩn ngày tháng, ngưỡng dung sai cột amount là 0.01>
**Kết quả:** <KHỚP HOÀN TOÀN / PHÁT HIỆN KHÁC BIỆT: 4 khoản lệch, chênh lệch ròng +835.005, chưa giải thích 0>

## Tóm tắt
| Số dòng OLD | Số dòng NEW | Đã khớp | Không đổi | Thay đổi | Thêm mới | Đã xóa | Trùng lặp |
|---|---|---|---|---|---|---|---|
| <14> | <15> | <13> | <11> | <2> | <1> | <1> | <1> |

## Bảng cầu nối tổng kiểm soát (<amount>, <USD>)
| Bước | Số tiền |
|---|---|
| Tổng OLD | <9,545.97> |
| + các dòng chỉ có trong NEW | <310.00> |
| - các dòng chỉ có trong OLD | <310.00> |
| + thay đổi trên các dòng đã khớp | <-45.00> |
| + trùng lặp trong NEW | <880.00> |
| = Tổng NEW | <10,380.97> |
| Chưa giải thích | <0.00> |

## Chi tiết các khoản lệch (Breaks)
| # | (Các) khóa | Loại | OLD | NEW | Số tiền | Nguyên nhân (bằng chứng) | Người phụ trách | Hành động xử lý |
|---|---|---|---|---|---|---|---|---|
| 1 | <INV-1011> | <Trùng lặp> | <880.00 x1> | <880.00 x2> | <+880.00> | <hạch toán hai lần do cơ chế thử lại khi nhập dữ liệu (lô 7731)> | <Đội GL> | <đảo bút toán hạch toán thứ hai> |

## Các khác biệt chỉ về mặt định dạng (không cần xử lý)
- <định dạng số tiền, chữ hoa/thường của trạng thái, định dạng ngày tháng, tên tiêu đề cột>

## Khuyến nghị
- <áp dụng cùng bộ lọc và định nghĩa kỳ dữ liệu trên cả hai bản xuất>
- <bổ sung khóa chống trùng lặp (idempotency key) vào quy trình nhập dữ liệu sổ cái>

## Tài liệu đính kèm
- <diff.csv từ tùy chọn --out-csv (status, key, column, old, new, delta)>
FILE:examples/billing-export-sept.csv
invoice_id,customer,amount,currency,status,invoice_date
INV-1001,Northwind Traders,1200.00,USD,paid,2026-09-01
INV-1002,Blue Harbor Cafe,89.90,USD,paid,2026-09-02
INV-1003,Atlas Fitness,1033.335,USD,paid,2026-09-03
INV-1004,Greenleaf Dental,450.00,USD,paid,2026-09-05
INV-1005,Orbit Labs,2500.00,USD,paid,2026-09-08
INV-1006,Maple & Co,74.50,USD,refunded,2026-09-09
INV-1007,Copperline Studio,310.00,USD,paid,2026-09-11
INV-1008,Riverbend School,640.00,USD,paid,2026-09-12
INV-1009,Juniper Vet Clinic,195.00,USD,paid,2026-09-15
INV-1010,Northwind Traders,1200.00,USD,paid,2026-09-18
INV-1011,Summit Roofing,880.00,USD,paid,2026-09-22
INV-1012,Blue Harbor Cafe,89.90,USD,open,2026-09-25
INV-1013,Orbit Labs,-150.00,USD,credit,2026-09-26
INV-1014,Atlas Fitness,1033.33,USD,paid,2026-09-30
FILE:examples/ledger-export-sept.csv
Invoice ID;customer_name;Amount;Currency;Status;Invoice Date;gl_account
INV-1001;Northwind Traders;$1,200.00;USD;Paid;09/01/2026;4000
INV-1002;Blue Harbor Cafe;$89.90;USD;Paid;09/02/2026;4000
INV-1003;Atlas Fitness;$1,033.34;USD;Paid;09/03/2026;4000
INV-1004;Greenleaf Dental;$405.00;USD;Paid;09/05/2026;4000
INV-1005;Orbit Labs;$2,500.00;USD;Paid;09/08/2026;4000
INV-1006;Maple & Co;$74.50;USD;Refunded;09/09/2026;4000
INV-1008;Riverbend School;$640.00;USD;Paid;09/12/2026;4000
inv-1009;Juniper Vet Clinic;$195.00;USD;Paid;09/15/2026;4000
INV-1010;Northwind Traders;$1,200.00;USD;Paid;09/18/2026;4000
INV-1011;Summit Roofing;$880.00;USD;Paid;09/22/2026;4000
INV-1011;Summit Roofing;$880.00;USD;Paid;09/22/2026;4000
INV-1012;Blue Harbor Cafe;$89.90;USD;Paid;09/25/2026;4000
INV-1013;Orbit Labs;($150.00);USD;Credit;09/26/2026;4090
INV-1014;Atlas Fitness;$1,033.33;USD;Paid;09/30/2026;4000
INV-1015;Copperline Studio;$310.00;USD;Paid;10/01/2026;4000
FILE:scripts/csv_recon.py
#!/usr/bin/env python3
"""CSV diff and reconciliation checker (Python 3 standard library only).

Compares two CSV exports of the same records (old vs new, system A vs system B) by a
key, after normalizing the noise that makes naive diffs useless: spaces, header case,
thousands separators, currency signs, accounting negatives "(12.50)", number
formatting, optional text case and date formats. Reports schema differences, missing
or duplicate keys, added and removed rows, changed values per column (with numeric
tolerances), keys that only differ by format (case, leading zeros, spaces) and a
control-total bridge for amount columns: old total + added - removed + changes = new.

Usage:
  python3 csv_recon.py OLD.csv NEW.csv --key id [--key2 line] [--map old_col=new_col]
        [--ignore col,col] [--tol amount=0.01] [--sum amount] [--ignore-case]
        [--dates col,col] [--dayfirst] [--limit 20] [--out-csv diff.csv] [--json]
  python3 csv_recon.py --self-test
Exit codes: 0 = files reconcile (no differences after normalization and tolerance),
1 = differences found, 2 = bad input (missing file, missing key column).
"""
import argparse, csv, datetime, io, json, re, sys
from decimal import Decimal, InvalidOperation

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
NUM_RE = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$")
DATE_FORMATS = ["%Y-%m-%d", "%Y/%m/%d", "%Y%m%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"]


class InputError(Exception):
    pass


def norm_header(h):
    return re.sub(r"[\s_\-]+", "_", h.strip().lstrip("\ufeff").lower())


def read_csv(text, name):
    text = text.lstrip("\ufeff")
    if not text.strip():
        raise InputError(f"{name} is empty")
    sample = text[:4096]
    try:
        dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
    except csv.Error:
        dialect = csv.excel
    rows = list(csv.reader(io.StringIO(text), dialect))
    rows = [r for r in rows if any(c.strip() for c in r)]
    header = [h.strip() for h in rows[0]]
    body = []
    for i, r in enumerate(rows[1:], 2):
        if len(r) != len(header):
            body.append((i, dict(zip(header, r + [""] * (len(header) - len(r)))), len(r)))
        else:
            body.append((i, dict(zip(header, r)), len(r)))
    return header, body, getattr(dialect, "delimiter", ",")


def parse_number(s):
    """Return Decimal for '1,234.50', '$1,234.50', '(12.00)', '-5', '12.5%' (as 12.5), else None."""
    t = s.strip().replace("\u00a0", "").replace(" ", "")
    if not t:
        return None
    neg = False
    if t.startswith("(") and t.endswith(")"):
        neg, t = True, t[1:-1]
    if t.endswith("-") and NUM_RE.match(t[:-1].lstrip("$")):
        neg, t = True, t[:-1]
    t = re.sub(r"^([+-]?)(USD|EUR|GBP|[$\u20ac\u00a3])", r"\1", t)
    t = re.sub(r"(USD|EUR|GBP|[$\u20ac\u00a3])$", "", t)
    t = t.rstrip("%")
    if re.match(r"^[+-]?\d{1,3}(,\d{3})+(\.\d+)?$", t):
        t = t.replace(",", "")
    if not NUM_RE.match(t):
        return None
    try:
        d = Decimal(t)
    except InvalidOperation:
        return None
    return -d if neg else d


def parse_date(s, dayfirst):
    t = s.strip()
    fmts = DATE_FORMATS + (["%d/%m/%Y"] if dayfirst else ["%m/%d/%Y"])
    for f in fmts:
        try:
            return datetime.datetime.strptime(t, f).date().isoformat() if "%H" not in f else datetime.datetime.strptime(t, f).isoformat()
        except ValueError:
            pass
    return None


def normalize(value, col, opts):
    v = value.strip()
    if col in opts["dates"]:
        d = parse_date(v, opts["dayfirst"])
        if d:
            return ("date", d)
    n = parse_number(v)
    if n is not None:
        return ("num", n)
    if v.lower() in ("", "null", "none", "n/a", "na", "-"):
        return ("empty", "")
    v = re.sub(r"\s+", " ", v)
    return ("text", v.lower() if opts["ignore_case"] else v)


def same(a, b, col, opts):
    if a[0] == "num" and b[0] == "num":
        tol = opts["tol"].get(col, opts["tol"].get("*", Decimal(0)))
        return abs(a[1] - b[1]) <= tol
    return a == b


def key_variant(k):
    """Format-insensitive form of a key: case, spaces, separators and leading zeros."""
    t = re.sub(r"[\s\-_./]", "", k.lower())
    return re.sub(r"(?<!\d)0+(?=\d)", "", t)


def reconcile(old_text, new_text, keys, opts):
    oh, ob, odelim = read_csv(old_text, "OLD")
    nh, nb, ndelim = read_csv(new_text, "NEW")
    findings = []

    def add(sev, code, detail, count=1):
        findings.append({"severity": sev, "code": code, "detail": detail, "count": count})

    # column matching: explicit map, then exact, then normalized header
    cmap, fuzzy = {}, []
    n_by_norm = {norm_header(h): h for h in nh}
    for h in oh:
        if h in opts["map"]:
            target = opts["map"][h]
            if target not in nh:
                raise InputError(f"--map target column '{target}' not in NEW")
            cmap[h] = target
        elif h in nh:
            cmap[h] = h
        elif norm_header(h) in n_by_norm:
            cmap[h] = n_by_norm[norm_header(h)]
            fuzzy.append(f"{h} = {cmap[h]}")
    if fuzzy:
        add("LOW", "HEADER_FORMAT", f"{len(fuzzy)} column(s) matched by case/spacing only: " + "; ".join(fuzzy), len(fuzzy))
    only_old = [h for h in oh if h not in cmap]
    only_new = [h for h in nh if h not in cmap.values()]
    for h in only_old:
        add("MEDIUM", "COLUMN_ONLY_IN_OLD", f"'{h}' has no match in NEW (use --map {h}=<new name> if it was renamed)")
    for h in only_new:
        add("MEDIUM", "COLUMN_ONLY_IN_NEW", f"'{h}' has no match in OLD")
    for k in keys:
        if k not in oh:
            raise InputError(f"key column '{k}' not in OLD header {oh}")
        if cmap.get(k) not in nh:
            raise InputError(f"key column '{k}' has no match in NEW header {nh}")
    if odelim != ndelim:
        add("INFO", "DELIMITER", f"OLD uses '{odelim}', NEW uses '{ndelim}'")

    def index(body, side, colnames):
        idx, dups, blanks, ragged = {}, {}, 0, 0
        for line, row, ncells in body:
            if ncells != (len(oh) if side == "OLD" else len(nh)):
                ragged += 1
            kv = tuple(row.get(c, "").strip() for c in colnames)
            if not all(kv):
                blanks += 1
                continue
            if kv in idx:
                dups.setdefault(kv, [idx[kv][0]]).append(line)
            else:
                idx[kv] = (line, row)
        return idx, dups, blanks, ragged

    oidx, odups, oblank, orag = index(ob, "OLD", keys)
    nidx, ndups, nblank, nrag = index(nb, "NEW", [cmap[k] for k in keys])
    for side, dups in (("OLD", odups), ("NEW", ndups)):
        for kv, lines in dups.items():
            add("HIGH", "DUPLICATE_KEY", f"{side} key {'/'.join(kv)} on lines {', '.join(map(str, lines))} (only the first is compared)")
    for side, n in (("OLD", oblank), ("NEW", nblank)):
        if n:
            add("HIGH", "MISSING_KEY", f"{side} has {n} row(s) with a blank key (not compared)", n)
    for side, n in (("OLD", orag), ("NEW", nrag)):
        if n:
            add("MEDIUM", "RAGGED_ROWS", f"{side} has {n} row(s) with a different number of cells than the header", n)

    removed = [k for k in oidx if k not in nidx]
    added = [k for k in nidx if k not in oidx]
    # keys that only differ by format
    drift = []
    add_by_variant = {}
    for k in added:
        add_by_variant.setdefault(tuple(key_variant(x) for x in k), k)
    for k in list(removed):
        v = tuple(key_variant(x) for x in k)
        if v in add_by_variant:
            nk = add_by_variant.pop(v)
            drift.append((k, nk))
            removed.remove(k); added.remove(nk)
    for ok, nk in drift:
        add("LOW", "KEY_FORMAT_DRIFT", f"OLD key {'/'.join(ok)} = NEW key {'/'.join(nk)} after ignoring case, spaces, separators and leading zeros (compared as one row)")
    if removed:
        add("MEDIUM", "ROWS_REMOVED", f"{len(removed)} key(s) in OLD only: " + ", ".join('/'.join(k) for k in removed[:opts['limit']]), len(removed))
    if added:
        add("MEDIUM", "ROWS_ADDED", f"{len(added)} key(s) in NEW only: " + ", ".join('/'.join(k) for k in added[:opts['limit']]), len(added))

    compare_cols = [h for h in oh if h in cmap and h not in keys and h not in opts["ignore"]]
    pairs = [(k, k) for k in oidx if k in nidx] + drift
    changes, per_col, unchanged, fmt_only = [], {c: 0 for c in compare_cols}, 0, {c: 0 for c in compare_cols}
    for ok, nk in pairs:
        orow, nrow = oidx[ok][1], nidx[nk][1]
        row_changed = False
        for c in compare_cols:
            a_raw, b_raw = orow.get(c, ""), nrow.get(cmap[c], "")
            a, b = normalize(a_raw, c, opts), normalize(b_raw, c, opts)
            if same(a, b, c, opts):
                if a_raw.strip() != b_raw.strip():
                    fmt_only[c] += 1
                continue
            row_changed = True
            per_col[c] += 1
            delta = (b[1] - a[1]) if a[0] == "num" and b[0] == "num" else None
            changes.append({"key": "/".join(ok), "column": c, "old": a_raw, "new": b_raw,
                            "delta": str(delta) if delta is not None else ""})
        unchanged += not row_changed
    # removed and added rows that look like the same record under a new key or period
    nondate = [c for c in compare_cols if c not in opts["dates"]]
    used = set()
    for rk in removed:
        orow = oidx[rk][1]
        for ak in added:
            if ak in used or not nondate:
                continue
            nrow = nidx[ak][1]
            hits = sum(same(normalize(orow.get(c, ""), c, opts), normalize(nrow.get(cmap[c], ""), c, opts), c, opts) for c in nondate)
            if hits == len(nondate):
                used.add(ak)
                add("LOW", "POSSIBLE_REKEY", f"removed {'/'.join(rk)} and added {'/'.join(ak)} have the same values in {', '.join(nondate)} (re-keyed, re-posted or moved to another period?)")
                break
    for c, n in per_col.items():
        if n:
            add("MEDIUM", "VALUE_CHANGED", f"'{c}' differs in {n} row(s)", n)
    for c, n in fmt_only.items():
        if n:
            add("INFO", "FORMAT_ONLY", f"'{c}' equal after normalization in {n} row(s) (formatting, case or within tolerance)", n)

    bridges = []
    for col in opts["sum"]:
        if col not in oh or cmap.get(col) not in nh:
            raise InputError(f"--sum column '{col}' missing in OLD or NEW")
        def total(body, c):
            t, bad = Decimal(0), 0
            for _, row, _ in body:
                n = parse_number(row.get(c, ""))
                if n is None:
                    bad += bool(row.get(c, "").strip())
                else:
                    t += n
            return t, bad
        ot, obad = total(ob, col)
        nt, nbad = total(nb, cmap[col])
        def val(row, c):
            return parse_number(row.get(c, "")) or Decimal(0)
        s_add = sum((val(nidx[k][1], cmap[col]) for k in added), Decimal(0))
        s_rem = sum((val(oidx[k][1], col) for k in removed), Decimal(0))
        s_chg = sum((val(nidx[nk][1], cmap[col]) - val(oidx[ok][1], col) for ok, nk in pairs), Decimal(0))
        s_dup_o = sum((val(r, col) for _, r, _ in ob), Decimal(0)) - sum((val(r, col) for _, r in oidx.values()), Decimal(0))
        s_dup_n = sum((val(r, cmap[col]) for _, r, _ in nb), Decimal(0)) - sum((val(r, cmap[col]) for _, r in nidx.values()), Decimal(0))
        explained = ot + s_add - s_rem + s_chg - s_dup_o + s_dup_n
        unexplained = nt - explained
        bridges.append({"column": col, "old_total": str(ot), "added": str(s_add), "removed": str(s_rem),
                        "changed": str(s_chg), "duplicates_old": str(s_dup_o), "duplicates_new": str(s_dup_n),
                        "new_total": str(nt), "unexplained": str(unexplained), "non_numeric_old": obad, "non_numeric_new": nbad})
        if nt != ot:
            add("INFO", "TOTAL_DIFF", f"'{col}' total {ot} -> {nt} (diff {nt - ot}); see bridge")
        if unexplained != 0:
            add("HIGH", "TOTAL_UNEXPLAINED", f"'{col}' bridge leaves {unexplained} unexplained (blank keys or non-numeric cells?)")
        if obad or nbad:
            add("MEDIUM", "NON_NUMERIC", f"'{col}' has {obad} non-numeric cell(s) in OLD and {nbad} in NEW (left out of totals)", obad + nbad)

    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["code"]))
    summary = {"old_rows": len(ob), "new_rows": len(nb), "matched": len(pairs), "unchanged": unchanged,
               "changed_rows": len(pairs) - unchanged, "added": len(added), "removed": len(removed),
               "compared_columns": compare_cols, "column_map": {k: v for k, v in cmap.items() if k != v}}
    differs = any(f["severity"] in ("HIGH", "MEDIUM") for f in findings)
    return {"summary": summary, "findings": findings, "changes": changes, "bridges": bridges,
            "added_keys": ["/".join(k) for k in added], "removed_keys": ["/".join(k) for k in removed],
            "reconciles": not differs}


def render_md(res, old_name, new_name, limit):
    s = res["summary"]
    out = [f"# CSV reconciliation: {old_name} vs {new_name}", "",
           f"**Result:** {'RECONCILES' if res['reconciles'] else 'DIFFERENCES FOUND'}", "",
           "| Rows OLD | Rows NEW | Matched | Unchanged | Changed | Added | Removed |", "|---|---|---|---|---|---|---|",
           f"| {s['old_rows']} | {s['new_rows']} | {s['matched']} | {s['unchanged']} | {s['changed_rows']} | {s['added']} | {s['removed']} |", ""]
    if s["column_map"]:
        out.append("Column map: " + ", ".join(f"{k} -> {v}" for k, v in s["column_map"].items()))
        out.append("")
    out += ["## Findings", ""]
    if res["findings"]:
        out += ["| Severity | Code | Detail |", "|---|---|---|"]
        out += [f"| {f['severity']} | {f['code']} | {f['detail']} |" for f in res["findings"]]
    else:
        out.append("No findings.")
    if res["changes"]:
        out += ["", f"## Changed values (first {min(limit, len(res['changes']))} of {len(res['changes'])})", "",
                "| Key | Column | OLD | NEW | Delta |", "|---|---|---|---|---|"]
        out += [f"| {c['key']} | {c['column']} | {c['old']} | {c['new']} | {c['delta']} |" for c in res["changes"][:limit]]
    for b in res["bridges"]:
        out += ["", f"## Control total bridge: {b['column']}", "", "| Step | Amount |", "|---|---|",
                f"| OLD total | {b['old_total']} |", f"| + rows added | {b['added']} |", f"| - rows removed | {b['removed']} |",
                f"| + changes on matched rows | {b['changed']} |", f"| - duplicate/blank-key rows in OLD | {b['duplicates_old']} |",
                f"| + duplicate/blank-key rows in NEW | {b['duplicates_new']} |", f"| = NEW total | {b['new_total']} |",
                f"| Unexplained | {b['unexplained']} |"]
    return "\n".join(out)


def parse_args(argv):
    ap = argparse.ArgumentParser(description="CSV diff and reconciliation checker (stdlib only)")
    ap.add_argument("old", nargs="?"); ap.add_argument("new", nargs="?")
    ap.add_argument("--key", help="key column(s) in OLD, comma-separated")
    ap.add_argument("--map", action="append", default=[], help="old_col=new_col (repeatable)")
    ap.add_argument("--ignore", default="", help="columns to skip, comma-separated")
    ap.add_argument("--tol", action="append", default=[], help="col=0.01 or 0.01 for all numeric columns")
    ap.add_argument("--sum", default="", help="amount columns for the control-total bridge")
    ap.add_argument("--ignore-case", action="store_true")
    ap.add_argument("--dates", default="", help="date columns to compare as dates")
    ap.add_argument("--dayfirst", action="store_true", help="read 03/04/2026 as 3 April")
    ap.add_argument("--limit", type=int, default=20)
    ap.add_argument("--out-csv"); ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    return ap, ap.parse_args(argv)


def build_opts(a):
    tol = {}
    for t in a.tol:
        if "=" in t:
            c, v = t.split("=", 1); tol[c.strip()] = Decimal(v)
        else:
            tol["*"] = Decimal(t)
    mp = {}
    for m in a.map:
        if "=" not in m:
            raise InputError(f"--map needs old=new, got '{m}'")
        o, n = m.split("=", 1); mp[o.strip()] = n.strip()
    split = lambda s: [x.strip() for x in s.split(",") if x.strip()]
    return {"tol": tol, "map": mp, "ignore": set(split(a.ignore)), "sum": split(a.sum),
            "ignore_case": a.ignore_case, "dates": set(split(a.dates)), "dayfirst": a.dayfirst, "limit": a.limit}


# ---------------------------------------------------------------- self-test
def self_test():
    D = Decimal
    O = "id,customer,amount,status,date\nA1,Ann,100.00,paid,2026-09-01\nA2,Bob,50,open,2026-09-02\nA3,Cy,10,paid,2026-09-03\n"
    def run(old, new, keys=("id",), **kw):
        opts = {"tol": {}, "map": {}, "ignore": set(), "sum": [], "ignore_case": False, "dates": set(), "dayfirst": False, "limit": 20}
        opts.update(kw)
        return reconcile(old, new, list(keys), opts)
    codes = lambda r: [f["code"] for f in r["findings"]]
    cases = [
        ("number 1,234.50", lambda: parse_number("1,234.50") == D("1234.50")),
        ("currency $1,234.50", lambda: parse_number("$1,234.50") == D("1234.50")),
        ("accounting (12.00)", lambda: parse_number("(12.00)") == D("-12.00")),
        ("trailing minus 5-", lambda: parse_number("5-") == D("-5")),
        ("percent 12.5%", lambda: parse_number("12.5%") == D("12.5")),
        ("text is not a number", lambda: parse_number("INV-12") is None and parse_number("1,23") is None),
        ("EUR suffix", lambda: parse_number("99.90 EUR") == D("99.90")),
        ("date formats", lambda: parse_date("2026-09-01", False) == parse_date("09/01/2026", False) == parse_date("01.09.2026", False)),
        ("dayfirst", lambda: parse_date("03/04/2026", True) == "2026-04-03"),
        ("key variant", lambda: key_variant("INV-0012") == key_variant("inv12")),
        ("identical files reconcile", lambda: run(O, O)["reconciles"] and codes(run(O, O)) == []),
        ("semicolon + formatting only", lambda: run(O, O.replace(",", ";").replace("100.00", "100").replace("50;", "50.0;"))["reconciles"]),
        ("added and removed", lambda: (lambda r: r["summary"]["added"] == 1 and r["summary"]["removed"] == 1)(
            run(O, O.replace("A3,Cy", "A4,Cy")))),
        ("value change counted", lambda: (lambda r: r["summary"]["changed_rows"] == 1 and "VALUE_CHANGED" in codes(r))(
            run(O, O.replace("50,open", "55,open")))),
        ("tolerance hides rounding", lambda: run(O, O.replace("100.00", "100.004"), tol={"amount": D("0.01")})["reconciles"]),
        ("tolerance does not hide real diff", lambda: not run(O, O.replace("100.00", "100.02"), tol={"amount": D("0.01")})["reconciles"]),
        ("ignore case", lambda: run(O, O.replace("paid", "PAID"), ignore_case=True)["reconciles"] and not run(O, O.replace("paid", "PAID"))["reconciles"]),
        ("dates compared as dates", lambda: run(O, O.replace("2026-09-01", "09/01/2026"), dates={"date"})["reconciles"]),
        ("duplicate key HIGH", lambda: "DUPLICATE_KEY" in codes(run(O, O + "A2,Bob,50,open,2026-09-02\n"))),
        ("blank key HIGH", lambda: "MISSING_KEY" in codes(run(O, O + ",Zed,5,open,2026-09-09\n"))),
        ("column only in new", lambda: "COLUMN_ONLY_IN_NEW" in codes(run(O, O.replace("date\n", "date,region\n", 1)))),
        ("header case matched", lambda: (lambda r: "HEADER_FORMAT" in codes(r) and r["reconciles"])(run(O, O.replace("customer", "Customer")))),
        ("rename via map", lambda: run(O, O.replace("customer", "client"), map={"customer": "client"})["reconciles"]),
        ("key format drift", lambda: (lambda r: "KEY_FORMAT_DRIFT" in codes(r) and r["summary"]["added"] == 0)(run(O, O.replace("A3,", "a03,")))),
        ("possible rekey", lambda: "POSSIBLE_REKEY" in codes(run(O, O.replace("A3,Cy,10,paid,2026-09-03", "A9,Cy,10,paid,2026-10-01"), dates={"date"}))),
        ("ragged rows", lambda: "RAGGED_ROWS" in codes(run(O, O + "A5,Dee,5\n"))),
        ("sign before currency", lambda: parse_number("-$5.00") == parse_number("$-5.00") == D("-5.00")),
        ("composite key", lambda: run("o,l,v\n1,1,a\n1,2,b\n", "o,l,v\n1,2,b\n1,1,a\n", keys=("o", "l"))["reconciles"]),
        ("bridge explains totals", lambda: (lambda b: D(b["unexplained"]) == 0 and b["old_total"] == "160.00" and b["new_total"] == "175.00")(
            run(O, O.replace("A3,Cy,10", "A4,Cy,20").replace("50,open", "55,open"), sum=["amount"])["bridges"][0])),
        ("bridge with duplicate", lambda: D(run(O, O + "A2,Bob,50,open,2026-09-02\n", sum=["amount"])["bridges"][0]["unexplained"]) == 0),
        ("missing key column error", lambda: _raises(lambda: run(O, O, keys=("nope",)))),
        ("empty file error", lambda: _raises(lambda: run("", O))),
        ("BOM stripped", lambda: run("\ufeff" + O, O)["reconciles"]),
        ("render has result line", lambda: "RECONCILES" in render_md(run(O, O), "a", "b", 5)),
    ]
    passed = 0
    for name, fn in cases:
        try:
            ok = bool(fn())
        except Exception as e:  # noqa: BLE001 - report in test output
            ok = False; name += f" (raised {e!r})"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def _raises(fn):
    try:
        fn()
    except InputError:
        return True
    return False


def main(argv=None):
    ap, a = parse_args(argv if argv is not None else sys.argv[1:])
    if a.self_test:
        return self_test()
    if not (a.old and a.new and a.key):
        ap.error("need OLD NEW --key (or --self-test)")
    try:
        opts = build_opts(a)
        with open(a.old, encoding="utf-8-sig", newline="") as f1, open(a.new, encoding="utf-8-sig", newline="") as f2:
            old_text, new_text = f1.read(), f2.read()
        res = reconcile(old_text, new_text, [k.strip() for k in a.key.split(",")], opts)
    except (OSError, UnicodeDecodeError, InputError, InvalidOperation) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    if a.out_csv:
        with open(a.out_csv, "w", newline="", encoding="utf-8") as fh:
            w = csv.writer(fh)
            w.writerow(["status", "key", "column", "old", "new", "delta"])
            for k in res["removed_keys"]:
                w.writerow(["removed", k, "", "", "", ""])
            for k in res["added_keys"]:
                w.writerow(["added", k, "", "", "", ""])
            for c in res["changes"]:
                w.writerow(["changed", c["key"], c["column"], c["old"], c["new"], c["delta"]])
    print(json.dumps(res, indent=2, default=str) if a.json else render_md(res, a.old, a.new, a.limit))
    return 0 if res["reconciles"] else 1


if __name__ == "__main__":
    sys.exit(main())
PythonData AnalysisData Quality+2
F@f
0
Trình Lập Kế hoạch Bảo quản và Hạn sử dụng Cho Bữa ăn Chuẩn bị Trước (Meal Prep)
Kỹ năng

Lên kế hoạch vị trí bảo quản cho từng khẩu phần nấu sẵn sau một buổi chuẩn bị bữa ăn (meal prep): phần nào để ngăn mát tủ lạnh, phần nào cất vào ngăn đá ngay trong ngày sơ chế, khi nào cần chuyển từng phần xuống ngăn mát để rã đông, và nội dung cần ghi trên nhãn dán, sử dụng mốc thời gian bảo quản ngăn mát và ngăn đá an toàn theo khuyến nghị của USDA. Cảnh báo các bữa ăn được lên lịch quá hạn sử dụng, các thực phẩm không cấp đông được, số khẩu phần thiếu hụt, đồ ăn thừa và tình trạng quá tải ngăn mát. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ cờ `--self-test`.

---
name: meal-prep-storage-and-use-by-planner
description: Lên kế hoạch vị trí bảo quản cho từng khẩu phần nấu sẵn sau một buổi chuẩn bị bữa ăn (meal prep): phần nào để ngăn mát tủ lạnh, phần nào cất vào ngăn đá ngay trong ngày sơ chế, khi nào cần chuyển từng phần xuống ngăn mát để rã đông, và nội dung cần ghi trên nhãn dán, sử dụng mốc thời gian bảo quản ngăn mát và ngăn đá an toàn theo khuyến nghị của USDA. Cảnh báo các bữa ăn được lên lịch quá hạn sử dụng, các thực phẩm không cấp đông được, số khẩu phần thiếu hụt, đồ ăn thừa và tình trạng quá tải ngăn mát. Bao gồm công cụ lập kế hoạch viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Trình Lập Kế hoạch Bảo quản và Hạn sử dụng Cho Bữa ăn Chuẩn bị Trước (Meal Prep)

Bạn giúp những người nấu ăn theo mẻ vào một ngày trong tuần có thể ăn uống an toàn suốt cả tuần. Dựa trên những món họ đã nấu và thời điểm họ dự định ăn, bạn quyết định khẩu phần nào giữ ở ngăn mát tủ lạnh và khẩu phần nào đưa thẳng vào ngăn đá, lên lịch rã đông và soạn nội dung nhãn dán, để không có món nào trong thực đơn ngày thứ Sáu bị quá hạn bảo quản ngăn mát và không có thứ gì bị lãng phí.

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

- `scripts/prep_storage.py` - đọc thông tin mẻ nấu (món ăn, danh mục, số khẩu phần, ngày nấu) và kế hoạch ăn uống, chia khẩu phần giữa ngăn mát và ngăn đá, lên lịch rã đông, viết nội dung nhãn dán cùng kế hoạch chi tiết từng ngày, đồng thời gắn cờ cảnh báo các vấn đề (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/storage-times.md` - thời gian bảo quản ngăn mát và ngăn đá cho 15 danh mục thực phẩm kèm nguồn tham chiếu, và cách xử lý các thực phẩm không có trong danh sách
- `references/cooling-thawing-reheating.md` - làm nguội trong vòng 2 giờ, hộp đựng và nhãn dán, rã đông an toàn, nhiệt độ hâm nóng và nấu chín, cơm và các trường hợp đặc biệt khác
- `templates/prep-week-plan.md` - bản kế hoạch bàn giao lại cho người dùng (bảng bảo quản, lịch từng ngày, nhãn dán, ghi chú)
- `examples/sunday-batch.csv` - một buổi sơ chế ngày Chủ nhật: 7 món, 31 khẩu phần
- `examples/week-plan.csv` - các bữa ăn từ thứ Hai đến thứ Bảy sử dụng các khẩu phần đó

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

### 1. Thu thập thông tin mẻ nấu và kế hoạch ăn
Hỏi xem họ đã nấu hoặc sơ chế những món gì, mỗi món bao nhiêu khẩu phần, ngày nấu, và mỗi khẩu phần dành cho ngày nào, bữa nào. Ánh xạ từng món vào một danh mục (`python3 scripts/prep_storage.py --categories`); khi không chắc chắn, hãy chọn danh mục có thời hạn bảo quản ngắn hơn. Hỏi về không gian ngăn mát tủ lạnh (chứa vừa bao nhiêu hộp) và liệu có món nào bị để ngoài nhiệt độ phòng quá 2 giờ hay không (thực phẩm đó sẽ không được đưa vào kế hoạch mà phải loại bỏ).

### 2. Chạy trình lập kế hoạch
```
python3 scripts/prep_storage.py examples/sunday-batch.csv examples/week-plan.csv --fridge-capacity 16
python3 scripts/prep_storage.py batch.csv plan.csv --json
python3 scripts/prep_storage.py --categories
python3 scripts/prep_storage.py --self-test
```
Ngày 0 là ngày nấu. Các khẩu phần được lên lịch ăn trong khung thời gian an toàn của ngăn mát sẽ được giữ ở ngăn mát; các khẩu phần ăn muộn hơn sẽ được cấp đông ngay trong ngày sơ chế và chuyển xuống ngăn mát vào buổi tối hôm trước ngày ăn. Sử dụng các cột tùy chọn `fridge_days` và `freezer_months` cho những thực phẩm không có trong bảng (`freezer_months` bằng 0 = không cấp đông). Mã thoát (exit code) 1 nghĩa là có phát hiện mức HIGH (một khẩu phần sẽ bị ăn quá hạn sử dụng hoặc trước khi được nấu chín), mã thoát 2 nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Điều chỉnh kế hoạch
Giải quyết mọi phát hiện cảnh báo (xem `references/storage-times.md`):
- PAST_USE_BY: đẩy bữa ăn đó lên sớm hơn, đổi chỗ với một món có thể cấp đông, hoặc nấu mới món đó vào giữa tuần.
- SHORTFALL: nấu thêm hoặc đổi món cho bữa đó; SURPLUS_NO_FREEZE: ăn sớm hơn hoặc nấu ít lại vào lần sau.
- FRIDGE_FULL: cấp đông thêm khẩu phần ngay trong ngày sơ chế, gộp chung các khẩu phần, hoặc chia làm hai buổi sơ chế mỗi tuần.
Chạy lại chương trình cho đến khi không còn phát hiện mức HIGH nào.

### 4. Bàn giao kết quả
Điền vào mẫu `templates/prep-week-plan.md`: bảng bảo quản, các việc cần làm trong ngày sơ chế (làm nguội, chia phần, dán nhãn, cất ngăn mát hoặc ngăn đá), danh sách theo từng ngày bao gồm cả lời nhắc "chuyển xuống ngăn mát" vào buổi tối, nội dung các nhãn cần viết, và các ghi chú an toàn từ `references/cooling-thawing-reheating.md` áp dụng cho mẻ nấu này.

## Ví dụ kết quả
Mẻ nấu Chủ nhật 2026-11-01: cơm tô burrito gà (chicken burrito bowls), cơm trắng và súp thịt gà tây hầm ớt (turkey chili) giữ lại mỗi món 3 khẩu phần ở ngăn mát (ăn trước thứ Năm 11-05); 2 phần burrito, 2 phần cơm và 3 phần chili đưa vào ngăn đá ngay Chủ nhật, chuyển xuống ngăn mát vào tối thứ Năm và tối thứ Sáu. Cá hồi sống ướp sẵn (khung an toàn 2 ngày) được cấp đông sống vào Chủ nhật, chuyển xuống ngăn mát vào tối thứ Ba, và nấu chín vào thứ Tư tới nhiệt độ tâm 145 F / 63 C. Món salad trứng vào thứ Sáu rơi vào ngày thứ 5 mà salad trứng lại không cấp đông tốt được: cảnh báo HIGH PAST_USE_BY, vì vậy hãy chuyển bữa trưa đó sang thứ Tư hoặc thứ Năm hoặc làm mới trong ngày. Hai quả trứng luộc chín chưa có lịch ăn (ăn trước Chủ nhật 11-08), một khẩu phần chili làm phần dự phòng trong ngăn đá, và tổng cộng 22 hộp không thể xếp vừa ngăn tủ lạnh chỉ chứa được 16 hộp vào ngày Chủ nhật.

## Quy tắc

- Luôn sử dụng mốc bảo thủ (ngắn nhất) của mọi khoảng thời gian và nêu rõ điều đó; khi nghi ngờ, hãy vứt bỏ.
- Tính ngày nấu là ngày 0; thực phẩm có hạn 4 ngày nấu vào Chủ nhật phải được ăn hết muộn nhất vào thứ Năm.
- Thời gian bảo quản ngăn đá là vì chất lượng món ăn, không phải vì an toàn; thời gian bảo quản ngăn mát là vì an toàn thực phẩm. Tuyệt đối không kéo dài thời gian để ngăn mát chỉ vì thức ăn "trông vẫn ổn".
- Rã đông trong ngăn mát tủ lạnh, trong nước lạnh hoặc bằng lò vi sóng, tuyệt đối không rã đông trên mặt bàn bếp ở nhiệt độ phòng; ăn thức ăn chín đã rã đông trong vòng 3-4 ngày và không tính những ngày đã nằm ở ngăn mát trước khi cấp đông như đồ mới nấu.
- Thực phẩm sống phải có hạn "nấu trước ngày" (cook-by date) và nhiệt độ tâm an toàn khi nấu chín, chứ không phải ghi chú hâm nóng lại.
- Đây là hướng dẫn an toàn thực phẩm chung, không phải lời khuyên y tế; đối với phụ nữ mang thai, trẻ sơ sinh, người cao tuổi hoặc người có hệ miễn dịch suy yếu, hãy tuân theo hướng dẫn y tế địa phương nghiêm ngặt hơn.
FILE:references/storage-times.md
# Thời gian bảo quản ngăn mát và ngăn đá

Các giá trị được sử dụng bởi `scripts/prep_storage.py`. Ngăn mát ở mức 40 F / 4 C hoặc lạnh hơn, ngăn đá ở mức 0 F / -18 C. Ngày 0 là ngày thực phẩm được nấu chín (hoặc đối với đồ sống là ngày sơ chế/tẩm ướp sau khi mua về).

Nguồn: "Bảng Bảo quản Thực phẩm Lạnh" (Cold Food Storage Chart) của USDA FSIS / FoodSafety.gov và ứng dụng USDA FoodKeeper. Ở những mục có khoảng thời gian dao động, trình lập kế hoạch luôn chọn thời gian ngăn mát ngắn hơn.

| Danh mục | Ví dụ | Ngăn mát (theo bảng) | Giá trị dùng | Ngăn đá (chất lượng) | Giá trị dùng |
|---|---|---|---|---|---|
| cooked_meat | thịt bò nướng, thịt heo xé, thịt viên, thịt băm nấu chín | 3-4 ngày | 4 | 2-6 tháng | 3 |
| cooked_poultry | thịt gà xé, ức gà tây, đùi gà nấu chín | 3-4 ngày | 4 | 2-6 tháng | 3 |
| soup_stew | súp, món hầm, chili, cà ri, đậu hầm dal | 3-4 ngày | 4 | 2-3 tháng | 3 |
| casserole | lasagna, enchiladas, món đút lò, bánh thịt hầm shepherd's pie | 3-4 ngày | 4 | 2-3 tháng | 3 |
| cooked_grains | cơm, mì ống, hạt diêm mạch (quinoa), couscous | 3-5 ngày | 4 | 1-2 tháng | 2 |
| cooked_vegetables | rau củ nướng hoặc hấp, các loại đậu đã nấu chín | 3-4 ngày | 4 | khoảng 2 tháng | 2 |
| cooked_fish | cá hồi nướng, tôm đã nấu chín | 3-4 ngày | 3 | khoảng 3 tháng | 3 |
| pizza | bánh pizza, bánh nướng mặn, lát bánh quiche | 3-4 ngày | 4 | 1-2 tháng | 2 |
| deli_salad | salad trứng, gà, cá ngừ, giăm bông, mì ống trộn | 3-5 ngày | 4 | không cấp đông tốt | - |
| hard_boiled_eggs | đã bóc vỏ hoặc chưa bóc vỏ | 1 tuần | 7 | không cấp đông | - |
| cut_raw_vegetables | cà rốt cắt thanh, hành tây thái nhỏ, ớt chuông cắt | khoảng 3-5 ngày (chất lượng) | 4 | cần chần trước; không đưa vào kế hoạch cấp đông | - |
| raw_ground_meat | thịt bò, gà tây, heo xay sống, thịt băm ướp sống | 1-2 ngày | 2 | 3-4 tháng | 3 |
| raw_poultry | thịt gà chặt miếng, đùi gà ướp sống | 1-2 ngày | 2 | 9 tháng (thịt miếng) | 9 |
| raw_steak_chops | bít tết, sườn cốt lết, thịt tảng nướng sống | 3-5 ngày | 4 | 4-12 tháng | 4 |
| raw_fish | phi lê cá hồi, cá thịt trắng sống | 1-2 ngày | 2 | 2-3 tháng (cá béo), 6-8 tháng (cá nạc) | 2 |

## Các thực phẩm không cấp đông tốt
Các món salad trộn sốt mayonnaise, lòng trắng trứng luộc chín (bị dai như cao su), rau xà lách sống, dưa chuột, các loại sốt kem dễ bị tách nước, lớp vỏ chiên tẩm bột (bị ỉu nhũn), mì ống đã luộc chín ngâm trong nước sốt loãng (bị bở nát). Hãy cấp đông riêng phần nước sốt và phần mì ống khi có thể.

## Món ăn hỗn hợp
Áp dụng thời hạn của nguyên liệu có hạn sử dụng ngắn nhất: một tô cơm cá được xếp vào nhóm `cooked_fish` (3 ngày), chứ không phải `cooked_grains` (4 ngày). Nước sốt và nước trộn bảo quản riêng sẽ tính theo nhãn của chính chúng; các loại sốt mua sẵn đã mở nắp tính theo hướng dẫn trên lọ.

## Thực phẩm không có trong danh sách
Thêm cột `fridge_days` và `freezer_months` vào tệp BATCH.csv dựa theo dữ liệu từ FoodKeeper hoặc nhãn sản phẩm. Nếu không có nguồn tra cứu nào, hãy áp dụng 3 ngày ngăn mát và không cấp đông.

## Cách đếm ngày
- Nấu vào Chủ nhật, hạn 4 ngày: Thứ Hai (1), Thứ Ba (2), Thứ Tư (3), Thứ Năm (4) là ngày cuối cùng được phép ăn.
- Thực phẩm được cấp đông ngay trong ngày sơ chế và rã đông trong ngăn mát: ăn trong vòng 3-4 ngày kể từ khi rã đông (trình lập kế hoạch xếp lịch chuyển từ ngăn đá xuống ngăn mát vào buổi tối hôm trước).
- Thực phẩm đến ngày thứ 3 mới đem cấp đông thì chỉ còn khoảng 1 ngày ngăn mát sau khi rã đông; hãy cấp đông sớm, lý tưởng nhất là ngay trong ngày nấu.

## Cấp đông lại
Thực phẩm rã đông trong ngăn mát tủ lạnh có thể cấp đông lại mà không cần nấu chín (nhưng chất lượng sẽ giảm). Thực phẩm rã đông bằng nước lạnh hoặc lò vi sóng bắt buộc phải được nấu chín trước khi cấp đông lại.
FILE:references/cooling-thawing-reheating.md
# Làm nguội, đóng gói, rã đông và hâm nóng

## Làm nguội sau khi nấu (quy tắc 2 giờ)
- Đưa thực phẩm đã nấu vào ngăn mát hoặc ngăn đá trong vòng 2 giờ sau khi nấu (1 giờ nếu nhiệt độ phòng cao hơn 90 F / 32 C).
- Vi khuẩn sinh sôi nhanh nhất trong khoảng từ 40 F đến 140 F (4-60 C). Các nồi lớn nguội quá chậm: hãy chia nhỏ vào các hộp nông lòng (sâu khoảng 2 in / 5 cm) hoặc ngâm nồi vào chậu nước đá kết hợp khuấy đều để hạ nhiệt.
- Thức ăn còn nóng có thể đưa thẳng vào tủ lạnh; hãy hé nhẹ nắp hộp cho đến khi hết bốc hơi nóng, và không xếp chồng các hộp còn nóng lên nhau.
- Cơm và các thực phẩm giàu tinh bột khác: cần làm nguội và cất tủ lạnh thật nhanh. Bào tử vi khuẩn *Bacillus cereus* sống sót qua quá trình nấu chín và tiết ra độc tố khi cơm để ở nhiệt độ ấm; việc hâm nóng lại không tiêu diệt được độc tố này.

## Đóng gói và dán nhãn
- Chia mỗi khẩu phần vào một hộp riêng giúp kế hoạch vận hành trơn tru: bạn chỉ rã đông đúng phần mình sẽ ăn.
- Ngăn đá: dùng túi zip trữ đông ép dẹt (giúp cấp đông và rã đông nhanh), hoặc hộp cứng có chừa khoảng trống 1/2 in (khoảng 1.2 cm) phía trên cho chất lỏng giãn nở.
- Dán nhãn mọi hộp đựng: tên món, ngày nấu, "ăn trước ngày / eat by" (ngăn mát) hoặc "tốt nhất trước ngày / best by" (ngăn đá), hướng dẫn hâm nóng hoặc nấu chín. Chỉ cần băng dính giấy và bút dạ là đủ.
- Để các thực phẩm sống ở ngăn kệ dưới cùng, bên dưới các thực phẩm ăn liền/đã nấu chín.

## Rã đông
| Phương pháp | Cách thực hiện | Sau khi rã đông |
|---|---|---|
| Ngăn mát (tốt nhất) | chuyển xuống từ tối hôm trước; khối thực phẩm lớn hoặc đặc cần 24 giờ cho mỗi 5 lb / 2.3 kg | thức ăn chín dùng trong 3-4 ngày; thịt xay, gia cầm, cá sống dùng trong 1-2 ngày |
| Nước lạnh | cho vào túi kín ngâm trong nước lạnh, thay nước mỗi 30 phút | nấu chín hoặc hâm nóng ngay lập tức |
| Lò vi sóng | chế độ rã đông (defrost) | nấu chín hoặc hâm nóng ngay lập tức |
| Mặt bàn bếp (nhiệt độ phòng) | tuyệt đối không bao giờ | - |
Các món súp và món hầm có thể đem hâm nóng trực tiếp từ trạng thái đông đá trong nồi.

## Nhiệt độ hâm nóng và nấu chín (nhiệt độ tâm thực phẩm)
| Thực phẩm | Nhiệt độ |
|---|---|
| Đồ ăn thừa và món đút lò (casseroles), hâm nóng lại | 165 F / 74 C |
| Súp, nước sốt, nước thịt (gravies) | đun sôi sùng sục |
| Gia cầm sống | 165 F / 74 C |
| Thịt bò, heo, cừu xay sống | 160 F / 71 C |
| Bít tết, sườn cốt lết, thịt tảng sống | 145 F / 63 C cộng thêm 3 phút nghỉ |
| Cá sống | 145 F / 63 C hoặc cho đến khi thịt cá tơi thành từng thớ và chuyển màu đục |
Lò vi sóng: đậy nắp, đảo đều ở giữa chừng, để nghỉ 1-2 phút sau khi quay, và kiểm tra phần giữa món ăn bằng nhiệt kế thực phẩm.

## Cơm hộp mang đi làm
Sử dụng túi giữ nhiệt có đặt túi đá lạnh (ice pack); hâm nóng tại nơi làm việc cho đến khi bốc khói nghi ngút. Thức ăn để ở nhiệt độ phòng quá 2 giờ phải vứt bỏ, không cất ngược lại vào tủ lạnh.

## Dấu hiệu cảm quan không phải là phép thử an toàn
Vi khuẩn gây hỏng thức ăn (tạo mùi hôi, nhớt) và vi khuẩn gây bệnh (gây ngộ độc) là hai nhóm khác nhau; thức ăn có thể trông vẫn đẹp và thơm tho bình thường nhưng vẫn không còn an toàn. Hãy tuân theo ngày tháng trên nhãn, đừng tin vào chiếc mũi.

## Ai cần tuân thủ nghiêm ngặt hơn
Phụ nữ mang thai, trẻ sơ sinh, người cao tuổi và bất kỳ ai có hệ miễn dịch suy yếu: ăn đồ nấu sẵn sớm hơn, hâm nóng tới mức bốc khói nghi ngút, tránh ăn các món salad trộn sẵn (deli salads) sau ngày thứ 2, và tuân thủ hướng dẫn y tế tại địa phương.
FILE:templates/prep-week-plan.md
# Kế hoạch chuẩn bị bữa ăn (Meal prep): tuần lễ từ <2026-11-02>

**Ngày sơ chế:** <Chủ nhật 2026-11-01>   **Số người ăn:** <2 người lớn>   **Sức chứa ngăn mát:** <16 hộp>
**Trạng thái:** <OK / Đã dời 1 bữa ăn để tránh lỗi PAST_USE_BY>

## Kế hoạch bảo quản
| Món ăn | Danh mục | Số khẩu phần | Ngăn mát | Ngăn đá | Ăn trước (ngăn mát) | Tốt nhất trước (ngăn đá) |
|---|---|---|---|---|---|---|
| <Turkey chili> | <soup_stew> | <6> | <3> | <3> | <Thứ Năm 11-05> | <2027-02-01> |

## Danh mục công việc ngày sơ chế
- [ ] Làm nguội tất cả trong hộp nông lòng; đưa vào ngăn mát hoặc ngăn đá trong vòng 2 giờ
- [ ] Chia khẩu phần: mỗi hộp một khẩu phần
- [ ] Cấp đông ngăn đá: <chili x3, burrito bowls x2, cơm x2, cá hồi x2 (sống, ép dẹt)>
- [ ] Cất ngăn mát: <...>
- [ ] Dán nhãn mọi hộp đựng (tên món, ngày nấu, ăn trước / tốt nhất trước, nhiệt độ hâm nóng hoặc nấu chín)

## Lịch trình từng ngày
| Ngày | Bữa sáng | Bữa trưa | Bữa tối | Buổi tối: chuyển xuống ngăn mát |
|---|---|---|---|---|
| <Thứ Hai 11-02> | <trứng luộc chín x2> | <burrito bowl + cơm> | <turkey chili x2> | <-> |

## Nhãn dán
| Hộp đựng | Nội dung nhãn dán |
|---|---|
| <Turkey chili (ngăn mát)> | <TURKEY CHILI - nấu CN 11/01 - ăn trước T5 11/05 - hâm nóng tới 165 F> |

## Các thay đổi đã thực hiện
- <Dời bữa trưa món salad trứng từ thứ Sáu sang thứ Tư (ngày thứ 5 vượt quá giới hạn 4 ngày)>

## Phần dự phòng và đồ ăn thừa
- <1 khẩu phần chili trong ngăn đá, dùng tốt nhất trước 2027-02-01>

## Ghi chú an toàn cho mẻ nấu này
- <cơm: làm nguội nhanh, chỉ hâm nóng một lần, nóng bốc khói>
- <cá hồi: rã đông trong ngăn mát vào tối thứ Ba, nấu chín vào thứ Tư tới 145 F / 63 C>
FILE:examples/sunday-batch.csv
item,category,portions,cooked_on
Chicken burrito bowl,cooked_poultry,5,2026-11-01
Cilantro lime rice,cooked_grains,5,2026-11-01
Turkey chili,soup_stew,6,2026-11-01
Egg salad,deli_salad,3,2026-11-01
Hard-boiled eggs,hard_boiled_eggs,6,2026-11-01
Roasted vegetables,cooked_vegetables,4,2026-11-01
Miso salmon (raw marinated),raw_fish,2,2026-11-01
FILE:examples/week-plan.csv
date,meal,item,portions
2026-11-02,breakfast,Hard-boiled eggs,2
2026-11-02,lunch,Chicken burrito bowl,1
2026-11-02,lunch,Cilantro lime rice,1
2026-11-02,dinner,Turkey chili,2
2026-11-03,lunch,Egg salad,1
2026-11-03,dinner,Chicken burrito bowl,1
2026-11-03,dinner,Cilantro lime rice,1
2026-11-04,breakfast,Hard-boiled eggs,2
2026-11-04,lunch,Turkey chili,1
2026-11-04,dinner,Miso salmon (raw marinated),2
2026-11-04,dinner,Roasted vegetables,2
2026-11-05,lunch,Chicken burrito bowl,1
2026-11-05,lunch,Cilantro lime rice,1
2026-11-05,dinner,Roasted vegetables,2
2026-11-06,lunch,Egg salad,2
2026-11-06,dinner,Chicken burrito bowl,2
2026-11-06,dinner,Cilantro lime rice,2
2026-11-07,lunch,Turkey chili,2
FILE:scripts/prep_storage.py
#!/usr/bin/env python3
"""Meal prep storage and use-by planner (Python 3 standard library only).

Takes a batch-cooking session (what was cooked, how many portions, when) and the
week's eating plan, and decides for every portion whether it stays in the fridge or
goes into the freezer on prep day, when to move frozen portions to the fridge to thaw,
and what to write on each label. Flags portions planned after their fridge window that
cannot be frozen, plans that need more portions than were cooked, portions planned
before they are cooked, leftovers with no plan, and a fridge that holds more
containers than it has room for.

Fridge and freezer times are conservative values from the USDA/FoodSafety.gov cold
storage chart and the FoodKeeper app (see references/storage-times.md). Day 0 is the
cooking day; a 4-day item cooked on Sunday must be eaten by Thursday. Freezer times
are for quality: food kept frozen at 0 F / -18 C stays safe, it just gets worse.

Usage:
  python3 prep_storage.py BATCH.csv PLAN.csv [--fridge-capacity 12] [--json]
  python3 prep_storage.py --categories
  python3 prep_storage.py --self-test
BATCH.csv columns: item,category,portions,cooked_on[,fridge_days,freezer_months]
PLAN.csv  columns: date,meal,item,portions   (dates as YYYY-MM-DD)
Exit codes: 0 = plan is safe, 1 = at least one HIGH finding, 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
MEAL_ORDER = {"prep": 0, "breakfast": 1, "lunch": 2, "snack": 3, "dinner": 4, "evening": 5}
SERVE_COLD = {"deli_salad", "hard_boiled_eggs", "cut_raw_vegetables"}
# safe minimum internal temperatures for raw categories (cook on the planned day)
COOK_TEMP = {"raw_ground_meat": "160 F / 71 C", "raw_poultry": "165 F / 74 C",
             "raw_steak_chops": "145 F / 63 C plus 3 min rest", "raw_fish": "145 F / 63 C"}
# category: (fridge_days, freezer_months or None if it does not freeze well, note)
CATEGORIES = {
    "cooked_meat":       (4, 3, "cooked beef, pork, lamb; chart 3-4 days / 2-6 months"),
    "cooked_poultry":    (4, 3, "cooked chicken, turkey; chart 3-4 days / 2-6 months"),
    "soup_stew":         (4, 3, "soups, stews, chili, curries; chart 3-4 days / 2-3 months"),
    "casserole":         (4, 3, "casseroles, lasagna, bakes; 3-4 days / 2-3 months"),
    "cooked_grains":     (4, 2, "cooked rice, pasta, quinoa; cool fast; 3-5 days / 1-2 months"),
    "cooked_vegetables": (4, 2, "roasted or steamed vegetables, cooked beans; 3-4 days used"),
    "cooked_fish":       (3, 3, "cooked fish and seafood; 3-4 days, 3 used"),
    "pizza":             (4, 2, "pizza, savory pies; chart 3-4 days / 1-2 months"),
    "deli_salad":        (4, None, "egg, chicken, tuna, pasta salads; chart 3-5 days, does not freeze well"),
    "hard_boiled_eggs":  (7, None, "hard-cooked eggs; chart 1 week, do not freeze"),
    "cut_raw_vegetables": (4, None, "washed and cut raw vegetables; quality guide 3-5 days; blanch before freezing"),
    "raw_ground_meat":   (2, 3, "raw ground meat or marinated mince; chart 1-2 days / 3-4 months"),
    "raw_poultry":       (2, 9, "raw chicken or turkey pieces, marinated; chart 1-2 days / 9 months"),
    "raw_steak_chops":   (4, 4, "raw steaks, chops, roasts; chart 3-5 days / 4-12 months"),
    "raw_fish":          (2, 2, "raw fish; chart 1-2 days / 2-3 months fatty, 6-8 months lean"),
}


class InputError(Exception):
    pass


def parse_date(s, where):
    try:
        return dt.date.fromisoformat(s.strip())
    except ValueError:
        raise InputError(f"{where}: date '{s}' is not YYYY-MM-DD")


def parse_int(s, where, minimum=0):
    try:
        v = int(str(s).strip())
    except ValueError:
        raise InputError(f"{where}: '{s}' is not a whole number")
    if v < minimum:
        raise InputError(f"{where}: {v} must be at least {minimum}")
    return v


def read_rows(text, required, name):
    rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
    if not rows:
        raise InputError(f"{name} has no rows")
    missing = [c for c in required if c not in rows[0]]
    if missing:
        raise InputError(f"{name} is missing column(s): {', '.join(missing)}")
    return [{k.strip(): (v or "").strip() for k, v in r.items() if k} for r in rows if any((v or "").strip() for v in r.values())]


def load_batch(text):
    items = {}
    for i, r in enumerate(read_rows(text, ["item", "category", "portions", "cooked_on"], "BATCH"), 2):
        where = f"BATCH line {i}"
        cat = r["category"].lower()
        if cat not in CATEGORIES and not r.get("fridge_days"):
            raise InputError(f"{where}: unknown category '{r['category']}' (see --categories, or give fridge_days/freezer_months)")
        fd, fm, note = CATEGORIES.get(cat, (None, None, "custom"))
        if r.get("fridge_days"):
            fd = parse_int(r["fridge_days"], where, 1)
        if r.get("freezer_months") is not None and r.get("freezer_months") != "":
            fm = parse_int(r["freezer_months"], where, 0) or None
        name = r["item"]
        if name.lower() in items:
            raise InputError(f"{where}: item '{name}' listed twice; give each batch its own name")
        items[name.lower()] = {"item": name, "category": cat, "portions": parse_int(r["portions"], where, 1),
                               "cooked_on": parse_date(r["cooked_on"], where), "fridge_days": fd,
                               "freezer_months": fm, "note": note}
    return items


def load_plan(text, items):
    plan = []
    for i, r in enumerate(read_rows(text, ["date", "meal", "item", "portions"], "PLAN"), 2):
        where = f"PLAN line {i}"
        key = r["item"].lower()
        if key not in items:
            raise InputError(f"{where}: item '{r['item']}' is not in BATCH")
        plan.append({"date": parse_date(r["date"], where), "meal": r["meal"], "item": key,
                     "portions": parse_int(r["portions"], where, 1)})
    plan.sort(key=lambda p: p["date"])
    return plan


def add_months(d, months):
    y, m = divmod(d.month - 1 + months, 12)
    y, m = d.year + y, m + 1
    last = (dt.date(y + (m == 12), m % 12 + 1, 1) - dt.timedelta(days=1)).day
    return dt.date(y, m, min(d.day, last))


def plan_storage(items, plan, fridge_capacity=None):
    findings, timeline, labels = [], {}, []

    def add(sev, code, item, detail):
        findings.append({"severity": sev, "code": code, "item": item, "detail": detail})

    def ev(day, slot, text):
        timeline.setdefault(day, []).append((MEAL_ORDER.get(slot.lower(), 3), text))

    summary = {}
    for key, it in items.items():
        cooked, fd, fm = it["cooked_on"], it["fridge_days"], it["freezer_months"]
        eat_by = cooked + dt.timedelta(days=fd)
        best_by = add_months(cooked, fm) if fm else None
        left = it["portions"]
        fridge_n = freeze_n = 0
        uses = [p for p in plan if p["item"] == key]
        raw = it["category"] in COOK_TEMP
        how = (f"cook to {COOK_TEMP[it['category']]}" if raw else
               "serve cold" if it["category"] in SERVE_COLD else "reheat to 165 F / 74 C")
        for p in uses:
            n = min(p["portions"], left)
            short = p["portions"] - n
            if short:
                add("MEDIUM", "SHORTFALL", it["item"], f"{p['date']} {p['meal']} needs {p['portions']} portion(s), only {n} left; cook more or swap the meal")
            if n == 0:
                continue
            left -= n
            if p["date"] < cooked:
                add("HIGH", "BEFORE_COOKED", it["item"], f"{p['date']} {p['meal']} is before the cooking day {cooked}")
                continue
            if p["date"] <= eat_by:
                fridge_n += n
                ev(p["date"], p["meal"], f"{p['meal']}: {it['item']} x{n} (fridge; {how})")
            elif fm:
                freeze_n += n
                thaw = p["date"] - dt.timedelta(days=1)
                ev(p["date"], p["meal"], f"{p['meal']}: {it['item']} x{n} (thawed; {how})")
                ev(thaw, "evening", f"evening: move {it['item']} x{n} from freezer to fridge for {p['date']} {p['meal']}")
                if best_by and p["date"] > best_by:
                    add("LOW", "PAST_FREEZER_QUALITY", it["item"], f"{p['date']} is after the best-quality date {best_by} (safe, but drier and blander)")
            else:
                fridge_n += n
                add("HIGH", "PAST_USE_BY", it["item"],
                    f"{p['date']} {p['meal']} is {(p['date'] - cooked).days} days after cooking; fridge limit is {fd} days (eat by {eat_by}) and it does not freeze well. Move the meal earlier or cook it fresh.")
        if left:
            if fm:
                freeze_n += left
                add("INFO", "SURPLUS_FROZEN", it["item"], f"{left} unplanned portion(s) go in the freezer on {cooked}; best by {best_by}")
            else:
                add("MEDIUM", "SURPLUS_NO_FREEZE", it["item"], f"{left} unplanned portion(s) do not freeze well: eat by {eat_by} or cook less next time")
                fridge_n += left
        if freeze_n:
            ev(cooked, "prep", f"freeze {it['item']} x{freeze_n} " + ("raw, flat in freezer bags" if raw else "once cooled (fridge or freezer within 2 hours of cooking)"))
        if fridge_n:
            ev(cooked, "prep", f"fridge {it['item']} x{fridge_n} ({'cook' if raw else 'eat'} by {eat_by})")
        labels.append({"item": it["item"], "cooked_on": str(cooked),
                       "fridge": f"x{fridge_n}, eat by {eat_by}" if fridge_n else "-",
                       "freezer": f"x{freeze_n}, best by {best_by}" if freeze_n else "-"})
        summary[it["item"]] = {"category": it["category"], "portions": it["portions"], "fridge": fridge_n,
                               "freezer": freeze_n, "eat_by": str(eat_by), "best_by": str(best_by) if best_by else None,
                               "fridge_days": fd, "freezer_months": fm}

    if fridge_capacity:
        days = sorted(set([it["cooked_on"] for it in items.values()] + [p["date"] for p in plan]))
        if days:
            d = days[0]
            worst = (0, None)
            while d <= days[-1]:
                count = _fridge_count(items, plan, summary, d)
                if count > worst[0]:
                    worst = (count, d)
                d += dt.timedelta(days=1)
            if worst[0] > fridge_capacity:
                add("MEDIUM", "FRIDGE_FULL", "-", f"{worst[0]} containers in the fridge on {worst[1]}, capacity {fridge_capacity}: freeze more on prep day or use bigger containers")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["item"], f["code"]))
    return {"summary": summary, "timeline": {str(k): [t for _, t in sorted(v, key=lambda x: x[0])] for k, v in sorted(timeline.items())},
            "labels": labels, "findings": findings}


def _fridge_count(items, plan, summary, day):
    """Containers in the fridge at the end of `day` (one portion = one container)."""
    total = 0
    for key, it in items.items():
        if day < it["cooked_on"]:
            continue
        eat_by = it["cooked_on"] + dt.timedelta(days=it["fridge_days"])
        fridge_left = summary[it["item"]]["fridge"]
        for p in plan:
            if p["item"] == key and p["date"] <= day and it["cooked_on"] <= p["date"] <= eat_by:
                fridge_left -= p["portions"]
        if day <= eat_by:
            total += max(fridge_left, 0)
        for p in plan:  # thawing portions moved in the evening before
            if p["item"] == key and p["date"] > eat_by and p["date"] - dt.timedelta(days=1) == day and it["freezer_months"]:
                total += p["portions"]
    return total


def render_md(res):
    out = ["# Meal prep storage plan", "", "| Item | Category | Portions | Fridge | Freezer | Eat by (fridge) | Best by (freezer) |",
           "|---|---|---|---|---|---|---|"]
    for name, s in res["summary"].items():
        out.append(f"| {name} | {s['category']} | {s['portions']} | {s['fridge']} | {s['freezer']} | {s['eat_by']} | {s['best_by'] or 'do not freeze'} |")
    out += ["", "## Day by day", ""]
    for day, evs in res["timeline"].items():
        d = dt.date.fromisoformat(day)
        out.append(f"- **{d.strftime('%a')} {day}**: " + "; ".join(evs))
    out += ["", "## Labels", "", "| Item | Cooked | Fridge | Freezer |", "|---|---|---|---|"]
    out += [f"| {l['item']} | {l['cooked_on']} | {l['fridge']} | {l['freezer']} |" for l in res["labels"]]
    out += ["", "## Findings", ""]
    if res["findings"]:
        out += ["| Severity | Code | Item | Detail |", "|---|---|---|---|"]
        out += [f"| {f['severity']} | {f['code']} | {f['item']} | {f['detail']} |" for f in res["findings"]]
    else:
        out.append("No problems: every planned portion is eaten inside its fridge window or thawed from the freezer.")
    out += ["", "Cool food within 2 hours in shallow containers, keep the fridge at 40 F / 4 C or colder and the freezer at 0 F / -18 C. When in doubt, throw it out."]
    return "\n".join(out)


# ---------------------------------------------------------------- self-test
def self_test():
    B = "item,category,portions,cooked_on\nChili,soup_stew,5,2026-11-01\nEgg salad,deli_salad,3,2026-11-01\nRice,cooked_grains,4,2026-11-01\n"
    P = ("date,meal,item,portions\n2026-11-02,lunch,Chili,1\n2026-11-03,lunch,Chili,1\n2026-11-06,dinner,Chili,2\n"
         "2026-11-02,lunch,Egg salad,1\n2026-11-04,lunch,Egg salad,1\n2026-11-03,dinner,Rice,2\n")
    def run(b=B, p=P, cap=None):
        items = load_batch(b)
        return plan_storage(items, load_plan(p, items), cap)
    codes = lambda r: [f["code"] for f in r["findings"]]
    cases = [
        ("categories table sane", lambda: all(v[0] >= 1 for v in CATEGORIES.values())),
        ("add_months end of month", lambda: add_months(dt.date(2026, 1, 31), 1) == dt.date(2026, 2, 28)),
        ("add_months over year", lambda: add_months(dt.date(2026, 11, 15), 3) == dt.date(2027, 2, 15)),
        ("fridge window day 0 = cooking day", lambda: run()["summary"]["Chili"]["eat_by"] == "2026-11-05"),
        ("chili day 5 goes to freezer", lambda: run()["summary"]["Chili"]["freezer"] == 3),
        ("chili days 1-2 stay in fridge", lambda: run()["summary"]["Chili"]["fridge"] == 2),
        ("thaw move the evening before", lambda: any("move Chili x2" in e for e in run()["timeline"]["2026-11-05"])),
        ("surplus chili frozen INFO", lambda: "SURPLUS_FROZEN" in codes(run())),
        ("egg salad inside window ok", lambda: "PAST_USE_BY" not in codes(run())),
        ("egg salad day 5 is PAST_USE_BY", lambda: "PAST_USE_BY" in codes(run(p=P.replace("2026-11-04,lunch,Egg", "2026-11-06,lunch,Egg")))),
        ("egg salad surplus no freeze", lambda: "SURPLUS_NO_FREEZE" in codes(run())),
        ("shortfall", lambda: "SHORTFALL" in codes(run(p=P + "2026-11-04,dinner,Rice,3\n"))),
        ("before cooked", lambda: "BEFORE_COOKED" in codes(run(p=P + "2026-10-31,dinner,Rice,1\n"))),
        ("past freezer quality", lambda: "PAST_FREEZER_QUALITY" in codes(run(p=P + "2027-03-01,dinner,Rice,1\n"))),
        ("override fridge_days column", lambda: run(b="item,category,portions,cooked_on,fridge_days,freezer_months\nSoup,custom,2,2026-11-01,2,0\n",
                                                   p="date,meal,item,portions\n2026-11-04,lunch,Soup,1\n")["findings"][0]["code"] == "PAST_USE_BY"),
        ("unknown category error", lambda: _raises(lambda: run(b="item,category,portions,cooked_on\nX,mystery,1,2026-11-01\n", p="date,meal,item,portions\n"))),
        ("bad date error", lambda: _raises(lambda: run(p="date,meal,item,portions\n11/02/2026,lunch,Chili,1\n"))),
        ("unknown plan item error", lambda: _raises(lambda: run(p="date,meal,item,portions\n2026-11-02,lunch,Tacos,1\n"))),
        ("zero portions error", lambda: _raises(lambda: run(b="item,category,portions,cooked_on\nChili,soup_stew,0,2026-11-01\n"))),
        ("duplicate batch item error", lambda: _raises(lambda: run(b=B + "chili,soup_stew,2,2026-11-01\n"))),
        ("missing column error", lambda: _raises(lambda: run(b="item,portions\nChili,2\n"))),
        ("case-insensitive item names", lambda: run(p=P.replace("lunch,Chili", "lunch,chili"))["summary"]["Chili"]["fridge"] == 2),
        ("fridge capacity exceeded", lambda: "FRIDGE_FULL" in codes(run(cap=4))),
        ("fridge capacity ok", lambda: "FRIDGE_FULL" not in codes(run(cap=20))),
        ("raw poultry 2 days", lambda: "PAST_USE_BY" not in codes(run(b="item,category,portions,cooked_on\nThighs,raw_poultry,2,2026-11-01\n",
                                                                   p="date,meal,item,portions\n2026-11-05,dinner,Thighs,2\n"))
                                     and run(b="item,category,portions,cooked_on\nThighs,raw_poultry,2,2026-11-01\n",
                                             p="date,meal,item,portions\n2026-11-05,dinner,Thighs,2\n")["summary"]["Thighs"]["freezer"] == 2),
        ("raw fish says cook, not reheat", lambda: any("cook to 145 F" in e for e in run(b="item,category,portions,cooked_on\nSalmon,raw_fish,1,2026-11-01\n",
                                                   p="date,meal,item,portions\n2026-11-04,dinner,Salmon,1\n")["timeline"]["2026-11-04"])),
        ("egg salad served cold", lambda: any("Egg salad x1 (fridge; serve cold)" in e for e in run()["timeline"]["2026-11-02"])),
        ("timeline sorted by meal", lambda: run(p="date,meal,item,portions\n2026-11-02,dinner,Rice,1\n2026-11-02,lunch,Chili,1\n")["timeline"]["2026-11-02"][0].startswith("lunch")),
        ("render mentions 2 hours", lambda: "2 hours" in render_md(run())),
    ]
    passed = 0
    for name, fn in cases:
        try:
            ok = bool(fn())
        except Exception as e:  # noqa: BLE001
            ok = False; name += f" (raised {e!r})"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def _raises(fn):
    try:
        fn()
    except InputError:
        return True
    return False


def main():
    ap = argparse.ArgumentParser(description="Meal prep storage and use-by planner (stdlib only)")
    ap.add_argument("batch", nargs="?"); ap.add_argument("plan", nargs="?")
    ap.add_argument("--fridge-capacity", type=int, help="max containers your fridge shelf can hold")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--categories", action="store_true", help="list categories and storage times")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if a.categories:
        print("| Category | Fridge days | Freezer months | Covers |\n|---|---|---|---|")
        for k, (fd, fm, note) in CATEGORIES.items():
            print(f"| {k} | {fd} | {fm if fm else 'do not freeze'} | {note} |")
        return 0
    if not (a.batch and a.plan):
        ap.error("need BATCH.csv PLAN.csv (or --self-test / --categories)")
    try:
        with open(a.batch, encoding="utf-8") as f1, open(a.plan, encoding="utf-8") as f2:
            items = load_batch(f1.read())
            plan = load_plan(f2.read(), items)
        res = plan_storage(items, plan, a.fridge_capacity)
    except (OSError, UnicodeDecodeError, InputError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else render_md(res))
    return 1 if any(f["severity"] == "HIGH" for f in res["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningSafety+2
F@f
0
Trình Kiểm tra Liên kết Markdown
Kỹ năng

Kiểm tra một thư mục tài liệu Markdown để tìm các liên kết gây lỗi cho người đọc - các liên kết tương đối trỏ đến những tệp bị thiếu hoặc đã đổi tên, các điểm neo (anchor) không khớp với tiêu đề nào, các điểm neo tiêu đề bị trùng lặp, cách viết hoa thường tên tệp chỉ hoạt động trên macOS, đường dẫn tuyệt đối cục bộ, các liên kết kiểu tham chiếu bị thiếu, không sử dụng hoặc trùng lặp định nghĩa, hình ảnh thiếu văn bản thay thế (alt text) và các URL trần. Hoạt động ngoại tuyến và liệt kê các URL bên ngoài để kiểm tra riêng. Bao gồm một trình kiểm tra bằng thư viện chuẩn Python với tùy chọn --self-test.

---
name: markdown-link-checker
description: Kiểm tra một thư mục tài liệu Markdown để tìm các liên kết gây lỗi cho người đọc - các liên kết tương đối trỏ đến những tệp bị thiếu hoặc đã đổi tên, các điểm neo (anchor) không khớp với tiêu đề nào, các điểm neo tiêu đề bị trùng lặp, cách viết hoa thường tên tệp chỉ hoạt động trên macOS, đường dẫn tuyệt đối cục bộ, các liên kết kiểu tham chiếu bị thiếu, không sử dụng hoặc trùng lặp định nghĩa, hình ảnh thiếu văn bản thay thế (alt text) và các URL trần. Hoạt động ngoại tuyến và liệt kê các URL bên ngoài để kiểm tra riêng. Bao gồm một trình kiểm tra bằng thư viện chuẩn Python với tùy chọn --self-test.
---

# Trình Kiểm tra Liên kết Markdown

Bạn hỗ trợ những người bảo trì dự án, chuyên viên viết tài liệu kỹ thuật và lập trình viên giữ cho tài liệu luôn dễ dàng điều hướng. Bạn tìm ra mọi liên kết bên trong một thư mục tài liệu nhưng lại không dẫn đến đâu cả (một tệp đã bị di chuyển, một tiêu đề đã đổi tên, lỗi chính tả trong điểm neo, một nhãn tham chiếu chưa từng được định nghĩa), giải thích nguyên nhân bị lỗi và trả về các bản sửa lỗi chính xác. Bạn ưu tiên làm việc ngoại tuyến trước: các liên kết nội bộ và điểm neo được kiểm tra trực tiếp từ chính các tệp tin, còn các URL bên ngoài chỉ được liệt kê ra để việc kiểm tra tài liệu không bao giờ phụ thuộc vào mạng hay gửi yêu cầu dồn dập làm quá tải máy chủ của người khác.

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

- `scripts/check_md_links.py` - duyệt qua một thư mục chứa các tệp .md/.markdown/.mdx, phân giải các liên kết tương đối và liên kết tương đối tính từ thư mục gốc, tạo các điểm neo tiêu đề theo kiểu GitHub và báo cáo các liên kết bị hỏng, điểm neo bị thiếu, điểm neo trùng lặp, các vấn đề về định nghĩa tham chiếu, thiếu văn bản thay thế (alt text) và các URL trần, kèm theo danh sách URL bên ngoài khi được yêu cầu (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/anchors-and-slugs.md` - cách GitHub, GitLab, MkDocs, Docusaurus và Hugo chuyển đổi các tiêu đề thành điểm neo, xử lý trùng lặp, điểm neo tường minh và các trường hợp biên làm hỏng liên kết
- `references/finding-codes.md` - mọi mã phát hiện lỗi, mức độ nghiêm trọng, lý do tại sao vấn đề đó quan trọng và cách khắc phục thông thường, cùng với cách kiểm tra các URL bên ngoài một cách lịch sự
- `templates/link-report.md` - mẫu báo cáo để bàn giao kết quả
- `examples/README.md` - một tệp README dự án nhỏ có cài sẵn các lỗi liên kết có chủ đích
- `examples/setup-guide.md` - tài liệu hướng dẫn mà tệp trên liên kết tới, chứa thêm nhiều vấn đề khác (tên tệp sai chữ hoa/thường, sai chữ hoa/thường ở điểm neo, thiếu tham chiếu, đường dẫn cục bộ)

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

### 1. Xác định phạm vi kiểm tra
Hỏi xem thư mục nào là thư mục gốc của tài liệu, tài liệu được xuất bản bằng cách nào (hiển thị trên GitHub/GitLab, MkDocs, Docusaurus, Hugo, hay duyệt kho lưu trữ thuần túy), và liệu các liên kết như `/guide/a.md` có nghĩa là "tính từ thư mục gốc của kho lưu trữ (repo root)" hay "tính từ thư mục gốc của trang web (site root)". Quy tắc tạo điểm neo sẽ khác nhau tùy theo từng công cụ tạo trang (xem `references/anchors-and-slugs.md`); mặc định sử dụng quy tắc của GitHub và nêu rõ điều đó.

### 2. Chạy trình kiểm tra
```
python3 scripts/check_md_links.py examples --external
python3 scripts/check_md_links.py docs README.md --root . --min-severity MEDIUM
python3 scripts/check_md_links.py docs --ignore BARE_URL,UNUSED_REF_DEF --json > links.json
python3 scripts/check_md_links.py --self-test
```
Mã thoát (Exit) 0 có nghĩa là không có phát hiện mức CAO (HIGH) nào, 1 nghĩa là có ít nhất một liên kết bị hỏng, thiếu điểm neo hoặc thiếu định nghĩa tham chiếu (có thể dùng làm cổng kiểm soát CI), 2 nghĩa là đầu vào không hợp lệ. Các khối mã (code blocks) và mã nội dòng (inline code) sẽ được bỏ qua; các điểm neo dòng của GitHub (`#L10-L20`) và liên kết `mailto:` được chấp nhận.

### 3. Phân loại và sửa lỗi
Xử lý các lỗi mức HIGH trước. Đối với BROKEN_LINK, hãy tìm kiếm tên tệp trong kho lưu trữ (`git log --diff-filter=R --name-status` sẽ hiển thị lịch sử đổi tên) và cập nhật liên kết trỏ tới vị trí hiện tại của tệp. Đối với MISSING_ANCHOR, hãy đọc các tiêu đề đích: kịch bản sẽ gợi ý điểm neo hiện có gần giống nhất; hãy quyết định xem nên sửa liên kết hay sửa tiêu đề (việc thay đổi tiêu đề sẽ làm hỏng các liên kết trỏ đến từ nơi khác, vì vậy hãy ưu tiên thêm một điểm neo tường minh nếu tiêu đề cũ đang được liên kết từ bên ngoài). Đối với DUPLICATE_ANCHOR, hãy làm cho các tiêu đề trở nên duy nhất. Đối với CASE_MISMATCH, hãy sử dụng chính xác tên tệp phân biệt hoa thường. Sau đó xử lý tiếp các mục mức MEDIUM và LOW: văn bản thay thế (alt text), liên kết rỗng, định nghĩa không sử dụng, URL trần.

### 4. Các URL bên ngoài
Với tùy chọn `--external`, kịch bản sẽ liệt kê mọi URL bên ngoài cùng vị trí sử dụng chúng mà không gửi bất kỳ yêu cầu mạng nào. Nếu người dùng muốn kiểm tra các URL này, hãy thực hiện riêng một cách lịch sự (gửi yêu cầu HEAD rồi mới đến GET, chỉ gửi một vài yêu cầu trên mỗi máy chủ, đặt thời gian chờ timeout, xem các mã 429/403 là "cần kiểm tra thủ công" chứ không báo là liên kết hỏng). Xem thêm tại `references/finding-codes.md`.

### 5. Bàn giao kết quả
Điền vào mẫu `templates/link-report.md`: phạm vi và bộ quy tắc đã sử dụng, số lượng thống kê, bảng sửa lỗi gồm tệp:dòng, đích cũ, đích mới, cùng danh sách URL bên ngoài. Nếu được yêu cầu cung cấp các tệp đã chỉnh sửa, hãy trả về các tệp đó và chỉ thay đổi phần liên kết, tuyệt đối không viết lại bất kỳ nội dung nào khác.

## Ví dụ kết quả
`examples/` (2 tệp): 13 phát hiện, 6 mức HIGH. Tệp README.md liên kết tới `setup-guide.md#configuration`, nhưng tiêu đề thực tế lại là "Configure the team folder" (#configure-the-team-folder, được kịch bản gợi ý); các tệp CHANGELOG.md, ../CONTRIBUTING.md, docs/style.md và hình ảnh sơ đồ không tồn tại; tiêu đề "Usage" xuất hiện hai lần nên lần thứ hai có điểm neo là #usage-1; [roadmap] được định nghĩa nhưng không được sử dụng. Tệp setup-guide.md liên kết tới `readme.md` (chỉ có README.md tồn tại: hoạt động được trên máy Mac nhưng bị lỗi trên GitHub), sử dụng `#Usage` (các điểm neo phải viết thường), trỏ tới đường dẫn /Users/ana/... và sử dụng [support] mà không có định nghĩa. Có 4 URL bên ngoài được liệt kê, không có URL nào bị truy vấn.

## Quy tắc

- Không bao giờ tự ý truy vấn các URL bên ngoài trừ khi người dùng yêu cầu; khi thực hiện, phải giới hạn tốc độ (rate-limit) và không báo cáo mã lỗi 403/429 là liên kết hỏng.
- Chỉ sửa liên kết, không sửa nội dung: không viết lại câu chữ văn xuôi trong lúc sửa đường dẫn.
- Ưu tiên dùng liên kết tương đối giữa các tài liệu; chỉ dùng liên kết tương đối tính từ gốc (root-relative) nếu thiết lập xuất bản có hỗ trợ.
- Khi bắt buộc phải thay đổi một tiêu đề, hãy giữ cho điểm neo cũ tiếp tục hoạt động bằng một thẻ tường minh `<a id="old-anchor"></a>` nếu có các trang khác đang liên kết tới nó.
- Nêu rõ bộ quy tắc điểm neo nào bạn đã sử dụng (mặc định là GitHub) và chạy lại trình kiểm tra sau khi sửa lỗi; kết quả trả về bắt buộc phải có mã thoát là 0.
- Viết văn bản thay thế (alt text) thực chất cho hình ảnh (mô tả hình ảnh hiển thị cái gì và tại sao nó lại ở đó); để đánh giá toàn diện về khả năng tiếp cận (accessibility), hãy sử dụng một quy trình kiểm tra khả năng tiếp cận chuyên biệt.
FILE:references/anchors-and-slugs.md
# Điểm neo tiêu đề và chuỗi định danh (slug)

Một liên kết như `guide.md#install` chỉ hoạt động nếu bộ kết xuất (renderer) tạo ra chính xác chuỗi `install` làm thuộc tính id của một tiêu đề trong tệp guide.md. Mỗi bộ kết xuất lại có quy tắc tạo slug riêng, vì vậy trước tiên hãy tìm hiểu xem tài liệu được đọc ở đâu.

## GitHub (mặc định được kịch bản sử dụng)
1. Lấy văn bản tiêu đề sau khi đã kết xuất: loại bỏ các định dạng nội dòng (`code` giữ nguyên phần chữ, `**bold**` trở thành bold, `[text](url)` trở thành text, các thẻ HTML bị lược bỏ).
2. Chuyển toàn bộ thành chữ thường.
3. Loại bỏ mọi ký tự không phải là chữ cái, chữ số, khoảng trắng, dấu gạch ngang hoặc dấu gạch dưới (chữ cái bao gồm cả chữ có dấu và chữ cái không thuộc hệ Latinh; biểu tượng cảm xúc emoji và dấu câu sẽ bị xóa bỏ).
4. Biến mỗi khoảng trắng thành một dấu gạch ngang. Các khoảng trắng KHÔNG bị gộp lại: "C++ & Rust" sẽ trở thành `c--rust`.
5. Các tiêu đề trùng lặp sẽ được gắn thêm `-1`, `-2`, ... theo thứ tự xuất hiện: tiêu đề "Usage" thứ hai sẽ là `#usage-1`.

| Tiêu đề | Điểm neo |
|---|---|
| `## What's new? (v2.0)` | `#whats-new-v20` |
| ``## The `--dry-run` flag`` | `#the---dry-run-flag` |
| `## Über uns` | `#über-uns` (GitHub cũng chấp nhận dạng mã hóa phần trăm) |
| `## C++ & Rust: 100% safe?` | `#c--rust-100-safe` |
| `## snake_case_name` | `#snake_case_name` |
| `## Usage` (lần thứ hai) | `#usage-1` |

Hàm tạo slug của kịch bản khớp với thư viện github-slugger trong các trường hợp này.

## Các bộ kết xuất khác
- **GitLab**: tương tự GitHub, nhưng các dấu gạch ngang liên tiếp được gộp lại thành một (`#c-rust-100-safe`), do đó các liên kết sao chép qua lại giữa GitHub và GitLab có thể bị gãy.
- **MkDocs (Python-Markdown toc)**: mặc định lược bỏ các ký tự non-ASCII, gộp các dấu phân cách; cú pháp `{#custom-id}` khả dụng khi bật tiện ích mở rộng attr_list. Hãy kiểm tra cấu hình `markdown_extensions: toc: slugify` trong tệp mkdocs.yml.
- **Docusaurus**: tạo slug tương tự GitHub; hỗ trợ cú pháp `## Heading {#custom-id}`.
- **Hugo (goldmark)**: mặc định tương tự GitHub; cấu hình `autoHeadingIDType` có thể đặt là `github`, `github-ascii` hoặc `blackfriday`.
- **Trình xem trước của VS Code / hầu hết các IDE**: tương tự GitHub.

Nếu trang web đã xuất bản và giao diện xem kho lưu trữ không đồng nhất với nhau, hãy ưu tiên dùng các điểm neo tường minh cho bất kỳ mục nào được liên kết từ bên ngoài.

## Điểm neo tường minh
- Đặt `<a id="install"></a>` hoặc `<a name="install"></a>` ngay trước tiêu đề (hoạt động ở mọi nơi cho phép nhúng HTML).
- Dùng `## Install {#install}` ở những nơi bộ kết xuất hỗ trợ danh sách thuộc tính (MkDocs attr_list, Docusaurus, Hugo, Pandoc). Trên GitHub, phần `{#...}` sẽ hiển thị nguyên văn như văn bản thường.

## Những nguyên nhân làm hỏng điểm neo
- Đổi tên một tiêu đề ("Configuration" -> "Configure the team folder").
- Trùng lặp tiêu đề: các liên kết tới `#usage` sẽ luôn nhảy đến tiêu đề đầu tiên.
- Viết hoa chữ cái trong liên kết (`#Usage`): các điểm neo trên GitHub đều viết thường; một số trình duyệt có thể bỏ qua lỗi này, nhưng nhiều bộ kết xuất thì không.
- Sao chép điểm neo từ một bộ kết xuất khác (sự khác biệt về dấu gạch ngang giữa GitLab và GitHub).
- Các tiêu đề được tạo ra bởi lệnh nhúng (includes) hoặc bản mẫu (templates) mà trình kiểm tra không thể nhìn thấy; hãy đánh dấu bỏ qua những trường hợp này bằng tùy chọn `--ignore MISSING_ANCHOR` chỉ riêng cho thư mục đó và nêu rõ trong báo cáo.
- Các điểm neo dòng (`#L10`, `#L10-L20`) là tính năng xem tệp của GitHub; chúng được chấp nhận nhưng sẽ bị hỏng nếu tệp bị di chuyển hoặc các dòng bị dịch chuyển.
FILE:references/finding-codes.md
# Mã phát hiện lỗi

| Mã | Mức độ | Lý do quan trọng | Cách khắc phục thông thường |
|---|---|---|---|
| BROKEN_LINK | HIGH | liên kết tương đối trỏ tới một tệp hoặc thư mục không tồn tại (đã di chuyển, đổi tên, gõ sai) | liên kết tới đường dẫn hiện tại; lệnh `git log --diff-filter=R --name-status -- docs/` sẽ hiển thị lịch sử đổi tên |
| BROKEN_IMAGE | HIGH | đường dẫn hình ảnh không tồn tại; người đọc chỉ thấy biểu tượng ảnh lỗi | sửa lại đường dẫn hoặc commit tệp hình ảnh lên kho lưu trữ |
| MISSING_ANCHOR | HIGH | #fragment không khớp với bất kỳ tiêu đề hay điểm neo tường minh nào trong tệp đích | sử dụng điểm neo được gợi ý, hoặc thêm một điểm neo tường minh |
| MISSING_REF_DEF | HIGH | `[text][label]` không có định nghĩa `[label]: url`; nó sẽ hiển thị thành văn bản thuần nằm trong ngoặc vuông | thêm định nghĩa hoặc sửa lại nhãn (nhãn không phân biệt chữ hoa chữ thường) |
| DUPLICATE_ANCHOR | MEDIUM | hai tiêu đề tạo ra cùng một chuỗi định danh (slug); tiêu đề thứ hai sẽ bị gắn thêm `-1` và các liên kết sẽ luôn trỏ về tiêu đề đầu tiên | đặt tên các tiêu đề sao cho duy nhất, ví dụ: "Usage (CLI)" và "Usage (API)" |
| DUPLICATE_REF_DEF | MEDIUM | cùng một nhãn được định nghĩa hai lần; chỉ có định nghĩa đầu tiên có hiệu lực | xóa hoặc đổi tên một trong hai định nghĩa |
| CASE_MISMATCH | MEDIUM | tệp tồn tại nhưng khác cách viết hoa/thường; chạy tốt trên macOS/Windows nhưng bị lỗi trên Linux, GitHub Pages và hầu hết hệ thống CI | sử dụng chính xác tên tệp gốc |
| ANCHOR_CASE | MEDIUM | điểm neo chỉ khớp khi được chuyển thành chữ thường | viết các điểm neo bằng chữ thường |
| LOCAL_ABSOLUTE_PATH | MEDIUM | liên kết trỏ tới /Users/..., /home/..., C:\ hoặc file:// chỉ hoạt động trên máy tính của tác giả | đổi sang đường dẫn tương đối, hoặc xóa bỏ |
| EMPTY_LINK | MEDIUM | `[text]()` hoặc thuộc tính href rỗng | điền đường dẫn vào hoặc xóa bỏ liên kết |
| EMPTY_LINK_TEXT | MEDIUM | `[](url)`: không có chữ gì để nhấp chuột hoặc để trình đọc màn hình đọc lên | thêm văn bản hiển thị cho liên kết |
| MISSING_ALT | MEDIUM | hình ảnh không có văn bản thay thế (alt text); trình đọc màn hình sẽ chỉ đọc là "image" hoặc đọc tên tệp | mô tả nội dung hình ảnh hiển thị và mục đích của nó |
| UNUSED_REF_DEF | LOW | định nghĩa tham chiếu không bao giờ được sử dụng; thường là phần dư thừa còn sót lại sau khi chỉnh sửa | xóa bỏ, hoặc gắn liên kết vào nếu liên kết đó vô tình bị mất |
| BARE_URL | LOW | URL thô nằm trực tiếp trong văn bản; không thể nhấp chuột ở một số bộ kết xuất và bị trình đọc màn hình đọc từng ký tự một | chuyển thành `[văn bản mô tả](url)` hoặc `<url>` |
| OUTSIDE_ROOT | LOW | liên kết tương đối vượt ra ngoài thư mục gốc của tài liệu; sẽ bị lỗi nếu thư mục tài liệu được xuất bản độc lập | liên kết trực tiếp tới URL đã xuất bản |

Mã thoát (Exit codes): 0 = không có lỗi HIGH, 1 = có lỗi HIGH, 2 = đầu vào không hợp lệ. Sử dụng `--min-severity` và `--ignore` để tinh chỉnh cổng kiểm soát CI, đồng thời ghi nhận lại mọi cấu hình bỏ qua (ignore) trong kho lưu trữ kèm theo lý do rõ ràng.

## Các dạng liên kết mà kịch bản hiểu được
- Nội dòng (Inline): `[text](path.md#anchor "title")`, `[text](<path with spaces.md>)`, `[text](my%20notes.md)`
- Hình ảnh: `![alt](img.png)`, `<img src="img.png" alt="...">`
- Tham chiếu (Reference): `[text][label]`, `[label][]`, `[label]` kèm định nghĩa `[label]: url "title"` ở bất kỳ đâu trong cùng một tệp
- Liên kết tự động `<https://...>`, thẻ HTML `<a href="...">`, điểm neo tường minh `<a id>`, `<a name>`, `{#id}`
- Đường dẫn tương đối tính từ gốc `/guide/a.md` được phân giải từ tham số `--root` (mặc định: đối số thư mục đầu tiên)
- Được bỏ qua: các khối mã có hàng rào (fenced) và thụt lề (indented), mã nội dòng, liên kết `mailto:`/`tel:`, điểm neo dòng của GitHub, các ô danh sách công việc `[ ]` `[x]`

## Kiểm tra các URL bên ngoài (chỉ thực hiện khi được yêu cầu)
- Khử trùng lặp trước tiên: tùy chọn `--external` sẽ liệt kê mỗi URL một lần kèm theo mọi vị trí mà URL đó được sử dụng.
- Sử dụng phương thức HEAD, chuyển sang phương thức GET khi máy chủ từ chối HEAD (mã 405) hoặc trả về kết quả không trung thực.
- Chỉ gửi một hoặc hai yêu cầu đồng thời trên mỗi máy chủ đích, đặt thời gian chờ (timeout) 10 giây và khai báo User-Agent rõ ràng.
- Mã 200-299 là OK; mã 301/308 thì cập nhật liên kết sang địa chỉ mới; mã 404/410 là liên kết hỏng; mã 403, 429 và hết thời gian chờ (timeout) được xếp vào nhóm "cần kiểm tra thủ công" (do cơ chế chống bot hoặc giới hạn tần suất truy cập).
- Các điểm neo trên những trang web bên ngoài không thể kiểm tra một cách đáng tin cậy khi ngoại tuyến; hãy ghi chú lại chứ không tự suy đoán.
- Ưu tiên liên kết tới các trang ổn định, có gắn phiên bản cụ thể (tài liệu theo thẻ phiên bản, liên kết cố định permalink) thay vì trỏ tới bản "latest" (mới nhất).
FILE:templates/link-report.md
# Kiểm tra liên kết tài liệu: <kho lưu trữ hoặc thư mục>, <ngày>

**Phạm vi:** <docs/ và README.md, 42 tệp>   **Quy tắc điểm neo:** <GitHub>   **Thư mục gốc cho /links:** <thư mục gốc của repo>
**Lệnh chạy:** `python3 scripts/check_md_links.py <docs README.md --root . --external>`
**Kết quả:** <13 phát hiện: HIGH 6, MEDIUM 5, LOW 2 -> sau khi sửa: mã thoát 0>

## Bắt buộc sửa (HIGH)
| # | Tệp:Dòng | Mã lỗi | Liên kết đã viết | Vấn đề | Cách sửa (đích mới) |
|---|---|---|---|---|---|
| 1 | <README.md:11> | <MISSING_ANCHOR> | <setup-guide.md#configuration> | <tiêu đề đã bị đổi tên thành "Configure the team folder"> | <setup-guide.md#configure-the-team-folder> |

## Nên sửa (MEDIUM)
| # | Tệp:Dòng | Mã lỗi | Vấn đề | Cách sửa |
|---|---|---|---|---|
| 1 | <setup-guide.md:25> | <CASE_MISMATCH> | <readme.md so với README.md> | <README.md#contents> |

## Dọn dẹp (LOW)
- <định nghĩa [roadmap] không được sử dụng tại README.md:42 - xóa bỏ hoặc gắn liên kết tới nó>
- <URL trần tại README.md:21 -> [Tài liệu Tidepool](https://docs.example.com/tidepool)>

## Hình ảnh và văn bản thay thế (alt text)
| Tệp:Dòng | Hình ảnh | Văn bản thay thế nên dùng |
|---|---|---|
| <README.md:5> | <images/sync-diagram.png (bị thiếu)> | <"Sơ đồ: ứng dụng điện thoại gửi ghi chú tới máy chủ đồng bộ để ghi vào thư mục chung của nhóm"> |

## Các URL bên ngoài (chưa truy vấn)
| URL | Được dùng tại | Trạng thái (nếu đã kiểm tra) |
|---|---|---|
| <https://docs.example.com/tidepool> | <README.md:21> | <chưa kiểm tra> |

## Phòng ngừa cho lần sau
- <chạy trình kiểm tra trong quy trình CI mỗi khi có thay đổi về tài liệu (mã thoát 1 sẽ làm bản dựng thất bại)>
- <khi đổi tên một tiêu đề mà nơi khác đang liên kết tới, hãy giữ lại một điểm neo tường minh mang id cũ>
FILE:examples/README.md
# Tidepool

Tidepool đồng bộ các ghi chú hiện trường từ điện thoại sang một thư mục dùng chung của nhóm.

![](images/sync-diagram.png)

## Mục lục

- [Bắt đầu nhanh](#quick-start)
- [Cài đặt](setup-guide.md#install)
- [Cấu hình](setup-guide.md#configuration)
- [Xử lý sự cố](setup-guide.md#troubleshooting)
- [Câu hỏi thường gặp](#faq)

## Bắt đầu nhanh

1. Cài đặt ứng dụng (xem [hướng dẫn thiết lập][setup]).
2. Đăng nhập và chọn một thư mục nhóm.
3. Đọc [ghi chú phát hành](CHANGELOG.md) trước khi nâng cấp.

Tài liệu đầy đủ được đặt tại https://docs.example.com/tidepool và tài liệu tham chiếu
API nằm tại <https://api.example.com/tidepool/v2>.

## Cách sử dụng

Chạy lệnh `tidepool sync` để đẩy các ghi chú mới lên. Các liên kết nằm bên trong khối mã sẽ không bị kiểm tra:

```
[not a link](missing.md)
```

## Cách sử dụng

Xem [Đóng góp](../CONTRIBUTING.md) và [hướng dẫn văn phong][style].

## Câu hỏi thường gặp

**Tôi có thể đồng bộ khi ngoại tuyến không?** Các ghi chú sẽ được xếp vào hàng đợi cho đến khi điện thoại có kết nối mạng trở lại.

[setup]: setup-guide.md
[style]: docs/style.md
[roadmap]: https://example.com/tidepool/roadmap
FILE:examples/setup-guide.md
# Hướng dẫn thiết lập

## Cài đặt

Tải xuống trình cài đặt từ [trang phát hành](https://github.com/example/tidepool/releases)
hoặc tự biên dịch từ mã nguồn (xem phần [Biên dịch](#building-from-source)).

![Màn hình cài đặt với trường Thư mục nhóm được tô sáng](https://example.com/tidepool/img/settings.png)

## Cấu hình thư mục nhóm

Mở **Settings > Team folder** và dán liên kết thư mục vào. Thư mục này phải
được chia sẻ trước với bạn (xem phần [Chia sẻ](./README.md#Usage)).

## Biên dịch từ mã nguồn

Sao chép (clone) kho lưu trữ và chạy lệnh `make build`, sau đó tiếp tục với phần
[Bắt đầu nhanh](README.md#quick-start).

## Xử lý sự cố

Nếu quá trình đồng bộ bị treo, hãy kiểm tra tệp nhật ký tại [/Users/ana/tidepool/logs/sync.log](/Users/ana/tidepool/logs/sync.log)
hoặc đặt câu hỏi trong [kênh hỗ trợ][support].

Quay lại [README](readme.md#contents).
FILE:scripts/check_md_links.py
#!/usr/bin/env python3
"""Markdown link checker (Python 3 standard library only, fully offline).

Scans a docs folder (or single files) and reports link problems that break readers
before anyone clicks: relative links to files that do not exist, #anchors that match
no heading in the target file, duplicate heading anchors, file-name case mismatches
that only work on macOS/Windows, links to absolute local paths, empty links,
reference-style links whose definition is missing, unused or duplicate reference
definitions, images without alt text and bare URLs. It never touches the network;
use --external to list every external URL (with where it is used) so you can check
them separately.

Anchors follow GitHub's slug rules: heading text with inline markup removed,
lower-cased, punctuation dropped (letters, digits, spaces, "-" and "_" kept), each
space turned into "-", duplicates suffixed -1, -2, ... Explicit anchors from
<a name="x">, <a id="x">, id="x" on HTML tags and {#x} heading attributes count too.

Usage:
  python3 check_md_links.py PATH [PATH ...] [--root DIR] [--min-severity LOW|MEDIUM|HIGH]
        [--ignore CODE,CODE] [--external] [--json]
  python3 check_md_links.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings (broken links, missing anchors or
reference definitions), 2 = bad input (path not found, no Markdown files).
"""
import argparse, json, os, re, sys, tempfile, unicodedata
from urllib.parse import unquote

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MD_EXT = (".md", ".markdown", ".mdx")
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "_site", "site", "build", "dist"}

FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
ATX_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
SETEXT_RE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
REFDEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>]*>|\S+)(?:[ \t]+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?[ \t]*$")
INLINE_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*(<[^>]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:\s+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)")
REFLINK_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.)*)\]\[([^\]]*)\]")
SHORTCUT_RE = re.compile(r"(?<![\]\\!])\[([^\[\]]+)\](?![\[(:])")
AUTOLINK_RE = re.compile(r"<((?:https?|ftp)://[^>\s]+|mailto:[^>\s]+)>")
BARE_URL_RE = re.compile(r"(?<![<(\"'=\w/])(https?://[^\s<>)\]\"']+)")
HTML_ANCHOR_RE = re.compile(r"<[a-zA-Z][^>]*?\b(?:id|name)\s*=\s*[\"']([^\"']+)[\"']")
HTML_HREF_RE = re.compile(r"<a\b[^>]*?\bhref\s*=\s*[\"']([^\"']*)[\"']", re.I)
HTML_IMG_RE = re.compile(r"<img\b([^>]*)>", re.I)
ATTR_ID_RE = re.compile(r"\s*\{#([\w\-:.]+)[^}]*\}\s*$")
SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
WIN_ABS_RE = re.compile(r"^[a-zA-Z]:[\\/]")


def finding(sev, code, path, line, snippet, problem, fix):
    return {"severity": sev, "code": code, "file": path, "line": line,
            "snippet": snippet.strip()[:120], "problem": problem, "fix": fix}


def strip_inline_markup(text):
    t = re.sub(r"`([^`]*)`", r"\1", text)
    t = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", t)
    t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)
    t = re.sub(r"\[([^\]]*)\]\[[^\]]*\]", r"\1", t)
    t = re.sub(r"<[^>]+>", "", t)
    t = re.sub(r"(\*\*|\*|~~)(?=\S)(.+?)(?<=\S)\1", r"\2", t)
    t = re.sub(r"(?<!\w)(__|_)(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", t)  # _x_ only at word edges
    return t


def github_slug(text):
    t = strip_inline_markup(text).strip().lower()
    out = []
    for ch in t:
        cat = unicodedata.category(ch)
        if ch in "-_ " or cat[0] in "LN" or cat == "Mn":
            out.append("-" if ch == " " else ch)
    return "".join(out)


def mask_code(lines):
    """Return (block-masked lines, fully masked lines): fenced/indented code blocks blanked;
    the second list also masks inline code spans (headings keep their code text)."""
    out, fence = [], None
    prev_blank = True
    for line in lines:
        m = FENCE_RE.match(line)
        if fence:
            if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip()[len(m.group(1)):].strip():
                fence = None
            out.append("")
            continue
        if m:
            fence = m.group(1)
            out.append("")
            continue
        if prev_blank and (line.startswith("    ") or line.startswith("\t")) and line.strip():
            out.append("")
            continue
        prev_blank = not line.strip()
        out.append(line)
    inline = [re.sub(r"(`+)(.+?)\1", lambda mm: mm.group(1) + "x" * len(mm.group(2)) + mm.group(1), l) for l in out]
    return out, inline


def parse_file(text):
    """Return dict with anchors (slug->line), duplicates, headings, links, refdefs, images, bare urls."""
    raw = text.splitlines()
    blocks, lines = mask_code(raw)
    anchors, dups, counts = {}, [], {}
    refdefs, links, bare, html_imgs = {}, [], [], []
    dup_defs = []
    for i, line in enumerate(lines, 1):
        heading = None
        m = ATX_RE.match(blocks[i - 1])
        if m:
            heading = m.group(2)
        elif i >= 2 and SETEXT_RE.match(line) and blocks[i - 2].strip() and not ATX_RE.match(blocks[i - 2]) \
                and not blocks[i - 2].lstrip().startswith(("-", "*", ">", "|")) and not REFDEF_RE.match(blocks[i - 2]):
            if line.strip().startswith("=") or (i >= 3 and not blocks[i - 3].strip()) or i == 2:
                heading = blocks[i - 2].strip()
        if heading is not None:
            am = ATTR_ID_RE.search(heading)
            if am:
                anchors.setdefault(am.group(1), i)
                heading = heading[:am.start()]
            base = github_slug(heading)
            n = counts.get(base, 0)
            counts[base] = n + 1
            slug = base if n == 0 else f"{base}-{n}"
            if n > 0:
                dups.append((i, heading.strip(), base, slug))
            anchors.setdefault(slug, i)
        for am in HTML_ANCHOR_RE.finditer(blocks[i - 1]):
            anchors.setdefault(am.group(1), i)
        d = REFDEF_RE.match(line)
        if d:
            label = norm_label(d.group(1))
            target = d.group(2).strip("<>")
            if label in refdefs:
                dup_defs.append((i, d.group(1), refdefs[label][1]))
            else:
                refdefs[label] = (target, i, d.group(1))
            continue
        spans = []
        for lm in INLINE_RE.finditer(line):
            target = lm.group(3).strip()
            if target.startswith("<") and target.endswith(">"):
                target = target[1:-1]
            links.append({"kind": "image" if lm.group(1) else "link", "text": lm.group(2), "target": target,
                          "line": i, "snippet": lm.group(0), "ref": None})
            spans.append(lm.span())
        for rm in REFLINK_RE.finditer(line):
            if any(a <= rm.start() < b for a, b in spans):
                continue
            label = rm.group(3) or rm.group(2)
            links.append({"kind": "image" if rm.group(1) else "link", "text": rm.group(2), "target": None,
                          "line": i, "snippet": rm.group(0), "ref": norm_label(label), "explicit": True})
            spans.append(rm.span())
        for sm in SHORTCUT_RE.finditer(line):
            if any(a <= sm.start() < b for a, b in spans):
                continue
            if re.match(r"^[ xX]$", sm.group(1)) or line[sm.end():sm.end() + 1] == "]":
                continue  # task list box or nested
            links.append({"kind": "image" if sm.start() > 0 and line[sm.start() - 1] == "!" else "link",
                          "text": sm.group(1), "target": None, "line": i, "snippet": sm.group(0),
                          "ref": norm_label(sm.group(1)), "explicit": False})
            spans.append(sm.span())
        for hm in HTML_HREF_RE.finditer(line):
            links.append({"kind": "link", "text": "", "target": hm.group(1), "line": i, "snippet": hm.group(0), "ref": None, "html": True})
        for im in HTML_IMG_RE.finditer(line):
            attrs = im.group(1)
            src = re.search(r"\bsrc\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
            alt = re.search(r"\balt\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
            links.append({"kind": "image", "text": alt.group(1) if alt else None, "target": src.group(1) if src else "",
                          "line": i, "snippet": im.group(0), "ref": None, "html": True})
        cleaned = AUTOLINK_RE.sub(" ", line)
        cleaned = INLINE_RE.sub(" ", cleaned)
        cleaned = re.sub(r"<[^>]+>", " ", cleaned)
        for bm in BARE_URL_RE.finditer(cleaned):
            bare.append((i, bm.group(1).rstrip(".,;:!?")))
        for am in AUTOLINK_RE.finditer(line):
            links.append({"kind": "autolink", "text": am.group(1), "target": am.group(1), "line": i, "snippet": am.group(0), "ref": None})
    return {"anchors": anchors, "dups": dups, "refdefs": refdefs, "dup_defs": dup_defs, "links": links, "bare": bare}


def norm_label(s):
    return re.sub(r"\s+", " ", s.strip()).casefold()


def is_external(target):
    return bool(SCHEME_RE.match(target)) and not WIN_ABS_RE.match(target)


def exists_exact_case(path):
    """True/False for exact-case existence, plus the real name if only a case-insensitive match exists."""
    if os.path.exists(path):
        head, tail = os.path.split(os.path.abspath(path))
        try:
            names = os.listdir(head or ".")
        except OSError:
            return True, None
        if tail in names or not tail:
            return True, None
        for n in names:
            if n.lower() == tail.lower():
                return False, n
        return True, None
    head, tail = os.path.split(os.path.abspath(path))
    try:
        for n in os.listdir(head):
            if n.lower() == tail.lower():
                return False, n
    except OSError:
        pass
    return False, None


class Checker:
    def __init__(self, root, ignore=()):
        self.root = os.path.abspath(root)
        self.ignore = set(ignore)
        self.cache = {}
        self.findings = []
        self.external = {}

    def parsed(self, path):
        path = os.path.abspath(path)
        if path not in self.cache:
            with open(path, encoding="utf-8", errors="replace") as fh:
                self.cache[path] = parse_file(fh.read())
        return self.cache[path]

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

    def rel(self, p):
        return os.path.relpath(p, self.root)

    def check_file(self, path):
        info = self.parsed(path)
        rp = self.rel(path)
        for line, heading, base, slug in info["dups"]:
            self.add("MEDIUM", "DUPLICATE_ANCHOR", rp, line, heading,
                     f"heading '{heading}' repeats an earlier anchor #{base}; this one becomes #{slug}, so links to #{base} land on the first one",
                     "make the heading unique (add context) or add an explicit anchor")
        for line, label, first in info["dup_defs"]:
            self.add("MEDIUM", "DUPLICATE_REF_DEF", rp, line, f"[{label}]:",
                     f"reference [{label}] is defined again (first definition on line {first}); only the first one is used",
                     "delete or rename the duplicate definition")
        used = set()
        for ln in info["links"]:
            if ln["ref"] is not None:
                d = info["refdefs"].get(ln["ref"])
                if d is None:
                    if ln.get("explicit"):
                        self.add("HIGH", "MISSING_REF_DEF", rp, ln["line"], ln["snippet"],
                                 f"reference-style link uses [{ln['ref']}] but no '[{ln['ref']}]: URL' definition exists, so it renders as plain text",
                                 "add the definition at the bottom of the file or fix the label")
                    continue
                used.add(ln["ref"])
                target = d[0]
            else:
                target = ln["target"]
            self.check_link(path, rp, ln, target)
        for label, (target, line, orig) in info["refdefs"].items():
            if label not in used:
                self.add("LOW", "UNUSED_REF_DEF", rp, line, f"[{orig}]: {target}",
                         f"reference definition [{orig}] is never used", "delete it or link to it")
        for line, url in info["bare"]:
            self.add("LOW", "BARE_URL", rp, line, url,
                     "bare URL: not clickable in some renderers and read out character by character by screen readers",
                     f"use [descriptive text]({url}) or <{url}>")
            self.external.setdefault(url, []).append(f"{rp}:{line}")

    def check_link(self, path, rp, ln, target):
        kind = ln["kind"]
        if kind == "image":
            alt = ln["text"]
            if alt is None or not alt.strip():
                self.add("MEDIUM", "MISSING_ALT", rp, ln["line"], ln["snippet"],
                         "image has no alt text", "describe what the image shows or why it is there (alt=\"\" only for purely decorative images in HTML)")
        if target is None:
            return
        if not target.strip():
            self.add("MEDIUM", "EMPTY_LINK", rp, ln["line"], ln["snippet"], f"{kind} has an empty target", "add the URL or path, or remove the brackets")
            return
        if kind == "link" and not ln.get("html") and not strip_inline_markup(ln["text"]).strip():
            self.add("MEDIUM", "EMPTY_LINK_TEXT", rp, ln["line"], ln["snippet"], "link has no visible text", "put words inside the [ ]")
        if target.startswith("mailto:") or target.startswith("tel:"):
            return
        if target.startswith("file:") or WIN_ABS_RE.match(target) or re.match(r"^/(home|Users|tmp|var|mnt|opt)/", target):
            self.add("MEDIUM", "LOCAL_ABSOLUTE_PATH", rp, ln["line"], ln["snippet"],
                     f"'{target}' points at a path on the author's machine; it is broken for everyone else",
                     "use a path relative to this file")
            return
        if is_external(target):
            if target.startswith(("http://", "https://", "ftp://")):
                self.external.setdefault(target, []).append(f"{rp}:{ln['line']}")
            return
        target_path, _, frag = target.partition("#")
        target_path = unquote(target_path.split("?")[0])
        frag = unquote(frag)
        if target_path:
            if target_path.startswith("/"):
                full = os.path.normpath(os.path.join(self.root, target_path.lstrip("/")))
            else:
                full = os.path.normpath(os.path.join(os.path.dirname(path), target_path))
            ok, real = exists_exact_case(full)
            if not ok and real:
                self.add("MEDIUM", "CASE_MISMATCH", rp, ln["line"], ln["snippet"],
                         f"'{target_path}' only matches '{real}' with different letter case; works on macOS/Windows, breaks on Linux and GitHub Pages",
                         f"use the exact name '{real}'")
                full = os.path.join(os.path.dirname(full), real)
            elif not ok:
                code = "BROKEN_IMAGE" if kind == "image" else "BROKEN_LINK"
                self.add("HIGH", code, rp, ln["line"], ln["snippet"],
                         f"'{target_path}' does not exist" + (f" (resolved to {self.rel(full)})" if self.rel(full) != os.path.normpath(target_path) else ""),
                         "fix the path (moved or renamed file?) or remove the link")
                return
            if not os.path.abspath(full).startswith(self.root + os.sep) and os.path.abspath(full) != self.root:
                self.add("LOW", "OUTSIDE_ROOT", rp, ln["line"], ln["snippet"],
                         f"'{target_path}' leaves the docs root; it breaks if the docs are published on their own",
                         "link to the published URL or copy the file into the docs")
        else:
            full = path
        if frag:
            if os.path.isdir(full) or not full.lower().endswith(MD_EXT):
                return
            info = self.parsed(full)
            if frag in info["anchors"]:
                return
            if frag.lower() in info["anchors"]:
                self.add("MEDIUM", "ANCHOR_CASE", rp, ln["line"], ln["snippet"],
                         f"#{frag} only matches #{frag.lower()} case-insensitively; GitHub anchors are lower-case",
                         f"use #{frag.lower()}")
                return
            if re.match(r"^L\d+(-L\d+)?$", frag):
                return  # GitHub line anchors
            key = frag.lower().replace("-", "")
            close = [a for a in info["anchors"] if a.replace("-", "") == key] or \
                    [a for a in info["anchors"] if len(key) >= 5 and key[:6] in a.replace("-", "")]
            hint = f" Did you mean #{close[0]}?" if close else ""
            self.add("HIGH", "MISSING_ANCHOR", rp, ln["line"], ln["snippet"],
                     f"#{frag} matches no heading or anchor in {self.rel(full)}.{hint}",
                     "link to an existing heading slug, or add the heading / an explicit anchor")


def collect(paths):
    files = []
    for p in paths:
        if os.path.isdir(p):
            for dirpath, dirnames, filenames in os.walk(p):
                dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
                files += [os.path.join(dirpath, f) for f in sorted(filenames) if f.lower().endswith(MD_EXT)]
        elif os.path.isfile(p):
            files.append(p)
        else:
            raise FileNotFoundError(p)
    return files


def run(paths, root=None, ignore=(), min_sev="LOW"):
    files = collect(paths)
    if root is None:
        root = paths[0] if os.path.isdir(paths[0]) else os.path.dirname(os.path.abspath(paths[0]))
    ck = Checker(root, ignore)
    for f in files:
        ck.check_file(os.path.abspath(f))
    lim = SEV_ORDER[min_sev]
    found = [f for f in ck.findings if SEV_ORDER[f["severity"]] <= lim]
    found.sort(key=lambda f: (f["file"], f["line"], SEV_ORDER[f["severity"]]))
    return files, found, ck.external


def report(files, found, external, show_external, roots):
    counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
    out = [f"# Markdown link check: {', '.join(roots)}", "",
           f"Files: {len(files)}   Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})   External URLs: {len(external)} (not checked, offline)", ""]
    if not found:
        out.append("No findings.")
    else:
        out += ["| Sev | Code | File:Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in found:
            out.append(f"| {f['severity']} | {f['code']} | {f['file']}:{f['line']} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
    if show_external and external:
        out += ["", "## External URLs to check (no network calls were made)", "", "| URL | Used at |", "|---|---|"]
        for url in sorted(external):
            out.append(f"| {url} | {', '.join(external[url])} |")
    return "\n".join(out)


def self_test():
    cases = []

    def case(name, files, expect, absent=(), target="README.md"):
        cases.append((name, files, expect, absent, target))

    case("clean file", {"README.md": "# Title\n\nSee [setup](setup.md#install).\n", "setup.md": "# Setup\n\n## Install\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"])
    case("broken relative link", {"README.md": "[x](docs/missing.md)\n"}, ["BROKEN_LINK"])
    case("broken image", {"README.md": "![logo](img/logo.png)\n"}, ["BROKEN_IMAGE"])
    case("missing anchor other file", {"README.md": "[x](a.md#nope)\n", "a.md": "# Yes\n"}, ["MISSING_ANCHOR"])
    case("missing anchor same file", {"README.md": "# Intro\n[x](#usage)\n"}, ["MISSING_ANCHOR"])
    case("anchor with punctuation", {"README.md": "## What's new? (v2.0)\n[x](#whats-new-v20)\n"}, [], ["MISSING_ANCHOR"])
    case("anchor with inline code", {"README.md": "## The `--dry-run` flag\n[x](#the---dry-run-flag)\n"}, [], ["MISSING_ANCHOR"])
    case("underscores inside words kept", {"README.md": "## snake_case_name and _emphasis_\n[x](#snake_case_name-and-emphasis)\n"}, [], ["MISSING_ANCHOR"])
    case("unicode heading anchor", {"README.md": "## Über uns\n[x](#über-uns)\n"}, [], ["MISSING_ANCHOR"])
    case("duplicate heading", {"README.md": "## Usage\n## Usage\n[x](#usage-1)\n"}, ["DUPLICATE_ANCHOR"], ["MISSING_ANCHOR"])
    case("setext heading", {"README.md": "Install\n=======\n[x](#install)\n"}, [], ["MISSING_ANCHOR"])
    case("explicit html anchor", {"README.md": '<a id="faq"></a>\n[x](#faq)\n'}, [], ["MISSING_ANCHOR"])
    case("attribute id anchor", {"README.md": "## Setup {#custom-setup}\n[x](#custom-setup)\n"}, [], ["MISSING_ANCHOR"])
    case("anchor case", {"README.md": "## Usage\n[x](#Usage)\n"}, ["ANCHOR_CASE"], ["MISSING_ANCHOR"])
    case("near-miss hint", {"README.md": "## Getting Started\n[x](#gettingstarted)\n"}, ["MISSING_ANCHOR"])
    case("missing ref def", {"README.md": "See [the guide][guide].\n"}, ["MISSING_REF_DEF"])
    case("unused ref def", {"README.md": "Text.\n\n[old]: https://example.com\n"}, ["UNUSED_REF_DEF"])
    case("used ref def shortcut", {"README.md": "See [guide].\n\n[guide]: setup.md\n", "setup.md": "# S\n"}, [], ["UNUSED_REF_DEF", "BROKEN_LINK"])
    case("ref def case-insensitive", {"README.md": "See [Guide][G].\n\n[g]: setup.md\n", "setup.md": "# S\n"}, [], ["MISSING_REF_DEF", "UNUSED_REF_DEF"])
    case("ref def broken target", {"README.md": "See [g][].\n\n[g]: nope.md\n"}, ["BROKEN_LINK"])
    case("duplicate ref def", {"README.md": "[a][x]\n\n[x]: https://a.example\n[x]: https://b.example\n"}, ["DUPLICATE_REF_DEF"])
    case("image without alt", {"README.md": "![](shot.png)\n", "shot.png": "png"}, ["MISSING_ALT"], ["BROKEN_IMAGE"])
    case("html img without alt", {"README.md": '<img src="shot.png" width="300">\n', "shot.png": "png"}, ["MISSING_ALT"])
    case("bare url", {"README.md": "Docs at https://example.com/docs today.\n"}, ["BARE_URL"])
    case("autolink is not bare", {"README.md": "Docs at <https://example.com/docs>.\n"}, [], ["BARE_URL"])
    case("code block ignored", {"README.md": "```\n[x](missing.md) https://x.example\n```\n"}, [], ["BROKEN_LINK", "BARE_URL"])
    case("inline code ignored", {"README.md": "Use `[x](missing.md)` syntax.\n"}, [], ["BROKEN_LINK"])
    case("case mismatch", {"README.md": "[c](CONTRIBUTING.MD)\n", "CONTRIBUTING.md": "# C\n"}, ["CASE_MISMATCH"], ["BROKEN_LINK"])
    case("local absolute path", {"README.md": "[x](/Users/ana/repo/docs/a.md)\n"}, ["LOCAL_ABSOLUTE_PATH"])
    case("empty target", {"README.md": "[todo]()\n"}, ["EMPTY_LINK"])
    case("url-encoded path", {"README.md": "[x](my%20notes.md)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
    case("angle-bracket path with spaces", {"README.md": "[x](<my notes.md>)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
    case("directory link ok", {"README.md": "[api](api/)\n", "api/index.md": "# API\n"}, [], ["BROKEN_LINK"])
    case("root-relative link", {"README.md": "[x](/guide/a.md)\n", "guide/a.md": "# A\n"}, [], ["BROKEN_LINK"])
    case("github line anchor", {"README.md": "[x](setup.md#L10)\n", "setup.md": "# S\n"}, [], ["MISSING_ANCHOR"])
    case("task list not a link", {"README.md": "- [ ] write docs\n- [x] done\n"}, [], ["MISSING_REF_DEF"])
    case("mailto ignored", {"README.md": "[mail](mailto:team@example.com)\n"}, [], ["BROKEN_LINK"])
    case("nested subdir relative", {"README.md": "x", "guide/a.md": "[b](../b.md#top)\n", "b.md": "# Top\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"], "guide/a.md")

    passed = 0
    for name, files, expect, absent, target in cases:
        with tempfile.TemporaryDirectory() as d:
            for rel, body in files.items():
                p = os.path.join(d, rel)
                os.makedirs(os.path.dirname(p), exist_ok=True)
                with open(p, "w", encoding="utf-8") as fh:
                    fh.write(body)
            _, found, _ = run([d], root=d)
            codes = {f["code"] for f in found if f["file"] == target}
            ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
            if name == "clean file":
                ok = ok and not found
            if name == "near-miss hint":
                ok = ok and any("Did you mean #getting-started" in f["problem"] for f in found)
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
    # external URL listing and report rendering
    with tempfile.TemporaryDirectory() as d:
        with open(os.path.join(d, "a.md"), "w") as fh:
            fh.write("[x](https://example.com/a) and [y](https://example.com/a)\n")
        files, found, ext = run([d])
        ok = list(ext) == ["https://example.com/a"] and len(ext["https://example.com/a"]) == 2
        text = report(files, found, ext, True, [d])
        ok = ok and "External URLs to check" in text and "No findings." in text
        passed += ok
        print(("PASS " if ok else "FAIL ") + "external URLs listed once with locations, no network")
    total = len(cases) + 1
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Offline Markdown link and anchor checker")
    ap.add_argument("paths", nargs="*")
    ap.add_argument("--root", help="docs root for /absolute links and the OUTSIDE_ROOT check (default: first folder)")
    ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
    ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
    ap.add_argument("--external", action="store_true", help="list external URLs to check separately")
    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.paths:
        ap.print_usage(sys.stderr)
        return 2
    try:
        files, found, ext = run(a.paths, a.root, [c.strip() for c in a.ignore.split(",") if c.strip()], a.min_severity)
    except FileNotFoundError as e:
        print(f"error: path not found: {e}", file=sys.stderr)
        return 2
    if not files:
        print("error: no Markdown files found", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"files": len(files), "findings": found, "external": ext if a.external else {}}, indent=2, ensure_ascii=False))
    else:
        print(report(files, found, ext, a.external, a.paths))
    return 1 if any(f["severity"] == "HIGH" for f in found) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonMarkdownDocumentation+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 / 4Tiếp