diff --git a/.github/workflows/refresh-profile-data.yml b/.github/workflows/refresh-profile-data.yml index 7473236..f10d96e 100644 --- a/.github/workflows/refresh-profile-data.yml +++ b/.github/workflows/refresh-profile-data.yml @@ -9,6 +9,7 @@ on: - "docs/i18n/zh-CN/README.md" - "scripts/build_profile_svgs.py" - "assets/**" + - "tests/**" workflow_dispatch: permissions: @@ -31,6 +32,12 @@ jobs: - name: Marker and parity lint run: python scripts/build_profile_svgs.py --lint + # The characterization suite is zero-dependency and sub-second: make the + # contract tests, README structure guards and SVG golden masters as + # mandatory as the lint instead of local-only. + - name: Contract and structure tests + run: python -m unittest discover tests + refresh: if: github.event_name != 'pull_request' runs-on: ubuntu-latest diff --git a/README.md b/README.md index a6f24b7..70821f6 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ - **`give-me-a-break`** — the state machine's `evaluate` has zero time dependency. It takes the clock as an argument, so the entire rest/work/AFK lifecycle is testable against a virtual clock: sleep, crash recovery with fast-forward, a display unplugged mid-break. Three modules, one of which knows nothing about macOS. - **`hyper-git`** — `engine/` contains no `vscode` import. That single constraint is why 403 unit tests exist: changelist grouping, DAG swimlane layout, and Conventional Commits validation are all testable without an editor host. The architecture note calls it "Path B" — consume the stable `vscode.git` API and hand-render everything above it, rather than fork. - **`hyper-git`, again** — five AI seams (`ILlmProvider`, `ICommitMessageProvider`, `IPreCommitInspector`, `IChangelistGrouper`, `IConflictResolver`) are wired in as null implementations, modeled on JetBrains' `CheckinHandler` lifecycle. The interfaces ship; the intelligence is deferred to M5. Declaring the seam early is cheap and declaring it late is not. -- **`coding-proxy`** — the failover chain is policy, not plumbing: circuit breaker state and quota accounting are per-vendor and local, in SQLite-WAL. No Redis, no queue, so a single process is the whole deployment and a restart loses nothing that matters. +- **`coding-proxy`** — the failover chain is policy, not plumbing: circuit breaker state and quota accounting are per-vendor and local, in SQLite-WAL — so a single process is the whole deployment, and a restart loses nothing that matters. - **`negentropy`** — the default path runs on the cheapest of its pluggable backends with **no cloud credentials at all**; if the cheapest configuration is not runnable, nobody runs the expensive one either. Stated as a virtue, this is testability. Stated honestly, it is what a single maintainer has to do to survive his own codebase: nothing here has a second pair of eyes, so the design has to make the mistakes cheap to find. And the null AI seams are still null — the interfaces are a plan, not a feature. @@ -290,7 +290,7 @@ sequenceDiagram P-->>C: SSE stream, in Anthropic's shape ``` -The client is told nothing. That is the whole product: one line of configuration, and the failure mode changes from *stop working* to *work slower on someone else's model*. Nine vendors are wired — Claude plans, GitHub Copilot, Google Antigravity, Z AI's GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao — with a per-vendor circuit breaker and quota guard, and a local SQLite-WAL dashboard so the burn is visible before the bill is. +The client is told nothing. That is the whole product: one line of configuration, and the failure mode changes from *stop working* to *work slower on someone else's model*. Each of the nine vendors named on the card above gets its own circuit breaker and quota guard, and a local SQLite-WAL dashboard makes the burn visible before the bill is. FastAPI and httpx. 12 releases, the latest still alpha-tagged — translation fidelity across three request shapes is the part that keeps not being finished. Chained failover also means a request can succeed on a model you did not choose; the dashboard exists partly so that is auditable. diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md new file mode 100644 index 0000000..635873c --- /dev/null +++ b/docs/.agents/issue.md @@ -0,0 +1,32 @@ +# Issue 台账 + +| ID | 标题 | 状态 | 完成时间 | 详情 | +|---|---|---|---|---| +| I-001 | 九厂商清单双层手写漂移:单侧修复未除根,结构性收敛为卡片单一事实源 | ✅ | 2026-10-18 | [详情](#I-001) | +| I-002 | 语义级镜像漂移与锚点死链无机器门:lint 仅护 marker 级 | ✅ | 2026-10-18 | [详情](#I-002) | + + +## I-001:九厂商清单双层手写漂移 + +**问题描述**:coding-proxy 九家厂商清单在项目卡(`README.md:163`)与深潜块(`README.md:293`)两处全文手写,中英共四份拷贝,无同步机制。PR #7 曾发现卡片侧漏写 Xiaomi(8 家对 9 家),修复方式为只补卡片侧数据——漂移根因(双清单结构)未除。 + +**表因根因**:v5 重建时同一事实一次性写入两处;两清单命名形不同(Copilot/GitHub Copilot 等),肉眼 diff 困难;lint 只校验 marker 流,散文重复不在守护范围。 + +**处理方式**(2026-10-18,preening-substrate 结构性熵减):`README.md:163` 定为唯一权威清单(承诺性陈述,加厂商先改这里);`:293` 深潜句收敛为「上方卡片点名的九家厂商」回指,保留每家熔断/配额守卫的增量叙述;中英同步镜像。验证:31 用例 + `--lint` 全绿,残留扫描确认厂商名仅存 :163。 + +**后续防范**:改厂商清单只动 :163;新增手写事实清单时先指定权威位置(编辑契约见 [knowledge-map.md](knowledge-map.md#双语镜像编辑契约卡) 第 7 条)。 + +**同类影响**:其余手写数字面(`403` 三处、`:249` 的 1,378、`:36` 年度序列 prose)已登记为漂移面,按 C4(角色不同)暂保留,未动。 + + +## I-002:语义级镜像漂移与锚点死链无机器门 + +**问题描述**:CI lint 与测试只守护 marker 级契约(75/13 流、parity、assets 闭包);三类承重面无机器守护——① 中英两文件 marker 外文本的语义级镜像漂移;② `` 锚点 / `#user-content-` 导航链接 / FIG key 三方一致性(破坏即静默死链);③ `tests/` 31 用例未接 CI(仅本地跑)。 + +**表因根因**:PR #8 建立 tests 时按最小护栏取舍;锚点前缀是 GitHub 渲染侧行为,仓库内工具不感知。 + +**处理方式**(2026-10-18,两步闭环):第一步重构时以 lab 级 gate 脚本护航(锚点三方一致 + marker 行号镜像 + 骨架行号镜像 + 行数相等),编辑契约沉淀至 [knowledge-map.md](knowledge-map.md#双语镜像编辑契约卡);第二步将检查上收为 [`tests/test_readme_structure.py`](../../tests/test_readme_structure.py)(零依赖纯正则,35 用例之一),并在 `refresh-profile-data.yml` lint job 中接入 `python -m unittest discover tests`,全套护栏由本地自觉变 PR 强制门。 + +**后续防范**:改任一 README 后跑 knowledge-map 契约卡第 6 条两条命令(与 CI 同款);新增结构元素(details/锚点/表格)时行级镜像约束由测试自动拦截。 + +**注意事项**:本地 Markdown 预览不加 `user-content-` 前缀,导航链接在本地必显死链——这是设计使然,以 GitHub 渲染为准。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md new file mode 100644 index 0000000..bdf2ad0 --- /dev/null +++ b/docs/.agents/knowledge-map.md @@ -0,0 +1,28 @@ +# Knowledge Map + +本仓文档索引与跨文档编辑契约的唯一入口。 + +## 文档索引 + +| 文档 | 职责 | +|---|---| +| [`README.md`](../../README.md) | 英文 profile 主页(月度构建产物:13 FIG + 75 DATA 标记) | +| [`docs/i18n/zh-CN/README.md`](../i18n/zh-CN/README.md) | 中文镜像主页(与英文版逐行结构镜像) | +| [`docs/motion-constraints.md`](../motion-constraints.md) | SVG 动效/布局闸门的「为什么」层(闸门机械强制在 `scripts/build_profile_svgs.py`,冲突时以闸门为准) | +| [`docs/.agents/issue.md`](issue.md) | Issue 台账(头部索引 + 详情锚点) | + +## 双语镜像编辑契约卡 + +改任一 README 前必读。两份文件是**逐行结构镜像**:marker 行号、结构骨架行号(details/summary/锚点/表格/代码块)、总行数必须逐位一致;合法差异仅三类——正文语言、img `src` 绝对化(中文版仓库外渲染所需)、语言切换行相对路径。 + +1. **机器领地,永不手改**:`…` 体(img/alt/src 每次刷新从零重建,手改必被覆盖);`值` 的值。生成器为 [`scripts/build_profile_svgs.py`](../../scripts/build_profile_svgs.py)。 +2. **标记即契约**:key 必须 ∈ `FACT_KEYS`/`FIG_SPEC`;两文件 (kind,key) 有序流必须全等且非空(`audit_parity`);增删/换序标记须两文件同步,且计数变化需同步 `tests/test_contract.py` 的 75/13 硬编码断言。 +3. **含 marker 的行谨慎编辑**:值以外的行内文字可改,但 marker 本身与其相对顺序不可动;`DATA:cur_year`/`DATA:cur_total` 是月度刷新「当年数字不回退」守卫的状态源,永不删除。 +4. **锚点三方同名同序**:`` ×13、两条簇导航 `` 行的 `#user-content-X` 链接、FIG key `X` 三者集合与顺序一致(`tests/test_readme_structure.py` 机器强制);GitHub 对手写 `name` 自动加 `user-content-` 前缀,链接侧必须手写该前缀。 +5. **冻结面绕行**:mermaid 块、upstream/census/道法术表格、IEEE 脚注、recompute 命令块承载既有契约,结构性重构一律绕行。 +6. **编辑后验证**(仓库根,两命令即 PR CI lint job 全部门禁): + ```bash + python3 -m unittest discover tests # 35 用例:Golden Master + 锚点三方一致 + 双文件行级镜像 + 单调性状态源 + python3 scripts/build_profile_svgs.py --lint # 零网络,marker 流与 assets 闭包审计 + ``` +7. **散文重复收敛原则**:同一事实多处手写 = 漂移风险(先例:九厂商清单曾单侧漏一家,PR #7 只修了数据);收敛时指定唯一权威位置(如承诺性陈述放项目卡),其余降为回指;机器供数(DATA/alt)的重复无漂移风险,不做收敛。 diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index e398b21..7de2289 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -212,7 +212,7 @@ - **`give-me-a-break`** —— 状态机的 `evaluate` 零时间依赖。时钟由参数传入,于是整套休息/工作/AFK 生命周期都能在虚拟时钟下测试:睡眠、崩溃后快进恢复、休息中途被拔掉的显示器。三个模块,其中一个对 macOS 一无所知。 - **`hyper-git`** —— `engine/` 里没有一处 `vscode` import。仅这一条约束,就是 403 个单元测试之所以存在的原因:变更列表分组、DAG 泳道布局、Conventional Commits 校验,全都不需要编辑器宿主即可测试。架构说明称之为「Path B」——消费稳定的 `vscode.git` API,其上一切手绘,而不去 fork。 - **`hyper-git`,再一次** —— 五个 AI 接缝(`ILlmProvider`、`ICommitMessageProvider`、`IPreCommitInspector`、`IChangelistGrouper`、`IConflictResolver`)以空实现预先接入,形制取自 JetBrains 的 `CheckinHandler` 生命周期。接口先交付,智能延到 M5。早声明接缝很便宜,晚声明就不便宜了。 -- **`coding-proxy`** —— 故障转移链是策略,不是管道:熔断状态与配额记账按厂商分离、且是本地的,落在 SQLite-WAL 里。没有 Redis、没有队列,于是单进程就是整个部署,重启不会丢掉任何要紧的东西。 +- **`coding-proxy`** —— 故障转移链是策略,不是管道:熔断状态与配额记账按厂商分离、且是本地的,落在 SQLite-WAL 里——于是单进程就是整个部署,重启不会丢掉任何要紧的东西。 - **`negentropy`** —— 默认路径跑在可插拔后端中最便宜的那档上,**完全不需要云凭据**;如果最便宜的配置跑不起来,也就没人会去跑贵的那个。 说得好听,这叫可测性。说得诚实,这是一个单人维护者为了在自己的代码库里活下来必须做的事:这里没有第二双眼睛,所以设计必须让错误便宜到能被找出来。而那五个空的 AI 接缝,至今仍然是空的——接口是一份计划,不是一个功能。 @@ -290,7 +290,7 @@ sequenceDiagram P-->>C: SSE 流,仍是 Anthropic 的形状 ``` -客户端什么都不知道。这就是产品的全部:一行配置,把失败模式从「停止工作」改成「用别人的模型慢一点工作」。已接入九家——Claude 套餐、GitHub Copilot、Google Antigravity、Z AI 的 GLM、MiniMax、Qwen、小米、Kimi、豆包——每家独立熔断与配额守卫,另有本地 SQLite-WAL 看板,让消耗在账单之前就可见。 +客户端什么都不知道。这就是产品的全部:一行配置,把失败模式从「停止工作」改成「用别人的模型慢一点工作」。上方卡片点名的九家厂商,每家独立熔断与配额守卫,另有本地 SQLite-WAL 看板,让消耗在账单之前就可见。 FastAPI 与 httpx。12 个 release,最新仍带 alpha 标——三种请求形状之间的转译保真度,正是那个一直没做完的部分。链式转移也意味着请求可能在你没选的模型上成功;看板的存在,一部分就是为了让这件事可审计。 diff --git a/scripts/build_profile_svgs.py b/scripts/build_profile_svgs.py index 3a534a8..81da44f 100644 --- a/scripts/build_profile_svgs.py +++ b/scripts/build_profile_svgs.py @@ -1655,6 +1655,41 @@ def upstream_alt(f, items, pub_prs): ledger, format(pub_prs, ","), len(rows) / pub_prs * 100)) +def cadence_alt(f, rel_lists, domain, trunk_commits, rel_total): + lanes = sorted(((n, sorted(v, key=lambda r: r["published_at"])) + for n, v in rel_lists.items() if v), + key=lambda kv: (-len(kv[1]), kv[0])) + # The trunk sentence carries two numbers that MOVE — negentropy is the + # active repo, so a literal here would have the alt contradict the + # neg_commits marker rendered beside it within a month. + trunk_rels = len(rel_lists.get(TRUNK, ())) + if f == EN: + rows = "; ".join( + "%s: %d releases, %s on %s through %s on %s%s" % ( + n, len(v), v[0]["tag_name"], v[0]["published_at"][:10], + v[-1]["tag_name"], v[-1]["published_at"][:10], + "" if not all(r["prerelease"] for r in v) else ", all pre-releases") + for n, v in lanes) + return ("Dot timeline of %d public releases across %d repositories on one shared " + "date axis, %s to %s. %s. Hollow dots are pre-releases. %s shows only its " + "%d release candidates against %s commits because it is a deployed " + "service, not a distributed package — its shipping unit is the merged " + "pull request, not the tag. Data: GitHub." + % (rel_total, len(lanes), domain[0], domain[1], rows, + TRUNK, trunk_rels, format(trunk_commits, ","))) + rows = ";".join( + "%s %d 个 release,%s(%s)至 %s(%s)%s" % ( + n, len(v), v[0]["tag_name"], v[0]["published_at"][:10], + v[-1]["tag_name"], v[-1]["published_at"][:10], + "" if not all(r["prerelease"] for r in v) else ",全部为预发布") + for n, v in lanes) + return ("各仓库公开 release 的点式时间线(共用日期轴,%s 至 %s)。%s。空心点为预发布。" + "%s 在 %s 条提交面前只有 %d 个 rc,因为它是部署型服务而非分发包——" + "它的交付单元是已合并 PR,不是 tag。数据:GitHub。" + % (domain[0], domain[1], rows, + TRUNK, format(trunk_commits, ","), trunk_rels)) + + LAT_EDGES = [1, 2, 5, 15, 60, 240, 1440, float("inf")] LAT_LABELS = ["<1m", "1-2", "2-5", "5-15", "15-60m", "1-4h", "4-24h", ">24h"] @@ -1862,41 +1897,6 @@ def windowed_run(dom): die(f"release roster {sorted(rel_counts)} != source roster " f"{sorted(repo_commits)} — a repo's releases would render as 0, refusing") - def cadence_alt(f, rel_lists, domain, trunk_commits): - lanes = sorted(((n, sorted(v, key=lambda r: r["published_at"])) - for n, v in rel_lists.items() if v), - key=lambda kv: (-len(kv[1]), kv[0])) - # The trunk sentence carries two numbers that MOVE — negentropy is the - # active repo, so a literal here would have the alt contradict the - # neg_commits marker rendered beside it within a month. - trunk_rels = len(rel_lists.get(TRUNK, ())) - if f == EN: - rows = "; ".join( - "%s: %d releases, %s on %s through %s on %s%s" % ( - n, len(v), v[0]["tag_name"], v[0]["published_at"][:10], - v[-1]["tag_name"], v[-1]["published_at"][:10], - "" if not all(r["prerelease"] for r in v) else ", all pre-releases") - for n, v in lanes) - return ("Dot timeline of %d public releases across %d repositories on one shared " - "date axis, %s to %s. %s. Hollow dots are pre-releases. %s shows only its " - "%d release candidates against %s commits because it is a deployed " - "service, not a distributed package — its shipping unit is the merged " - "pull request, not the tag. Data: GitHub." - % (rel_total, len(lanes), domain[0], domain[1], rows, - TRUNK, trunk_rels, format(trunk_commits, ","))) - rows = ";".join( - "%s %d 个 release,%s(%s)至 %s(%s)%s" % ( - n, len(v), v[0]["tag_name"], v[0]["published_at"][:10], - v[-1]["tag_name"], v[-1]["published_at"][:10], - "" if not all(r["prerelease"] for r in v) else ",全部为预发布") - for n, v in lanes) - return ("各仓库公开 release 的点式时间线(共用日期轴,%s 至 %s)。%s。空心点为预发布。" - "%s 在 %s 条提交面前只有 %d 个 rc,因为它是部署型服务而非分发包——" - "它的交付单元是已合并 PR,不是 tag。数据:GitHub。" - % (domain[0], domain[1], rows, - TRUNK, format(trunk_commits, ","), trunk_rels)) - - # ------------------------------------------------- figure derivations ---- # Degenerate-but-reachable shapes die cleanly here instead of raising a bare # traceback further down; the write-nothing contract holds either way. @@ -2078,7 +2078,8 @@ def ecdf_points(): "accrual": {f: accrual_alt(f, monthly, DOMAIN, grad_events, commits_total) for f in README_FILES}, "lifecycles": {f: lifecycles_alt(f, spans, DOMAIN) for f in README_FILES}, - "cadence": {f: cadence_alt(f, rel_lists, DOMAIN, len(repo_commits.get(TRUNK, ()))) + "cadence": {f: cadence_alt(f, rel_lists, DOMAIN, + len(repo_commits.get(TRUNK, ())), rel_total) for f in README_FILES}, "streak": {f: streak_alt(f, rug_act, RUG_DAYS - rug_act, rug_run, rug_dom, RUG_DAYS) for f in README_FILES}, diff --git a/tests/test_readme_structure.py b/tests/test_readme_structure.py new file mode 100644 index 0000000..ea4fbcb --- /dev/null +++ b/tests/test_readme_structure.py @@ -0,0 +1,70 @@ +"""Machine guards for the README contracts that --lint cannot see. + +The marker stream, its parity and the assets closure are audited by +scripts/build_profile_svgs.py --lint on every PR. Three load-bearing surfaces +used to rest on human discipline only; these tests lock them: + +1. Anchor triple-consistency — every anchor, every + #user-content-X link in the two cluster-nav rows, and every FIG key + must be the same set in the same order (nav links read across the two + rows follow the FIG cluster order). A broken link here fails silently on + GitHub (the user-content- prefix is renderer-side). +2. Bilingual line-mirror — the two READMEs must carry their markers and their + structural skeleton (details/summary/anchors/tables/code fences) at + identical line numbers, and stay the same length. Prose may differ; the + shape may not. +3. Monotonicity state source — DATA:cur_year/DATA:cur_total must survive in + both files: the monthly refresh reads them back from disk as its only + state (see main() in the generator). +""" +import re +import unittest + +from tests._bps import ROOT + +EN, ZH = "README.md", "docs/i18n/zh-CN/README.md" + +MARKER = re.compile(r"") +FIG_OPEN = re.compile(r"") +ANCHOR = re.compile(r'') +NAV = re.compile(r"#user-content-([a-z]+)") +SKELETON = re.compile( + r"\d{4}") + self.assertRegex(text, r"[\d,]+") + + +if __name__ == "__main__": + unittest.main()