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
F

Fatih Kadir Akın

@f

446prompt
0lượt bình chọn nhận được
0đóng góp
Tham gia about 18 hours ago
446 đóng góp trong năm qua
Tháng 11
Tháng 12
Tháng 1
Tháng 2
Tháng 3
Tháng 4
Tháng 5
Tháng 6
Tháng 7
Tháng 8
Tháng 9
Tháng 10
M
W
F
Ít
Nhiều
Trình xây dựng Bản tóm tắt Ý tưởng Bối cảnh Trang trại Hoa oải hương Provence
Văn bản

Xây dựng một bản tóm tắt bối cảnh sẵn sàng cho việc tạo ảnh và tạo chuyển động cho một cảnh quay chủ đạo (hero shot) về ngôi nhà trang trại bằng đá cổ kính ở cuối những luống hoa oải hương tím biếc tại Provence lúc bình minh: ngôi nhà trang trại và cây cối, các luống hoa oải hương, chiều sâu phong cảnh, ánh sáng, các chi tiết cố định, sơ đồ chuyển động và hai prompt hạt giống (seed): một bức ảnh tĩnh tỷ lệ 16:9 và một hoạt cảnh chuyển đổi từ ảnh sang video.

Hãy đóng vai một nhà thiết kế bối cảnh phong cảnh điện ảnh chuyên viết các bản tóm tắt bối cảnh sẵn sàng cho việc tạo ảnh và tạo chuyển động (image-ready và motion-ready). Hãy giúp tôi thiết kế một cảnh quay chủ đạo (hero shot) về một ngôi nhà trang trại bằng đá cổ kính nằm ở cuối những luống hoa oải hương màu tím trải dài tại vùng Provence lúc bình minh. Cảnh quay này trước tiên sẽ được tạo ra dưới dạng một bức ảnh tĩnh và sau đó được tạo chuyển động thành một đoạn video ngắn sử dụng chính bức ảnh đó làm khung hình đầu tiên. Bản tóm tắt của bạn phải làm cho bức ảnh tĩnh giàu các yếu tố có thể chuyển động một cách tự nhiên (các luống hoa oải hương đung đưa trong làn gió nhẹ, sương sớm trôi qua những ngọn đồi, ánh sáng của mặt trời lúc mới mọc từ từ rực lên, những đám mây nhỏ trôi nhẹ, đàn ong bay trên mặt hoa, một hoặc hai chú chim bay ngang bầu trời) đồng thời vẫn giữ cho bố cục sự yên bình, rõ ràng và dễ tạo chuyển động.

Các lựa chọn khởi đầu của tôi:
- Ngôi nhà trang trại: a two-story old stone farmhouse with a terracotta tile roof and pale blue shutters
- Cây cối: one tall dark cypress right beside the house and a lone round oak tree on a grassy rise
- Cánh đồng hoa oải hương: long straight rows of purple lavender with dirt paths between them, converging toward the house
- Phong cảnh: gentle rolling hills fading to blue in the distance
- Thời gian và ánh sáng: sunrise with the sun just above the hills, warm golden light, long shadows, light haze
- Phong cách hình ảnh: photoreal landscape photography, natural colors
- Các từ khóa tâm trạng (Mood words): peaceful, warm, fragrant, timeless

Trước tiên, hãy hỏi tôi trong một tin nhắn ngắn gọn về bất kỳ thông tin nào còn thiếu: vị trí đặt máy quay mà tôi ưa thích (đặt thấp ở cuối một luống hoa oải hương, ngang tầm mắt người đứng trên lối đi, hay hơi nâng cao một chút), ngôi nhà trang trại và cây sồi nên nằm ở phía bên nào của khung hình, mặt trời nên ở đâu, tôi muốn có bao nhiêu sương mù, và bất kỳ yếu tố nào cần tránh (con người, xe hơi, máy kéo, hàng rào, biển báo, những chú chim đòi hỏi chuyển động vỗ cánh phức tạp, chữ viết, logo, hoặc việc sao chép một bức ảnh chụp có thật cụ thể).

Sau đó, hãy lập bản tóm tắt với các phần sau đây:

1. **Tổng quan Bối cảnh (Scene Overview)**: hai hoặc ba câu mô tả địa điểm, thời gian, ánh sáng và cảm xúc của khoảnh khắc đó. Hãy mô tả một khung cảnh Provence tưởng tượng mang đậm tinh thần của vùng đất này, chứ không phải bản sao của một bức ảnh cụ thể hay một trang trại có tên tuổi ngoài đời thực.

2. **Ngôi nhà Trang trại và Cây cối (Farmhouse and Trees)**: vị trí và kích thước của ngôi nhà trang trại trong khung hình, những bức tường đá, mái nhà, cửa chớp, cửa sổ và những bụi cây xung quanh nó, cây bách và cây sồi cùng vị trí chúng đứng, và cách ánh mặt trời ở vị trí thấp chiếu sáng từng chi tiết đó.

3. **Các luống Hoa oải hương và Tiền cảnh (Lavender Rows and Foreground)**: số lượng luống hoa nhìn thấy được, lối đi ở trung tâm, cách các luống hoa hội tụ về phía điểm tụ (vanishing point), màu sắc của những bông hoa dưới ánh nắng và trong bóng râm, cùng kết cấu của những cành hoa gần nhất.

4. **Phong cảnh và Chiều sâu (Landscape and Depth)**: các lớp chiều sâu tính từ thảm hoa oải hương ở tiền cảnh cho đến ngôi nhà trang trại, cây sồi trên gò đất cao, thảm thực vật xanh ở trung cảnh, những ngọn đồi nơi đường chân trời, và cách làn sương mù làm dịu đi từng lớp cảnh vật.

5. **Ánh sáng và Bầu trời (Light and Sky)**: vị trí và độ cao của mặt trời, quầng sáng và sắc ấm trên ống kính xung quanh mặt trời, dải chuyển màu (gradient) của bầu trời, hình dáng và vị trí của những đám mây, chiều dài và hướng của các bóng đổ dọc theo các luống hoa.

6. **Các Chi tiết Hình ảnh Cố định (Fixed Visual Details)**: một danh sách đánh số gồm 8 đến 10 chi tiết bắt buộc phải giữ nguyên giống hệt nhau trong cả bức ảnh tĩnh và trong video (vị trí ngôi nhà trang trại, màu mái nhà và cửa chớp, vị trí cây bách, vị trí cây sồi, hướng của các luống hoa và lối đi trung tâm, vị trí mặt trời, đường chân trời, bố cục đám mây, vị trí máy quay, khung hình).

7. **Sơ đồ Chuyển động (Motion Map)**: một bảng liệt kê mọi yếu tố có thể chuyển động trong video, với các cột: yếu tố, kiểu chuyển động, hướng, tốc độ (rất chậm, chậm, trung bình), và mức độ chuyển động mạnh hay nhẹ. Bao gồm hoa oải hương, làn sương, quầng sáng mặt trời, mây, cây cối, đàn ong hoặc chim chóc, và chuyển động của máy quay. Giữ cho tổng thể chuyển động tinh tế và hợp lý về mặt vật lý, với một hướng gió duy nhất.

8. **Prompt Hạt giống A - Ảnh Tĩnh Chủ đạo (Prompt Seed A - Hero Still Image)**: một prompt tạo ảnh sẵn sàng để sao chép-dán dưới 170 từ cho khung hình ngang rộng tỷ lệ 16:9, có nhắc đến các luống hoa oải hương, ngôi nhà trang trại bằng đá và cảnh bình minh ngay trong câu đầu tiên, kết thúc bằng cụm từ: no people, no text, no logos.

9. **Prompt Hạt giống B - Hoạt cảnh Chuyển đổi từ Ảnh sang Video (Prompt Seed B - Image-to-Video Animation)**: một prompt tạo video sẵn sàng để sao chép-dán dưới 120 từ bắt đầu từ bức ảnh tĩnh chủ đạo làm khung hình đầu tiên, mô tả hoa oải hương đung đưa, làn sương trôi nhẹ, quầng sáng rực lên, những đám mây nhỏ trôi, một chuyển động máy quay tiến vào (push-in) rất chậm dọc theo luống hoa trung tâm trong khoảng 6 giây, và dặn giữ nguyên ngôi nhà trang trại, cây cối, các luống hoa cùng khung hình giống hệt như trong ảnh mà không xuất hiện thêm vật thể mới nào.

Quy tắc: không có người, phương tiện giao thông hay máy móc nông nghiệp; không có chữ viết, biển báo hay logo; không có chuyển động máy quay nhanh hay cắt cảnh; và không có chuyển động nào phá vỡ quy luật vật lý (tất cả cây cối đều nghiêng theo cùng một chiều gió, ngôi nhà và các thân cây lớn không bao giờ dịch chuyển hay biến dạng, mặt trời không nhảy cóc, bóng đổ luôn nhất quán với vị trí mặt trời). Hãy sử dụng các đoạn văn ngắn và danh sách.
TravelPlanningImage To Video+2
F@f
0
Video
Trang trại Hoa oải hương Provence lúc Bình minh (Hoạt cảnh Chuyển đổi từ Ảnh sang Video)

Tạo chuyển động cho bức ảnh trang trại hoa oải hương ở bước 2 làm khung hình đầu tiên thành một cảnh quay êm đềm dài 6 giây: hoa oải hương đung đưa trong làn gió nhẹ, máy quay tiến từ từ dọc theo luống hoa trung tâm về phía ngôi nhà đá, ánh nắng rực sáng dần, làn sương mỏng trôi nhẹ, những chú chim nhỏ bay ngang bầu trời và đàn ong lượn lờ trên các luống hoa. Bước 3 trong quy trình Trang trại Hoa oải hương Provence.

Bắt đầu từ bức ảnh ở bước 2 "Ngôi nhà Trang trại bằng Đá ở Cuối Luống Hoa oải hương tại Provence lúc Bình minh" ("Stone Farmhouse at the End of Lavender Rows in Provence at Sunrise") làm khung hình đầu tiên chính xác tuyệt đối, và tạo chuyển động cho nó thành một cảnh quay điện ảnh êm đềm dài 6 giây. Giữ nguyên ngôi nhà trang trại bằng đá với mái ngói đất nung và những cánh cửa chớp màu xanh lam nhạt ở một phần ba bên phải khung hình, cây bách cao vút bên cạnh, cây sồi đơn độc ở bên trái, các luống hoa oải hương hội tụ cùng lối đi ở giữa, mặt trời ở vị trí thấp phía ngoài cùng bên trái, đường chân trời và bố cục giống hệt như trong bức ảnh. Một làn gió nhẹ làm các luống hoa oải hương đung đưa dịu dàng, tất cả đều nghiêng theo cùng một hướng. Máy quay thực hiện chuyển động tiến vào (push-in) rất chậm rãi và ổn định dọc theo luống hoa trung tâm hướng về phía ngôi nhà trang trại. Ánh sáng vàng rực của mặt trời từ từ sáng lên và làn sương mai mỏng manh trôi nhẹ qua những ngọn đồi phía bên trái. Một vài chú chim nhỏ bay ngang qua bầu trời từ phải sang trái phía trên ngôi nhà, một vài chú ong lượn lờ trên thảm hoa oải hương ở tiền cảnh, và những đám mây trắng nhỏ trôi nhẹ. Chuyển động tự nhiên, chân thực, không cắt cảnh, không có người, không thêm tòa nhà hay vật thể mới, không có chữ. Âm thanh êm dịu của làn gió nhẹ, tiếng chim hót từ xa và tiếng ong vo ve.
TravelVideo GenerationImage To Video+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
Tác vụ Nền (Background Jobs) taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để xây dựng hàng đợi tác vụ nền (background job queues) và các worker đáng tin cậy.

# Tác vụ Nền (Background Jobs) taste của một kỹ sư độ tin cậy backend
- Thiết kế mọi tác vụ (job) có tính lũy đẳng (idempotent) để việc thử lại (retry) hoặc gửi trùng lặp không bao giờ trừ tiền thẻ hay gửi email hai lần. Confidence: 0.95
- Chỉ truyền các ID trong phần dữ liệu (payload) của tác vụ và tải dữ liệu mới nhất bên trong worker thay vì tuần tự hóa (serialize) toàn bộ bản ghi. Confidence: 0.90
- Đưa các tác vụ vào hàng đợi (enqueue) trong cùng một giao dịch cơ sở dữ liệu (database transaction) với thao tác ghi đã kích hoạt chúng, bằng cách sử dụng bảng hộp thư đi (outbox table) hoặc hàng đợi dựa trên Postgres. Confidence: 0.85
- Ưu tiên sử dụng một hệ thống hàng đợi đơn giản, ổn định và được hỗ trợ tốt như Sidekiq, BullMQ, Celery hoặc hàng đợi Postgres như Graphile Worker thay vì tự xây dựng mới. Confidence: 0.80
- Thử lại với thời gian chờ tăng theo cấp số nhân (exponential backoff) kèm độ lệch ngẫu nhiên (jitter), và giới hạn số lần thử để các tác vụ lỗi độc hại (poison jobs) được chuyển vào hàng đợi thư chết (dead letter queue). Confidence: 0.95
- Phân loại lỗi thành lỗi có thể thử lại (retryable) hoặc lỗi vĩnh viễn (permanent), và cho dừng ngay lập tức các lỗi vĩnh viễn thay vì lãng phí số lần thử lại. Confidence: 0.85
- Thiết lập thời gian chờ tối đa (timeout) rõ ràng cho mọi tác vụ và cho mọi lệnh gọi mạng mà tác vụ đó thực hiện. Confidence: 0.90
- Giữ cho các tác vụ nhỏ gọn và chạy nhanh, đồng thời chia nhỏ các công việc xử lý hàng loạt (batch work) kéo dài thành từng phần (chunks) có thể tiếp tục chạy lại từ điểm đánh dấu (checkpoint). Confidence: 0.85
- Sử dụng các hàng đợi riêng biệt cho công việc nhạy cảm với độ trễ và công việc xử lý số lượng lớn (bulk work) để tác vụ nạp bù dữ liệu (backfill) không bao giờ làm chậm email đặt lại mật khẩu. Confidence: 0.90
- Giới hạn tần suất (rate limit) các tác vụ gọi API bên thứ ba theo từng nhà cung cấp dịch vụ, chứ không phải theo từng tiến trình worker. Confidence: 0.80
- Sử dụng khóa tác vụ duy nhất (unique job keys) hoặc cơ chế khóa (locks) để ngăn cùng một tác vụ định kỳ chạy hai lần cùng lúc. Confidence: 0.85
- Chạy các lịch trình kiểu cron từ một bộ lập lịch (scheduler) duy nhất có cơ chế bầu chọn nút dẫn đầu (leader election), tuyệt đối không chạy từ mọi thực thể (instance) của ứng dụng. Confidence: 0.80
- Đánh phiên bản (version) cho dữ liệu đầu vào (payload) của tác vụ và đảm bảo các worker vẫn đọc được phiên bản cũ trong quá trình triển khai (deploy) bản mới. Confidence: 0.80
- Xử lý tín hiệu SIGTERM một cách êm đẹp (gracefully): ngừng nhận tác vụ mới, hoàn tất hoặc đưa lại tác vụ hiện tại vào hàng đợi, sau đó mới thoát. Confidence: 0.90
- Ghi nhận mã tác vụ (job ID), số thứ tự lần thử (attempt number) và mã tương quan (correlation ID) trên mọi dòng nhật ký (log) phát sinh từ một tác vụ. Confidence: 0.85
- Cảnh báo dựa trên độ sâu của hàng đợi, tuổi của tác vụ cũ nhất và mức tăng trưởng của hàng đợi thư chết (dead letter queue), chứ không chỉ cảnh báo khi worker bị sập. Confidence: 0.90
- Phân công người phụ trách rõ ràng cho hàng đợi thư chết và có tài liệu hướng dẫn lệnh chạy lại (replay command). Confidence: 0.75
- Kiểm thử các tác vụ bằng cách gọi trực tiếp hàm xử lý (handler) trong kiểm thử đơn vị (unit tests) và chạy một bài kiểm thử đầu-cuối (end-to-end) thông qua hàng đợi thật trong hệ thống CI. Confidence: 0.80
- Tránh nối chuỗi quá nhiều tác vụ thành các luồng công việc ngầm phức tạp; hãy sử dụng một công cụ điều phối luồng công việc (workflow engine) như Temporal khi các bước cần được điều phối chặt chẽ. Confidence: 0.70
- Lưu trữ kết quả tác vụ và các dấu hiệu đánh dấu hiệu ứng phụ (side-effect markers) trong cơ sở dữ liệu, chứ không chỉ lưu trong hệ thống lưu trữ của hàng đợi. Confidence: 0.75
BackendBest PracticesDevOps+2
F@f
0
Ứng dụng Vue taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để xây dựng ứng dụng Vue 3 dễ bảo trì với TypeScript và Composition API.

# Ứng dụng Vue taste của một kỹ sư frontend
- Bắt đầu các dự án mới bằng Vite và Vue 3, hoặc Nuxt khi cần kết xuất phía máy chủ (server rendering) hoặc định tuyến dựa trên tệp (file-based routing). Confidence: 0.90
- Viết các component bằng script setup và Composition API, không dùng Options API. Confidence: 0.90
- Sử dụng TypeScript ở mọi nơi và kiểm tra kiểu cho template bằng vue-tsc trong hệ thống CI. Confidence: 0.90
- Định nghĩa kiểu cho props và emits bằng generics của defineProps và defineEmits thay vì chỉ khai báo ở thời điểm chạy (runtime-only). Confidence: 0.85
- Sử dụng Pinia cho trạng thái phía client được chia sẻ chung và giữ cho các store nhỏ gọn, chỉ tập trung vào một miền nghiệp vụ (domain) duy nhất. Confidence: 0.85
- Tải dữ liệu từ máy chủ bằng TanStack Query cho Vue hoặc useFetch của Nuxt thay vì tự viết các cờ trạng thái đang tải (loading flags) thủ công trong store. Confidence: 0.80
- Tách logic có thể tái sử dụng thành các composable có tên theo mẫu useSomething trả về các ref và hàm. Confidence: 0.90
- Tìm đến thư viện VueUse trước khi tự viết một composable tùy chỉnh cho các API trình duyệt phổ biến. Confidence: 0.75
- Ưu tiên dùng computed hơn là watch, và chỉ sử dụng watch cho các tác vụ có hiệu ứng phụ (side effects). Confidence: 0.90
- Tuyệt đối không thay đổi trực tiếp (mutate) giá trị của props; hãy phát sự kiện (emit events) hoặc sử dụng defineModel cho ràng buộc hai chiều (two-way binding). Confidence: 0.95
- Luôn cung cấp cho các phần tử trong v-for một khóa (key) ổn định lấy từ dữ liệu, tuyệt đối không dùng chỉ số mảng (index). Confidence: 0.90
- Không bao giờ kết hợp v-if và v-for trên cùng một phần tử. Confidence: 0.90
- Giữ cho các component có độ dài dưới khoảng 200 dòng và tách nhỏ chúng ra khi phần template trở nên khó đọc lướt. Confidence: 0.70
- Sử dụng tên component gồm nhiều từ viết theo kiểu PascalCase và đặt các component cùng chỗ (colocate) theo tính năng chứ không phân chia theo loại kỹ thuật. Confidence: 0.80
- Định kiểu giao diện bằng scoped CSS hoặc Tailwind, và tránh sử dụng các kiểu toàn cục (global styles) ngoại trừ một phần reset nhỏ và các design token. Confidence: 0.75
- Tải trễ (lazy load) các component của tuyến đường (route) bằng cú pháp import động trong Vue Router. Confidence: 0.85
- Kiểm thử các component bằng Vitest và Vue Testing Library, truy vấn phần tử theo vai trò (role) và văn bản hiển thị. Confidence: 0.85
- Kiểm tra mã nguồn (lint) bằng ESLint và eslint-plugin-vue với bộ quy tắc được khuyến nghị, đồng thời định dạng mã bằng Prettier. Confidence: 0.85
- Tránh sử dụng provide và inject cho trạng thái toàn ứng dụng; chỉ dùng chúng cho phần nội bộ của thư viện component và các cấu hình truyền sâu nhiều tầng. Confidence: 0.70
- Làm sạch (sanitize) bất kỳ mã HTML nào trước khi sử dụng v-html, hoặc tốt hơn hết là tránh dùng v-html hoàn toàn. Confidence: 0.95
Best PracticesFrontendJavaScript+1
F@f
0
Ứng dụng Desktop Tauri taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để xây dựng ứng dụng máy tính để bàn (desktop) đa nền tảng an toàn bằng Tauri và Rust.

# Ứng dụng Desktop Tauri taste của một lập trình viên desktop đa nền tảng
- Chọn Tauri thay vì Electron cho các ứng dụng desktop mới khi kích thước gói cài đặt (bundle size) và mức sử dụng bộ nhớ là yếu tố quan trọng. Confidence: 0.80
- Xây dựng phần frontend bằng Vite và TypeScript ở chế độ nghiêm ngặt (strict mode), đồng thời giữ cho nó độc lập với framework nhìn từ phía Rust. Confidence: 0.85
- Giữ logic nghiệp vụ có tương tác với hệ thống tệp, mạng hoặc hệ điều hành ở trong các lệnh Rust (Rust commands), và giữ cho tầng web chỉ tập trung vào giao diện người dùng (UI). Confidence: 0.85
- Chỉ bật các năng lực (capabilities) và quyền hạn (permissions) mà từng cửa sổ thực sự cần, và tuyệt đối không phát hành danh sách cho phép dùng ký tự đại diện (wildcard allowlist). Confidence: 0.95
- Thiết lập Chính sách Bảo mật Nội dung (Content Security Policy - CSP) nghiêm ngặt và không bao giờ tải nội dung từ xa vào một cửa sổ có quyền gọi lệnh hệ thống. Confidence: 0.95
- Tạo các liên kết kiểu (bindings) TypeScript cho các lệnh bằng tauri-specta hoặc ts-rs để các kiểu dữ liệu giữa frontend và Rust không thể bị lệch nhau. Confidence: 0.80
- Trả về các lỗi có cấu trúc từ các lệnh bằng thiserror và serde thay vì trả về chuỗi văn bản thuần túy. Confidence: 0.85
- Chạy các tác vụ nặng hoặc chậm trong các lệnh bất đồng bộ (async commands) hoặc tác vụ nền và báo cáo tiến độ thông qua các sự kiện (events), tuyệt đối không làm chặn luồng chính (main thread). Confidence: 0.90
- Lưu trữ cài đặt của người dùng bằng plugin store chính thức và lưu các thông tin bí mật (secrets) trong trình quản lý khóa của hệ điều hành (OS keychain), không lưu trong các tệp JSON thuần túy. Confidence: 0.90
- Sử dụng SQLite thông qua sqlx hoặc plugin SQL cho dữ liệu cục bộ, với các tệp di trú cơ sở dữ liệu (migrations) được lưu trữ trong kho mã nguồn (repo). Confidence: 0.80
- Phân giải đường dẫn bằng path API cho các thư mục dữ liệu ứng dụng và cấu hình thay vì gán cứng (hardcode) vị trí. Confidence: 0.90
- Phát hành tính năng cập nhật tự động bằng plugin updater và các gói phát hành (release artifacts) đã được ký số. Confidence: 0.85
- Ký số (sign) và chứng thực (notarize) các bản dựng macOS cũng như ký số các trình cài đặt Windows ngay từ hệ thống CI, tuyệt đối không thực hiện từ máy tính cá nhân của lập trình viên. Confidence: 0.90
- Xây dựng bản cài đặt cho mọi nền tảng trong GitHub Actions bằng ma trận tauri-action trên mỗi thẻ phát hành (release tag). Confidence: 0.80
- Kiểm thử giao diện người dùng (UI) trên cả WebKit, WebView2 và WebKitGTK, vì cơ chế hiển thị và các API có sự khác biệt giữa chúng. Confidence: 0.85
- Tôn trọng các quy ước của từng nền tảng đối với thanh menu, phím tắt và nút điều khiển cửa sổ thay vì dùng chung một thanh tiêu đề tùy chỉnh ở mọi nơi. Confidence: 0.75
- Viết kiểm thử đơn vị (unit test) trực tiếp cho các lệnh Rust và bao phủ các luồng chính bằng kiểm thử đầu-cuối (end-to-end) thông qua WebDriver. Confidence: 0.70
- Giữ cây phụ thuộc (dependency tree) nhỏ gọn và chạy cargo audit cùng npm audit trong hệ thống CI. Confidence: 0.80
- Ghi nhật ký (log) vào tệp xoay vòng (rotating file) trong thư mục nhật ký của ứng dụng và cung cấp cho người dùng cách xuất nhật ký chỉ bằng một cú nhấp chuột để gửi báo cáo lỗi. Confidence: 0.75
Best PracticesSecurityTypeScript+1
F@f
0
Video
Bữa tiệc Khiêu vũ của Biệt đội Mèo Halloween (Video Dọc)

Một đoạn hoạt hình 3D siêu thực dài 30 giây, tỷ lệ 9:16 về bốn chú mèo mặc đồ hóa trang (phù thủy, con ma, bộ xương, quả bí ngô) và một chú hổ Frankenstein đứng thẳng bằng hai chân nhảy múa đồng điệu trong khoảng sân làng Halloween ngập tràn ánh nắng, được viết kịch bản gồm sáu cảnh chia theo mốc thời gian kèm gợi ý âm thanh và quy tắc giữ tính nhất quán nhân vật nghiêm ngặt. Đang thịnh hành trên X, được chia sẻ gốc bởi @Just_sharon7: https://x.com/Just_sharon7/status/2113964901446094947

Phong cách Video: Động vật nhân cách hóa 3D siêu thực, trang phục hóa trang Halloween đáng yêu, điệu nhảy đồng bộ tràn đầy năng lượng, bầu không khí giả tưởng ngộ nghĩnh, sắc màu mùa thu rực rỡ, hoạt hình điện ảnh, kết cấu bộ lông chân thực, chuyển động nhân vật mượt mà, định dạng dọc 9:16, chất lượng 4K.

BỐI CẢNH:

Một khoảng sân làng mang chủ đề Halloween tuyệt đẹp vào một buổi chiều thu nắng ấm rực rỡ. Một dinh thự ma ám kiến trúc Victoria tráng lệ sừng sững ở hậu cảnh, được trang trí bằng những quả bí ngô khắc mặt quỷ phát sáng, bí ngô chạm khắc, đèn lồng treo, mạng nhện, đồ trang trí ma quái và phụ kiện Halloween. Những hàng cây phong cao lớn với tán lá màu cam rực rỡ, vàng óng và đỏ thẫm bao quanh khoảng sân. Lá thu rụng phủ kín lối đi lát đá cuội. Bầu trời xanh biếc sống động với những đám mây trắng bồng bềnh tạo nên bầu không khí Halloween vui tươi.

NHÂN VẬT CHÍNH:

Bốn chú mèo nhân cách hóa đáng yêu và một chú hổ nhân cách hóa to lớn, tất cả đều đứng thẳng bằng hai chân sau.

Mèo Phù thủy (Witch Cat): Một chú mèo lông xù hai màu đen trắng đội chiếc mũ phù thủy chóp nhọn màu đen, mặc váy phù thủy màu tím và áo choàng tím bồng bềnh.

Mèo Ma (Ghost Cat): Một chú mèo trắng lông xù đáng yêu mặc trang phục con ma màu trắng bay bổng với chiếc mũ trùm đầu hình ma dễ thương và chiếc nơ đen nhỏ xíu ở cổ.

Mèo Bộ xương (Skeleton Cat): Một chú mèo lông xù đen trắng mặc bộ đồ liền thân (jumpsuit) hình bộ xương màu đen in họa tiết xương trắng và đội chiếc mũ Halloween nhỏ màu đen.

Mèo Bí ngô (Pumpkin Cat): Một chú mèo mướp màu cam mũm mĩm mặc bộ trang phục quả bí ngô màu cam tươi sáng có khắc khuôn mặt jack-o'-lantern và đội chiếc mũ nhỏ hình cuống bí ngô màu xanh lá.

Hổ Frankenstein (Frankenstein Tiger): Một chú hổ vằn cam đen to lớn, chân thực đứng thẳng như người, đội phần đầu hóa trang quái vật kiểu Frankenstein màu xanh lá, khoác áo măng tô dài thời Victoria màu xanh lục đậm, áo ghi lê màu tím và quần dài tối màu.

Tất cả các nhân vật đều có khuôn mặt động vật chân thực, đôi mắt biểu cảm, bộ lông xù chi tiết và cá tính đáng yêu.

CẢNH 1 (0–5 giây): MỞ ĐẦU NHẢY NHÓM

Máy quay mở đầu bằng một góc toàn cảnh điện ảnh bao quát khoảng sân Halloween.

Bốn chú mèo mặc đồ hóa trang đáng yêu đứng ở tiền cảnh trong khi chú hổ Frankenstein khổng lồ đứng phía sau chúng.

Tất cả các nhân vật bắt đầu nhảy múa cùng nhau theo nhịp điệu Halloween vui nhộn, sôi động.

Những chú mèo nhún nhảy trên hai chân sau, vung vẩy đôi chân trước nhỏ nhắn, lắc lư cơ thể và nghiêng đầu hoàn toàn đồng bộ.

Chú hổ Frankenstein thực hiện những động tác nhảy phóng đại, hài hước ở phía sau.

Máy quay từ từ đẩy nhẹ về phía trước.

CẢNH 2 (5–10 giây): CẬN CẢNH NHỮNG CHÚ MÈO ĐÁNG YÊU

Một cú chuyển cảnh nhanh hé lộ chú mèo ma lông trắng xù và chú mèo bộ xương màu đen đang nhảy cạnh nhau.

Cả hai chú mèo quay mặt về phía máy quay, giơ hai chân trước lên gần mặt và thực hiện những cử chỉ vẫy đệm thịt chân tinh nghịch giống hệt nhau.

Trang phục của chúng nảy lên tự nhiên khi chúng di chuyển.

Chú mèo ma nghiêng đầu một cách đáng yêu trong khi chú mèo bộ xương tạo một dáng nhỏ đầy tự tin.

Máy quay lia theo (tracking) mượt mà, chuyển động lông chân thực, biểu cảm vui tươi.

CẢNH 3 (10–15 giây): NHẢY NHÓM TỪ PHÍA SAU

Máy quay di chuyển ra phía sau những chú mèo đang nhảy múa.

Chiếc áo choàng tím của mèo phù thủy tung bay đầy ấn tượng, trang phục trắng của mèo ma phấp phới, còn mèo bộ xương thì lắc lư hông.

Những chú mèo thực hiện các bước nhảy ngang đồng bộ hướng về phía chú hổ Frankenstein.

Chú hổ nhảy múa đầy nhiệt huyết ngay phía trước dinh thự ma ám.

Lá mùa thu bay tán loạn nhẹ nhàng trên mặt đường lát đá cuội.

CẢNH 4 (15–20 giây): MÀN SOLO CỦA HỔ FRANKENSTEIN

Góc máy trung cận cảnh tập trung vào chú hổ Frankenstein khổng lồ.

Chú hổ trình diễn một điệu nhảy solo vô cùng hài hước, bắt chéo hai cánh tay đầy lông trước ngực, lắc vai, vung chân theo nhịp điệu và gật gù cái đầu.

Chiếc áo khoác dài màu xanh lá của chú đung đưa tự nhiên.

Chú hổ giữ nguyên vẻ mặt nghiêm nghị một cách hài hước trong lúc nhảy.

Máy quay từ từ thu phóng lại gần hơn, làm nổi bật khuôn mặt giàu biểu cảm và bộ lông vằn chi tiết của chú hổ.

CẢNH 5 (20–25 giây): MÈO BÍ NGÔ CHIẾM LĨNH TRUNG TÂM SÂN KHẤU

Chú mèo bí ngô màu cam mũm mĩm đáng yêu bước ra giữa khoảng sân.

Chú thực hiện một điệu nhảy nhún vui nhộn, vung vẩy đôi chân nhỏ xíu từ bên này sang bên kia.

Bộ trang phục bí ngô rung rinh theo từng chuyển động.

Những chú mèo khác nhảy múa xung quanh trong khi chú hổ Frankenstein nhún nhảy theo nhịp ở hậu cảnh.

Chuyển động máy quay nhanh và tinh nghịch làm nổi bật màn vũ đạo tràn đầy năng lượng.

CẢNH 6 (25–30 giây): Điệu NHẢY HALLOWEEN KẾT MÀN

Cả năm nhân vật tụ họp lại với nhau cho điệu nhảy Halloween đồng bộ cuối cùng.

Những chú mèo thực hiện các động tác vẫy chân, bật nhảy nhỏ, lắc vai và nghiêng đầu tinh nghịch khớp nhau.

Chú hổ Frankenstein đứng phía sau, thực hiện các cử chỉ vũ đạo phong cách quái vật đầy phóng đại.

Máy quay luân phiên giữa góc quay toàn cảnh cả nhóm và góc cận cảnh chú hổ.

Video kết thúc với cảnh chú hổ Frankenstein tự tin khoanh hai cánh tay đầy lông trước ngực trong khi những chú mèo tạo dáng Halloween vô cùng đáng yêu.

MÁY QUAY & CHẤT LƯỢNG HÌNH ẢNH:

Hoạt hình động vật 3D chân thực như ảnh chụp (photorealistic), bộ lông xù chân thực, khuôn mặt động vật giàu biểu cảm, chuyển động trang phục chuẩn xác về mặt vật lý, ánh sáng ban ngày mang chất điện ảnh, màu sắc mùa thu rực rỡ, đổ bóng tự nhiên, kiến trúc Victoria chi tiết, các cú máy lia theo mượt mà, góc cận cảnh tinh nghịch, vũ đạo sôi động, biểu cảm nhân vật đáng yêu.

ÂM THANH:

Nhạc nhảy Halloween vui nhộn, sôi động với giai điệu bắt tai, hiệu ứng âm thanh ma quái hài hước, tiếng gầm gừ nhẹ của quái vật, tiếng mèo kêu meo meo vui vẻ và nhịp trống vũ đạo đồng bộ.

HƯỚNG DẪN QUAN TRỌNG:

Duy trì ngoại hình nhân vật, thiết kế trang phục, hoa văn bộ lông và tỷ lệ cơ thể giống hệt nhau xuyên suốt mọi cảnh quay. Tất cả các con vật phải luôn đứng thẳng trên hai chân sau trong khi nhảy. Các chuyển động vũ đạo phải mượt mà, nhịp nhàng và đồng bộ. Không biến đổi hình dạng nhân vật (morphing), không thừa chi, không thay đổi trang phục, không biến dạng khuôn mặt, không nhấp nháy hình (flickering), không chèn lớp chữ (text overlays), không phụ đề và không có hình mờ (watermarks).
Video GenerationCinematicSeedance+3
F@f
0
Video
Phim Ngắn Gia đình Hoạt hình 3D: Em bé Tập đi Gặp Em Sơ sinh (Video Dọc)

Một bộ phim ngắn gia đình hoạt hình 3D tỷ lệ 9:16 ấm áp lấy cảm hứng từ Pixar gồm bảy cảnh: một cậu bé tuổi tập đi tò mò trong bộ đồ ngủ kẻ sọc gặp người em sơ sinh của mình trong ngôi nhà tràn ngập ánh nắng, chơi đùa và ôm ấp chú gấu bông, và kết thúc vào ban đêm với cảnh người mẹ hôn lên trán em bé, kèm theo ghi chú phong cách và câu lệnh phủ định (negative prompt) để đảm bảo tính nhất quán. Đang thịnh hành trên X, được chia sẻ gốc bởi @Zarnab_with_Ai: https://x.com/Zarnab_with_Ai/status/2114326486870339935

Hãy tạo một bộ phim ngắn gia đình hoạt hình 3D tuyệt đẹp, siêu chi tiết với nhân vật chính là một cậu bé tuổi tập đi đáng yêu có mái tóc vàng óng bồng bềnh, đôi mắt xanh to tròn biểu cảm, đôi má ửng hồng và mặc bộ đồ ngủ kẻ sọc xanh trắng. Cậu bé tương tác đầy yêu thương với người em sơ sinh của mình trong một ngôi nhà hiện đại ấm cúng, tràn ngập ánh nắng.

Cảnh 1: Phòng khách ấm áp với ánh nắng tự nhiên dịu nhẹ chiếu qua những khung cửa sổ lớn. Người mẹ nhẹ nhàng làm các công việc nhà trong khi cậu bé tuổi tập đi tò mò bước về phía em bé sơ sinh đang ngủ bình yên trong chiếc nôi đan mềm mại.

Cảnh 2: Cận cảnh cậu bé cẩn thận nghiêng người qua thành nôi, mỉm cười ngọt ngào với em bé sơ sinh nhỏ xíu được quấn trong chiếc chăn màu xanh nhạt. Em bé khẽ cựa mình và tạo ra những biểu cảm vô cùng đáng yêu.

Cảnh 3: Người mẹ đặt em bé sơ sinh nằm an toàn vào trong chiếc cũi gỗ. Cậu anh trai nhỏ đứng bên cạnh cũi, nhìn em bằng ánh mắt trìu mến và tò mò. Ghi lại sợi dây gắn kết anh em dịu dàng bằng những góc máy cận cảnh mang đậm chất điện ảnh.

Cảnh 4: Cậu bé ngồi trên sàn nhà, tay cầm bình sữa em bé, trông vô cùng ngây thơ và đáng yêu. Ánh nắng ấm áp chiếu sáng khuôn mặt cậu, làm nổi bật đôi mắt giàu biểu cảm và mái tóc vàng mềm mại.

Cảnh 5: Cậu bé nhẹ nhàng chơi đồ chơi bên cạnh em sơ sinh trên tấm thảm phòng khách êm ái trong khi người mẹ ngồi trên ghế sofa âu yếm dõi theo.

Cảnh 6: Cậu bé ôm chặt một chú gấu bông dễ thương và mỉm cười hạnh phúc. Sử dụng góc máy cận cảnh để nhấn mạnh biểu cảm vui sướng của cậu bé và các chi tiết hoạt hình chân thực.

Cảnh Cuối: Vào ban đêm, người mẹ ngồi thoải mái trên giường cùng cả hai con. Cô âu yếm ôm em bé sơ sinh vào lòng trong khi cậu anh trai ngồi sát bên cạnh mẹ. Kết thúc bằng góc máy cận cảnh dịu dàng khi người mẹ hôn lên trán em bé trong khoảnh khắc gia đình bình yên, tràn ngập yêu thương.

Phong cách Hình ảnh: Hoạt hình 3D cao cấp lấy cảm hứng từ Pixar, thiết kế nhân vật đáng yêu, kết cấu vải chân thực, từng sợi tóc chi tiết, hoạt ảnh biểu cảm khuôn mặt sống động, ánh sáng tổng thể (global illumination) dịu nhẹ, ánh sáng điện ảnh ấm áp, đổ bóng tự nhiên, chuyển động máy quay mượt mà, độ sâu trường ảnh nông, cách kể chuyện chạm đến cảm xúc, ngoại hình nhân vật nhất quán xuyên suốt tất cả các cảnh, chất lượng 4K, tỷ lệ khung hình dọc 9:16.

Negative Prompt: Khuôn mặt biến dạng, thiết kế nhân vật không nhất quán, thừa ngón tay, chuyển động thiếu tự nhiên, hình ảnh nhấp nháy (flickering), kết cấu mờ nhòe, bàn tay dị dạng, trang phục thay đổi đột ngột, tỷ lệ cơ thể phi thực tế, văn bản, logo và hình mờ (watermarks).
Video GenerationStorytellingSeedance+2
F@f
0
Ảnh Thẻ Cabinet Halloween Thời Victoria (khoảng năm 1890)
Hình ảnh
Ảnh Thẻ Cabinet Halloween Thời Victoria (khoảng năm 1890)

Một tấm ảnh thẻ cabinet thập niên 1890 trông như thật chụp ba đứa trẻ trong trang phục Halloween tự làm (một phù thủy cầm chổi, Bá tước Dracula và một con ma trùm khăn trải giường) tạo dáng trang nghiêm trong một studio thời Victoria giữa những quả bí ngô khắc mặt quỷ, mạng nhện, một con mèo đen và những chân nến kiến trúc gothic, được tái hiện dưới dạng bản in albumen màu nâu đỏ (sepia) nhuốm màu thời gian trên tấm bìa dập nổi, tỷ lệ 3:4. Đang thịnh hành trên X, được chia sẻ gốc bởi @minuitIA: https://x.com/minuitIA/status/2114338847655932352

Ảnh chụp thẻ cabinet (cabinet card) Halloween thời đại Victoria nguyên bản, khoảng năm 1890, ba đứa trẻ tạo dáng trang nghiêm trong một studio nhiếp ảnh cổ điển, một bé gái hóa trang thành phù thủy thời Victoria mặc chiếc váy ren đen cầu kỳ và đội mũ chóp nhọn, tay cầm cây chổi gỗ kiểu xưa, một bé trai hóa trang thành Bá tước Dracula với áo choàng tối màu thanh lịch và cổ áo dựng cao, cùng một đứa trẻ bí ẩn hóa trang thành con ma bên dưới tấm vải trải giường màu trắng rủ xuống có khoét hai lỗ mắt. Xung quanh là những quả bí ngô khắc mặt quỷ (jack-o'-lantern), mạng nhện mỏng manh, một bộ xương người cổ, một con mèo đen và những chân nến phong cách gothic. Phông nền studio thời Victoria cầu kỳ, bố cục mang tính sân khấu, bầu không khí ám ảnh nhưng đầy cuốn hút, nét thẩm mỹ kinh dị gothic tinh tế, quần áo thế kỷ 19 nguyên bản và trang phục hóa trang tự làm bằng tay, khuôn mặt cực kỳ chân thực, kết cấu da tự nhiên, biểu cảm nghiêm nghị, bản in ảnh albumen màu nâu đỏ (sepia) nhuốm màu thời gian, hạt ảnh cổ điển, độ phai màu tinh tế, các vết trầy xước, mép giấy sờn cũ, tấm bìa cứng dập nổi hoa văn cầu kỳ với kiểu chữ trang trí, nhiếp ảnh thẻ cabinet chuẩn xác về mặt lịch sử, ánh sáng studio khuếch tán dịu nhẹ, chi tiết lưu trữ tinh xảo, chân thực như ảnh chụp (photorealistic), không có yếu tố hiện đại --ar 3:4
PhotographyPhotorealisticTrending+2
F@f
0
Đóng vai Người hướng dẫn Ngắm sao cho Người mới bắt đầu
Văn bản

Đi từ việc chỉ biết đến Mặt Trăng sang tự mình tìm thấy các ngôi sao, hành tinh và chòm sao: bản xem trước thực tế về bầu trời đêm nay, bảng thuật ngữ cho người mới bắt đầu, danh sách kiểm tra trước khi đi, 30 phút đầu tiên ngoài trời, lộ trình nhảy cóc giữa các vì sao (star-hopping), mẹo quan sát các hành tinh và Mặt Trăng, kiến thức cơ bản về ống nhòm, lập kế hoạch ngắm Dải Ngân Hà và mưa sao băng, phiên bản thân thiện với trẻ em, mẹo chụp ảnh bằng điện thoại, lời khuyên về thiết bị và kế hoạch 4 tuần.

Hãy đóng vai một Người hướng dẫn Ngắm sao cho Người mới bắt đầu. Bạn giúp những người hoàn toàn mới bắt đầu đi từ chỗ "Tôi chỉ biết mỗi Mặt Trăng" đến việc tự tin tự mình tìm thấy các ngôi sao, hành tinh, chòm sao và Dải Ngân Hà bằng chính đôi mắt và thiết bị họ đang có sẵn. Bạn suy nghĩ như một nhà thiên văn học nghiệp dư kiên nhẫn thường tổ chức các buổi ngắm sao cộng đồng: bạn bắt đầu bằng kỹ năng quan sát bằng mắt thường trước khi dùng đến thiết bị, gắn mọi gợi ý với vị trí, ngày tháng và điều kiện bầu trời của tôi, giải thích mọi thuật ngữ ngay lần đầu tiên bạn sử dụng, và trung thực về những gì có thể nhìn thấy thực tế từ thành phố so với một địa điểm bầu trời tối. Bạn không bao giờ tự bịa ra thời gian mọc chính xác, vị trí hay ngày diễn ra sự kiện; khi yếu tố thời gian là quan trọng, bạn chỉ cho tôi cách kiểm tra thông tin đó trên một ứng dụng mô phỏng bầu trời miễn phí hoặc một nguồn chính thức.

Tình huống của tôi:
- Vị trí và bầu trời: a suburb about 30 km from a large city, some light pollution to the south
- Ngày và giờ tôi dự định quan sát: a weekend evening this month, from about 9 pm to 11 pm
- Thiết bị tôi có: just my eyes, a phone, and an old pair of 8x42 binoculars
- Những gì tôi đã biết: I can find the Moon and maybe the Big Dipper
- Những gì tôi muốn xem: planets, a few bright constellations, the Milky Way, maybe a meteor shower
- Người đi cùng: my partner and a 9-year-old
- Phạm vi di chuyển để đến vùng trời tối hơn: up to 1 hour by car
- Thời tiết và mùa: autumn, nights around 8 C
- Ngân sách cho thiết bị: up to 150 for now

Trước tiên, trong một tin nhắn ngắn gọn, hãy hỏi tôi bất kỳ thông tin quan trọng nào còn thiếu: vĩ độ tương đối hoặc thành phố gần nhất của tôi, liệu tầm nhìn của tôi có bị chắn ở hướng nào không (cây cối, tòa nhà, đèn đường), pha Mặt Trăng vào đêm tôi dự định quan sát nếu tôi biết, liệu tôi có đeo kính không, và liệu có ai trong nhóm gặp khó khăn với thời tiết lạnh hoặc việc thức khuya hay không. Sau đó, hãy hướng dẫn tôi qua các phần sau:

1. **Đêm nay Tóm gọn trong Một Đoạn văn**: một bản tóm tắt ngắn gọn, thực tế về những gì một người mới bắt đầu có thể kỳ vọng nhìn thấy từ vị trí của tôi vào ngày tôi chọn, và một mục tiêu tốt nhất duy nhất để bắt đầu.

2. **Những Thuật ngữ Bầu trời Bạn Sẽ Gặp**: một bảng thuật ngữ ngắn với tên thuật ngữ và giải nghĩa dễ hiểu: chòm sao (constellation), nhóm sao (asterism), cấp sao biểu kiến (magnitude), ô nhiễm ánh sáng (light pollution), thang đo Bortle (Bortle scale), thích nghi bóng tối (dark adaptation), tầm nhìn lệch (averted vision), thiên đỉnh (zenith), đường chân trời (horizon), hoàng đạo (ecliptic), phân biệt hành tinh và ngôi sao (hiện tượng nhấp nháy), lõi Dải Ngân Hà (Milky Way core), tâm điểm mưa sao băng (meteor shower radiant), và trường nhìn (field of view).

3. **Danh sách Kiểm tra Trước Khi Đi**: cách kiểm tra thời tiết và độ che phủ mây, pha Mặt Trăng và lý do Mặt Trăng sáng làm mờ các thiên thể mờ nhạt, bản đồ ô nhiễm ánh sáng, và một ứng dụng mô phỏng bầu trời miễn phí được cài đặt theo vị trí của tôi; cùng với những thứ cần mang theo (đèn ánh sáng đỏ, nhiều lớp áo ấm, chăn hoặc ghế ngả lưng, đồ uống nóng, đồ ăn nhẹ, bản đồ sao in sẵn hoặc trên ứng dụng) và những nguyên tắc an toàn cơ bản ở khu vực trời tối.

4. **30 Phút Đầu tiên của Bạn**: quy trình từng bước: đến nơi, để mắt thích nghi với bóng tối trong 20 đến 30 phút, chỉ sử dụng ánh sáng đỏ, tìm hướng bắc hoặc hướng nam, đo khoảng cách trên bầu trời bằng bàn tay duỗi thẳng (bề ngang ngón tay, nắm đấm và bàn tay xòe rộng), và ba hình mẫu sao neo dễ nhận biết theo mùa của tôi để làm điểm xuất phát.

5. **Lộ trình Nhảy cóc Giữa các Vì sao (Star-Hopping)**: một lộ trình có hướng dẫn đi từ hình mẫu sao neo của tôi đến năm hoặc tám mục tiêu dành cho người mới bắt đầu có thể nhìn thấy trong mùa của tôi, gồm các cột: mục tiêu, phân loại (ngôi sao, hành tinh, chòm sao, cụm sao, thiên hà, tinh vân), cách tìm mục tiêu đó từ mục tiêu trước, quan sát bằng mắt thường hay ống nhòm, và hình dáng thực tế của nó sẽ trông như thế nào. Đánh dấu bất kỳ thông tin nào tôi phải xác nhận lại trên ứng dụng cho ngày cụ thể của mình.

6. **Các Hành tinh và Mặt Trăng**: cách phân biệt một hành tinh với một ngôi sao, cách kiểm tra xem hành tinh nào đang mọc vào đêm nay, hình dáng của từng hành tinh sáng khi nhìn qua ống nhòm, và những pha Mặt Trăng nào là tốt nhất để quan sát các hố va chạm dọc theo đường phân giới sáng tối (terminator line).

7. **Kiến thức Cơ bản về Ống nhòm**: cách lấy nét, cách giữ ống nhòm ổn định (tựa khuỷu tay lên đầu gối, dựa vào xe hơi hoặc ghế), ý nghĩa của thông số 8x42, và những mục tiêu quan sát bằng ống nhòm tốt nhất trong mùa của tôi.

8. **Dải Ngân Hà và Sao băng**: thời điểm và vị trí có thể nhìn thấy Dải Ngân Hà trong mùa của tôi, bầu trời cần tối đến mức nào, cách lên kế hoạch cho một đêm săn mưa sao băng (tâm điểm phát, khung giờ tốt nhất, tư thế nằm ngửa, sự kiên nhẫn), và những gì cần xác nhận trong lịch thiên văn chính thức.

9. **Phiên bản Thân thiện với Trẻ em**: ba hoạt động ngắn giúp một đứa trẻ 9 tuổi luôn hào hứng (kể chuyện về chòm sao, săn tìm vệ tinh, ghi nhật ký quan sát đơn giản kèm hình vẽ), và nên ở lại bao lâu trước khi buổi đi chơi hết vui.

10. **Chụp ảnh bằng Điện thoại Không Gây Nản lòng**: những kỳ vọng thực tế đối với chế độ chụp đêm trên điện thoại, cách sử dụng chân máy nhỏ hoặc bề mặt cố định, hẹn giờ chụp để tránh rung máy, và lý do Mặt Trăng là bức ảnh đầu tiên dễ chụp nhất.

11. **Lời khuyên về Thiết bị theo Ngân sách của Tôi**: liệu có nên mua bất cứ thứ gì ngay bây giờ không, nên mua gì trước tiên (bản đồ sao xoay - planisphere, đèn ánh sáng đỏ, kẹp điện thoại gắn ống nhòm, chân máy nhỏ) và tại sao một chiếc kính thiên văn giá rẻ thường là lựa chọn đầu tiên không tốt; khi nào việc mua một chiếc kính thiên văn nhỏ mới thực sự hợp lý về sau.

12. **Kế hoạch Học tập trong 4 Tuần**: một buổi tối ngắn mỗi tuần, mỗi buổi có một mục tiêu, hai hoặc ba thiên thể cần tìm, và một kỹ năng đơn giản để thực hành, kết thúc bằng việc có thể tự mình tìm được mười thiên thể.

Quy tắc: gắn nhãn dễ, trung bình hoặc khó cho từng mục tiêu tùy theo điều kiện bầu trời của tôi; không bao giờ đưa ra thời gian hoặc ngày tháng cụ thể như một sự thật hiển nhiên mà không nhắc tôi xác nhận lại theo vị trí của mình; tuyệt đối không gợi ý nhìn vào Mặt Trời bằng mắt thường, ống nhòm hoặc điện thoại mà không có bộ lọc Mặt Trời đạt chuẩn; tôn trọng đất tư nhân, nội quy công viên và giữ trật tự tại các điểm ngắm sao chung; sử dụng ngôn từ bình dân, đoạn văn ngắn và bảng biểu ở những chỗ hữu ích. Kết thúc bằng một mục tiêu duy nhất mà tôi nên tìm kiếm đầu tiên vào tối nay và hướng dẫn chính xác cách tìm ra nó.
PlanningChecklistBeginner+2
F@f
0
Kế hoạch Yêu cầu Hoàn tiền và Khiếu nại Hàng lỗi theo Cấp độ (JSON)
Văn bản

Mô tả sản phẩm bị lỗi hoặc dịch vụ không được cung cấp đúng cam kết và nhận về một kế hoạch JSON chặt chẽ: tóm tắt vụ việc kèm hướng khắc phục cần yêu cầu, dòng thời gian sự việc, danh sách kiểm tra bằng chứng, các thời hạn cần xác nhận chính thức, lộ trình leo thang khiếu nại từ bộ phận hỗ trợ của người bán đến tranh chấp thẻ thanh toán và cơ quan bảo vệ người tiêu dùng, bản nháp tin nhắn lịch sự cho từng bước, kịch bản gọi điện và lịch trình theo dõi tiếp theo.

1{
2 "role": "Chuyên viên tư vấn bảo vệ quyền lợi người tiêu dùng điềm tĩnh, làm việc có tổ chức, người giúp khách hàng nhận lại tiền hoàn trả, đổi sản phẩm mới hoặc sửa chữa đối với hàng hóa bị lỗi hay dịch vụ không được cung cấp đúng như cam kết, thông qua việc xây dựng hồ sơ bằng chứng rõ ràng và kế hoạch khiếu nại nâng dần cấp độ một cách lịch sự, không phóng đại, không đe dọa và không đưa ra tư vấn pháp lý",
3 "task": "Chuyển đổi thông tin mua hàng của tôi, vấn đề gặp phải và những gì đã diễn ra tính đến hiện tại thành một bản kế hoạch JSON duy nhất tuân thủ cấu trúc nghiêm ngặt: tóm tắt vụ việc kèm theo phương án khắc phục mà tôi nên yêu cầu, dòng thời gian các sự kiện, danh sách kiểm tra bằng chứng nêu rõ những gì tôi đã có và những gì còn thiếu, các mốc thời hạn có thể áp dụng và cần được xác nhận lại, lộ trình khiếu nại từng bước từ bộ phận hỗ trợ của chính người bán cho đến tranh chấp qua thẻ hoặc cổng thanh toán và các cơ quan bảo vệ người tiêu dùng chính thức, các bản nháp tin nhắn soạn sẵn cho từng bước, lịch trình theo dõi nhắc lại, và một kịch bản gọi điện thoại ngắn gọn. Tuyệt đối không tự bịa ra các điều luật, thời hạn, chính sách, mã số hồ sơ hoặc các tình tiết mà tôi không cung cấp.",
4 "context": {
5 "item_or_service": "${item:a cordless vacuum cleaner}",
6 "seller": "${seller:a large online electronics store}",
7 "purchase_date": "${purchase_date:5 weeks ago}",
8 "price_paid": "${price:249}",
9 "currency": "${currency:EUR}",
10 "payment_method": "${payment:credit card}",
...+120 dòng nữa
Personal FinancePlanningEmail+3
F@f
0
Trình Xây dựng Kế hoạch Hậu cần Khách mời Đám cưới (YAML)
Văn bản

Chuyển đổi ngày cưới, địa điểm tổ chức và danh sách khách mời của bạn thành bản kế hoạch hậu cần khách mời định dạng YAML: các trường thông tin xác nhận tham dự (RSVP) và kế hoạch nhắc phản hồi, phân nhóm khách mời, đặt giữ khối phòng khách sạn, lịch đón khách và các chuyến xe trung chuyển, bộ quà tặng chào mừng, theo dõi suất ăn và chế độ ăn kiêng, quy tắc xếp chỗ ngồi, nhu cầu hỗ trợ tiếp cận và gia đình, lịch trình truyền thông, luồng đón tiếp khách trong ngày cưới, phương án dự phòng và người phụ trách từng đầu việc.

1role: Điều phối viên hậu cần đám cưới ấm áp, tỉ mỉ, người giúp các cặp đôi và gia đình đảm bảo mọi khách mời đều được gửi thiệp mời, sắp xếp chỗ ở, đưa đón, xếp chỗ ngồi, phục vụ ăn uống và cập nhật thông tin đầy đủ đúng giờ, để kỳ nghỉ cuối tuần diễn ra đám cưới suôn sẻ và không một ai bị bỏ rơi hay lúng túng
2task: >
3 Chuyển đổi thông tin chi tiết đám cưới, tóm tắt danh sách khách mời và các địa điểm tổ chức của tôi thành một kế hoạch hậu cần khách mời hoàn chỉnh dưới định dạng YAML:
4 kế hoạch thu thập dữ liệu khách mời và xác nhận tham dự (RSVP), bảng phân nhóm khách mời, các bước sắp xếp chỗ ở và giữ khối phòng khách sạn (room block), lịch trình di chuyển
5 và xe trung chuyển (shuttle), đề cương bộ quà tặng chào mừng và cẩm nang thông tin, theo dõi suất ăn và chế độ ăn kiêng, quy tắc
6 xếp sơ đồ chỗ ngồi, nhu cầu hỗ trợ tiếp cận và gia đình, lịch trình gửi thông báo từ thiệp báo hỷ (save-the-dates) cho đến ngày
7 hôm sau đám cưới, luồng di chuyển của khách trong ngày cưới, và kế hoạch dự phòng cho thời tiết xấu, sự cố chậm trễ và khách vắng mặt.
8
9inputs:
10 wedding_date: ${wedding_date:a Saturday in mid-June}
...+112 dòng nữa
TravelPlanningYAML+3
F@f
0
Đàn Hạc Giấy Origami Bay Qua Ngôi Chùa Gấp Giấy lúc Hoàng hôn
Hình ảnh
Đàn Hạc Giấy Origami Bay Qua Ngôi Chùa Gấp Giấy lúc Hoàng hôn

Mô hình sa bàn (diorama) origami thủ công: khoảng ba mươi chú hạc giấy màu đỏ, vàng kim và trắng kem đang bay theo hình vòng cung phía trên một ngôi chùa giấy năm tầng màu trắng kem có mái hiên viền đỏ tọa lạc trên đỉnh đồi bậc thang, những cây hoa anh đào gấp giấy màu hồng, những dãy núi giấy đa diện xếp lớp, và mặt trời tròn bằng giấy phẳng màu cam lớn trên nền trời giấy màu xám be trơn.

Một mô hình sa bàn (diorama) nghệ thuật gấp giấy origami thủ công khắc họa hàng chục chú hạc giấy đang bay thành một vòng cung rộng phía trên một ngôi chùa gấp giấy năm tầng vào lúc hoàng hôn, trong bố cục khung hình ngang rộng tỷ lệ 16:9. Khoảng ba mươi chú hạc giấy origami mang sắc đỏ thẫm, vàng kim và trắng kem bay vút lên theo một đường cong mềm mại từ góc dưới bên trái, ngay sát cạnh mặt trời, hướng lên trên và vắt ngang sang góc trên bên phải, mỗi chú hạc đều có những nếp gấp sắc sảo, gọn gàng và lộ rõ vân giấy tự nhiên. Ở chính giữa, ngôi chùa tọa lạc trên phần bệ giấy xếp bậc thang có một cầu thang nhỏ ở trên đỉnh đồi, được dựng từ chất liệu giấy màu trắng kem tự nhiên với năm tầng mái hiên uốn cong lên trên có viền chỉ đỏ mảnh, hàng lan can tí hon và những ô cửa sổ nhỏ. Một cây hoa anh đào gấp giấy màu hồng nhỏ nhắn nằm ngay bên trái chân đế ngôi chùa, và một cây anh đào lớn với thân cây bằng giấy nâu xoắn lại cùng từng chùm hoa giấy màu hồng chiếm trọn phần tiền cảnh bên phải. Phía sau chúng, những dãy núi giấy tạo khối đa diện xếp chồng lên nhau mang sắc màu be, xám ấm và hồng phấn dịu nhẹ (sắc hồng nằm ở phía bên trái) lùi dần về xa theo từng sống núi gối lên nhau, cùng những ngọn đồi bằng giấy màu be vò nhăn ở tiền cảnh. Một mặt trời hình tròn bằng giấy phẳng có vân nổi màu cam lớn treo thấp ở phía bên trái nổi bật trên nền trời bằng giấy trơn màu xám be nhạt. Ánh sáng phòng chụp (studio) dịu nhẹ tạo ra những khoảng bóng đổ mềm mại giữa các lớp giấy, độ sâu trường ảnh nông, mang lại cảm giác thủ công ấm áp và yên bình. Không có chữ, không có người.
VisualDesignArt+2
F@f
0
Ảnh Chụp Chân thực Chiếc Thuyền Kayak Vàng trên Vịnh Phát quang Sinh học Dưới Dải Ngân Hà
Hình ảnh
Ảnh Chụp Chân thực Chiếc Thuyền Kayak Vàng trên Vịnh Phát quang Sinh học Dưới Dải Ngân Hà

Ảnh chụp đêm phơi sáng dài chân thực: một chiếc thuyền kayak biển màu vàng trống không nằm ở chính giữa vịnh nhiệt đới phẳng lặng, những vệt phát quang sinh học màu xanh điện xoáy tròn phía sau thuyền hướng về góc dưới bên trái, bờ rừng ngập mặn tối sẫm ở bên trái, Dải Ngân Hà uốn cong phía trên góc phải, và một vài đốm sáng nhỏ xíu ở đường chân trời xa xa.

Một bức ảnh chụp ban đêm chân thực khắc họa một chiếc thuyền kayak biển màu vàng trống không đang trôi nổi trên vịnh nhiệt đới phẳng lặng phát sáng rực rỡ bởi các sinh vật phù du phát quang sinh học màu xanh điện (electric-blue), ngay bên dưới Dải Ngân Hà, trong khung hình ngang rộng tỷ lệ 16:9. Chiếc thuyền kayak dài màu vàng nằm ở chính giữa khung hình, mũi thuyền chếch về phía góc trên bên phải, với các nắp hầm boong tàu hình tròn màu đen, khoang ngồi mở tối màu và các sợi dây chằng mảnh trên boong; không có mái chèo và không có người. Phía sau và xung quanh chiếc thuyền, những vệt sáng phát quang màu xanh điện rực rỡ xoáy tròn và lan tỏa khắp mặt nước tối sẫm hướng về phía góc dưới bên trái, tựa như những dải ruy băng ánh sáng được khuấy lên dọc theo đường thuyền trôi qua. Bóng đen của hàng cây rừng ngập mặn chạy dọc theo bờ biển bên trái, bộ rễ chằng chịt của chúng lộ ra ngay trên ngấn nước, thuôn nhỏ dần về phía đường chân trời thấp gần chính giữa bức ảnh. Dải Ngân Hà uốn vòng cung qua góc trên bên phải của bầu trời với những dải bụi vũ trụ mềm mại và ánh sáng dịu nhẹ, nổi bật trên nền trời đêm xanh tím than thẫm dày đặc những vì sao nhỏ li ti. Mặt nước phẳng lặng như gương và tối thẫm, in bóng những ánh sao mờ ảo cùng những gợn sóng lăn tăn dịu nhẹ, và một vài đốm sáng ấm áp nhỏ xíu lấp lánh nơi đường chân trời xa tắp ở phía bên phải. Phong cách nhiếp ảnh thiên văn phơi sáng dài, ống kính góc rộng, chi tiết sắc nét, màu sắc tự nhiên, bầu không khí tĩnh lặng và kỳ diệu. Không có người, không có chữ.
TravelPhotographyVisual+2
F@f
0
Trang Bản thảo Minh họa Thời Trung cổ Vẽ Người Nuôi ong Chăm sóc Giỏ Tổ ong bằng Rơm
Hình ảnh
Trang Bản thảo Minh họa Thời Trung cổ Vẽ Người Nuôi ong Chăm sóc Giỏ Tổ ong bằng Rơm

Trang bản thảo minh họa thời Trung cổ trên giấy da cừu (vellum) nhuốm màu thời gian: một chữ cái đầu đoạn lớn sơn màu xanh lam, đỏ và vàng kim cùng các khối chữ viết tay kiểu Gothic (blackletter) không đọc được ở bên trái, một bức tiểu họa đóng khung vàng vẽ người nuôi ong đội mũ trùm đầu cầm que xông khói đang chăm sóc ba giỏ tổ ong bằng rơm trong khu vườn có tường bao quanh cùng cây táo, hoa hồng, hoa loa kèn và những chú ong vàng, nằm bên trong đường viền dây leo vàng và lá ô rô đính kèm những chú ốc sên nhỏ.

Một trang bản thảo minh họa thời Trung cổ trên nền giấy da cừu (vellum) cũ kỹ, ố màu thời gian, khắc họa cảnh một người nuôi ong đội mũ trùm đầu đang chăm sóc ba chiếc giỏ tổ ong (skep) đan bằng rơm trong một khu vườn có tường bao quanh, ở góc nhìn trang giấy ngang rộng tỷ lệ 16:9. Ở phía bên trái, một chữ cái đầu đoạn lớn trang trí cầu kỳ ở góc trên bên trái được vẽ bằng màu xanh ngọc lưu ly (lapis blue), đỏ son (vermilion red) và vàng kim đánh bóng với các họa tiết hoa lá nhỏ bên trong, theo sau là những khối chữ viết tay kiểu chữ Gothic (blackletter) dày đặc, không thể đọc thành chữ bằng mực nâu sẫm, với hai dòng cuối cùng được viết bằng mực đỏ. Ở phía bên phải, một bức tiểu họa đóng khung vàng kim miêu tả một người nuôi ong mặc áo choàng dài màu nâu có mũ trùm kín mặt, đang cúi người về phía trước và cầm một chiếc que xông khói mảnh hướng về phía ba chiếc giỏ tổ ong bằng rơm hình mái vòm có lối vào nhỏ tối màu, đặt trên thảm cỏ xanh điểm xuyết những bông hoa nhỏ màu vàng. Phía sau người đó là bức tường vườn bằng đá màu xám có răng cưa trên đỉnh, một cây táo trĩu quả màu đỏ pha vàng, những bụi hoa hồng sắc hồng và đỏ, những cành hoa loa kèn trắng vươn cao, cùng những cụm hoa màu xanh lam giống hoa phi yến (delphinium), với những chú ong nhỏ màu vàng kim đang bay lượn trên không trung. Toàn bộ trang giấy được bao quanh bởi phần khung viền trang trí công phu gồm những nhánh dây leo bằng vàng uốn lượn điểm lá ô rô (acanthus) màu xanh lam và đỏ, cùng những chú ốc sên nhỏ được vẽ đậu ở góc dưới bên trái và ở mép phải. Phong cách hội họa Gothic phẳng, đường viền mực mảnh sắc nét, bảng màu xanh ngọc lưu ly, đỏ son và vàng kim rực rỡ, các mép giấy sờn nhẹ tự nhiên. Không có chữ viết đọc được, không có chi tiết hiện đại.
VisualArtVintage+2
F@f
0
Trình Xây dựng Bản mô tả Ý tưởng Cảnh Kênh đào Cối xay gió Hà Lan
Văn bản

Xây dựng bản mô tả chi tiết sẵn sàng tạo ảnh và tạo chuyển động cho một cảnh quay chủ đạo (hero shot) về những chiếc cối xay gió bằng gạch dọc theo con kênh vùng đất lấn biển Hà Lan thẳng tắp, êm đềm lúc bình minh: cối xay gió, bóng phản chiếu trên kênh, hai bên bờ và chiếc thuyền chèo, chiều sâu phong cảnh, ánh sáng, các chi tiết cố định, sơ đồ chuyển động và hai câu lệnh (prompt) gốc: một ảnh tĩnh tỷ lệ 16:9 và một hoạt ảnh chuyển từ ảnh sang video.

Hãy đóng vai một nhà thiết kế bối cảnh phong cảnh điện ảnh chuyên viết các bản mô tả cảnh quay (scene brief) sẵn sàng để tạo ảnh tĩnh và tạo chuyển động video. Hãy giúp tôi thiết kế một cảnh quay chủ đạo (hero shot) khắc họa những chiếc cối xay gió truyền thống bằng gạch đứng dọc theo một con kênh vùng đất lấn biển (polder) Hà Lan thẳng tắp, êm đềm lúc bình minh. Cảnh này trước tiên sẽ được tạo ra dưới dạng một bức ảnh tĩnh và sau đó được làm hoạt ảnh thành một đoạn video ngắn sử dụng chính bức ảnh đó làm khung hình đầu tiên. Bản mô tả của bạn phải làm cho bức ảnh tĩnh giàu các yếu tố có thể chuyển động một cách tự nhiên (cánh buồm cối xay gió quay chậm rãi, bóng phản chiếu như gương lấp lánh trên mặt kênh, lau sậy đung đưa nơi mép nước, làn sương sớm là là trên đồng cỏ, ánh bình minh dịu nhẹ) trong khi vẫn giữ cho bố cục tĩnh tại, rõ ràng và dễ làm hoạt ảnh.

Các lựa chọn ban đầu của tôi:
- Cối xay gió: five round tapered brick windmills with dark caps and wooden lattice sails, standing in a row along one bank
- Kênh đào: a long straight canal with still, mirror-like water receding to a vanishing point
- Hai bên bờ: grass and reeds on both sides, a narrow dirt path along a grassy dike on the opposite bank
- Chi tiết tiền cảnh: a small dark wooden rowboat moored at the water's edge
- Phong cảnh: flat green meadows with low mist and a distant line of trees
- Thời gian và ánh sáng: sunrise, soft pink, lilac, and gold sky with a warm glow near the horizon
- Phong cách hình ảnh: photoreal landscape photography, natural colors
- Các từ chỉ cảm xúc (Mood): peaceful, quiet, timeless, fresh

Trước tiên, hãy hỏi tôi trong một tin nhắn ngắn gọn về bất kỳ thông tin nào còn thiếu: vị trí đặt máy quay mà tôi muốn (thấp sát mép nước, ngang tầm mắt người đứng trên bờ, hay hơi cao hơn một chút), các cối xay gió nên đứng ở bờ bên nào, cối xay gió gần nhất nên nằm gần đến mức nào, quầng sáng bình minh nên đặt ở đâu, tôi muốn độ dày của sương mù ra sao, và những thứ cần tránh xuất hiện (con người, người đi xe đạp, thuyền đang chạy, ô tô, tua-bin gió hiện đại, đường dây điện, biển báo, chữ viết, logo, hoặc việc sao chép lại một bức ảnh chụp có thật cụ thể).

Sau đó, hãy lập bản mô tả bao gồm các phần sau:

1. **Tổng quan Cảnh quay (Scene Overview)**: hai hoặc ba câu mô tả địa điểm, thời gian, ánh sáng và cảm xúc của khoảnh khắc đó. Hãy mô tả một khung cảnh vùng đất lấn biển tưởng tượng mang đậm tinh thần Hà Lan, chứ không phải bản sao của một bức ảnh cụ thể hay một địa danh có tên thật.

2. **Cối xay gió (Windmills)**: có bao nhiêu cối xay gió, chúng đứng ở bờ nào, kích thước của chiếc gần nhất trong khung hình, phần thân gạch, chóp mái, các ô cửa sổ nhỏ, cánh buồm mắt cáo và góc nghiêng của cánh buồm, cấu trúc đuôi gỗ hoặc khung cột chống, và cách ánh sáng góc thấp chiếu lên chúng.

3. **Kênh đào và Bóng phản chiếu (Canal and Reflections)**: đường dẫn của con kênh hướng về điểm tụ, độ tĩnh lặng của mặt nước, những hình khối nào được phản chiếu xuống mặt nước, và màu sắc bầu trời hiện lên dưới làn nước ra sao.

4. **Hai bên bờ và Tiền cảnh (Banks and Foreground)**: đám lau sậy và thảm cỏ ở mỗi bên bờ, lối mòn trên đê, chiếc thuyền chèo đang neo đậu cùng sợi dây thừng, và bề mặt chất liệu của những bụi cây gần ống kính nhất.

5. **Phong cảnh và Chiều sâu (Landscape and Depth)**: các lớp chiều sâu không gian tính từ chiếc thuyền và bụi lau sậy ở tiền cảnh đến chiếc cối xay gió gần nhất, hàng cối xay gió lùi dần ở phía sau, những cánh đồng cỏ, cho tới hàng cây xa tắp nơi chân trời, cùng cách làn sương mù làm dịu đi từng lớp cảnh.

6. **Ánh sáng và Bầu trời (Light and Sky)**: vị trí của quầng sáng bình minh, dải chuyển màu (gradient) của bầu trời từ tím tử đinh hương sang hồng rồi vàng kim, hình dáng của những dải mây mỏng, và độ mềm mại của bóng đổ.

7. **Các Chi tiết Hình ảnh Cố định (Fixed Visual Details)**: một danh sách đánh số từ 8 đến 10 chi tiết bắt buộc phải giữ nguyên giống hệt nhau giữa bức ảnh tĩnh và đoạn video (số lượng và bờ đặt cối xay gió, vị trí và góc cánh buồm của cối xay gió gần nhất, đường kẻ của con kênh, vị trí thuyền chèo, lối mòn trên đê, đường chân trời, vị trí quầng sáng, bố cục mây, vị trí máy quay, khung hình).

8. **Sơ đồ Chuyển động (Motion Map)**: một bảng liệt kê mọi yếu tố có thể chuyển động trong video, bao gồm các cột: yếu tố, kiểu chuyển động, hướng, tốc độ (rất chậm, chậm, trung bình), và cường độ chuyển động. Bao gồm cánh buồm của từng cối xay gió, bóng phản chiếu, lau sậy, sương mù, ánh sáng, chiếc thuyền chèo và chuyển động của máy quay. Giữ cho tổng thể chuyển động tinh tế và hợp lý về mặt vật lý, thống nhất một hướng gió duy nhất và tất cả các cánh buồm đều quay cùng một chiều.

9. **Prompt Gốc A - Ảnh Tĩnh Chủ đạo (Prompt Seed A - Hero Still Image)**: một câu lệnh tạo ảnh hoàn chỉnh sẵn sàng sao chép và dán, dài dưới 170 từ cho khung hình ngang rộng tỷ lệ 16:9, nêu bật hình ảnh những chiếc cối xay gió, con kênh và ánh bình minh ngay trong câu đầu tiên, kết thúc bằng cụm từ: không có người, không có chữ, không có logo (no people, no text, no logos).

10. **Prompt Gốc B - Hoạt ảnh từ Ảnh sang Video (Prompt Seed B - Image-to-Video Animation)**: một câu lệnh tạo video hoàn chỉnh sẵn sàng sao chép và dán, dài dưới 120 từ, bắt đầu từ bức ảnh tĩnh chủ đạo làm khung hình đầu tiên, mô tả cánh buồm quay chậm rãi, bóng phản chiếu lấp lánh, lau sậy đung đưa, một chuyển động máy quay đẩy nhẹ (push-in) cực kỳ chậm dọc theo con kênh trong khoảng 6 giây, và yêu cầu giữ nguyên các cối xay gió, con kênh, hai bên bờ, chiếc thuyền cùng khung hình giống hệt như trong ảnh mà không sinh thêm vật thể mới.

Quy tắc: không có người, không có động vật đòi hỏi chuyển động phức tạp, không có phương tiện giao thông hay tua-bin hiện đại; không có chữ viết, biển báo hay logo; không chuyển động máy quay nhanh hoặc cắt cảnh; và không có chuyển động nào vi phạm quy luật vật lý (cánh buồm quay quanh trục giữa mà không bị biến dạng, công trình kiến trúc không bao giờ dịch chuyển, bóng phản chiếu đi theo đúng vật thể phía trên, ánh sáng không bị nhảy đột ngột). Hãy trình bày bằng các đoạn văn ngắn và danh sách gạch đầu dòng.
TravelPlanningImage To Video+2
F@f
0
Năm Cối xay gió bằng Gạch Dọc theo Kênh đào Hà Lan Mờ sương lúc Bình minh
Hình ảnh
Năm Cối xay gió bằng Gạch Dọc theo Kênh đào Hà Lan Mờ sương lúc Bình minh

Phong cảnh bình minh chân thực như ảnh chụp: năm cối xay gió bằng gạch với chóp tối màu và cánh buồm mắt cáo xếp thành một hàng dọc bờ trái của con kênh vùng đất lấn biển (polder) thẳng tắp, phẳng lặng như gương, chiếc gần nhất hiện lên to lớn ở bên trái kèm khung gỗ chống đuôi, một chiếc thuyền chèo nhỏ bằng gỗ buộc neo ở góc dưới bên trái, lối mòn trên đê cỏ cùng lau sậy ở bên phải, làn sương sớm là là trên đồng cỏ, và bầu trời sắc hồng, tím tử đinh hương pha vàng kim. Bước 2 trong quy trình Kênh đào Cối xay gió Hà Lan.

Năm cối xay gió truyền thống bằng gạch đứng thành một hàng dọc theo bờ bên trái của một con kênh vùng đất lấn biển (polder) Hà Lan dài, thẳng tắp và phẳng lặng lúc bình minh, nhìn từ góc máy thấp ngay sát mép nước, trong khung hình ngang rộng tỷ lệ 16:9. Cối xay gió gần nhất hiện lên to lớn ở một phần ba bên trái khung hình: phần thân xây bằng gạch nâu dáng tròn thuôn dần lên trên với những ô cửa sổ nhỏ, phần chóp mái tối màu, và bốn cánh buồm khung gỗ mắt cáo xếp thành hình chữ X nghiêng, cùng phần đuôi gỗ dài và các cột chống xiên xuống mặt đất ở phía bên trái của nó. Bốn cối xay gió còn lại lùi dần dọc theo cùng bờ bên trái hướng về điểm tụ gần chính giữa khung hình, mỗi chiếc nhỏ dần và mờ ảo hơn trong làn sương. Con kênh chạy thẳng tắp về phía xa với mặt nước phẳng như gương phản chiếu hình ảnh những chiếc cối xay gió và bầu trời. Ở góc dưới bên trái, một chiếc thuyền chèo nhỏ bằng gỗ sẫm màu được neo đậu giữa đám lau sậy xanh mướt, buộc bằng một sợi dây thừng. Ở bờ bên phải, một con đê phủ cỏ với những bụi lau sậy cao dọc mép nước và một lối mòn nhỏ bằng đất chạy dài về phía xa, cạnh một đồng cỏ bằng phẳng phủ làn sương sớm là là và một hàng cây mờ nhạt ở tận chân trời. Bầu trời rực sáng sắc tím tử đinh hương, hồng và vàng kim dịu nhẹ cùng những dải mây mỏng kéo thành vệt, sáng rực nhất ở gần đường chân trời ngay chếch về bên phải tâm ảnh. Nhiếp ảnh phong cảnh chân thực, ống kính góc rộng, màu sắc tự nhiên, bầu không khí thanh bình. Không có người, không có chữ, không có logo.
TravelWorkflowPhotography+3
F@f
0
Video
Kênh đào Cối xay gió Hà Lan lúc Bình minh (Hoạt ảnh từ Ảnh sang Video)

Tạo chuyển động cho bức ảnh kênh đào cối xay gió Hà Lan ở bước 2 làm khung hình đầu tiên thành một cảnh quay êm đềm dài 6 giây: cánh buồm mắt cáo của cối xay gió gần nhất quay rõ ràng trong khi những cánh buồm ở xa cũng quay theo, máy quay tiến chậm rãi dọc theo kênh đào, bóng phản chiếu lấp lánh, lau sậy đung đưa, chiếc thuyền chèo buộc neo đứng yên, và ánh bình minh sắc hồng pha vàng dịu nhẹ giữ nguyên. Bước 3 trong quy trình Kênh đào Cối xay gió Hà Lan.

Bắt đầu từ bức ảnh ở bước 2 "Năm Cối xay gió bằng Gạch Dọc theo Kênh đào Hà Lan Mờ sương lúc Bình minh" làm khung hình đầu tiên chính xác, và tạo chuyển động cho nó thành một cảnh quay điện ảnh êm đềm dài 6 giây. Giữ nguyên năm cối xay gió bằng gạch ở bờ bên trái, cối xay gió lớn gần nhất ở một phần ba bên trái với khung gỗ chống đuôi, con kênh chạy thẳng tắp, chiếc thuyền chèo buộc neo ở góc dưới bên trái, lối mòn trên đê cỏ và đám lau sậy ở bờ bên phải, đồng cỏ mờ sương, đường chân trời và bố cục giống hệt như trong ảnh. Trong làn gió nhẹ, những cánh buồm mắt cáo của cối xay gió gần nhất quay chậm rãi và rõ nét quanh trục giữa, và những cánh buồm của các cối xay gió phía xa cũng quay theo, tất cả cùng quay theo một chiều. Máy quay thực hiện chuyển động đẩy nhẹ (push-in) cực kỳ chậm rãi và ổn định dọc theo con kênh hướng về phía điểm tụ. Bóng phản chiếu như gương trên mặt kênh lấp lánh dịu nhẹ, những bụi lau sậy dọc hai bên bờ đung đưa khe khẽ, và chiếc thuyền chèo vẫn đứng yên tại chỗ neo. Ánh sáng giữ nguyên sắc hồng và vàng dịu nhẹ của buổi bình minh. Chuyển động tự nhiên, chân thực, không cắt cảnh, không có người, không xuất hiện thêm tòa nhà hay vật thể mới, không có chữ. Âm thanh dịu êm của làn gió nhẹ, tiếng cánh buồm gỗ kẽo kẹt và tiếng chim hót từ xa.
TravelVideo GenerationImage To Video+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
Ứng dụng SvelteKit taste
Văn bản

Các quy ước giàu tính định hướng để xây dựng ứng dụng SvelteKit full-stack với Svelte 5 runes và TypeScript.

# Gu thiết kế (taste) Ứng dụng SvelteKit của một lập trình viên web full-stack
- Bắt đầu mọi dự án bằng khuôn mẫu (template) chính thức `sv create`, bật sẵn TypeScript và kích hoạt chế độ nghiêm ngặt (strict mode). Độ tin cậy: 0.90
- Sử dụng Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`) cho tất cả các component mới thay vì các câu lệnh phản ứng (reactive statements) kiểu cũ. Độ tin cậy: 0.90
- Tìm đến `$derived` trước khi nghĩ tới `$effect`, và chỉ giữ `$effect` cho việc đồng bộ hóa với những thứ nằm ngoài Svelte như DOM hoặc các thư viện bên thứ ba. Độ tin cậy: 0.90
- Tải dữ liệu trong `+page.server.ts` khi cần dùng tới thông tin bí mật (secrets) hoặc cơ sở dữ liệu, và chỉ sử dụng `+page.ts` dùng chung (universal) cho các lệnh gọi lấy dữ liệu công khai. Độ tin cậy: 0.90
- Xử lý các thao tác thay đổi dữ liệu (mutations) bằng form actions và `use:enhance` để biểu mẫu vẫn hoạt động được khi không có JavaScript và tự động hưởng lợi từ khả năng nâng cấp lũy tiến (progressive enhancement). Độ tin cậy: 0.85
- Xác thực dữ liệu đầu vào của biểu mẫu và API ở phía máy chủ bằng Zod hoặc Valibot, đồng thời trả về `fail()` kèm lỗi chi tiết từng trường thay vì ném ngoại lệ (throw). Độ tin cậy: 0.90
- Giữ mã nguồn chỉ chạy trên máy chủ trong thư mục `$lib/server` để quá trình build lập tức báo lỗi nếu mã đó vô tình bị import vào phía máy khách. Độ tin cậy: 0.95
- Đọc các thông tin bí mật từ `$env/static/private` hoặc `$env/dynamic/private`, tuyệt đối không đọc từ các module biến môi trường công khai. Độ tin cậy: 0.95
- Đặt logic xác thực và tra cứu phiên làm việc (session) trong `hooks.server.ts` và cung cấp thông tin người dùng thông qua `event.locals`. Độ tin cậy: 0.90
- Tuyệt đối không lưu trữ dữ liệu người dùng của từng yêu cầu (per-request) vào biến trạng thái ở cấp độ module, vì nó sẽ làm rò rỉ dữ liệu giữa các yêu cầu khác nhau trên máy chủ. Độ tin cậy: 0.95
- Chia sẻ trạng thái phía máy khách thông qua context hoặc các module nhỏ dựa trên rune trong các file `.svelte.ts`, thay vì dùng một store toàn cục cho mọi thứ. Độ tin cậy: 0.80
- Sử dụng các nhóm route như `(app)` và `(marketing)` để áp dụng các bố cục (layouts) khác nhau cho từng phân khu mà không làm thay đổi đường dẫn URL. Độ tin cậy: 0.80
- Kết xuất trước (prerender) các trang tĩnh như trang tài liệu và tiếp thị, đồng thời giữ các trang động được kết xuất từ phía máy chủ (server-rendered). Độ tin cậy: 0.85
- Chọn một bộ chuyển đổi (adapter) tường minh (`adapter-node`, `adapter-vercel`, `adapter-cloudflare`) thay vì phụ thuộc vào `adapter-auto` trên môi trường production. Độ tin cậy: 0.80
- Trả về dữ liệu có kiểu rõ ràng từ các hàm `load` và dựa vào `./$types` được sinh tự động thay vì tự viết kiểu cho prop bằng tay. Độ tin cậy: 0.90
- Sử dụng các trang `+error.svelte` và hàm trợ giúp `error()` cho những lỗi nằm trong dự kiến, và ghi nhật ký các lỗi bất ngờ trong `handleError`. Độ tin cậy: 0.85
- Định kiểu giao diện bằng CSS có phạm vi cục bộ theo component (scoped component CSS) làm mặc định và chỉ thêm Tailwind khi nhóm phát triển đã quen sử dụng nó. Độ tin cậy: 0.70
- Giữ cho các component nhỏ gọn và truyền các `snippets` cho phần nội dung linh hoạt thay vì dùng quá nhiều prop kiểu boolean. Độ tin cậy: 0.80
- Kiểm thử các component bằng Vitest và `@testing-library/svelte`, đồng thời bao phủ các luồng người dùng chính bằng Playwright. Độ tin cậy: 0.85
- Chạy `svelte-check`, ESLint và Prettier kèm plugin Svelte trong quy trình CI trên mọi pull request. Độ tin cậy: 0.85
Web DevelopmentBest PracticesFrontend+1
F@f
0
Previous17 / 19Next