Compare commits

...

83 Commits

Author SHA1 Message Date
codex-bot 15024c3908 merge: include latest company research updates from PR #23 2026-10-30 18:20:34 +08:00
codex-bot 770be83692 Backfill 2025 discovery ledger and verify public archive scope 2026-10-30 18:19:29 +08:00
codex-bot 083f532609 merge: integrate document upload API from PR #24 2026-10-30 18:17:53 +08:00
codex-bot 2b3706c90d merge: integrate company categorization and deployment fixes from PR #23 2026-10-30 18:17:53 +08:00
codex-bot 6bfeea4e7e feat: add scoped document API and resumable batch uploader 2026-10-30 18:14:33 +08:00
codex-bot f156406265 Classify Thrive services rollup and exclude Brila existing operator 2026-10-30 18:12:52 +08:00
codex-bot e65d90c78b Resolve Utopai founding history and preserve Fundraisly entity gaps 2026-10-30 18:10:35 +08:00
codex-bot 96f1417cda Add Goldfish with investor-backed founding-year evidence 2026-10-30 18:08:01 +08:00
codex-bot 347b9587c6 Reconcile Tapflow history and Atoms company-year evidence 2026-10-30 18:04:11 +08:00
codex-bot 677cd73945 Fix Peec team bounds regression assertion 2026-10-30 17:54:08 +08:00
codex-bot e075eee8a5 Separate legacy launches from unresolved Happycapy entity lineage 2026-10-30 17:41:15 +08:00
codex-bot d971362a97 Resolve four launch candidates through operator and app history evidence 2026-10-30 17:31:44 +08:00
codex-bot 0e5ccafa30 Classify remaining directory profiles using official business evidence 2026-10-30 17:21:06 +08:00
Zayden-Jung 0c2a909521 Merge pull request '汇总 README 整理与 Product Hunt 候选历史核查' (#22) from codex/readme-research-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 3m51s
Reviewed-on: #22
2026-10-30 09:17:21 +00:00
codex-bot a58a409c38 merge: combine README cleanup and Product Hunt history research 2026-10-30 17:13:48 +08:00
codex-bot c4ab5caac0 merge: integrate Product Hunt candidate history from PR #21 2026-10-30 17:13:13 +08:00
codex-bot 4cbb88181a Exclude four pre-2025 businesses from launch candidates 2026-10-30 17:10:51 +08:00
codex-bot ce87abd1cb docs: streamline README around content management and operations 2026-10-30 17:10:00 +08:00
Zayden-Jung dc7ec078e1 Merge pull request '补充行业基准适用性与收费研究,适配数据库文档' (#19) from codex/ai-research-context into main
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Reviewed-on: #19
2026-10-30 09:05:44 +00:00
codex-bot bba9fddef1 Add benchmark methodology and pricing research after document migration 2026-10-30 17:01:09 +08:00
Zayden-Jung 1302287205 Merge pull request '汇总研究更新、奇思妙想与文档数据库迁移(main-only CI)' (#18) from codex/docs-integration into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m37s
Reviewed-on: #18
2026-10-30 08:48:28 +00:00
codex-bot 59a1e4608b merge: preserve the latest Wabi research in the document database migration 2026-10-30 16:45:16 +08:00
codex-bot ed99743730 merge: integrate database documents, preserve all proposals and keep CI main-only 2026-10-30 16:40:53 +08:00
codex-bot 74af205b29 Add Wabi company case with attributed founding and free-service limits 2026-10-30 16:40:38 +08:00
codex-bot cffca6942b merge: retain the traffic platform proposal from PR #15 2026-10-30 16:39:03 +08:00
codex-bot bf58a27779 merge: integrate income group labels from PR #16 2026-10-30 16:39:03 +08:00
codex-bot 922835704b merge: integrate AI research sources from PR #14 2026-10-30 16:39:03 +08:00
codex-bot a80f6568ff fix: restore main-only CI policy and remove branch validation workflow 2026-10-30 16:36:20 +08:00
codex-bot 864a30356d Record bounded TechCrunch candidates and original-source checks 2026-10-30 16:35:52 +08:00
codex-bot 534795617d fix: decouple report checks from legacy snapshots and tolerate slow dependency downloads
Verify database-backed documents / verify (pull_request) Failing after 4m0s
2026-10-30 16:32:16 +08:00
codex-bot d57e9b77d7 feat: make the site database authoritative for documents and catalog
Verify database-backed documents / verify (pull_request) Failing after 6m6s
2026-10-30 16:23:54 +08:00
codex-bot 9a026a08a5 登记中文来源线索并补Agent与基础设施公司个案 2026-10-30 16:22:55 +08:00
codex-bot 62d3d498bb 复核EveryApp入表并保留原候选初筛审计 2026-10-30 16:09:43 +08:00
codex-bot 62c015fa9b 核对产品运营主体并补Lightfield转型对照 2026-10-30 16:00:13 +08:00
codex-bot ec95f3be31 明确收入图表的业务类别与统计分组层级 2026-10-30 15:51:57 +08:00
codex-bot 744e3314b8 核对七项候选年份并隔离第三方财务字段异常 2026-10-30 15:44:50 +08:00
codex-bot 79a6a8dbd7 补2025产品候选并核对成立年与Trace主体 2026-10-30 15:21:28 +08:00
codex-bot 0dcd178394 docs: explore Endor as a coding agent traffic platform 2026-10-30 15:16:35 +08:00
codex-bot dca7c81889 登记Product Hunt候选并补充Kilo公司个案 2026-10-30 15:11:59 +08:00
codex-bot f27b82b6a5 登记AI研究来源边界并列明覆盖缺项 2026-10-30 14:24:12 +08:00
Zayden-Jung 25c5e83d08 Merge pull request '新增 Smalltalk-80 现代化研究导读与源码研究入口' (#13) from codex/smalltalk-guide into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m56s
Reviewed-on: #13
2026-10-30 06:19:43 +00:00
codex-bot 798c6d17d0 docs: add modern Smalltalk-80 study guide 2026-10-30 14:17:39 +08:00
Zayden-Jung c2ea0900e6 Merge pull request '补充AI公司收入、合同与业务规模口径核查' (#12) from codex/ai-research-revenue-scope into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m30s
Reviewed-on: #12
2026-10-30 06:04:36 +00:00
codex-bot 1cc2c9f97f 补充6家AI公司合同与交易支出口径核查 2026-10-30 13:53:00 +08:00
Zayden-Jung 62bba8d320 Merge pull request '新增AI公司收入分布、证据核查与研究报告' (#11) from codex/ai-research-income into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 7m48s
Reviewed-on: #11
2026-10-30 05:48:56 +00:00
codex-bot 28fa155829 深化8家AI公司收入声明及合同并购口径核查 2026-10-30 06:58:06 +08:00
codex-bot db47c1da13 补全20家AI公司主营买家与交付边界 2026-10-30 06:51:40 +08:00
codex-bot 446ad80089 深化20条目录资料并保留发现来源审计 2026-10-30 06:47:02 +08:00
codex-bot b52d1e59a4 增加研究覆盖审计及分类数据直达链接 2026-10-30 06:24:56 +08:00
codex-bot c7aa6e54c6 补充Bud收购案例与Rocket转型业务边界 2026-10-30 06:08:28 +08:00
codex-bot f93e094ba2 明确核查范围并补齐公司明细关联 2026-10-30 06:02:20 +08:00
codex-bot 5ee28cef98 补充Gradium第三方语音评测及测试范围 2026-10-30 05:58:54 +08:00
codex-bot 8da38694bc 增加收入分布快照报告与公司明细直达链接 2026-10-30 05:54:51 +08:00
codex-bot 850ed8f9a7 补充Speedrun收入声明并关联公司核查记录 2026-10-30 05:48:49 +08:00
codex-bot 43bd5a86bf 补足ARR历史月份并更新企业规模与业务边界 2026-10-30 05:44:25 +08:00
codex-bot cfb9273f88 增加技术验证证据筛选并区分材料与独立测试 2026-10-30 05:41:35 +08:00
codex-bot 3e0cbf9831 补充中文AI样本及成立年和近名实体核查 2026-10-30 05:38:35 +08:00
codex-bot aa2d3fe244 补充Runable经营案例与商业模式初评 2026-10-30 05:35:30 +08:00
codex-bot 2e4dd8c6c7 补齐59条主营分类并记录3条资料缺口 2026-10-30 05:32:05 +08:00
codex-bot 1c4e2fc9b6 按官方长简介复核62条AI公司业务分类 2026-10-30 05:30:01 +08:00
codex-bot 69fa0b240f 新增支付平台收入样本与分布分析 2026-10-30 05:25:05 +08:00
Zayden-Jung 6d26a5b0c1 Merge pull request 'ci: 仅 main 分支执行 CI/CD' (#8) from codex/main-only-cicd into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m20s
Reviewed-on: #8
2026-10-29 20:34:12 +00:00
Zayden-Jung bb9ebaa052 Merge pull request '扩展 AI 公司目录覆盖与来源核查台账' (#10) from codex/ai-research-coverage into main
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled
Reviewed-on: #10
2026-10-29 20:34:04 +00:00
codex-bot d50281a1e9 扩展 AI 公司目录覆盖并保留逐条核查台账
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m10s
2026-10-30 04:31:33 +08:00
codex-bot 3e2183798d 补充 AI 公司收入证据及声明纠错案例
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 13m33s
2026-10-30 03:54:22 +08:00
codex-bot 562618cd68 ci: restrict workflows to main branch 2026-10-30 03:43:53 +08:00
Zayden-Jung f550fc8955 Merge pull request '修复 AI 公司研究导入引用与平板布局' (#7) from codex/ai-research-validation into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m51s
Reviewed-on: #7
2026-10-29 19:37:04 +00:00
codex-bot 157144b3f9 Correct research review references and advance the data revision
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 6m46s
2026-10-30 03:35:17 +08:00
codex-bot 3cfce44a25 Align research workbench breakpoints with document navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Waiting to run
2026-10-30 03:34:05 +08:00
Zayden-Jung e91255856c Merge pull request '新增 AI 公司研究工作台并共用文档站数据库' (#6) from codex/ai-company-research into main
Verify and deploy Tabularium / verify-and-deploy (push) Failing after 2m26s
Reviewed-on: #6
2026-10-29 19:31:59 +00:00
codex-bot 1250b674b0 Add AI company research workbench backed by the shared site database
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m57s
2026-10-30 03:25:38 +08:00
Zayden-Jung 074881b650 Merge pull request '补充 Agent 编程边界与 LangGraph / DSPy 研究草案' (#5) from codex/agent-composition into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 6m7s
Reviewed-on: #5
2026-10-28 14:28:52 +00:00
codex-bot 1966d7a0a7 Document agent composition boundaries and judgment object proposal
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 7m58s
2026-10-28 22:26:12 +08:00
Zayden-Jung 319de8a3a7 Merge pull request '按 Supabase 参考修正为横向分类下拉导航' (#4) from codex/category-menus into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m33s
Reviewed-on: #4
2026-10-28 11:55:47 +00:00
codex-bot 787491622b Replace collection select with horizontal category dropdown navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
2026-10-28 19:38:07 +08:00
Zayden-Jung e448acb904 Merge pull request '按分类与合集组织文档 URL,收紧导航与阅读排版' (#2) from codex/docs-navigation into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m33s
Reviewed-on: #2
2026-10-28 11:16:16 +00:00
codex-bot e286139572 Redirect legacy document URLs after authentication without prerendering aliases
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 5m25s
2026-10-28 19:15:31 +08:00
codex-bot c49c8348d2 Merge published comic changes and adapt navigation verification
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m28s
2026-10-28 19:02:26 +08:00
Zayden-Jung 7a9c88a05a Merge pull request '为 Endor 项目讨论增加双图漫画与共享图片压缩策略' (#3) from codex/endor-review-comic into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 4m34s
Reviewed-on: #3
2026-10-28 10:57:58 +00:00
codex-bot 422d6622a2 Organize document URLs by category and collection with compact navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 18:38:13 +08:00
codex-bot bd932c36b2 Add a compressed illustration carousel with slide indicators
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m44s
2026-10-28 18:16:17 +08:00
codex-bot c08371647d Document reusable review capabilities and publish compressed comic 2026-10-28 18:02:37 +08:00
Zayden-Jung 8be53c1e25 Merge pull request '完善文档独立导航,精简边框并增加渐变与交互反馈' (#1) from codex/docs-ui into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 10m25s
Reviewed-on: #1
2026-10-28 07:35:50 +00:00
96 changed files with 64965 additions and 3833 deletions
+5
View File
@@ -6,4 +6,9 @@
**/*.pyc
**/*.tsbuildinfo
.artifacts
apps/docs/public/figures/**/*.webp
.env*
**/*.sqlite
**/*.sqlite-*
**/*.db
+2 -1
View File
@@ -2,7 +2,6 @@ name: Verify and deploy Tabularium
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
@@ -11,6 +10,7 @@ concurrency:
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: ces-control
timeout-minutes: 45
env:
@@ -22,6 +22,7 @@ jobs:
with:
github-server-url: ${{ gitea.server_url }}
persist-credentials: false
fetch-depth: 0
- name: Verify with persistent caches or reuse verified image
run: python3 scripts/ci.py
- name: Deploy latest main with health checks and rollback
+8
View File
@@ -11,3 +11,11 @@ __pycache__/
!.env.example
.artifacts/
# Browser raster derivatives are generated on the confirmed remote runner.
/apps/docs/public/figures/**/*.webp
# Application data and content exports belong in private persistent storage.
*.sqlite
*.sqlite-*
*.db
+2 -2
View File
@@ -1,9 +1,9 @@
# Tabularium working agreements
- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 使用现有 ces-control Runner,在 main 通过验证后自动部署。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。
- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。
- Markdown 正文放在 `content/docs`,Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用文档 API 或 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。
- 未确认的架构提议明确标注为草案,不能表述为现有实现。
- 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。
- 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot <codex-bot@gitea.entroia.local>。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。
+171 -63
View File
@@ -1,108 +1,216 @@
# Endor · Tabularium
Endor 文档与制图 monorepo。Tabularium 取古罗马档案馆之意。支持本地开发,并由 Gitea Actions 在 ces-control 上验证与部署。
Endor 的文档站与 AI 公司研究工作台,包含文档阅读、全文搜索、研究数据展示和制图工具。使用 Next.js Pages Router、React、TypeScript 与 Tailwind CSS;JavaScript workspace 使用 pnpm,制图 Python 项目使用 uv。
## 本地开发
## 代码与内容放在哪里
需要 Node.js 22.13+(推荐 LTS)、pnpm 11.25、Python 3.11+ 和 uv。
首次安装依赖需要访问软件包源。本地开发服务只监听 127.0.0.1。
文档正文、目录元数据和修订记录由站点 SQLite 数据库管理,日常写作直接更新数据库,无需提交代码、构建或部署。研究业务数据仍沿用版本化 JSON 导入机制,后续单独迁移。
| 内容 | 权威来源与维护方式 |
| --- | --- |
| 文档正文、分类、合集、分组、摘要、草案和推荐状态 | 数据库;通过文档 API 或服务器上的 `docs` 管理工具读写 |
| 文档旧地址与修订历史 | 数据库;改名保留别名,修改追加修订 |
| AI 公司、观测、证据、候选与核查记录 | `content/research` 中的版本化快照;服务端显式按版本导入同库后查询 |
| 页面、组件、管理工具和部署逻辑 | 本仓库,通过工作分支与 PR 评审 |
| 图形源码与静态资源 | `tools/figures` 与 `apps/docs/public`;生成步骤在远程 Runner 执行 |
以上数据库数据共用 `AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`,该文件名沿用历史命名。生产环境挂载到容器 `/auth`,不随镜像替换。文档使用 `documents`、`document_aliases`、`document_revisions`、`document_imports` 表,研究数据使用 `research_*` 表,登录状态使用 `login_state` 表。
仓库不接收新的文档正文 Markdown、目录 JSON、数据库文件或日常正文快照。历史 Git 正文仅用于一次性迁移;运行时不读取 `content/docs` 或 `content/catalog.json`。README 维护程序使用与运维约定,研究个案、统计数量与内容更新日志在工作台和数据库文档中查看。
## 阅读与访问
首页、阅读页、目录及全文搜索按请求读取数据库,内容更新后刷新即可看到。旧文件式地址和改名前的语义地址继续跳转到当前地址,并保留查询参数。
- 文档原文:`GET /api/documents/source?slug=<文档地址>`。
- 全文搜索:`GET /api/search`。
- 研究工作台:`/docs/other-research/ai-company-research/overview`。
- 收入样本:`/docs/other-research/ai-company-research/income-snapshot`。
- 覆盖与缺项:`/docs/other-research/ai-company-research/coverage`。
正文、搜索、研究 API 与静态资料均经过站点登录认证,私有响应使用 `private, no-store`。Markdown 支持 GFM 表格,不解析原始 HTML;正文图片写入时检查压缩格式。草案标记用于展示状态,不构成单独的访问权限。
## 管理文档
部署后,管理员可在服务器上使用 `/opt/endorphin-tabularium-docs/docs`。该工具在当前容器中操作数据库,需要服务器运维权限。自动化上传使用下文的文档 API,日常读写无需登录服务器。
```sh
pnpm install --frozen-lockfile
uv sync --project tools/figures --locked
pnpm dev
sudo /opt/endorphin-tabularium-docs/docs list
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft
sudo /opt/endorphin-tabularium-docs/docs history 04-endor-purpose-draft
```
访问 http://127.0.0.1:3000 ,先使用验证器动态码登录。首次本地使用需按下文初始化私有认证配置;缺少配置时正文关闭访问。站点无外部字体或 CDN 资源。
`save` 从标准输入接收完整 JSON。必填字段如下:
```text
apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS
content/docs/ Markdown 文档源,支持目录,路径成为 /docs/<path> 路由
tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
| 字段 | 用途 |
| --- | --- |
| `slug` | 语义化公开地址,不含数字章节前缀 |
| `sourceSlug` | 稳定标识;修改公开地址时保持不变 |
| `title`、`content` | 标题与 Markdown 正文 |
| `category`、`collection`、`group` | 分类、合集与目录分组 |
| `description`、`draft`、`featured` | 摘要、草案状态与推荐状态 |
| `revision` | 新建传 `0`;修改传最近一次读取到的版本号 |
修改现有文档的流程:
```sh
umask 077
doc_work_dir=$(mktemp -d)
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft > "$doc_work_dir/document.json"
# 编辑该私有 JSON 文件,保留读取到的 revision 和 sourceSlug。
sudo /opt/endorphin-tabularium-docs/docs save < "$doc_work_dir/document.json"
```
## 命令
保存成功后,正文、目录和搜索通过后续请求读取新内容。发生版本冲突时重新读取并合并修改,不能仅改大 `revision` 覆盖并发编辑。调整分类或分组也走同一保存流程;`history` 使用稳定的 `sourceSlug` 查询修订。
- `pnpm dev`:本地开发,修改 Markdown 后刷新页面即可读取新内容。
- `pnpm figures`:运行 Python 制图,更新 `apps/docs/public/figures`。
- `pnpm lint` / `pnpm typecheck`:前端静态检查。
- `pnpm build`:构建文档站。
- `pnpm start`:在本地预览已构建站点。
- `pnpm check`:依次执行 lint、类型检查、制图与构建。
## 文档上传 API
站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/Ctrl K`、方向键与 Enter;代码块提供复制与反馈。桌面提供分组导航和滚动高亮的页内目录,小屏可展开文档菜单与页内目录。首页入口、草案提示、前后篇导航均引用现有 Markdown 内容和资料目录配置。顶部提供文档切换,阅读页左侧只展示当前文档,并有独立图标、名称与返回入口;搜索框标明当前文档范围。界面主要通过留白、轻渐变与状态反馈分层,支持减少动态效果的系统偏好。
文档 API 直接读写同一个数据库,供自动化整理和大量文档上传使用。它只接受独立的文档 API Key,不接受网页 Cookie 或通用网页会话令牌;Key 不能用于研究、登录或其他站点 API。
参考设计截图与研究记录保存在 `design/references`;浏览器验收截图与检查说明保存在 `design/verification`。这些文件不作为站点静态资源发布。
| 方法与地址 | 用途 |
| --- | --- |
| `GET /api/documents?limit=50&offset=0` | 分页读取目录元数据;不返回全部正文 |
| `GET /api/documents/item/<slug>` | 读取单篇完整正文、目录字段和当前 revision;支持旧地址 |
| `GET /api/documents/item/<slug>?history=1&limit=50&offset=0` | 分页读取修订元数据与时间,按新到旧排列 |
| `GET /api/documents/item/<slug>?revision=1` | 读取指定修订的完整正文 |
| `POST /api/documents` | 保存一个完整文档 JSON;字段与 `docs save` 相同 |
| `POST /api/documents/batch` | 保存 `{ "documents": [...] }`,每个请求 1—100 篇 |
文档标题取首个 `# 标题`,文档排序按相对路径。支持多层目录(例如 `content/docs/pretext/01-chapter.md` 对应 `/docs/pretext/01-chapter`);旧文档地址保持可用。`content/catalog.json` 管理合集、分类、摘要、草案与推荐,新增资料无需修改页面代码。首页推荐最多3篇,目录每页8条,可按合集与标题筛选;全站搜索仍覆盖全文。相邻篇目限于当前合集。
写入使用 `Authorization: Bearer <文档 API Key>`。新建传 `revision: 0`;更新先读取当前全文,再使用读到的 revision。每篇正文最多 2,000,000 个 JavaScript 字符单位;每次请求最多 8 MiB。目录和修订分页的 `limit` 范围为 1—100,响应包含 `total` 和 `nextOffset`。
Pretext 教程位于 `content/docs/pretext`,包含导读与12章,来自本地 Pretext 研究成果。源码链接锁定上游研究版本;实验脚本与原始记录位于 `apps/docs/public/materials/pretext`,和正文一样经过登录验证。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/<name>.svg`。不解析原始 HTML。
单个批量请求在同一事务提交,任意一篇格式错误、版本冲突或地址冲突都会整体回滚。成功返回文档地址、稳定标识和新修订号;版本或幂等键冲突返回 `409`,格式错误返回 `400`,超出请求大小返回 `413`。缺少、过期或撤销的 Key 返回 `401`,只读 Key 写入返回 `403`。
语言设计问题集位于 `content/docs/language-design`,是第三个资料合集,包含总览与原编号02–14的13个分类,共271项议题。原讨论已确认26项,其余保持待讨论;来源映射和本次状态记录位于 `content/language-design-sources.json`。维护时同步章节、总览计数与来源状态;不会自动从聊天同步,也不把设计记录等同于已实现功能。
建议写入时提供 `Idempotency-Key`,由 1—128 个字母、数字或 `._:-` 组成。同一个 Key、相同请求 ID 和相同输入重试时,返回原回执并标记 `replayed: true`,不追加修订;相同 ID 改换输入会冲突。回执描述原请求,不代表之后没有其他修改;需要当前内容时重新读取文档。改过正文或 revision 后,应使用新的请求 ID。
生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。
main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署。工作流也支持手动触发。
### 创建、查看与撤销 Key
## Endor 概念图
管理员只需通过已验证的服务器连接进行 Key 管理:
`content/docs/00-endor-concepts-draft.md` 解释全球入口、区域网关与 Runtime、分片、CDN、身份及跨区恢复的概念设计,页面为 `/docs/00-endor-concepts-draft`。
```sh
sudo /opt/endorphin-tabularium-docs/docs key-create codex-upload /auth/codex-docs-key.json read-write 90
sudo /opt/endorphin-tabularium-docs/docs key-list
sudo /opt/endorphin-tabularium-docs/docs key-revoke <key-id>
```
图形源码位于 `tools/figures/src/endor_figures/architecture.py`,由 `pnpm figures` 生成 `apps/docs/public/figures/endor-architecture.svg`。Python 负责可复现的节点、全连接和复制路径,正文直接显示图片,点击可放大;大图支持原尺寸查看、滚动和 Escape/空白处关闭。无需外部图标、字体或绘图服务。图中的数量是示意,身份服务/CDN 接入属于建议,所有未实现的关系均以草案说明。
创建命令将完整 Key 写入认证私有目录中的指定新文件,权限 `0600`,终端仅输出 ID、权限和到期信息。文件已存在时拒绝覆盖。权限可选 `read` 或 `read-write`;有效期默认 90 天,可设 1—365 天。数据库仅保存 Key 摘要,列表不返回原文;撤销后不能继续认证。独立 Key 文件和认证 config.json 都不包含在 SQLite 整库备份中。
拓扑检查:`uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`。容器检查流程会运行同一检查。
### 使用上传客户端
## 动态码登录与二维码
`scripts/documents-client.py` 仅使用 Python 标准库。通过 `TABULARIUM_DOCS_URL` 指定站点 origin,通过 `TABULARIUM_DOCS_KEY_FILE` 指定本机私有 Key 文件(支持创建命令输出的 JSON),不把 Key 放入命令行参数。
与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。
客户端要求 HTTPS;尚未配置 HTTPS 时,可通过已验证的 SSH 连接建立本机端口转发,连接 `http://127.0.0.1:18136`。以下 `$DOCS_SSH_TARGET` 应事先设置为受信任的 SSH 别名或 `user@host`:
正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。
```sh
ssh -N -L 18136:127.0.0.1:18135 "$DOCS_SSH_TARGET"
```
生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。
将服务器私有目录中的 Key 文件复制到受保护的本机位置后,在另一个终端配置客户端。`$TABULARIUM_DOCS_KEY_FILE` 文件须仅本人可读写,不能提交到仓库:
管理员通过SSH管理:
```sh
export TABULARIUM_DOCS_URL=http://127.0.0.1:18136
export TABULARIUM_DOCS_KEY_FILE="$HOME/.config/tabularium/codex-docs-key.json"
python3 scripts/documents-client.py list --limit 50
python3 scripts/documents-client.py get endor/endor-purpose-draft
python3 scripts/documents-client.py get endor/endor-purpose-draft --history
python3 scripts/documents-client.py get endor/endor-purpose-draft --revision 1
```
修改流程与 CLI 相同:
```sh
umask 077
api_work_dir=$(mktemp -d)
python3 scripts/documents-client.py get endor/endor-purpose-draft > "$api_work_dir/document.json"
# 编辑正文或目录字段,保留 sourceSlug 和读取到的 revision。
python3 scripts/documents-client.py save "$api_work_dir/document.json"
```
大量文档可使用仅含 `documents` 数组的私有 JSON 文件。客户端按 100 篇和 8 MiB 请求上限自动分批,并为各批派生固定的幂等请求 ID。每批独立提交;后续批次失败时,前面的成功批次保留。客户端会打印根请求 ID 和各批进度,使用相同 ID 与未修改的输入重试可跳过已完成的批次;可通过 `--request-id` 显式指定根 ID(客户端最多 100 个字符)。若后续批次因业务冲突失败,按进度中已提交的文档数量提取尚未成功的部分,修正后使用新的根 ID 上传。
客户端只自动重试一次临时网络或 `502/503/504` 错误,保持正文和请求 ID 不变;`409` 等业务错误不会自动覆盖。它不跟随 HTTP 重定向,避免将 Bearer 凭据转发到其他地址。正文和 Key 继续保存在数据库或私有文件中,不因使用 API 而进入 Git。
## 研究数据与检查
研究工作台提供公司筛选、经营指标、原始证据、收费与交付模式、技术材料及核查记录。它区分公司披露、支付平台指标、第三方估算、融资和客户收益;未知收入不填零,材料可访问也不代表性能或商业效果已经验证。研究范围、样本限制和个案结论以工作台记录为准。
| 仓库资料 | 用途 |
| --- | --- |
| `content/research/ai-companies.seed.json` | 研究数据导入快照;更新必须更换 `version` |
| `content/research/ai-companies.sources.json` | 来源范围、用途、覆盖边界与后续缺项 |
| `content/research/ai-companies.coverage.json` | 候选池逐条筛选台账 |
| `content/research/ai-income-analysis.json` | 独立复算的统计参考快照 |
服务端以事务导入未载入的版本,保留旧研究版本与登录、文档数据;同一版本标识下内容变化会拒绝导入。页面和 `GET /api/research/ai-companies` 从数据库读取对应研究版本。回退程序可能选择旧研究版本,不能等同于撤销数据库中的正文编辑。
以下 Python 检查不触发编译或构建,可在工作分支执行:
```sh
PYTHONDONTWRITEBYTECODE=1 python3 scripts/audit-ai-research.py --check
PYTHONDONTWRITEBYTECODE=1 python3 scripts/summarize-ai-income.py --check
```
若需对照研究数据检查报告正文,先用 `docs export` 导出当前文档,再给对应脚本传入 `--document-export <导出文件>`。默认 `--check` 核对研究数据的统计与审计不变量,不将历史迁移快照当作当前正文。脚本不带参数时仅在 `.artifacts/reports` 生成候选正文,需审阅后通过 `docs save` 写入数据库。
## 备份与首次迁移
```sh
sudo /opt/endorphin-tabularium-docs/docs backup /auth/manual-backup.sqlite
sudo /opt/endorphin-tabularium-docs/docs export /auth/current-documents.json
```
路径在容器内,输出文件已存在时拒绝覆盖。`backup` 是整个共享 SQLite 数据库的一致性备份,包含登录状态、研究数据、正文、别名和修订;`export` 只保存当前正文与目录,供空库初始化或外部迁移使用,不包含完整修订历史。认证配置单独保存在 `/auth/config.json`,整库备份不包含该配置文件。备份与导出应保存在私有挂载或受控备份位置。
首次迁移由 `scripts/export-legacy-documents.py` 从固定历史版本导出正文和目录,基线与研究正文覆盖版本在脚本中明确记录。导出写入 Git 忽略目录,权限为 `0600`,不进入生产镜像:
```sh
python3 scripts/export-legacy-documents.py --output .artifacts/documents-migration.json
```
该命令用于迁移准备,不是日常写作入口;目标文件存在时拒绝覆盖。部署先备份共享数据库,再用 `import --if-empty` 向空文档表事务导入。已有文档时跳过,不覆盖后续编辑;导入失败整体回滚并中止部署。`--ref` 仅用于明确选择历史导出版本。
程序回退不会撤销数据库编辑;回退到旧文件式站点则会显示旧正文。完整数据库恢复会同时影响登录、研究和文档状态,需先停止写入、保留当前备份,再明确选择恢复点。部署脚本只回退镜像,不自动恢复数据库。
## 登录管理
站点使用共享验证器的 6 位 TOTP 动态码,每 30 秒更新。会话使用 HttpOnly、SameSite=Lax Cookie,有效期 7 天;共享尝试上限为每 5 分钟 20 次,重放与限流状态在数据库中持久保存。缺少或损坏认证配置时拒绝私有访问。
```sh
sudo /opt/endorphin-tabularium-docs/auth qr
sudo /opt/endorphin-tabularium-docs/auth verify
```
`qr`只允许交互终端显示;`qr-png`用于通过SSH将PNG直接重定向到私有本地文件,不在CI中运行。二维码等同登录凭据,不放入本仓库或网页。只有明确执行 `auth rotate --confirm` 才更换密钥并使旧二维码、会话失效;重新显示或下载二维码不会轮换。
认证配置和数据库位于服务器 `/opt/endorphin-tabularium-docs/private-auth`。正常部署保留验证器与会话签名密钥。`qr` 只在交互终端显示,`qr-png` 可导出到私有文件;只有 `auth rotate --confirm` 会轮换密钥并使旧会话失效。二维码和令牌不进入仓库、网页或 CI 日志。
本地先构建CLI,再初始化独立的本地测试配置:
当前 Compose 配置默认 `AUTH_COOKIE_SECURE=false`;实际采用 HTTPS 时,应在独立的生产配置中设置为 `true`。HTTP 不加密动态码与会话传输。生产部署使用无副作用的 `verify`,不执行会消耗动态码和触发限流的 `exercise`。
```sh
pnpm build
AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json node apps/docs/.next/standalone/apps/docs/auth-cli.cjs init
AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=false pnpm dev
```
## 开发、验证与发布
当前服务器入口为HTTP,与参考项目一致使用 `AUTH_COOKIE_SECURE=false`;配置HTTPS后应在独立production.env设置 `AUTH_COOKIE_SECURE=true`。HTTP本身不加密动态码和会话传输。
代码依赖要求见 `package.json` 和锁文件;当前要求 Node.js 22.13+、pnpm 11.25.0,制图使用 Python 与 uv。构建、打包、制图及会隐式编译的检查只在已确认的 `ces-control` Runner 执行,本机不执行这些步骤。
`pnpm test:auth`运行算法、会话、重放及限流测试。容器验证使用自己的匿名测试卷完成登录/并发重放/CSRF/限流测试,并在重启后检查状态仍存在。生产部署只运行无副作用的 `verify`,不会消耗用户动态码或触发生产限流。
工作分支完成轻量检查后推送并创建 PR,由用户手动合并。**CI 仅在 main 推送或针对 main 的手动运行时执行,工作分支和 PR 不运行 CI。** 不自动合并,也不因确认 Runner 而扩大触发范围。
## CI/CD 与缓存
主线通过 `.gitea/workflows/ci-deploy.yml` 执行 `scripts/ci.py`:
`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。
1. 检查研究统计、审计与 Python 测试,生成首次迁移所需的历史导出。
2. 根据程序、研究快照、图形、锁文件和构建脚本计算内容指纹。命中 verified 镜像时复用,否则安装依赖并执行 lint、类型检查、数据库测试、图形检查与构建。
3. 使用隔离数据库卷核对完整迁移、认证、全部文档与资源,以及不重建内容更新和重启持久化;通过后标记镜像为 verified。
4. `scripts/deploy.py` 确认仍为最新 main,备份并初始化文档数据,使用 `--no-build` 部署;失败时回退上一版镜像。
1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。
2. 命中 `verified-<scope>-<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。
3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。
4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过动态码认证、重放/限流、重启持久化、健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。
5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。
`pnpm check`、`pnpm typecheck`、`pnpm test:auth`、`pnpm test:research`、`pnpm test:docs`、`pnpm figures`、`pnpm build` 和 `pnpm dev` 等命令涉及编译、构建或产物生成,不在本机运行。图形源码与生成资源保持可复现,前端继续使用 Pages Router。
根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。
根 README 等非构建输入变更不要求重新构建;数据库正文和目录更新也不触发构建。保留 pnpm、uv、Next.js 与 SWC 缓存,不清理共享 Docker 缓存或卷,不修改共享 Runner 或其他项目。
默认入口为服务器的 **18135** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。部署前先检查端口是否空闲(或属于本服务),不会停止其他服务以抢占端口。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`:
## 部署位置与仓库
```dotenv
TABULARIUM_BIND=0.0.0.0
TABULARIUM_PORT=18135
```
| 项目 | 位置或默认值 |
| --- | --- |
| Compose 项目 | `endorphin-tabularium-docs` |
| 容器监听 | `0.0.0.0:3000` |
| 服务器入口 | 默认端口 `18135`,由生产配置决定 |
| 生产配置 | `/etc/endorphin-tabularium-docs/production.env` |
| 发布记录与当前版本 | `/opt/endorphin-tabularium-docs/releases/<commit>` 与 `current` |
| 健康检查 | `/api/health` |
发布记录位于 `/opt/endorphin-tabularium-docs/releases/<commit>`,`current` 指向最近成功发布。修改服务器入口配置后手动重跑 main 工作流即可,复用现有镜像。
服务入口使用 `TABULARIUM_BIND`、`TABULARIUM_PORT` 配置。部署前检查端口是否空闲或属于本服务,不停止其他服务抢占端口;本项目不自动修改防火墙、域名或反向代理。
健康检查:`/api/health`。本项目不修改 Hestia、PROMPT 或其他服务,也不自动修改防火墙、域名和反向代理。
## Git 远程
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。开发者 Git 操作使用 codex-bot 专用 SSH;Actions 检出使用 Gitea 内置任务令牌,不保存检出凭据。
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。Git 操作仅使用 `codex-bot` 专用 SSH 身份与仓库级提交配置;Actions 检出使用 Gitea 内置任务令牌且不保存凭据。
+8 -3
View File
@@ -3,12 +3,16 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
"build": "NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
"dev": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
"build": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
"images": "node scripts/build-doc-images.mjs",
"test:images": "node scripts/build-doc-images.mjs --check",
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
"lint": "eslint . --max-warnings=0",
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
"test:auth": "node scripts/test-auth.mjs"
"test:auth": "node scripts/test-auth.mjs",
"test:research": "node scripts/test-research.mjs",
"test:docs": "node scripts/test-docs.mjs"
},
"dependencies": {
"next": "16.4.3",
@@ -29,6 +33,7 @@
"eslint": "^9.39.5",
"eslint-config-next": "16.4.3",
"postcss": "8.5.31",
"sharp": "0.35.5",
"tailwindcss": "4.3.3",
"typescript": "~5.9.3"
}
+139 -9
View File
@@ -18,6 +18,8 @@ import {
totp,
isLoginRedirect,
} from "../src/lib/auth";
import { createDocumentKey, revokeDocumentKey } from "../src/lib/document-keys";
import { readDocument, saveDocument } from "../src/lib/docs";
import { consumeCode, initializeStore } from "../src/lib/auth-store";
async function main() {
@@ -132,15 +134,12 @@ async function main() {
join(__dirname, ".next/BUILD_ID"),
"utf8",
).trim();
const routes = Object.keys(
JSON.parse(
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
).routes,
) as string[];
for (const path of [
"/",
"/docs/00-endor-concepts-draft",
"/figures/endor-architecture.svg",
"/figures/endor-review-gate.webp",
"/figures/endor-review-desk.webp",
"/docs/pretext/01-chapter",
"/materials/pretext/verify.cjs",
"/materials/pretext/results.chrome156.json",
@@ -158,7 +157,7 @@ async function main() {
path,
);
}
for (const path of ["/api/private", "/api/unknown", "/api/search"])
for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/documents/source?slug=endor/endor-purpose-draft", "/api/research/ai-companies"])
assert.equal((await request(path)).status, 401, path);
assert.equal(
(
@@ -179,18 +178,110 @@ async function main() {
const headers = {
Authorization: "Bearer " + signSession(config, undefined, 600),
};
const researchResponse = await request("/api/research/ai-companies", { headers });
assert.equal(researchResponse.status, 200, "authenticated research API");
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
const research = await researchResponse.json();
assert.equal(research.companies.length, 1023);
assert.equal(research.categories.length, 11);
assert.equal(research.evidence.length, 1259);
assert.equal(research.observations.length, 189);
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
assert.equal(researchPage.status, 200);
const researchHtml = await researchPage.text();
assert.ok(researchHtml.includes('data-category="其他研究"'));
assert.ok(researchHtml.includes("AI 公司研究"));
assert.ok(researchHtml.includes("正在读取研究数据库"));
const searchResponse = await request("/api/search", { headers });
assert.equal(searchResponse.status, 200);
const searchIndex = await searchResponse.json();
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "pretext/04-chapter" && doc.searchText.includes("getSegmentMetrics")));
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "source-studies/pretext/measurement-cache" && doc.searchText.includes("getSegmentMetrics")));
for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) {
assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug);
const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers });
assert.equal(legacy.status, 308, doc.sourceSlug);
const destination = new URL(legacy.headers.get("location")!, base);
assert.equal(destination.pathname, `/docs/${doc.slug}`);
assert.equal(destination.searchParams.get("from"), "legacy");
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
}
const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)];
const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers });
assert.equal(raw.status, 200);
assert.ok((await raw.text()).startsWith("# Endor"));
if (process.env.DOCUMENT_SMOKE_TEST) {
if (command === "exercise") await exerciseDocumentApi(request, headers);
const sourceSlug = "ci-live-document";
const previous = readDocument(sourceSlug);
if (command === "exercise") {
const first = saveDocument({ slug: "endor/ci-live-document", sourceSlug,
title: "Runtime document", category: "Endor", collection: "Endor 文档", group: "CI",
description: "Isolated smoke test", draft: true, featured: false,
content: "# Runtime document\n\nInitial body" }, previous?.revision ?? 0);
assert.equal((await request(`/docs/${first.slug}`, { headers })).status, 200);
saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision);
}
const stored = readDocument(sourceSlug);
assert.ok(stored, "Smoke document must exist after creation or restart");
assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart");
const live = await request(`/docs/${stored.slug}`, { headers });
assert.equal(live.status, 200);
assert.ok((await live.text()).includes("Updated without rebuilding"));
const alias = await request("/docs/endor/ci-live-document?live=1", { headers });
assert.equal(alias.status, 308);
assert.ok(alias.headers.get("location")?.includes("ci-live-document-renamed?live=1"));
const updatedIndex = await (await request("/api/search", { headers })).json();
assert.ok(updatedIndex.some((doc: { searchText: string }) => doc.searchText.includes("Updated without rebuilding")));
}
let home = "";
let purpose = "";
for (const route of routes) {
const response = await request(route, { headers });
assert.equal(response.status, 200, route);
if (route === "/") home = await response.text();
const html = await response.text();
if (route === "/") home = html;
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
if (route.startsWith("/docs/")) {
assert.ok(html.includes('aria-label="文档分类"'), route);
assert.ok(html.includes('data-category="语言与运行时"'), route);
assert.ok(html.includes('class="category-panel" hidden=""'), route);
assert.ok(!html.includes('class="collection-picker"'), route);
assert.ok(!html.includes("<select"), route);
assert.ok(!html.includes('class="nav-back"'), route);
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`);
}
}
assert.ok(home.includes("资料库"));
assert.ok(home.includes("/docs/pretext/00-overview"));
assert.ok(home.includes("/docs/source-studies/pretext/overview"));
// Inline preview and zoom dialog must both use the compressed browser asset.
assert.equal(
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-gate\.webp"/g)].length,
2,
"The comic preview and lightbox must use WebP",
);
assert.ok(purpose.includes('aria-label="上一张图片"'));
assert.ok(purpose.includes('aria-label="下一张图片"'));
assert.ok(purpose.includes('aria-label="显示第 1 张图片" aria-current="true"'));
assert.ok(purpose.includes('aria-label="显示第 2 张图片"'));
assert.equal(
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-desk\.webp"/g)].length,
0,
"The second slide should not download until selected",
);
for (const name of ["endor-review-gate", "endor-review-desk"]) {
assert.ok(!purpose.includes(`/figures/${name}.png`));
const comic = await request(`/figures/${name}.webp`, { headers });
assert.equal(comic.status, 200);
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
assert.deepEqual(
Buffer.from(await comic.arrayBuffer()),
readFileSync(join(__dirname, `public/figures/${name}.webp`)),
);
assert.equal((await request(`/figures/${name}.png`, { headers })).status, 404);
}
const assets = new Set(
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
m[1].replaceAll("&amp;", "&"),
@@ -267,3 +358,42 @@ main().catch((error) => {
);
process.exitCode = 1;
});
async function exerciseDocumentApi(request: (path: string, init?: RequestInit) => Promise<Response>, session: Record<string, string>) {
const writer = createDocumentKey("isolated API smoke", "read-write", 1);
const reader = createDocumentKey("isolated read smoke", "read", 1);
const headers = { Authorization: "Bearer " + writer.token, "Content-Type": "application/json", "Idempotency-Key": "smoke-batch" };
const readHeaders = { Authorization: "Bearer " + reader.token, "Content-Type": "application/json" };
const make = (name: string) => ({ slug: `endor/${name}`, sourceSlug: name, title: "API smoke",
category: "Endor", collection: "Endor 文档", group: "CI", description: "Isolated API smoke",
draft: true, featured: false, content: "# API smoke", revision: 0 });
const payload = { documents: [make("ci-api-first"), make("ci-api-second")] };
try {
assert.equal((await request("/api/documents")).status, 401);
assert.equal((await request("/api/documents", { method: "POST", headers: { ...session, "Content-Type": "application/json" }, body: JSON.stringify(payload.documents[0]) })).status, 401);
assert.equal((await request("/api/research/ai-companies", { headers })).status, 401, "Document keys cannot read research");
assert.equal((await request("/api/search", { headers })).status, 401, "Document keys cannot authenticate other APIs");
assert.equal((await request("/api/documents/batch", { method: "POST", headers: readHeaders, body: JSON.stringify(payload) })).status, 403);
const saved = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
assert.equal(saved.status, 200);
assert.equal((await saved.json()).documents.length, 2);
const replay = await request("/api/documents/batch", { method: "POST", headers, body: JSON.stringify(payload) });
assert.equal(replay.status, 200); assert.equal((await replay.json()).replayed, true);
const current = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
assert.equal(current.status, 200); assert.equal((await current.json()).revision, 1);
const page = await request("/api/documents?limit=1", { headers: readHeaders });
assert.equal(page.status, 200);
const listing = await page.json();
assert.equal(listing.documents.length, 1); assert.equal(listing.documents[0].content, undefined);
const bad = { documents: [{ ...payload.documents[0], revision: 1, content: "Must roll back" }, payload.documents[1]] };
const conflict = await request("/api/documents/batch", { method: "POST", headers: { ...headers, "Idempotency-Key": "smoke-conflict" }, body: JSON.stringify(bad) });
assert.equal(conflict.status, 409);
const unchanged = await request("/api/documents/item/endor/ci-api-first", { headers: readHeaders });
assert.equal((await unchanged.json()).content, "# API smoke");
const history = await request("/api/documents/item/endor/ci-api-first?history=1", { headers });
assert.equal(history.status, 200); assert.equal((await history.json()).total, 1);
revokeDocumentKey(writer.id);
assert.equal((await request("/api/documents", { headers })).status, 401);
} finally { revokeDocumentKey(writer.id); revokeDocumentKey(reader.id); }
console.log("Document API smoke passed: scoped keys, batch rollback, pagination, history, replay and revocation; no secrets displayed");
}
+5
View File
@@ -7,3 +7,8 @@ await build({
format: "cjs",
outfile: ".next/standalone/apps/docs/auth-cli.cjs",
});
await build({
entryPoints: ["scripts/docs-cli.ts"], bundle: true, platform: "node", target: "node22",
format: "cjs", outfile: ".next/standalone/apps/docs/docs-cli.cjs",
});
+49
View File
@@ -0,0 +1,49 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const root = fileURLToPath(new URL("../../../", import.meta.url));
const sources = path.join(root, "tools/figures/assets");
const output = path.join(root, "apps/docs/public/figures");
const check = process.argv.includes("--check");
const policy = JSON.parse(await readFile(path.join(root, "content/image-policy.json"), "utf8"));
async function files(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return (await Promise.all(entries.map((entry) => {
const name = path.join(directory, entry.name);
return entry.isDirectory() ? files(name) : [name];
}))).flat().sort();
}
// Originals stay outside public; both inline and lightbox images use the WebP.
for (const name of await files(path.join(root, "apps/docs/public"))) {
if (/\.(?:png|jpe?g|tiff?|bmp)$/i.test(name)) {
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
}
}
await mkdir(output, { recursive: true });
for (const source of await files(sources)) {
if (!/\.(?:png|jpe?g|tiff?|bmp)$/i.test(source)) continue;
const relative = path.relative(sources, source).replace(/\.[^.]+$/, ".webp");
const destination = path.join(output, relative);
const original = await readFile(source);
const encoded = await sharp(original)
.rotate()
.resize({ width: policy.maxWidth, withoutEnlargement: true })
.webp({ quality: policy.quality, effort: 6 })
.toBuffer();
if (encoded.length > policy.maxBytes) {
throw new Error(`${relative}: ${encoded.length} bytes exceeds the ${policy.maxBytes}-byte image budget.`);
}
if (check) {
const generated = await readFile(destination);
if (!encoded.equals(generated)) throw new Error(`${relative} is stale. Regenerate browser images on the confirmed remote runner.`);
} else {
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, encoded);
}
console.log(`${check ? "Verified" : "Generated"} ${relative}: ${original.length} → ${encoded.length} bytes (${(100 * (1 - encoded.length / original.length)).toFixed(1)}% smaller)`);
}
+70
View File
@@ -0,0 +1,70 @@
import { readFileSync, writeFileSync, openSync, closeSync, unlinkSync } from "node:fs";
import { resolve, dirname, sep } from "node:path";
import { createDocumentKey, listDocumentKeys, revokeDocumentKey, type DocumentKeyScope } from "../src/lib/document-keys";
import { openSiteDatabase } from "../src/lib/site-database";
import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs";
process.umask(0o077);
const [command, argument, ...extra] = process.argv.slice(2);
try {
switch (command) {
case "key-create": {
const authFile = process.env.AUTH_CONFIG_FILE;
if (!authFile || !argument || !extra[0]) throw Error("Usage: key-create <name> <private-file> [read|read-write] [days]");
const file = resolve(extra[0]);
const directory = resolve(dirname(authFile));
if (!file.startsWith(directory + sep)) throw Error("Key output must be inside the private authentication directory");
const fd = openSync(file, "wx", 0o600);
let created: { id: string } | undefined;
try {
const key = createDocumentKey(argument, (extra[1] ?? "read-write") as DocumentKeyScope, Number(extra[2] ?? 90));
created = key;
writeFileSync(fd, JSON.stringify(key) + "\n");
console.log(JSON.stringify({ id: key.id, scope: key.scope, expiresAt: key.expiresAt, saved: file }));
} catch (error) {
if (created) revokeDocumentKey(created.id);
unlinkSync(file);
throw error;
} finally { closeSync(fd); }
break;
}
case "key-list": console.log(JSON.stringify(listDocumentKeys(), null, 2)); break;
case "key-revoke":
if (!argument) throw Error("Provide key id");
revokeDocumentKey(argument); console.log("Document key revoked"); break;
case "backup": {
if (!argument) throw Error("Provide a private backup filename");
const db = openSiteDatabase();
try { db.prepare("VACUUM INTO ?").run(argument); } finally { db.close(); }
console.log("Full site database backup created; existing files are never overwritten"); break;
}
case "list":
console.log(JSON.stringify(allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }), null, 2)); break;
case "get": {
const doc = readDocument(argument ?? "");
if (!doc) throw Error("Document not found");
console.log(JSON.stringify(doc, null, 2)); break;
}
case "save": {
const { revision, ...input } = JSON.parse(readFileSync(0, "utf8"));
const doc = saveDocument(input, revision);
console.log(JSON.stringify({ slug: doc.slug, revision: doc.revision })); break;
}
case "history":
console.log(JSON.stringify(documentHistory(argument ?? ""), null, 2)); break;
case "import": {
const bundle = JSON.parse(readFileSync(0, "utf8"));
console.log(JSON.stringify({ imported: importDocuments(bundle, argument === "--if-empty") })); break;
}
case "export": {
if (!argument) throw Error("Provide a private export filename");
writeFileSync(argument, JSON.stringify({ format: "tabularium-documents-v1", documents: allDocuments() }),
{ flag: "wx", mode: 0o600 });
console.log("Document export created; existing files are never overwritten"); break;
}
default: throw Error("Usage: docs key-create <name> <private-file> [read|read-write] [days] | key-list | key-revoke <id> | backup <private-file> | list | get <slug> | save < input.json | history <sourceSlug> | import [--if-empty] < export.json | export <private-file>");
}
} catch (error) {
console.error(error instanceof Error ? error.message : "Document operation failed");
process.exitCode = 1;
}
+13
View File
@@ -0,0 +1,13 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-"));
try {
await build({ entryPoints: ["tests/docs.test.ts", "tests/documents-api.test.ts"], bundle: true,
platform: "node", target: "node22", format: "cjs", outdir: dir, outExtension: { ".js": ".cjs" } });
const result = spawnSync(process.execPath, ["--test", join(dir, "docs.test.cjs"), join(dir, "documents-api.test.cjs")], { stdio: "inherit" });
process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
+14
View File
@@ -0,0 +1,14 @@
import { build } from "esbuild";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const dir = await mkdtemp(join(tmpdir(), "tabularium-research-tests-"));
try {
const file = join(dir, "research.test.cjs");
await build({ entryPoints: ["tests/research.test.ts"], bundle: true,
platform: "node", target: "node22", format: "cjs", outfile: file });
const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" });
process.exitCode = result.status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router";
import IncomeDistribution from "./IncomeDistribution";
import {
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
} from "@/lib/ai-research";
const pageSize = 12;
const evidencePageSize = 30;
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
const useLabels: Record<string, string> = {
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
};
const money = (value: number, unit = "USD") => unit === "USD"
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
function External({ url, children }: { url: string; children: React.ReactNode }) {
const safe = evidenceUrl(url);
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
}
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
return <li id={`${prefix}-${item.id}`} className="research-evidence">
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
<strong><External url={item.url}>{item.title}</External></strong>
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
</li>;
}
export default function AICompanyResearch() {
const router = useRouter();
const [data, setData] = useState<ResearchSnapshot | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [query, setQuery] = useState("");
const [categoryChoice, setCategoryChoice] = useState<{ route: string; id: string } | null>(null);
const linkedCategory = router.query.category;
const category = categoryChoice && categoryChoice.route === router.asPath ? categoryChoice.id
: router.isReady && typeof linkedCategory === "string" && data?.categories.some((c) => c.id === linkedCategory)
? linkedCategory : "";
function setCategory(id: string) { setCategoryChoice({ route: router.asPath, id }); }
const [cohort, setCohort] = useState("");
const [income, setIncome] = useState("");
const [page, setPage] = useState(0);
const [sourcePage, setSourcePage] = useState(0);
const [depth, setDepth] = useState("");
const [teamBand, setTeamBand] = useState("");
const [technicalLevel, setTechnicalLevel] = useState("");
const [selection, setSelection] = useState<{ route: string; id: string | null } | null>(null);
const linkedCompany = router.query.company;
const selected = selection && selection.route === router.asPath ? selection.id
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
? linkedCompany : null;
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
const companySection = useRef<HTMLElement>(null);
const detailSection = useRef<HTMLElement>(null);
useEffect(() => {
const controller = new AbortController();
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("Research unavailable");
const snapshot: ResearchSnapshot = await response.json();
setData(snapshot);
}).catch(() => { if (!controller.signal.aborted) setError(true); });
return () => controller.abort();
}, [attempt]);
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
const filtered = useMemo(() => data?.companies.filter((c) => {
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
const depthMatch = !depth || (depth === "profile" ? c.profileOnly : !c.profileOnly);
const teamMatch = !teamBand || (teamBand === "unknown" ? c.team === null || (!!c.teamBound && c.team < 51)
: c.team !== null && (teamBand === "xl" ? c.team >= 51 : !c.teamBound && (teamBand === "small" ? c.team <= 5 : teamBand === "medium" ? c.team >= 6 && c.team <= 20 : c.team >= 21 && c.team <= 50)));
return (!technicalLevel || (c.technicalReview?.level ?? "unreviewed") === technicalLevel) && depthMatch && teamMatch && (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
&& search.includes(query.trim().toLocaleLowerCase());
}) ?? [], [data, query, category, cohort, income, depth, teamBand, technicalLevel]);
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
const categories = new Map(data.categories.map((c) => [c.id, c]));
const selectedCompany = data.companies.find((c) => c.id === selected);
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
const disclosedCompanies = new Set(observed.map((o) => o.company)).size;
const revenueCompanies = data.companies.filter((c) => c.revenueStatus !== "unknown").length;
const selectedObservations = data.observations.filter((o) => o.company === selected);
const selectedEvidenceIds = new Set([
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
...(selectedCompany?.technicalReview?.evidenceIds ?? []),
...selectedObservations.map((o) => o.evidence),
]);
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
const reviewCompanies = (review: ResearchReview) => {
const ids = new Set(reviewCompanyIds(data, review));
return data.companies.filter((c) => ids.has(c.id));
};
const selectedReviews = data.reviews.filter((r) => selected && reviewCompanyIds(data, r).includes(selected));
function chooseCategory(id: string) {
setCategory(id); setPage(0); setTab("companies");
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
return <div className="ai-research not-prose">
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
<div className="research-stats">
<div><strong>{data.companies.length}</strong><span>公司/产品记录</span></div>
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
<div><strong>{revenueCompanies}</strong><span>有收入线索的记录</span></div>
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
</div>
<div className="research-scope"><p>{data.scope}</p>
{data.coverage && <p>YC 候选池 {data.coverage.candidateRecords} 条,官方页面读取 {data.coverage.profilesRead} 条;初筛匹配 {data.coverage.matchingProfiles} 条,新增 {data.coverage.newProfiles} 条目录记录。年份不符或缺失 {data.coverage.otherOrMissingYear} 条、AI 业务待确认 {data.coverage.unclearAI} 条、日期冲突 {data.coverage.dateConflicts} 条。目录基础资料尚未逐家核验收入和交付能力。</p>}
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
</details>
</div>
<section aria-labelledby="research-categories-title" className="research-section">
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}条记录`}>
<div><span>{c.name}</span><strong>{c.count}</strong></div>
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
<small>{c.description}</small>
</button>)}</div>
</section>
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
<h2 id="research-records-title">研究数据</h2>
<div className="research-tabs" role="group" aria-label="研究数据视图">
{([['companies','公司与产品'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
</div>
{tab === "companies" && <>
<div className="research-filters">
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
</div>
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
<div className="research-table-wrap"><table><caption className="sr-only">公司/产品分类与经营资料。选择名称查看指标和原始证据。</caption><thead><tr><th>公司或产品 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>交付与技术验证证据</th><th>收入资料</th></tr></thead><tbody>
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
<td><span className="research-tag">{categories.get(c.cat)?.name}</span>{c.profileOnly && <small>目录基础资料 · 分类依据见明细</small>}<p>{c.business}</p><small>买家:{c.buyer}</small></td>
<td>{c.cohort}</td><td>{c.team !== null && c.teamBound}{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{c.stage}<small>{technicalReviewLabels[c.technicalReview?.level ?? "unreviewed"]}</small></td><td>{c.profileOnly ? "收入尚未逐一核验" : revenueLabels[c.revenueStatus]}</td>
</tr>)}
</tbody></table></div>
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
</>}
{tab === "revenue" && <>
<IncomeDistribution data={data} onSelect={setSelected} />
<h3>公司披露与估算的历史里程碑</h3>
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
</div>)}</div>
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>公司披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
</>}
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3>{r.scope === "dataset" && <small>全局数据方法核查</small>}{r.scope === "excluded-candidate" && <small>未纳入公司表的候选</small>}<p>{r.summary}</p><p>{r.handling}</p><div>{reviewCompanies(r).map((c) => <button key={c.id} className="research-company-name" onClick={() => setSelected(c.id)}>查看 {c.name} 明细</button>)}</div><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
</section>
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}资料明细`}>
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
<p><External url={selectedCompany.url}>资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费/交付模式</dt><dd>{selectedCompany.businessModel ?? "待进一步核验"}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>分类依据</dt><dd>{selectedCompany.classificationBasis ?? "原有个案分类;随研究复核"}</dd></div><div><dt>技术验证证据</dt><dd>{technicalReviewLabels[selectedCompany.technicalReview?.level ?? "unreviewed"]}<p>{selectedCompany.technicalReview?.summary ?? "本轮未形成专项技术评审记录;不表示公司没有技术能力。"}</p>{selectedCompany.technicalReview && <small>评审日期:{selectedCompany.technicalReview.reviewedAt}</small>}{selectedCompany.technicalReview?.testScope && <p>测试范围:{selectedCompany.technicalReview.testScope}</p>}<p>材料可访问仅确认可查看,不证明性能、可靠性或商业交付质量;不同任务不作统一能力排名。</p></dd></div><div><dt>观测主体</dt><dd>{selectedCompany.entityBasis ?? "公司或经营品牌,法律主体未逐一核验"}</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
<h3>经营与技术观测</h3>
{!!selectedReviews.length && <details><summary>关联核查记录({selectedReviews.length})</summary><ul>{selectedReviews.map((r) => <li key={r.id}><strong>{r.type}:{r.summary}</strong><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const item = data.evidence.find((e) => e.id === id); return item ? <External key={id} url={item.url}>{id}</External> : null; })}</div></li>)}</ul></details>}
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
</section>}
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
</div>;
}
@@ -0,0 +1,93 @@
import Link from "next/link";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import type { DocLink } from "@/lib/docs";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
import { Icon } from "./Icon";
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
const navigation = useRef<HTMLElement>(null);
const triggers = useRef(new Map<string, HTMLButtonElement>());
const [open, setOpen] = useState<string | null>(null);
const id = useId();
const categories = [...new Set(docs.map((doc) => doc.category))];
const collections = collectionEntries(docs);
const selected = docs.find((doc) => doc.slug === current);
useEffect(() => {
function outside(event: PointerEvent) {
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
}
function escape(event: globalThis.KeyboardEvent) {
if (event.key !== "Escape" || !open) return;
if (navigation.current?.contains(document.activeElement)) {
event.preventDefault();
triggers.current.get(open)?.focus();
}
setOpen(null);
}
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open]);
function focusItem(panel: string, last = false) {
requestAnimationFrame(() => {
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
links?.[last ? links.length - 1 : 0]?.focus();
});
}
function move(event: KeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
if (index < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
links[next]?.focus();
}
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
}}>
<ul>
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
{categories.map((category, index) => {
const panel = `${id}-category-${index}`;
const expanded = open === category;
return <li className="category-menu" key={category}
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
type="button" className="category-trigger" data-category={category}
data-active={selected?.category === category || undefined}
aria-expanded={expanded} aria-controls={panel}
onClick={() => setOpen(expanded ? null : category)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(category);
focusItem(panel, event.key === "ArrowUp");
}
}}>
{category}<Icon name="chevron" size={11} />
</button>
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
<p className="category-panel-title">{category}</p>
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
onClick={() => setOpen(null)}>
<Icon name={collectionIcon(name)} size={19} />
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
</Link>)}
</div>
</li>;
})}
</ul>
</nav>;
}
@@ -0,0 +1,66 @@
import { useId, useState } from "react";
import { ArchitectureFigure } from "./ArchitectureFigure";
type Slide = { source: string; alt: string };
export function FigureGallery({ slides }: { slides: Slide[] }) {
const [active, setActive] = useState(0);
const viewport = useId();
const slide = slides[active];
if (!slide) return null;
function move(offset: number) {
setActive((current) => (current + offset + slides.length) % slides.length);
}
return (
<section
className="figure-gallery"
role="region"
aria-roledescription="轮播"
aria-label="文档配图"
onKeyDown={(event) => {
if ((event.target as HTMLElement).closest("dialog")) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
move(event.key === "ArrowLeft" ? -1 : 1);
}
}}
>
<div
id={viewport}
role="group"
aria-roledescription="幻灯片"
aria-label={`第 ${active + 1} 张,共 ${slides.length} 张`}
>
<ArchitectureFigure key={slide.source} source={slide.source} alt={slide.alt} />
</div>
<div className="figure-gallery-controls">
<button type="button" className="figure-gallery-arrow" aria-label="上一张图片" aria-controls={viewport} onClick={() => move(-1)}>
<span aria-hidden="true">‹</span>
</button>
<div className="figure-gallery-dots" role="group" aria-label="选择图片">
{slides.map((item, index) => (
<button
key={item.source}
type="button"
className="figure-gallery-dot"
aria-label={`显示第 ${index + 1} 张图片`}
aria-current={index === active ? "true" : undefined}
aria-controls={viewport}
onClick={() => setActive(index)}
>
<span aria-hidden="true" />
</button>
))}
</div>
<button type="button" className="figure-gallery-arrow" aria-label="下一张图片" aria-controls={viewport} onClick={() => move(1)}>
<span aria-hidden="true">›</span>
</button>
</div>
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">
第 {active + 1} 张,共 {slides.length} 张:{slide.alt}
</p>
</section>
);
}
@@ -0,0 +1,87 @@
import { useMemo, useState } from "react";
import type { ResearchSnapshot } from "@/lib/ai-research";
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
style: "currency", currency: "USD", maximumFractionDigits: 0,
}).format(value);
const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(1)}%`;
export default function IncomeDistribution({ data, onSelect }: {
data: ResearchSnapshot; onSelect: (id: string) => void;
}) {
const [dateWindow, setDateWindow] = useState("7");
const [year, setYear] = useState("");
const [annualize, setAnnualize] = useState(false);
const [splitModels, setSplitModels] = useState(true);
const [logScale, setLogScale] = useState(true);
const sample = useMemo(() => platformIncomeSample(data, {
windowDays: dateWindow === "all" ? null : Number(dateWindow), foundedYear: year ? Number(year) : undefined, annualize,
}), [data, dateWindow, year, annualize]);
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
category, rows: groups.filter((group) => group.category === category),
}));
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
const values = sample.points.map((point) => point.value);
const minValue = Math.min(...values);
const maxValue = Math.max(1, ...values);
const axisMin = logScale && minValue > 0 && Number.isFinite(minValue) ? 10 ** Math.floor(Math.log10(minValue)) : 0;
const axisMax = Math.max(axisMin * 10, 10 ** Math.ceil(Math.log10(maxValue)));
const scale = logScale ? (value: number) => Math.log10(value + 1) : (value: number) => value;
const left = 250, right = 865;
const x = (value: number) => left + (scale(value) - scale(axisMin)) / (scale(axisMax) - scale(axisMin)) * (right - left);
const ticks = logScale ? [0, 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]
.filter((value) => value >= axisMin && value <= axisMax) : [0, .25, .5, .75, 1].map((fraction) => fraction * axisMax);
return <section className="research-distribution" aria-labelledby="income-distribution-title">
<h3 id="income-distribution-title">收入分布与均值敏感性</h3>
<p>单独比较支付平台显示的 USD MRR。每个点是一个产品或业务,法律主体与支付账户的独立性尚未全部核实;这不是行业总体或公司财报样本。</p>
<div className="research-filters">
<label>最后同步日期窗口<select value={dateWindow} onChange={(event) => setDateWindow(event.target.value)}><option value="7">截止日前 7 天内</option><option value="30">截止日前 30 天内</option><option value="90">截止日前 90 天内</option><option value="all">全部已知日期(混合历史)</option></select></label>
<label>资料中的创立年<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
</div>
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
{sample.points.length ? <>
<figure>
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
{groups.map((group, index) => {
const y = index * 70 + 40;
const firstInCategory = index === 0 || groups[index - 1].category !== group.category;
const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length;
return <g key={group.key} role="group" aria-label={`${categories.get(group.category)},${group.provider},${group.model},${group.stats.n}个产品`}>
{firstInCategory && <>
{index > 0 && <line x1={8} x2={right} y1={y - 34} y2={y - 34} stroke="#dfe6e0" />}
<text x={8} y={y - 8} fontSize="12" fontWeight="600" fill="#202724">{categories.get(group.category)}({categoryRowCount}组)</text>
{categoryRowCount > 1 && <line x1={10} x2={10} y1={y + 3} y2={y + (categoryRowCount - 1) * 70 + 12} stroke="#95b4a6" strokeWidth={2} />}
</>}
<text x={20} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
{group.stats.n < 3 && <text x={20} y={y + 26} fontSize="9" fill="#637066">{group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}</text>}
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
{group.points.map((point, position) => <g key={point.company.id} role="button" tabIndex={0} aria-label={`查看${point.company.name},${metric} ${usd(point.value)}`} onClick={() => onSelect(point.company.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(point.company.id); } }}>
<title>{point.company.name} · {usd(point.value)} · 数据日期 {point.observation.observedAt}{point.observation.connectionExpired ? " · 连接提示过期" : ""}</title>
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={10} fill="transparent" />
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={4.5} fill="#27745b" stroke="white" />
</g>)}
</g>;
})}
</svg></div>
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
</figure>
<div className="research-table-wrap"><table><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
<td>{group.provider}<small>{group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
</tr>)}</tbody>)}</table></div>
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
</section>;
}
+3 -9
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { CategoryNavigation } from "./CategoryNavigation";
import { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
@@ -36,9 +37,6 @@ export function Navigation({
return (
<nav aria-label="文档导航">
{collection ? <>
<Link className="nav-back" href="/" onClick={onNavigate}>
<Icon name="arrow" size={15} style={{ transform: "rotate(180deg)" }} />全部文档
</Link>
<Link className="collection-identity" href={`/docs/${collections.find((c) => c.name === collection)!.entry.slug}`} onClick={onNavigate}>
<Icon name={collectionIcon(collection)} size={22} /><span>{collectionLabel(collection)}</span>
</Link>
@@ -52,7 +50,7 @@ export function Navigation({
{scopedDocs.filter((d) => d.group === group).map((d) => (
<Link onClick={onNavigate} aria-current={d.slug === current ? "page" : undefined}
className="nav-item" href={`/docs/${d.slug}`} key={d.slug}>
<span>{d.slug.endsWith("/00-overview") ? "概览" : d.title.replace("(草案)", "")}</span>
<span>{d.slug.endsWith("/overview") ? "概览" : d.title.replace("(草案)", "").replace(/^\d+\s*·\s*/, "")}</span>
{d.draft && <span className="tiny-dot" aria-label="草案" />}
</Link>
))}
@@ -81,7 +79,6 @@ export function Shell({
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const collection = docs.find((d) => d.slug === current)?.collection;
const collections = collectionEntries(docs);
const results = searchDocs.filter((d) =>
(!collection || d.collection === collection) &&
`${d.title} ${d.description} ${d.searchText ?? ""}`
@@ -126,10 +123,7 @@ export function Shell({
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/" aria-current={!current ? "page" : undefined}>文档中心</Link>
{collections.map(({ name, entry }) => <Link key={name} href={`/docs/${entry.slug}`} aria-current={collection === name ? "page" : undefined}>{collectionLabel(name)}</Link>)}
</nav>
<CategoryNavigation docs={docs} current={current} />
<button
ref={trigger}
className="search-trigger"
+87
View File
@@ -0,0 +1,87 @@
import seed from "../../../../content/research/ai-companies.seed.json";
import { createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
import { validateResearch, type ResearchSnapshot } from "./ai-research";
// Each release selects an immutable research revision. New revisions are added
// transactionally, preserving both earlier research and all authentication rows.
export function readResearchSnapshot(snapshot = seed as ResearchSnapshot): ResearchSnapshot {
validateResearch(snapshot);
const digest = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
const db = openSiteDatabase();
try {
db.exec(`
CREATE TABLE IF NOT EXISTS research_datasets (
id TEXT PRIMARY KEY, as_of TEXT NOT NULL, metadata TEXT NOT NULL, content_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS research_categories (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_companies (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, category_id TEXT NOT NULL,
name TEXT NOT NULL, founded_year INTEGER, team_size INTEGER,
revenue_status TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,category_id) REFERENCES research_categories(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_evidence (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, url TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_observations (
dataset_id TEXT NOT NULL, id TEXT NOT NULL, company_id TEXT NOT NULL,
evidence_id TEXT NOT NULL, kind TEXT NOT NULL, amount REAL NOT NULL,
unit TEXT NOT NULL, record_use TEXT NOT NULL, payload TEXT NOT NULL,
PRIMARY KEY(dataset_id,id),
FOREIGN KEY(dataset_id,company_id) REFERENCES research_companies(dataset_id,id),
FOREIGN KEY(dataset_id,evidence_id) REFERENCES research_evidence(dataset_id,id)
);
CREATE TABLE IF NOT EXISTS research_reviews (
dataset_id TEXT NOT NULL REFERENCES research_datasets(id),
id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(dataset_id,id)
);
`);
// The database transaction also serializes first requests from two workers.
db.exec("BEGIN IMMEDIATE");
try {
const exists = db.prepare("SELECT content_hash FROM research_datasets WHERE id=?")
.get(snapshot.version) as { content_hash: string } | undefined;
if (exists && exists.content_hash !== digest)
throw new Error("Research data changed without a new revision identifier");
if (!exists) {
const { categories, companies, observations, evidence, reviews, ...metadata } = snapshot;
db.prepare("INSERT INTO research_datasets VALUES (?,?,?,?)")
.run(snapshot.version, snapshot.asOf, JSON.stringify(metadata), digest);
const insertCategory = db.prepare("INSERT INTO research_categories VALUES (?,?,?)");
for (const row of categories) insertCategory.run(snapshot.version, row.id, JSON.stringify(row));
const insertCompany = db.prepare("INSERT INTO research_companies VALUES (?,?,?,?,?,?,?,?)");
for (const row of companies) insertCompany.run(snapshot.version, row.id, row.cat,
row.name, row.year, row.team, row.revenueStatus, JSON.stringify(row));
const insertEvidence = db.prepare("INSERT INTO research_evidence VALUES (?,?,?,?)");
for (const row of evidence) insertEvidence.run(snapshot.version, row.id, row.url, JSON.stringify(row));
const insertObservation = db.prepare("INSERT INTO research_observations VALUES (?,?,?,?,?,?,?,?,?)");
for (const row of observations) insertObservation.run(snapshot.version, row.id, row.company,
row.evidence, row.kind, row.value, row.unit, row.selection, JSON.stringify(row));
const insertReview = db.prepare("INSERT INTO research_reviews VALUES (?,?,?)");
for (const row of reviews) insertReview.run(snapshot.version, row.id, JSON.stringify(row));
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
const meta = db.prepare("SELECT metadata FROM research_datasets WHERE id=?").get(snapshot.version) as { metadata: string };
// All displayed records come from the site database, not the seed arrays.
const rows = (table: string) => (db.prepare(
`SELECT payload FROM ${table} WHERE dataset_id=? ORDER BY id`,
).all(snapshot.version) as { payload: string }[]).map((r) => JSON.parse(r.payload));
const stored: ResearchSnapshot = { ...JSON.parse(meta.metadata),
categories: rows("research_categories"), companies: rows("research_companies"),
observations: rows("research_observations"), evidence: rows("research_evidence"),
reviews: rows("research_reviews") };
validateResearch(stored);
return stored;
} finally { db.close(); }
}
+180
View File
@@ -0,0 +1,180 @@
export type ResearchCategory = { id: string; name: string; description: string };
export const technicalReviewLabels = {
unreviewed: "技术证据尚未专项评审",
description: "已读技术自述",
"public-artifact": "公开代码/模型材料可访问",
"independent-test": "有第三方独立评测记录",
};
export type ResearchCompany = {
id: string; name: string; url: string; cat: string; year: number | null;
team: number | null; teamBound?: ">" | "≥"; location: string | null; business: string; buyer: string;
delivery: string; notes: string; cohort: string; teamBasis: string | null;
price: string; stage: string; sourceType: string; evidenceIds: string[];
revenueStatus: "self" | "estimate" | "platform" | "unknown";
extraUrl?: string; pricingEvidence?: string;
businessModel?: string; entityBasis?: string;
profileOnly?: boolean; classificationBasis?: string; directoryTags?: string[];
directoryOrigin?: "yc-2025-2026";
technicalReview?: {
level: "description" | "public-artifact" | "independent-test";
reviewedAt: string; summary: string; evidenceIds: string[]; testScope?: string;
};
};
export type ResearchObservation = {
id: string; company: string; name: string; kind: string; value: number;
unit: string; timing: string; evidence: string; scope: string;
selection: string; reason: string; bound: string; comparisonGroup: string | null;
metric?: "mrr" | "arr" | "revenue-30d" | "annualized-revenue";
observedAt?: string; provider?: string; connectionExpired?: boolean; origin?: "payment-platform";
};
export type ResearchEvidence = {
id: string; companyId: string | null; title: string; url: string;
publishedAt: string | null; accessedAt: string; kind: string;
summary: string; limitation: string;
};
export type ResearchReview = {
id: string; type: string; company: string; summary: string;
evidenceIds: string[]; handling: string;
scope?: "companies" | "dataset" | "excluded-candidate"; companyIds?: string[];
};
export type ResearchCoverage = {
source: string; accessedAt: string; candidateRecords: number; profilesRead: number;
matchingProfiles: number; existingProfiles: number; newProfiles: number;
otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number;
discoverySha256: string;
};
export type ResearchCandidateBatch = {
id: string; sourceId: string; url: string; accessedAt: string; scope: string; complete: boolean;
records: {
id: string; name: string; url: string | null; categoryId: string;
kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only";
status: "pending-year" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
companyId?: string; evidenceIds?: string[]; note: string;
}[];
};
export type ResearchSnapshot = {
version: string; asOf: string; scope: string; limitations: string[];
categories: ResearchCategory[]; companies: ResearchCompany[];
observations: ResearchObservation[]; evidence: ResearchEvidence[];
reviews: ResearchReview[]; coverage?: ResearchCoverage; candidateBatches?: ResearchCandidateBatch[];
directoryRechecks?: {
ycId: number; originalStatus: "ai-unclear" | "year-outside-or-missing" | "date-conflict";
companyId: string; reviewedAt: string; evidenceIds: string[]; reason: string;
}[];
};
export function evidenceUrl(value: string): string | undefined {
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
} catch { return undefined; }
}
export function validateResearch(snapshot: ResearchSnapshot) {
const unique = (values: string[], label: string) => {
if (new Set(values).size !== values.length || values.some((v) => !v))
throw new Error(`Duplicate or missing research ${label}`);
return new Set(values);
};
if (!snapshot.version || !/^\d{4}-\d{2}-\d{2}$/.test(snapshot.asOf))
throw new Error("Invalid research dataset metadata");
const categories = unique(snapshot.categories.map((c) => c.id), "categories");
const companies = unique(snapshot.companies.map((c) => c.id), "companies");
const evidence = unique(snapshot.evidence.map((e) => e.id), "evidence");
unique(snapshot.observations.map((o) => o.id), "observations");
unique(snapshot.reviews.map((r) => r.id), "reviews");
for (const c of snapshot.companies) {
const technical = c.technicalReview;
if (technical && (!["description", "public-artifact", "independent-test"].includes(technical.level) ||
!/^\d{4}-\d{2}-\d{2}$/.test(technical.reviewedAt) ||
Number.isNaN(Date.parse(technical.reviewedAt)) ||
new Date(technical.reviewedAt).toISOString().slice(0, 10) !== technical.reviewedAt ||
technical.reviewedAt > snapshot.asOf || !technical.summary.trim() ||
!technical.evidenceIds.length || technical.evidenceIds.some((id) => !evidence.has(id)) ||
(technical.level === "independent-test" && !technical.testScope?.trim())))
throw new Error(`Invalid technical review: ${c.id}`);
if (c.profileOnly && (c.revenueStatus !== "unknown" || !c.classificationBasis))
throw new Error(`Invalid profile-only research record: ${c.id}`);
if (c.directoryOrigin && (c.directoryOrigin !== "yc-2025-2026" || !/^YC\d+$/.test(c.id)))
throw new Error(`Invalid research directory origin: ${c.id}`);
if (!categories.has(c.cat) || !evidenceUrl(c.url) ||
c.evidenceIds.some((id) => !evidence.has(id)) ||
(c.pricingEvidence && !evidence.has(c.pricingEvidence)))
throw new Error(`Invalid research company reference: ${c.id}`);
}
for (const e of snapshot.evidence) {
if (!evidenceUrl(e.url) || (e.companyId && !companies.has(e.companyId)))
throw new Error(`Invalid research evidence: ${e.id}`);
}
for (const o of snapshot.observations) {
if (!companies.has(o.company) || !evidence.has(o.evidence) ||
!Number.isFinite(o.value))
throw new Error(`Invalid research observation: ${o.id}`);
}
unique((snapshot.candidateBatches ?? []).map((b) => b.id), "candidate batches");
unique((snapshot.candidateBatches ?? []).flatMap((b) => b.records.map((r) => r.id)), "candidates");
for (const batch of snapshot.candidateBatches ?? []) {
if (!/^S\d{2}$/.test(batch.sourceId) || !evidenceUrl(batch.url) || !batch.scope.trim() ||
typeof batch.complete !== "boolean" || !/^\d{4}-\d{2}-\d{2}$/.test(batch.accessedAt) ||
Number.isNaN(Date.parse(batch.accessedAt)) ||
new Date(batch.accessedAt).toISOString().slice(0, 10) !== batch.accessedAt ||
batch.accessedAt > snapshot.asOf)
throw new Error(`Invalid research candidate batch: ${batch.id}`);
for (const row of batch.records) {
const linked = ["linked", "existing-outside-cohort"].includes(row.status);
if (!row.name.trim() || !row.note.trim() || !categories.has(row.categoryId) ||
!["product", "company", "promotion"].includes(row.kind) ||
!["readable", "unavailable", "archive-only"].includes(row.profileStatus) ||
!["pending-year", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
(row.kind !== "promotion" && (!row.url || !evidenceUrl(row.url))) ||
(row.url !== null && !evidenceUrl(row.url)) ||
linked !== !!row.companyId || (row.companyId && !companies.has(row.companyId)) ||
(row.kind === "promotion") !== (row.status === "promotion") ||
(row.evidenceIds ?? []).some((id) => !evidence.has(id)) ||
(row.status === "outside-cohort" && !row.evidenceIds?.length))
throw new Error(`Invalid research candidate: ${row.id}`);
}
}
const coverage = snapshot.coverage;
unique((snapshot.directoryRechecks ?? []).map((row) => String(row.ycId)), "directory rechecks");
for (const row of snapshot.directoryRechecks ?? []) {
if (!Number.isSafeInteger(row.ycId) || row.ycId <= 0 || !companies.has(row.companyId) ||
!["ai-unclear", "year-outside-or-missing", "date-conflict"].includes(row.originalStatus) ||
!/^\d{4}-\d{2}-\d{2}$/.test(row.reviewedAt) || Number.isNaN(Date.parse(row.reviewedAt)) ||
new Date(row.reviewedAt).toISOString().slice(0, 10) !== row.reviewedAt || row.reviewedAt > snapshot.asOf ||
!row.reason.trim() || !row.evidenceIds.length || row.evidenceIds.some((id) => !evidence.has(id)))
throw new Error(`Invalid directory recheck: ${row.ycId}`);
}
if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords ||
coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles ||
coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead ||
snapshot.companies.filter((c) => c.profileOnly || c.directoryOrigin === "yc-2025-2026").length !== coverage.newProfiles || !evidenceUrl(coverage.source)))
throw new Error("Invalid research coverage accounting");
for (const r of snapshot.reviews)
if (r.evidenceIds.some((id) => !evidence.has(id)) ||
(r.companyIds && (new Set(r.companyIds).size !== r.companyIds.length ||
r.companyIds.some((id) => !companies.has(id)))) ||
(r.scope && !["companies", "dataset", "excluded-candidate"].includes(r.scope)) ||
(r.scope === "companies" && !r.companyIds?.length) ||
(r.scope && r.scope !== "companies" && !!r.companyIds?.length))
throw new Error(`Invalid research review: ${r.id}`);
}
/** Explicit reviewed associations take priority; older revisions use evidence ownership. */
export function reviewCompanyIds(snapshot: ResearchSnapshot, review: ResearchReview): string[] {
if (review.companyIds) return review.companyIds;
const evidenceIds = new Set(review.evidenceIds);
return [...new Set(snapshot.evidence.flatMap((e) =>
e.companyId && evidenceIds.has(e.id) ? [e.companyId] : []))];
}
export function categorySummary(snapshot: ResearchSnapshot) {
return snapshot.categories.map((category) => {
const companies = snapshot.companies.filter((c) => c.cat === category.id);
return { ...category, count: companies.length,
self: companies.filter((c) => c.revenueStatus === "self").length,
platform: companies.filter((c) => c.revenueStatus === "platform").length,
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
});
}
+2 -8
View File
@@ -1,14 +1,8 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { authId, matchingStep, type AuthConfig } from "./auth";
import { openSiteDatabase } from "./site-database";
function database() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
chmodSync(file, 0o600);
const db = openSiteDatabase();
db.exec(`PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS login_state (
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
+1 -1
View File
@@ -18,7 +18,7 @@ export function collectionIcon(name?: string): "book" | "code" | "layers" | "nod
export function collectionEntries(docs: DocLink[]) {
return [...new Set(docs.map((doc) => doc.collection))].map((name) => {
const items = docs.filter((doc) => doc.collection === name);
const entry = items.find((doc) => doc.slug.endsWith("/00-overview")) ?? items[0];
const entry = items.find((doc) => doc.slug.endsWith("/overview")) ?? items[0];
return { name, entry, count: items.length };
});
}
+226 -33
View File
@@ -1,44 +1,237 @@
import fs from "node:fs/promises";
import path from "node:path";
import catalog from "../../../../content/catalog.json";
import { randomUUID, createHash } from "node:crypto";
import { openSiteDatabase } from "./site-database";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
type Metadata = { collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
export type DocLink = {
slug: string; title: string; searchText?: string;
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
group: string; collection: string; description: string; draft: boolean; featured: boolean;
revision: number;
};
export type Doc = DocLink & { content: string };
export type DocInput = Omit<Doc, "revision" | "searchText">;
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
export class DocumentValidationError extends Error {}
export class DocumentConflictError extends Error {}
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
function database() {
const db = openSiteDatabase();
db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, source_slug TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL, payload TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_aliases (
alias TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id)
);
CREATE TABLE IF NOT EXISTS document_revisions (
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
);
CREATE TABLE IF NOT EXISTS document_write_requests (
namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL,
response TEXT NOT NULL, PRIMARY KEY(namespace, request_id)
);
CREATE TABLE IF NOT EXISTS document_imports (
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
);
`);
return db;
}
export function validateDocument(input: DocInput): void {
if (!input || typeof input !== "object") throw new DocumentValidationError("Invalid document");
for (const key of ["slug", "sourceSlug"] as const)
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`);
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug");
for (const key of ["title", "category", "collection", "group"] as const)
if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`);
if (typeof input.content !== "string" || typeof input.description !== "string" ||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields");
if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit");
const images = [
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
];
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
}
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
validateDocument(input);
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
const id = existing?.id ?? randomUUID();
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
title: input.title, group: input.group, collection: input.collection, description: input.description,
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
const payload = JSON.stringify(doc);
// Reserve both canonical and historical names before updating any rows.
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
.get(alias) as { document_id: string } | undefined;
if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`);
}
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
for (const alias of new Set([doc.slug, doc.sourceSlug]))
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
.run(id, doc.revision, payload, new Date().toISOString());
return doc;
}
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try { const doc = write(db, input, expectedRevision); db.exec("COMMIT"); return doc; }
catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function readDocument(slug: string): Doc | null {
if (!validSlug.test(slug)) return null;
const db = database();
try {
const row = db.prepare(`SELECT d.payload FROM documents d JOIN document_aliases a
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { payload: string } | undefined;
return row ? JSON.parse(row.payload) as Doc : null;
} finally { db.close(); }
}
export function allDocuments(): Doc[] {
const db = database();
try {
return (db.prepare("SELECT payload FROM documents ORDER BY source_slug").all() as { payload: string }[])
.map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); }
}
export async function readDoc(slug: string): Promise<Doc> {
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
const content = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
const collection = catalog.collections.find((c) => !c.path || slug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[slug] ?? {};
return { slug, title, content, searchText: content,
group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title,
description: metadata.description ?? collection.description,
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
};
const doc = readDocument(slug);
if (!doc) throw Error("Document not found");
return doc;
}
export async function listDocs(): Promise<DocLink[]> {
async function walk(folder: string): Promise<string[]> {
const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const relative = folder ? `${folder}/${entry.name}` : entry.name;
if (entry.isDirectory()) return walk(relative);
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
return [relative.slice(0, -3)];
return [];
}));
return files.flat();
}
return Promise.all((await walk("")).sort().map(async (slug) => {
const { content: _, searchText: __, ...doc } = await readDoc(slug);
void _; void __;
return doc;
}));
return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; });
}
// Explicit, atomic bootstrap only. Runtime reads never import repository data.
export function importDocuments(bundle: DocumentExport, onlyIfEmpty = false): number {
if (bundle?.format !== "tabularium-documents-v1" || !Array.isArray(bundle.documents) || !bundle.documents.length)
throw Error("Invalid or empty document export");
bundle.documents.forEach(validateDocument);
const digest = createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try {
const imported = db.prepare("SELECT digest FROM document_imports WHERE digest=?").get(digest);
if (imported) { db.exec("COMMIT"); return 0; }
const count = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
if (count) {
if (!onlyIfEmpty) throw Error("Bootstrap requires an empty document database");
db.exec("COMMIT"); return 0;
}
for (const doc of bundle.documents) write(db, doc, 0);
db.prepare("INSERT INTO document_imports VALUES (?,?,?)").run(digest, bundle.documents.length, new Date().toISOString());
db.exec("COMMIT"); return bundle.documents.length;
} catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function documentHistory(sourceSlug: string): Doc[] {
const db = database();
try {
return (db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
ON r.document_id=d.id WHERE d.source_slug=? ORDER BY r.revision`).all(sourceSlug) as { payload: string }[])
.map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); }
}
// API batches commit entirely or not at all; receipts make lost-response retries safe.
export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) {
if (!Array.isArray(documents) || !documents.length || documents.length > 100)
throw new DocumentValidationError("Provide 1 to 100 documents");
const seen = new Set<string>();
for (const doc of documents) {
validateDocument(doc);
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0)
throw new DocumentValidationError("Provide expected revision");
if (seen.has(doc.sourceSlug)) throw new DocumentValidationError("Duplicate sourceSlug in batch");
seen.add(doc.sourceSlug);
}
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id)))
throw new DocumentValidationError("Invalid Idempotency-Key");
const digest = createHash("sha256").update(JSON.stringify(documents)).digest("hex");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try {
if (request) {
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
.get(request.namespace, request.id) as { digest: string; response: string } | undefined;
if (prior) {
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
db.exec("COMMIT");
return { documents: JSON.parse(prior.response) as DocumentReceipt[], replayed: true };
}
}
const receipts = documents.map((input) => {
const { slug, sourceSlug, revision } = write(db, input, input.revision);
return { slug, sourceSlug, revision };
});
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
.run(request.namespace, request.id, digest, JSON.stringify(receipts));
db.exec("COMMIT");
return { documents: receipts, replayed: false };
} catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function documentPage(limit = 50, offset = 0) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
throw new DocumentValidationError("Invalid pagination");
const db = database();
try {
db.exec("BEGIN");
const total = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
const documents = (db.prepare("SELECT json_remove(payload,'$.content') AS payload FROM documents ORDER BY source_slug LIMIT ? OFFSET ?")
.all(limit, offset) as { payload: string }[]).map((row) => JSON.parse(row.payload) as DocLink);
db.exec("COMMIT");
return { documents, total, limit, offset, nextOffset: offset + documents.length < total ? offset + documents.length : null };
} finally { db.close(); }
}
export function documentHistoryPage(sourceSlug: string, limit = 50, offset = 0) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
throw new DocumentValidationError("Invalid pagination");
const db = database();
try {
db.exec("BEGIN");
const total = (db.prepare(`SELECT COUNT(*) AS n FROM document_revisions r JOIN documents d
ON d.id=r.document_id WHERE d.source_slug=?`).get(sourceSlug) as { n: number }).n;
const revisions = (db.prepare(`SELECT json_remove(r.payload,'$.content') AS payload, r.created_at AS createdAt
FROM document_revisions r JOIN documents d ON d.id=r.document_id
WHERE d.source_slug=? ORDER BY r.revision DESC LIMIT ? OFFSET ?`)
.all(sourceSlug, limit, offset) as { payload: string; createdAt: string }[])
.map((row) => ({ ...JSON.parse(row.payload) as DocLink, createdAt: row.createdAt }));
db.exec("COMMIT");
return { revisions, total, limit, offset, nextOffset: offset + revisions.length < total ? offset + revisions.length : null };
} finally { db.close(); }
}
export function documentRevision(sourceSlug: string, revision: number): Doc | null {
if (!Number.isSafeInteger(revision) || revision < 1) throw new DocumentValidationError("Invalid revision");
const db = database();
try {
const row = db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
ON d.id=r.document_id WHERE d.source_slug=? AND r.revision=?`).get(sourceSlug, revision) as { payload: string } | undefined;
return row ? JSON.parse(row.payload) as Doc : null;
} finally { db.close(); }
}
+56
View File
@@ -0,0 +1,56 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { openSiteDatabase } from "./site-database";
export type DocumentKeyScope = "read" | "read-write";
export type DocumentKey = { id: string; name: string; scope: DocumentKeyScope;
createdAt: number; expiresAt: number; revokedAt: number | null; lastUsedAt: number | null };
function database() {
const db = openSiteDatabase();
db.exec(`CREATE TABLE IF NOT EXISTS document_api_keys (
id TEXT PRIMARY KEY, name TEXT NOT NULL, digest TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK(scope IN ('read','read-write')), created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, revoked_at INTEGER, last_used_at INTEGER
);`);
return db;
}
export function createDocumentKey(name: string, scope: DocumentKeyScope = "read-write", days = 90) {
if (!name.trim() || name.length > 100 || !["read", "read-write"].includes(scope) ||
!Number.isSafeInteger(days) || days < 1 || days > 365) throw Error("Invalid key name, scope or lifetime");
const id = randomUUID();
const token = "tdoc_" + randomBytes(32).toString("base64url");
const createdAt = Date.now();
const expiresAt = createdAt + days * 86400000;
const db = database();
try {
db.prepare("INSERT INTO document_api_keys(id,name,digest,scope,created_at,expires_at) VALUES (?,?,?,?,?,?)")
.run(id, name.trim(), createHash("sha256").update(token).digest("hex"), scope, createdAt, expiresAt);
return { id, token, name: name.trim(), scope, createdAt, expiresAt };
} finally { db.close(); }
}
export function authenticateDocumentKey(token: string | undefined, now = Date.now()) {
if (!token || !/^tdoc_[A-Za-z0-9_-]{43}$/.test(token)) return null;
const db = database();
try {
const row = db.prepare("SELECT id,scope,last_used_at AS lastUsedAt FROM document_api_keys WHERE digest=? AND revoked_at IS NULL AND expires_at>?")
.get(createHash("sha256").update(token).digest("hex"), now) as { id: string; scope: DocumentKeyScope; lastUsedAt: number | null } | undefined;
if (!row) return null;
if (row.lastUsedAt === null || row.lastUsedAt < now - 60000)
db.prepare("UPDATE document_api_keys SET last_used_at=? WHERE id=? AND (last_used_at IS NULL OR last_used_at<?)")
.run(now, row.id, now - 60000);
return { id: row.id, scope: row.scope };
} finally { db.close(); }
}
export function listDocumentKeys(): DocumentKey[] {
const db = database();
try {
return db.prepare(`SELECT id,name,scope,created_at AS createdAt,expires_at AS expiresAt,
revoked_at AS revokedAt,last_used_at AS lastUsedAt FROM document_api_keys ORDER BY created_at,id`).all() as DocumentKey[];
} finally { db.close(); }
}
export function revokeDocumentKey(id: string) {
const db = database();
try {
const result = db.prepare("UPDATE document_api_keys SET revoked_at=COALESCE(revoked_at,?) WHERE id=?").run(Date.now(), id);
if (!result.changes) throw Error("Document key not found");
} finally { db.close(); }
}
+51
View File
@@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticateDocumentKey } from "./document-keys";
import { DocumentConflictError, DocumentValidationError, validateDocument, type Doc } from "./docs";
export function authorizeDocuments(request: NextApiRequest, response: NextApiResponse, write = false) {
response.setHeader("Cache-Control", "private, no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
const header = request.headers.authorization;
const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : undefined;
const key = authenticateDocumentKey(token);
if (!key) {
response.status(401).json({ error: "需要有效的文档 API Key", code: "unauthorized" });
return null;
}
if (write && key.scope !== "read-write") {
response.status(403).json({ error: "该 API Key 仅允许读取文档", code: "read_only" });
return null;
}
return key;
}
export function documentApiError(response: NextApiResponse, error: unknown) {
if (error instanceof DocumentConflictError)
return response.status(409).json({ error: error.message, code: "conflict" });
if (error instanceof DocumentValidationError)
return response.status(400).json({ error: error.message, code: "invalid_document" });
return response.status(503).json({ error: "文档数据库暂时不可用", code: "unavailable" });
}
export function parseDocument(value: unknown): Doc {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected a document object");
const row = value as Record<string, unknown>;
const fields = ["slug", "sourceSlug", "title", "category", "collection", "group", "description", "draft", "featured", "content", "revision"];
if (Object.keys(row).some((key) => !fields.includes(key))) throw new DocumentValidationError("Unknown document field");
const doc = Object.fromEntries(fields.map((key) => [key, row[key]])) as Doc;
validateDocument(doc);
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0) throw new DocumentValidationError("Provide expected revision");
return doc;
}
export function pagination(request: NextApiRequest) {
const number = (value: string | string[] | undefined, fallback: number) => {
if (value === undefined) return fallback;
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new DocumentValidationError("Invalid pagination");
return Number(value);
};
return { limit: number(request.query.limit, 50), offset: number(request.query.offset, 0) };
}
export function idempotency(request: NextApiRequest, namespace: string) {
const id = request.headers["idempotency-key"];
if (id === undefined) return undefined;
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new DocumentValidationError("Invalid Idempotency-Key");
return { namespace, id };
}
+82
View File
@@ -0,0 +1,82 @@
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
export type IncomeStats = {
n: number; mean: number; median: number; min: number; max: number;
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null;
largestShare: number | null; meanWithoutLargest: number | null; largestRemovalDrop: number | null;
};
/** Describes the supplied sample only; missing values never become zero. */
export function describeIncome(values: number[]): IncomeStats | null {
if (!values.length || values.some((value) => !Number.isFinite(value) || value < 0)) return null;
const ordered = [...values].sort((a, b) => a - b);
const n = ordered.length;
const total = ordered.reduce((sum, value) => sum + value, 0);
const mean = total / n;
const median = n % 2 ? ordered[(n - 1) / 2] : (ordered[n / 2 - 1] + ordered[n / 2]) / 2;
const sampleDeviation = n > 1 ? Math.sqrt(ordered.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)) : null;
const max = ordered[n - 1];
const meanWithoutLargest = n > 1 ? (total - max) / (n - 1) : null;
return { n, mean, median, min: ordered[0], max, sampleDeviation,
cv: sampleDeviation !== null && mean > 0 ? sampleDeviation / mean : null,
meanMedianRatio: median > 0 ? mean / median : null,
largestShare: total > 0 ? max / total : null, meanWithoutLargest,
largestRemovalDrop: meanWithoutLargest !== null && mean > 0 ? (mean - meanWithoutLargest) / mean : null };
}
function calendarDay(value: string | undefined): number | null {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const time = Date.parse(`${value}T00:00:00Z`);
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? time / 86400000 : null;
}
/** Latest dated platform MRR per product, never ARR, cash collections or estimates. */
export function platformIncomeSample(snapshot: ResearchSnapshot, options: {
windowDays: number | null; foundedYear?: number; annualize?: boolean;
}) {
const end = calendarDay(snapshot.asOf);
if (end === null || (options.windowDays !== null && (!Number.isInteger(options.windowDays) || options.windowDays < 0)))
throw new Error("Invalid income sample window");
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
const latest = new Map<string, ResearchObservation>();
const excluded: { observation: ResearchObservation; reason: string }[] = [];
const candidates = snapshot.observations.filter((o) => o.selection === "platform" && o.metric === "mrr");
for (const observation of candidates) {
const company = companies.get(observation.company);
const day = calendarDay(observation.observedAt);
let reason = "";
if (!company || ![2025, 2026].includes(company.year ?? 0)) reason = "创立年份未纳入2025—2026组";
else if (options.foundedYear && company.year !== options.foundedYear) reason = "不属于所选创立年份";
else if (observation.origin !== "payment-platform" || observation.unit !== "USD" || observation.bound !== "=" ||
observation.comparisonGroup !== "trustmrr-usd-mrr" || !observation.provider || !Number.isFinite(observation.value) || observation.value < 0)
reason = "来源、币种、指标或数值口径不满足条件";
else if (day === null) reason = "缺少有效数据日期";
else if (day > end) reason = "数据日期晚于研究截止日";
else if (options.windowDays !== null && end - day > options.windowDays) reason = "最后同步日期超出所选窗口";
if (reason) { excluded.push({ observation, reason }); continue; }
const previous = latest.get(observation.company);
if (!previous || observation.observedAt! > previous.observedAt!) {
if (previous) excluded.push({ observation: previous, reason: "同一产品的较早观测" });
latest.set(observation.company, observation);
} else excluded.push({ observation, reason: "同一产品已选较新或同日观测" });
}
const points: IncomePoint[] = [...latest.values()].map((observation) => ({
company: companies.get(observation.company)!, observation,
value: observation.value * (options.annualize ? 12 : 1),
}));
return { points, excluded };
}
export function groupIncome(points: IncomePoint[], splitModels = true) {
const groups = new Map<string, { category: string; provider: string; model: string; points: IncomePoint[] }>();
for (const point of points) {
const category = point.company.cat;
const provider = point.observation.provider!;
const model = splitModels ? point.company.businessModel ?? "收费模式未核验" : "全部收费模式";
const key = JSON.stringify([category, provider, model]);
if (!groups.has(key)) groups.set(key, { category, provider, model, points: [] });
groups.get(key)!.points.push(point);
}
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
}
+20
View File
@@ -0,0 +1,20 @@
import { chmodSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
// Keep the historical filename so existing authentication state is preserved.
// Research and authentication use different tables in this same site database.
export function openSiteDatabase() {
const configFile = process.env.AUTH_CONFIG_FILE;
if (!configFile) throw new Error("Authentication is not configured");
const file = join(dirname(configFile), "login-state.sqlite");
const db = new DatabaseSync(file);
try {
chmodSync(file, 0o600);
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
return db;
} catch (error) {
db.close();
throw error;
}
}
@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { DocumentValidationError, saveDocumentBatch } from "@/lib/docs";
import { authorizeDocuments, documentApiError, idempotency, parseDocument } from "@/lib/documents-api";
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
const key = authorizeDocuments(request, response, true);
if (!key) return;
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return response.status(405).end();
}
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body) ||
Object.keys(body).length !== 1 || !Array.isArray(body.documents) ||
!body.documents.length || body.documents.length > 100)
throw new DocumentValidationError("Expected { documents: [...] } with 1 to 100 documents");
const documents = body.documents.map((row: unknown) => parseDocument(row));
return response.status(200).json(saveDocumentBatch(documents, idempotency(request, key.id)));
} catch (error) { return documentApiError(response, error); }
}
@@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { documentPage, saveDocumentBatch } from "@/lib/docs";
import { authorizeDocuments, documentApiError, idempotency, pagination, parseDocument } from "@/lib/documents-api";
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
const key = authorizeDocuments(request, response, request.method === "POST");
if (!key) return;
if (request.method === "GET") {
const { limit, offset } = pagination(request);
return response.status(200).json(documentPage(limit, offset));
}
if (request.method === "POST") {
const result = saveDocumentBatch([parseDocument(request.body)], idempotency(request, key.id));
return response.status(200).json({ document: result.documents[0], replayed: result.replayed });
}
response.setHeader("Allow", "GET, POST");
return response.status(405).end();
} catch (error) { return documentApiError(response, error); }
}
@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { DocumentValidationError, documentHistoryPage, documentRevision, readDocument } from "@/lib/docs";
import { authorizeDocuments, documentApiError, pagination } from "@/lib/documents-api";
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
if (!authorizeDocuments(request, response)) return;
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const slug = request.query.slug;
if (!Array.isArray(slug)) throw new DocumentValidationError("Invalid document slug");
const doc = readDocument(slug.join("/"));
if (!doc) return response.status(404).json({ error: "文档不存在", code: "not_found" });
if (request.query.revision !== undefined) {
if (request.query.history !== undefined || typeof request.query.revision !== "string" || !/^\d+$/.test(request.query.revision))
throw new DocumentValidationError("Invalid revision query");
const prior = documentRevision(doc.sourceSlug, Number(request.query.revision));
if (!prior) return response.status(404).json({ error: "修订不存在", code: "not_found" });
return response.status(200).json(prior);
}
if (request.query.history !== undefined) {
if (request.query.history !== "1") throw new DocumentValidationError("Use history=1");
const { limit, offset } = pagination(request);
return response.status(200).json(documentHistoryPage(doc.sourceSlug, limit, offset));
}
return response.status(200).json(doc);
} catch (error) { return documentApiError(response, error); }
}
@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { readDocument } from "@/lib/docs";
// The proxy protects Markdown just like the rendered document and search index.
export default function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const slug = request.query.slug;
if (typeof slug !== "string") return response.status(400).json({ error: "缺少文档地址" });
try {
const doc = readDocument(slug);
if (!doc) return response.status(404).end();
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("X-Content-Type-Options", "nosniff");
return response.status(200).send(doc.content);
} catch {
return response.status(503).json({ error: "文档数据库暂时不可用" });
}
}
@@ -0,0 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { readResearchSnapshot } from "@/lib/ai-research-store";
// The site's existing proxy protects this endpoint with the same login session.
export default function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
try { return response.status(200).json(readResearchSnapshot()); }
catch { return response.status(503).json({ error: "研究数据库暂时不可用,请稍后重试" }); }
}
+7 -6
View File
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { listDocs, readDoc } from "@/lib/docs";
import { allDocuments } from "@/lib/docs";
// The proxy authenticates this endpoint, including index content.
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
@@ -8,9 +8,10 @@ export default async function handler(request: NextApiRequest, response: NextApi
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const docs = await listDocs();
const index = await Promise.all(docs.map(async (doc) => ({
...doc, searchText: (await readDoc(doc.slug)).content,
})));
response.status(200).json(index);
try {
const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content }));
return response.status(200).json(index);
} catch {
return response.status(503).json({ error: "文档数据库暂时不可用" });
}
}
+50 -23
View File
@@ -1,9 +1,9 @@
import type {
GetStaticPaths,
GetStaticProps,
InferGetStaticPropsType,
GetServerSideProps,
InferGetServerSidePropsType,
} from "next";
import Head from "next/head";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Children,
@@ -14,25 +14,28 @@ import {
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { listDocs, readDocument, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
import { FigureGallery } from "@/components/FigureGallery";
import { CodeBlock } from "@/components/CodeBlock";
import { collectionEntries, collectionLabel } from "@/lib/collections";
import { Icon } from "@/components/Icon";
import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
fallback: false,
});
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
res.setHeader("Cache-Control", "private, no-store");
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
if (typeof slug !== "string") return { notFound: true };
const docs = await listDocs();
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
return { props: { doc: await readDoc(slug), docs } };
const doc = readDocument(slug);
if (!doc) return { notFound: true };
if (slug !== doc.slug) {
const query = resolvedUrl.includes("?") ? resolvedUrl.slice(resolvedUrl.indexOf("?")) : "";
return { redirect: { destination: `/docs/${doc.slug}${query}`, permanent: true } };
}
return { props: { doc, docs: await listDocs() } };
};
function textOf(value: ReactNode): string {
return Children.toArray(value)
@@ -46,7 +49,7 @@ function textOf(value: ReactNode): string {
export default function DocPage({
doc,
docs,
}: InferGetStaticPropsType<typeof getStaticProps>) {
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const headings = headingsFor(doc.content);
const [active, setActive] = useState("");
@@ -87,7 +90,7 @@ export default function DocPage({
<meta name="description" content={doc.description} />
</Head>
<Shell key={doc.slug} docs={docs} current={doc.slug}>
<div className="reading-layout">
<div className={`reading-layout${doc.collection === "AI 公司研究" ? " research-layout" : ""}`}>
<aside className="sidebar">
<Navigation docs={docs} current={doc.slug} />
</aside>
@@ -102,15 +105,41 @@ export default function DocPage({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children, node }) =>
node?.children.some(
a: ({ href, children }) => {
if (!href) return <a>{children}</a>;
let target = href;
if (!/^(?:[a-z]+:|\/\/|#)/i.test(href)) {
const resolved = new URL(href, `https://docs.local/docs/${doc.sourceSlug}`);
const source = decodeURIComponent(resolved.pathname).replace(/^\/docs\//, "").replace(/\.md$/, "");
const linked = docs.find((d) => d.sourceSlug === source || d.slug === source);
if (linked) target = `/docs/${linked.slug}${resolved.search}${resolved.hash}`;
}
return <a href={target}>{children}</a>;
},
p: ({ children, node }) => {
const slides = node?.children.flatMap((child) =>
child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
: [],
) ?? [];
const onlyImages = node?.children.every((child) =>
(child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")) ||
(child.type === "text" && child.value.trim() === ""),
);
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
return node?.children.some(
(child) =>
child.type === "element" && child.tagName === "img",
) ? (
<div>{children}</div>
) : (
<p>{children}</p>
),
);
},
img: ({ src, alt }) =>
typeof src === "string" && src.startsWith("/figures/endor-") ? (
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
@@ -198,12 +227,13 @@ export default function DocPage({
{doc.content}
</ReactMarkdown>
</article>
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
<div className="article-end">
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.slug}.md`}
href={`/api/documents/source?slug=${encodeURIComponent(doc.slug)}`}
>
查看文档源码 ↗
查看 Markdown 原文 ↗
</a>
</div>
<nav className="pagination" aria-label="相邻文档">
@@ -213,10 +243,7 @@ export default function DocPage({
<strong>{siblings[index - 1].title}</strong>
</Link>
) : (
<Link href="/">
<small>← 返回</small>
<strong>文档概览</strong>
</Link>
<span />
)}
{index < siblings.length - 1 && (
<Link
+6 -5
View File
@@ -1,4 +1,4 @@
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
@@ -7,11 +7,12 @@ import { Shell, Navigation } from "@/components/Shell";
import { Icon } from "@/components/Icon";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
props: { docs: await listDocs() },
});
export const getServerSideProps: GetServerSideProps<{ docs: DocLink[] }> = async ({ res }) => {
res.setHeader("Cache-Control", "private, no-store");
return { props: { docs: await listDocs() } };
};
const PAGE_SIZE = 8;
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
export default function Home({ docs }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [collection, setCollection] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
+9 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import { authenticateDocumentKey } from "./lib/document-keys";
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const headers = {
@@ -32,6 +32,14 @@ export function proxy(request: NextRequest) {
? authorization.slice(7)
: undefined;
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
// Document keys are scoped to the document API; they never authenticate other pages or APIs.
if (bearer && (path === "/api/documents" || path.startsWith("/api/documents/"))) {
try {
if (authenticateDocumentKey(bearer)) return NextResponse.next({ headers });
} catch {
return NextResponse.json({ error: "文档认证暂时不可用" }, { status: 503, headers });
}
}
if (validSession(token, config)) {
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
try {
+218
View File
@@ -882,6 +882,57 @@ button {
border-radius: 0;
background: transparent;
}
.figure-gallery {
margin: 28px 0;
}
.figure-gallery .architecture-preview {
margin: 0;
}
.figure-gallery-controls,
.figure-gallery-dots {
display: flex;
align-items: center;
justify-content: center;
}
.figure-gallery-controls {
gap: 20px;
margin-top: 12px;
}
.figure-gallery-arrow,
.figure-gallery-dot {
display: grid;
place-items: center;
min-width: 36px;
min-height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
color: #57534e;
background: transparent;
cursor: pointer;
}
.figure-gallery-arrow {
font-size: 28px;
line-height: 1;
}
.figure-gallery-dot span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d6d3d1;
}
.figure-gallery-dot[aria-current="true"] span {
background: #2563eb;
}
.figure-gallery-arrow:hover,
.figure-gallery-dot:hover {
background: #f5f5f4;
}
.figure-gallery-arrow:focus-visible,
.figure-gallery-dot:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.image-lightbox {
width: 100vw;
height: 100dvh;
@@ -1262,3 +1313,170 @@ svg { flex-shrink: 0; }
*, *::before, *::after { animation: none !important; transition: none !important; }
.featured-link:hover, .featured-link:hover .collection-art, .pagination > a:hover { transform: none; }
}
/* Compact navigation and local font roles, with no font downloads. */
:root {
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
}
.top-nav { margin-left: 0; }
.sidebar { padding: 22px 16px 32px; }
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
.collection-identity svg { width: 18px; height: 18px; }
.nav-group { margin-top: 20px; }
.nav-group > p { margin: 0 8px 6px; font-size: 11px; letter-spacing: .02em; color: #7d8981; }
.nav-item { font-size: 13px; padding: 5px 8px; margin: 0; line-height: 1.5; gap: 6px; }
.nav-item[aria-current="page"]::before { top: 7px; bottom: 7px; }
.collection-nav-link { padding: 7px 8px; margin-bottom: 2px; gap: 9px; font-size: 13px; }
.prose { font-family: var(--font-reading); font-size: 16px; line-height: 1.95; }
.prose :is(h1,h2,h3,h4), .prose table, .code-toolbar, .mobile-toc { font-family: var(--font-sans); }
.prose :is(h1,h2,h3) { font-weight: 600; letter-spacing: -.025em; }
.prose :is(code,pre), .code-block pre, kbd { font-family: var(--font-code); font-variant-ligatures: none; }
.prose code { font-size: .86em; }
@media (min-width: 1201px) {
.home-layout { grid-template-columns: 240px minmax(0,1fr); }
.reading-layout { grid-template-columns: 240px minmax(0,1fr) 222px; }
}
@media (min-width: 761px) and (max-width: 1200px) {
.home-layout, .reading-layout { grid-template-columns: 224px minmax(0,1fr); }
}
@media (max-width: 760px) {
.top-nav { overflow: visible; }
.mobile-navigation .nav-group { margin-top: 16px; }
}
/* Categories stay visible across the header, each with its own disclosure panel. */
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
.category-navigation > ul > li { display: flex; align-items: stretch; }
.category-menu { position: relative; }
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
.category-panel[hidden] { display: none; }
.category-menu:last-child .category-panel { left: auto; right: -16px; }
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
.top-nav .category-panel a::after { display: none; }
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
.category-panel a > svg { margin-top: 2px; color: var(--green); }
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
@media (min-width: 761px) and (max-width: 1100px) {
.site-header { gap: 20px; }
.category-navigation > ul { gap: 16px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
.search-trigger span, .search-trigger kbd { display: none; }
}
@media (max-width: 760px) {
.top-nav.category-navigation { overflow: visible; }
.category-navigation > ul { width: 100%; gap: 18px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.category-menu { position: static; }
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
}
/* Research keeps the documentation navigation and uses the remaining reading width. */
.research-layout { grid-template-columns: 240px minmax(0, 1fr); }
.research-layout .reading-main { max-width: 1440px; width: 100%; }
.research-layout > .toc { display: none; }
.ai-research { margin-top: 28px; font-size: 13px; line-height: 1.65; }
.ai-research h2 { font-size: 20px; font-weight: 600; margin: 0 0 10px; letter-spacing: -.4px; }
.ai-research h3 { font-size: 15px; font-weight: 600; margin: 18px 0 8px; }
.ai-research p { margin: 6px 0 10px; }
.ai-research a, .research-company-name { color: var(--green); }
.ai-research a:hover, .research-company-name:hover { text-decoration: underline; }
.ai-research button { text-align: left; }
.research-meta { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--muted); font-size: 12px; }
.research-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin: 22px 0; }
.research-stats > div { border-top: 1px solid var(--line); padding: 14px 0; }
.research-stats strong { display: block; font-size: 29px; line-height: 1.3; font-weight: 550; font-variant-numeric: tabular-nums; }
.research-stats span { color: var(--muted); font-size: 12px; }
.research-scope { padding: 14px 18px; background: var(--soft); border-radius: 7px; }
.research-scope summary { color: var(--green); font-weight: 500; }
.research-scope ul { padding-left: 20px; }
.research-section { margin: 38px 0; scroll-margin-top: 92px; }
.research-muted, .research-footnote { color: var(--muted); font-size: 12px; }
.research-categories { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 26px; margin-top: 20px; }
.research-categories button { padding: 12px 0; background: transparent; border: 0; border-bottom: 1px solid var(--line); }
.research-categories button:hover { color: var(--green); }
.research-categories button > div, .research-revenue-chart > div > div { display: flex; justify-content: space-between; gap: 16px; }
.research-categories strong { font-variant-numeric: tabular-nums; }
.research-categories small { font-size: 11px; color: var(--muted); }
.research-bar { display: block; height: 5px; background: #f0f3f1; margin: 8px 0; border-radius: 3px; overflow: hidden; }
.research-bar i { display: block; height: 100%; background: #47856d; border-radius: 3px; }
.research-bar[data-estimate] i { background: #b98a40; }
.research-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin: 18px 0; overflow-x: auto; }
.research-tabs button { padding: 10px 0; white-space: nowrap; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); }
.research-tabs button[aria-pressed="true"] { border-bottom-color: var(--green); color: var(--green); font-weight: 600; }
.research-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
.research-filters label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 11px; flex: 1 1 140px; }
.research-filters .research-query { flex: 2 1 210px; }
.research-filters :is(input,select) { min-width: 0; width: 100%; padding: 8px 10px; font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe6e0; border-radius: 5px; height: 36px; }
.research-result-count { display: flex; justify-content: space-between; margin: 14px 0 8px; color: var(--muted); font-size: 12px; }
.research-result-count button { border: 0; background: none; color: var(--green); }
.research-table-wrap { overflow-x: auto; max-width: 100%; }
.ai-research table { width: 100%; border-collapse: collapse; min-width: 720px; text-align: left; font-size: 12px; }
.ai-research caption { text-align: left; color: var(--muted); padding: 12px 0; font-size: 12px; }
.ai-research th { color: var(--muted); background: #f7f9f7; font-weight: 500; }
.ai-research :is(th,td) { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
.ai-research td:first-child { min-width: 140px; }
.ai-research td:nth-child(2) { min-width: 220px; }
.ai-research td p { margin: 6px 0; }
.ai-research td small { display: block; color: var(--muted); margin-top: 5px; }
.ai-research tr[data-selected] { background: #f0f8f3; }
.research-company-name { font-weight: 600; background: none; border: 0; padding: 0; }
.research-tag { display: inline-block; font-size: 10px; line-height: 1.7; padding: 1px 6px; border-radius: 3px; background: #eef3ef; color: #596e60; }
.research-pagination { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
.research-pagination button, .research-message button, .research-detail-heading button { padding: 6px 11px; border-radius: 4px; border: 1px solid var(--line); background: white; }
.research-pagination button:disabled { color: #a8afa9; cursor: default; }
.research-message { padding: 24px 0; color: var(--muted); }
.research-message button { margin-left: 12px; color: var(--green); }
.research-notice { border-left: 3px solid #c6a063; padding: 10px 16px; background: #faf8f2; }
.research-revenue-chart { display: grid; gap: 18px; padding: 18px 0 30px; }
.research-revenue-chart button { padding: 0; border: 0; background: none; color: var(--green); }
.research-revenue-chart strong { font-variant-numeric: tabular-nums; font-weight: 500; }
.research-revenue-chart small { color: var(--muted); font-size: 11px; }
.research-evidence-list, .research-review-list { list-style: none; padding: 0; margin: 0; }
.research-evidence { padding: 18px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 92px; }
.research-evidence > div { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.research-evidence code { font-size: 11px; color: var(--muted); }
.research-evidence strong { font-weight: 500; }
.research-evidence small { color: var(--muted); font-size: 11px; }
.research-review-list > li { padding: 20px 0; border-bottom: 1px solid var(--line); }
.research-review-list h3 { margin: 8px 0; }
.research-review-list > li > div { display: flex; gap: 14px; flex-wrap: wrap; }
.research-detail { padding-top: 24px; border-top: 2px solid #dbe8df; }
.research-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.research-company-facts { margin: 24px 0; }
.research-company-facts > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
.research-company-facts dt { color: var(--muted); }
.research-company-facts dd { margin: 0; }
.research-footnote { border-top: 1px solid var(--line); padding: 18px 0; }
@media (max-width: 760px) {
.research-layout { grid-template-columns: 1fr; }
.research-layout .reading-main { padding: 26px 24px 0; }
.research-section, .research-evidence { scroll-margin-top: 176px; }
}
@media (max-width: 560px) {
.research-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 18px; }
.research-categories { grid-template-columns: 1fr; }
.research-tabs { gap: 16px; }
.research-layout .reading-main { padding: 22px 16px 0; }
}
.research-distribution { margin: 24px 0 38px; }
.research-distribution figure { margin: 20px 0; }
.income-distribution-chart { display: block; width: 100%; min-width: 900px; height: auto; }
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
.research-distribution figcaption { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 10px 0; }
.income-options { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0; font-size: 12px; }
.income-options label { display: flex; gap: 6px; align-items: center; }
.research-distribution details { margin: 16px 0; font-size: 12px; line-height: 1.8; }
+99
View File
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { allDocuments, documentHistory, importDocuments, readDocument, saveDocument,
type DocInput, type DocumentExport } from "../src/lib/docs";
const input: DocInput = { slug: "endor/example", sourceSlug: "00-example", title: "Example",
category: "Endor", collection: "Endor 文档", group: "讨论", description: "Example description",
draft: true, featured: false, content: "# Example\n\nOriginal body" };
function isolated(run: (directory: string) => void) {
const directory = mkdtempSync(join(tmpdir(), "tabularium-docs-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
try { run(directory); } finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(directory, { recursive: true, force: true });
}
}
function bundle(documents: DocInput[]): DocumentExport {
return { format: "tabularium-documents-v1", documents: documents.map((doc) => ({ ...doc, revision: 1 })) };
}
test("writes and metadata persist across independent connections; no repository seed", () => isolated(() => {
assert.deepEqual(allDocuments(), []);
const saved = saveDocument(input, 0);
assert.deepEqual(readDocument(input.slug), saved);
assert.deepEqual(readDocument(input.sourceSlug), saved);
const changed = saveDocument({ ...input, content: "# Revised", collection: "新合集", group: "新目录" }, 1);
assert.equal(changed.revision, 2);
assert.equal(allDocuments()[0].collection, "新合集");
assert.deepEqual(documentHistory(input.sourceSlug).map((doc) => doc.content), [input.content, "# Revised"]);
}));
test("optimistic concurrency prevents lost updates and requires explicit revision", () => isolated(() => {
saveDocument(input, 0);
assert.throws(() => saveDocument({ ...input, content: "Lost" }, 0), /conflict/);
assert.throws(() => saveDocument(input, NaN), /expected revision/);
assert.equal(readDocument(input.slug)?.content, input.content);
assert.equal(documentHistory(input.sourceSlug).length, 1);
}));
test("renames preserve aliases and alias collisions roll back document and revision", () => isolated(() => {
saveDocument(input, 0);
saveDocument({ ...input, slug: "endor/renamed" }, 1);
assert.equal(readDocument(input.slug)?.slug, "endor/renamed");
assert.throws(() => saveDocument({ ...input, sourceSlug: "another", slug: input.slug }, 0), /collision/);
assert.equal(allDocuments().length, 1);
assert.equal(readDocument("another"), null);
assert.equal(documentHistory(input.sourceSlug).length, 2);
}));
test("bootstrap is atomic and repeat imports never replace DB edits", () => isolated(() => {
const snapshot = bundle([input]);
assert.equal(importDocuments(snapshot), 1);
saveDocument({ ...input, content: "Written directly to DB" }, 1);
assert.equal(importDocuments(snapshot), 0);
assert.equal(importDocuments(bundle([{ ...input, content: "Stale release" }]), true), 0);
assert.throws(() => importDocuments(bundle([{ ...input, content: "Other" }])), /empty/);
assert.equal(readDocument(input.slug)?.content, "Written directly to DB");
}));
test("colliding bootstrap leaves no partial rows and can be retried", () => isolated(() => {
assert.throws(() => importDocuments(bundle([input, { ...input, sourceSlug: "other" }])));
assert.deepEqual(allDocuments(), []);
assert.equal(importDocuments(bundle([input])), 1);
}));
test("migration preserves unrelated authentication and research tables", () => isolated((directory) => {
const db = new DatabaseSync(join(directory, "login-state.sqlite"));
db.exec("CREATE TABLE login_state(value TEXT); INSERT INTO login_state VALUES ('preserved'); CREATE TABLE research_datasets(value TEXT); INSERT INTO research_datasets VALUES ('research');");
db.close();
importDocuments(bundle([input]));
const reopened = new DatabaseSync(join(directory, "login-state.sqlite"));
assert.equal(reopened.prepare("SELECT value FROM login_state").get()?.value, "preserved");
assert.equal(reopened.prepare("SELECT value FROM research_datasets").get()?.value, "research");
assert.deepEqual(reopened.prepare("PRAGMA foreign_key_check").all(), []);
reopened.close();
}));
test("invalid content cannot enter the database", () => isolated(() => {
for (const invalid of [ { ...input, slug: "../private" }, { ...input, slug: "endor/01-example" },
{ ...input, content: "![original](/figures/raw.png)" }, { ...input, category: "" } ])
assert.throws(() => saveDocument(invalid, 0));
assert.deepEqual(allDocuments(), []);
assert.equal(readDocument("../private"), null);
}));
test("missing database configuration fails closed", () => {
const previous = process.env.AUTH_CONFIG_FILE;
delete process.env.AUTH_CONFIG_FILE;
try { assert.throws(() => allDocuments(), /not configured/); }
finally { if (previous !== undefined) process.env.AUTH_CONFIG_FILE = previous; }
});
+136
View File
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NextApiRequest, NextApiResponse } from "next";
import documents from "../src/pages/api/documents/index";
import batch from "../src/pages/api/documents/batch";
import item from "../src/pages/api/documents/item/[...slug]";
import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
import { allDocuments, documentHistory, readDocument, saveDocument, type Doc } from "../src/lib/docs";
import { newAuthConfig, signSession } from "../src/lib/auth";
const input: Doc = { slug: "endor/example", sourceSlug: "00-example", title: "Example", category: "Endor",
collection: "Endor 文档", group: "讨论", description: "Description", draft: true, featured: false,
content: "# Example\n\nOriginal body", revision: 0 };
type Options = { method?: string; body?: unknown; headers?: Record<string, string>;
query?: Record<string, string | string[]> };
function call(handler: (req: NextApiRequest, res: NextApiResponse) => unknown, options: Options = {}) {
let status = 200;
let body: unknown;
const headers: Record<string, string> = {};
const response = {
status(value: number) { status = value; return this; },
setHeader(name: string, value: string) { headers[name.toLowerCase()] = value; return this; },
json(value: unknown) { body = value; return this; },
end() { return this; },
};
handler({ method: options.method ?? "GET", body: options.body, headers: options.headers ?? {},
query: options.query ?? {} } as unknown as NextApiRequest, response as unknown as NextApiResponse);
return { status, body, headers };
}
function isolated(run: (context: { directory: string; headers: Record<string, string> }) => void) {
const directory = mkdtempSync(join(tmpdir(), "tabularium-doc-api-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
writeFileSync(process.env.AUTH_CONFIG_FILE, JSON.stringify(newAuthConfig()), { mode: 0o600 });
try {
const key = createDocumentKey("test uploader");
run({ directory, headers: { authorization: "Bearer " + key.token } });
} finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(directory, { recursive: true, force: true });
}
}
test("new API requires document keys, rejects browser cookies and broad web sessions", () => isolated(() => {
assert.equal(call(documents).status, 401);
assert.equal(call(documents, { headers: { authorization: "Bearer invalid" } }).status, 401);
const config = JSON.parse(readFileSync(process.env.AUTH_CONFIG_FILE!, "utf8"));
const token = signSession(config);
assert.equal(call(documents, { headers: { authorization: "Bearer " + token } }).status, 401);
assert.equal(call(documents, { headers: { cookie: "tabularium_session=" + token } }).status, 401);
assert.deepEqual(allDocuments(), []);
}));
test("key scopes, revocation, expiry and secret-free listing are persistent", () => isolated(({ directory }) => {
const key = createDocumentKey("reader", "read", 1);
const headers = { authorization: "Bearer " + key.token };
assert.equal(call(documents, { headers }).status, 200);
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 403);
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input] } }).status, 403);
assert.ok(!JSON.stringify(listDocumentKeys()).includes(key.token));
assert.ok(!readFileSync(join(directory, "login-state.sqlite")).includes(Buffer.from(key.token)));
assert.equal(authenticateDocumentKey(key.token, key.expiresAt), null);
revokeDocumentKey(key.id);
assert.equal(call(documents, { headers }).status, 401);
const revoked = listDocumentKeys().find((row) => row.id === key.id);
assert.ok(revoked); assert.equal(typeof revoked.revokedAt, "number");
}));
test("single save, aliases, metadata and historical content are visible without rebuilding", () => isolated(({ headers }) => {
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 200);
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).revision, 1);
const changed = { ...input, revision: 1, slug: "endor/renamed", content: "# Changed", collection: "新合集" };
assert.equal(call(documents, { method: "POST", headers, body: changed }).status, 200);
assert.equal((call(item, { headers, query: { slug: input.slug.split("/") } }).body as Doc).slug, changed.slug);
assert.equal((call(item, { headers, query: { slug: [input.sourceSlug], revision: "1" } }).body as Doc).content, input.content);
const history = call(item, { headers, query: { slug: [input.sourceSlug], history: "1", limit: "1" } });
const page = history.body as { revisions: Doc[]; total: number; nextOffset: number };
assert.equal(page.total, 2);
assert.equal(page.nextOffset, 1);
assert.equal(page.revisions[0].revision, 2);
assert.equal(page.revisions[0].content, undefined);
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "99" } }).status, 404);
assert.equal(call(item, { headers, query: { slug: [input.sourceSlug], revision: "0" } }).status, 400);
}));
test("pagination returns metadata only with strict bounds", () => isolated(({ headers }) => {
const second = { ...input, slug: "endor/second", sourceSlug: "01-second" };
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, second] } }).status, 200);
const result = call(documents, { headers, query: { limit: "1" } });
const page = result.body as { documents: Doc[]; total: number; nextOffset: number };
assert.equal(page.total, 2); assert.equal(page.nextOffset, 1);
assert.equal(page.documents[0].content, undefined);
assert.equal((call(documents, { headers, query: { limit: "1", offset: "1" } }).body as typeof page).documents[0].slug, second.slug);
const invalidQueries: Record<string, string | string[]>[] = [
{ limit: "101" }, { offset: "-1" }, { limit: ["1", "2"] }, { limit: "1e2" },
];
for (const query of invalidQueries) assert.equal(call(documents, { headers, query }).status, 400);
assert.equal(result.headers["cache-control"], "private, no-store");
}));
test("late batch conflict rolls back earlier rows and their revision history", () => isolated(({ headers }) => {
saveDocument({ ...input, slug: "endor/existing", sourceSlug: "existing" }, 0);
const conflict = { ...input, slug: "endor/existing", sourceSlug: "existing", revision: 0 };
assert.equal(call(batch, { method: "POST", headers, body: { documents: [input, conflict] } }).status, 409);
assert.equal(readDocument(input.slug), null);
assert.equal(documentHistory(input.sourceSlug).length, 0);
assert.equal(documentHistory("existing").length, 1);
assert.equal(allDocuments().length, 1);
}));
test("idempotent retries return the original receipt without creating another revision", () => isolated(({ headers }) => {
const keyed = { ...headers, "idempotency-key": "upload-1" };
const first = call(documents, { method: "POST", headers: keyed, body: input });
const second = call(documents, { method: "POST", headers: keyed, body: input });
assert.equal(first.status, 200); assert.equal(second.status, 200);
assert.equal((second.body as { replayed: boolean }).replayed, true);
assert.equal(documentHistory(input.sourceSlug).length, 1);
assert.equal(call(documents, { method: "POST", headers: keyed, body: { ...input, content: "Different" } }).status, 409);
assert.equal(call(documents, { method: "POST", headers, body: input }).status, 409);
}));
test("invalid batch members, duplicate identifiers, raw images and malformed fields never write", () => isolated(({ headers }) => {
for (const body of [{ documents: [] }, { documents: [input, input] },
{ documents: [input], unexpected: true }, { documents: Array.from({ length: 101 }, () => input) }])
assert.equal(call(batch, { method: "POST", headers, body }).status, 400);
for (const body of [{ ...input, revision: undefined }, { ...input, typo: true },
{ ...input, slug: "../private" }, { ...input, content: "![raw](/figures/original.png)" }, null])
assert.equal(call(documents, { method: "POST", headers, body }).status, 400);
assert.deepEqual(allDocuments(), []);
assert.equal(call(documents, { method: "DELETE", headers }).status, 405);
assert.equal(call(item, { headers, query: { slug: ["missing"] } }).status, 404);
}));
+351
View File
@@ -0,0 +1,351 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import seed from "../../../content/research/ai-companies.seed.json";
import coverageAudit from "../../../content/research/ai-companies.coverage.json";
import incomeReference from "../../../content/research/ai-income-analysis.json";
import { readResearchSnapshot } from "../src/lib/ai-research-store";
import { openSiteDatabase } from "../src/lib/site-database";
import { categorySummary, evidenceUrl, reviewCompanyIds, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research";
import { consumeCode, initializeStore } from "../src/lib/auth-store";
import { newAuthConfig } from "../src/lib/auth";
import { describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
test("research revisions share the authentication database and preserve replay limits", () => {
const dir = mkdtempSync(join(tmpdir(), "site-research-"));
const previous = process.env.AUTH_CONFIG_FILE;
process.env.AUTH_CONFIG_FILE = join(dir, "config.json");
try {
initializeStore();
const config = newAuthConfig();
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
assert.equal(consumeCode(config, "invalid"), "limited");
const initial = readResearchSnapshot();
assert.equal(initial.companies.length, 1023);
assert.equal(initial.evidence.length, 1259);
assert.equal(initial.observations.length, 189);
assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata");
assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit");
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
const db = openSiteDatabase();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
assert.ok(tables.some((r) => r.name === "login_state"));
assert.ok(tables.some((r) => r.name === "research_companies"));
assert.equal(db.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
db.close();
const next = structuredClone(seed) as ResearchSnapshot;
next.version += "-revision-test";
next.companies[0].name = "Revised company";
assert.equal(readResearchSnapshot(next).companies[0].name, "Revised company");
assert.deepEqual(readResearchSnapshot(), initial, "old release still reads its original revision");
assert.equal(consumeCode(config, "invalid"), "limited", "research import must not reset auth limits");
const collision = structuredClone(seed) as ResearchSnapshot;
collision.companies[0].name = "Changed without revision";
assert.throws(() => readResearchSnapshot(collision), /new revision/);
assert.deepEqual(readResearchSnapshot(), initial, "failed import leaves valid revision intact");
} finally {
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
else process.env.AUTH_CONFIG_FILE = previous;
rmSync(dir, { recursive: true, force: true });
}
});
test("research validation rejects broken evidence and unsafe source links", () => {
validateResearch(seed as ResearchSnapshot);
const invalid = structuredClone(seed) as ResearchSnapshot;
invalid.observations[0].evidence = "missing";
assert.throws(() => validateResearch(invalid), /observation/);
assert.equal(evidenceUrl("javascript:alert(1)"), undefined);
assert.equal(evidenceUrl("/local-link"), undefined);
assert.equal(evidenceUrl("https://example.com/evidence"), "https://example.com/evidence");
});
test("reviews link explicit companies without turning global or excluded cases into companies", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 60);
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 9);
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 29);
const review = snapshot.reviews.find((r) => r.id === "R4")!;
assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]);
assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []);
const oldReview = structuredClone(snapshot.reviews.find((r) => r.id === "TECH-GRAD-R-SCOPE")!);
delete oldReview.scope;
delete oldReview.companyIds;
assert.deepEqual(reviewCompanyIds(snapshot, oldReview), ["AI051"], "old revisions retain evidence-owner fallback");
review.companyIds = ["unknown-company"];
assert.throws(() => validateResearch(snapshot), /research review/);
review.companyIds = ["AI007", "AI007"];
assert.throws(() => validateResearch(snapshot), /research review/);
review.companyIds = [];
assert.throws(() => validateResearch(snapshot), /research review/);
});
test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => {
const snapshot = seed as ResearchSnapshot;
const summary = categorySummary(snapshot);
assert.equal(summary.reduce((n, c) => n + c.count, 0), 1023);
assert.equal(summary.reduce((n, c) => n + c.self, 0), 26);
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1);
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16);
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 980);
assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown");
assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown");
assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体"));
});
test("technical reviews keep accessible materials separate from tested capabilities", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 16);
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 6);
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1);
assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown");
assert.match(snapshot.companies.find((c) => c.id === "AI051")!.technicalReview!.testScope!, /Sep 2026/);
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("TECH-GRAD-O-")).every((o) => o.selection === "technical" && o.comparisonGroup === null));
const company = snapshot.companies.find((c) => c.id === "AI061")!;
const review = company.technicalReview!;
review.evidenceIds = ["missing-technical-evidence"];
assert.throws(() => validateResearch(snapshot), /technical review/);
review.evidenceIds = ["TECH-NAIVE-HF"];
review.reviewedAt = "2026-02-30";
assert.throws(() => validateResearch(snapshot), /technical review/);
review.reviewedAt = "2099-01-01";
assert.throws(() => validateResearch(snapshot), /technical review/);
review.reviewedAt = snapshot.asOf;
review.level = "independent-test";
assert.throws(() => validateResearch(snapshot), /technical review/,
"an accessible artifact cannot become a test record without a stated test scope");
});
test("ambiguous ARR claims retain currency, precision and attribution limits", () => {
const snapshot = seed as ResearchSnapshot;
const variant = snapshot.observations.filter((o) => o.company === "AI066");
assert.deepEqual(variant.filter((o) => o.selection === "crosscheck").map((o) => o.value).sort(), [500000, 600000]);
assert.equal(variant.filter((o) => o.selection === "signal").length, 0);
assert.ok(variant.filter((o) => [2500000, 40000000].includes(o.value)).every((o) => o.selection === "excluded"));
const codos = snapshot.observations.find((o) => o.id === "SR-O-AI065-arr")!;
assert.equal(codos.bound, "≥");
assert.equal(codos.unit, "币种未注明");
assert.equal(codos.comparisonGroup, null);
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("SR-O-")).every((o) => o.comparisonGroup === null && o.unit !== "USD"));
assert.equal(snapshot.companies.find((c) => c.id === "AI071")?.year, 2021);
const rocket = snapshot.observations.find((o) => o.id === "ROCKET-O-arr")!;
assert.equal(rocket.selection, "historical");
assert.equal(rocket.comparisonGroup, null);
assert.notEqual(rocket.scope, "公司整体");
assert.equal(snapshot.companies.find((c) => c.id === "AI070")?.revenueStatus, "unknown");
assert.equal(snapshot.observations.filter((o) => o.company === "AI070").length, 0,
"unreadable announcement summaries do not become numeric ARR");
});
// Retractions and forecasts must stay visible for review without becoming revenue signals.
test("retracted ARR and projected margins remain excluded", () => {
const snapshot = seed as ResearchSnapshot;
const cluely = snapshot.companies.find((c) => c.name === "Cluely")!;
assert.equal(cluely.revenueStatus, "unknown");
assert.ok(snapshot.reviews.some((r) => r.company === "Cluely" && r.evidenceIds.includes("E100")));
const retracted = snapshot.observations.filter((o) => o.company === cluely.id);
assert.ok(retracted.length > 0);
assert.ok(retracted.every((o) => o.selection === "excluded"));
const forecast = snapshot.observations.find((o) => o.kind === "非GAAP毛利率预期")!;
assert.equal(forecast.selection, "excluded");
const peec = snapshot.companies.find((c) => c.name === "Peec AI")!;
assert.equal(peec.team, 100);
assert.equal(peec.teamBound, "≥");
const currentTeam = snapshot.observations.find((o) => o.id === "PEEC-JOB-TEAM")!;
assert.equal(currentTeam.value, peec.team);
assert.equal(currentTeam.bound, peec.teamBound);
const historicalTeam = snapshot.observations.find((o) => o.id === "O067")!;
assert.equal(historicalTeam.value, 70);
assert.equal(historicalTeam.bound, ">");
assert.equal(snapshot.observations.filter((o) => o.company === peec.id && o.selection === "signal").length, 1);
assert.ok(snapshot.observations.some((o) => o.company === peec.id && o.selection === "historical-estimate"));
});
test("directory coverage reconciles every candidate and keeps profile-only income unknown", () => {
const snapshot = seed as ResearchSnapshot;
const coverage = snapshot.coverage!;
assert.equal(coverageAudit.records.length, coverage.candidateRecords);
assert.equal(new Set(coverageAudit.records.map((r) => r.ycId)).size, coverage.candidateRecords);
const count = (status: string) => coverageAudit.records.filter((r) => r.status === status).length;
assert.equal(count("imported"), coverage.newProfiles);
assert.equal(count("existing"), coverage.existingProfiles);
assert.equal(count("date-conflict"), coverage.dateConflicts);
assert.equal(count("year-outside-or-missing"), coverage.otherOrMissingYear);
assert.equal(count("ai-unclear"), coverage.unclearAI);
for (const row of coverageAudit.records.filter((r) => r.status === "imported")) {
const company = snapshot.companies.find((c) => c.id === `YC${row.ycId}`)!;
assert.ok(company.profileOnly || company.directoryOrigin === "yc-2025-2026");
assert.equal(company.url, row.source);
if (company.profileOnly) assert.equal(company.revenueStatus, "unknown");
assert.equal(company.year, row.foundedYear);
assert.ok([2025, 2026].includes(company.year!));
}
const invalid = structuredClone(snapshot);
invalid.coverage!.newProfiles += 1;
assert.throws(() => validateResearch(invalid), /coverage accounting/);
const promoted = snapshot.companies.find((c) => c.id === "YC30215")!;
assert.equal(promoted.profileOnly, false);
assert.equal(promoted.directoryOrigin, "yc-2025-2026");
assert.equal(promoted.revenueStatus, "self");
const lostOrigin = structuredClone(snapshot);
delete lostOrigin.companies.find((c) => c.id === promoted.id)!.directoryOrigin;
assert.throws(() => validateResearch(lostOrigin), /coverage accounting/);
});
test("income descriptions report sample dispersion and maximum-value sensitivity", () => {
const stats = describeIncome([100, 200, 300])!;
assert.equal(stats.mean, 200);
assert.equal(stats.median, 200);
assert.equal(stats.sampleDeviation, 100);
assert.equal(stats.cv, .5);
assert.equal(stats.largestShare, .5);
assert.equal(stats.meanWithoutLargest, 150);
assert.equal(stats.largestRemovalDrop, .25);
assert.equal(describeIncome([5])!.cv, null);
assert.equal(describeIncome([0, 0])!.mean, 0, "observed zero is distinct from missing");
assert.equal(describeIncome([0, 0])!.cv, null);
assert.equal(describeIncome([]), null);
assert.equal(describeIncome([100, NaN]), null);
assert.equal(describeIncome([-1]), null);
assert.ok(describeIncome([1, 2, 100])!.largestRemovalDrop! > .95);
});
test("platform samples exclude stale data and keep ARR, estimates and collections separate", () => {
const snapshot = seed as ResearchSnapshot;
const recent = platformIncomeSample(snapshot, { windowDays: 7 });
assert.equal(recent.points.length, 14);
assert.equal(recent.excluded.length, 3);
assert.ok(recent.points.every((p) => p.observation.metric === "mrr" && p.observation.selection === "platform"));
assert.deepEqual(new Set(recent.excluded.map((p) => p.observation.company)), new Set(["AI016", "MR013", "MR014"]));
assert.ok(recent.points.some((p) => p.company.name === "TrackAI" && p.observation.connectionExpired), "dated recent value is not a claim of active connection");
const older = platformIncomeSample(snapshot, { windowDays: 90 });
assert.equal(older.points.length, 15);
assert.ok(older.points.some((p) => p.company.id === "AI016"));
const scaled = platformIncomeSample(snapshot, { windowDays: 7, annualize: true });
const monthlyGroups = groupIncome(recent.points);
const annualGroups = groupIncome(scaled.points);
for (const group of monthlyGroups) {
const annual = annualGroups.find((g) => g.key === group.key)!;
assert.ok(Math.abs(annual.stats.mean - group.stats.mean * 12) < 1e-6);
if (group.stats.cv !== null) assert.ok(Math.abs(annual.stats.cv! - group.stats.cv) < 1e-10);
}
const invalid = structuredClone(snapshot);
invalid.observations = invalid.observations.filter((o) => o.company === "MR001" && o.metric === "mrr");
const first = invalid.observations[0];
invalid.observations.push({ ...first, id: "future", observedAt: "2026-11-01", value: 999999 });
invalid.observations.push({ ...first, id: "bad-date", observedAt: "2026-02-30", value: 999999 });
invalid.observations.push({ ...first, id: "currency", unit: "EUR", value: 999999 });
invalid.observations.push({ ...first, id: "older", observedAt: "2026-10-28", value: 1 });
const selected = platformIncomeSample(invalid, { windowDays: 7 });
assert.equal(selected.points.length, 1);
assert.equal(selected.points[0].observation.id, first.id, "bad newer data and older duplicate must not replace latest valid point");
assert.equal(selected.excluded.length, 4);
assert.throws(() => platformIncomeSample(snapshot, { windowDays: -1 }), /window/);
});
test("distribution statistics match independently calculated snapshot references", () => {
const snapshot = seed as ResearchSnapshot;
assert.equal(incomeReference.sourceVersion, snapshot.version);
for (const reference of incomeReference.windows) {
const sample = platformIncomeSample(snapshot, { windowDays: reference.days });
assert.deepEqual(new Set(sample.points.map((p) => p.company.id)), new Set(reference.products));
const groups = groupIncome(sample.points);
assert.equal(groups.length, reference.groups.length);
for (const expected of reference.groups) {
const actual = groups.find((g) => g.category === expected.category && g.provider === expected.provider && g.model === expected.model)!;
assert.equal(actual.stats.n, expected.n);
for (const metric of ["mean", "median", "sampleDeviation", "cv", "meanWithoutLargest", "largestRemovalDrop"] as const) {
const value = expected[metric];
if (value === null) assert.equal(actual.stats[metric], null);
else assert.ok(Math.abs(actual.stats[metric]! - value) < 1e-6, metric);
}
}
}
});
test("product launch candidates preserve unresolved years and many launches per company", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
const batch = snapshot.candidateBatches![0];
assert.equal(batch.complete, false);
assert.equal(batch.records.length, 19);
const kilo = batch.records.filter((r) => r.companyId === "AI072");
assert.equal(kilo.length, 2, "IDE and hosted products resolve to one company");
assert.equal(snapshot.companies.filter((c) => c.id === "AI072").length, 1);
assert.ok(batch.records.some((r) => r.status === "pending-year" && !r.companyId));
assert.ok(batch.records.some((r) => r.status === "existing-outside-cohort" && r.companyId === "AI054"));
kilo[0].companyId = "missing-company";
assert.throws(() => validateResearch(snapshot), /research candidate/);
});
test("annual launch archives exclude older companies only with evidence and reuse existing records", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
const batch = snapshot.candidateBatches!.find((b) => b.id === "PH-2025-visible-1")!;
assert.equal(batch.complete, false);
assert.equal(batch.records.length, 19);
const older = batch.records.filter((r) => r.status === "outside-cohort");
assert.equal(older.length, 16);
assert.ok(older.every((r) => !r.companyId && r.evidenceIds?.length));
assert.equal(batch.records.find((r) => r.name === "Trace")?.companyId, "YC30687");
const trace = snapshot.companies.find((c) => c.id === "YC30687")!;
assert.equal(trace.profileOnly, false);
assert.equal(trace.directoryOrigin, "yc-2025-2026");
assert.equal(trace.revenueStatus, "unknown");
assert.ok(!snapshot.observations.some((o) => o.evidence === "TRACE-HOME"), "demo metrics must not become reported results");
older[0].evidenceIds = [];
assert.throws(() => validateResearch(snapshot), /research candidate/);
older[0].evidenceIds = ["missing-proof"];
assert.throws(() => validateResearch(snapshot), /research candidate/);
});
test("unreconciled directory financials remain source reviews instead of income observations", () => {
const snapshot = seed as ResearchSnapshot;
const review = snapshot.reviews.find((r) => r.id === "PH-R-SOURCE-QUALITY")!;
assert.equal(review.scope, "dataset");
assert.ok(review.evidenceIds.includes("PH-TANA-DR"));
assert.equal(reviewCompanyIds(snapshot, review).length, 0);
assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR"));
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 8);
});
test("product pivots and founder histories do not become new company years or current revenue", () => {
const snapshot = seed as ResearchSnapshot;
const pivot = snapshot.companies.find((c) => c.id === "AI073")!;
assert.equal(pivot.year, null, "CRM launch is not a verified company founding year");
assert.equal(pivot.revenueStatus, "unknown");
const candidate = snapshot.candidateBatches!.flatMap((b) => b.records).find((r) => r.id === "PH26-11")!;
assert.equal(candidate.status, "existing-outside-cohort");
assert.equal(candidate.companyId, pivot.id);
assert.ok(!platformIncomeSample(snapshot, { windowDays: null }).points.some((point) => point.company.id === pivot.id));
assert.ok(!snapshot.observations.some((o) => ["PH-FUNDRAISLY-ABOUT", "LF-PIVOT", "LF-HISTORY"].includes(o.evidence)), "other ventures' ARR, users and funding must not transfer");
});
test("follow-up evidence can admit an uncertain directory candidate without rewriting its original status", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
const followup = snapshot.directoryRechecks!.find((row) => row.ycId === 36088)!;
const original = coverageAudit.records.find((row) => row.ycId === followup.ycId)!;
assert.equal(original.status, "ai-unclear");
assert.equal(followup.originalStatus, original.status);
assert.equal(snapshot.companies.find((c) => c.id === followup.companyId)?.year, 2026);
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).find((r) => r.name === "OpenSEO")?.companyId, followup.companyId);
assert.ok(!snapshot.observations.some((o) => ["SEO-PRICE", "UP-YC", "PAZI-PRICE"].includes(o.evidence)), "pricing, other ventures' ARR and prepaid balances stay outside income observations");
followup.companyId = "missing-company";
assert.throws(() => validateResearch(snapshot), /directory recheck/);
});
test("Chinese discovery entries preserve unread articles and use separate primary evidence", () => {
const snapshot = structuredClone(seed) as ResearchSnapshot;
const batch = snapshot.candidateBatches!.find((b) => b.id === "JQ-2026-targeted-1")!;
assert.equal(batch.complete, false);
assert.equal(batch.records.length, 4);
assert.ok(batch.records.every((row) => row.kind === "company" && row.profileStatus === "unavailable"));
assert.equal(batch.records.filter((row) => row.status === "linked").length, 2);
assert.equal(batch.records.find((row) => row.name === "Utopai")?.status, "outside-cohort");
assert.ok(!snapshot.observations.some((o) => ["PS-CODE", "SG-START", "SG-APR", "SSI-HISTORY"].includes(o.evidence)), "task costs, investment, capacity and financing are not revenue");
const prism = snapshot.companies.find((c) => c.id === "AI075")!;
assert.equal(prism.team, null, "a team range or linked employees must not become exact headcount");
batch.records[0].url = null;
assert.throws(() => validateResearch(snapshot), /research candidate/);
});
-110
View File
@@ -1,110 +0,0 @@
{
"collections": [
{
"path": "pretext",
"title": "Pretext 源码教程",
"description": "从浏览器排版基础到缓存、换行算法与虚拟列表。"
},
{
"path": "language-design",
"title": "语言设计问题集",
"description": "13 个分类的语言设计问题、候选方案与确认记录。"
},
{
"path": "",
"title": "Endor 文档",
"description": "系统设计、项目讨论与工作指南。"
}
],
"documents": {
"00-endor-concepts-draft": {
"group": "架构设计",
"description": "Endor 的全球入口、区域运行时与对象数据。",
"draft": true,
"featured": true
},
"01-getting-started": {
"group": "开始使用",
"description": "本地开发、文档编写与发布。"
},
"02-mounting-draft": {
"group": "架构设计",
"description": "节点、对象与统一接入协议。",
"draft": true
},
"03-figures": {
"group": "图形档案",
"description": "Python 源码与可编辑 SVG 的制图工作流。"
},
"04-endor-purpose-draft": {
"group": "项目讨论",
"description": "从程序入口走向用户与 AI 的对象操作。",
"draft": true
},
"05-object-graphs-gc": {
"collection": "对象关系与垃圾回收",
"group": "运行时基础",
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。"
},
"pretext/00-overview": {
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
"featured": true
},
"language-design/00-overview": {
"description": "从名称与作用域到反射与表示,按 271 项议题逐步讨论语言设计。",
"featured": true
},
"language-design/02-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/03-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/04-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/05-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/06-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/07-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/08-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/09-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/10-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/11-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/12-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/13-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
},
"language-design/14-questions": {
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
"draft": true
}
}
}
-133
View File
@@ -1,133 +0,0 @@
# Endor 系统架构
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。
> **架构说明**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
## 架构总览
![Endor 架构:全球入口连接北美、亚洲及其他区域;各区两个 Traefik 与五个 Nexus Runtime 全连接,每个 Nexus 侧下方配一个 Aster VM;Aerarium 分片群分别与 PostgreSQL、S3 分片群全连接,所有分片汇入统一备份系统](/figures/endor-architecture.svg)
图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示向统一备份系统采集数据。连线表示可达或依赖关系,**不表示请求向所有节点广播**。
## 本地、自部署与平台挂载
![Endor 部署与对象挂载:两组自部署通过 Nginx 或 Traefik 接入,各自包含一到两个 Nexus Runtime、配套 Aster VM 和 Aerarium、PostgreSQL、S3;自部署对象池可挂载到平台,Windows、Linux、macOS、iOS、Android 上的 Tessera 通过本地 Nexus 挂载本地和远端对象](/figures/endor-deployment.svg)
这张图将部署规模收起来,强调对象可以在哪里运行、从哪里挂载。两组自部署示例分别使用一个和两个 Nexus Runtime,每个 Nexus 都带一个 Aster VM;**每个 Nexus 在示例中配一套简单的 Aerarium、PostgreSQL 和 S3**;这用于表达小规模自部署的完整组成。Aerarium 依赖 PostgreSQL 和 S3,右侧平台则将多区域、运行时和存储分片简化为小型集群。
自部署入口可以选择 Nginx 或 Traefik。如果云服务已经提供合适的托管入口、TLS 和路由能力,就可以省去自行部署网关这一环;仍需配置服务暴露和访问权限。
紫色虚线表示**自部署 Aerarium 可以经授权挂载到 Endor 平台端**:平台获得访问源对象池的逻辑入口,源端仍保留数据归属。它不是将整个 PostgreSQL 或 S3 直接公开,也不隐含自动上传、镜像、备份或数据合并。
下方五种系统都运行 Tessera 前端,并带有本地 Nexus Runtime 和对应 Aster VM。本地对象先挂载到自带的 Nexus;本地 Nexus 也可按需挂载自部署或平台端的远程对象池,让 Tessera 通过本地运行时访问它们。蓝色线路表示可选择的远端连接,不要求所有设备同时挂载所有服务。该图延续本文的目标架构约定,不代表五个平台的运行时集成都已完成;远端身份映射、连接协议、写入权限和断线行为仍需继续定义。
## 全球入口与区域入口
客户端包括浏览器、Tessera、SDK 和 CLI。`endorphin API` 是逻辑上的全球流量入口,尚未在本文规定一个真实的域名。全球调度根据健康、延迟、项目归属和数据放置策略选择区域;不能只选择网络最近的区域而忽略对象在哪个分片。
图中展开北美和亚洲两个区域,并用“ … / etc · 其他区域”表示可以继续扩展。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。
### 为什么画成二分全连接
每个区域示例中有两个 Traefik 和五个 Nexus Runtime,画出全部十条边,即二分图 **K₂,₅**。网关使用独立的路由分流图标,与 Nexus 的服务器图标区分。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。
这是逻辑可达关系。实际转发仍需考虑服务发现、健康检查、项目和对象归属,以及有状态连接的路由策略;Runtime 之间并没有因此形成全连接网状网络。
## Nexus Runtime 与 Aster VM
此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器侧下方单独画出一个 VM 芯片,用点线连接到对应 Nexus;节点与 VM 采用错位排布,保持一对一归属。
请求经过区域入口和 Traefik 到达 Nexus,再定位目标分片。图中运行时下方的连接汇入分片路由层,表示节点可以按归属访问区域分片,**不是**「Runtime 1 只能访问分片 1」。对象身份、远程引用、生命周期与调用语义仍需结合[节点与对象挂载草案](/docs/02-mounting-draft)继续定义。
## 三类存储各自独立分片
PostgreSQL、Aerarium Object data pool 和 S3 是三组独立的分片群。图中每组各展开三个分片,数量可以独立增长,分片边界也可以不同;不能将一个 PG 分片、一个 Aerarium 分片和一个 S3 分片打包成固定“三件套”。
| 分片群 | 概念职责 | 分片标识示例 |
| ------------------------- | ------------------------------------------------------------ | ---------------------------- |
| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | NA-PG-01、NA-PG-02、NA-PG-03 |
| Aerarium Object data pool | 对象数据的逻辑组织、对象池状态和访问层;同时依赖 PG 与 S3 | NA-AO-01、NA-AO-02、NA-AO-03 |
| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | NA-S3-01、NA-S3-02、NA-S3-03 |
### 分片群之间的全连接
每个 Aerarium 分片都能按映射访问任意一个 PostgreSQL 分片,也能访问任意一个 S3 分片。图中左右各画一组 **K₃,₃**:Aerarium ↔ PostgreSQL 有九条边,Aerarium ↔ S3 也有九条边;每区合计十八条存储关系边。
这些线表示逻辑可达关系,实际读写仍根据对象归属、分片映射、授权与健康状态选择目标。它们不表示广播,也不表示所有分片保存相同数据。PostgreSQL 和 S3 之间没有凭空增加直接全连接;两种存储通过 Aerarium 的对象模型与版本映射协作。
运行时访问先进入对象池的分片定位层,再由 Aerarium 按映射访问 PG 和 S3。因此不同层可以采用不同的分片数量和容量规划,而无需一一绑定。
### 区域内高可用
每个存储图标下面有一个小型副本图标,用点线连接,表示本区域的高可用副本。生产部署应跨独立故障域放置,具体可以是不同可用区或独立节点。
这是逻辑 HA 要求,各层的实现不同:PostgreSQL 可以使用主备复制,Aerarium 需要自己的状态协调和故障恢复设计,S3 的耐久性与副本由选定的存储系统提供。图不强制三者采用同一种同步复制机制,也不把单纯增加副本等同于已经实现故障自动切换。
## 一个统一的备份系统
备份是覆盖所有区域的总体系统,不按“北美备份亚洲、亚洲备份北美”分成两个固定系统。它统一编排各区域、各存储类型的备份采集、持久化、校验、保留与恢复。物理副本应隔离源区域的故障风险,实际存放位置由备份策略决定;图中不将该系统固定放进任何业务区域。
图中金色线路将每个区域的三类分片群接入同一备份边界,代表采集组内全部分片。统一恢复目录按“区域 + 存储类型 + 分片 ID”追踪每个分片,避免遗漏或将不同层的编号误当作同一个分片。
### 备份中的 PG、Aerarium、S3 分别是什么
| 备份内容 | 保存什么 | 恢复时的用途 |
| --------------- | -------------------------------------------------------------------- | ------------------------------------------ |
| PostgreSQL 备份 | 各 PG 分片的基础备份、WAL 与可恢复时间点等事务恢复材料 | 恢复结构化元数据与事务状态 |
| Aerarium 备份 | 各对象池分片的状态、索引、对象身份和版本映射等恢复材料,具体格式待定 | 重建对象池,并关联 PG 恢复点与 S3 对象版本 |
| S3 备份 | 各 S3 分片的实际对象版本、Blob、文件及快照 | 恢复被对象池引用的实际字节数据 |
这里展示的是**统一系统内三类不同的备份内容与采集能力**,不是每个备份槽里又放一套在线 PG + Aerarium + S3 服务。系统还要维护统一恢复清单、校验和、重试状态,以及各层的版本对应关系。
恢复某个对象或项目时,清单应能定位所需的 PG 分片恢复点、Aerarium 分片状态与 S3 对象版本。因为三类存储独立分片,恢复集合也可能涉及多个不同编号的分片;不能按固定三件套恢复,也不能只备份其中一种存储就宣称整个对象可恢复。
### “最终一定拿到数据”需要怎样的保证
设计目标是:正常访问优先本区域;本地不可用时,仍能找到异地保存的数据。需要区分两类能力:
- **异地在线副本**:已有服务能读取它;追平状态、授权和路由满足要求后,才可承担故障读取。
- **异地备份**:保留了恢复材料,需要恢复 PostgreSQL、重建 Aerarium 并校验对象版本后才能提供服务,通常更慢。
> **保证边界**:异步复制与定期备份不能无条件保证最后一笔已确认写入不会丢失。若源区在复制完成前永久丢失,未到达异地的数据仍可能丢失。当前图承诺的是“已校验恢复点可恢复”的目标,而非已实现的零数据丢失保证。
要实现更强的承诺,需要明确写入确认条件。例如必须等跨区持久化成功才返回写入成功,或者以独立耐久日志作为重放来源,并设计重试、校验、告警与恢复演练。这会增加延迟或实现成本,不能只靠一条“最终一致”虚线获得。
后续设计需要分别确定 **RPO**(可接受的数据丢失窗口)和 **RTO**(恢复服务所需时间),以及备份保留、删除传播、加密密钥和数据跨区合规策略。
## CDN 应该放在哪里
建议把 CDN 作为面向静态和可缓存内容的边缘路径,与动态 API 调度区分:
1. 客户端请求静态前端、文档或允许缓存的对象,优先命中 CDN;命中后直接返回,无需访问 Nexus。
2. 缓存未命中时,CDN 回源到指定的 S3 兼容存储,或专门的资源服务。图中仅示例回源到亚洲区域的 S3,并不把亚洲设为唯一源站。
3. 私有对象可使用短时签名 URL、受控回源或独立授权服务。缓存键、过期、权限隔离和失效策略需要单独设计。
4. 登录、写入、实时连接和个性化动态 API 默认不进入共享缓存。CDN 能代理这些流量,不代表应该缓存这些响应。
CDN 是可替换、可选的加速层,不是数据库副本,也不承担跨区灾难恢复的唯一责任。
## 身份服务是否依赖 Nexus
**建议在逻辑上独立,不把它画成依赖某个业务 Nexus 的服务。** 客户端向身份服务登录或刷新凭证;Nexus 验证访问凭证、项目归属和操作权限后才接受业务请求。
图中紫色线路表示公钥或认证配置的分发关系:使用适当的签名访问令牌方案时,Nexus 可以缓存 JWKS,在本地完成签名及必要声明校验。具体访问令牌格式尚未确定,不能把 OpenID Connect 的 ID Token 直接当作所有 API 的授权凭证。
如果采用不透明令牌、即时撤销或实时策略评估,可能需要在线内省或授权查询;这会引入身份服务的在线依赖,需要单独设计可用性和失败策略。已有令牌可离线验证也不意味着公钥轮换、过期、撤销和权限变化可以不处理。
身份服务本身仍可以使用 PostgreSQL、缓存和密钥管理等基础设施;**逻辑独立不等于没有存储依赖**。将来即使选择在通用 Nexus 基础设施上托管它,也应保留独立的故障和信任边界,避免启动、登录与业务运行时形成循环依赖。
## 继续确定的设计边界
- 全球入口如何发现项目与分片的归属,跨区域请求采用转发还是让客户端重连。
- Nexus 接入职责与 Aster VM 边界、连接亲和性和 Runtime 故障迁移。
- Aerarium 的数据格式、事务边界、对象版本和跨存储一致恢复点。
- 区域内 HA 的实际协议与跨区域 RPO/RTO、故障读取与恢复流程。
- 身份签发者、访问令牌验证与撤销机制,以及私有内容的 CDN 授权。
## 参考依据
以下资料仅用于解释通用组件的能力,不证明 Endor 已经实现本文的概念设计:
- [Traefik:服务与后端负载均衡](https://doc.traefik.io/traefik/master/routing/services/)。
- [OpenID Connect Discovery:配置发现和 JWKS URI](https://openid.net/specs/openid-connect-discovery-1_0.html)。
- [Amazon S3:同区与跨区异步复制](https://docs.aws.amazon.com/AmazonS3/latest/userguide/replication.html)。本文中的 S3 指兼容对象存储,具体部署并未指定 AWS。
-30
View File
@@ -1,30 +0,0 @@
# 开始使用
Tabularium 是 Endor 的文档工作台。文档与图形源码都保存在同一个仓库。
## 启动
在仓库根目录运行:
```sh
pnpm install --frozen-lockfile
uv sync --project tools/figures --locked
pnpm dev
```
浏览器打开 http://127.0.0.1:3000 。
## 添加文档
在 `content/docs` 下添加 Markdown 文件,例如 `04-runtime.md`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。该文件对应 `/docs/04-runtime`。
开发模式下刷新页面读取修改;本地生产预览需要重新构建。
## 检查
```sh
pnpm check
pnpm start
```
本地开发入口只监听 127.0.0.1。main 推送会在 ces-control 上验证并自动部署,服务器入口默认 18135。相同构建输入复用已验证镜像;源码变化时复用依赖与 Next.js 缓存。Pull Request 只验证,不部署。
-25
View File
@@ -1,25 +0,0 @@
# 节点与对象挂载(草案)
> 状态:讨论草案。以下记录当前设计方向,不代表协议已经实现或接口已经定稿。
## 设计方向
每一个 Aster VM 是一个节点。实例之间可以通过统一接入协议挂载对象。
接入对象来源包含 Endor 官方配件,例如 Tessera GUI,以及通过 Endor Python、C、Go、JavaScript 等 SDK 接入的第三方程序。这两类接入采用同一套协议。
## 待确认的术语
| 概念 | 当前提议 | 状态 |
| --- | --- | --- |
| Node | 一个 Aster VM | 用户提出的模型 |
| Endpoint | 统一协议的连接端点 | 术语提议 |
| Export Root | 对外导出的顶级对象入口 | 术语提议 |
| Tessera 顶级对象 | 协议层称 root,挂载名可为 tessera | 尚未确定 |
## 后续设计问题
- 对象的导出、身份、远程引用与生命周期。
- 方法调用、属性访问和事件传播的语义。
- 授权、断连、错误与重新连接。
- 官方配件和不同语言 SDK 的共同协议边界。
-19
View File
@@ -1,19 +0,0 @@
# 图形与脚本
Python 绘图项目位于 `tools/figures`,使用 drawsvg 输出可编辑 SVG。脚本与输出都保留在 Git 中。
```sh
pnpm figures
```
下图是制图工作流的最小示例,用于验证从 Python 源码到文档图片的完整链路。
![制图工作流:Python 源码生成 SVG,再由本地文档引用](/figures/workflow.svg)
## 扩展方式
在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
-287
View File
@@ -1,287 +0,0 @@
# Endor 的意义:从程序入口到对象操作
这篇文档用来逐步整理我们为什么要做 Endor。先记录一个切入点:**把对象的操作权交还给用户与 AI**。后续讨论可以在这里补充、修正,暂不把它写成一份已经定稿的项目宣言。
> **讨论草案**:以下是希望探索的系统组织方式。图中进程和对象的数量、对象关系与操作路径都是示意,不表示当前实现或性能测量。
## Endor:面向 AI 的操作系统
**我们可以把 Endor 理解为面向 AI 的操作系统:以对象池组织工作对象,以 Agent 承载和推进任务。** 用户与 AI 不必始终围绕一个个程序和进程组织工作,而是能够围绕对象、关系和可执行的能力继续处理同一件事。
在这个定位下,我们希望把用户需要面对的进程管理,简化为对 **Object data pool(对象数据池)** 中对象及其关系、状态的管理。工作材料和结果不必只存在于某个程序的私有上下文里;它们可以作为有身份、可引用的对象,由不同入口和任务继续使用。对象池是统一的逻辑组织方式,不要求所有对象都位于同一台机器或同一个物理存储实例。
**Agent 充当以往操作系统中进程在任务层面承担的角色**:承载一项正在进行的工作,组织所需对象与能力,保留相关上下文,并推进执行。用户关注的可以是“这项工作处理哪些对象、进行到哪里、下一步做什么”,而不只是“启动了哪个程序、背后有几个进程”。这些是希望建立的任务与生命周期模型,具体的暂停、恢复、调度和状态保留机制仍需定义。
这是一种上层运行环境的组织方式。对象池负责数据与对象关系,Agent 负责组织任务行为,实际执行仍需要运行时、调度和资源隔离。它们不意味着用存储替代全部执行机制,也不要求 Agent 与底层 OS 进程一一对应;一个 Agent 的工作可以包含普通函数调用,普通计算也不必全部改写成 Agent。
这一定位希望形成的体验是:**对象池构成持续可用的工作世界,Agent 成为其中执行工作的主体,Endor 语言让对象和行为能够被表达、检查与组合。** 用户与 AI 因而可以在同一套对象基础上发起、接续和检查工作。这是本文的设计方向,不表示所有系统能力已经实现。
## 一对图:我们希望改变什么
![从程序入口到对象操作的对比:上图中 OS 分出九个进程,按二、四、三个分为三组,对应三个程序;对象以进程内的随机小圆圈表示,用户通过程序入口访问。下图中 OS 只分出少量进程,承载 Endorphin 的对象关系网络,用户与 AI 用多条贝塞尔曲线直接指向选定对象;OS、进程区与 Endorphin 都位于 Endor OS 边界内](/figures/endor-purpose.svg)
### 第一张图:用户面对程序
从 OS 出发,许多进程组成不同的程序。每个程序可以对应不同数量的进程;图中分别画了两个、四个和三个。进程区里的许多小圆圈表示对象,它们散布在各进程中。
用户通常通过程序提供的界面访问能力,也可能通过 Web 或 API。这里想强调的是操作入口:用户先选择程序,再在程序提供的功能中寻找自己要做的事。图中的对象仍然存在,但通常隐藏在程序的组织和入口之后。
这是一种用于对照的简化视角,并不是说现有程序都无法开放对象或让外部系统组合能力。
### 第二张图:用户与 AI 面对对象
仍然从 OS 出发,但这次只画少量进程,用来承载 Endorphin。接下来展开的是许多相互连接的小圆圈:对象不再只作为程序内部的实现细节出现,而成为可以被引用、选择和操作的基本单位。
用户与 AI 的多条曲线分别指向部分对象,表达我们希望提供的能力:**在授权范围内,直接找到所需对象,对它们执行操作,并把不同对象的能力组合起来**。图中没有把所有对象都连向用户,因为操作权应当是有范围、可控制的。
OS、进程区与 Endorphin 被共同框在 **Endor OS** 的边界内。这是此处对面向 AI 的整体运行环境的命名:对象池组织工作对象,Agent 承载任务。图中仍保留底层 OS,具体的内核与运行时职责划分尚需继续定义。
## 为什么程序不该是唯一入口
设想我们要把一段会议录像整理成可执行的项目计划。录像在媒体库里,讨论结论写进文档,行动项录入任务工具,补充资料又放在另一个位置。用户需要把时间点、原话、负责人和截止时间反复搬运,才能让这些程序中的内容对上。
困难不只在于点击次数。任务里粘贴的会议摘要,可能无法跟随原始结论的修改;文档里的一段文字,可能没有保留它来自哪个视频片段的关系。用户知道这些内容属于同一件事,系统却往往只保存了分别属于几个程序的数据副本。
AI 参与时也会遇到这种分割。它可能通过 Web 界面、API 或工具调用取得内容,再把结果写进另一套入口。每个入口都能提供有用能力,但对象身份、关联关系和状态变化不一定在这些入口之间保持连续。一次自动化任务成功,也不等于下一次还能准确找到并更新同一对象。
Endor 想探索的是:录像、片段、结论、任务和负责人之间的关系,能否成为可以继续访问和操作的系统结构,而不是只由用户记住、再通过复制粘贴维持。
已有程序也可以用链接、共享数据库、开放 API 或其他方式解决这些问题。Endor 的意义需要通过更连贯的使用体验证明,不能只靠“现有程序彼此隔离”这个前提成立。
## 什么是用户真正想操作的对象
用户想修改的通常不是某个进程里的内存地址,而是有实际含义的东西:一篇文档、一段视频、一个任务、一组设备,或这些内容之间的联系。
在会议整理的例子里,可以先讨论以下对象与关系。这里列的是业务建模示例,不是已经确定的内置类型。
| 对象 | 用户可能希望执行的操作 | 需要保留的关系 |
| --- | --- | --- |
| 会议录像 | 查看、选择片段、引用内容 | 片段来自哪份录像 |
| 讨论结论 | 修订、核对来源、交给其他流程使用 | 依据哪些片段形成 |
| 行动任务 | 修改状态、指派负责人、调整时间 | 对应哪条结论 |
| 项目计划 | 汇集任务、查看进展、调整组织方式 | 包含哪些任务和依赖 |
一个对象能被多个入口使用,不意味着把它复制成多份互不关联的数据。我们希望同一任务可以出现在项目看板、会议记录和个人待办里,入口变化时仍能辨认它是同一个任务。需要快照或独立副本时,也应明确表达这种区别。
对象的身份、当前名称、显示位置和所属进程不能混为一谈。改名、换一个视图或迁移执行位置,是否改变对象身份,应有明确规则。对象删除、替换或不可访问时,保留的引用又如何表现,也是对象模型必须回答的问题。
这不要求把每个临时计算值都做成一个可联网访问的资源。哪些对象需要独立身份、保存和跨端引用,哪些只在一次执行中存在,需要由类型、生命周期和系统成本共同决定。
## “把操作权交还给用户”是什么意思
这里的操作权,首先是让用户能够理解并使用自己的工作对象:找到目标、保留引用、选择合适的入口,并在获准范围内发起操作。它不仅是把程序界面的按钮换成一个通用调用按钮。
- **可定位**:之后仍能找到同一对象,知道名称与引用指向什么。
- **可理解**:能够识别对象的类型、可用操作和当前状态,不必猜测某个界面动作的含义。
- **可操作**:通过明确的输入和操作契约读取、修改或调用能力,并取得可检查的结果。
- **可组合**:把一个对象或操作结果交给其他获准使用它的能力,保留必要的身份和关系。
- **可选择入口**:根据任务选择不同界面或自动化方式,不因更换入口就被迫重新建立全部数据关系。
这些是体验目标,不表示数据所有权、访问权和操作权天然相同。能查看一项任务,不一定能修改;能调用“提交”能力,也不一定能编辑它的实现或直接修改相关的全部资源。对象属于谁、用户此刻能做什么、程序允许产生什么业务效果,需要分别表达。
“交还”也不意味着消除程序提供的业务约束。例如用户调用一个约定好的提交能力,应遵守它的输入、状态和操作范围。把系统的内部存储或任意内存暴露出来,并不能自动带来清晰的对象操作权。
## 程序在 Endor 中还有什么意义
对象成为共同的操作单位后,程序仍然可以提供有价值的组织方式。项目看板擅长安排任务,视频编辑器擅长选择时间片段,专业业务界面则可以把复杂约束组织成容易理解的工作流。
区别在于,我们希望程序不必成为对象唯一的归属入口。同一任务可以由一个界面创建,由另一个界面展示,再交给 AI 协助整理;这些入口使用同一对象身份和约定好的能力,而不是各自建立一份无法关联的任务。
在这个方向上,程序可以承担几种职责:
| 职责 | 对用户的价值 |
| --- | --- |
| 视图与编辑器 | 针对当前任务选择合适的呈现和交互方式 |
| 专业工作流 | 将多个操作、检查和业务步骤组织成可靠流程 |
| 可复用逻辑 | 把一组能力实现为可引用、可调用的程序结构 |
| 与其他系统连接 | 在外部格式和接口与 Endor 对象之间建立明确映射 |
Tessera 可以作为进入对象世界的一种前端;Web 和其他专业程序也可以提供不同入口。这是希望形成的关系,不表示所有现有程序都已经能直接接入,也不表示一个通用界面可以替代全部专业工具。
## 为什么 AI 需要对象,而不只是更多工具
工具让 AI 能够采取动作,对象则让这些动作有一个可以持续引用和理解的目标。以会议整理为例,AI 不只需要“新建任务”这个工具,还需要知道这次处理的是哪次会议、哪条结论、哪些任务已经存在,以及它们之间有什么关系。
如果每次调用只得到一段文本,AI 可能需要反复从描述中重新识别目标。若结果能够保留对象身份、类型和来源关系,后续操作就有机会继续作用于同一目标,而不必每次重建上下文。
我们希望对象模型能够帮助 AI:
- 根据类型和可访问的能力理解目标,而不只依靠界面位置或名称猜测。
- 把操作结果作为后续步骤的输入,保留其来源和关联。
- 在用户、程序和 AI 之间传递同一工作对象,支持继续处理。
- 将“准备修改什么”和“实际修改了什么”组织成可以让用户检查的结果。
这些能力也可以由设计良好的 API 和工具系统提供。Endor 希望进一步让它们成为语言与运行环境中一致的组织方式,减少每个工具单独定义一套对象标识、结果格式和上下文约定的负担。是否真的减少了集成成本,需要在具体工作流里验证。
对象模型不会自动解决 AI 的理解错误,也不会因为对象有类型就保证操作正确。发现能力、理解业务意图、遵守授权和验证结果仍然是不同问题。
## Endor 语言:让对象成为可编程的共同基础
Endor 的意义不能只停留在界面上可以点击一张对象关系图。开发者、用户编写的逻辑和 AI 生成的程序,需要能够表达这些对象、关系与操作,并把它们交给运行环境执行。**Endor 语言应当是连接对象世界与可执行行为的核心部分。**
现有语言文档使用 **Endorphin** 作为语言名称;本文的“Endor 语言”指这套语言在整个项目中的角色。下面依据现有语言设计稿整理其意义,不把设计约定等同于已经完成的软件能力。
### 统一对象模型,而不只是统一调用入口
现有设计以 Object 为共同基础,把数值、文本、容器、引用、类型定义、代码、函数和运行实体纳入对象体系。类型本身也是对象,由 Object 与 Type 的关系组织实例和类型定义。
这使我们能够讨论同一个工作对象的不同层面:它是什么类型,能被什么结构引用,支持哪些操作,由什么规则管理生命周期。类型的意义也不限于检查一个函数参数是否合法,还包括对象怎样组织、使用和呈现。
“一切皆对象”不能直接推导为“一切对象都独立保存、远程暴露或永久存在”。设计稿用 atom 讨论可独立识别、保存和引用的资源单元;哪些对象需要成为这样的单元,应与具体类型和生命周期规则一起确定。
### 身份、引用与命名,让组合能够延续
用户希望反复操作同一任务,语言就需要区分任务本身与当前拿到它的名字或引用。引用应当使程序能够继续关联目标,而不只是把某次显示得到的文本复制到另一个位置。
现有设计由 workspace 管理资源归属,namespace 提供命名和组织入口。资源身份、命名路径与用户身份承担不同职责;代码引用也不自动改变资源归属或取得授权。
对项目意义而言,这种区分让“跨入口使用同一对象”有了可表达的基础。一个对象可以被不同程序引用,用户不必为了更换呈现方式就重新建立它与其他对象的关系。具体的引用失效、资源回收、命名变更和跨端行为,仍应以语言规范及系统设计逐项落实。
### 函数与类型,表达对象能够做什么
有对象身份还不够,语言还需要表达怎样操作对象。类型描述结构与能力,函数表达输入、计算和结果,使可复用逻辑能够被其他程序引用和组合。
会议整理可以被拆成选择片段、形成结论、整理行动项和建立关联等能力。这里的重点不是预先发明一套内置会议 API,而是让业务逻辑能够用可检查的类型和调用关系表达,而不只能藏在某个界面的事件处理里。
函数本身与调用结果也要区分。用户查看一个函数,不代表已经执行;函数可以被引用,也不代表调用者已经具备执行它的授权。界面、AI 与其他程序使用这些能力时,应共享明确的调用含义。
### Preflight 与 inflight,连接定义和实际执行
现有设计把程序处理分为 Preflight 与 inflight:前者负责理解声明和引用、检查名称与类型并建立关联,后者使用实际输入执行逻辑,管理调用、实例和任务的运行状态。
对于 Endor 的目标,这意味着生成或编辑一段逻辑,与实际执行它应当有清楚的边界。AI 写出一段程序,不应仅因生成了文本就被视为已经就绪或已经执行;它仍需要进入相应的检查和运行过程。
这些是语言设计中的阶段职责,不等于本文已经确认所有编译器、虚拟机和平台路径都实现了同样的语义。语言设计与当前实现之间的对应,需要分别核对。
### Agent 也是程序结构的一部分
在“面向 AI 的操作系统”这一定位中,Agent 是任务的执行主体,承担类似传统进程在任务层面的职责。语言需要能够表达它使用的对象、逻辑和上下文,让任务不只是一段外部聊天记录;对象池则为这些任务提供可以持续引用的工作对象。
现有设计将 agent 与 class、function 一起纳入语言资源体系,讨论工具、记忆、状态与运行控制如何在共有模型中组织。AI 因而不只是从语言外部发送指令,也可以通过语言定义和组合行为。
如果 Agent 能引用已有函数、处理类型化结果并保留与对象的关系,它就有机会成为这个对象世界的参与者。人编写的逻辑与 AI 组织的逻辑可以使用共同的资源与调用约定,便于检查、复用和交接。
语言层面的 Agent 结构并不自动授予运行权限。操作身份、委托、任务恢复时的授权检查等仍有待定义,不能从“Agent 在某个 workspace 中”推导出它能以管理员身份执行。
### 从对象类型到界面呈现
语言设计还讨论客户端根据资源或调用结果的类型选择呈现方式,并用普通函数返回可渲染值来表达界面。这让界面与业务逻辑有机会围绕同一对象模型组织,而不是在前后端之间反复重建对应的数据结构。
同一个任务对象,可以在任务列表中显示摘要,在会议记录中显示来源,在专业编辑界面中展示更多操作。不同入口需要适合各自场景的呈现,同时保留对同一对象的关联。
类型驱动呈现不意味着所有对象都自动拥有完整 UI,也不意味着跨设备共享内存或瞬时一致。呈现契约、本地副本、同步状态和失败行为仍需要具体实现。
### 语言应该承担什么,系统又应该承担什么
语言应当把对象、类型、引用和行为表达清楚,让程序可以检查、组合和执行。Nexus、Aster、Aerarium、存储与通信机制负责支撑相应运行能力;不能仅靠增加一个语法结构就宣称已经解决分布式运行、持久化或故障恢复。
对用户来说,成功的结果应当是更容易表达一件事、继续处理同一对象,并把已有能力组合成新的工作流。语言的价值需要落在这种表达与操作的连续性上,而不只在于少写了几个请求或多了一种声明形式。
语言章节会随设计继续整理。当前依据可查阅:[语言概览](https://gitea.entroia.com/Zayden-Jung/Endorphin-Document/src/branch/main/guide/01-overview.md)、[Object 与类型系统](https://gitea.entroia.com/Zayden-Jung/Endorphin-Document/src/branch/main/guide/07-top-level-types/README.md)、[资源访问与呈现](https://gitea.entroia.com/Zayden-Jung/Endorphin-Document/src/branch/main/guide/11-resource-access-and-presentation.md)和[规范状态](https://gitea.entroia.com/Zayden-Jung/Endorphin-Document/src/branch/main/appendices/specification-status.md)。这些资料记录语言设计,不能作为实现完成度的证明。
## 一个完整的使用故事:从会议录像到项目计划
下面是一条用来检验项目意义的目标工作流,不是现有功能演示。它沿用同一次会议,让对象在用户、程序与 AI 之间连续流转。
### 从分散入口开始
用户打开录像,手工记下重要时间点,把结论复制到文档,再切换到任务工具创建行动项。AI 可以协助转写和提炼,但如果交接只剩几段文本,之后仍需要人工确认每个任务来自哪里、是否重复、对应哪一次修改。
当结论变化时,用户要重新找到相关任务和文档。原有工具可以通过链接和集成改善这个过程;我们需要明确比较的是这些已经可用的做法,而不是只和纯手工操作比较。
### 在对象世界中完成同一件事
1. **找到会议**:用户在 Tessera 中打开会议录像对象,选择需要处理的片段,保留它们与原始录像的关联。
2. **交给 AI 整理**:用户要求 AI 基于这些片段形成结论和候选行动项。候选结果继续关联来源,先供用户检查,不把生成内容直接当成已确认计划。
3. **核对并形成任务**:用户修正结论、去掉无效行动项,再通过约定的程序能力建立任务与来源的关系。
4. **换一个入口继续处理**:同一任务出现在项目看板、会议记录和个人工作视图中。不同入口展示不同信息,但引用的是同一任务。
5. **继续修改**:用户调整任务或结论后,可以沿关系找到受影响的内容。哪些变化自动传播、哪些需要重新确认,由具体对象与工作流规则决定。
6. **交接工作**:另一个获准参与的人或 AI 能够继续引用这些对象,了解其来源和当前状态,而不必仅凭上一轮聊天摘要重建整个过程。
这个故事的关键不是“一次指令自动完成全部工作”,而是中途可以检查、修改、换入口并继续处理。Endor 语言应当能表达其中的对象类型、引用和业务能力,前端负责呈现和交互,运行环境则支撑实际的访问与执行。
## 用户怎样保持控制(试写,待确认)
> **本章是待讨论的体验设想,不是已确认的权限规范。** 语言设计已有 workspace 整体授权、资源编辑与程序使用的区分;下面关注用户应能看懂什么、决定什么。操作身份、Agent 委托、撤销和记录的具体机制仍需设计。
“交还操作权”最终需要体现在用户能够作出的决定上。用户应当知道自己正在查看什么、以谁的身份操作、操作会影响哪些对象,以及什么结果已经发生。仅仅让对象可以被调用,而用户看不清影响范围,并不足以实现这个目标。
### 看懂对象、身份与预期影响
在会议整理的例子里,用户应能分辨 AI 正在处理的是哪次会议、哪些片段和哪些候选结果;也应能区分“生成了一份建议”与“已经创建任务”这两种状态。
对于会改变业务状态的操作,可以探索先展示目标、主要输入和预期效果,再让用户判断是否继续。哪些操作需要确认、哪些可以在既定范围内连续执行,应根据任务与风险讨论,不在这里规定所有动作都弹窗确认。
图中让曲线指向某个对象,不表示每个对象、字段或函数都需要一份独立的用户权限表。现有语言设计以 workspace 为整体授权边界,对象定位、代码可视域、入口允许的业务效果与用户授权应各自清楚,避免在这篇意义讨论中另造一套冲突规则。
### 委托给 AI,仍能区分谁在作决定
一个值得探索的目标是:用户可以将某项工作交给 AI,同时理解这次委托的目标、允许产生的效果和停止条件。AI 能引用对象或生成代码,不应自动获得修改任意对象或管理成员的权限。
委托的具体表示、运行身份如何绑定、长期任务是否重新检查授权、用户怎样收回委托,仍未定稿。这里先记录用户需要的体验,不把某种令牌、权限语法或审批流程提前确定为方案。
### 能检查结果,也能面对失败
用户需要区分未开始、执行中、成功、部分完成和失败。若一个流程只完成了部分任务创建,系统应尽量提供足够信息,让用户判断是否继续,而不是只返回一个无法解释的失败提示。
操作记录和来源关系可以帮助检查,但记录哪些内容、保存多久、谁能查看,仍需要权衡。我们可以讨论撤销、补偿操作或恢复到某个版本的能力,不能假定每次操作都可以回滚:已经发生的外部效果可能无法收回,跨系统执行也未必具有一个统一事务。
### 部署选择与控制之间的关系
本地、自部署和平台端提供了不同的运行与数据放置选择。它们可以帮助用户决定在哪里运行对象、由谁维护服务,但部署在自己机器上并不自动解决授权、备份或错误操作问题。
如果用户希望迁出数据、替换前端或更换运行位置,系统需要有可解释的边界。哪些对象、关系和执行状态可以导出,怎样保留引用与依赖,以及离开某个服务后还能使用什么,都应继续讨论,而不是仅用“支持自部署”代替这些问题。
## Endor 的代价、边界与验证标准(试写,待确认)
> **本章提出评估问题,不是已经达成的性能、兼容性或可靠性承诺。** 我们需要用真实工作流判断对象模型带来的收益,是否值得它引入的复杂度。
### 对象共同基础也会带来成本
| 需要承担的代价 | 为什么会出现 | 应当检查什么 |
| --- | --- | --- |
| 对象身份与引用的维护 | 对象被多个入口长期使用,关系不能只存在于一次调用中 | 改名、替换、删除和迁移后,引用是否仍有可理解的行为 |
| 类型与业务语义的协调 | 多个程序要对同一对象的含义形成约定 | 类型演进与不兼容变化是否使其他入口失效 |
| 跨端状态和网络成本 | 本地与远端对象不共享同一块内存 | 延迟、断线、过期状态和部分失败是否会造成错误判断 |
| 运行环境与运维成本 | 对象的执行、保存和访问需要相应基础设施 | 小规模使用是否也必须承担不必要的部署负担 |
| 学习和集成成本 | 开发者需要理解新的语言与对象模型 | 完成同一业务是否比已有语言、API 和工具增加更多工作 |
| 共同基础的依赖风险 | 多个入口可能依赖相同运行时与对象服务 | 故障影响范围、版本兼容和退出路径是否明确 |
图里更少的进程只是组织方式的示意。实际的内存、启动时间、吞吐量、隔离性和故障影响需要分别测量;进程少不天然意味着成本低,也不证明系统更可靠。
### 哪些事情不应勉强统一
专业程序可以保留适合自己的内部结构和交互方式。对于已有明确接口、成熟工作流或特殊执行约束的系统,建立适度的连接也许比全部迁入新的对象体系更合适。
对象关系不应抹平真实边界:一个远端对象可能不可达,一个业务操作可能被拒绝,一次结果更新可能尚未传播。语言可以帮助表达和处理这些情况,不能把它们变成“看起来像本地调用,因此一定立即成功”。
Endor 也不应仅凭对象之间可连接,就宣称用户可以无损迁移全部程序、消除所有格式转换或让 AI 理解全部业务。需要保留什么、转换什么、失去什么,应在具体对象和连接机制中说明。
### 用同一条工作流验证意义
可以先用前面的会议整理故事做一个范围有限的原型,对比现有工具的正常用法与 Endor 原型。两边使用同一份材料、同样的目标和相近的辅助条件;如果现有工具已经有良好的链接与自动化能力,也应纳入比较。
| 想验证的主张 | 可以观察的结果 |
| --- | --- |
| 减少重复组织数据 | 从录像到任务,需要多少次重复录入、复制和人工重建关联 |
| 保留同一对象与来源 | 换入口或重新进入流程后,能否准确找到原任务及其依据 |
| 支持人和 AI 交接 | 人能否修正 AI 的候选结果,AI 能否在修正后继续处理正确目标 |
| 操作效果容易理解 | 用户能否判断哪些只是建议,哪些已经改变业务状态 |
| 组合能力值得复用 | 加入第二种视图或工作流时,需要新增多少适配逻辑 |
| 失败时仍可继续 | 遇到断线、拒绝访问或部分完成后,能否辨认状态并继续工作 |
这些观察需要同时记录用户体验、实现工作量与运行成本。不能只比较演示中最顺利的一次,也不能把更多后台复杂度隐藏起来,再把少几个界面步骤当成全部收益。
### 什么结果意味着需要调整方向
如果保留对象身份仍然离不开大量手工映射,换一个界面仍然要复制一套数据,或者简单任务必须先部署过多基础设施,那么当前设计还没有兑现它的意义。
如果 AI 操作对象之后更难解释结果,或开发者为了接入对象模型付出的工作远高于已有接口带来的成本,也应调整对象粒度、语言边界或集成方式。
这里先不设任意的数值门槛。第一步应是做出可重复的具体工作流,记录收益和问题,再决定哪些目标值得继续投入,哪些假设需要修改。
## 继续整理的方向
这一版先把程序入口、对象、操作权、AI 与语言之间的关系串起来。后续可以用真实工作流修订这些描述,进一步确定对象身份、执行与挂载的语义。
[系统架构](/docs/00-endor-concepts-draft)讨论部署与存储,[节点与对象挂载](/docs/02-mounting-draft)讨论运行位置和挂载边界;本文关注它们为何能给用户带来值得验证的变化。
-148
View File
@@ -1,148 +0,0 @@
# 对象关系、循环引用与分代垃圾回收
记录日期:2026 年 10 月 28 日。本文整理一次从“继承是有向无环图,那么 object 的所有关系可以有环吗?”展开的讨论,依次解释对象图、追踪式垃圾回收和分代回收。这里记录通用原理与其他运行时的例子,不代表 Endor 已确定或实现了某种内存管理方案。
## 继承无环,不等于整个对象图无环
**是否允许成环,要看边代表什么关系。** 把所有关系都画在同一张图上,会掩盖不同关系的约束。
| 关系 | 节点与边的含义 | 是否允许有环 |
| --- | --- | --- |
| 类继承 | 类指向父类 | 通常禁止;单继承形成森林,多继承可形成 DAG |
| 原型委托 | 对象沿原型链查找属性 | JavaScript 普通对象的原型链禁止形成环 |
| 普通强引用 | 对象字段指向另一个对象 | 允许,例如父子反向引用、双向链表、社交关系 |
| 所有权或包含关系 | 生命周期由谁管理、谁包含谁 | 由模型规定;树形包含通常限制成环 |
继承的无环约束让“父类”构成可排序的层次,便于定义类型关系、成员查找和初始化顺序。不能简单地说“有环必然让查找无限递归”:查找算法可以检测环,但语言仍可以为了保持继承层次的语义而禁止环。不同语言的检测时机也不同,不一定都在语法或编译阶段。
普通字段引用则可以这样成环:
```js
let parent = { name: "Parent" };
let child = { name: "Child", parent };
parent.child = child;
// parent → child → parent
parent = null;
child = null;
// 假设没有其他强引用,两个对象组成的环现在不可达。
```
双向链表相邻节点的 `next` 和 `prev` 也构成引用环;这不表示“沿 next 前进”一定会回到起点。讨论图是否有环时,需要明确观察哪些边。
JavaScript 对普通对象原型环的限制,可见 [ECMAScript 的 OrdinarySetPrototypeOf 算法](https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ordinarysetprototypeof)。
## 循环引用影响哪些操作
对图进行递归遍历时,需要用已访问集合等方式防止重复处理。序列化也必须决定怎样表示对象身份和回边。
原生 `JSON.stringify()` 在实际遍历到循环引用时会抛出 `TypeError`,因为 JSON 本身没有表示对象引用的机制。可以按业务需要输出对象 ID,再用独立的关系表记录连接;不能简单丢弃回边后仍声称完整保存了对象图。相关规则见 [ECMAScript 的 SerializeJSONObject 算法](https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonobject)。
内存管理上的关键区别是:
- **单纯的引用计数**只看入引用数量。没有外部引用的环内部仍彼此计数,无法仅靠“计数降到零”释放。
- **追踪式 GC**从根出发判断可达性。孤立环没有来自根的路径,可以被回收。
- 引用计数也可以搭配循环检测。CPython 就以引用计数为基础,另有循环垃圾收集器,不能把它直接归为与 V8 相同的标记清除实现。见 [Python 的 gc 文档](https://docs.python.org/3/library/gc.html)。
环并不必然造成内存泄漏。反过来,即使对象关系无环,只要长期缓存等根可达对象一直引用着已经不需要的数据,追踪式 GC 也不会替程序判断它们“业务上已经没用”。
## Tracing GC 如何回收孤立环
Tracing GC(追踪式垃圾回收)把从根沿强引用可达的对象视为需要保留的对象。根可以包括执行栈中仍然存活的引用、全局环境,以及宿主运行时持有的句柄;具体集合由实现决定。源代码里存在一个变量名,并不等于它在任意时刻都一定是 GC 根。
下面用概念上的对象图说明一次回收:
```text
根 R ──→ A ⇄ B 保留:A、B 可从根到达
C ⇄ D 回收候选:C、D 彼此引用,但根到不了
```
### 标记:从根寻找可达对象
最简单的工作队列算法如下。这里假设程序暂停、只有普通强引用,忽略弱引用和终结器处理。
```text
marked = 空集合
worklist = 根所指对象
while worklist 非空:
object = 取出一个对象
if object 已在 marked 中:
continue
把 object 加入 marked
把 object 的所有强引用目标加入 worklist
```
**先记录访问状态,再继续展开引用**,就能避免在 A → B → A 中无限循环。标记结束后,集合中正好包含本次模型下所有根可达对象。工程实现通常使用位图和工作队列等结构,不要求给每个对象增加一个布尔字段。
### 清除:回收未标记对象占用的空间
Mark-and-Sweep(标记清除)是追踪式 GC 的一种实现:完成标记后,遍历管理的堆空间,把未标记对象占用的空间变为可供分配的空闲空间。上图中 C、D 都未被标记,因此环不会妨碍回收。
清除并不保证立刻把内存归还操作系统;运行时可以先保留空间供后续分配。标记状态如何为下一轮重置也是实现细节。
标记清除可能留下不连续的空闲块。Mark-Compact(标记整理)通过移动存活对象并更新引用来减少碎片;移动和整理不一定覆盖每个页面,也不一定每轮执行。V8 的说明可参见 [Orinoco 概览](https://v8.dev/blog/trash-talk)和[标记与清除实现文档](https://github.com/v8/v8/blob/main/docs/heap/marking-and-sweeping.md)。
## 分代回收为什么更快
分代回收利用弱分代假说:在许多程序中,大量对象分配后很快就变得不可达。它是关于工作负载的经验规律,不是所有程序都满足的定理。“活得短”指对象很快不再可达,而不是函数一返回,对象就必然死亡;返回值、闭包和缓存都可能延长对象的生命周期。
运行时把对象按年龄等信息分组,让年轻代更频繁地回收,以减少每次检查整个堆的开销。
| 方面 | 年轻代 | 老年代 |
| --- | --- | --- |
| 常见对象 | 刚分配、尚未晋升的对象 | 已晋升的长期存活对象;部分对象可能直接分配到相关空间 |
| 常见存活特征 | 大量对象很快不可达 | 经筛选后存活率往往较高 |
| 回收思路 | 经常进行较小范围的回收 | 较低频率地进行更大范围的回收 |
| 可能的算法 | 复制、标记清除等 | 标记清除、选择性整理等 |
老年代并不意味着“对象数量少”;长期缓存和不断增长的数据结构都可能使它很大。Minor GC 通常只处理年轻代,Major / Full GC 的具体覆盖范围取决于收集器;不能理解成 Major GC 必然只看老年代。
### 复制回收:只搬运存活对象
经典半空间模型为年轻代准备两个空间。一次回收时,把含有待处理对象的空间称为 From,把接收存活对象的空间称为 To:
```text
回收前 From: [活 A][死][死][活 B][死] To: [空闲]
回收后 From: [整体可复用] To: [活 A][活 B][连续空闲]
```
收集器从根和跨代引用入口出发,把存活对象复制到 To 或晋升到老年代,并修正引用。转发信息保证同一个对象不会被重复复制,也保留共享引用和环。之后两个空间轮换角色;文献在分配前后使用 From / To 名称的时机可能不同。
当存活对象少时,需要搬运的数据也少,而且目标空间天然紧凑。不过要付出备用空间、引用扫描和复制的代价。分代不等于复制:V8 除 Scavenger 外也提供年轻代 Minor Mark-Sweep 路径。见 [V8 GC 文档](https://github.com/v8/v8/blob/main/docs/heap/garbage-collection.md)。
### 晋升:年龄和空间策略共同决定
年轻对象经过回收仍存活,可以按收集器策略晋升到老年代。年龄、目标空间容量、对象大小或页面存活率都可能影响决定。
“熬过两次回收”可以作为某些实现的教学例子,但不是分代 GC 的定义;“To 空间超过 25% 就晋升”也不应作为所有 V8 版本和所有收集器的固定规则。堆大小和阈值需要结合具体版本、配置与工作负载理解。
## 只回收年轻代,如何处理老年代到年轻代的引用
假设根到年轻对象的唯一路径经过老对象:
```text
根 ──→ 老对象 O ──→ 年轻对象 Y
```
如果 Minor GC 既不扫描整个老年代,也不知道 O → Y 这条边,就可能漏掉 Y。常见解决办法是把跨代引用信息记录下来:
1. 程序执行 `oldObject.child = youngObject` 等引用写入时,**写屏障**执行附加的记录逻辑。
2. **记忆集**记录需要重新检查的引用位置,或通过卡表等结构记录包含这些位置的区域。它是收集器维护的元数据,不要求是老年代里的一块固定“小区域”。
3. Minor GC 从根与这些跨代引用入口追踪年轻对象,无需每次完整扫描老年代。扫描入口时还需要检查记录是否仍指向年轻代。
为保证正确性,初始化、晋升等过程产生的新跨代引用也必须由实现维护。写屏障还可能服务于并发或增量标记,不只用于分代记录。V8 的相关说明见[标记与清除文档中的写屏障部分](https://github.com/v8/v8/blob/main/docs/heap/marking-and-sweeping.md)。
记忆集允许保守保留对象:即使 O 已不可达,只要其相关记录本轮仍被作为入口处理,Y 就可能在这次 Minor GC 中暂时存活,等后续更大范围的回收再清理。因此,“不可达对象可以回收”不意味着每次局部回收都会回收所有不可达对象。
## 对 Endor 后续设计的启发
本次讨论留下的设计问题是:应分别规定继承边、普通引用边和所有权边的约束,再决定运行时怎样管理对象生命周期。
- 若普通对象允许互相引用,遍历和持久化需要处理环与共享对象身份。
- 若采用引用计数,需要决定如何处理循环,例如弱引用、显式断环或附加的循环检测。
- 若采用追踪式 GC,需要定义根、可扫描的引用字段,以及弱引用等特殊关系的处理方式。
- 若采用分代策略,需要维护跨代引用,并通过实际对象存活率与暂停时间测量判断收益。
分代回收通常能减少平均回收工作,但不保证没有暂停,也不保证对所有负载都最快。并行、并发和增量执行是另外的优化维度。这些问题应作为后续方案评估项,不能据此推断 Endor 已采用 V8 的方案。
@@ -1,50 +0,0 @@
# 语言设计问题集
从名称与作用域到反射与表示,集中维护语言设计讨论中的问题、候选方案与确认记录。
当前收录 **13 个分类、271 项议题,26 项已确认**。本合集是设计工作底稿,不是已经实现的语言规范。
## 章节目录
| 分类 | 议题数 | 已确认 | 当前问题 |
| --- | ---: | ---: | --- |
| [02 · 名称与作用域](/docs/language-design/02-questions) | 22 | 13 | 02-15 |
| [03 · 值与类型](/docs/language-design/03-questions) | 30 | 13 | 03-14 |
| [04 · 存储与生命周期](/docs/language-design/04-questions) | 20 | 0 | 04-01 |
| [05 · 对象与成员](/docs/language-design/05-questions) | 18 | 0 | 05-01 |
| [06 · 表达式与运算](/docs/language-design/06-questions) | 20 | 0 | 06-01 |
| [07 · 函数与调用](/docs/language-design/07-questions) | 22 | 0 | 07-01 |
| [08 · 控制流](/docs/language-design/08-questions) | 20 | 0 | 08-01 |
| [09 · 集合与访问协议](/docs/language-design/09-questions) | 22 | 0 | 09-01 |
| [10 · 错误与资源](/docs/language-design/10-questions) | 16 | 0 | 10-01 |
| [11 · 模块与程序执行](/docs/language-design/11-questions) | 22 | 0 | 11-01 |
| [12 · 并发与异步](/docs/language-design/12-questions) | 21 | 0 | 12-01 |
| [13 · 外部交互](/docs/language-design/13-questions) | 22 | 0 | 13-01 |
| [14 · 反射与表示](/docs/language-design/14-questions) | 16 | 0 | 14-01 |
## 怎样继续讨论
每项保留原编号、问题范围与验收要求。只有规则、正常示例、边界情况和失败行为明确,并且已有用户确认或明确授权的默认方案时,才勾选完成。建议、语言对照和“已介绍过”均不计为已确认。明确决定不支持某项能力时,也应记录其后果。
已有展开的问题保留 Python、JavaScript/TypeScript、Go、Rust 等语言的对照和原始资料链接;其余议题保留问题与验收要求,后续讨论时再补充对应方案与来源。
复选框是 Markdown 中的维护记录。更新章节后,也要同步本页的数量、当前问题和跨分类约束;网站不会自动从聊天同步。
## 已有决定与待对齐事项
- **名称与作用域**:已确认词法作用域、普通函数局部绑定、块内 `let`、遮蔽、编译时确定赋值检查、`nonlocal`、循环中的独立 `let` 绑定和默认捕获绑定。02-11 的特殊结构作用域仍待核对;02-15 的多个闭包共享读写仍待选择。
- **值与类型**:已确认动态类型、名字绑定对象、显式转换、精确有理数 `Number`、默认分数显示、Unicode 字符串及独立 `Boolean`;条件只接受 `Boolean`。`String * 整数值 Number` 已修订为等价于 `duplicate`,不能继续沿用早期“不支持字符串乘法”的记录。
- **空值仍待确认**:已提出“变量位置存在、当前没有目标对象”的需求;`null` 本身是否为对象尚未得到最终确认。把引用设为 `null` 不会替原对象保活,具体回收机制仍待设计。
- **赋值与对象模型**:04-01、05-01 的原讨论尚未完成,但 03 已记录对象绑定与基本类型按类建模的方向。后续应先核对适用范围,再决定生命周期和对象扩展规则,避免重新默认为全部未定。
- **运算、控制流与集合**:06 的类型组合与条件、08 的真值判断、09 的文本索引应分别对齐 03 的显式转换、仅布尔条件及码点索引规则。
- **错误机制**:03 已确认除零触发算术错误;10 的通用失败模型仍待选择。错误分类、捕获与传播必须交叉核对。
- **执行与类型检查**:03 原讨论记录了解释执行、REPL 与动态类型的方向。名称与确定赋值仍可做执行前检查;不应把动态类型误写成“没有任何执行前检查”。其他章节的“静态或运行时待定”应按具体检查对象重新核对。
跨分类事项保持未完成,直到相关分类明确对齐。本次整理不据此替用户选择新的规则。
## 来源与维护范围
本合集依据用户指定的“语言设计 02”至“语言设计 14”聊天整理,截至 2026-10-28。章节保留原编号,本次没有提供 01 分类的对应语言设计聊天,因此不补造该章。附带的《Chromium 主线 01:导航与文档提交》属于另一主题,未并入。
各章末尾标明原讨论标题;仓库的 `content/language-design-sources.json` 记录对应聊天标识,便于后续追溯。它是本次整理的来源清单,不是自动同步配置。
@@ -1,196 +0,0 @@
# 02 · 名称与作用域
13/22 项已确认。当前推进 02-15;02-11 的特殊结构作用域仍需核对。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 02-01 词法作用域
- [x] 已确认
名称按定义位置的代码嵌套关系查找;调用者的局部变量不会参与被调用函数的名称查找。
## 02-02 普通变量的基本作用范围
- [x] 已确认
不需要声明关键词;函数内普通变量采用 Python 式作用范围,普通 `if`、`for` 块不限制它。块内赋值后,块外可以使用,前提是该赋值确实发生。
## 02-03 `let` 临时绑定
- [x] 已确认
`let` 声明的变量限定在所属块内;后续赋值仍操作该临时绑定,不扩大作用域;不提供 `var`。
## 02-04 内层遮蔽外层
- [x] 已确认
内层 `let` 可以与外层变量同名。块内读写内层绑定,退出后恢复访问外层绑定,外层值不受这些修改影响。
## 02-05 同层重复 `let`
- [x] 已确认
同一作用域重复声明同名 `let` 报错;修改已有变量使用普通赋值。
## 02-06 `let` 声明前的直接使用
- [x] 已确认
整个块中的同名直接读写都认定为该临时绑定;声明前使用在编译检查阶段报错,不会转而访问外层同名变量。
## 02-07 分支与未初始化读取
- [x] 已确认
确定不能保证读取前已赋值时,编译报错还是运行时报错;覆盖条件分支、可能执行零次的循环和提前退出。
**已确认规则:**采用编译时的确定赋值检查。每条能够到达读取位置的路径都必须先赋值;编译器无法证明这一点,就报错。
## 02-08 普通变量的声明前查找
- [x] 已确认
确定函数后面出现普通赋值时,前面的同名读取算本地未初始化,还是可以访问外层绑定。
**已确认规则:**局部归属按整个函数确定,赋值前读取不能回退到外层同名变量。
## 02-09 同层名称冲突
- [x] 已确认
明确参数、普通变量、`let` 之间哪些组合属于赋值、哪些属于重复声明或冲突。
**已确认规则:**参数后的普通赋值修改参数绑定;参数与函数体同层 let 同名、同层重复 let、重复参数名均编译报错;更内层 let 可以遮蔽;let 之前的同名赋值属于声明前访问,编译报错。
## 02-10 内层函数修改外层绑定
- [x] 已确认
明确普通赋值默认修改哪一层,是否需要专门语法才能修改外层变量。
**已确认规则:**普通赋值默认建立或修改当前函数的局部绑定;通过 `nonlocal` 显式指定外层函数中已有的绑定。
## 02-11 各语法结构的作用域边界
- [ ] 待讨论
列清函数、独立块、循环及其他受支持结构各自如何引入绑定;未决定是否支持的结构标注依赖。依赖:**语法设计**。
## 02-12 循环绑定
- [x] 已确认
确定循环变量和循环体中的 `let` 是每轮建立新绑定,还是多轮复用;说明闭包捕获后的结果。
**已确认规则:**每次进入循环体,创建这一轮自己的 `let` 绑定;普通变量继续复用所属函数中的绑定。循环外不能访问循环体内的 `let`。若支持循环头的 `let`,也采用每轮独立绑定的原则。
## 02-13 声明与初始化顺序
- [x] 已确认
明确 `let` 能否不带初值、初始化表达式中的同名引用如何处理,以及受支持的复合声明按什么顺序建立绑定。依赖:**语法设计**。
**已确认规则:**允许先声明后赋值;初始化前读取自身报错;多目标声明或赋值先求完整右侧,再绑定目标。具体复合语法由语法设计对齐。
## 02-14 闭包捕获什么
- [x] 已确认
确定默认捕获绑定还是当时的值,以及是否提供显式选择。
**已确认规则:**默认捕获绑定;外层重新赋值后,闭包读到新值。需要快照时手动建立独立绑定,不自动深拷贝对象。
## 02-15 闭包修改与共享
- [ ] 待讨论
确定多个闭包是否共享同一外层绑定、哪些修改允许、哪些情况报错。依赖:**可变性规则**。
## 02-16 逃逸闭包的绑定存续
- [ ] 待讨论
确定外层函数返回或代码块退出后,闭包仍可访问什么,以及如何保证访问有效。依赖:**运行时实现、资源管理**。
## 02-17 完整名称查找顺序
- [ ] 待讨论
明确函数外层、模块、内置名称的查找顺序,以及类成员是否参与裸名称查找。依赖:**模块系统、对象与类型模型、内置库设计**。
## 02-18 模块可见性
- [ ] 待讨论
明确默认私有还是公开、如何导出,以及访问不可见名称时的错误。依赖:**模块系统**。
## 02-19 导入名称的绑定与冲突
- [ ] 待讨论
明确导入名能否重新赋值、别名和重名如何处理、导入后观察到的是哪个绑定。依赖:**模块系统**。
## 02-20 跨模块初始化
- [ ] 待讨论
明确循环导入或模块尚未初始化时,读取其名称如何处理。依赖:**模块系统、执行模型**。
## 02-21 类型名与值名的关系
- [ ] 待讨论
确定是否共享命名空间、同名是否冲突、名称如何区分解析。依赖:**类型系统**。
## 02-22 最终一致性验收
- [ ] 待讨论
用一组正常与错误示例交叉检查上述规则,解决跨分类冲突,形成最终规则摘要。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
接下来是 **02-15:多个闭包能否共同读写同一个绑定**。
```text
fn example() {
count = 0
fn increase() {
nonlocal count
count = count + 1
}
fn read() {
return count
}
increase()
print(read()) // 如果允许共享,输出 1
}
```
各语言的相关规则:
| 语言 | 做法 | 收益与代价 |
|---|---|---|
| **Python** | 多个闭包可以共享绑定;修改外层函数绑定需要 `nonlocal` | 共享状态方便,修改也有明确标记;多个函数的行为会相互影响。[官方说明](https://docs.python.org/3/faq/programming.html#why-am-i-getting-an-unboundlocalerror-when-the-variable-has-a-value) |
| **JS / TS** | 可以共享并修改同一绑定,不需要 `nonlocal`;TS 保持 JS 的运行语义 | 写法直接,读代码时需要留意哪些闭包会修改变量。[官方手册](https://www.typescriptlang.org/docs/handbook/variable-declarations.html) |
| **Go** | 外层函数与闭包共享变量,允许修改 | 共享直接;并发执行时需要另行协调访问。[规范](https://go.dev/ref/spec#Function_literals) |
| **Rust** | 借用捕获受访问限制约束;相互冲突的可变借用和只读借用不能同时存续。共享可变状态需要合适的显式机制 | 编译器能限制冲突访问,但需要更多所有权与借用规则。[官方说明](https://doc.rust-lang.org/book/ch13-01-closures.html#capturing-references-or-moving-ownership) |
我们有两个主要方向:
1. **允许多个闭包共享读写**:上面例子合法,输出 `1`;修改仍要求 `nonlocal`。
2. **限制共享可变捕获**:某些同时持有读写访问的闭包组合被拒绝;具体限制需要与所有权模型一起设计。
我推荐 **1**,与前四种语言的方向一致,也能延续已经采用的 `nonlocal`。这里先决定单线程中的共享行为;并发访问规则属于并发模型。
## 讨论来源
《语言设计 02:名称与作用域》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,250 +0,0 @@
# 03 · 值与类型
13/30 项已确认。当前推进 03-14;null 是否为对象仍未确认。其余分类中的类型、赋值和条件规则须与本章已有决定核对。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 03-01 动态类型
- [x] 已确认
操作根据运行时对象的类型检查;不要求执行前通过静态类型检查。不适用的类型组合在运行时失败。
## 03-02 名字与对象绑定
- [x] 已确认
变量名绑定对象,类型属于对象;`b = a` 共享对象,重新绑定 `a` 不改变 `b`。
## 03-03 隐式转换政策
- [x] 已确认
需要转换类型时必须显式写出;不靠自动转换接受不匹配的操作数,例如 `"41" + 1` 报 `TypeError`。
## 03-04 数值类型与支持范围
- [x] 已确认
对外统一为 `Number`,覆盖整数与有理数,大小自动调整;不引入无穷大、`NaN`、虚数,正负零视为同一个数。精确性及异常运算另见下项。
## 03-05 整数性质与循环次数
- [x] 已确认
记录数值是否为整数;按次数循环要求正整数,零、负数和非整数不满足要求。具体错误形式交由 03-29 统一。
## 03-06 字符串算术操作
- [x] 已确认
确定字符串拼接、减法与重复运算的支持范围。
**已确认规则:**两个 String 可以用 + 拼接,不支持字符串减法;后续已修订为支持 String * 整数值 Number,完全等价于 duplicate,包含相同的参数检查与失败行为。
## 03-07 数值精确性
- [x] 已确认
确定有理数存储和四则运算是否始终精确,是否允许舍入。
**已确认规则:**`1 / 3` 精确保存为 `1/3`;`1/3 + 1/3` 得到 `2/3`;`1/3 * 3` 得到整数 `1`。如何显示另由 03-09 决定。
## 03-08 数值异常
- [x] 已确认
确定除零等不成立的数值运算如何失败,哪些情况交给平台模块。
**已确认规则:**`1 / 0`、`0 / 0`、`1 / (1 - 1)` 都抛出算术错误,停止本次表达式求值。具体错误名称、捕获和传播机制由【错误与异常】及 03-29 对齐。
## 03-09 数值输入与显示
- [x] 已确认
确定小数文字表示什么数、循环小数如何显示,以及显示后能否无损读回。依赖【词法与语法】。
**已确认规则:**数值按精确值解释,默认输出最简分数,分母为正;整数直接显示为整数,零显示为 `0`。
## 03-10 基本类型清单
- [x] 已确认
确定本轮内置哪些类型,以及明确不提供哪些类型。
**已确认规则:**本轮提供 `Number`、`String`、`Boolean`、空值类、`List`、`Map`、`Tuple`、`Set`、`Bytes`、函数类、类型对象所属的类。具体类名暂定。 空值类的对象地位在 03-14 被重新提出,仍待确认;不能据此宣布 null 已确定为对象。
## 03-11 字符串的值模型
- [x] 已确认
确定字符单位、Unicode 处理及索引单位;补齐空字符串等边界。
**已确认规则:**合法 Unicode 标量序列;保留原码点、不自动规范化;默认按码点计数,字素簇使用专门接口;索引从零开始、支持负索引、单个索引越界报错;普通正向切片左闭右开、越界截断、空范围返回空字符串。 创建或解码时拒绝非法编码和孤立代理项,不自动替换或丢弃;原始数据用 Bytes 保存。
## 03-12 字符串重复方法
- [x] 已确认
确定名称、次数参数、零和负数、错误行为及返回值。
**已确认规则:**`s.duplicate(n)` 返回重复后的 `String`,不修改接收者。`n` 必须是非负整数值的 `Number`;零次返回空字符串,负数、非整数及其他类型报错,不做隐式转换。
## 03-13 布尔值与条件转换
- [x] 已确认
确定布尔值是否独立于数字,以及条件位置接受布尔值还是其他值。依赖【控制流】。
**已确认规则:**`Boolean` 独立于 `Number`;条件表达式只接受 `Boolean` 对象。`true`、`false` 可以作为条件,数字、字符串、容器、空值等直接作为条件均报 `TypeError`。具体控制流语法由【控制流】对齐。
## 03-14 空值与缺失
- [ ] 待讨论
确定是否区分空值、缺失、未初始化和无返回值,以及误用时的行为。
## 03-15 复合值规则
- [ ] 待讨论
为选中的容器、元组、记录或枚举等确定成员类型限制、缺失成员及递归结构规则。
## 03-16 用户定义类型的能力
- [ ] 待讨论
确定用户能定义哪些类型,哪些能力仅供内置类型使用。依赖【对象与类型模型】。
## 03-17 类型身份与兼容性
- [ ] 待讨论
确定按名字还是结构认类型、如何处理子类型,以及哪些类型可以互相替代。
## 03-18 运行时类型查询
- [ ] 待讨论
确定如何取得类型、检查类型及检查能力;明确精确类型与子类型检查的区别。
## 03-19 显式转换接口
- [ ] 待讨论
确定转换入口、支持的转换、可能丢失信息的转换及失败行为。
## 03-20 泛型与容器类型约束
- [ ] 待讨论
决定是否支持参数化类型或带成员约束的容器;支持则规定运行时如何检查。
## 03-21 可选类型标注
- [ ] 待讨论
决定是否提供;提供则明确其用途及是否影响运行行为。
## 03-22 顶级类型与顶级类
- [ ] 待讨论
分别决定是否存在能容纳所有值的类型,以及是否存在统一祖先类。
## 03-23 可变性与共享
- [ ] 待讨论
确定哪些对象可变,以及多个名字共享对象时能观察到什么变化。
## 03-24 复制语义
- [ ] 待讨论
确定赋值之外是否提供复制、浅复制和深复制,以及循环引用如何处理。
## 03-25 相等与身份
- [ ] 待讨论
确定内容相等和同一对象如何区分,以及不同类型之间如何比较。
## 03-26 哈希与键的限制
- [ ] 待讨论
确定哪些值可作集合成员或映射键,保证相等、哈希和可变性规则一致。
## 03-27 参数与返回值的绑定
- [ ] 待讨论
确定传参、返回、重新绑定及修改对象的可见效果。依赖【函数与调用】。
## 03-28 显式引用能力
- [ ] 待讨论
决定能否直接取得并传递“变量存储单元”的引用;不支持也可完成此项。
## 03-29 类型相关错误契约
- [ ] 待讨论
统一类型不匹配、值不合法、转换失败等情况的错误分类。依赖【错误与异常】。
## 03-30 跨分类一致性验收
- [ ] 待讨论
用一组正常和失败示例核对类型、转换、对象、运算、函数与控制流规则,消除冲突和阻塞。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
可以,**你要的空状态表示“变量位置存在,但当前没有指向对象”**。
不过,“避免回收”这里需要区分两件事:
```text
result = null
……之后……
result = calculate()
```
这是**先保留名字,之后再绑定对象**。前面还没有计算结果对象,因此没有对象需要保活。名字什么时候有效,由作用域规则决定。
如果已经有对象,情况不同:
```text
a = 某个对象
b = a
a = null
```
在采用强引用与自动回收的模型中,`b` 仍持有该对象,就不能提前回收它;如果所有有效强引用都消失,对象才可能被回收。**把变量设成 `null` 会解除它原来的引用,不会保护原对象。**具体回收机制属于【运行时与内存管理】,尚未确定。
各语言也区分“空状态”和“对象保活”:
| 语言 | 空状态怎样表达 | 对我们的启发 |
|---|---|---|
| **Python** | `None` 本身是一个对象,名字绑定到它。[对象模型](https://docs.python.org/3/reference/datamodel.html#objects-values-and-types) | 可以保持所有可绑定值都是对象的统一规则。 |
| **JS / TS** | `null` 是独立的原始值,不是对象;TS 另有静态类型约束。[JS 规范](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-ecmascript-language-types)、[TS 文档](https://www.typescriptlang.org/tsconfig/strictNullChecks.html) | 可以在语言层面明确允许“无对象”的值。 |
| **Go** | 指针变量可以为 `nil`,表示没有指向对象,变量本身仍存在。[语言规范](https://go.dev/ref/spec#The_zero_value) | 接近你描述的“位置存在、目标为空”。 |
| **Rust** | `Option<T>` 的 `None` 表示其中没有 `T` 值,不要求使用空指针实现。[官方说明](https://doc.rust-lang.org/std/option/) | “没有目标对象”的语义不必绑定某种内存布局。 |
这里还有一个需要明确的设计边界:
1. **空引用例外**:名字可以绑定对象,也可以持有 `null`;`null` 不属于对象。
2. **统一对象模型**:`null` 是空值类的对象,用它表示“没有业务对象”。
你说的“没有对象”,更接近 **1**。这会成为之前“所有值都是类的实例”的一个明确例外;方案 **2** 则保留那条统一规则。两者都能实现先声明、后赋值。
**你希望 `null` 本身也不是对象,采用方案 1,对吗?**
## 讨论来源
《语言设计 03:值与类型》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,161 +0,0 @@
# 04 · 存储与生命周期
0/20 项已确认。当前推进 04-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 04-01 赋值的默认语义
- [ ] 待讨论
明确赋值默认是独立值、共享还是移动;是否按值的类别区分,以及如何识别这种区别。
## 04-02 创建、身份与存储位置
- [ ] 待讨论
明确何时产生新实例、身份是否可观察,以及存储位置和地址是否属于语言保证。依赖:对象与类型模型。
## 04-03 初始化流程
- [ ] 待讨论
明确初始化顺序、必需字段、默认值和“可正常使用”的判定条件。
## 04-04 初始化期间的访问与逃逸
- [ ] 待讨论
明确未完成初始化的值能否被读取、调用、共享或捕获,以及违规行为。
## 04-05 初始化失败
- [ ] 待讨论
明确失败如何传播、已初始化部分如何清理,以及外部可观察状态的保证。
## 04-06 别名与修改权限
- [ ] 待讨论
明确多个引用能否同时存在、谁能修改,以及修改对其他持有者是否可见。
## 04-07 复制规则
- [ ] 待讨论
明确哪些值可复制、浅复制与深复制的含义、内部共享关系及复制失败行为。
## 04-08 移动规则
- [ ] 待讨论
明确是否支持移动、何时发生、源值之后能做什么,以及禁止移动的情况。
## 04-09 跨函数与闭包传递
- [ ] 待讨论
明确传参、返回值、闭包捕获沿用哪些生命周期规则,局部值逃逸如何处理。依赖:函数与闭包相关规则。
## 04-10 所有权与借用
- [ ] 待讨论
明确是否存在所有者、借用及其期限;哪些约束由静态检查或运行时检查保证。依赖:类型系统。
## 04-11 引用失效与悬空访问
- [ ] 待讨论
明确销毁、容器变化、存储迁移等情况下引用何时失效,以及失效访问的处理。
## 04-12 自动回收机制
- [ ] 待讨论
明确采用作用域释放、引用计数、追踪式 GC 或混合机制,并区分语言保证与实现自由。
## 04-13 循环引用与弱引用
- [ ] 待讨论
明确循环如何处理,是否提供弱引用,以及弱引用失效后的访问结果。
## 04-14 销毁时机与顺序
- [ ] 待讨论
明确销毁是否及时、顺序是否保证,以及正常退出和提前退出时的行为。
## 04-15 清理中的异常行为
- [ ] 待讨论
明确清理失败、重复清理、清理期间重入,以及重新发布待销毁值的处理规则。
## 04-16 非内存资源
- [ ] 待讨论
明确文件、锁等资源如何及时释放,是否支持显式关闭或作用域管理,以及关闭后操作的结果。
## 04-17 用户自定义生命周期操作
- [ ] 待讨论
明确哪些创建、复制、移动、清理操作允许定制,其触发时机与限制。依赖:定制协议。
## 04-18 分配失败与手动存储能力
- [ ] 待讨论
明确内存不足如何表现,以及是否暴露手动分配、释放或分配器选择;不支持也须明确。
## 04-19 并发、异步与外部接口边界
- [ ] 待讨论
明确跨执行单元共享或转移、任务取消、外部代码持有值时的生命周期契约;不支持的能力明确排除。依赖:相应并发及外部接口规则。
## 04-20 跨分类一致性验收
- [ ] 待讨论
用创建、共享、传递、失败、退出的完整例子检查已定规则;解决与类型、对象、协议和运行时规则的冲突。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **04-01:`b = a` 默认意味着什么?** 重点看两个问题:修改 `b` 是否影响 `a`,赋值后还能否使用 `a`。
- **Python:赋值建立绑定,不复制对象。**
`a = [1]; b = a; b.append(2)` 后,`a` 也是 `[1, 2]`。但给 `b` 重新赋值不会改变 `a` 的绑定。共享很方便,代价是可变对象的修改会沿别名传播。对我们的启发是:必须分清“修改同一个值”和“变量改为指向另一个值”。这是 Python 的语义,不依赖 CPython 的引用计数实现。[官方说明](https://docs.python.org/3/library/copy.html)
- **JS:对象赋值保留同一对象;TS 沿用这一运行时行为。**
`const a = {n: 1}; const b = a; b.n = 2` 后,`a.n` 为 `2`。`const` 限制重新赋值,不会冻结对象;数字等原始值则没有可共享修改的内部状态。TS 的类型标注不会让赋值自动复制对象。好处是对象共享直接,代价是静态类型本身不消除别名影响。启发是:绑定是否可改、值是否可变、是否共享,需要分别规定。[JS 规范](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-assignment-operators-runtime-semantics-evaluation)、[TS 文档](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types)
- **Go:复制赋给变量的值,但不递归复制它引用的数据。**
`a := [1]int{1}; b := a; b[0] = 2` 后,`a[0]` 仍为 `1`;换成切片 `a := []int{1}`,同样操作后 `a[0]` 为 `2`,因为复制的切片描述信息仍引用同一底层数组。好处是支持直接的数据复制,也能方便共享;代价是必须理解类型内部是否含引用。启发是:**“赋值复制值”不保证整份数据相互独立。** [Go 规范](https://go.dev/ref/spec#Assignment_statements)
- **Rust:赋值根据类型发生复制或移动。**
`let a = 1; let b = a;` 后仍可读 `a`,因为整数实现了 `Copy`;`let a = String::from("hi"); let b = a;` 后再读取 `a` 会被编译器拒绝。好处是无需隐式复制字符串,也能明确资源归属;代价是使用者需要理解移动、借用和复制能力。启发是:按类型区分可行,但需要类型系统支撑;“放在栈上还是堆上”并非判断复制或移动的规则。[Rust 官方教程](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html)
回到我们的待选方案:
| 方案 | 普通可变数据的 `b = a` |
|---|---|
| **A:默认独立值** | 修改 `b` 不影响 `a`;需要明确独立性覆盖到哪一层。 |
| **B:默认共享** | 修改共享内容会影响 `a`;需要独立副本时显式复制。 |
| **C:默认移动** | 转移使用资格;之后不能继续读取原来的 `a`。 |
| **D:按类别区分** | 为普通数据、共享引用、唯一资源分别规定行为;类别必须可识别。 |
**我的建议是先从 A 或 B 确定普通可变数据的使用体验**,再在复制、移动任务中检查其他类别是否需要不同规则。这样能先明确用户可观察的行为,不必提前决定完整类型系统或回收机制。这只是推进建议,尚未替你选择。
对于普通可变记录或序列,你更希望默认 **A:各自独立**,还是 **B:共享修改**?如果你希望所有权成为语言的基础,也可以直接选 C 或 D。
## 讨论来源
《语言设计 04:存储与生命周期》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,144 +0,0 @@
# 05 · 对象与成员
0/18 项已确认。当前推进 05-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 05-01 对象的基本模型
- [ ] 待讨论
完成标准:确定对象以记录、类实例、原型委托或明确的组合模型为基础;说明对象怎样获得数据和行为,是否必须存在类。
## 05-02 成员种类与命名空间
- [ ] 待讨论
完成标准:确定支持哪些成员种类;明确字段、方法、计算属性等能否同名,同名时如何处理。
## 05-03 成员集合与结构变化
- [ ] 待讨论
完成标准:确定成员是否预先声明,能否在运行时增加、删除或替换;说明非法变更的行为。
## 05-04 字段状态与可写性
- [ ] 待讨论
完成标准:确定字段是否有默认值、能否未初始化、如何表达只读;说明读取未初始化字段、非法赋值的行为。值与引用的具体规则依赖【类型系统/语言语义】。
## 05-05 方法接收者与方法取出
- [ ] 待讨论
完成标准:确定接收者如何传入;说明 `f = p.move` 后调用 `f()` 的含义,以及接收者不匹配时的行为。
## 05-06 计算属性与访问器
- [ ] 待讨论
完成标准:决定是否支持 getter、setter;若支持,明确读写触发规则、只读属性赋值及访问器递归的处理。
## 05-07 成员查找与缺失成员
- [ ] 待讨论
完成标准:确定查找顺序、遮蔽规则,以及成员不存在时是报错、返回特殊结果还是允许自定义处理。错误的发现时机依赖【类型系统】。
## 05-08 成员可见性与封装
- [ ] 待讨论
完成标准:确定公开、私有等访问范围及默认值;说明跨对象、模块、继承关系中的访问边界和失败行为。
## 05-09 对象创建与初始化
- [ ] 待讨论
完成标准:确定创建入口、初始化顺序、必填字段要求;说明初始化失败及初始化期间访问对象的规则。资源释放交由相关【运行时实现/语言语义】议题处理。
## 05-10 继承与委托关系
- [ ] 待讨论
完成标准:决定支持哪些关系、是否允许多个来源及关系变更;说明循环和非法关系的处理。是否形成子类型关系依赖【类型系统】。
## 05-11 组合与成员转发
- [ ] 待讨论
完成标准:确定组合对象的成员必须显式访问,还是支持自动转发;若支持转发,明确冲突、接收者和可见性规则。
## 05-12 覆盖与冲突约束
- [ ] 待讨论
完成标准:确定是否允许覆盖、是否要求显式标记、能否禁止覆盖;明确多个来源提供同名成员时的处理。签名兼容依赖【类型系统】。
## 05-13 方法分派
- [ ] 待讨论
完成标准:确定调用实现由什么决定,例如接收者或多个参数;说明重载候选冲突、无匹配实现的行为。依赖【类型系统】的部分单独标记。
## 05-14 调用上层或指定来源的实现
- [ ] 待讨论
完成标准:决定是否提供 `super` 或类似能力;若支持,明确查找起点及实际接收者。若不支持相关对象关系,明确其后果后可关闭此项。
## 05-15 类型级成员与共享状态
- [ ] 待讨论
完成标准:决定是否存在类/类型级方法和字段;明确访问方式、共享范围,以及它们与实例成员的关系。
## 05-16 外部扩展与行为替换
- [ ] 待讨论
完成标准:决定能否在定义之外添加方法、扩展成员或替换既有行为;明确作用范围、冲突和已有对象受到的影响。
## 05-17 公共基础接口、反射与内部字段边界
- [ ] 待讨论
完成标准:决定是否需要共同顶级类或公共基础协议;明确哪些成员信息可以检查,哪些运行时元数据不可作为普通成员访问。依赖【定制协议/运行时实现/内置与标准库】。
## 05-18 跨分类一致性验收
- [ ] 待讨论
完成标准:用一组贯穿示例检查创建、读写、方法调用、封装和行为复用;核对相关分类的已定规则,解决冲突及阻塞依赖。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
**05-01 当前要决定的是:对象的数据与行为,主要通过什么机制组织?** 这里先不决定继承、动态增删成员或类型检查。
下面是官方文档中的语言机制;“好处与代价”是供我们判断的取舍分析。
| 语言 | 规则与具体行为 | 好处、代价及对我们的启发 |
|---|---|---|
| **Python** | 用户通常通过类定义实例行为。例如类定义 `move(self, dx, dy)`,调用 `p.move(3, 4)` 时自动传入 `p`。类本身也是对象。[官方文档](https://docs.python.org/3/tutorial/classes.html) | 数据与行为容易集中组织;同时需要解释实例、类、方法绑定等关系。启发:选择类模型,仍需逐项决定这些规则,不能把 Python 的整套机制一并带入。 |
| **JS** | 可以直接创建对象,也可以通过原型共享行为。例如 `p = Object.create(proto)` 后,`p.move()` 可以找到 `proto` 上的方法。`class` 也建立在原型机制之上,普通实例方法通常位于原型上。[ECMAScript 规范](https://tc39.es/ecma262/multipage/overview.html#sec-objects) | 对象创建和行为共享灵活;查找涉及自身成员与原型链,需要处理遮蔽和共享成员变化。启发:类语法与底层对象模型是两个决定。 |
| **TS** | 在 JS 之上提供静态类型检查。例如 `const p: {x: number; y: number} = {x: 1, y: 2}` 不需要定义类。普通对象类型主要按结构检查;运行时对象仍遵循 JS 语义。[官方文档](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html) | 可以描述“需要哪些成员”,而不要求对象来自同一个类;但这种类型描述本身不提供运行时成员验证。启发:对象如何构成,与类型如何判断兼容,应分开决定。 |
| **Go** | 结构体定义字段,方法单独声明并关联到接收者类型。例如为 `Point` 声明接收者为 `*Point` 的 `Move` 方法,然后调用 `p.Move(3, 4)`。方法不是任意对象上随手添加的函数属性,接收者基础类型必须定义在同一包内。[语言规范](https://go.dev/ref/spec#Method_declarations) | 无须类也能组织数据与方法;扩展方法受到明确边界约束。启发:我们此前的 A 方案还应包含“记录/结构体+关联方法”这一变体,不能只考虑独立函数。 |
| **Rust** | `struct Point` 定义数据,`impl Point` 定义关联方法;例如 `move_by(&mut self, …)` 可修改接收者。没有传统的结构体继承,行为抽象还可通过 trait 表达。[方法文档](https://doc.rust-lang.org/book/ch05-03-method-syntax.html)、[继承与多态说明](https://doc.rust-lang.org/book/ch18-01-what-is-oo.html) | 数据、关联方法和行为抽象可以分别组织;需要明确它们之间的规则。启发:方法、封装和多态都不必以类继承为前提。所有权与借用属于其他分类,不随此方案自动引入。 |
因此,仍保留三个主要方向,并把 A 说明得更完整:
- **A.记录/结构体为基础**:数据由记录或结构体表达;行为可以是独立函数,也可以有明确关联的方法。选 A 后,再明确这两种组织方式的关系。
- **B.类与实例为基础**:类集中描述实例的数据与行为。是否继承、能否动态扩展,继续按后续任务决定。
- **C.对象与原型委托为基础**:对象可以直接构造,通过其他对象共享行为;必须明确委托查找规则。
**如果优先考虑基础机制较少、数据与行为关系显式,我建议从 A 中的“结构体+关联方法”开始。** 它能提供 `p.move(...)` 的使用方式,又不必同时引入类继承或原型链;代价是动态对象扩展需要另外设计。这只是建议,尚未定案。
你更倾向 **A、B,还是 C**?如果选 A,我们接着比较“独立函数为主”与“关联方法为主”。
## 讨论来源
《语言设计 05:对象与成员》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,173 +0,0 @@
# 06 · 表达式与运算
0/20 项已确认。当前推进 06-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 06-01 普通操作数的求值顺序
- [ ] 待讨论
明确二元运算、嵌套表达式的求值顺序,并确定是否采用一条适用于调用参数等表达式的共同规则;用有副作用的例子验收。
## 06-02 求值失败与中断
- [ ] 待讨论
明确某一步发生错误或不终止时,后续哪些步骤还会执行;说明副作用是否保留。依赖【错误处理】。
## 06-03 运算符清单与分组规则
- [ ] 待讨论
列明本轮支持的运算符、优先级、结合性,以及是否允许连续比较;用存在歧义的表达式验收。与【语法】核对。
## 06-04 数值运算的类型组合与转换
- [ ] 待讨论
确定哪些数值组合可运算、是否隐式转换、结果类型,以及不允许组合的失败方式。依赖【类型系统】。
## 06-05 算术结果的具体含义
- [ ] 待讨论
明确支持的加减乘除、余数及其他算术规则,覆盖负数、零、除零、舍入和精度。
## 06-06 溢出与特殊数值
- [ ] 待讨论
确定整数越界、浮点溢出、无穷、NaN、正负零等情况是否存在,以及相应行为。依赖【数值类型】。
## 06-07 同类型值的相等
- [ ] 待讨论
确定相等比较的含义、结果要求,以及复合值按结构还是其他方式比较;说明是否允许违反自反性等性质。
## 06-08 跨类型相等
- [ ] 待讨论
确定不同类型的值能否相等、是否转换,以及无法比较时返回假还是失败;与数值转换规则核对。
## 06-09 身份比较
- [ ] 待讨论
决定是否提供身份判断、哪些值具有身份,以及复制、共享对身份的影响。依赖【对象与类型模型】【内存与生命周期】。
## 06-10 大小比较
- [ ] 待讨论
确定可比较的类型、全序或偏序、不可比较时的行为,以及大小比较与相等的关系。
## 06-11 哈希及其约束
- [ ] 待讨论
决定哪些值可哈希,明确相等与哈希一致性的要求,以及可变值和自定义相等的影响。与【集合与容器】核对。
## 06-12 条件位置接受什么值
- [ ] 待讨论
确定条件必须为布尔值还是允许其他值,并说明转换规则和失败行为。与【控制流】【类型系统】核对。
## 06-13 逻辑运算与短路
- [ ] 待讨论
确定逻辑运算符、是否跳过右侧、结果为布尔值还是操作数,以及能否自定义这些行为。
## 06-14 位运算与移位
- [ ] 待讨论
决定是否支持;若支持,明确适用类型、位宽、负数、右移和非法移位量的行为。
## 06-15 运算扩展的声明方式
- [ ] 待讨论
决定用户能否定义或重载运算,以及通过函数、协议、类型成员或其他机制声明。依赖【类型系统】【定制协议】。
## 06-16 运算实现的选择规则
- [ ] 待讨论
若允许扩展,确定左右操作数、类型匹配、转换和候选优先级,以及歧义的处理;若不允许扩展,记录内置选择规则。
## 06-17 不支持操作时的行为
- [ ] 待讨论
明确“不支持”、运算内部出错、存在歧义的区别,以及编译期和运行期分别如何处理。依赖【类型系统】【错误处理】。
## 06-18 复合赋值与修改语义
- [ ] 待讨论
决定是否支持 `+=` 等形式;若支持,明确目标求值次数、是否原地修改、失败后的状态。依赖【变量与赋值】【对象与类型模型】。
## 06-19 常量求值与优化边界
- [ ] 待讨论
明确常量表达式和普通表达式是否遵守相同结果、错误与精度规则,以及优化必须保留哪些可观察行为。依赖【执行模型】。
## 06-20 跨分类一致性验收
- [ ] 待讨论
核对上述规则与相关分类的最终决定;通过包含副作用、混合数值、不可比较值、哈希、自定义运算和失败路径的代表性例子,解决所有冲突。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **06-01:普通操作数的求值顺序**。要决定的是:一个表达式包含多个计算步骤时,语言保证怎样的先后顺序。
例如下面的伪代码,假设各函数都会记录自己被调用了:
```text
a() + b() * c()
```
“先乘后加”决定运算分组;它并不意味着必须先调用 `b()`。我们讨论的是这些函数以及普通变量读取的先后顺序。
相关语言的对照如下。这里说的是语言规则,后面的取舍分析不代表其历史设计动机。
| 语言 | 规则与具体行为 | 对我们设计的启发与取舍 |
|---|---|---|
| **Python 3** | 普通表达式从左到右求值。上例依次调用 `a、b、c`,再完成乘法、加法。赋值另有规则:先求右侧,再处理左侧目标。[语言参考](https://docs.python.org/3/reference/expressions.html#evaluation-order) | 顺序容易预测;实现需要保留可观察行为。也提醒我们应明确赋值等特殊结构的边界。 |
| **JavaScript / TypeScript** | JS 的普通算术表达式先求左操作数,再求右操作数;上例也是 `a、b、c`。TS 对这类表达式提供静态检查,运行时遵循生成的 JS 的规则,类型标注不会建立另一套求值顺序。[ECMAScript 规范](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-evaluatestringornumericbinaryexpression)、[TS 文档](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types) | 类型检查与求值顺序可以分别设计;无论选择静态还是动态类型,都可以保证从左到右。 |
| **Rust** | 普通算术、调用参数、元组元素等规定按源码从左到右求值,上例也是 `a、b、c`。赋值和复合赋值另有专门规则。[Rust Reference](https://doc.rust-lang.org/reference/expressions.html#evaluation-order-of-operands)、[赋值规则](https://doc.rust-lang.org/reference/expressions/operator-expr.html#assignment-expressions) | 明确顺序与编译优化可以并存;需要谨慎控制特殊规则,否则使用者记忆负担会上升。 |
| **Go** | 采用部分顺序保证:函数调用、方法调用、接收操作等按词法从左到右,但并非所有普通读取都固定顺序。所以上例调用仍是 `a、b、c`,但下面的例子可能有两个结果。[Go 规范](https://go.dev/ref/spec#Order_of_evaluation) | 可以只固定部分操作的顺序,给其他求值留下自由;代价是“从左到右”的直觉不足以解释所有表达式。 |
Go 的区别可以用这个例子看出来:
```go
a := 1
f := func() int { a++; return a }
x := []int{a, f()}
```
规范允许 `x` 是 `[1, 2]` 或 `[2, 2]`:普通读取 `a` 与调用 `f()` 之间的顺序没有固定。这说明,**仅保证函数调用从左到右,比保证所有普通操作数从左到右更弱**。[Go 规范中的示例](https://go.dev/ref/spec#Order_of_evaluation)
据此,我们有三个可选方案:
- **A:普通操作数统一从左到右。** 变量读取、函数调用等都遵守;逻辑短路、赋值等结构单独明确。最容易推理。
- **B:普通操作数顺序不作保证。** 必要的先后依赖仍须遵守;带副作用的表达式可能允许多个结果,实现选择空间更大。
- **C:部分操作保证从左到右。** 类似 Go,固定调用等操作的顺序,允许其他求值顺序不确定。折中,但规范和使用都更复杂。
**我建议 A**,原因是它能为后续错误传播、重载和复合表达式提供清晰基础;不能观察到差别的优化仍可进行。错误发生后的传播规则留在 06-02,短路留在 06-13,赋值与复合赋值的顺序另行核对。
你选择 **A、B,还是 C**?
## 讨论来源
《语言设计 06:表达式与运算》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,182 +0,0 @@
# 07 · 函数与调用
0/22 项已确认。当前推进 07-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 07-01 函数作为值【进行中】
- [ ] 待讨论
明确能否赋值、存入数据、作为参数和返回结果,以及受限情形。
## 07-02 函数定义与创建
- [ ] 待讨论
明确具名函数、匿名函数是否支持;定义何时创建函数、何时可见,重复定义怎样处理。
## 07-03 可调用目标
- [ ] 待讨论
明确哪些值可以调用,调用非函数如何失败;方法、可调用对象若存在,明确接入边界。依赖:**对象与类型模型**。
## 07-04 调用的求值策略与顺序
- [ ] 待讨论
明确实参是立即求值还是延迟求值,调用目标与实参的求值顺序,以及求值中断后的行为。
## 07-05 参数传递与修改
- [ ] 待讨论
明确形参赋值、修改实参所指数据分别有什么效果;是否支持引用、借用或其他传递方式。依赖:**变量与内存模型、类型系统**。
## 07-06 位置参数
- [ ] 待讨论
明确按什么顺序绑定,是否支持仅限位置的参数;数量不足或过多怎样处理。
## 07-07 命名参数
- [ ] 待讨论
明确是否支持、参数名是否属于公开接口、与位置参数如何混用,以及未知名称与重复绑定的处理。
## 07-08 默认参数
- [ ] 待讨论
明确求值时机、可访问的名字、默认值之间的依赖、可变默认值的共享行为,以及省略与显式传空值的区别。
## 07-09 可变参数与展开
- [ ] 待讨论
明确是否支持剩余位置参数、剩余命名参数及调用展开;规定收集形式、顺序、重复名称与非法展开。
## 07-10 绑定失败的统一规则
- [ ] 待讨论
明确绑定失败何时发现、是否执行函数体、此前哪些表达式可能已执行,以及错误如何报告。依赖:**错误处理**。
## 07-11 返回与无结果
- [ ] 待讨论
明确显式返回、执行到末尾、无结果与空值、多结果及接收数量不符的行为。
## 07-12 递归与名字绑定
- [ ] 待讨论
明确自递归、相互递归、局部递归函数是否支持,以及重绑定函数名后递归调用指向什么。依赖:**作用域与模块**。
## 07-13 尾调用与调用深度
- [ ] 待讨论
明确是否保证尾调用、哪些位置算尾位置,以及调用深度耗尽怎样失败。依赖:**控制流与运行时**。
## 07-14 闭包的名字解析
- [ ] 待讨论
明确是否支持访问外层局部名字,采用词法作用域还是其他规则,以及遮蔽和未绑定名字的行为。依赖:**作用域**。
## 07-15 闭包捕获与共享状态
- [ ] 待讨论
明确捕获值还是变量、自动还是显式捕获,以及多个闭包、循环变量与外部修改之间的关系。依赖:**变量与内存模型**。
## 07-16 闭包逃逸与生命周期
- [ ] 待讨论
明确闭包能否比创建它的调用活得更久,捕获的资源如何保持有效,不能逃逸时如何拒绝。依赖:**资源管理与类型系统**。
## 07-17 函数的调用接口
- [ ] 待讨论
明确函数签名包含哪些信息、怎样检查兼容性;重载或泛型若存在,明确调用选择的接口。依赖:**类型系统**。
## 07-18 函数身份与比较
- [ ] 待讨论
明确函数是否可比较、复制后是否保持身份、重复创建的闭包有什么关系,以及能否作为键。依赖:**值与相等性模型**。
## 07-19 非普通控制流的调用边界
- [ ] 待讨论
明确异常、异步、生成器等若被采用,对调用与返回产生什么影响;只规定函数侧接口。依赖:**错误处理、并发与控制流**。
## 07-20 内置函数与外部函数
- [ ] 待讨论
明确它们是否遵守同样的参数、返回和错误规则,以及无法兼容时如何声明差异。依赖:**内置与标准库、外部互操作**。
## 07-21 语言调用语义与底层约定
- [ ] 待讨论
明确哪些行为属于语言保证,哪些由实现决定;外部调用需要哪些额外约定,避免把栈、寄存器布局误当语言规则。
## 07-22 跨分类一致性验收
- [ ] 待讨论
用代表性程序核对上述规则,并解决与其他分类的冲突;没有未解决的阻塞后完成。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **07-01:函数能否作为值**,即能否保存、传参、返回,以及通过变量调用。闭包捕获规则仍留在后续任务。
### 五种语言怎样做
下面的例子都假定已经定义了一个“输入整数、返回整数”的 `increment` 函数。取舍是针对我们设计的分析,不代表这些语言的历史设计动机。
| 语言 | 规则与具体行为 | 好处、代价及对我们的启发 |
|---|---|---|
| **Python** | 函数是对象。`f = increment` 保存函数,`f(3)` 调用它;也能放入列表、传参和返回。[官方教程](https://docs.python.org/3/tutorial/controlflow.html#defining-functions) | 行为组合直接;单靠语言运行时的动态检查,部分不合法调用到执行时才发现。**支持函数值,不要求我们采用动态类型或 Python 的对象模型。** |
| **JS** | 函数是可调用的对象。例如 `const f = increment; f(3)`,也能作为参数和结果。[ECMAScript 规范](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-object-type) | 回调与组合方便;接口的正确使用需要额外约束。**函数具有对象属性,是可单独选择的能力,不必随函数值一起引入。** |
| **TS** | 延续 JS 的函数值行为,并增加静态函数类型,例如 `const f: (x: number) => number = increment`。类型标注被擦除,实际执行的是 JS,不会自动获得运行时参数校验。[函数类型](https://www.typescriptlang.org/docs/handbook/2/functions.html)、[类型擦除](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types) | 能提前检查许多接口错误;需要设计类型兼容规则,且静态检查不能代替外部输入验证。**函数值能力和检查时机可以分开决定。** |
| **Go** | 支持有静态类型的函数值,例如 `var f func(int) int = increment`,随后 `f(3)`;函数也可传参、返回。函数类型变量的零值为 `nil`。[Go 规范](https://go.dev/ref/spec#Function_types) | 调用接口明确;需要处理空函数值等边界。**可以支持函数值,同时保留较直接的签名模型;我们不必继承 `nil`。** |
| **Rust** | 支持保存、传递和返回函数相关的值,但区分函数项、函数指针和闭包。例如 `let f: fn(i32) -> i32 = increment;`。捕获环境的闭包不能直接当作普通 `fn` 指针,通常通过 `Fn` 等接口使用。[Rust 官方书](https://doc.rust-lang.org/book/ch20-04-advanced-functions-and-closures.html)、[函数指针规则](https://doc.rust-lang.org/reference/types/function-pointer.html) | 能明确表达不同调用能力;类型与接口规则更多。**一等函数不要求所有可调用值具有同一种类型或底层表示。** |
这些语言都支持广泛的函数值能力,区别主要在类型、对象能力和环境捕获。**Rust 的类型区分并不意味着它只支持我们下面的 B。**
### 我们当前的三个选项
- **A:函数只作为调用目标。** 可以直接调用,不能作为普通数据保存、传递或返回。规则较少,但通用回调需要其他机制。
- **B:支持受限的函数引用。** 只开放明确的一部分能力,例如仅允许引用静态定义的函数。适合有具体限制目标的语言,需要说明每条限制的理由。
- **C:函数是一等值。** 允许保存、传参和返回,遵守一般的类型与生命周期规则。随后分别决定匿名函数、闭包和函数类型。
**我仍建议 C**:它让排序比较器、事件回调、可替换的处理步骤等接口容易表达,同时保留后续设计空间。
选择 C 后,下面的基础行为应成立:
```text
chosen = increment // 保存,不执行
apply(chosen, 3) // apply 内部调用 chosen,得到 4
return increment // 可以返回已有函数
```
调用非函数应被拒绝;拒绝发生在静态检查还是运行时,依赖类型系统。返回捕获局部变量的函数是否允许,仍待闭包任务决定。
**你选择 A、B,还是 C?**
## 讨论来源
《语言设计 07:函数与调用》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,157 +0,0 @@
# 08 · 控制流
0/20 项已确认。当前推进 08-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 08-01 控制结构与值的关系
- [ ] 待讨论
明确条件、匹配、循环中哪些能作为表达式,以及结果能否被忽略;给出允许和不允许的使用位置。
## 08-02 条件分支的完整规则
- [ ] 待讨论
明确分支写法、结果来源、缺少 `else` 的行为,以及不同出口如何形成结果。结果兼容性依赖【类型系统】。
## 08-03 条件与真值判断
- [ ] 待讨论
明确条件接受哪些值、是否转换或允许定制,以及不合法条件如何失败。定制机制依赖【对象与类型模型/定制协议】。
## 08-04 短路与条件求值
- [ ] 待讨论
明确是否支持短路逻辑运算、返回布尔值还是操作数,以及哪些子表达式不会执行。
## 08-05 循环形式与正常结束
- [ ] 待讨论
明确支持哪些循环、条件何时检查、零次执行和正常耗尽的行为;若循环有值,定义正常结束的结果。
## 08-06 遍历与迭代协议
- [ ] 待讨论
明确遍历怎样取得下一项、如何识别结束,以及遍历失败和提前退出时的行为。协议形态依赖【定制协议】。
## 08-07 分支及循环的绑定与作用域
- [ ] 待讨论
明确局部绑定的可见范围、循环变量在各次迭代间的关系、循环后的可见性;检查闭包捕获的交互,依赖【名称与作用域/函数】。
## 08-08 `break`
- [ ] 待讨论
明确合法目标、是否携带值、嵌套时如何选择目标,以及目标不存在或值不合法时的行为。
## 08-09 `continue`
- [ ] 待讨论
明确合法目标、跳转后执行循环的哪个阶段、是否支持指定外层循环,以及非法位置的行为。
## 08-10 `return`
- [ ] 待讨论
明确返回目标、无返回值的行为、函数末尾是否隐式返回,以及在嵌套函数和生成器中的含义。依赖【函数】。
## 08-11 模式的种类与匹配含义
- [ ] 待讨论
明确支持哪些模式、每种模式如何判断成功,以及不支持的形式如何处理。结构读取与相等判断依赖【对象与类型模型/运算符】。
## 08-12 匹配顺序与守卫条件
- [ ] 待讨论
明确分支选择顺序、守卫何时执行、失败后是否继续,以及重叠模式和副作用的行为。
## 08-13 匹配覆盖与失败
- [ ] 待讨论
明确是否要求穷尽、是否允许默认分支、无分支命中的行为,以及不可达分支如何处理。静态检查能力依赖【类型系统】。
## 08-14 模式中的变量绑定
- [ ] 待讨论
明确绑定与已有值比较如何区分、重复变量与组合模式是否合法,以及失败匹配是否留下绑定。
## 08-15 生成器的基本控制模型
- [ ] 待讨论
决定是否支持生成器;若支持,明确创建、启动、暂停、恢复和结束,以及如何交付生成值和最终结果。函数调用形式依赖【函数】。
## 08-16 生成器的提前终止与生命周期
- [ ] 待讨论
若支持生成器,明确主动关闭、内部失败、恢复已结束生成器及重入的行为;明确遗弃生成器时对清理作何保证。依赖【资源管理/内存管理】。
## 08-17 异常与执行路径
- [ ] 待讨论
明确异常怎样穿过条件、循环、匹配和函数,在哪里可以被接住,以及捕获后从哪里继续。异常表示与匹配规则依赖【异常与错误处理】。
## 08-18 非正常退出与资源清理
- [ ] 待讨论
明确 `break`、`continue`、`return`、异常和生成器关闭触发哪些清理、执行顺序,以及清理自身再次退出或失败时谁生效。依赖【异常与错误处理/资源管理】。
## 08-19 控制流边界与非法跨越
- [ ] 待讨论
明确跳转能否跨越函数、闭包、生成器或清理区域,是否支持额外的非局部跳转;给出非法跨越的诊断原则。若其他分类引入异步边界,也在此核对。
## 08-20 跨分类一致性验收
- [ ] 待讨论
用一组涵盖嵌套分支、循环退出、匹配失败、生成器暂停及清理冲突的例子核对最终规则;所有相关依赖均已解决或明确不适用,没有相互矛盾的决定。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前待选的 **08-01** 是:**条件、匹配和循环能否直接产生值?产生的值能否被忽略?**
相关语言提供了几种不同的安排:
| 语言 | 规则与小例子 | 取舍及对我们的启发 |
|---|---|---|
| **Python** | `if`、`match`、`for`、`while` 是语句;另有条件表达式:`x = a if ready else b`。[语句规则](https://docs.python.org/3/reference/compound_stmts.html)、[条件表达式](https://docs.python.org/3/reference/expressions.html#conditional-expressions) | 简短选择可以直接产生值;需要多步计算时,通常回到分支赋值或辅助函数。说明“语句为主”仍可以单独提供条件表达式。 |
| **JS/TS** | `if`、`switch` 和循环是语句;条件表达式写成 `const x = ready ? a : b`。TS 在此沿用 JS 的执行语义,并增加静态检查,不是另一套运行时。[ECMAScript 语句](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html)、[条件表达式](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-conditional-operator)、[TS 类型擦除](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types) | 与 Python 类似:简单选择紧凑,多步分支不直接作为表达式组合。启发是要区分“单独的条件运算符”与“整个分支块都能产生值”。 |
| **Go** | `if`、`switch`、`for` 是语句,也没有 `?:`;通常先声明变量,再在分支中赋值。[Go 规范](https://go.dev/ref/spec)、[官方 FAQ](https://go.dev/doc/faq#Does_Go_have_a_ternary_form) | 选择结果与执行步骤使用同一种分支结构,规则较少;代价是简单初始化也可能需要多行。适合参考方案 A。 |
| **Rust** | `if`、`match` 是表达式,如 `let x = if ready { a } else { b };`。`loop` 可以用 `break value` 交付结果。[分支规则](https://doc.rust-lang.org/reference/expressions/if-expr.html)、[匹配规则](https://doc.rust-lang.org/reference/expressions/match-expr.html)、[循环规则](https://doc.rust-lang.org/reference/expressions/loop-expr.html) | 多步计算也能直接组合、初始化变量;需要处理各出口的结果兼容性。**但 Rust 的 `while`、`for` 正常结束得到 `()`,不能像 `loop` 那样直接 `break value`。** 表达式化仍然可以区分不同循环。 |
关于“忽略结果”,Rust 明确支持通过表达式语句求值并丢弃结果;这说明**能够产生值,不必意味着必须接住值**。[Rust 表达式语句](https://doc.rust-lang.org/reference/statements.html#expression-statements)
回到我们的三个候选方向:
- **A:控制结构主要是语句。** 简单条件是否另设表达式,需要明确;Python/JS 和 Go 在这里就有区别。
- **B:`if`、匹配可以产生值,循环是语句。** 分支计算容易组合,循环无需引入结果规则。
- **C:分支和循环都可以产生值。** 查找、重试可以直接交付结果,但需要定义哪些循环支持、正常耗尽时得到什么。
**我的建议是 B,并允许忽略控制表达式的结果。** 它能让多步分支直接初始化变量,又暂时不引入循环结果的额外规则。这只是建议,尚未记为决定;分支缺少 `else` 等细节仍在 `08-02` 处理。
你更倾向 **A、B、C** 哪一种?对于能够产生值的控制结构,是否允许直接执行并忽略结果?
## 讨论来源
《语言设计 08:控制流》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,168 +0,0 @@
# 09 · 集合与访问协议
0/22 项已确认。当前推进 09-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 09-01 能力如何组织
- [ ] 待讨论
确定下标、迭代、成员判断等能力独立提供、按类别打包,还是允许相互推导;明确组合约束。
## 09-02 下标接受什么
- [ ] 待讨论
确定位置、键、多维下标及其他选择器的支持范围,以及不支持的下标如何失败。
## 09-03 读取失败与缺失
- [ ] 待讨论
确定键不存在、无法读取时的行为,以及如何区分“缺失”和“存储的空值”。依赖「错误处理与结果类型」。
## 09-04 下标写入与删除
- [ ] 待讨论
确定是否分别支持、键不存在时如何处理,以及只读容器上的行为。
## 09-05 位置索引规则
- [ ] 待讨论
确定索引起点、可接受的索引值、负索引、越界与空集合行为。
## 09-06 文本索引单位
- [ ] 待讨论
确定字符串是否支持位置访问,以及访问单位和边界。依赖「字符串与文本」。
## 09-07 切片范围规则
- [ ] 待讨论
确定端点包含关系、默认端点、步长、反向切片、越界与非法步长行为。
## 09-08 切片结果语义
- [ ] 待讨论
确定副本、共享视图或惰性结果的选择规则,以及原集合变化的影响。
## 09-09 切片写入与删除
- [ ] 待讨论
确定支持范围、替换长度要求、重叠操作及失败后的集合状态。
## 09-10 长度与有限性
- [ ] 待讨论
确定哪些对象提供长度,未知或无限长度如何表达,长度是否允许通过遍历计算。
## 09-11 获得迭代器与重复遍历
- [ ] 待讨论
确定如何开始遍历,区分可重复遍历对象与单次消费对象,以及多个遍历是否独立。
## 09-12 取下一项与迭代结束
- [ ] 待讨论
确定元素、正常结束和读取失败的区别,以及结束后再次读取的行为。依赖「错误处理与结果类型」。
## 09-13 遍历内容与顺序
- [ ] 待讨论
确定映射等容器产出什么,各类容器承诺哪些顺序,以及顺序是否稳定。
## 09-14 遍历期间修改集合
- [ ] 待讨论
确定允许的修改、观察到的结果,以及冲突检测和失败行为。
## 09-15 提前结束与资源释放
- [ ] 待讨论
确定退出循环、异常和部分消费时的迭代器处理。依赖「资源管理与控制流」。
## 09-16 成员判断的含义
- [ ] 待讨论
确定判断键、值还是元素,采用何种匹配规则,以及缺失时的结果。依赖「值与相等性」。
## 09-17 成员判断的默认实现
- [ ] 待讨论
确定是否允许退回遍历,以及单次流、无限数据和遍历失败时的行为。
## 09-18 解包的数据来源
- [ ] 待讨论
确定解包依赖迭代、位置访问还是独立协议,以及消费顺序。
## 09-19 解包数量与失败
- [ ] 待讨论
确定数量不足、数量过多、剩余项捕获及无限输入的行为;明确失败时已消费数据和已绑定变量的状态。依赖「绑定与赋值」。
## 09-20 自定义容器如何接入
- [ ] 待讨论
确定用户需要提供哪些操作、哪些有默认行为,以及缺失或冲突如何报告。依赖「类型系统与定制协议」。
## 09-21 异步遍历的支持边界
- [ ] 待讨论
确定本轮是否支持;若支持,明确与同步遍历、取消和清理的关系。依赖「并发与异步」。
## 09-22 跨协议一致性验收
- [ ] 待讨论
检查索引、长度、切片、迭代、成员判断和解包之间的约束;核对相关分类决定,解决冲突。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **09-01:下标、迭代、成员判断等能力如何组织**。核心问题是:支持一种能力,是否自动获得其他能力?
下面的规则来自语言或标准库文档;其中的优缺点是我们的取舍分析,不代表已经确认的历史设计动机。
| 语言 | 相关规则与具体行为 | 好处、代价及启发 |
|---|---|---|
| **Python** | 能力分别定制,但有回退:没有 `__contains__` 时,`x in obj` 会尝试迭代,再尝试旧式位置访问。因此 `2 in iter([1, 2, 3])` 为真,同时消费迭代器到 `2`。标准库也提供 `Sequence` 等组合契约。[数据模型](https://docs.python.org/3/reference/datamodel.html#object.__contains__)、[容器抽象](https://docs.python.org/3/library/collections.abc.html) | 定制操作较少也能获得便利;但成员判断可能隐含消费和长时间搜索。说明“独立能力”“组合契约”“默认推导”可以分层存在。 |
| **JS** | 迭代通过 `Symbol.iterator`,与下标访问分开。`{0: "a", length: 1}` 能读取 `[0]`,却不能直接用于 `for...of`。`Map` 通过 `.has(k)` 判断键是否存在;JS 的 `in` 检查对象属性,并非通用集合成员判断。[迭代协议](https://tc39.es/ecma262/2025/multipage/abstract-operations.html#sec-getiterator)、[属性判断](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-relational-operators-runtime-semantics-evaluation)、[Map.has](https://tc39.es/ecma262/2025/multipage/keyed-collections.html#sec-map.prototype.has) | 能力边界较明确;不同容器的查询接口也可能不同。启发是:统一语法与统一能力是两个决定。 |
| **TS** | 用静态类型分别表达能力,例如 `ArrayLike<T>` 描述数字下标和长度,`Iterable<T>` 描述可迭代;只有前者不保证满足后者。执行仍依赖生成的 JS、运行环境及编译配置,TS 没有独立运行时。[对象类型](https://www.typescriptlang.org/docs/handbook/2/objects.html)、[迭代类型](https://www.typescriptlang.org/docs/handbook/iterators-and-generators.html) | 可以提前检查算法需要的能力;类型声明仍需与运行行为一致。我们可以先决定语义,再决定是否静态检查。 |
| **Go** | 内置下标适用于规定的类型,不能靠给任意结构体添加方法来重载 `[]`;`range` 有独立规则,Go 1.23 起支持规定签名的迭代函数。例如通道可以被 `range` 遍历,却不能下标访问。[语言规范](https://go.dev/ref/spec) | 内置规则集中明确;自定义容器接入下标语法的空间较小。说明访问与遍历也可以采用不同的扩展机制。 |
| **Rust** | `Index` 与 `IntoIterator` 是分开的 trait。自定义类型只实现 `Index`,不会因此获得 `for` 遍历。通用搜索可显式写成 `iter.any(|x| x == 42)`,会推进迭代器直到匹配或结束。[Index](https://doc.rust-lang.org/std/ops/trait.Index.html)、[IntoIterator](https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)、[any](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.any) | 能力要求明确,搜索也能复用;自定义类型需要提供对应能力。说明“提供通用算法”不必意味着“自动赋予另一项协议”。 |
这些语言都混合使用了多种机制,不能简单把整门语言归为某一个方案。我们当前仍选这三个方向之一:
- **A.按类别打包**:序列、映射等各自规定必需能力;特殊容器可能需要适配。
- **B.能力独立**:可下标不自动意味着可迭代,可迭代不自动意味着支持成员判断;通用搜索可以显式调用。
- **C.能力独立并允许默认推导**:例如没有专门成员判断时,自动遍历;具体回退规则在后续任务明确。
**我建议 B**:目前语言用途尚未确定,先独立表达能力,可以容纳数组、映射和单次输入流;常用能力仍可组合成契约。代价是自定义容器可能要多提供几个操作。这只是建议,尚未成为决定。
你倾向 **A、B,还是 C**?
## 讨论来源
《语言设计 09:集合与访问协议》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,136 +0,0 @@
# 10 · 错误与资源
0/16 项已确认。当前推进 10-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 10-01 失败的基本控制流
- [ ] 待讨论
确定使用错误结果、异常还是混合机制,以及失败后调用处如何继续。
## 10-02 错误的表示与分类
- [ ] 待讨论
确定错误携带哪些信息、如何区分类别和匹配处理器,是否允许自定义错误。依赖【对象与类型模型】。
## 10-03 失败是否属于接口契约
- [ ] 待讨论
确定函数是否声明可能的失败、调用者是否必须处理,以及遗漏处理如何检查。依赖【类型系统】。
## 10-04 向上传播与机制转换
- [ ] 待讨论
确定自动或显式传播规则,以及错误结果与异常之间是否允许转换、转换保留什么信息。
## 10-05 捕获与恢复
- [ ] 待讨论
确定处理器的匹配顺序、可见范围、重新传播,以及处理后从哪里继续;明确是否支持回到失败点继续执行。
## 10-06 退出作用域与栈展开
- [ ] 待讨论
确定错误跨越调用和作用域时的退出顺序,哪些清理必须执行,以及清理能访问哪些仍有效的数据。
## 10-07 清理结构与执行时机
- [ ] 待讨论
决定支持哪些 `finally`、`defer` 或资源作用域机制,明确注册时机、执行顺序,以及正常结束、提前返回和错误退出的行为。
## 10-08 资源责任与关闭规则
- [ ] 待讨论
确定谁负责关闭、责任能否转移,以及共享、重复关闭和关闭后使用如何处理。依赖【所有权与生命周期/类型系统】。
## 10-09 清理过程中再次失败
- [ ] 待讨论
确定原始错误与清理错误如何保留,后续清理是否继续,以及最终向外报告什么。
## 10-10 析构、终结与 GC 的边界
- [ ] 待讨论
确定是否提供析构或终结机制、执行保证及限制,明确内存回收能否承担资源关闭责任。依赖【内存管理与运行时】。
## 10-11 不可恢复失败与终止
- [ ] 待讨论
区分可处理失败、程序缺陷和强制终止;确定各类情况下是否展开和清理,以及未处理错误到达程序边界后的行为。
## 10-12 异步操作与取消中的清理
- [ ] 待讨论
明确是否支持异步清理、取消如何传播,以及清理期间再次取消的行为。依赖【并发与异步】。
## 10-13 并发中的多个失败
- [ ] 待讨论
确定任务失败由谁接收,多个失败如何组合,其他任务是否继续或取消。依赖【并发与异步】。
## 10-14 资源协议与外部接口边界
- [ ] 待讨论
明确语言内置保证与库协议的分工,以及外部调用的错误如何进入本语言、跨越外部调用时谁负责清理。依赖【定制协议、标准库与外部互操作】。
## 10-15 错误诊断与上下文保留
- [ ] 待讨论
确定是否保留调用位置、传播路径、原始原因与附加上下文,以及包装、重抛后保留什么。
## 10-16 跨分类一致性验收
- [ ] 待讨论
用正常返回、嵌套失败、清理失败、未处理失败及适用的取消场景核对全部规则,消除与其他分类决定的冲突和阻塞。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **10-01:常规操作失败时,返回失败结果,还是直接中断控制流?** 这里先讨论文件不存在、配置格式错误等情况,致命故障留到 10-11。
**相关语言怎样做**
| 语言 | 规则与小例子 | 好处、代价与启发 |
|---|---|---|
| **Python** | 广泛使用异常。例如 `int("abc")` 抛出 `ValueError`,跳过当前路径中的后续语句,寻找匹配的 `except`;中间函数不必逐层转发。也允许接口返回普通错误结果。[官方文档](https://docs.python.org/3/tutorial/errors.html) | 正常路径紧凑,便于外层集中处理;调用表达式本身通常看不出可能抛什么。启发:选择异常,就要接受并管理这种隐式退出路径。 |
| **JS** | 支持 `throw` 和 `try/catch`,可抛出任意值。例如 `throw "bad config"` 会中断当前路径,由外层捕获。[语言规范](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-throw-statement) | 传播方便,但处理器不能天然假定拿到的是标准错误对象。启发:**采用异常**与**允许抛什么**是两个独立决定。 |
| **TS** | 运行时沿用 JS 的异常行为。静态检查可以约束错误的使用:启用 `useUnknownInCatchVariables` 时,捕获值默认为 `unknown`,访问其属性前需要缩小类型;这不等于静态列出函数可能抛出的错误。[官方文档](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-4.html#defaulting-to-the-unknown-type-in-catch-variables---useunknownincatchvariables) | 能提高处理代码的安全性,却不会自动消除隐式异常路径。启发:静态类型检查与失败控制流要分别设计。 |
| **Go** | 常规错误通常作为额外返回值,例如得到 `(文件, error)` 后检查 `err != nil`;返回错误本身不会自动退出调用者。另有 `panic/recover` 机制。[官方说明](https://go.dev/doc/effective_go#errors) | 检查与返回位置明确;逐层传播容易重复,也可能忽略错误。启发:显式错误值和另一种中断机制可以并存,但需要明确用途。 |
| **Rust** | 常规可恢复错误使用 `Result<T, E>`,区分 `Ok` 与 `Err`。例如 `File::open(path)?` 在失败时提前返回当前函数;不用 `?` 时,可检查或传递这个结果。[官方文档](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html) | 结果结构清楚,传播简写减少重复;需要相应的类型与转换规则。启发:**显式结果并不要求每次手写完整分支**,传播语法可以另行设计。 |
这些实践提供参考,不替我们作决定;表中的好处与代价是取舍分析,不是对历史设计动机的断言。
**我们当前的三个候选方案**
沿用“配置文件不存在就使用默认配置,格式错误就停止启动”的例子:
- **A.常规失败统一返回显式结果**:调用者检查或显式传播。可以随后讨论传播简写,无须现在确定类型语法。
- **B.常规失败统一通过异常传播**:调用者捕获“文件不存在”来恢复,格式错误继续向外传播。
- **C.两者并存**:允许不同接口选择,但必须进一步确定分工,避免调用者不清楚该检查返回值还是捕获异常。
我的暂时建议是优先考虑 **A**:它让失败路径容易在调用处辨认,也能通过传播简写减少重复。如果你更重视多层调用后集中处理错误,**B** 更值得选。选择 **C** 则需要有明确的两类使用场景,收益才足以抵消复杂度。语言用途尚未确定,这个建议仍是候选。
你更倾向 **A、B,还是 C**?
## 讨论来源
《语言设计 10:错误与资源》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,168 +0,0 @@
# 11 · 模块与程序执行
0/22 项已确认。当前推进 11-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 11-01 模块的职责与顶层内容
- [ ] 待讨论
明确模块主要承载声明、可执行初始化,还是可实例化的定义;说明导入与执行的关系。
## 11-02 模块的组织单位
- [ ] 待讨论
明确模块与文件、目录、包的对应关系,以及嵌套模块、多文件模块是否支持;说明不合法的组织形式。
## 11-03 模块身份
- [ ] 待讨论
明确判断“同一个模块”的依据;覆盖路径别名、重复引用及不同版本,说明身份冲突如何处理。
## 11-04 名称与路径解析
- [ ] 待讨论
明确绝对引用、相对引用、搜索位置及优先级;说明找不到目标、存在多个候选时的行为。
## 11-05 依赖与版本选择
- [ ] 待讨论
明确依赖由谁声明、版本由谁选择,能否同时使用多个版本;划清语言、构建工具和包管理器的职责。
## 11-06 导出与可见性
- [ ] 待讨论
明确默认公开范围、显式导出及内部成员的访问规则;说明越权引用如何失败。依赖【名称与作用域】。
## 11-07 导入形式与名称冲突
- [ ] 待讨论
明确整体导入、成员导入、别名、重新导出和批量导入的支持范围;说明重名与遮蔽规则。
## 11-08 跨模块绑定语义
- [ ] 待讨论
明确导入获得值、持续关联的绑定,还是其他形式;说明重新赋值和修改共享对象的区别。依赖【名称与作用域】【对象与类型模型】。
## 11-09 依赖检查与条件导入
- [ ] 待讨论
明确依赖必须何时可用,是否支持可选或条件导入;说明未使用但缺失的依赖是否导致失败。依赖【编译与执行方式】。
## 11-10 初始化触发与次数
- [ ] 待讨论
明确初始化何时发生、何种范围内执行几次,以及重复导入的行为。
## 11-11 初始化顺序与并发
- [ ] 待讨论
明确依赖之间的初始化顺序,是否允许并发或异步初始化;说明顺序不确定时有哪些保证。依赖【并发与异步】。
## 11-12 循环依赖
- [ ] 待讨论
明确允许哪些循环,何时检测;说明访问尚未准备好的定义或状态如何处理。
## 11-13 初始化失败
- [ ] 待讨论
明确失败后模块处于什么状态,能否重试,如何处理已发生的副作用,以及依赖方会得到什么结果。依赖【错误处理】。
## 11-14 模块状态与隔离
- [ ] 待讨论
明确是否允许可变模块状态、由谁持有、何时结束,以及多个程序环境能否相互隔离。依赖【内存与资源管理】。
## 11-15 程序入口的确定
- [ ] 待讨论
明确入口是指定函数、指定模块、顶层代码还是其他形式;说明缺失或存在多个入口时如何处理。
## 11-16 入口的输入输出契约
- [ ] 待讨论
明确参数如何传入、入口允许返回什么,以及如何形成退出结果;说明不合法的入口签名。依赖【类型系统】【标准库与宿主接口】。
## 11-17 启动流程
- [ ] 待讨论
明确依赖准备、模块初始化与入口执行的先后关系;说明启动中途失败时入口和清理是否执行。
## 11-18 动态装载与模块生命周期
- [ ] 待讨论
明确是否支持运行中加载、重新加载和卸载;如支持,说明旧引用、状态与资源的去向;如不支持,记录限制。
## 11-19 正常结束
- [ ] 待讨论
明确入口返回、显式退出与后台工作之间的关系;说明哪些条件代表程序真正结束。依赖【并发与异步】。
## 11-20 异常与外部终止
- [ ] 待讨论
明确未处理错误、取消、外部终止请求和强制终止分别有什么保证;说明错误报告与退出结果。依赖【错误处理】【并发与异步】【宿主接口】。
## 11-21 清理协议与顺序
- [ ] 待讨论
明确清理动作如何注册或归属、执行顺序、能否异步,以及清理自身失败或重复触发的处理。依赖【内存与资源管理】【错误处理】。
## 11-22 跨分类一致性验收
- [ ] 待讨论
用普通启动、共享依赖、循环依赖、初始化失败和退出清理等完整场景检查规则;相关分类决定已对齐,且没有未解决的阻塞,才可完成。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **11-01:模块的职责与顶层内容**。要决定的是:模块顶层能否执行读取配置、打开连接等动作,以及这些动作是否由导入自动触发。
相关语言的对照如下。下述好处与代价是设计取舍分析,不是在断言它们的历史设计动机。
| 语言 | 规则与具体行为 | 取舍及对我们的启发 |
|---|---|---|
| **Python** | 对普通源码模块,常规首次导入会执行模块代码。例如顶层 `current = read_config()` 会在导入时调用函数;后续命中模块缓存时通常不再执行。这里说的是 Python 导入规则,并非 CPython 独有的优化。[官方参考](https://docs.python.org/3/reference/import.html#loading) | 顶层写法直接,容易提供共享状态;但一次导入可能读文件、修改环境或抛出异常。对应我们的 **B**。 |
| **JS:ES Modules** | 模块允许顶层语句。例如模块中写 `console.log("loaded")`,该模块被求值时就会打印;普通静态导入会使依赖参与求值。导入声明的连接过程与模块代码求值是不同阶段。这里专指 ESM,CommonJS 另有规则。[ECMAScript 规范](https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#sec-modules) | 能自动执行依赖的准备动作;也需要处理副作用、初始化顺序和失败。提醒我们区分“找到并连接定义”和“执行初始化”。 |
| **TS** | 运行时模块行为由实际采用的 JS 模块格式和宿主决定,TS 不提供独立运行时。`import type { Config } ...` 会从输出 JS 中消除,因此这条导入本身不会触发运行时加载。[官方文档](https://www.typescriptlang.org/docs/handbook/modules/reference#type-only-imports-and-exports) | 类型依赖可以与运行时依赖分开,但要明确两者的区别。启发是:需要一个类型,不一定需要运行其所属模块;是否采用该机制留给后续任务。 |
| **Go** | 包顶层不能任意写执行语句,但变量初始化可以调用函数,如 `var current = readConfig()`;还可以写自动执行的 `func init()`。程序启动时先初始化依赖包,再运行入口。[语言规范](https://go.dev/ref/spec#Package_initialization) | 顶层结构规整,仍可自动准备共享状态;副作用仍可能藏在变量初始化里。说明**“顶层只准声明”并不等于“没有自动执行”**。 |
| **Rust** | 模块是定义项的容器,没有普通的“导入即执行模块体”机制。直接在模块体中写 `read_config();` 不合法;普通 `static` 的初始化表达式要求能在编译期求值,不能直接读取运行时配置文件。可把读取动作放入函数,由调用者调用。[模块规则](https://doc.rust-lang.org/reference/items/modules.html)、[静态项规则](https://doc.rust-lang.org/reference/items/static-items.html) | 普通模块使用不会自动读取配置,启动动作更明确;调用者需要组织初始化。对应我们的 **A** 方向。 |
还有一点需要澄清:**A、B 两种模块都可以导出 `create(config)` 这样的普通函数,用它创建多个独立状态。** 只有当我们希望语言本身支持“创建模块实例、给实例连接依赖”时,才需要之前的 **C**;普通对象或函数已经能解决不少隔离需求。
因此当前三个选择保持不变:
- **A.显式调用**:模块提供定义;读取配置等运行时动作放入函数,由调用者启动。还需明确允许哪些顶层初始化表达式,才能保证声明中不会暗藏这些动作。
- **B.自动初始化**:模块可以带运行时初始化,使用模块时由系统安排执行;触发时机、次数和失败处理分别在后续任务决定。
- **C.显式模块实例**:导入定义后,由调用者创建带状态的模块实例;语言需要提供实例创建和依赖连接规则。
**我暂建议 A**:它让初始化失败发生在明确的调用处,也方便调用者决定状态如何共享。代价是程序需要多写一些启动与状态传递代码。如果你希望模块直接提供“准备好的共享服务”,B 会更顺手。这个建议尚未成为决定。
## 讨论来源
《语言设计 11:模块与程序执行》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,163 +0,0 @@
# 12 · 并发与异步
0/21 项已确认。当前推进 12-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 12-01 可变状态的共享规则
- [ ] 待讨论
确定普通可变值默认能否跨执行单位共享,以及允许共享的条件、违规后果。
## 12-02 数据跨边界传递
- [ ] 待讨论
确定参数、闭包捕获和消息采用复制、共享还是转移;明确嵌套引用及发送后继续访问的规则。依赖:对象与类型模型。
## 12-03 支持哪些执行单位
- [ ] 待讨论
确定是否提供线程、任务、协程或其他单位,明确各自能力及相互关系;不支持的单位也明确记录。
## 12-04 并行能力与边界
- [ ] 待讨论
确定哪些执行单位允许同时执行,哪些必须串行,以及程序能否依赖特定并行度。
## 12-05 创建、启动与生命周期
- [ ] 待讨论
确定工作何时启动、由谁管理、能否脱离创建者,以及作用域退出时如何处理未结束工作。
## 12-06 等待结果与失败传播
- [ ] 待讨论
确定等待一次或多次的行为、结果保存方式、未观察错误,以及多个子任务失败时的处理。依赖:异常与错误处理。
## 12-07 异步暂停与调用规则
- [ ] 待讨论
确定暂停是否显式、哪些上下文允许等待、可等待值的协议,以及普通调用与异步调用如何组合。依赖:函数与定制协议。
## 12-08 阻塞操作与异步的衔接
- [ ] 待讨论
确定同步 I/O、阻塞调用进入任务后的行为,以及是否提供转交线程等机制;明确误用后果。
## 12-09 取消与清理
- [ ] 待讨论
确定谁能请求取消、何时生效、能否屏蔽、如何观察取消,以及清理代码能否继续等待。依赖:资源管理、异常与错误处理。
## 12-10 超时与竞争等待
- [ ] 待讨论
确定超时是否取消工作、多路等待如何选胜者、同时完成如何处理,以及落选工作由谁回收。
## 12-11 调度与进展保证
- [ ] 待讨论
确定切换时机、协作式或抢占式约束、饥饿与公平性保证,以及执行单位能否迁移线程。
## 12-12 显式同步工具
- [ ] 待讨论
确定支持哪些锁、条件变量或信号量;明确重入、持锁暂停、取消等待和死锁的处理。
## 12-13 消息与通道
- [ ] 待讨论
确定是否支持消息机制;若支持,明确顺序、容量、背压、关闭、发送失败与接收结束的行为。
## 12-14 原子操作
- [ ] 待讨论
确定是否提供原子值及操作;若支持,明确操作范围、内存顺序和不支持的值类型。依赖:类型系统、数值与基础类型。
## 12-15 内存可见性与数据竞争
- [ ] 待讨论
明确同步建立什么可见性保证,以及未同步冲突访问是被拒绝、报错,还是具有某种规定的后果。
## 12-16 运行时自身的并发保护
- [ ] 待讨论
明确 GIL、对象级锁、隔离堆等候选机制必须保障什么,以及哪些实现选择可以保留自由;区分运行时安全与用户操作的原子性。依赖:运行时实现。
## 12-17 复合操作与重入
- [ ] 待讨论
明确单次安全操作能否组合成事务;规定回调、用户代码重入及跨多个值维护不变量时的保证。依赖:对象与定制协议。
## 12-18 资源与对象生命周期的交互
- [ ] 待讨论
明确任务退出、共享对象存活、资源关闭、终结处理与并发访问发生冲突时的规则。依赖:内存管理、资源管理。
## 12-19 外部代码与外来线程
- [ ] 待讨论
确定是否支持外部阻塞调用、外来线程进入语言和跨边界回调;明确同步责任及违规后果。依赖:外部接口、运行时实现。
## 12-20 执行上下文与局部状态
- [ ] 待讨论
确定线程局部、任务局部或等价机制是否存在,以及创建、迁移、取消时如何继承和释放。
## 12-21 跨分类一致性与最终验收
- [ ] 待讨论
用代表性场景检查上述规则能否共同成立,解决跨分类冲突,并形成最终规则摘要。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **12-01:普通可变数据能否跨执行单位直接共享,以及谁负责保证安全。** 这里允许不同边界采用不同规则,例如同一线程内的任务与可能并行的工作之间,可以有不同限制。
现有语言提供了四种有用的参考:
| 语言 | 规则与具体行为 | 好处、代价及启发 |
|---|---|---|
| **Python** | 同一解释器中的线程可以持有同一个列表、字典等对象。程序员可以用同一把锁保护“检查库存+扣减”。GIL 是 CPython 的实现机制;CPython 也有自由线程构建,内置容器的内部保护不能替代业务同步。[官方文档](https://docs.python.org/3.14/howto/free-threading-python.html) | 共享方便,但同步责任主要由程序员承担。启发:必须分别说明“对象内部安全”和“整段业务操作正确”。 |
| **JS/TS** | 同一 JS agent 内的异步工作可以访问同一个普通对象;跨 `await` 的操作仍可能交错。浏览器 Worker 之间通常通过消息复制或转移数据,不能直接共享普通对象;共享内存可显式使用 `SharedArrayBuffer`,配合原子操作。Worker 属于宿主提供的机制。[ECMAScript](https://tc39.es/ecma262/multipage/executable-code-and-execution-contexts.html#sec-agents)、[HTML 标准](https://html.spec.whatwg.org/multipage/workers.html) | 普通对象跨 Worker 的隔离减少直接共享风险,但有传递成本;显式共享内存增加复杂性。启发:可以采用“局部共享、跨并行边界默认隔离”。 |
| **Go** | goroutine 可以直接共享变量,但冲突访问必须同步。例如两个 goroutine 访问同一库存,可以用 `sync.Mutex` 保护;也可以让一个 goroutine 管理库存,其他工作通过 channel 请求。channel 不会自动剥夺发送者对所发送指针的访问权。[内存模型](https://go.dev/ref/mem)、[Effective Go](https://go.dev/doc/effective_go#sharing) | 共享内存和消息协作都方便;“发送后不再访问”通常需要程序员遵守。启发:提供消息工具与强制隔离是两个不同决定。 |
| **Rust** | 安全 Rust 通过所有权、借用和跨线程约束限制危险共享。共享修改库存可以使用 `Arc<Mutex<库存>>`;访问锁内数据需要取得锁守卫,普通 `Rc` 则不能直接跨线程传递。[官方教程](https://doc.rust-lang.org/book/ch16-03-shared-state.html) | 能提前拒绝部分危险访问,但规则和类型更复杂,也仍可能死锁或出现业务竞态。启发:可以把“满足条件才能共享”做成可检查的规则。 |
其中,**TS 没有独立的并发运行时**,实际行为依赖生成的 JS 和宿主环境。`readonly` 等静态限制也不会自动提供运行时冻结或同步保护。[TS 对象类型文档](https://www.typescriptlang.org/docs/handbook/2/objects.html#readonly-properties)
回到我们的三个候选方向:
- **A.默认允许共享,使用者负责同步。** 接近 Python 线程、Go 共享变量的使用方式。表达直接,但容易遗漏同步。
- **B.跨并行边界默认隔离,通过消息协作。** 可以参考浏览器 Worker 的普通对象边界。管理责任清楚,但必须明确复制、转移和响应规则。
- **C.只有满足检查规则才能共享或转移。** 可以参考 Rust。能更早发现危险用法,但依赖更完整的别名、所有权或运行期检查设计。
**我的暂定建议是优先考虑 B**:先让普通可变数据跨并行边界的归属清楚,再讨论是否提供显式共享机制。它也有成本:频繁交换大型数据时,需要认真设计传递方式。如果我们的目标更偏向细粒度共享和底层控制,C 值得优先考虑。这个建议尚未成为决定。
你更倾向 **A 的直接共享、B 的默认隔离,还是 C 的受检查共享**?如果你的偏好是“同一线程内共享、跨并行边界隔离”,也可以直接这样确定方向。
## 讨论来源
《语言设计 12:并发与异步》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,175 +0,0 @@
# 13 · 外部交互
0/22 项已确认。当前推进 13-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 13-01 外部交互的默认安全边界
- [ ] 待讨论
明确普通代码默认能否接触外部地址、依靠谁保证安全,以及是否提供不安全入口。
## 13-02 I/O 的基础接口单位
- [ ] 待讨论
明确字节、文本、流、消息之间的基础关系,以及哪些能力由上层封装提供。
## 13-03 I/O 的执行方式
- [ ] 待讨论
明确阻塞、异步是否支持,默认方式,以及超时和取消对操作结果的影响。依赖【并发与执行模型】。
## 13-04 文件与路径接口
- [ ] 待讨论
明确路径如何表示,文件如何打开,以及创建、覆盖、追加和访问失败的规则。
## 13-05 网络接口范围
- [ ] 待讨论
明确本轮支持哪些通信抽象,以及连接、监听、半关闭和断开的基本行为;明确不支持的能力。
## 13-06 I/O 的部分成功与结束状态
- [ ] 待讨论
明确短读、短写、EOF、暂不可用、部分完成后失败如何区分和报告。
## 13-07 外部资源的关闭规则
- [ ] 待讨论
明确谁负责关闭、重复关闭和关闭后使用的行为,以及自动清理是否保证及时发生。依赖【资源管理】。
## 13-08 FFI 支持范围与调用约定
- [ ] 待讨论
明确支持哪些外部调用目标、是否支持向外导出函数,以及变参等特殊接口的支持边界。依赖【目标平台与执行方式】。
## 13-09 外部声明与符号解析
- [ ] 待讨论
明确外部函数如何声明、库和符号何时解析,以及缺失、重复或声明不匹配时的行为。依赖【模块系统】。
## 13-10 基础类型映射
- [ ] 待讨论
明确整数宽度与符号、浮点、布尔、空值、指针的对应关系,以及不能表示时的处理。
## 13-11 复合类型与数据布局
- [ ] 待讨论
明确结构体、数组、枚举等如何传递,哪些布局可以公开,以及大小、对齐、填充如何确定。依赖【类型与数据模型】。
## 13-12 字符串与编码
- [ ] 待讨论
明确编码、长度单位、终止符、内嵌零值和非法编码的处理,以及转换责任。
## 13-13 缓冲区访问契约
- [ ] 待讨论
明确只读或可写、连续性、复制或共享、借用期限,以及外部保存地址时的要求。依赖【内存管理】。
## 13-14 内存所有权与分配器配对
- [ ] 待讨论
明确跨边界分配、转移、释放的责任,以及由哪个分配器或释放函数回收。
## 13-15 不透明句柄与运行时值映射
- [ ] 待讨论
明确外部资源或复杂值如何表示,是否允许直接暴露语言内部值,以及无效句柄的处理。依赖【对象与类型模型】。
## 13-16 回调的注册与生命周期
- [ ] 待讨论
明确回调如何传递、保存、注销,如何保活其上下文,以及注销后迟到调用的处理。
## 13-17 外部线程与重入
- [ ] 待讨论
明确哪些线程能进入运行时、是否允许嵌套调用,以及违反约束时的行为。依赖【并发与运行时】。
## 13-18 跨边界错误传递
- [ ] 待讨论
明确错误码、异常和取消怎样转换,是否允许栈展开越过边界,以及失败时如何清理。依赖【错误机制】。
## 13-19 库卸载与运行时退出
- [ ] 待讨论
明确仍有句柄、回调或进行中的操作时,能否卸载库或退出运行时,以及处理顺序。
## 13-20 跨进程与跨运行时的数据交换
- [ ] 待讨论
明确是否提供通用交换格式或协议;若提供,规定支持的值、身份或引用关系及转换失败行为。
## 13-21 平台差异与接口版本兼容
- [ ] 待讨论
明确哪些保证跨平台成立,不兼容布局或库版本如何识别,以及无法验证时由谁承担责任。
## 13-22 跨分类一致性验收
- [ ] 待讨论
用文件读取、外部缓冲区、外部返回资源、异步回调四类完整场景,检查上述规则与相关分类是否一致,消除阻塞和冲突。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍是 **13-01:外部交互的默认安全边界**。要决定的是:普通代码调用外部库时,内存有效性由谁保证,哪些责任交给底层绑定作者。
仍用同一个例子:把字节缓冲区交给外部库,外部库可能立即读取,也可能保存地址、稍后读取。
**Python:底层接口可以绕过通常的安全保护。**
以 CPython 提供的 `ctypes` 为例,可以创建缓冲区、传递指针、声明外部函数参数类型。但声明参数类型不等于证明外部访问安全;错误的地址或使用方式可能导致内存损坏、进程崩溃。这种方式方便直接接入本地库,代价是调用者或封装作者承担较多责任。对我们的启发是:**“能声明函数签名”与“能保证生命周期正确”需要分开设计。** [官方文档](https://docs.python.org/3/library/ctypes.html)
**JS:这里以 Node.js 的原生扩展机制为例,属于宿主提供的能力。**
Node-API 可以把外部内存包装成 JS 可访问的 `Buffer`,并关联回收时调用的清理回调。例如,扩展分配一块内存,再将它暴露给 JS;扩展仍需正确安排其有效期和释放。好处是普通 JS 使用者可以通过熟悉的值访问数据;代价是底层扩展仍然承担内存责任,不同宿主的支持也可能不同。启发是:**上层接口可以简单,但底层责任必须明确。** [Node-API 文档](https://nodejs.org/api/n-api.html#napi_create_external_buffer)
**TS:增加静态检查,不另提供一套运行时内存保证。**
例如,把接口声明成接受 `Uint8Array`,可以帮助检查调用处的类型,却不能证明原生扩展没有保存过期地址。类型注解会被擦除,运行时行为仍取决于 JS 宿主和扩展。好处是提前发现一部分接口误用;局限是**类型声明本身不能验证外部实现是否守约**。这也提醒我们,方案 B 不能只停留在“多写几个类型”。[TS 官方说明](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types)
**Go:cgo 明确限制跨边界指针,并提供复制转换。**
例如,`C.CBytes(data)` 会复制字节到 C 堆,由调用者安排释放;直接向 C 传递 Go 内存则受指针和固定内存规则约束,不能默认让 C 无限期保存。好处是既支持复制也支持受约束共享;代价是绑定作者需要理解两边的内存规则。启发是:**复制与共享可以并存,但应该让使用者知道自己选择了哪种契约。** [cgo 官方文档](https://pkg.go.dev/cmd/cgo)
**Rust:用显式不安全边界承接无法自动检查的责任,再封装安全接口。**
例如,一个接受“地址+长度”的不安全外部函数,可以被包装成接受字节切片的安全函数;包装作者必须确保长度正确、调用期间内存有效,并确认外部不会在约定期限外使用地址。好处是普通调用者不必反复证明这些条件;代价是包装必须正确,否则安全接口也可能失去保证。启发是:**区分“使用接口的人”与“为接口安全性负责的人”。** [Rust 官方说明](https://doc.rust-lang.org/stable/book/ch20-01-unsafe-rust.html)
据此,我们的三个选项仍然是:
| 方案 | 普通使用者默认面对的接口 | 主要代价 |
|---|---|---|
| **A:直接控制地址** | 自己保证地址、长度和生命周期 | 调用方便直接,但内存责任分散到调用处 |
| **B:通过契约绑定** | 使用声明好借用、保留或所有权转移规则的接口 | 绑定设计更复杂,需要信任绑定和外部库守约 |
| **C:主要使用副本与句柄** | 交换独立数据,通过受管理操作访问资源 | 复制与转换开销,部分底层接口较难表达 |
**我仍建议 B,并保留显式不安全入口供底层绑定使用。** 这样可以把责任集中在绑定层,缓冲区采用复制还是共享则留给 13-13 决定。其保证以绑定和外部库遵守契约为前提;同进程原生代码违约时,不能承诺一定转成可捕获错误,也可能损坏或终止进程。
## 讨论来源
《语言设计 13:外部交互》,整理日期 2026-10-28。章节编号沿用原清单。
@@ -1,136 +0,0 @@
# 14 · 反射与表示
0/16 项已确认。当前推进 14-01;本分类尚未完成选择。
本页整理原讨论的问题、验收要求和候选方案。勾选代表讨论中已确认的规则,不代表编译器或运行时已经实现。
## 14-01 运行时反射的基本承诺
- [ ] 待讨论
确定普通程序默认可以观察哪些信息,哪些需要显式声明,以及元数据是否可以被裁剪。
## 14-02 类型查询与类型描述的身份
- [ ] 待讨论
确定能查询什么、返回什么,如何比较查询结果;明确静态类型、运行时实际类型与抽象接口之间的关系。依赖【类型系统】【对象与类型模型】。
## 14-03 元数据的内容与可用性
- [ ] 待讨论
确定字段名、参数、注解、文档、源码位置等分别是否可查询,以及信息缺失、不同构建配置下的行为。
## 14-04 动态读取成员
- [ ] 待讨论
确定按名称查找的对象范围、查找规则,以及成员不存在、不可访问、读取触发计算时的行为。依赖【对象与类型模型】【定制协议】。
## 14-05 动态修改成员
- [ ] 待讨论
确定是否允许新增、写入、删除成员,以及只读值、类型不匹配和修改失败的行为。依赖【类型系统】【对象与类型模型】。
## 14-06 动态调用
- [ ] 待讨论
确定是否支持通过反射调用函数或成员,以及参数匹配、默认参数和调用失败的规则。依赖【函数与调用】。
## 14-07 反射权限与封装
- [ ] 待讨论
确定查看描述、读取、修改、调用分别需要什么权限,以及跨模块和私有成员的边界。依赖【模块与封装】【安全模型,如有】。
## 14-08 调试表示
- [ ] 待讨论
确定默认调试文本、定制方式、信息隐藏要求,以及是否要求稳定、唯一或可重新解析。
## 14-09 用户字符串与格式化
- [ ] 待讨论
确定用户文本与调试表示的关系、默认回退、格式参数和转换失败行为。依赖【定制协议】【格式化语法】。
## 14-10 表示过程的执行边界
- [ ] 待讨论
确定生成文本能否执行用户代码、产生副作用或抛错,以及循环、递归深度、长度和并发修改的处理。
## 14-11 可序列化的值与定制入口
- [ ] 待讨论
确定哪些值可以保存,如何处理函数、类型描述、外部资源及不支持的值;明确是否依赖反射。依赖【值与资源模型】【定制协议】。
## 14-12 数据格式、模式与版本兼容
- [ ] 待讨论
确定语言承诺与格式库的分工,以及名称、缺失字段、未知字段和版本变化的处理。
## 14-13 反序列化与有效状态恢复
- [ ] 待讨论
确定如何选择目标类型、是否调用构造或校验逻辑,以及无效数据、部分恢复失败的行为。依赖【类型系统】【构造与不变量】【资源管理】。
## 14-14 身份、共享引用与循环的保存
- [ ] 待讨论
确定保存树还是对象图、何时保留共享关系,以及循环、不支持的引用和恢复后身份的语义。依赖【身份与相等性】【内存与引用模型】。
## 14-15 反序列化的权限与资源限制
- [ ] 待讨论
确定输入能否指定类型或执行定制代码,以及不可信数据、超限输入和错误报告的默认规则。依赖【错误处理】【安全模型,如有】。
## 14-16 跨分类一致性验收
- [ ] 待讨论
用一组完整例子核对反射、权限、表示、恢复与相关分类的规则;记录并解决冲突及阻塞。
## 当前问题的方案与语言对照
以下沿用原讨论材料;建议和参考语言的做法均不自动成为本语言规则。
当前仍在讨论 **14-01:普通程序拿到一个陌生值,默认能查询多少结构信息?** 例如,检查工具能否直接知道 `Account` 有 `name` 和 `token` 两个字段?
下面是相关语言的对照。取舍和启发是我们的分析,不代表这些语言的历史设计动机。
| 语言 | 规则与具体行为 | 好处、代价及对我们的启发 |
|---|---|---|
| **Python** | 许多普通实例可用 `vars(x)` 取得属性字典;没有 `__dict__` 的值会报 `TypeError`。`dir(x)` 可以被定制,也不保证列出完整、准确的属性集合。[官方文档](https://docs.python.org/3/library/functions.html#vars) · [dir](https://docs.python.org/3/library/functions.html#dir) | 通用检查很方便,但“列出名称”“实例存储”“所有可访问属性”并不相同。启发:不能只设计一个含糊的“查看所有字段”操作。 |
| **JS** | `Reflect.ownKeys({name: "林"})` 返回 `["name"]`,枚举自身属性键,包括不可枚举属性和 Symbol。`#token` 私有元素不属于普通属性,不会这样列出。[ECMAScript 规范](https://tc39.es/ecma262/multipage/reflection.html#sec-reflect.ownkeys) · [私有元素](https://tc39.es/ecma262/2024/#sec-privateelement-specification-type) | 对普通属性默认可检查,适合动态工具;代价是这些名称成为可观察行为。启发:可以对普通数据开放反射,同时为私有状态设置独立边界。 |
| **TS** | 类型注解、接口等静态信息通常被擦除。例如 `interface Account { name: string }` 不会自动产生可供运行时查询的接口描述;运行时仍使用 JS 的机制。[官方文档](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types) | 静态检查不必带来完整运行时元数据成本;代价是运行时校验、按接口生成表单等功能需要额外描述。启发:查询对象现有属性与查询静态类型定义必须分开。 |
| **Go** | 标准库 `reflect` 能查询结构体字段,包括未导出字段的描述。但取得描述不等于取得全部操作权限:对来自未导出字段的值调用 `Interface()` 会 panic,修改还受 `CanSet()` 等条件限制。[官方文档](https://pkg.go.dev/reflect) | 通用工具能使用结构元数据,但必须处理权限和操作前提。启发:**知道字段存在、读取字段值、修改字段是不同能力**。 |
| **Rust** | 稳定版标准库的 `Any` 支持类型识别及向具体类型转换,例如 `x.is::<Account>()`;它不提供通用字段枚举。`#[derive(Debug)]` 可以生成调试输出代码,这本身不等于开放字段反射。实验性的编译期反射另当别论。[Any 文档](https://doc.rust-lang.org/std/any/index.html) · [derive 文档](https://doc.rust-lang.org/book/appendix-03-derivable-traits.html) | 可以按需提供能力,避免要求所有值都携带通用字段描述;代价是通用工具需要显式适配或生成代码。启发:**调试打印可以独立于运行时结构反射存在**。 |
这些实践不能直接一一对应我们的选项。一门语言可以同时采用不同规则,例如:普通属性默认可枚举,静态接口被擦除,调试表示又通过显式生成提供。
回到 **14-01**,我们的三个方向仍是:
- **A.不保证通用结构反射**:陌生值只能通过主动提供的接口接受检查。语言承诺较少,通用工具需要更多适配。
- **B.显式参与结构反射**:只有声明参与或提供描述的值,才能通过统一机制检查。暴露范围可控,但需要维护描述。
- **C.默认提供结构反射**:对适用的结构化值,默认保证可以查询结构;访问权限另外规定。通用工具方便,但元数据需要成为语言承诺的一部分。
我仍建议先考虑 **B**:它让我们能够分别决定反射、调试和序列化的能力。不过语言用途尚未确定,这只是建议,尚未采用。
如果选 B,我建议同时确定两个验收边界:**“没有反射描述”必须区别于“有描述但没有字段”;已经承诺可查询的描述,发布构建不能静默裁剪。** 权限规则留给 14-07。
你倾向 **A、B、C** 中哪一个?如果选 B,是否一并接受上面两个边界?
## 讨论来源
《语言设计 14:反射与表示》,整理日期 2026-10-28。章节编号沿用原清单。
-24
View File
@@ -1,24 +0,0 @@
# 从浏览器排版到 Pretext:高性能文本布局源码教程
这份教程回答一个问题:**Pretext 为什么能很快地算出一段文字的换行与高度?**
读完后,你应该能解释它的性能来源,沿着函数调用读懂核心源码,并知道如何在自己的界面中使用它。只需要了解 JavaScript 函数、数组和基本 DOM;字体、字素、回流等概念会逐步解释。
分析基于上游提交 [`0d102a5cd933611e86a7e60bda7b4baa5995c6b0`](https://github.com/chenglou/pretext/tree/0d102a5cd933611e86a7e60bda7b4baa5995c6b0),研究日期为 2026 年 10 月 28 日。本文描述这个版本,不把早期实现或未来版本的行为混在一起。源码链接指向本次研究的上游版本;实验脚本和记录随文保存在本站。
## 阅读路线
1. [第一章:为什么算文字高度也会慢](/docs/pretext/01-chapter)
2. [第二章:先看懂 prepare 与 layout 的分工](/docs/pretext/02-chapter)
3. [第三章:分析阶段如何把文字变成可布局的分段](/docs/pretext/03-chapter)
4. [第四章:测量缓存如何减少浏览器工作](/docs/pretext/04-chapter)
5. [第五章:换行算法为什么能变成数值循环](/docs/pretext/05-chapter)
6. [第六章:为什么只求高度比拿到每行文字便宜](/docs/pretext/06-chapter)
7. [第七章:如何避免长文本拖垮算法](/docs/pretext/07-chapter)
8. [第八章:准确性怎样影响性能设计](/docs/pretext/08-chapter)
9. [第九章:把它接进界面和虚拟列表](/docs/pretext/09-chapter)
10. [第十章:亲自验证性能机制](/docs/pretext/10-chapter)
11. [第十一章:按什么顺序读源码](/docs/pretext/11-chapter)
12. [第十二章:把这些设计迁移到自己的代码](/docs/pretext/12-chapter)
第一次阅读建议顺着读到第六章,再看第九章。第四、七、八章适合对照源码细读;第十章提供实验与复现方法。
-45
View File
@@ -1,45 +0,0 @@
# 第一章:为什么算文字高度也会慢
### 1.1 字符数不能直接决定高度
假设一段话放在宽 320px 的聊天气泡中。它会占几行,至少取决于文字内容、字体、字号、可用宽度、字间距和换行规则。
同样长度的 `iiii` 和 `WWWW` 宽度可能差很多。中文通常能在许多字之间断行,英文更常在空格处换行;标点又有自己的限制。所以“字符数乘平均字宽”只能估计,无法可靠地预测真正的行数。
传统方案是让浏览器先排一遍,再读取结果:
```js
element.textContent = text
element.style.width = '320px'
const height = element.getBoundingClientRect().height
```
写入文字或宽度后,之前的排版结果可能失效。此时立刻读取几何信息,浏览器必须先保证这个结果是最新的;如果有待处理的样式或布局工作,就可能同步完成这些工作。
这就是这里关心的“回流”或“布局”:确定元素的尺寸与位置。**一次几何读取不一定总触发昂贵回流,关键在于读取时是否存在需要更新的布局。**
### 1.2 写与读交替,会放大成本
```js
for (const row of rows) {
row.element.style.width = width + 'px'
heights.push(row.element.getBoundingClientRect().height)
}
```
这个循环可能不断要求浏览器“写一点、立即排好、读出来”。把写操作与读操作分批,通常能改善它。但虚拟列表还有另一个问题:大多数行根本没有挂载,无法直接读取那些行的 DOM 高度。
Pretext 的切入点是:**如果能在应用代码中算出文本行数,就能在创建那些 DOM 之前知道高度。** 浏览器最终仍然负责把字画出来。
### 1.3 区分布局和绘制
把一段文字显示到屏幕上,可以粗略拆成:
```text
文字与样式 → 确定字形和宽度 → 决定换行与位置 → 绘制文字
```
Pretext 借助 Canvas 的字体测量,自己完成一部分换行与布局计算。它不是一个自动让任意页面绘制都变快的开关,也不承担完整的浏览器 CSS 排版。
本章检查:如果宽度变化,文字本身的字体宽度一定要重新测量吗?通常不需要。变的是“每行能装多少”,这就是下一章的设计基础。
-61
View File
@@ -1,61 +0,0 @@
# 第二章:先看懂 prepare 与 layout 的分工
### 2.1 同一份输入,有不同的变化频率
聊天消息的文字和字体可能很久不变,但容器宽度会在拖动窗口时连续变化。把所有工作绑在一个函数里,每次 resize 都会重复做许多相同的事情。
Pretext 将它拆成两步:
```js
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('Hello 世界,今天一起读源码。', '16px Arial')
const narrow = layout(prepared, 200, 24)
const wide = layout(prepared, 400, 24)
```
这里 `200`、`400` 是内容宽度,`24` 是调用者指定的每行高度,单位都是 CSS 像素。
| 阶段 | 输入 | 主要工作 | 什么时候重做 |
| --- | --- | --- | --- |
| `prepare()` | 文字、字体、选项及语言环境 | 分析断行机会、测量、记录修正数据 | 相关输入变化 |
| `layout()` | prepared、宽度、行高 | 根据已知宽度决定换行并计数 | 容器宽度或行高变化 |
prepared 可以理解为“已经整理好的排版材料”。它与容器宽度无关,但并不包含任意宽度下现成的布局答案。
### 2.2 沿源码走一遍
打开 [`src/layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts),定位 `prepareInternal()`:
```text
读取引擎规则与语言
→ analyzeText(...)
→ measureAnalysis(...)
→ prepared handle
```
再定位 `layout()`,其核心逻辑是:
```js
const lineCount = countPreparedLines(internal, normalizedWidth)
return { lineCount, height: lineCount * lineHeight }
```
这是省略类型与内部转换的源码摘义。它没有逐行调用 Canvas,也没有先创建 DOM 再读取高度。
### 2.3 一段文字被布局多次,省下多少工作
用 `P` 表示准备成本,用 `L` 表示一次布局成本,一共布局 `k` 次:
```text
复用 prepared:P + k × L
每次重新 prepare:k × (P + L)
```
两种写法相差 `(k - 1) × P`。这解释了为什么使用方式会直接决定收益:在 resize 回调中重新 prepare,会让原本可以省掉的分析工作再次发生。
另外,重复 prepare 可能命中测量缓存,但仍要分析文本、组装新的 handle。它依然比直接复用已有 handle 做得更多。
本章检查:`layout()` 是 O(1) 吗?不是。它通常还要扫描分段。快来自减少每一步要做的工作,而不是完全不看文本。
-54
View File
@@ -1,54 +0,0 @@
# 第三章:分析阶段如何把文字变成可布局的分段
### 3.1 三个容易混淆的单位
| 名称 | 含义 | 例子 |
| --- | --- | --- |
| UTF-16 码元 | JavaScript 字符串索引使用的存储单位 | 一些 emoji 的 `length` 大于 1 |
| 字素簇,简称字素 | 用户通常感知的一个文字单位 | 字母加组合音标、由多个码点组成的 emoji |
| 分段 segment | Pretext 为测量与换行建立的单位 | 一个词、一段文本、空格或特殊控制段 |
分段并不总等于单词。字素也不总等于一个 JavaScript 字符。理解这两个区别,后面的超长单词断行和 emoji 处理才容易读懂。
### 3.2 先找哪里允许断行
对 `hello world`,可以先想象成:
```text
['hello', ' ', 'world']
```
这个示意足以解释普通英文,但真实的中文标点、日文、泰文、软连字符和不可断空格需要更多规则。
[`analysis.ts` 的 `analyzeText()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts)先处理空白与源文本,再按引擎选择扫描器,最后形成分段及标志。其中主要的引擎对应关系是:
| 浏览器引擎 | 主要实现入口 |
| --- | --- |
| Blink,例如 Chrome | `line-breaks.ts` 中的 `getBlinkLineBreaks()` |
| WebKit,例如 Safari | `line-breaks.ts` 中的 `getWebKitLineBreaks()` |
| Gecko,例如 Firefox | `gecko-line-breaks.ts` 中的 `getGeckoLineBreaks()` |
选择由 `measurement.ts` 的 `getEngineProfile()` 完成,并缓存 profile。未知引擎采用 Blink 的模型。
### 3.3 表驱动扫描是什么意思
可以把扫描器想象成一个不断读取字符的机器:
```text
当前状态 + 当前字符类别 → 新状态 + 是否出现边界
```
字符属于哪一类、某种状态遇到某一类应该怎么走,都放在表里。运行时主要是读字符、查类别、查转换表、更新状态。
规则数据来自对应引擎及其 Unicode/ICU 数据,保存在 [`src/generated/`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/generated)。数据经过压缩;页面按需解开自己的引擎所用的表。这样能把复杂规则变成较直接的扫描,但也增加了包体积与初始化成本。这是有代价的工程选择。
[`graphemes.ts` 的 `findGraphemeEnds()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts)也采用类似方式寻找字素边界。这个版本不是对所有文字都调用 `Intl.Segmenter`:词典分词主要留给泰语、老挝语、高棉语、缅甸语等特定文字的运行片段。
### 3.4 为什么空白处理要先做
`white-space: normal` 下,连续普通空格通常被折叠;`pre-wrap` 则保留空格、换行与制表符的相关行为。若测量的是原始字符串,绘制时浏览器却折叠了它,预测自然会错。
所以分析阶段必须建立与目标 CSS 相符的文本视图。这个版本对孤立 `\r` 有明确限制:`pre-wrap` 下绘制前也应把它规范成 `\n`,详见根目录 README。
本章检查:为什么不能直接 `text.split(' ')`?因为断行机会远不止空格,而且许多语言没有用空格分隔每一个词。
-56
View File
@@ -1,56 +0,0 @@
# 第四章:测量缓存如何减少浏览器工作
### 4.1 Canvas 提供的是字体测量能力
浏览器已经知道如何选择字体、塑造字形、计算宽度。Pretext 通过:
```js
context.font = '16px Arial'
const width = context.measureText('hello').width
```
获得字体事实。它优先创建 `OffscreenCanvas`;没有该能力时,创建不挂载到文档的普通 Canvas。
`OffscreenCanvas` 这个名字不表示测量会自动进入另一个线程。代码在哪里调用,工作就在哪里执行;想在 worker 中使用,需要应用自己安排,还有第八章的准确性限制。
### 4.2 两层复用各解决什么问题
第一层是应用保留 prepared。第二层在库内部,以字体等测量条件组织缓存,再按分段文本查找:
```text
字体 A 的缓存
'hello' → 测量结果
'world' → 测量结果
字体 B 的缓存
'hello' → 另一份测量结果
```
在 [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts) 搜索 `getSegmentMetrics()`,会看到这一核心表达式:
```js
measurement.metrics.get(seg) ?? addMetrics(...)
```
命中就读取结果;未命中才调用 Canvas 并存入缓存。相同词语出现在不同消息里,也有机会复用,不要求整段消息完全相同。
实际缓存不只有一个数字。`SegmentMetrics` 还会保留拆词适配、emoji 统计或其他修正所需的信息。语言变化和字距造成的 shaping 模式变化也受到专门处理;不能把它简单描述成全局 `(text, font)` 永久不变的表。
### 4.3 命中缓存后,连字体设置都尽量省
`getFontMeasurement()` 先找到字体对应的数据;真正测量时才通过 `getContext()` 设置 Canvas 的字体和测量模式。
为什么把两者拆开?富文本可能反复查询许多 item 的字体,但这些 item 的文字全都命中缓存。既然无需测量,也就不必为了查询而触碰 Canvas。
源码同时照顾了字体后加载等浏览器行为,因此不是无条件地“字体字符串相同就永不赋值”。读这一段时应一起看注释,而不要只把它当成普通 setter 缓存。
### 4.4 prepared 与全局测量缓存的寿命不同
全局缓存为了复用测量,prepared 为了一段具体文本的后续布局。二者不应混为一谈。
`MAX_CACHED_SEGMENTS = 1 << 18`,即 262,144。所有字体缓存的分段数超过边界后,下一次准备开始时会清理测量缓存。它不是每次插入都逐出一个条目的 LRU,因此单次准备期间可能超过阈值。
已经创建的 handle 保留自己的布局数据,不会因为这次缓存清理突然失效。不过,如果实际字体或语言变了,旧 handle 也不会自动更新,仍需要重新准备。
本章检查:清理全局缓存会自动把旧 prepared 换成新字体宽度吗?不会。缓存清理和重新创建 handle 是两步。
-60
View File
@@ -1,60 +0,0 @@
# 第五章:换行算法为什么能变成数值循环
### 5.1 用一组虚构宽度手算
下面只是教学数据,不代表某种真实字体:
```text
分段:hello 空格 world 空格 again
宽度: 40 4 45 4 42
可用宽度:90
```
从左到右累计:
| 操作 | 当前宽度 | 决定 |
| --- | ---: | --- |
| 放入 hello | 40 | 留在第一行 |
| 放入空格 | 44 | 继续 |
| 放入 world | 89 | 继续 |
| 尝试后面的空格 | 93 | 第一行结束,行尾空格按规则处理 |
| 放入 again | 42 | 第二行 |
最终两行;若行高是 24px,高度就是 48px。
### 5.2 核心思路简单,实现处理边界
[`line-break.ts` 的 `countPreparedLines()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts)为普通文本专门实现数值循环。以下是教学版,不是可替代源码的完整算法:
```js
for (const segment of segments) {
if (currentWidth + segment.width <= availableWidth) {
currentWidth += segment.width
} else {
finishCurrentLine()
startNextLineWith(segment)
}
}
```
真实代码还处理行首空白、行尾修正、零宽空格、放不下一整个分段时的字素断行。关键是这些判断都基于提前准备的数据,不再重新问 Canvas 这个词有多宽。
### 5.3 为什么需要简单路径和完整路径
准备阶段设置两个相关标志:
- `simpleLineWalkFastPath`:适合简单逐行遍历。
- `simpleLineCountFastPath`:适合简单行数计算;某些边界仍可能回退处理。
普通文本、普通空格等可以走简单路径。保留空白、制表符和软连字符等内容需要更完整的规则,进入 `walkPreparedComplexLines()` 等路径。
源码在 `countPreparedLines()` 前的注释记录:完整 walker 每个分段的成本约为简单路径的 3~5 倍。**这是项目记录的局部测量依据,不是所有设备上的固定倍数。** 分开实现的目的,是让常见输入少支付复杂状态管理的成本。
### 5.4 高度计算为何不经过“每行对象”
仅求高度时,简单计数循环使用局部数值变量,直接累加行数,没有每行游标对象和逐行回调。`layout()` 最后仍会返回一个结果对象,所以准确说法是“避免每行分配”,而不是“零分配”。
这也是为什么一个看似更通用、更加抽象的 walker,未必适合最高频的行数路径。
本章检查:为什么文本支持更多规则,不一定让普通英文同样变慢?因为准备阶段能判断适用路径,复杂处理不必全部进入普通文本的热循环。
-52
View File
@@ -1,52 +0,0 @@
# 第六章:为什么只求高度比拿到每行文字便宜
### 6.1 计算结果越详细,需要的工作越多
假设一个段落有 50 行。高度只需要一个行数;返回每行文字则还要确定范围、切字符串、拼接内容并创建结果对象。这些临时对象最终还需要垃圾回收。
因此选择 API 时应先问:调用者实际要什么?
| 需求 | 准备方式 | 布局 API |
| --- | --- | --- |
| 高度、行数 | `prepare()` | `layout()` |
| 行数、最宽行 | `prepare()` | `measureLineStats()` |
| 每行范围与宽度 | `prepare()` | `walkLineRanges()` |
| 每行文字 | `prepareWithSegments()` | `layoutWithLines()` |
| 逐行取文字,可按需停止 | `prepareWithSegments()` | `layoutNextLine()` |
普通 `prepare()` 的最终 handle 不保留分段字符串;准备过程中仍会使用文字,不能因此说它完全没有字符串处理。
### 6.2 范围与文字分开生成
一行可以先表示为:
```text
从第几个 segment 的第几个字素开始
到第几个 segment 的第几个字素结束
这一行有多宽
```
这是 range。需要显示那一行时,再把 range 变成实际字符串,这一步叫 materialize,即生成具体内容。
`walkLineRanges()` 不生成行字符串,但公共回调接收的 range 及其游标仍然会分配对象。它比“每行完整文字”做得少,也不应被描述成零分配。
[`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts)还用 WeakMap 按 handle 缓存所需分段的字素结束位置。只有行边界切进一个分段内部、确实需要定位字素时,才会补上相关数据。
### 6.3 流式逐行适合什么情境
如果只显示前三行摘要,逐行 API 可以在取到所需行数时停止;如果每行可用宽度不同,也可以逐次传入不同宽度。
```js
let cursor
for (let i = 0; i < 3; i++) {
const line = layoutNextLine(preparedWithSegments, 320, cursor)
if (!line) break
console.log(line.text)
cursor = line.end
}
```
这个片段假设已经导入 `layoutNextLine` 并准备好 `preparedWithSegments`。它演示逐行读取,不包含省略号处理。高度用途直接选 `layout()` 会更合适。
本章检查:虚拟列表只想知道 10,000 条消息各有多高,是否需要为每条创建所有行字符串?不需要。
-38
View File
@@ -1,38 +0,0 @@
# 第七章:如何避免长文本拖垮算法
### 7.1 一次调用的成本也取决于字符串长度
假设为了知道单词的每个位置能否断开,依次测量:
```text
a
ab
abc
abcd
……
```
虽然只调用了 n 次 Canvas,总共提交的字符量却是:
```text
1 + 2 + … + n = n(n + 1) / 2
```
因此只数 API 调用次数还不够,也要数测量请求总共处理了多少文字。
### 7.2 96 个字素的边界
[`measurement.ts` 的 `getSegmentFit()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts)按引擎与输入情况使用字素求和、相邻组合、前缀等测量模型。
前缀测量限定在 `MAX_PREFIX_FIT_GRAPHEMES = 96` 以内。超过这一规模,相关路径改用相邻字素组合,限制提交给 Canvas 的文字量随长度增长的放大程度。
这并不等于严格证明浏览器内部 shaping 总是线性的。它限制的是库自己构造测量请求所造成的成本;浏览器字体引擎还有自己的工作。
### 7.3 逐行继续时,不从头重扫
逐行 API 将上一行结束的游标传给下一行,遍历从那里继续。若每取下一行都从全文开头重新定位,行数多时会累积出大量重复工作。
[`RESEARCH.md` 的 Keeping Work Bounded](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md#keeping-work-bounded)记录了许多实际陷阱,包括增长字符串的反复处理、从每行起点重扫到文本结尾、重复搜索断行片段。阅读性能代码时,除了看一次循环的内容,也要问:这个循环会被重复启动多少次?
本章检查:每个前缀只测一次,能否保证总成本是 O(n)?不能,因为每次测量的输入长度在增长。
-41
View File
@@ -1,41 +0,0 @@
# 第八章:准确性怎样影响性能设计
### 8.1 字符宽度不总能独立相加
字偶距 kerning 会让相邻字形距离发生变化;连字可能把多个字母组成一个字形;阿拉伯文字母还会随上下文改变形态。
所以 `width('A') + width('V')` 未必等于 `width('AV')`。一个词整体测得的宽度,也未必直接告诉你从词中间截断后每一行的正确宽度。
Pretext 的策略是测量完整分段,同时提前记录紧急断词所需的 advances,以及行首、行尾的修正信息。复杂性主要支付在准备阶段,布局阶段读取结果。
### 8.2 相同 Unicode 文本,各浏览器也可能不同
浏览器引擎在空白、标点、行边界与拆词等方面存在差异。Pretext 的 engine profile 选择对应扫描规则、拟合模式与容差,而不是仅靠一套理想化 Unicode 换行实现预测所有浏览器。
这份模型有已知缺口,尤其在特殊字体设置、窄到不常见的内容宽度、某些复杂文字与平台 bug 上。它的目标是匹配支持范围内的浏览器行为;“算法能给出一个结果”并不能证明所有浏览器都一定这样画。
### 8.3 “无 DOM”有需要说明的例外
`layout()` 本身不做 DOM 测量。准备阶段则可能:
- 读取 `<html lang>`,除非调用者通过 `setLocale()` 给出了语言。
- 在需要修正 emoji 宽度时,按字体使用 DOM span 探测。
- 缺少 OffscreenCanvas 时,创建不挂载的 Canvas 元素。
因此应理解成它避免了“每个文本块依赖 DOM 测量”的架构,而不是声称整个库绝对不会访问 DOM。worker 中缺少 emoji 探测条件,准确性也可能不同。
### 8.4 重新准备的条件
| 变化 | 通常怎样处理 |
| --- | --- |
| 只有宽度变化 | 复用 handle,重新 `layout()` |
| 只有调用者指定行高变化 | 复用 handle,传入新行高 |
| 文字、字体、字号、字距、whiteSpace、wordBreak 变化 | 重新 `prepare()` |
| 页面语言变化 | 重新准备;旧 handle 保留旧规则与宽度 |
| 先前测过后,目标字体才加载完成 | 清理缓存并重新准备,避免继续用回退字体数据 |
| macOS emoji 相关 DPR 变化 | 按 README 要求清理并重新准备含 emoji 文本 |
最终行为受字体、语言和 CSS 的共同约束。根目录 [README 的 Caveats](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/README.md#caveats)、[PLATFORM_BUGS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/PLATFORM_BUGS.md)和 [ENGINE_FOLLOWUPS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/ENGINE_FOLLOWUPS.md)是理解边界的重要入口。
本章检查:`height = lineCount × lineHeight` 是否能概括带任意大小图片的富文本?不能,图片或不同字体可能把某一行撑高,需要调用者处理行高模型。
-94
View File
@@ -1,94 +0,0 @@
# 第九章:把它接进界面和虚拟列表
### 9.1 一个完整的浏览器模块例子
先在你的前端项目中安装:
```sh
npm install @chenglou/pretext
```
下面是供 Vite 等支持 npm 模块导入的环境使用的 JavaScript 模块。直接打开一个 HTML 文件并不能解析这个包名。
```js
import { prepare, layout } from '@chenglou/pretext'
const text = 'Hello 世界,今天一起读源码。'.repeat(8)
const font = '16px "Helvetica Neue", "PingFang SC", Arial, sans-serif'
const lineHeight = 24
document.documentElement.lang = 'zh-CN'
await document.fonts.load(font, text)
const prepared = prepare(text, font)
const block = document.createElement('div')
block.textContent = text
Object.assign(block.style, {
font,
lineHeight: lineHeight + 'px',
overflowWrap: 'break-word',
wordBreak: 'normal',
whiteSpace: 'normal',
lineBreak: 'auto',
letterSpacing: '0px',
wordSpacing: '0px',
fontKerning: 'auto',
textWrap: 'wrap',
hyphens: 'manual',
textRendering: 'auto',
boxSizing: 'content-box',
padding: '0',
border: '0',
})
document.body.appendChild(block)
function setContentWidth(width) {
const predicted = layout(prepared, width, lineHeight)
block.style.width = width + 'px'
return predicted
}
const predicted = setContentWidth(320)
console.log('预测高度:', predicted.height)
console.log('实际高度:', block.getBoundingClientRect().height)
// 模拟容器变宽:复用相同的 prepared。
setContentWidth(480)
```
这个例子最后的 DOM 高度读取是开发期核对。生产中如果每次布局都再读一次 DOM,仍会承担那次读取可能触发的工作。示例也假设没有其他 CSS 规则覆盖这些值,所用字体在目标设备上可用;跨平台项目应配置自己的字体。
### 9.2 让模型与显示使用相同输入
`layout(prepared, 320, 24)` 中的 320 是内容宽度。如果外框总宽 320px、左右各有 12px padding,再加边框,真正给文字使用的宽度就小于 320px。
可以由应用维护一个尺寸模型:先算出内容宽度,把同一个数同时传给 Pretext 和渲染层。这样可以减少“CSS 用一个值,JS 猜另一个值”的误差。
准备前等待具体文字所需字体加载很重要。只有 `document.fonts.ready` 不一定覆盖尚未使用、由 unicode-range 分片加载的字体;因此例子把 text 也传给 `document.fonts.load()`。
### 9.3 虚拟列表从哪里获得收益
假设有 10,000 条消息,屏幕上只显示 20 条:
```text
每条消息保留 prepared
→ 按当前内容宽度求各条高度
→ 累计高度,确定每条消息顶部的位置
→ 按滚动位置找可见范围
→ 只挂载可见消息与少量缓冲区
```
Pretext 解决的是“没挂载的文字怎样求高度”。累计位置、查找可见范围、滚动锚定、图片高度、选择与无障碍行为,仍由应用或虚拟列表库负责。
窗口变宽时,每条消息通常仍需要重新布局。因此大量消息的整表 resize 成本不会凭空消失。准备与布局分离,让这次更新的单位成本更低。
### 9.4 编辑与富文本如何处理
长文本编辑时,可以按段落保留 prepared,只重新准备被编辑影响的段落;有跨段结构变化时,应用还要更新相应块。
混合字体、提及标签和行内图片使用 `@chenglou/pretext/rich-inline`。这个版本把富文本作为一个段落分析,保留 item 与分段的映射,然后复用布局基础设施,而不是把每个 span 完全独立换行。
富文本还要考虑垂直尺寸:字体大小、上升部与下降部、行内盒子高度都可能影响真实行高。不要直接把所有富文本的行数乘一个固定值,当成任意 CSS 的精确高度。可继续读项目自己的 [Markdown 聊天案例说明](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/pages/demos/markdown-chat.md)。
本章检查:Pretext 是否已经替你完成虚拟列表?没有,但它提供了此前很难在挂载前获得的文本几何信息。
-75
View File
@@ -1,75 +0,0 @@
# 第十章:亲自验证性能机制
### 10.1 先把问题变成可观察的证据
本教程关心四件事:
1. 首次准备是否调用 Canvas?
2. 相同文本再次准备是否复用测量?
3. 改变宽度做布局时是否继续调用 Canvas?
4. 这些样本的预测高度是否与 DOM 相符?
下载脚本后,将它放进研究版本的 Pretext 仓库 `tutorials/zh-CN/verify.cjs`(按需新建目录),因为它以该位置查找 `../../src`。
随文的 [`verify.cjs`](/materials/pretext/verify.cjs)用真实 Chromium Canvas,在 `measureText()` 外包一层计数器,同时统计提交字符串的 UTF-16 码元总数。然后测试英文、中文、保留空白的文本。
实验不使用伪造的平均字宽。源码通过 Node 的 TypeScript 转换功能转换为浏览器模块;没有修改库实现。
### 10.2 第一次研究保存的结果
原始记录在 [`results.chrome156.json`](/materials/pretext/results.chrome156.json)。环境为本机 macOS、Headless Chrome 156、DPR 1,设置 `<html lang="en">`,使用记录脚本中的命名字体及其回退链。三段文本都重复了 12 次,刻意让缓存效果可观察。
| 样本 | 字符串 length | 首次 Canvas 次数 | 首次提交码元数 | 再次准备 Canvas 次数 | 变宽布局 Canvas 次数 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 英文 | 1,428 | 62 | 529 | 0 | 0 |
| 中文 | 408 | 53 | 67 | 0 | 0 |
| 保留空白、tab、软连字符 | 888 | 50 | 497 | 0 | 0 |
每段分别在 80、121、200、319、320、321、480、800px 下求高度,24 组结果与 DOM 高度一致。这只能证明这些样本的高度一致,不能证明所有语言与断行位置都完全一致。
### 10.3 怎样理解计时数据
预先准备后,每轮做 3,000 次宽度切换,热身后取 5 轮中位数。原始记录为:
| 样本 | Pretext 布局总时间 | DOM 改宽后立即测高总时间 |
| --- | ---: | ---: |
| 英文 | 1.7ms | 86.0ms |
| 中文 | 1.5ms | 57.8ms |
| 保留空白等 | 6.3ms | 93.9ms |
这些数字展示“用已测宽度计算”与“改变 DOM 宽度后同步求几何信息”的成本差异。它们不包含 Pretext 首次准备成本,也不包含完整页面绘制。
这不是严谨的产品性能排名:样本很少且重复,固定先测 Pretext 再测 DOM,没有记录硬件与电源条件,短样本受计时分辨率影响,也没有与批量 DOM 测量对照。结果不应被宣传成“任意页面快几十倍”。更可靠的机制证据是调用次数和这组样本的结果核对。
### 10.4 复现方法
要求 Node 23.10+ 或支持 `module.stripTypeScriptTypes(..., { mode: 'transform' })` 的较新 Node、Playwright,以及可用 Chromium。
为避免改动本仓库的依赖,在单独目录安装实验依赖:
```sh
mkdir -p /tmp/pretext-study-deps
npm install --prefix /tmp/pretext-study-deps playwright
/tmp/pretext-study-deps/node_modules/.bin/playwright install chromium
```
在本仓库根目录运行:
```sh
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
node tutorials/zh-CN/verify.cjs
```
默认打印 JSON,不覆盖已归档结果。若要保存本次输出:
```sh
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
node tutorials/zh-CN/verify.cjs --output /tmp/pretext-study-result.json
```
也可以通过 `CHROME_EXECUTABLE` 指定已安装 Chrome 的可执行文件路径;两种方式选一种即可。脚本只监听本机回环地址,结束后关闭服务器与浏览器。
脚本会检查 24 组高度、重复准备和布局的 Canvas 调用次数,以及行文本 API 与行数 API 的行数一致性。失败时返回非零状态。它不设置速度阈值,因为这种微型计时不适合承担性能回归门禁。
项目正式验证还要看 [`harness/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/README.md) 与 [`harness/bench/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/bench/README.md):区分新文本、已见文本、resize、生成行文本与最坏情况;同时处理浏览器自身缓存、JIT、GC 和跨轮噪声。本教程没有运行完整的固定浏览器 harness,也没有把 headless 结果当成其正式准确性基线。
-21
View File
@@ -1,21 +0,0 @@
# 第十一章:按什么顺序读源码
不要从庞大的规则数据开始。先建立主流程,再读会影响这个流程的细节。
| 顺序 | 文件与函数 | 阅读时要回答的问题 |
| --- | --- | --- |
| 1 | [`layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts):`prepareInternal`、`prepare`、`layout` | 两个阶段分别接收什么、返回什么? |
| 2 | [`line-break.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts):`PreparedLineBreakData` | 布局真正需要哪些提前准备的数据? |
| 3 | 同文件:`countPreparedLines` | 普通文本怎样被一个循环计数? |
| 4 | [`analysis.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts):`analyzeText` | 空白、语言与引擎怎样影响分段? |
| 5 | [`prepare.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/prepare.ts):`measureAnalysis` | 宽度、适配数据与快路径标志在哪里建立? |
| 6 | [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts):`getFontMeasurement`、`getSegmentMetrics`、`getContext` | 命中缓存时到底跳过哪些浏览器工作? |
| 7 | 同文件:`getSegmentFit`、`measureFitAdvances` | 一个放不下的词怎样预先准备拆分信息? |
| 8 | [`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts) 与 `layoutWithLines` | 哪些成本只在要文字时出现? |
| 9 | `stepPreparedSimpleLineGeometry`、`walkPreparedComplexLines` | 按行输出和复杂规则怎样衔接? |
| 10 | [`line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-breaks.ts)、[`gecko-line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/gecko-line-breaks.ts)、[`graphemes.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts) | 扫描器怎样使用表和状态? |
| 11 | [`rich-inline.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/rich-inline.ts) | item 与整段文本的映射怎样建立? |
| 12 | [`RESEARCH.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md) 的 Keeping Work Bounded、Evaluation Traps 与 Decisions Log | 哪些看似更简单的实现被测过,又为何没保留? |
做一个小练习:对 `hello world` 写下你认为需要的 prepared 数据,然后依次改变宽度、字体和 whiteSpace,判断哪些数据能保留。能解释这些变化,就已经掌握主设计,不需要先记住所有 Unicode 规则。
-27
View File
@@ -1,27 +0,0 @@
# 第十二章:把这些设计迁移到自己的代码
### 12.1 先按变化频率拆工作
找出输入中很少变的部分与高频变化的部分。这里文字和字体较稳定,宽度频繁变化,于是准备与布局分离。类似思路也适用于图表几何、搜索索引和格式化数据。
预计算需要内存,也会增加第一次处理的成本。只有被复用、能减少更贵的工作时,它才值得。
### 12.2 让 API 允许调用者少做事
如果只要计数,就提供计数路径;只要范围,就不强迫构造字符串;只要可见几行,就允许提前停止。
这比在一个总会生成全部结果的 API 内微调循环,通常更能减少实际工作。
### 12.3 给常见输入一条清晰的快路径
先通过准备阶段建立可验证的前提,再让热循环使用这些前提。完整路径仍负责其余情况。快路径的正确性应来自明确条件,而不是凭输入“看起来简单”猜测。
### 12.4 衡量总工作量
除了总耗时,还观察测量次数、提交字符量、扫描范围、对象分配和重复启动次数。它们能解释为什么快,也能暴露短样本看不到的二次增长。
### 12.5 把结论说到证据能支持的位置
这次源码研究与实验支持的是:Pretext 用准备阶段的测量和规则数据,使后续宽度变化主要成为数值布局;缓存、专用计数循环和按需生成结果进一步减少工作。完整页面是否获益、获益多少,还取决于首次准备量、复用频率、字体与 CSS 是否匹配,以及应用剩余的 DOM 和绘制成本。
读完后可以用三个问题检验理解:哪些工作被移到准备阶段?哪些结果被跨调用复用?调用者只要高度时,哪些工作根本没有发生?把这三个问题顺着源码回答出来,就能解释 Pretext 的主要性能来源。
+5
View File
@@ -0,0 +1,5 @@
{
"maxWidth": 1600,
"quality": 82,
"maxBytes": 196608
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
{
"version": "2026-10-30-19",
"scopeConfirmedAt": "2026-10-30",
"acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。",
"provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。",
"sources": [
{
"id": "S01",
"name": "Y Combinator",
"url": "https://www.ycombinator.com/companies",
"role": "discovery",
"status": "candidate-audit",
"hosts": [
"www.ycombinator.com"
],
"initialAccess": "官网目录可访问",
"boundary": "已读取八个2025—2026批次镜像候选池1428条,再核YC官方页面;不声称覆盖全部YC历年或未公开公司。",
"nextCheck": "当前快照三条原待分类经官网方向补证归类;继续复核规则初分、具体产品与财务缺项,不把领域粗分类当独立能力或收入验证。",
"limitation": "批次不等于成立年;不代表全部公司"
},
{
"id": "S02",
"name": "TechCrunch AI",
"url": "https://techcrunch.com/category/artificial-intelligence/",
"role": "reporting",
"status": "targeted",
"hosts": [
"techcrunch.com"
],
"initialAccess": "AI栏目可读取",
"boundary": "栏目首页选择的3条公司线索已追溯原始资料:Wabi据公开名录2025年份、Thrive Holdings据公司主页2025自述关联公司,DreamHQ成立年仍待核。Thrive控股改造与收购/有机收入边界单列;非首页全部条目或两年档案,公开分类接口与RSS普通HTTP请求返回403。 已补读2025年度档案入口、AI第二页,并从2025企业竞赛报道定向核5条:复用1条、范围外1条、待核3条;不是完整32项名单或两年全量。",
"nextCheck": "沿已验证可读的普通2025档案及AI分页继续补台账;记录未读分页,补DreamHQ、Billow、Cashew Research和Zinnia成立与主体依据,区分竞赛年、批次、产品和公司成立年。",
"limitation": "需追溯原始披露,部分文章受访问限制",
"accessRecheckedAt": "2026-10-30"
},
{
"id": "S03",
"name": "Dealroom",
"url": "https://dealroom.co/",
"role": "discovery",
"status": "targeted",
"hosts": [
"dealroom.co",
"app.dealroom.co"
],
"initialAccess": "官网可读取",
"boundary": "已读Tana与Wabi公开公司档案:前者财务字段不一致留缺,后者2025成立字段与官方业务关联入表;尚未取得两年成立筛选结果集,两个个案不等于目录全量。",
"nextCheck": "确认公开可取得的两年成立筛选清单;逐项追溯财务原始凭证及字段口径,受权限限制保留缺项。",
"limitation": "完整档案需权限;概览及财务表不能未经勾稽直接导入ARR",
"accessRecheckedAt": "2026-10-30"
},
{
"id": "S04",
"name": "Product Hunt",
"url": "https://www.producthunt.com/",
"role": "discovery",
"status": "targeted",
"hosts": [
"www.producthunt.com",
"producthunt.com"
],
"initialAccess": "官网可读取",
"boundary": "两年公开响应各19条,共38条。当前5条关联公司、2条范围外对照、25条范围外/既有业务、4条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。",
"nextCheck": "继续补两年其余分页/日期、仅图片位置及4条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。",
"limitation": "上线时间不等于成立时间",
"accessRecheckedAt": "2026-10-30"
},
{
"id": "S05",
"name": "Sacra",
"url": "https://sacra.com/",
"role": "estimation",
"status": "targeted",
"hosts": [
"sacra.com"
],
"initialAccess": "官网可读取",
"boundary": "已用于定向估算及日期口径交叉核查;无全站公司名录读取台账。",
"nextCheck": "保留估算与公司披露差异,核实历史截至日。",
"limitation": "部分付费;不能把估算当审计数"
},
{
"id": "S06",
"name": "Bessemer",
"url": "https://www.bvp.com/atlas/the-state-of-ai-2025",
"role": "context",
"status": "context",
"hosts": [
"www.bvp.com",
"bvp.com"
],
"initialAccess": "报告页面可读取",
"boundary": "主动选取20家高增长AI企业,含组合内外公司;非随机普查;已核方法与增长/毛利基准段;未取得20家逐公司底表。仅背景基准,不构成两年新公司名录。",
"nextCheck": "高增长选样和投资机构利益关系;不能把头部样本均值当各领域平均或新成立公司总体;需要公开可核底表时另补,不把缺项当已完成。",
"limitation": "投资机构选样偏差",
"accessRecheckedAt": "2026-10-30",
"methodology": {
"sample": "主动选取20家高增长AI企业,含组合内外公司;非随机普查",
"unit": "企业增长与毛利基准,按商业化后的第几年比较",
"period": "2025-08-13报告;商业化年数不等法律成立年",
"access": "已核方法与增长/毛利基准段;未取得20家逐公司底表",
"use": "用于提出留存、毛利、资本效率假设及分类线索",
"limit": "高增长选样和投资机构利益关系;不能把头部样本均值当各领域平均或新成立公司总体",
"evidenceIds": [
"CTX-BVP"
],
"checkedAt": "2026-10-30"
}
},
{
"id": "S07",
"name": "Menlo Ventures",
"url": "https://menlovc.com/perspective/2025-the-state-of-generative-ai-in-the-enterprise/",
"role": "context",
"status": "context",
"hosts": [
"menlovc.com",
"www.menlovc.com"
],
"initialAccess": "报告页面可读取",
"boundary": "495名美国企业AI采购/开发决策者,企业已主动使用AI;已核公开方法、范围及限制;受访个体与公司估算底表未取得。仅背景基准,不构成两年新公司名录。",
"nextCheck": "仅美国企业;排除芯片、推理/模型服务及既有软件内嵌AI功能,不能与包含这些项目的总量直接比较或分摊为单家公司ARR;需要公开可核底表时另补,不把缺项当已完成。",
"limitation": "口径与样本需查看方法说明",
"accessRecheckedAt": "2026-10-30",
"methodology": {
"sample": "495名美国企业AI采购/开发决策者,企业已主动使用AI",
"unit": "采购调查与自下而上的市场支出估算,非公司逐项ARR",
"period": "2025-11-07至11-25调查,市场判断截至2025-12",
"access": "已核公开方法、范围及限制;受访个体与公司估算底表未取得",
"use": "用于买家、采购方式和任务方向假设",
"limit": "仅美国企业;排除芯片、推理/模型服务及既有软件内嵌AI功能,不能与包含这些项目的总量直接比较或分摊为单家公司ARR",
"evidenceIds": [
"CTX-MENLO"
],
"checkedAt": "2026-10-30"
}
},
{
"id": "S08",
"name": "Stripe",
"url": "https://stripe.com/annual-updates/2025",
"role": "context",
"status": "context",
"hosts": [
"stripe.com",
"www.stripe.com",
"assets.stripeassets.com"
],
"initialAccess": "年度信可读取",
"boundary": "2025年开始使用Stripe的商户分组,另有Atlas注册企业分组;已读并视觉核对PDF第1、3页;未取得商户明细与完整分组计算底表。仅背景基准,不构成两年新公司名录。",
"nextCheck": "开始使用平台不等成立年,也非AI公司专属样本;交易量不等Stripe收入,门槛数量翻倍不等ARR均值翻倍;不与TrustMRR公开者样本混算;需要公开可核底表时另补,不把缺项当已完成。",
"limitation": "平台样本不能代表全部AI公司",
"accessRecheckedAt": "2026-10-30",
"methodology": {
"sample": "2025年开始使用Stripe的商户分组,另有Atlas注册企业分组",
"unit": "平台交易量、商户增长及达到收入门槛的数量;分母不同",
"period": "2025年度;2026-02-24年度信",
"access": "已读并视觉核对PDF第1、3页;未取得商户明细与完整分组计算底表",
"use": "用于支付、全球市场及首次收费速度假设",
"limit": "开始使用平台不等成立年,也非AI公司专属样本;交易量不等Stripe收入,门槛数量翻倍不等ARR均值翻倍;不与TrustMRR公开者样本混算",
"evidenceIds": [
"CTX-STRIPE"
],
"checkedAt": "2026-10-30"
}
},
{
"id": "S09",
"name": "SaaS Capital",
"url": "https://www.saas-capital.com/research/",
"role": "context",
"status": "context",
"hosts": [
"www.saas-capital.com",
"saas-capital.com"
],
"initialAccess": "研究页可读取",
"boundary": "第15次年度调查,超过1000家私营B2B SaaS公司;仅读公开摘要;完整下载需填写表单,未提交表单或取得完整底表。仅背景基准,不构成两年新公司名录。",
"nextCheck": "样本不是两年新AI公司;增长率中位数不能当ARR中位数;完整抽样与指标细节仍缺,不外推至硬件、项目服务或所有AI模式;需要公开可核底表时另补,不把缺项当已完成。",
"limitation": "不直接套用硬件与项目制业务",
"accessRecheckedAt": "2026-10-30",
"methodology": {
"sample": "第15次年度调查,超过1000家私营B2B SaaS公司",
"unit": "2025年度增长率与留存等公开摘要;非ARR金额分布",
"period": "2026报告回顾2025增长;不把报告年当公司成立年",
"access": "仅读公开摘要;完整下载需填写表单,未提交表单或取得完整底表",
"use": "用于B2B订阅软件的规模、融资和留存分层假设",
"limit": "样本不是两年新AI公司;增长率中位数不能当ARR中位数;完整抽样与指标细节仍缺,不外推至硬件、项目服务或所有AI模式",
"evidenceIds": [
"CTX-SAASCAP"
],
"checkedAt": "2026-10-30"
}
},
{
"id": "S10",
"name": "机器之心",
"url": "https://www.jiqizhixin.com/",
"role": "reporting",
"status": "targeted",
"hosts": [
"www.jiqizhixin.com",
"jiqizhixin.com",
"pro.jiqizhixin.com"
],
"initialAccess": "首页可读取",
"boundary": "已登记4篇定向检索线索;三篇直达仅登录外壳,一篇读取失败。2项经公开原始资料关联公司,SSI与Utopai经原始历史材料判为范围外,0项成立年待核;未阅读全文或穷尽两年档案。",
"nextCheck": "补公开可读文章及2025—2026档案;受限正文留缺,不以搜索摘录代全文,更名与产品发布不新增公司。",
"limitation": "全文访问受限;仅作发现时明确标识,技术和财务另追原始资料",
"accessRecheckedAt": "2026-10-30"
},
{
"id": "S11",
"name": "36氪",
"url": "https://www.36kr.com/",
"role": "reporting",
"status": "access-limited",
"hosts": [
"www.36kr.com",
"36kr.com"
],
"initialAccess": "安全检测,内容未核验",
"boundary": "2026-10-30仍返回安全检测页面,未把首页作为已读文章。",
"nextCheck": "使用可公开读取的具体文章或其他原始来源;不绕过访问保护。",
"limitation": "暂不作为已读取内容证据",
"accessRecheckedAt": "2026-10-30"
},
{
"id": "S12",
"name": "公司官网与官方博客",
"url": null,
"role": "primary",
"status": "per-company",
"hosts": [],
"initialAccess": "按公司逐一核验",
"boundary": "公司官网和官方材料逐条绑定证据;不是可预先列尽的全站候选池。",
"nextCheck": "核定价、主体、交付与收入定义,自述与独立验证分开。",
"limitation": "自述不等于独立审计"
},
{
"id": "S13",
"name": "TrustMRR",
"url": "https://trustmrr.com/",
"role": "platform",
"status": "targeted",
"hosts": [
"trustmrr.com"
],
"initialAccess": "见具体证据访问记录",
"boundary": "已采集产品与支付快照;公开者自选,不是全部商户或独立财务审计。",
"nextCheck": "核同步日、连接失效、支付平台公式及产品/法人归属。",
"limitation": "来源可读不等于声明已独立验证。"
},
{
"id": "S14",
"name": "a16z Speedrun",
"url": "https://speedrun.a16z.com/",
"role": "investor",
"status": "targeted",
"hosts": [
"speedrun.a16z.com"
],
"initialAccess": "见具体证据访问记录",
"boundary": "投资机构公司介绍定向样本,收入数字仍多来自公司。",
"nextCheck": "核币种、相对时间及管线/履历;未读取的页面保持缺项。",
"limitation": "来源可读不等于声明已独立验证。"
},
{
"id": "S15",
"name": "Artificial Analysis",
"url": "https://artificialanalysis.ai/",
"role": "technical",
"status": "targeted",
"hosts": [
"artificialanalysis.ai"
],
"initialAccess": "见具体证据访问记录",
"boundary": "第三方已发表技术评测,仅覆盖所列模型版本和任务。",
"nextCheck": "核榜单版本、测评方法及置信区间,不将排名变成企业整体能力。",
"limitation": "来源可读不等于声明已独立验证。"
},
{
"id": "S16",
"name": "其他公开补充资料",
"url": null,
"role": "supplement",
"status": "targeted",
"hosts": [],
"initialAccess": "见具体证据访问记录",
"boundary": "未归入上述来源的公开材料按原始证据链接逐条保留;不视为新增已批准的完整候选池。",
"nextCheck": "逐项核来源归属、可靠性及可读正文,不能将搜索摘要当全文。",
"limitation": "来源可读不等于声明已独立验证。"
}
]
}
+221
View File
@@ -0,0 +1,221 @@
{
"sourceVersion": "2026-10-30-archives-1",
"asOf": "2026-10-30",
"metric": "platform-mrr-usd",
"sampleUnit": "product-or-business",
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
"windows": [
{
"days": 7,
"products": [
"MR001",
"MR002",
"MR003",
"MR004",
"MR005",
"MR006",
"MR007",
"MR008",
"MR009",
"MR010",
"MR011",
"MR012",
"MR015",
"MR016"
],
"groups": [
{
"category": "C06",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR011",
"MR012",
"MR015",
"MR016"
],
"n": 4,
"mean": 34815.0,
"median": 36723.0,
"sampleDeviation": 16334.54845820151,
"cv": 0.46918134304758036,
"meanWithoutLargest": 28940.666666666668,
"largestRemovalDrop": 0.16872995356407675
},
{
"category": "C08",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR001"
],
"n": 1,
"mean": 53101.0,
"median": 53101,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "内容制作服务",
"products": [
"MR002"
],
"n": 1,
"mean": 62423.0,
"median": 62423,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR003",
"MR004",
"MR005"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
},
{
"category": "C09",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR006",
"MR007",
"MR008",
"MR009",
"MR010"
],
"n": 5,
"mean": 25349.2,
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"largestRemovalDrop": 0.20920581320120557
}
]
},
{
"days": 90,
"products": [
"MR001",
"MR002",
"MR003",
"MR004",
"MR005",
"MR006",
"MR007",
"MR008",
"MR009",
"MR010",
"MR011",
"MR012",
"MR015",
"MR016",
"AI016"
],
"groups": [
{
"category": "C06",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR011",
"MR012",
"MR015",
"MR016",
"AI016"
],
"n": 5,
"mean": 112725.6,
"median": 40112,
"sampleDeviation": 174786.78744916618,
"cv": 1.5505509613536426,
"meanWithoutLargest": 34815.0,
"largestRemovalDrop": 0.6911526751687284
},
{
"category": "C08",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR001"
],
"n": 1,
"mean": 53101.0,
"median": 53101,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "内容制作服务",
"products": [
"MR002"
],
"n": 1,
"mean": 62423.0,
"median": 62423,
"sampleDeviation": null,
"cv": null,
"meanWithoutLargest": null,
"largestRemovalDrop": null
},
{
"category": "C08",
"provider": "Stripe",
"model": "软件订阅",
"products": [
"MR003",
"MR004",
"MR005"
],
"n": 3,
"mean": 41693.0,
"median": 49069,
"sampleDeviation": 28111.375064197768,
"cv": 0.6742468775141575,
"meanWithoutLargest": 29849.0,
"largestRemovalDrop": 0.2840764636749574
},
{
"category": "C09",
"provider": "RevenueCat",
"model": "消费者订阅",
"products": [
"MR006",
"MR007",
"MR008",
"MR009",
"MR010"
],
"n": 5,
"mean": 25349.2,
"median": 20483,
"sampleDeviation": 11942.947278624319,
"cv": 0.4711370488466823,
"meanWithoutLargest": 20046.0,
"largestRemovalDrop": 0.20920581320120557
}
]
}
]
}
+4 -2
View File
@@ -13,7 +13,9 @@
"lint": "pnpm --filter @endor/docs run lint",
"typecheck": "pnpm --filter @endor/docs run typecheck",
"figures": "uv run --locked --project tools/figures endor-figures",
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth"
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run test:research && pnpm run test:docs && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
"test:auth": "pnpm --filter @endor/docs run test:auth",
"test:research": "pnpm --filter @endor/docs run test:research",
"test:docs": "pnpm --filter @endor/docs run test:docs"
}
}
+4 -3
View File
@@ -59,6 +59,9 @@ importers:
postcss:
specifier: 8.5.31
version: 8.5.31
sharp:
specifier: 0.35.5
version: 0.35.5(@types/node@22.20.5)
tailwindcss:
specifier: 4.3.3
version: 4.3.3
@@ -2876,8 +2879,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
@@ -5184,7 +5186,6 @@ snapshots:
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 22.20.5
optional: true
shebang-command@2.0.0:
dependencies:
+251
View File
@@ -0,0 +1,251 @@
"""Summarize observed research coverage; does not claim an exhaustive AI census."""
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text())
SOURCES = json.loads((ROOT / 'content/research/ai-companies.sources.json').read_text())
TARGET = ROOT / '.artifacts/reports/02-coverage-and-gaps.md'
SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps'
BASE = '/docs/other-research/ai-company-research/overview'
def source_register():
sources = SOURCES['sources']
ids = {s['id'] for s in sources}
assert len(ids) == len(sources) and ids == {f'S{i:02}' for i in range(1, 17)}
assert SOURCES['scopeConfirmedAt'] <= DATA['asOf']
host_map = {}
for source in sources:
assert source['status'] in {'candidate-audit', 'targeted', 'pending', 'context', 'access-limited', 'per-company'}
assert source['boundary'] and source['nextCheck']
if source['url']:
assert urlsplit(source['url']).scheme in {'http', 'https'} and urlsplit(source['url']).netloc
for host in source['hosts']:
assert host not in host_map, f'Duplicate source host: {host}'
host_map[host] = source['id']
assigned = {key: [] for key in ids}
for evidence in DATA['evidence']:
owner = host_map.get(urlsplit(evidence['url']).netloc)
if owner is None:
owner = 'S12' if any(word in evidence['kind'] for word in ('官方', '公司自述', '公司披露')) else 'S16'
assigned[owner].append(evidence)
assert sum(len(v) for v in assigned.values()) == len(DATA['evidence'])
statuses = {'candidate-audit': '候选池逐条已读', 'targeted': '定向使用,未穷尽', 'pending': '候选台账待建',
'context': '背景研究', 'access-limited': '访问受限', 'per-company': '随公司逐条核对'}
lines = ['', '## 来源清单与验收边界', '', SOURCES['provenance'], '',
'每条证据在下表只计入一个渠道:先按所列域名归属,再将类型明确标为官方/公司自述/公司披露的剩余项计入S12,其余计入S16。公司可跨渠道出现,所以公司数不可跨行相加。零条证据表示尚未进入研究表,不表示该来源没有相关公司。背景报告不承担公司发现全集的角色。', '',
'| 来源 | 当前状态 | 证据 / 公司 / 观测数 | 已核对范围 | 剩余核查 |', '| --- | --- | --- | --- | --- |']
for source in sources:
evidence = assigned[source['id']]
evidence_ids = {e['id'] for e in evidence}
company_ids = {e['companyId'] for e in evidence if e['companyId']}
observations = sum(o['evidence'] in evidence_ids for o in DATA['observations'])
name = f"[{source['name']}]({source['url']})" if source['url'] else source['name']
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {source['boundary']} | {source['nextCheck']} |")
lines += ['', 'Dealroom、Product Hunt、机器之心与36氪入口于2026-10-30重新访问。官网首页可读不证明已取得完整筛选结果;安全检测页面不作为文章内容。候选池之外的来源边界尚需建立可回查条目,完成前不宣布来源清单已全部覆盖。', '']
return lines
def context_methods():
evidence = {e['id']: e for e in DATA['evidence']}
rows = [s for s in SOURCES['sources'] if s.get('methodology')]
assert {s['id'] for s in rows} == {'S06', 'S07', 'S08', 'S09'}
lines = ['', '## 背景基准适用性与缺项', '',
'以下核对背景资料的方法及适用范围,不是公司名录的完整覆盖。原始方法记录与全局核查随研究版本进入同一数据库;没有把行业汇总值新增为任何公司的收入观测。', '',
'| 来源与证据 | 样本与统计单位 | 时间 | 本次读取范围 | 可用于什么 | 主要限制 |',
'| --- | --- | --- | --- | --- | --- |']
for source in rows:
m = source['methodology']
assert m['checkedAt'] <= DATA['asOf']
assert all(m.get(key) for key in ('sample', 'unit', 'period', 'access', 'use', 'limit', 'evidenceIds'))
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in m['evidenceIds'])
assert not any(o['evidence'] in m['evidenceIds'] for o in DATA['observations'])
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in m['evidenceIds'])
lines.append(f"| {source['id']} {source['name']}:{proof} | {m['sample']};{m['unit']} | {m['period']} | {m['access']} | {m['use']} | {m['limit']} |")
lines += ['', '本表的应用解释属于研究判断。选样、分母、时间或指标不同会造成表观差异;这些差异不单独证明财务造假,也不能作为给未知公司填补收入的依据。', '']
return lines
def candidate_register():
batches = DATA.get('candidateBatches', [])
companies = {c['id']: c for c in DATA['companies']}
categories = {c['id']: c['name'] for c in DATA['categories']}
known_sources = {s['id'] for s in SOURCES['sources']}
evidence = {e['id']: e for e in DATA['evidence']}
assert len({b['id'] for b in batches}) == len(batches)
all_ids = [r['id'] for b in batches for r in b['records']]
assert len(set(all_ids)) == len(all_ids)
lines = []
statuses = {'pending-year': '成立年待核', 'linked': '已关联公司',
'existing-outside-cohort': '已有关联,年份范围外',
'outside-cohort': '范围外或既有业务,不新增公司', 'promotion': '推广活动,非公司新增'}
for batch in batches:
assert batch['sourceId'] in known_sources
assert batch['accessedAt'] <= DATA['asOf']
assert any(e['url'] == batch['url'] for e in DATA['evidence'])
rows = batch['records']
counts = Counter(r['status'] for r in rows)
assert sum(counts.values()) == len(rows)
lines += ['', f"## {batch['sourceId']} 候选核对({batch['id']})", '',
f"[发现页面]({batch['url']}),读取于{batch['accessedAt']}。{batch['scope']}", '',
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
'| 发现条目 | 主营或产品任务初分 | 核对状态 | 关联公司 | 核对证据 | 限制 |', '| --- | --- | --- | --- | --- | --- |']
for row in rows:
assert row['categoryId'] in categories
assert row['status'] in statuses
assert all(eid in evidence for eid in row.get('evidenceIds', []))
if row['status'] == 'outside-cohort':
assert row.get('evidenceIds') and not row.get('companyId')
name = f"[{row['name']}]({row['url']})" if row['url'] else row['name']
company = '—'
if row.get('companyId'):
assert row['companyId'] in companies
c = companies[row['companyId']]
company = f"[{c['name']}]({BASE}?company={c['id']}#research-company-detail)"
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row.get('evidenceIds', [])) or '成立证据待补,产品页读取状态见候选记录'
category = f"[{categories[row['categoryId']]}]({BASE}?category={row['categoryId']})"
lines.append(f"| {name} | {category} | {statuses[row['status']]} | {company} | {proof} | {row['note']} |")
if batch['sourceId'] == 'S04':
lines += ['', '产品页可读只表示有可访问的资料;本批未将所有产品页完成公司、技术或财务深查。既有直接HTTP采集返回403,当前采用可读的公开页面响应,不声称取得底层全量列表;后续公开档案与未读页面继续保留缺项。', '']
else:
lines += ['', '发现条目和可读正文分开记录;登录外壳、读取失败或搜索摘录不充当已阅读全文。关联公司以列出的原始材料补证,仍保留技术、主体与收入未核事项。', '']
return lines
def directory_rechecks():
rows = DATA.get('directoryRechecks', [])
if not rows:
return []
original = {row['ycId']: row for row in AUDIT['records']}
companies = {c['id']: c for c in DATA['companies']}
evidence = {e['id']: e for e in DATA['evidence']}
assert len({row['ycId'] for row in rows}) == len(rows)
lines = ['', '## 原候选池的后续复核', '',
'原始1,428条读取快照与初筛计数保持不变;下表记录当时未入表、后续补足证据后进入公司表的项目。初筛未通过不是永久排除。后续入表不回写为当时已匹配,也不重复计入928条原始新增目录项。', '',
'| 原候选 | 原始状态 | 后续公司记录 | 复核证据 | 处理与限制 |', '| --- | --- | --- | --- | --- |']
for row in rows:
baseline = original[row['ycId']]
assert baseline['status'] == row['originalStatus']
assert row['reviewedAt'] <= DATA['asOf'] and row['reason']
assert row['originalStatus'] in {'ai-unclear', 'year-outside-or-missing', 'date-conflict'}
company = companies[row['companyId']]
assert row['evidenceIds'] and all(eid in evidence for eid in row['evidenceIds'])
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row['evidenceIds'])
lines.append(f"| [{baseline['name']} · YC{row['ycId']}]({baseline['source']}) | {row['originalStatus']} | [{company['name']}]({BASE}?company={company['id']}#research-company-detail) | {proof} | {row['reviewedAt']}:{row['reason']} |")
lines += ['', f"已记录{len(rows)}条后续复核入表;其余未入表候选仍按原状态保留缺项,不能把这一批复核视为完成全部模糊项。", '']
return lines
def render():
cov = DATA['coverage']
companies = DATA['companies']
depth = sum(bool(c.get('profileOnly')) for c in companies)
origin = sum(bool(c.get('profileOnly')) or c.get('directoryOrigin') == 'yc-2025-2026' for c in companies)
promoted = origin - depth
reviewed = sum(bool(c.get('profileOnly')) and '人工复核' in c.get('classificationBasis', '') for c in companies)
years = Counter(c['year'] for c in companies)
income = Counter(c['revenueStatus'] for c in companies)
technical = Counter(c.get('technicalReview', {}).get('level', 'unreviewed') for c in companies)
assert cov['profilesRead'] + cov['failedProfiles'] == cov['candidateRecords']
assert cov['matchingProfiles'] == cov['existingProfiles'] + cov['newProfiles']
assert cov['matchingProfiles'] + cov['otherOrMissingYear'] + cov['unclearAI'] + cov['dateConflicts'] == cov['profilesRead']
assert origin == cov['newProfiles']
assert AUDIT['accessedAt'] == cov['accessedAt']
assert AUDIT['counts'] == cov
assert len(AUDIT['records']) == cov['profilesRead']
assert Counter(x['status'] for x in AUDIT['records']) == {
'imported': cov['newProfiles'], 'existing': cov['existingProfiles'],
'year-outside-or-missing': cov['otherOrMissingYear'],
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records'])
lines = ['# AI 公司资料覆盖与缺项', '',
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
f"[分类和数据工作台]({BASE})链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。", '',
'## 候选池与公司表的关系', '',
'| 环节 | 记录数 | 含义 |', '| --- | --- | --- |',
f"| YC批次候选 | {cov['candidateRecords']:,} | 从公开镜像发现,随后读取YC官方页面 |",
f"| 官方页面读取 | {cov['profilesRead']:,} | 此候选池读取失败{cov['failedProfiles']}条 |",
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
'## 成立年与证据深度', '',
'| 维度 | 当前记录 | 解读 |', '| --- | --- | --- |',
f"| 记录为2025年成立 | {years[2025]} | 成立来源和精度见公司明细 |",
f"| 记录为2026年成立 | {years[2026]} | 不将产品发布日期代替成立日 |",
f"| 成立年未知或范围外 | {sum(v for k,v in years.items() if k not in (2025,2026))} | 发布/转型对照单列,不进入两年成立分组 |",
f"| 目录业务分类人工复核 | {reviewed} | 仅复核主营分类,不代表技术或财务审计 |",
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |",
f"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |",
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |",
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
'## 业务分类与数据入口', '',
'| 业务类别 | 记录数 |', '| --- | --- |',
]
counts = Counter(c['cat'] for c in companies)
for category in DATA['categories']:
lines.append(f"| [{category['name']}]({BASE}?category={category['id']}) | {counts[category['id']]} |")
lines += ['', '## 已使用来源的分布', '',
f"{len(DATA['evidence']):,}条证据记录对应{len({e['url'] for e in DATA['evidence']}):,}个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。", '',
'| 来源 | 证据记录 | 不同URL | 用途及限制 |', '| --- | --- | --- | --- |',
]
families = [('YC官方目录', 'www.ycombinator.com', '公司自述的成立、团队与业务;英文及加速器样本偏多'),
('TrustMRR', 'trustmrr.com', '支付平台重建指标及方法;公开者存在选择偏差,非财务审计'),
('a16z Speedrun', 'speedrun.a16z.com', '投资机构中的公司介绍及收入声明;存在利益关系'),
('TechCrunch', 'techcrunch.com', '原始采访或报道;转述公司数字不等于查账'),
('Sacra', 'sacra.com', '第三方估算;与直接披露分开')]
included = set()
for name, host, note in families:
entries = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc == host]
included.add(host)
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |")
others = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc not in included]
lines.append(f"| 其他官网、评测及公开资料 | {len(others)} | {len({e['url'] for e in others})} | 按每条来源限制理解,不能统一当作独立证据 |")
lines += directory_rechecks()
lines += source_register()
lines += context_methods()
lines += candidate_register()
lines += ['', '## 尚未满足的研究条件', '',
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
]
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--document-export', type=Path)
args = parser.parse_args()
content = render()
if args.check:
if args.document_export is not None:
bundle = json.loads(args.document_export.read_text())
stored = next((d['content'] for d in bundle['documents'] if d['sourceSlug'] == SOURCE_SLUG), None)
if stored != content:
sys.exit('Research coverage report differs; review and regenerate')
print('Research coverage accounting verified; supplied document export compared when requested')
else:
TARGET.parent.mkdir(parents=True, exist_ok=True)
TARGET.write_text(content)
print(TARGET.relative_to(ROOT))
if __name__ == '__main__':
main()
+4
View File
@@ -6,6 +6,10 @@ uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
pnpm --filter @endor/docs run test:auth
pnpm --filter @endor/docs run test:research
pnpm --filter @endor/docs run test:docs
pnpm --filter @endor/docs run images
pnpm --filter @endor/docs run test:images
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v
+17 -2
View File
@@ -14,7 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
APP = "endorphin-tabularium-docs"
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures",
"scripts/check-container.sh", "scripts/ci.py"]
"scripts/check-container.sh", "scripts/ci.py", "scripts/summarize-ai-income.py",
"scripts/audit-ai-research.py", "scripts/export-legacy-documents.py"]
def run(*args: str, capture: bool = False) -> str:
@@ -35,9 +36,17 @@ def fingerprint() -> str:
def smoke_image(image: str, name: str) -> None:
run("docker", "run", "-d", "--name", name, "--mount", "type=volume,dst=/auth", "-p", "127.0.0.1::3000", image)
run("docker", "run", "-d", "--name", name, "--mount", "type=volume,dst=/auth", "-e", "DOCUMENT_SMOKE_TEST=1", "-p", "127.0.0.1::3000", image)
try:
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "init")
subprocess.run(["docker", "exec", "-i", name, "node", "apps/docs/docs-cli.cjs", "import"],
input=(ROOT / ".artifacts/documents-migration.json").read_bytes(), check=True)
run("docker", "exec", name, "node", "apps/docs/docs-cli.cjs", "export", "/auth/migrated-documents.json")
migrated = json.loads(run("docker", "exec", name, "cat", "/auth/migrated-documents.json", capture=True))
expected = json.loads((ROOT / ".artifacts/documents-migration.json").read_text())
if migrated != expected:
raise RuntimeError("Migration changed document content or catalog metadata")
run("docker", "exec", name, "node", "apps/docs/docs-cli.cjs", "backup", "/auth/smoke-backup.sqlite")
for _ in range(60):
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
if state.get("Health", {}).get("Status") == "healthy":
@@ -63,6 +72,12 @@ def smoke_image(image: str, name: str) -> None:
def main() -> None:
os.chdir(ROOT)
migration = ROOT / ".artifacts/documents-migration.json"
migration.parent.mkdir(exist_ok=True)
migration.unlink(missing_ok=True)
run(sys.executable, "scripts/export-legacy-documents.py", "--output", str(migration))
run(sys.executable, "scripts/summarize-ai-income.py", "--check")
run(sys.executable, "scripts/audit-ai-research.py", "--check")
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
scope = os.environ.get("CI_CACHE_SCOPE", "main")
if not re.fullmatch(r"main|pr-[0-9]+", scope):
+50
View File
@@ -0,0 +1,50 @@
"""Draft business labels for public company profiles, never financial verification.
Input is a directory of JSON records with a `company` object containing
one_liner and tags, as retained by the official-profile research snapshot.
Output is for review; unmatched records remain C00 rather than being forced.
This tool does not fetch sources, modify the site database or run a build.
"""
import argparse
import json
import re
from pathlib import Path
def match(pattern,text):return bool(re.search(pattern,text,re.I))
def classify(c):
short=c.get('one_liner') or '';tags=' '.join(c.get('tags') or [])
rules=[
('C10',r'\brobot(?:s|ics|ic)?\b|embodied|physical intelligence|autonomous (?:driving|vehicle)|drones?|manufacturing hardware'),
('C02',r'context compression|compression middleware|RNA intelligence|audio data for AI|synthetic data|data quality|\bGPU\b|\bGPUs\b|\bchips?\b|semiconductor|data centers?|datacenters?|inference|compute|training data|data labeling|data labelling|RL environments|reinforcement learning|model training'),
('C07',r'precision medicine|ambulance|clinic|biology|manufacturers|distributors|fabrication|people-intelligence|deskless workers|health|medical|clinical|dental|physician|hospital|veterinar|therap|radiolog|pharma|legal|law firm|lawyer|litigation|accountant|accounting|audit\b|insurance|underwrit|financ|hedge funds?|bank|wealth|investment|investing|real estate|property|properties|construction|building (?:plan|code)|engineering projects|\bCAD\b|\bEDA\b|chip design|architecture|architects|logistics|supply chain|freight|global trade|compliance|government|govtech|lobby|energy|utilities|\bfleets?\b|education|edtech|\btutor|schools?|exam prep|tax\b|recruit|hiring|human resources|procurement|retail|e-commerce|ecommerce|merchants?|industrial'),
('C03',r'AI evaluations|AI governance|voice agents|chat AI agents|harnesses|agent.{0,25}(?:infrastructure|tools|identity|identities|memory|evaluation|eval|observability|browser|payment|communication)|(?:infrastructure|tools|identity|identities|evaluation|eval|observability|browsers?|payments?).{0,25}agents?|\bMCP\b|agentmail|API.{0,20}agents?|developer.{0,15}agents?'),
('C04',r'firmware|internal apps|coding|software engineer|code\b|codebase|developer|programming|app(?:lication)? (?:builder|creation|generation)|(?:build|building|make|create|generat).{0,25}(?:apps|applications|software)|\bDevOps\b|product docs|documentation engineer|\bIDE\b|logs and fix|testing|debug'),
('C06',r'\bsales\b|marketing|customer support|customer service|call center|call centre|\bGTM\b|\bCRM\b|\bSEO\b|\bGEO\b|\bAEO\b|growth engine|outbound|telemarketing|advertis|\bads\b|contact center|customer success'),
('C08',r'\bvideo\b|\bimage\b|\bphoto\b|\bfilm\b|\bdesign\b|animation|creative|creators?|content creation|\bmusic\b|media|audio edit|voiceover'),
('C01',r'foundation models?|frontier models?|large language models?|\bLLMs?\b|voice model|speech model|world model'),
('C09',r'consumer|personal|companions?|\bstor(?:y|ies)\b|entertainment|\bgam(?:e|es|ing)\b|social network|\bphone\b|smartphone|keyboard|your digital life'),
('C05',r'\bB2B\b|enterprise|workflow|workspace|productivity|operations|\bSaaS\b|\bSOP\b|Word editor|spreadsheet|knowledge|company OS|company brain|work\b|teams?|business|employee|meeting|security|cyber|search'),
]
for label,text in [('主营简介',short),('目录标签',tags)]:
for cat,pattern in rules:
if match(pattern,text):return cat,label+'的规则初分'
return 'C00','简介不足以确定主业务,待复核'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
result = []
for path in sorted(args.input.glob("*.json")):
record = json.loads(path.read_text())
company = record["company"]
category, basis = classify(company)
result.append({"ycId": company["id"], "category": category,
"basis": basis, "status": "provisional"})
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
if __name__ == "__main__":
main()
+16
View File
@@ -7,6 +7,7 @@ import re
import shutil
import socket
import subprocess
import time
import urllib.request
from ci import APP, ROOT, fingerprint, run
@@ -108,6 +109,21 @@ def main() -> None:
run("docker", "run", "--rm", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/auth-cli.cjs", "init")
# Back up the entire shared database, then bootstrap documents only when empty.
# A normal release never reimports, overwrites or resets authored DB content.
migration = ROOT / ".artifacts/documents-migration.json"
if not migration.is_file():
raise RuntimeError("Verified document migration export is missing")
backup = f"/auth/before-documents-{sha}-{time.time_ns()}.sqlite"
run("docker", "run", "--rm", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/docs-cli.cjs", "backup", backup)
subprocess.run(["docker", "run", "--rm", "-i", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/docs-cli.cjs", "import", "--if-empty"],
input=migration.read_bytes(), check=True)
shutil.copyfile(ROOT / "scripts/docs-admin.sh", DEPLOY_ROOT / "docs")
(DEPLOY_ROOT / "docs").chmod(0o750)
shutil.copyfile(ROOT / "scripts/auth-admin.sh", DEPLOY_ROOT / "auth")
(DEPLOY_ROOT / "auth").chmod(0o750)
current = DEPLOY_ROOT / "current"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Run on ces-control. JSON content travels through stdin, never shell arguments.
set -eu
cd /opt/endorphin-tabularium-docs/current
case "${1:-}" in
list|get|save|history|export|backup|import|key-create|key-list|key-revoke)
docker compose -p endorphin-tabularium-docs --env-file /etc/endorphin-tabularium-docs/production.env --env-file release.env -f compose.yml exec -T docs node apps/docs/docs-cli.cjs "$@" ;;
*) echo 'Usage: docs key-create <name> <private-file> [read|read-write] [days] | key-list | key-revoke <id> | list | get <slug> | save < JSON | history <sourceSlug> | export <private-file> | backup <private-file> | import < JSON' >&2; exit 1 ;;
esac
+167
View File
@@ -0,0 +1,167 @@
"""Upload documents through the database API; credentials stay in a private file."""
import argparse
import json
import os
from pathlib import Path
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
class DocumentClientError(ValueError):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# Never forward a bearer credential to a redirected host or protocol.
return None
def server_url(value):
parsed = urllib.parse.urlsplit(value)
if (parsed.username or parsed.password or parsed.query or parsed.fragment
or parsed.path not in ('', '/') or not parsed.hostname):
raise ValueError('Provide a server origin without credentials, path, query or fragment')
if parsed.scheme != 'https' and not (
parsed.scheme == 'http' and parsed.hostname in ('localhost', '127.0.0.1', '::1')):
raise ValueError('Use HTTPS or an HTTP loopback connection through a trusted SSH tunnel')
return value.rstrip('/')
def read_token(filename):
path = Path(filename)
if stat.S_IMODE(path.stat().st_mode) & 0o077:
raise ValueError('API key file must be private (chmod 600)')
value = path.read_text().strip()
if value.startswith('{'):
value = json.loads(value)['token']
if not isinstance(value, str) or not value.startswith('tdoc_') or len(value) != 48:
raise ValueError('Invalid document API key file')
return value
def request(base, token, method, path, payload=None, request_id=None):
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
if data is not None and len(data) > 8 * 1024 * 1024:
raise ValueError('Request exceeds 8 MiB; split into smaller atomic batches')
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
if data is not None:
headers['Content-Type'] = 'application/json'
headers['Idempotency-Key'] = request_id
opener = urllib.request.build_opener(NoRedirect())
for attempt in range(2):
req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
try:
with opener.open(req, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code in (502, 503, 504) and attempt == 0:
error.close()
time.sleep(1)
continue
status = error.code
error.close()
explanations = {400: 'Invalid document or pagination', 401: 'Key missing, expired or revoked',
403: 'Read-only key', 404: 'Document or revision not found',
409: 'Revision or request-id conflict; read current state before retrying',
413: 'Request too large', 503: 'Database unavailable'}
raise DocumentClientError(f'HTTP {status}: ' + explanations.get(status, 'Request failed')) from None
except (urllib.error.URLError, TimeoutError):
if attempt == 0:
time.sleep(1)
continue
raise DocumentClientError('Connection failed; retry using the same request id and unchanged input') from None
def document_batches(documents, max_bytes=8 * 1024 * 1024):
if not isinstance(documents, list) or not documents:
raise DocumentClientError('Provide a nonempty documents array')
batches, chunk, size, seen = [], [], 20, set()
for document in documents:
if not isinstance(document, dict) or not isinstance(document.get('sourceSlug'), str):
raise DocumentClientError('Every document needs a sourceSlug')
if document['sourceSlug'] in seen:
raise DocumentClientError('Duplicate sourceSlug in upload')
seen.add(document['sourceSlug'])
encoded = len(json.dumps(document, ensure_ascii=False).encode()) + 2
if encoded + 20 > max_bytes:
raise DocumentClientError('A document exceeds the request limit')
if chunk and (len(chunk) == 100 or size + encoded > max_bytes):
batches.append(chunk)
chunk, size = [], 20
chunk.append(document)
size += encoded
batches.append(chunk)
return batches
def main():
os.umask(0o077)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--url', default=os.environ.get('TABULARIUM_DOCS_URL'))
parser.add_argument('--key-file', default=os.environ.get('TABULARIUM_DOCS_KEY_FILE'))
sub = parser.add_subparsers(dest='command', required=True)
listing = sub.add_parser('list')
listing.add_argument('--limit', type=int, default=50)
listing.add_argument('--offset', type=int, default=0)
reading = sub.add_parser('get')
reading.add_argument('slug')
mode = reading.add_mutually_exclusive_group()
mode.add_argument('--history', action='store_true')
mode.add_argument('--revision', type=int)
reading.add_argument('--limit', type=int, default=50)
reading.add_argument('--offset', type=int, default=0)
saving = sub.add_parser('save')
saving.add_argument('file', help='Private JSON file or - for stdin; one document or {documents: [...]}')
saving.add_argument('--request-id', help='Reuse this id only with the same input when retrying')
args = parser.parse_args()
if not args.url or not args.key_file:
parser.error('Set --url and --key-file or TABULARIUM_DOCS_URL and TABULARIUM_DOCS_KEY_FILE')
try:
base = server_url(args.url)
token = read_token(args.key_file)
if args.command == 'list':
result = request(base, token, 'GET', '/api/documents?' + urllib.parse.urlencode(
{'limit': args.limit, 'offset': args.offset}))
elif args.command == 'get':
path = '/api/documents/item/' + urllib.parse.quote(args.slug, safe='/')
if args.history:
path += '?' + urllib.parse.urlencode({'history': 1, 'limit': args.limit, 'offset': args.offset})
elif args.revision is not None:
path += '?' + urllib.parse.urlencode({'revision': args.revision})
result = request(base, token, 'GET', path)
else:
payload = json.load(sys.stdin) if args.file == '-' else json.loads(Path(args.file).read_text())
request_id = args.request_id or str(uuid.uuid4())
if not request_id or len(request_id) > 100 or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-' for c in request_id):
raise ValueError('Invalid request id')
print('Request id: ' + request_id, file=sys.stderr)
if isinstance(payload, dict) and 'documents' in payload:
if set(payload) != {'documents'}:
raise DocumentClientError('Batch payload must contain only documents')
chunks = document_batches(payload['documents'])
receipts, replayed = [], 0
for index, chunk in enumerate(chunks, 1):
batch_id = f'{request_id}:{index}'
saved = request(base, token, 'POST', '/api/documents/batch', {'documents': chunk}, batch_id)
receipts.extend(saved['documents'])
replayed += int(saved['replayed'])
print(f'Batch {index}/{len(chunks)} committed; {len(receipts)} documents total; request id: {batch_id}', file=sys.stderr)
result = {'documents': receipts, 'batches': len(chunks), 'replayedBatches': replayed, 'requestId': request_id}
else:
result = request(base, token, 'POST', '/api/documents', payload, request_id)
print(json.dumps(result, ensure_ascii=False, indent=2))
except DocumentClientError as error:
raise SystemExit(str(error)) from None
except (ValueError, OSError, KeyError, TypeError):
# Never print exception objects that could contain credentials or document data.
raise SystemExit('Document request failed. Check the URL, private key file, JSON schema and server response; credentials omitted.') from None
if __name__ == '__main__':
main()
+50
View File
@@ -0,0 +1,50 @@
"""One-time export from an explicit historical Git revision, never a runtime seed."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
ROOT = Path(__file__).resolve().parents[1]
BASELINE = 'cffca6942b1bc0005321e1685f38593fb0b853d5' # Integration snapshot before database migration.
RESEARCH_REVISION = '74af205b2959e1bb6a1fe9f567bc87069ac6d12c' # Final PR 14 update received during integration.
def export(ref):
commit = subprocess.check_output(['git', 'rev-parse', '--verify', ref + '^{commit}'], cwd=ROOT, text=True).strip()
def read(name, revision=commit):
return subprocess.check_output(['git', 'show', f'{revision}:{name}'], cwd=ROOT).decode()
catalog = json.loads(read('content/catalog.json'))
names = subprocess.check_output(['git', 'ls-tree', '-r', '--name-only', commit, '--', 'content/docs'], cwd=ROOT, text=True).splitlines()
documents = []
for name in sorted(n for n in names if n.endswith('.md')):
source = name.removeprefix('content/docs/').removesuffix('.md')
metadata = catalog['documents'][source]
collection = next(c for c in catalog['collections'] if not c['path'] or source.startswith(c['path'] + '/'))
revision = RESEARCH_REVISION if commit == BASELINE and source.startswith("ai-company-research/") else commit
content = read(name, revision)
title = re.search(r'^# (.+)$', content, re.M)
documents.append(dict(slug=metadata['slug'], sourceSlug=source, category=metadata['category'],
title=title[1].strip() if title else source, content=content,
group=metadata.get('group', collection['title']), collection=metadata.get('collection', collection['title']),
description=metadata.get('description', collection['description']),
draft=metadata.get('draft', False), featured=metadata.get('featured', False), revision=1))
if not documents:
raise ValueError('Refusing empty migration')
return dict(format='tabularium-documents-v1', documents=documents)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--ref', default=BASELINE)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
payload = export(args.ref)
args.output.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, 'w') as output:
json.dump(payload, output, ensure_ascii=False)
print(f"Exported {len(payload['documents'])} documents; source is immutable Git history")
+1 -1
View File
@@ -27,7 +27,7 @@ def verify(base: str) -> None:
assert status == 200 and json.loads(health) == {"status": "ok"}
status, _, login = get("/login")
assert status == 200 and "输入动态码" in login.decode()
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/figures/endor-review-gate.webp", "/figures/endor-review-desk.webp", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
status, headers, _ = get(path)
assert status == 307, path
target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"]))
+159
View File
@@ -0,0 +1,159 @@
"""Render a fixed research snapshot; --check performs data analysis only, no build."""
import argparse
import datetime as dt
import json
from pathlib import Path
import statistics as st
import sys
ROOT = Path(__file__).resolve().parents[1]
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
REFERENCE = json.loads((ROOT / 'content/research/ai-income-analysis.json').read_text())
TARGET = ROOT / '.artifacts/reports/01-income-snapshot.md'
SOURCE_SLUG = 'ai-company-research/01-income-snapshot'
BASE = '/docs/other-research/ai-company-research/overview'
COMPANIES = {c['id']: c for c in DATA['companies']}
CATEGORIES = {c['id']: c['name'] for c in DATA['categories']}
END = dt.date.fromisoformat(DATA['asOf'])
def sample(days):
latest, excluded = {}, []
for o in DATA['observations']:
if o.get('selection') != 'platform' or o.get('metric') != 'mrr':
continue
c = COMPANIES[o['company']]
day = o.get('observedAt')
try:
parsed = dt.date.fromisoformat(day)
valid_date = parsed.isoformat() == day
except (TypeError, ValueError):
valid_date = False
valid = (c['year'] in (2025, 2026) and o.get('origin') == 'payment-platform'
and o['unit'] == 'USD' and o['bound'] == '='
and o.get('comparisonGroup') == 'trustmrr-usd-mrr'
and o.get('provider') and isinstance(o['value'], (int, float))
and 0 <= o['value'] < float('inf') and valid_date
and 0 <= (END - parsed).days <= days)
if not valid:
excluded.append(o)
continue
prior = latest.get(c['id'])
if prior is None or day > prior['observedAt']:
latest[c['id']] = o
return latest, excluded
def groups(points):
result = {}
for cid, o in points.items():
c = COMPANIES[cid]
key = (c['cat'], o['provider'], c.get('businessModel', '收费模式未核验'))
result.setdefault(key, []).append(o['value'])
return result
def stats(values):
mean = st.mean(values)
sd = st.stdev(values) if len(values) > 1 else None
reduced = st.mean(sorted(values)[:-1]) if len(values) > 1 else None
return dict(n=len(values), mean=mean, median=st.median(values),
sampleDeviation=sd, cv=sd / mean if sd is not None and mean else None,
meanWithoutLargest=reduced,
largestRemovalDrop=(mean - reduced) / mean if reduced is not None and mean else None)
def verify():
assert REFERENCE['sourceVersion'] == DATA['version'], 'Reference revision mismatch'
for window in REFERENCE['windows']:
points, _ = sample(window['days'])
assert set(points) == set(window['products']), 'Sample selection mismatch'
computed = groups(points)
assert len(computed) == len(window['groups'])
for group in window['groups']:
values = computed[(group['category'], group['provider'], group['model'])]
for key, value in stats(values).items():
expected = group[key]
assert (value is None and expected is None) or (
value is not None and expected is not None
and abs(value - expected) <= 1e-8 * max(1, abs(expected))), key
def money(value):
return f'{value:,.0f}' if value is not None else '不计算'
def link(cid):
return f"[{COMPANIES[cid]['name']}]({BASE}?company={cid}#research-company-detail)"
def render():
points, excluded = sample(7)
lines = [
'# AI 公司收入样本分布快照', '',
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
'## 7日窗口的分组结果', '',
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
'| --- | --- | --- | --- | --- | --- | --- |',
]
for key, values in sorted(groups(points).items()):
cat, provider, model = key
v = stats(values)
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] is not None else '不计算'
lines.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
lines += ['',
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
'## 样本与证据入口', '',
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
'| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |',
'| --- | --- | --- | --- | --- | --- |',
]
for cid, o in sorted(points.items()):
status = '已过期的日期快照' if o.get('connectionExpired') else '采集时未标过期'
lines.append(f"| {link(cid)} | {COMPANIES[cid]['year']} | {money(o['value'])} | {o['observedAt']} | {o['provider']} | {status} |")
fresh = stats(groups(points)[('C06', 'Stripe', '软件订阅')])
broad, _ = sample(90)
older = stats(groups(broad)[('C06', 'Stripe', '软件订阅')])
lines += ['', '## 扩大窗口后的敏感性', '',
f"营销软件订阅组从7日窗口的{fresh['n']}个产品扩大到90日窗口的{older['n']}个产品,均值从{money(fresh['mean'])}升至{money(older['mean'])}美元每月,中位数从{money(fresh['median'])}变为{money(older['median'])},CV从{fresh['cv']:.3f}升至{older['cv']:.3f}。", '',
f"新增点来自{link('AI016')}的8月历史快照,连接已过期。移除该最大值后,均值回到{money(older['meanWithoutLargest'])}美元,下降{older['largestRemovalDrop']:.1%}。这是日期与样本构成的敏感性,不能解释为行业风险变化。", '',
'## 默认窗口排除的历史记录', '',
'| 产品 | MRR美元每月 | 最后同步 | 排除原因 |',
'| --- | --- | --- | --- |',
]
for o in excluded:
lines.append(f"| {link(o['company'])} | {money(o['value'])} | {o.get('observedAt', '未知')} | 超过7日窗口,保留历史观测 |")
lines += ['', '## 对 ARR 与商业模式的含义', '',
'MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。', '',
'当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。', '',
f"行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台]({BASE}),不补成零值或精确数字。", '',
]
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--document-export', type=Path)
args = parser.parse_args()
verify()
content = render()
if args.check:
if args.document_export is not None:
bundle = json.loads(args.document_export.read_text())
stored = next((d['content'] for d in bundle['documents'] if d['sourceSlug'] == SOURCE_SLUG), None)
if stored != content:
sys.exit('Income snapshot differs; regenerate after reviewing data changes')
print('Income reference and samples verified; supplied document export compared when requested')
else:
TARGET.parent.mkdir(parents=True, exist_ok=True)
TARGET.write_text(content)
print(TARGET.relative_to(ROOT))
if __name__ == '__main__':
main()
+2 -2
View File
@@ -1,4 +1,4 @@
"""Guard cache invalidation against stale documents and needless metadata rebuilds."""
"""Guard cache invalidation against stale application code and needless metadata rebuilds."""
from pathlib import Path
import subprocess
import tempfile
@@ -13,7 +13,7 @@ class FingerprintTests(unittest.TestCase):
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / ".gitignore").write_text(".artifacts/\n**/.next/\n")
doc = root / "content/docs/concept.md"
doc = root / "apps/docs/src/lib/docs.ts"
doc.parent.mkdir(parents=True)
doc.write_text("# Original\n")
package = root / "package.json"
+85
View File
@@ -0,0 +1,85 @@
"""Lightweight client tests: no HTTP server, compilation or real credentials."""
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import urllib.error
import urllib.request
spec = importlib.util.spec_from_file_location('documents_client', Path(__file__).with_name('documents-client.py'))
client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(client)
class ClientTests(unittest.TestCase):
def test_only_tls_or_loopback_origins_are_accepted(self):
self.assertEqual(client.server_url('https://docs.example/'), 'https://docs.example')
self.assertEqual(client.server_url('http://127.0.0.1:18135'), 'http://127.0.0.1:18135')
for url in ['http://docs.example', 'https://user:secret@docs.example', 'file:///tmp/data',
'https://docs.example/api', 'https://docs.example/?token=secret']:
with self.assertRaises(ValueError):
client.server_url(url)
def test_key_file_permissions_and_formats(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'key.json'
token = 'tdoc_' + 'x' * 43
path.write_text(json.dumps({'token': token}))
path.chmod(0o600)
self.assertEqual(client.read_token(path), token)
path.chmod(0o644)
with self.assertRaises(ValueError):
client.read_token(path)
def test_redirects_never_forward_bearer_credentials(self):
request = urllib.request.Request('https://docs.example', headers={'Authorization': 'Bearer test-only'})
self.assertIsNone(client.NoRedirect().redirect_request(request, None, 302, '', {}, 'https://other.example'))
def test_network_retry_preserves_idempotency_and_body(self):
calls = []
class Opener:
def open(self, request, timeout):
calls.append(request)
if len(calls) == 1:
raise urllib.error.URLError('temporary network failure')
return io.BytesIO(b'{"replayed":true}')
with patch.object(client.urllib.request, 'build_opener', return_value=Opener()), patch.object(client.time, 'sleep'):
result = client.request('https://docs.example', 'test-token', 'POST', '/api/documents', {'revision': 0}, 'request-1')
self.assertTrue(result['replayed'])
self.assertEqual(calls[0].data, calls[1].data)
self.assertEqual(calls[0].get_header('Idempotency-key'), 'request-1')
self.assertEqual(calls[1].get_header('Idempotency-key'), 'request-1')
def test_large_uploads_are_split_by_count_and_bytes(self):
documents = [{'sourceSlug': str(i), 'content': 'a' * 20} for i in range(201)]
batches = client.document_batches(documents)
self.assertEqual([len(batch) for batch in batches], [100, 100, 1])
self.assertEqual([doc for batch in batches for doc in batch], documents)
bounded = client.document_batches(documents[:5], max_bytes=150)
self.assertTrue(all(len(json.dumps({'documents': batch}, ensure_ascii=False).encode()) <= 150 for batch in bounded))
def test_duplicate_identifiers_and_oversized_items_fail_before_upload(self):
with self.assertRaises(ValueError):
client.document_batches([{'sourceSlug': 'same'}, {'sourceSlug': 'same'}])
with self.assertRaises(ValueError):
client.document_batches([{'sourceSlug': 'one', 'content': 'a' * 500}], max_bytes=100)
def test_conflict_is_reported_without_printing_server_content_or_secret(self):
class Opener:
def open(self, request, timeout):
raise urllib.error.HTTPError(request.full_url, 409, 'Conflict', {}, io.BytesIO(b'{"error":"private-content"}'))
with patch.object(client.urllib.request, 'build_opener', return_value=Opener()):
with self.assertRaisesRegex(client.DocumentClientError, 'HTTP 409') as result:
client.request('https://docs.example', 'private-token', 'POST', '/api/documents', {}, 'request-1')
self.assertNotIn('private-token', str(result.exception))
self.assertNotIn('private-content', str(result.exception))
if __name__ == '__main__':
unittest.main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,14 @@
Endor inspection desk comic — approved second slide
Source: built-in image_gen; physics inspection-bench variant with corrected top-row flow.
Original: exec-432e0f3d-d01d-4658-b5d5-0c63db95e472.png
The user selected this alongside the gate variant for one carousel.
The two identical machines emit raw papers. Above, a dashed integral dW boundary
encloses the backlog BEFORE one person's fixed-capacity C0 inspection desk.
Below, a dashed dW boundary encloses the one new paper being inspected. Earlier
checked outputs become structured document helpers that contribute additional
magnifiers to the Endor desk, whose capacity is C0 + k integral dW.
Store this PNG as the production master outside public. The confirmed remote
runner generates /figures/endor-review-desk.webp using content/image-policy.json.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,20 @@
Endor review gate comic — approved source
Source: built-in image_gen, physics gate variant, approved by the user on 2026-10-28.
Original: exec-380f8db3-6450-4981-97df-473c13a62223.png
The PNG is the production master, not a public browser asset.
The confirmed remote runner generates the browser WebP with:
pnpm --filter @endor/docs images
Visual specification:
Two rows, white background, charcoal outlines, blue accents, identical coding
machines emitting identical raw sheets. Top: accumulated unchecked sheets inside
a dashed integral dW boundary, one person and a fixed C0 inspection gate, checked
output after the gate. Bottom: one new sheet inside a dashed dW boundary directly
inspected by a stick figure at an Endor gate. Gate capacity is C0 + k integral dW.
Checked output becomes a structured document helper, joins historical helpers,
and blue arrows from helpers feed the gate to increase inspection capability.
No dialogue or explanatory prose. Math is a conceptual metaphor, not measured data.
The bitmap master retains the approved artwork, including its mathematical
symbols. Browser optimization changes encoding and dimensions only.