Audit #014 — Đối chiếu tài liệu với mã nguồn (2026-09-08, commit 8b6c66ba)
Chỉ đạo SRC-688: rà toàn bộ tài liệu và mã nguồn xem có khớp nhau không, đồng bộ, rồi tìm tính năng còn thiếu hoặc đang lỗi, đưa vào Audit Report, sau đó fix và nâng cấp.
Bản này không chấm lại 255 chỉ báo của Audit Standard v0.2 — lần chấm toàn hệ gần nhất là Audit #013 cách đây năm ngày, và chấm lại không đổi được gì khi phát hiện chưa sửa. Mỗi dòng dưới đây nối về chỉ báo AS tương ứng.
Kết quả một dòng: hệ thống lành hơn nhiều so với Audit #013 — 8/9 cổng xanh ngay từ lần chạy đầu, 1.539 test pass, 0 lỗi typecheck. Nhưng chỗ hỏng tìm được lại nằm đúng ở chính công cụ đo: bộ sinh docs/reference/api.md nuốt mất 16 route có thật, và hai cổng tài liệu có điểm mù khiến loại trôi này không thể bị bắt. Tám phát hiện, đã sửa và kiểm chứng cả tám trong phiên.
Phát hiện thứ bảy đến từ chính lần push của audit này: CI đỏ, và nguyên nhân là công cụ cấp số SRC ghi ngày theo UTC trong khi git ghi theo giờ máy — một điểm mù mà không cổng nào bắt được trước lúc commit, vì phép đo cần chính commit đó mới tồn tại (xem F-7).
1. Phương pháp và phạm vi
| Bước | Cách làm | Kết quả |
|---|---|---|
| Chốt phạm vi | main @ 8b6c66ba (2026-09-08), cây làm việc dùng chung | 12 app · 3 worker · 207 migration · 143 docs kiểm · 461 docs có last_reviewed |
| Máy chạy trước | check:docs · check:code · typecheck · lint · test | docs ✓ · code ✓ · typecheck ✓ · lint ⚠ 1 cảnh báo (F-4) · test ✓ 1.539 pass |
| Cổng ít khi chạy | audit:bounds · contract:diff · verify:bindings · migrate:dryrun · check:ui-latest | bindings ✓ · migration ✓ 207/207 · contract ⚠ snapshot lệch 16 route (F-2) · ui-latest ✗ 8 chỗ tụt bản (F-3) · bounds ✗ (đã biết, REQ-KNW-17 — xem §4) |
| Đối chiếu docs ↔ code | Sinh lại mọi tài liệu dẫn xuất (gen:knowledge · gen:reference · gen:playbooks) rồi xem git status | 1 file trôi thật (F-5); phát hiện lệch 313 vs 329 endpoint dẫn tới F-1 |
| Đối chiếu số liệu docs | Script rời: dòng meta **Unit** của 61 package so với số unit thật trên trang module | 0 lệch — nhưng không cổng nào đang giữ (F-6) |
Điểm mấu chốt của phương pháp: sinh lại rồi so git status là cách rẻ nhất để bắt tài liệu dẫn xuất trôi khỏi nguồn. Nó tìm ra F-5 trong một lệnh. Còn F-1 thì git status KHÔNG bắt được, vì file sinh ra vẫn khớp với chính nó — chỉ khi đặt hai bộ đếm độc lập cạnh nhau (gen:reference báo 313 endpoint, contract:diff báo 329 route) mới lộ ra khoảng cách 16.
2. Phát hiện
| # | Mức | Phát hiện | Chỉ báo AS | Trạng thái |
|---|---|---|---|---|
| F-1 | 🔴 | docs/reference/api.md thiếu 16 route có thật. Bộ sinh không nở được template literal khi biến đường dẫn là tham số của hàm tạo route; 19 endpoint coral bị thu thành 3 dòng vô nghĩa in ra đúng chữ ${kind} / ${path} | AS-01.4.2 | ✅ đã sửa |
| F-2 | 🟡 | Snapshot hợp đồng OpenAPI lệch 16 route (email admin + coral authoring). Không breaking, nhưng snapshot lệch thì lần sau có breaking change thật cũng không so được | AS-01.4.2 | ✅ đã sửa |
| F-3 | 🟡 | motion tụt bản ở cả 8 app React (13.1.1 → 13.2.0), vi phạm DS-001 §0 | AS-06 | ✅ đã sửa |
| F-4 | ⚪ | Chỉ thị eslint-disable no-console thừa trong apps/marlins/src/parentLessons.test.ts | AS-06 | ✅ đã sửa |
| F-5 | 🟡 | docs/knowledge/english.md trôi khỏi nguồn: file trên main khai 1.320 câu trên 64 node, cây kiến thức thật đã có 2.868 câu trên 322 node kể từ lúc nạp 12 khoá IELTS | AS-01.4.1 | ✅ đã sửa |
| F-6 | 🟡 | Hai cổng tài liệu có điểm mù khiến F-1 và loại trôi của F-5 không thể bị bắt tự động | AS-01.1 | ✅ đã bịt |
| F-7 | 🟡 | src-new.mjs cấp SRC với last_reviewed lùi một ngày trong khung 00:00–07:00 giờ Việt Nam: dùng ngày UTC, còn git ghi ngày commit theo giờ local. CI đỏ chắc chắn ở lần push kế | AS-01.1 | ✅ đã sửa |
| F-8 | ⚪ | Ba file docs/reference/* bị đóng dấu thời điểm chạy bộ sinh, nên bước CI "sinh lại và bắt buộc không lệch" kêu ở mọi push sang ngày mới dù không route nào đổi. Cùng gốc UTC với F-7 | AS-01.4.1 | ✅ đã sửa |
F-1 — Catalog API thiếu 16 route (nặng nhất)
scripts/gen-reference.mjs đã có hàm expandTemplatePath để nở đường dẫn viết bằng template literal, nhưng nó chỉ hiểu một dạng: biến chạy trong vòng lặp for (const [kind, …] of […] as const). Module coral dùng dạng thứ hai — hàm tạo route nhận đường dẫn qua tham số:
const foundryGet = (path: string, …) =>
coralRouter.openapi(createRoute({ method: "get", path: `/v1/coral/foundry${path}` … }));
foundryGet("/courses", …); // 10 chỗ gọi
foundryPost("/runs", …); // 7 chỗ gọi
listRouteFor("pains", …); // 2 chỗ gọiGiá trị không nằm quanh chỗ khai báo route mà nằm ở các chỗ gọi. Không suy được thì bộ sinh giữ nguyên template, nên catalog công khai in ra ba dòng:
| PUT | `/v1/coral/courses/{courseId}/${kind}` | 🔐 role `staff` |
| GET | `/v1/coral/foundry${path}` | 🔐 role `staff` |
| POST | `/v1/coral/foundry${path}` | 🔐 role `staff` |Vì sao đây là 🔴 chứ không phải lỗi hình thức. api.md là tài liệu người và máy đọc để biết platform có endpoint gì. Ba dòng này không chỉ xấu — chúng đứng thay cho 19 route có thật, nên người đọc catalog kết luận sai rằng 16 endpoint kia không tồn tại. Đúng loại sai mà AS-01.4.2 sinh ra để chặn: route có thật nhưng vắng mặt trong api.md.
Sửa. Mở rộng expandTemplatePath: khi không tìm được vòng lặp bao ngoài, truy ngược lên hàm tạo gần nhất có biến đó trong danh sách tham số, rồi thu các đối số chuỗi ở đúng vị trí tham số tại mọi chỗ gọi. Quét cân bằng ngoặc chứ không cắt theo dấu phẩy, để z.object({ … }) trong danh sách đối số không cắt nhầm.
Bằng chứng: gen:reference từ 313 → 329 endpoint, khớp chính xác con số 329 route mà contract:diff đọc độc lập từ OpenAPI. Không còn ${ nào trong api.md. 19 dòng mới đều là route thật, không dòng nào bịa.
F-5 — docs/knowledge/english.md trôi
File trên main là ảnh chụp của cây kiến thức trước khi nạp 12 khoá IELTS. Chỉ mục kho câu hỏi ghi 1.320 câu trên 64 node; thực tế đã là 2.868 câu trên 322 node — 258 node vắng mặt hoàn toàn khỏi tài liệu. Nguyên nhân là phiên nạp nội dung không chạy lại gen:knowledge, và không cổng nào so file dẫn xuất với nguồn (xem F-6).
F-6 — Hai điểm mù của cổng
Điểm mù thứ nhất: template chưa nở đi lọt. Bộ sinh gặp đường dẫn không suy được thì giữ nguyên rồi ghi file và báo thành công. Đây là lựa chọn có lý ("đừng nuốt route") nhưng thiếu vế sau: không ai được báo. Đã thêm chốt chặn ở cuối gen-reference.mjs — còn route nào chứa ${ thì gãy và không ghi file, vì một catalog thiếu route âm thầm nguy hiểm hơn là không sinh được catalog.
Điểm mù thứ hai: số unit khai trong meta chưa từng được đối chiếu. check-curriculum-pearl.mjs đối chiếu số module khai trong meta với số module liệt kê thật, nhưng dòng | **Unit** | N | thì không cổng nào đọc. Nặng hơn: cổng đếm unit qua bảng index đánh số kiểu x.y.z, mà ba môn lớn nhất — toán, văn, tiếng Anh — không đánh số kiểu đó, nên cột UNIT của chúng bằng 0 và mọi đối chiếu index ↔ nội dung của 346 unit đều truợt qua không cột nào giữ.
Đã đo tay toàn bộ 61 package: 0 lệch — số liệu tài liệu hiện đúng. Nhưng nó đúng do may chứ không do có cổng giữ, nên đã thêm phép đối chiếu meta **Unit** ↔ số unit thật trên trang module, chạy cho mọi môn kể cả ba môn không đánh số.
Cả hai cổng đều đã thử nghiệm âm tính (cố tình làm hỏng để xem cổng có đỏ không), chứ không chỉ chạy thấy xanh — đúng bẫy "typecheck xanh mà sai" mà SRC-678 đã ghi:
| Cổng | Cách thử | Kết quả |
|---|---|---|
Chốt template trong gen-reference | Tắt nhánh nở vòng lặp | ✗ đỏ, exit 1, chỉ đúng GET /v1/whale/${kind}/{id} |
| Đối chiếu số unit | Sửa **Unit** của toan/represent từ 28 → 27 | ✗ đỏ, [S4] meta ghi 27 unit nhung trang module co 28 |
Cả hai file đều đã khôi phục nguyên trạng sau khi thử.
F-7 — src-new.mjs cấp SRC với ngày lùi một hôm (tìm ra khi CI đỏ)
Phát hiện này không đến từ vòng rà mà từ chính lần push của audit này: CI đỏ ở bước Docs integrity (QG-001) với đúng một dòng — docs/intake.md — khai last_reviewed: 2026-09-07, nhưng sửa thật ngày 2026-09-08.
src-new.mjs bump last_reviewed bằng new Date().toISOString().slice(0, 10), tức ngày UTC. Git thì ghi ngày commit theo múi giờ máy. Ở +07, từ 00:00 tới 07:00 sáng hai con số đó lệch nhau một ngày, nên mọi SRC cấp trong khung bảy tiếng đó đều làm check-docs-fresh đỏ ở lần push kế. Trớ trêu là đoạn bump ấy sinh ra chính để chặn loại đỏ này, và comment ngay trên nó đã ghi một lần vấp trước (SRC-674).
Vì sao không cổng nào bắt được trước khi push. Phép đo của check-docs-fresh là last_reviewed so với ngày commit gần nhất chạm file. Trước lúc commit, commit đó chưa tồn tại — nên cả npm run check:docs chạy tay lẫn hook pre-commit đều thấy xanh một cách trung thực. Đây là một điểm mù không vá được bằng cách chạy cổng sớm hơn; chỉ vá được ở nguồn sinh ra ngày.
Sửa: dùng ngày local (toLocaleDateString("sv-SE"), đúng dạng YYYY-MM-DD) cho cùng một quy ước múi giờ với git. Kiểm chứng tại thời điểm sửa (05:28 giờ Việt Nam — đang trong khung hỏng): local 2026-09-08 · UTC 2026-09-07 · git 2026-09-08 — local khớp git, UTC lệch.
F-8 — File sinh ra không ổn định qua các lần sinh lại
Tìm ra ngay sau khi nâng GitHub Actions: cảnh báo Node 20 biến mất đúng như mong đợi, nhưng lượt CI đó lộ ra một annotation khác vốn bị lấp dưới nó — docs/reference trong repo lệch với source — chạy 'npm run gen:reference' rồi commit.
gen-reference.mjs đóng last_reviewed bằng ngày chạy bộ sinh (và bằng ngày UTC, y hệt F-7). Hệ quả: một file sinh ra từ nguồn không đổi vẫn khác chính nó khi sinh lại sang ngày mới, nên bước "sinh lại và bắt buộc không lệch" kêu ở gần như mọi push. Các lượt chạy trước không thấy cảnh báo này chỉ vì chúng tình cờ cùng ngày UTC với commit gần nhất chạm docs/reference.
Đây là hỏng ở mức thiết kế của cổng chứ không phải một lần lệch: một cảnh báo kêu hằng ngày là cảnh báo không ai còn đọc, tức cổng mất tác dụng đúng vào lúc nó có chuyện thật để nói — và chuyện thật ấy chính là loại trôi F-1/F-5 mà audit này vừa tìm ra.
Sửa: writeStable() — nếu phần còn lại của file trùng bản cũ từng chữ thì giữ nguyên ngày cũ, chỉ bump khi nội dung thật sự đổi. Ngày cũng chuyển sang giờ local cho cùng quy ước với git.
Kiểm chứng hai chiều:
| Tình huống | Kỳ vọng | Kết quả |
|---|---|---|
| Nguồn không đổi, sinh lại hai lượt | File y hệt, git status trống | ✓ trống cả hai lượt, ngày giữ 2026-09-07 |
| Nội dung đổi thật (xoá một route khỏi bản đã commit) | Ngày bump, route quay lại | ✓ 2026-09-07 → 2026-09-08, route trở lại đủ |
3. Nâng cấp kèm theo
| Việc | Lý do |
|---|---|
qs 6.15.3 → bản vá (npm audit fix) | Hai advisory mức trung bình (bỏ qua array-limit; DoS qua isBuffer). Vào qua CLI shadcn → MCP SDK → express, tức chỉ công cụ dev, không lên Workers hay trình duyệt. Lỗ hổng: 4 → 3 |
Nâng motion lên 13.2.0 ở 8 app | DS-001 §0; ghim số cụ thể chứ không để dải mở. Chạy typecheck ngay sau khi nâng theo luật SRC-591 — không vỡ kiểu |
Nâng GitHub Actions: checkout v4→v7 · setup-node v4→v7 · cache v4→v6 (37 chỗ, 18 workflow) | Bản @v4 nhắm Node 20 nên runner ép ngầm sang Node 24 kèm cảnh báo deprecation ở mọi lượt chạy — loại ép ngầm có ngày thành lỗi cứng. Ba breaking change trên đường v4→v7 đều đã kiểm là không chạm repo này: cache tự động của setup-node v5/v6 cần trường packageManager (repo không khai, và cache: npm ở đây là khai tay); checkout v7 chỉ chặn fork PR ở pull_request_target/workflow_run (không workflow nào dùng); yêu cầu runner ≥ v2.327.1 thoả vì 18/18 chạy ubuntu-latest |
4. Không sửa, có chủ đích
| Việc | Vì sao để lại |
|---|---|
audit:bounds đỏ — package math có module 12 unit, ap/genai có package chỉ 1 module | Đây là nợ nội dung đã đo được, không phải lỗi kỹ thuật. Sửa nghĩa là chia lại cây kiến thức, mà mọi bằng chứng học tập đã ghi đều trỏ vào node của cây hiện tại. Quyết định của chủ dự án theo từng môn |
esbuild ≤0.24.2 → vite → vitepress (1 cao, 2 trung bình) | Chưa có bản vá. Chỉ ảnh hưởng dev server của bốn site VitePress, không có mặt trong bản build production. Nâng vitepress là một việc riêng có commit và có test, không gộp vào audit |
Bốn file đang sửa dở của phiên khác (curriculum/*, sitemap.xml) | Cây làm việc dùng chung — đổi số SRC-681 → 682 và sitemap tự sinh là việc của phiên khác. Không đụng, theo luật CLAUDE.md §"Chạy nhiều phiên song song" |
verify-bindings không liệt kê được bucket R2 (cảnh báo ở mọi lượt CI) | Token Cloudflare thiếu quyền r2:read, tức lớp C — đối chiếu binding với bucket có thật — đang không chạy cho R2. Đây là việc TAY trên dashboard, không sửa được bằng code từ phiên này. Đã ghi thành việc chờ ở backlog |
TODO gom bảng nhãn môn ở StartWizard.tsx | Đã có task riêng, nợ được khai báo rõ ngay tại chỗ. Gom bốn bản chép rời là việc đụng ba app, không gộp vào audit |
5. Bằng chứng sau khi sửa
| Cổng | Trước | Sau |
|---|---|---|
check:docs (9 cổng con) | ✓ | ✓ (+1 phép đối chiếu số unit) |
check:code (10 cổng con) | ✓ | ✓ |
typecheck | ✓ | ✓ (sau khi nâng motion) |
lint | ⚠ 1 cảnh báo | ✓ 0 |
test | 1.539 pass | 1.539 pass |
contract:diff | ⚠ snapshot lệch 16 | ✓ 329 route, snapshot khớp |
check:ui-latest | ✗ 8 chỗ tụt bản | ✓ 12 app đều mới nhất |
gen:reference | 313 endpoint | 329 endpoint, 0 template sót |
npm audit | 4 lỗ hổng | 3 (còn lại chưa có bản vá) |
6. Đề xuất cho lần sau
- Đưa
check:ui-latestvào một lịch chạy đều (tuần/lần). Nó cố ý không chắn CI vì phụ thuộc mạng — đúng, nhưng hệ quả là không ai chạy, và lần này nó đã tụt bản ở cả 8 app trước khi có người nhìn tới. - Đặt hai bộ đếm độc lập cạnh nhau thành cổng. F-1 lộ ra vì
gen:referencevàcontract:diffđếm route theo hai đường khác nhau. So hai con số đó là một cổng rẻ và mạnh: lệch nghĩa là một trong hai đường đang mù. - Sinh lại tài liệu dẫn xuất trong CI rồi bắt
git diffkhác rỗng. F-5 nằm trênmainnhiều ngày dù mọi cổng đều xanh, vì không ai chạy lại bộ sinh.