Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
160a6b9
docs(mcp-tools): 正文行号引用也改钉符号 —— 两版行号 pin 归零
SmartFlowAITeam Aug 13, 2026
fa992ed
docs(tests): 刷新 test831 报告 —— 记录 27/53 那次跑
SmartFlowAITeam Aug 13, 2026
b3e14d5
docs(mcp-tools): 修三处锚错 tool 的引用,并补上「属主是否匹配」的全量审计
SmartFlowAITeam Aug 13, 2026
cb8d96e
Merge branch 'docs/mcp-tools-symbol-anchors' into docs/mcp-tools-inli…
SmartFlowAITeam Aug 13, 2026
c3c8fa3
docs(tests): 合入 #843/#844 后重跑 test831
SmartFlowAITeam Aug 13, 2026
89e454b
ci(test831): 加 L6 —— 符号锚点必须落在它声称的那个 tool 段
SmartFlowAITeam Aug 13, 2026
d86f982
docs(tests): 刷新 test831 报告(含 L6)
SmartFlowAITeam Aug 13, 2026
d57f72d
docs(rest): 18 处不与 #809/#810 冲突的行号锚点改钉符号
SmartFlowAITeam Aug 13, 2026
4cb0ca3
docs(tests): 刷新 test831 报告(rest.md 18 处改完之后)
SmartFlowAITeam Aug 13, 2026
d951738
ci(test831): 加 --write-baseline(只许缩小)与 L7 的两向断言
SmartFlowAITeam Aug 13, 2026
de56da1
docs(tests): 刷新 test831 报告(含 L7)
SmartFlowAITeam Aug 13, 2026
f565e9b
fix(cli): 起/停这几条路不再宣布没量过的成功 (#895)
vansin Aug 17, 2026
40574a0
fix(cli): project up / restart 的退出码要反映节点是否真的起来了 (#896)
vansin Aug 17, 2026
6c58a9a
docs: 去掉过期版本号与硬编码计数,改为指向权威来源 (#869)
vansin Aug 17, 2026
7752437
fix(ci): 让 CI 真会跑的测试能重触发自己那道门,并加一道防漂回去的门 (#897)
vansin Aug 17, 2026
9db6dcf
fix(docs,ci): 修 W19 编码与死链、给矛盾耗时标条件、把两个没人调的验证脚本挂上 (#899)
vansin Aug 17, 2026
883d4bc
fix(cli): 让 dev-channels 自动应答真的能用 —— pane 目标用坐标 + 候选按 server: channel…
vansin Aug 17, 2026
d88dc38
docs(refresh): stale-snapshot caveats on 4 独立面 (task 27faa700) (#898)
vansin Aug 17, 2026
cea145c
docs(refresh): Q2 anchors for password + #450 + Fact-2 (#895/#896) no…
vansin Aug 17, 2026
7495746
fix(tests): derive the opencode pair versions from source instead of …
vansin Aug 17, 2026
1dcee5b
fix(docs): changelogs must not line-anchor into main — the anchor rot…
vansin Aug 17, 2026
13ffd8b
feat(hub): let get_all_status filter by alias, and say what its summa…
vansin Aug 17, 2026
f9c5e58
fix(hub): PORT=0 must mean an ephemeral port, not the production Hub …
vansin Aug 17, 2026
8523cf8
fix(tests): outbound 工具集断言改为从真相源派生(#816 —— 门是错的,而且没人跑) (#905)
vansin Aug 17, 2026
fa36482
fix(install.sh): stop blaming the registry for every failure (#868) (…
vansin Aug 17, 2026
4a1ed44
fix(ci): 把唯一的第三方 action 钉到 SHA,并加一道门防下一个 (#746) (#907)
vansin Aug 17, 2026
27cf35e
fix(agent-node): 别把 vendor 说的 success 当成节点自己的结论打进日志 (#910)
vansin Aug 17, 2026
2e2dc65
docs(deploy): 写明 dashboard 的 ecosystem 没有 cwd 是有意的,别照着在跑的进程补 (#912)
vansin Aug 17, 2026
390a803
fix(docs): 两条深度算错一级的死链——目标文件一直在,只是路径多/少了一层 (#913)
vansin Aug 17, 2026
abb31c5
fix(ci,docs): docs-integrity 看不见「裸文件名」相对链接——它宣称的分母少了 16 条,两条坏链都在里面 (#…
vansin Aug 17, 2026
c0c2773
fix(ci): 四个 workflow 的 job 全叫 scan——在 check 列表里挤成同一个名字,required 里没法指名…
vansin Aug 17, 2026
235b882
fix(cli): #909 — agent-node --help no longer presents claude-code-cli…
vansin Aug 17, 2026
1f7de2c
docs(cli): dashboard 那行注释说"默认按 channel 匹配",而代码对所有人都返回 preview;并记下 #61…
vansin Aug 17, 2026
5978337
fix(ci): e2e-docker 的 push 触发器漏了 branches: [main],于是每个 PR 都把最贵的 job 跑…
vansin Aug 17, 2026
21b17a6
ci: 加一道门盯住「跑什么」和「什么时候跑」这两份清单不同步(#860 的失效已经发生过一次) (#920)
vansin Aug 17, 2026
68f9152
ci: 给公开仓里的 /home/<真人名> 加一道基线门——止住新增,不动存量(#894) (#921)
vansin Aug 17, 2026
e1d746c
ops(deploy): 把 README 里那条「只在安装时跑一次」的校验做成一个能反复跑的脚本 (#922)
vansin Aug 17, 2026
a0e34db
test(ci): 给 src/ 补绝对下限 —— 两道门都放行「大量删除测试文件」(#817) (#854)
vansin Aug 17, 2026
50dd008
test(test224): 「网络已禁用」以前是一句声明,现在是一条断言 (#923)
vansin Aug 17, 2026
94fab23
test(server): resolveRestWriteNetworkId 是 network-scope.ts 里唯一零测试的导出函…
vansin Aug 17, 2026
2fb87c0
fix(cli): 就绪探针只看 tmux 可见区,一行日志把「listening on」顶走就等满 25s 判失败(#849) (#926)
vansin Aug 17, 2026
eae1012
fix(cli): 首次 start 懒取 agent-node 失败时,把 npx 说的话丢掉了(#450) (#927)
vansin Aug 17, 2026
97c2e41
ci(test686): 三次运行都断言「注册到几个测试」——退出码分不出「全过」和「只跑了一个」(#928) (#929)
vansin Aug 17, 2026
450b526
docs(playbook): 5 条链接指向从未进过仓的文件,2 处内部 memory slug 泄进了公开仓 (#930)
vansin Aug 17, 2026
61f7203
test(qa-hub): 修四个静默失效 6 周的 e2e 套件 —— 实跑 4/4 从红到绿 (#861) (#863)
vansin Aug 17, 2026
5e59070
test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) (#798)
vansin Aug 17, 2026
cc325f4
ci: 注册三个从没进 CI 的 Docker 门,build-arg 改推导(第四个已过时,另开 issue) (#803)
vansin Aug 17, 2026
a0cb1e0
test(#167): 定长 sleep 换成轮询 —— 4.0s 的 sleep 装在 bun 默认 5.0s 预算里 (#931)
vansin Aug 17, 2026
9bd8ef0
chore: 删掉 test682 这道过时的门,以及 #698 废弃设计留下的两个死模块 (#804) (#855)
vansin Aug 17, 2026
944d5c0
chore(deps): agent-network lockfile 把 hono 推过修复线(4.12.25 → 4.13.1,清 6…
vansin Aug 17, 2026
10c71dd
test(ci): 让 test725/test745 覆盖 tests/ 目录(两个门自称 complete 却漏了 25 个文件) (…
vansin Aug 17, 2026
ba02073
ci: 元门 —— 新增测试文件不能落在所有聚合门的扫描范围之外(依赖 #798 #800) (#801)
vansin Aug 17, 2026
46829d4
docs: docs/ 里 13 条 cli.ts 行号引用改钉符号锚(22 → 9,改前 11/11 全错) (#857)
vansin Aug 17, 2026
31ea026
ci(docs): 符号锚点必须真实存在 —— 补上 #857 换过去之后没人看的那一格 (#932)
vansin Aug 17, 2026
5a330a0
docs(changelog): v0.10.1 那两条 cli.ts 引用钉到当时的提交(行号在范围内但已经指错) (#851)
vansin Aug 17, 2026
de0a93a
docs(changelog): 把 RFC-014 那条的源码引用钉到当时的提交,不再钉 main (#834)
vansin Aug 17, 2026
ee35ef5
Merge origin/main into #845 —— 并把本 PR 的门改名,避免和 main 上同名的另一道混掉
Aug 17, 2026
4a93eac
基线删掉 server/src/index.ts#L253 —— 它已被 #834 修好(这道门自己抓到的)
Aug 17, 2026
e65faa7
test831 的分母预期 18 → 15 —— 是进展不是扫漏,逐条能指出来
Aug 17, 2026
606d96d
test831 L5① 改成断言增量 —— 写死的 `=1` 只在「仓里一条钉 SHA 的引用都没有」时成立
Aug 17, 2026
25e8fa3
test831 L7 造场景改用合成 SHA —— 原来借的那个真实提交,今晚变成了真实数据
Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/scripts/check-action-pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Third-party GitHub Actions must be pinned to a commit SHA, not a moving tag.

`uses: some-org/some-action@v2` resolves whatever that tag points at today. The
tag is writable by whoever owns the action, so the code that runs in CI — with
this repository's checkout and secrets in scope — can change without a commit
here and without anyone reviewing it.

Scope, stated because a filter you cannot see is a filter you cannot trust:

* `actions/*` (GitHub's own) are ALLOWED on tags. That is the near-universal
convention, they are first-party, and changing them is a separate policy
call — not something to smuggle in under a guard about third parties.
* Everything else must carry a 40-hex SHA. A trailing `# v2` comment is
encouraged so a reader can still tell what the pin means.
* Local actions (`./…`) and Docker actions (`docker://…`) are out of scope:
they are not fetched from a tag at all.

This does NOT claim to fix flaky downloads. On 2026-08-17 a `L0 + L1` job here
failed with three consecutive 429s fetching `oven-sh/setup-bun`, and a SHA pin
would not have changed that — the request still goes to codeload. Pinning is
about knowing WHAT ran, not about whether the fetch succeeds. Saying otherwise
would be selling the guard on a benefit it does not deliver.

Fail-closed: no workflow files, or no `uses:` lines at all, exits 2 rather than
reporting a clean scan of nothing.
"""
import re
import sys
from pathlib import Path

WORKFLOWS = Path(".github/workflows")
USES = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)")
SHA40 = re.compile(r"^[0-9a-f]{40}$")
FIRST_PARTY_OWNERS = {"actions", "github"}


def main() -> int:
if not WORKFLOWS.is_dir():
print(f"::error::{WORKFLOWS} does not exist — scope regression, refusing to pass")
return 2

files = sorted(list(WORKFLOWS.glob("*.yml")) + list(WORKFLOWS.glob("*.yaml")))
if not files:
print(f"::error::no workflow files under {WORKFLOWS} — scope regression, refusing to pass")
return 2

total = 0
problems = 0
for f in files:
for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
m = USES.match(line)
if not m:
continue
ref = m.group(1)
if ref.startswith("./") or ref.startswith("docker://"):
continue
total += 1
if "@" not in ref:
problems += 1
print(f"::error file={f},line={i}::`{ref}` has no ref at all — pin it to a commit SHA")
continue
repo, _, version = ref.rpartition("@")
owner = repo.split("/", 1)[0]
if owner in FIRST_PARTY_OWNERS:
continue
if not SHA40.match(version):
problems += 1
print(
f"::error file={f},line={i}::third-party action `{repo}` is pinned to "
f"`{version}`, a tag its owner can repoint. Whatever it points at runs here "
f"with this checkout and these secrets, without a commit in this repo.\n"
f" Pin the SHA and keep the tag as a comment:\n"
f" uses: {repo}@<40-hex-sha> # {version}"
)

if total == 0:
print("::error::scanned ZERO `uses:` lines — the parser stopped matching; refusing to pass")
return 2

print(f"checked {total} action reference(s) across {len(files)} workflow file(s)")
if problems:
print(f"\n{problems} unpinned third-party action(s).")
return 1
print("every third-party action is pinned to a commit SHA.")
return 0


if __name__ == "__main__":
sys.exit(main())
238 changes: 238 additions & 0 deletions .github/scripts/check-doc-symbol-anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""docs 里的「符号锚点」必须在它自己点名的那个文件里真实存在。

背景 —— 为什么需要这道门
========================

#857 把 docs 里 13 条 `cli.ts:228 loadProfile` 这样的**行号 pin** 换成了
**符号锚点**:

[`cli.ts`](…/agent-network/bin/cli.ts) —— 搜 `function loadProfile(`

换的理由是行号会漂:那 13 条抽查下来 **13 条全错**,`loadProfile` 实际在 1274 行,
doc 写 228;`runCommand` 在 5812,doc 写 2044。而它们全都**长得像有效引用** ——
格式对、行号在文件范围内、点开能打开 —— 所以读的人不会怀疑。

符号锚点确实不会因为「上面插了几行」而失效。**但它会因为改名而失效,而失效之后
同样没有任何东西会喊。** #843 那道门在数行号 pin(守住不再变多),而符号锚点
在变多,却没有任何门在看。

这道门补的就是这一格:**每一条 `搜 `X`` 里的 X,必须在它前面那个链接指向的文件里
真实存在。**

判据
====

对每一条 `搜 `<anchor>``:
1. 往左找**最近的**一个指向本仓源码的 markdown 链接,取出仓库相对路径;
2. 断言 `<anchor>` 是那个文件内容的子串(逐字,不做正则,不忽略空白)。

两类失败都报:
- anchor 在文件里找不到 → 锚点失效(改名/删除/写错)
- anchor 前面没有链接 → 无法判定它指哪个文件,这本身就是缺陷

分母承重
========

🔴 这道门最可能的坏法不是「判据写错」,是**「一条都没扫到」然后打印一片绿**。
所以:扫到 0 个 md 文件、或 0 条锚点,一律 exit 2(而不是 exit 0)。
「没有问题」和「没有看」在输出上必须长得不一样。

用法
====

python3 .github/scripts/check-doc-symbol-anchors.py
python3 .github/scripts/check-doc-symbol-anchors.py --selftest
"""

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

# 锚点本体:`搜 ` 之后的第一个反引号串。
# 🔴 只取第一个 —— docs/architecture.md:450 那种一行里 `搜 X` 后面还跟着两个
# 描述性代码串(`writeFileSync(..., {mode: 0o600})` 之类),它们不是锚点。
ANCHOR = re.compile(r"搜\s*`([^`]+)`")

# 指向本仓源码的链接。两种写法都收:
# [`cli.ts`](https://github.com/<owner>/<repo>/blob/<ref>/agent-network/bin/cli.ts)
# [`cli.ts`](../../agent-network/bin/cli.ts)
BLOB_LINK = re.compile(
r"\]\(\s*(?:https?://github\.com/[^/\s]+/[^/\s]+/blob/[^/\s]+/)?([^)\s#]+?)\s*(?:#[^)\s]*)?\)"
)

# 只有这些后缀算「源码文件」——链接到别的 .md 不构成锚点目标。
SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".sh", ".yml", ".yaml", ".json"}

DOC_ROOTS = ("docs/", "docs-site/")


def tracked_markdown(repo: Path) -> list[str]:
out = subprocess.run(
["git", "ls-files", "-z", "--", "docs", "docs-site"],
cwd=repo, capture_output=True, text=True, check=True,
).stdout
return sorted(p for p in out.split("\0") if p.endswith(".md"))


def nearest_source_link(line: str, before: int) -> str | None:
"""往左找最近的、指向源码文件的链接目标。"""
best = None
for m in BLOB_LINK.finditer(line):
if m.end() > before:
break
target = m.group(1)
if Path(target).suffix in SOURCE_SUFFIXES:
best = target
return best


def scan_text(rel: str, text: str) -> tuple[list[tuple], int]:
"""返回 (问题列表, 本文件里的锚点数)。"""
problems: list[tuple] = []
count = 0
for lineno, line in enumerate(text.split("\n"), start=1):
for m in ANCHOR.finditer(line):
count += 1
anchor = m.group(1)
target = nearest_source_link(line, m.start())
if target is None:
problems.append((rel, lineno, anchor, None, "no source link precedes this anchor"))
continue
problems.append((rel, lineno, anchor, target, None))
return problems, count


def resolve(repo: Path, doc_rel: str, target: str) -> Path:
"""相对链接按 doc 所在目录解析;仓库绝对路径(如 agent-network/bin/cli.ts)按仓根解析。"""
if target.startswith("./") or target.startswith("../"):
return (repo / doc_rel).parent.joinpath(target).resolve()
return (repo / target).resolve()


def run(repo: Path) -> int:
docs = tracked_markdown(repo)
if not docs:
print("FAIL: 0 tracked .md under docs/ or docs-site/ — 扫描范围塌了", file=sys.stderr)
return 2

pending: list[tuple] = []
total_anchors = 0
for rel in docs:
try:
text = (repo / rel).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
print(f"::error file={rel}::cannot read: {exc}")
pending.append((rel, 0, "", None, f"unreadable: {exc}"))
continue
found, n = scan_text(rel, text)
pending.extend(found)
total_anchors += n

if total_anchors == 0:
print("FAIL: 0 symbol anchors found across "
f"{len(docs)} doc(s) — 判据没变,是取集塌了", file=sys.stderr)
return 2

problems = 0
checked = 0
for rel, lineno, anchor, target, note in pending:
if note:
print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` — {note}")
problems += 1
continue
path = resolve(repo, rel, target)
try:
body = path.read_text(encoding="utf-8")
except OSError:
print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` "
f"names '{target}', which does not exist")
problems += 1
continue
checked += 1
if anchor not in body:
print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` "
f"not found in '{target}' — 被改名/删掉了,或者一开始就写错了")
problems += 1

print(f"checked {total_anchors} symbol anchor(s) across {len(docs)} tracked doc(s); "
f"{checked} resolved to a readable source file")
if problems:
print(f"\n{problems} problem(s).")
return 1
print("every symbol anchor exists in the file it names.")
return 0


# ---------------------------------------------------------------------------
# selftest
#
# 🔴 夹具里的锚点用字符串拼接造,不写成字面量 —— 否则这个文件自己会被
# 真实扫描当成 docs 命中(它不在 docs/ 下,但同类门吃过这个亏,留个明示)。
# ---------------------------------------------------------------------------
def selftest() -> int:
SEARCH = "搜"
BT = "`"

def anchor(text: str) -> str:
return SEARCH + " " + BT + text + BT

def link(target: str) -> str:
return "[`x`](https://github.com/o/r/blob/main/" + target + ")"

cases: list[tuple[str, bool, str]] = []

def check(name: str, line: str, src_map: dict[str, str], want_problem: bool) -> None:
probs, n = scan_text("docs/f.md", line)
got_problem = False
for _rel, _ln, a, target, note in probs:
if note:
got_problem = True
elif a not in src_map.get(target or "", ""):
got_problem = True
ok = (got_problem == want_problem) and n >= 1
cases.append((name, ok, f"anchors={n} problem={got_problem} want={want_problem}"))

src = {"a/b.ts": "function loadProfile() {}\nconst x = 1;\n"}

check("锚点存在 → 过", link("a/b.ts") + " —— " + anchor("function loadProfile("), src, False)
check("锚点不存在 → 红", link("a/b.ts") + " —— " + anchor("function gone("), src, True)
check("锚点前没有链接 → 红", "见 " + anchor("function loadProfile("), src, True)
check("链接是 .md 不算源码 → 红",
"[`d`](https://github.com/o/r/blob/main/docs/x.md) " + anchor("function loadProfile("),
src, True)
check("一行两个链接,取最近的那个",
link("a/other.ts") + " 前文 " + link("a/b.ts") + " —— " + anchor("function loadProfile("),
src, False)
check("搜后面跟多个代码串,只有第一个是锚点",
link("a/b.ts") + " —— " + anchor("function loadProfile(") + " " + BT + "无关描述" + BT,
src, False)
check("逗号连接(不是破折号)也算",
link("a/b.ts") + "," + anchor("function loadProfile("), src, False)

# 分母:一条锚点都没有的文本,scan 必须返回 0(上游据此 exit 2)
_p, n0 = scan_text("docs/f.md", "一段没有任何锚点的正文")
cases.append(("无锚点文本 → count=0(上游 exit 2)", n0 == 0, f"count={n0}"))

for name, ok, detail in cases:
print(f" {'ok ' if ok else 'FAIL'} {name} [{detail}]")
bad = sum(1 for _n, ok, _d in cases if not ok)
print(f"selftest: {len(cases) - bad}/{len(cases)} ok")
return 1 if bad else 0


def main() -> int:
if "--selftest" in sys.argv:
return selftest()
repo = Path(subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True,
).stdout.strip())
return run(repo)


if __name__ == "__main__":
sys.exit(main())
Loading
Loading