-
Notifications
You must be signed in to change notification settings - Fork 9
ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) #801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
9a6822b
test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个)
SmartFlowAITeam 46e752c
ci: server 单测门抽成独立 job,别挂在 agent-network 名下
SmartFlowAITeam a4fd375
test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain"
SmartFlowAITeam 4031216
docs(tests): report-only —— 锚点 46e752c3(含 current main 034f0064)
SmartFlowAITeam bdfd432
docs(tests): report-only —— 锚点 a4fd375f(含 current main 034f0064)
SmartFlowAITeam 48f99bb
Merge remote-tracking branches 'origin/test/server-unit-ci' and 'orig…
SmartFlowAITeam 152455a
ci: 元门 —— 修掉独立审抓出的三条 P1(其中一条是元门自己的漏网)
SmartFlowAITeam 9626c98
ci: 元门要验「这道门真的被 CI 跑」,不只是「它存在且声明了范围」
SmartFlowAITeam c06df3c
docs(tests): report-only —— 锚点 9626c98e,七条 mutation
SmartFlowAITeam 809faac
ci: 落实 ⑤⑥ 两条已接受未实施的意见;② 需所有者决定,如实标注
SmartFlowAITeam 7538cf0
ci(test798): server 依赖钉死 —— 提交 lockfile 并改用 npm ci
SmartFlowAITeam cb2c6aa
ci(test798): 把 RUNSH_BLOB 真的传进去 —— 门在要求它,workflow 从没供给
70d9f6f
Merge origin/main into #801 —— 只保留本 PR 独有的部分,不回退 main 上更强的判据
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| #!/usr/bin/env python3 | ||
| """元门:每个 *.test.ts 都必须落在某个聚合门的扫描范围里。 | ||
|
|
||
| ## 为什么需要这个 | ||
|
|
||
| 2026-08-13 手工扫了一遍,发现三处「有测试、但没有任何 CI job 会跑它」: | ||
| server/src 69 个,CI 只点名跑 6 个 | ||
| agent-network/src 46 个,0 个被引用(#791 补掉) | ||
| agent-network/tests 19 个 + agent-node/tests 6 个,两个门自称 complete 却漏了 | ||
|
|
||
| 每一处都是同一个结构:测试在本地是绿的,PR 上看不出异常,改坏了不会有人知道。 | ||
| 补完之后剩下的问题是 —— **下一个新增的测试文件会不会又静默漏掉?** | ||
| 靠人再扫一遍不是答案。这个脚本就是答案。 | ||
|
|
||
| ## 判据 | ||
|
|
||
| 聚合门用 `find <root> -name '*.test.ts'` 覆盖若干个根。任何测试文件: | ||
| - 落在某个根下 → 被覆盖 | ||
| - 落在 tests/<套件>/ 下 → 属于「套件自带」,单独计数并列出(它们由各自的 | ||
| Docker 套件跑,是否进 CI 由套件决定,不在本门的判据里) | ||
| - 两者都不是 → **失败**。这是唯一的漏网形态:新包、新目录、或者把测试 | ||
| 放在了聚合门扫不到的地方。 | ||
|
|
||
| ## 两条防空转 | ||
|
|
||
| 1. **根必须真的是门的扫描范围**。COVERED 是一份声明,声明会漂 —— 门被删、 | ||
| 改名、或者把范围缩掉,这里就要红,否则本门会对着一份早已不成立的清单发绿。 | ||
| 注意这条**不能**用子串检查:第一版写的是 `root not in text`,mutation 当场 | ||
| 证伪 —— 把 find 的路径改成 $ROOT/server/nonexistent 之后,'server/src' 仍然 | ||
| 出现在注释和 FAIL 文案里,门照样绿。见 declares_scope()。 | ||
| 2. **分母必须非零**。扫出 0 个测试文件时退出 3,而不是「没有违规,通过」—— | ||
| 扫描器范围塌掉和真的没有违规,打印出来是同一片绿色。 | ||
| """ | ||
|
|
||
| import re | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| REPO = Path(__file__).resolve().parents[2] | ||
|
|
||
| # root → 声称覆盖它的门(run.sh 路径)。该门必须把 root 真正声明为扫描范围,见 declares_scope()。 | ||
| COVERED = { | ||
| "server/src": "tests/test798-server-unit-ci/run.sh", | ||
| "agent-network/src": "tests/test745-agent-network-unit-ci/run.sh", | ||
| "agent-network/tests": "tests/test745-agent-network-unit-ci/run.sh", | ||
| "agent-node/src": "tests/test725-agent-node-unit-ci/run.sh", | ||
| "agent-node/tests": "tests/test725-agent-node-unit-ci/run.sh", | ||
| } | ||
|
|
||
| # tests/<套件>/ 下的测试文件属于套件自带,单独计数 | ||
| SUITE_PREFIX = "tests/" | ||
|
|
||
|
|
||
|
|
||
|
|
||
| WORKFLOW = REPO / ".github" / "workflows" / "qa.yml" | ||
|
|
||
|
|
||
| def gate_is_wired(gate: str) -> tuple[bool, str]: | ||
| """这道门有没有真的被 CI 构建并运行。 | ||
|
|
||
| 原来只验了两件事:门文件存在、门声明了扫描范围。**都不等于它会跑。** | ||
| 独立审(codex P1)指出:qa.yml 一旦删掉或改名某个 job、或不再 build/run | ||
| 它的 Dockerfile,本脚本照样发绿 —— 因为它从没看过 qa.yml。 | ||
| 这正是本门要防的那类问题(有门、没人跑),所以不能留在自己身上。 | ||
|
|
||
| 判据是 qa.yml 里同时出现: | ||
| - `-f tests/<suite>/Dockerfile`(真的构建了它) | ||
| - `docker run … <这次 build 打的 tag>`(真的跑了那个产物) | ||
| 只比 tag 字符串,不解析 YAML —— 但两条都要中,单独一条不算。 | ||
| """ | ||
| suite = Path(gate).parent.name | ||
| if not WORKFLOW.is_file(): | ||
| return False, "qa.yml 不存在" | ||
| wf = WORKFLOW.read_text(encoding="utf-8") | ||
| build = re.search(rf'-f\s+tests/{re.escape(suite)}/Dockerfile', wf) | ||
| if not build: | ||
| return False, f"qa.yml 里没有 build tests/{suite}/Dockerfile" | ||
| tags = re.findall(rf'-t\s+(\S+)\s+\\?\s*\n?\s*-f\s+tests/{re.escape(suite)}/Dockerfile', wf) | ||
| if not tags: | ||
| return False, f"qa.yml 里 build tests/{suite} 时没有 -t <tag>" | ||
| tag = tags[0] | ||
| if not re.search(rf'docker run[^\n]*\b{re.escape(tag)}\b', wf): | ||
| return False, f"qa.yml 构建了 {tag} 但没有 docker run 它" | ||
| return True, tag | ||
|
|
||
|
|
||
| def suite_is_real(path: str) -> bool: | ||
| """`tests/<suite>/x.test.ts` 只有在那个套件真的是一套门时才豁免。 | ||
|
|
||
| 独立审(codex P1):原来只要路径以 `tests/` 开头就放行,于是 | ||
| `tests/test999-example/new.test.ts` 这种既没有 Dockerfile 也没有 run.sh 的 | ||
| 目录也能过 —— 豁免变成了「只要放对地方就不用被任何东西跑」。 | ||
| 所以要求套件目录里 Dockerfile 和 run.sh 都在。 | ||
|
|
||
| 注意这条**仍然不保证该套件进了 CI**(它可能像 test224/597/679 那样长期 | ||
| 没人注册)。那是另一回事,写在 NOT COVERED 里。 | ||
| """ | ||
| parts = path.split("/") | ||
| if len(parts) < 3: | ||
| return False | ||
| suite = REPO / parts[0] / parts[1] | ||
| return (suite / "Dockerfile").is_file() and (suite / "run.sh").is_file() | ||
|
|
||
|
|
||
| def scan_depth(gate_text: str, root: str) -> int | None: | ||
| """门扫这个根时的深度上限:1 = 只扫直属文件,None = 递归。 | ||
|
|
||
| 这条是独立审(codex P1)抓出来的,而且当场复现:两个 unit runner 扫 | ||
| `<pkg>/tests` 用的是 `find … -maxdepth 1`,而本脚本原来只按前缀判覆盖 —— | ||
| 于是 `agent-network/tests/sub/x.test.ts` 被判为「已覆盖」,可 runner 的 | ||
| find 对它命中 0。**这道门放行了一个没人会跑的测试**,正是它存在的意义所在。 | ||
|
|
||
| 所以深度必须从门里推导,不能假定。 | ||
| """ | ||
| m = re.search( | ||
| rf'find\s+"\$ROOT/{re.escape(root)}"\s+(?P<flags>(?:-maxdepth\s+\d+\s+)?)', | ||
| gate_text, | ||
| ) | ||
| if m: | ||
| d = re.search(r'-maxdepth\s+(\d+)', m.group("flags") or "") | ||
| return int(d.group(1)) if d else None | ||
| return None # `bun test <dir>/` 形式:bun 会递归 | ||
|
|
||
|
|
||
| def declares_scope(gate_text: str, root: str) -> bool: | ||
| """门里必须真的把 root 当成扫描范围,而不是只在注释里提到它。 | ||
|
|
||
| 第一版这里写的是 `root not in gate_text` —— 子串检查。mutation 当场证明 | ||
| 它是坏的:把 find 的路径从 $ROOT/server/src 改成 $ROOT/server/nonexistent | ||
| 之后,'server/src' 仍然出现在注释和 FAIL 文案里,门照样发绿。 | ||
| 宽容的断言会把不合规当合规收下。所以只认两种真实的范围声明形式。 | ||
| """ | ||
| pkg, _, sub = root.partition("/") | ||
| patterns = [ | ||
| # find "$ROOT/<root>" … -name '*.test.ts' | ||
| rf'find\s+"\$ROOT/{re.escape(root)}"', | ||
| # cd /workspace/<pkg> && bun test <sub>/ | ||
| # 结尾必须锚定:`bun test src/` 才算声明整个目录。不锚的话 | ||
| # `bun test src/cli.test.ts` 也会匹配上 —— 范围收窄到单个文件, | ||
| # 门却仍然宣称覆盖了整个 src/。第二轮 mutation 就是这么活下来的。 | ||
| rf'cd\s+/workspace/{re.escape(pkg)}\s+&&\s+bun test\s+{re.escape(sub)}/(?=[\'"\s]|$)', | ||
| ] | ||
| return any(re.search(p, gate_text) for p in patterns) | ||
|
|
||
|
|
||
| def tracked_test_files() -> list[str]: | ||
| out = subprocess.run( | ||
| ["git", "ls-files", "*.test.ts"], | ||
| cwd=REPO, capture_output=True, text=True, check=True, | ||
| ).stdout | ||
| return sorted(p for p in out.splitlines() if p) | ||
|
|
||
|
|
||
| def main() -> int: | ||
| failures: list[str] = [] | ||
|
|
||
| # 防空转 1:每个声明的根都要在它声称的门里字面出现 | ||
| for root, gate in COVERED.items(): | ||
| gate_path = REPO / gate | ||
| if not gate_path.is_file(): | ||
| failures.append(f"门不存在:{gate}(声称覆盖 {root})") | ||
| continue | ||
| text = gate_path.read_text(encoding="utf-8") | ||
| if not declares_scope(text, root): | ||
| failures.append( | ||
| f"门 {gate} 没有把 '{root}' 声明为扫描范围 —— " | ||
| "覆盖声明与门的实际范围已经不一致" | ||
| ) | ||
| wired, why = gate_is_wired(gate) | ||
| # 一道门可能覆盖多个根(test745 覆盖 src 和 tests),接线问题只报一次 | ||
| msg = f"门 {gate} 没有接进 CI:{why}" | ||
| if not wired and msg not in failures: | ||
| failures.append(msg) | ||
|
|
||
| files = tracked_test_files() | ||
| print(f"tracked_test_files={len(files)}") | ||
|
|
||
| # 防空转 2:分母为零说明扫描范围塌了,不是「没有违规」 | ||
| if not files: | ||
| print("FAIL: 扫到 0 个 *.test.ts —— 扫描范围塌了,不是通过", file=sys.stderr) | ||
| return 3 | ||
|
|
||
| by_root: dict[str, int] = {r: 0 for r in COVERED} | ||
| suite_files: list[str] = [] | ||
| orphans: list[str] = [] | ||
|
|
||
| depths = { | ||
| root: scan_depth((REPO / gate).read_text(encoding="utf-8"), root) | ||
| if (REPO / gate).is_file() else None | ||
| for root, gate in COVERED.items() | ||
| } | ||
| for f in files: | ||
| for root in COVERED: | ||
| if not f.startswith(root + "/"): | ||
| continue | ||
| rest = f[len(root) + 1:] | ||
| d = depths[root] | ||
| if d is not None and rest.count("/") >= d: | ||
| # 落在门扫不到的深度里 —— 加了也不会有人跑,按漏网处理 | ||
| continue | ||
| by_root[root] += 1 | ||
| break | ||
| else: | ||
| if f.startswith(SUITE_PREFIX) and suite_is_real(f): | ||
| suite_files.append(f) | ||
| else: | ||
| orphans.append(f) | ||
|
|
||
| for root, n in sorted(by_root.items()): | ||
| print(f" covered {root:<22} {n}") | ||
| print(f" suite-owned (tests/<suite>/) {len(suite_files)}") | ||
| for f in suite_files: | ||
| print(f" {f}") | ||
|
|
||
| total = sum(by_root.values()) + len(suite_files) + len(orphans) | ||
| if total != len(files): | ||
| failures.append(f"计数不闭合:分类合计 {total} != 文件数 {len(files)}") | ||
|
|
||
| if orphans: | ||
| failures.append( | ||
| "以下测试文件不在任何聚合门的扫描范围里 —— 加了也不会有人跑:\n" | ||
| + "\n".join(f" {f}" for f in orphans) | ||
| + "\n 要么把它挪进已覆盖的根,要么给它所在的包补一个聚合门" | ||
| "(照 tests/test798-server-unit-ci 的形状)。" | ||
| ) | ||
|
|
||
| if failures: | ||
| print() | ||
| for msg in failures: | ||
| print(f"FAIL: {msg}", file=sys.stderr) | ||
| return 1 | ||
|
|
||
| print(f"\nOK: {len(files)} 个测试文件,{len(files) - len(suite_files)} 个在聚合门范围内," | ||
| f"{len(suite_files)} 个套件自带,0 个漏网") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # 元门:新增的 *.test.ts 不能落在所有聚合门的扫描范围之外。 | ||
| # | ||
| # 起因是 2026-08-13 手工扫出的三处盲区(server/src 69 个 CI 只跑 6 个、 | ||
| # agent-network/src 46 个 0 被引用、两个门自称 complete 却漏了 tests/ 下 25 个)。 | ||
| # 那三处都补掉了,但补完剩下的问题是:下一个新增的测试文件会不会又静默漏掉。 | ||
| # 靠人再扫一遍不是答案,所以有了这道门。 | ||
| # | ||
| # 判据和两条防空转见 .github/scripts/check-test-file-coverage.py 的文档串。 | ||
| # 用 Python 而不是 yml 里的 bash 循环,同 no-memory-slugs.yml 的理由。 | ||
|
|
||
| name: lint (every test file is covered by a gate) | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - '**/*.test.ts' | ||
| - 'tests/test725-agent-node-unit-ci/**' | ||
| - 'tests/test745-agent-network-unit-ci/**' | ||
| - 'tests/test798-server-unit-ci/**' | ||
| # qa.yml 决定这三个门到底跑不跑 —— 它一改,本门的前提就可能塌(codex P1) | ||
| - '.github/workflows/qa.yml' | ||
| - '.github/scripts/check-test-file-coverage.py' | ||
| - '.github/workflows/test-file-coverage.yml' | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - '**/*.test.ts' | ||
| - 'tests/test725-agent-node-unit-ci/**' | ||
| - 'tests/test745-agent-network-unit-ci/**' | ||
| - 'tests/test798-server-unit-ci/**' | ||
| # qa.yml 决定这三个门到底跑不跑 —— 它一改,本门的前提就可能塌(codex P1) | ||
| - '.github/workflows/qa.yml' | ||
| - '.github/scripts/check-test-file-coverage.py' | ||
| - '.github/workflows/test-file-coverage.yml' | ||
|
|
||
| concurrency: | ||
| group: lint-test-coverage-${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | ||
|
|
||
| jobs: | ||
| test-file-coverage: | ||
| name: every test file is covered by a gate | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 2 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Run check-test-file-coverage.py | ||
| run: python3 .github/scripts/check-test-file-coverage.py | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # 元门 check-test-file-coverage.py | ||
| source_commit=9626c98e4d301d88822cbb3031753fd94a946ed8 | ||
| base(current main)=034f00647d42d38d5086d7fc057eb7824a441791 | ||
| stack: 含 #798 与 #800 的源码 | ||
|
|
||
| ## 基线 | ||
| tracked_test_files=236 | ||
| covered agent-network/src 46 | ||
| covered agent-network/tests 19 | ||
| covered agent-node/src 91 | ||
| covered agent-node/tests 6 | ||
| covered server/src 69 | ||
| suite-owned (tests/<suite>/) 5 | ||
| tests/test224-grok-preview-security/security-gate.test.ts | ||
| tests/test597-dashboard-slash-namespace/cli-wire.test.ts | ||
| tests/test679-task-trace/wiring.test.ts | ||
| tests/test682-uncovered-task-trace/semantics.test.ts | ||
| tests/test682-uncovered-task-trace/wiring.test.ts | ||
|
|
||
| OK: 236 个测试文件,231 个在聚合门范围内,5 个套件自带,0 个漏网 | ||
| rc=0 | ||
|
|
||
| ## mutation(七条,全部双向验过) | ||
| A 落在任何根之外的新文件 → rc=1 点名 | ||
| B find 范围改成 nonexistent → rc=1「没有把 server/src 声明为扫描范围」 | ||
| C bun test src/ 收窄成单文件 → rc=1(加结尾锚定后才红,第一版活下来过) | ||
| D 伪套件 tests/test999-example/ → rc=1;补 Dockerfile+run.sh 后 rc=0 | ||
| E 子目录 agent-network/tests/sub/ → rc=1;直属文件 rc=0(独立审抓出的,不是我自己发现的) | ||
| F1 删掉 server-unit 的 docker run → rc=1「构建了但没有 docker run 它」 | ||
| F2 build -f 路径改名 → rc=1「没有 build tests/…/Dockerfile」 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
.github/workflows/qa.ymlremoves or renames one of the three unit jobs, or stops building and running its Dockerfile, this workflow is not triggered becauseqa.ymlis absent from these paths, and the Python checker only validates the standalonerun.shtext rather than any workflow reference. The edited QA workflow can therefore pass with its remaining jobs while every file assigned to the disconnected runner is still reported as covered; trigger onqa.ymland validate each declared gate's live workflow linkage.AGENTS.md reference: AGENTS.md:L16-L16
Useful? React with 👍 / 👎.