Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e3a505d24 | |||
| cd3e0c695c | |||
| 5514875213 | |||
| ec50b3302e | |||
| 68780f1f2b | |||
| 34f7cae0fe | |||
| 76aa80fedc | |||
| 7901ab940e | |||
| d52dac64e5 | |||
| b3fcb9ad3a | |||
| 91d6c22e6b | |||
| e04ab96187 | |||
| eb7e758570 | |||
| 5ba790eec2 | |||
| ac9ddd9c27 | |||
| ea31287924 | |||
| 5b31fa5b77 | |||
| c833aa56c1 | |||
| 0a640d75d4 | |||
| 0bd2ce6b02 | |||
| cce2fd8581 | |||
| ae84304e16 | |||
| c6e5a7b05f | |||
| 9bfd6891c3 | |||
| 3557ee7e87 | |||
| cc6993de2a | |||
| 462fe01df0 | |||
| 838c9f1bad | |||
| 0d7c24c529 | |||
| 74cd21c6fa | |||
| 3c14c6fdca | |||
| a1587e86bb | |||
| 17d6589db1 | |||
| 708f136163 | |||
| f74b268da4 | |||
| 2f525f97e3 | |||
| 92f7bcffbd | |||
| 1ca8ab72e8 | |||
| 2c1f35f000 | |||
| 5cbaa013a7 | |||
| f72043a4ed | |||
| 21fe8ded32 | |||
| 3c631a43a6 | |||
| 53df8a4a2b | |||
| 5b86f08959 | |||
| 4330817138 | |||
| 72f0928efa | |||
| e113b74c41 | |||
| a1184b31b8 | |||
| 14e0c8ac76 | |||
| ea3433bd60 | |||
| dc17a4d802 | |||
| f49b5b96e7 | |||
| f3d83901a1 | |||
| cb803662c3 | |||
| 7fc6742129 | |||
| 91d4734763 | |||
| 4269d05b2e | |||
| 4be984fa0a | |||
| 643c0df7aa | |||
| 8eb3b888d1 | |||
| e9f48d6d9c | |||
| a67a3b3b81 | |||
| d7251e892b | |||
| c0f9ee50c1 | |||
| ad682df1cc | |||
| cf47c1af31 | |||
| 06a6883be3 | |||
| 9c08d9dc0f | |||
| c0924df7c4 | |||
| 0e1e127d4b | |||
| a9e5ac6a87 | |||
| b81e1ad76e | |||
| 1f87628076 | |||
| a6a1860ef1 | |||
| 94dcac4e95 | |||
| ec0d9abb34 | |||
| d2e83cd33b | |||
| 69c3cc8e64 | |||
| cdb84376cb | |||
| fabfce1f4c | |||
| 142fa62d10 | |||
| 868594c79a | |||
| 83f91f1b1e | |||
| f99a354db2 | |||
| 6410535ed9 | |||
| db3b21bf54 | |||
| 7140ea0469 | |||
| 241c82aa88 | |||
| 5d62dee4e9 | |||
| 543d6ed527 | |||
| 445bfb37d0 | |||
| baaea4fc61 | |||
| ac04839571 | |||
| 7cecb9f753 | |||
| a2a5958fc4 | |||
| 7cc2397103 | |||
| feb4de220d | |||
| 652b0fd539 | |||
| 5ee04f26ae | |||
| 812749823b | |||
| 8a4df5c7ba | |||
| 8d886ad3cc | |||
| 790dda09de | |||
| 5182eca961 | |||
| 5cd8f831be | |||
| a520694f9e | |||
| dc373bad09 | |||
| dc2c3a22f3 | |||
| fefa7c5ebd | |||
| 5c2d9fd06c | |||
| 85a0c534aa | |||
| d4736f7477 | |||
| f41d9e0d53 | |||
| 1736912ecb | |||
| 45f1d1dba3 | |||
| 8d7b10df19 | |||
| d9d675ce56 | |||
| b8a82359e3 | |||
| 4682e3513e | |||
| c0ff99e640 | |||
| b5e4f817fb | |||
| df227df18c | |||
| 1e81887682 | |||
| c25fb79eee | |||
| a8b054381f | |||
| 8e5df06fdd | |||
| 4942b86a4a | |||
| 7e4f4fe3e6 | |||
| 1e716c36b2 | |||
| ba042ed5ae | |||
| 0cc3815bd8 | |||
| f49aac2778 | |||
| d588f2e1fb | |||
| ad1f351a6b | |||
| f73d2631fb | |||
| 6dca32dc66 | |||
| 992275560f | |||
| 74c74ddda9 | |||
| 61201c1bb0 | |||
| 4eb08c0857 | |||
| 3e7792191e | |||
| fa09a01421 | |||
| 87987f6cc6 | |||
| c9497ccc6b | |||
| 05b500e911 | |||
| ea09b414a2 | |||
| 945e7eadb2 | |||
| f86a4a75a0 | |||
| ccba2c546a | |||
| 56b0ee60c0 | |||
| f7cdf16933 | |||
| deadd24dce | |||
| 4f1d3caafd | |||
| 28ced713dc | |||
| c8c8bad563 | |||
| 13f2b31e8a | |||
| 9ee2bd219a | |||
| 20e5097783 | |||
| ae4bd203a1 | |||
| 5b6d3d9bfd | |||
| 6b4fa711c1 | |||
| 76e32490e9 | |||
| fa0bcb15fb | |||
| 12123c278f | |||
| 4273dd77e0 | |||
| 3ab2f8e122 | |||
| 15024c3908 | |||
| 770be83692 | |||
| 083f532609 | |||
| 2b3706c90d | |||
| 6bfeea4e7e | |||
| f156406265 | |||
| e65d90c78b | |||
| 96f1417cda | |||
| 347b9587c6 | |||
| 677cd73945 | |||
| e075eee8a5 | |||
| d971362a97 | |||
| 0e5ccafa30 | |||
| 0c2a909521 | |||
| a58a409c38 | |||
| c4ab5caac0 | |||
| 4cbb88181a | |||
| ce87abd1cb | |||
| dc7ec078e1 | |||
| bba9fddef1 | |||
| 1302287205 | |||
| 59a1e4608b | |||
| ed99743730 | |||
| 74af205b29 | |||
| cffca6942b | |||
| bf58a27779 | |||
| 922835704b | |||
| a80f6568ff | |||
| 864a30356d | |||
| 534795617d | |||
| d57e9b77d7 | |||
| 9a026a08a5 | |||
| 62d3d498bb | |||
| 62c015fa9b | |||
| ec95f3be31 | |||
| 744e3314b8 | |||
| 79a6a8dbd7 | |||
| 0dcd178394 | |||
| dca7c81889 | |||
| f27b82b6a5 | |||
| 25c5e83d08 | |||
| 798c6d17d0 | |||
| c2ea0900e6 | |||
| 1cc2c9f97f | |||
| 62bba8d320 | |||
| 28fa155829 | |||
| db47c1da13 | |||
| 446ad80089 | |||
| b52d1e59a4 | |||
| c7aa6e54c6 | |||
| f93e094ba2 | |||
| 5ee28cef98 | |||
| 8da38694bc | |||
| 850ed8f9a7 | |||
| 43bd5a86bf | |||
| cfb9273f88 | |||
| 3e0cbf9831 | |||
| aa2d3fe244 | |||
| 2e4dd8c6c7 | |||
| 1c4e2fc9b6 | |||
| 69fa0b240f | |||
| 6d26a5b0c1 | |||
| bb9ebaa052 | |||
| d50281a1e9 | |||
| 3e2183798d | |||
| 562618cd68 | |||
| f550fc8955 | |||
| 157144b3f9 | |||
| 3cfce44a25 | |||
| e91255856c | |||
| 1250b674b0 | |||
| 074881b650 | |||
| 1966d7a0a7 | |||
| 319de8a3a7 | |||
| 787491622b | |||
| e448acb904 | |||
| e286139572 | |||
| c49c8348d2 | |||
| 7a9c88a05a | |||
| 422d6622a2 | |||
| bd932c36b2 | |||
| c08371647d | |||
| 8be53c1e25 |
@@ -6,4 +6,9 @@
|
||||
**/*.pyc
|
||||
**/*.tsbuildinfo
|
||||
.artifacts
|
||||
apps/docs/public/figures/**/*.webp
|
||||
.env*
|
||||
|
||||
**/*.sqlite
|
||||
**/*.sqlite-*
|
||||
**/*.db
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# 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`。
|
||||
- 编辑已有文章时优先使用章节 API:先读取章节目录与内部摘要,据此选择需要读取正文的章节,再提交单章或多章操作。摘要缺失时先读对应正文并补充;修改观点前必须核对该章原文,检查读取版本与提交 revision 一致。摘要只写入内部章节数据,不放入网页正文、目录摘要或 Git;改写正文时同步更新摘要。完整操作与 Python 客户端用法见 README 的「先读摘要,再按章节编辑」。
|
||||
- 未确认的架构提议明确标注为草案,不能表述为现有实现。
|
||||
- 本地开发与预览只监听 `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`。
|
||||
|
||||
@@ -1,108 +1,266 @@
|
||||
# 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 篇 |
|
||||
| `GET /api/documents/sections/<slug>` | 读取章节 ID、标题和内部摘要,不返回正文 |
|
||||
| `GET /api/documents/sections/<slug>?section=<id>` | 读取选中章节正文;重复 `section` 可选多章 |
|
||||
| `GET /api/documents/sections/<slug>?revision=2` | 读取对应修订的章节目录;也可搭配 `section` |
|
||||
| `POST /api/documents/sections/<slug>` | 原子执行章节新增、更新、删除和移动 |
|
||||
|
||||
文档标题取首个 `# 标题`,文档排序按相对路径。支持多层目录(例如 `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 只验证,不部署。工作流也支持手动触发。
|
||||
### 先读摘要,再按章节编辑
|
||||
|
||||
## Endor 概念图
|
||||
章节接口使用相同的文档 Key 和读写权限。编辑流程为:先读章节目录及摘要,选择需要深入的章节,再读取这些章节的正文,最后提交一组操作。不要只凭摘要修改未读正文;目录与正文响应的 `revision` 应一致,否则重新读取。接口不调用模型生成摘要:由编辑 Agent 阅读正文、撰写摘要,并在编辑请求中保存。
|
||||
|
||||
`content/docs/00-endor-concepts-draft.md` 解释全球入口、区域网关与 Runtime、分片、CDN、身份及跨区恢复的概念设计,页面为 `/docs/00-endor-concepts-draft`。
|
||||
章节按行首 Markdown ATX 二级标题(`##`)划分,三级至六级标题随所属章节一起编辑;代码围栏内的标题不会切章。二级标题之前的文档标题及导言属于固定 ID `preamble`,可以修改但不能删除或移动。Setext 标题、带缩进或引用、列表标记的标题不作为章节边界。正文无行首二级标题时,全文作为导言处理。拆分及组合保持换行和未编辑内容原样,不自动格式化 Markdown。
|
||||
|
||||
图形源码位于 `tools/figures/src/endor_figures/architecture.py`,由 `pnpm figures` 生成 `apps/docs/public/figures/endor-architecture.svg`。Python 负责可复现的节点、全连接和复制路径,正文直接显示图片,点击可放大;大图支持原尺寸查看、滚动和 Escape/空白处关闭。无需外部图标、字体或绘图服务。图中的数量是示意,身份服务/CDN 接入属于建议,所有未实现的关系均以草案说明。
|
||||
目录中的每章包含 `id`、`title`、`position`、`summary`、`summaryStatus` 和 `contentHash`。摘要为编辑内部数据,单独保存在 SQLite 的 `document_sections` 和 `document_section_revisions` 中,不加入普通文档响应、网页 props、搜索索引或页面正文。完整数据库备份包含这些表;`docs export` 仍只导出当前文档,不包含内部摘要和修订。
|
||||
|
||||
拓扑检查:`uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`。容器检查流程会运行同一检查。
|
||||
旧文章无需重写,首次读取即可按原文返回目录,初始摘要为空,`summaryStatus` 为 `missing`。Agent 应按需读取这些章节,生成摘要并通过 `update` 保存;保存后状态为 `current`。这里的 `current` 仅表示摘要与该版正文一起保存,不保证模型的总结正确。全文接口仍兼容;未变化章节保留摘要与 ID,正文变化的章节清空摘要,等待更新。全文改名且改写章节时可能产生新 ID,因此全文保存后应重新读取目录。
|
||||
|
||||
## 动态码登录与二维码
|
||||
章节编辑请求示例(示例 ID 需要替换为目录实际返回的值):
|
||||
|
||||
与 PROMPT 的参考流程一致:共享验证器的6位TOTP动态码,每30秒更新;成功登录的HttpOnly、SameSite=Lax会话有效7天。此方案是动态码单因素登录,不是密码加动态码的双因素组合。
|
||||
```json
|
||||
{
|
||||
"revision": 3,
|
||||
"operations": [
|
||||
{
|
||||
"op": "update",
|
||||
"id": "s_existing",
|
||||
"content": "## 原有章节\n\n修改后的正文。\n\n",
|
||||
"summary": "本章说明修改后的核心观点与边界。"
|
||||
},
|
||||
{
|
||||
"op": "insert",
|
||||
"after": "s_existing",
|
||||
"content": "## 新章节\n\n新增论证。\n\n",
|
||||
"summary": "补充上一章尚未覆盖的论证。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
正文、SVG、文档数据与API都经过认证,仅登录入口、登录所需代码资源和最小健康检查公开。20次/5分钟的共享尝试上限与已用时间步持久保存在SQLite中,跨进程和重启继续生效。缺失或损坏配置时拒绝访问,不自动降为公开站点。
|
||||
`update` 可只改 `summary`,也可改正文;正文改变时若不提供新摘要,旧摘要会清空。`insert` 必须提供正文和非空摘要;摘要最多 8000 个 JavaScript 字符单位。`delete` 使用 `{ "op": "delete", "id": "s_existing" }`;`move` 使用 `{ "op": "move", "id": "s_existing", "after": "preamble" }`。按数组顺序执行,最多 100 个操作;新章 ID 由服务器生成,在回执目录中返回。章正文应从自己的 `##` 标题开始,只包含一个二级标题,并保留与下一章之间的换行;跨章代码围栏或章节边界改变会被拒绝,拆章请使用多个操作。
|
||||
|
||||
生产认证文件位于服务器 `/opt/endorphin-tabularium-docs/private-auth`,目录0700、文件0600,只挂载进本项目容器。部署只初始化一次,后续更新保留同一验证器密钥与会话签名密钥。生产镜像、Git、CI日志和网页不包含密钥。
|
||||
章节变更、内部摘要、组合后的全文和历史快照在同一事务内提交。每批操作只增加一次文章 `revision`;摘要单独更新也增加版本号。后续操作失败或全文版本过期时全部回滚。写入沿用幂等规则,回执包含新版本、章节目录和 `replayed`;章节接口与全文接口的幂等记录分开。修改章节不会改变其 ID,移动亦保留 ID;删除后的正文和摘要仍可从对应的旧修订读取。启用前的历史修订只有原文,摘要显示缺失。
|
||||
|
||||
管理员通过SSH管理:
|
||||
Python 客户端对应命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/documents-client.py outline endor/endor-purpose-draft
|
||||
python3 scripts/documents-client.py sections endor/endor-purpose-draft preamble s_existing
|
||||
python3 scripts/documents-client.py edit-sections endor/endor-purpose-draft /private/path/edit.json --request-id chapter-edit-1
|
||||
```
|
||||
|
||||
`outline` 和 `sections` 支持 `--revision`;请求文件只包含 `revision` 和 `operations`,不要把内部摘要文件提交到 Git。
|
||||
|
||||
### 创建、查看与撤销 Key
|
||||
|
||||
管理员只需通过已验证的服务器连接进行 Key 管理:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
创建命令将完整 Key 写入认证私有目录中的指定新文件,权限 `0600`,终端仅输出 ID、权限和到期信息。文件已存在时拒绝覆盖。权限可选 `read` 或 `read-write`;有效期默认 90 天,可设 1—365 天。数据库仅保存 Key 摘要,列表不返回原文;撤销后不能继续认证。独立 Key 文件和认证 config.json 都不包含在 SQLite 整库备份中。
|
||||
|
||||
### 使用上传客户端
|
||||
|
||||
`scripts/documents-client.py` 仅使用 Python 标准库。通过 `TABULARIUM_DOCS_URL` 指定站点 origin,通过 `TABULARIUM_DOCS_KEY_FILE` 指定本机私有 Key 文件(支持创建命令输出的 JSON),不把 Key 放入命令行参数。
|
||||
|
||||
客户端要求 HTTPS;尚未配置 HTTPS 时,可通过已验证的 SSH 连接建立本机端口转发,连接 `http://127.0.0.1:18136`。以下 `$DOCS_SSH_TARGET` 应事先设置为受信任的 SSH 别名或 `user@host`:
|
||||
|
||||
```sh
|
||||
ssh -N -L 18136:127.0.0.1:18135 "$DOCS_SSH_TARGET"
|
||||
```
|
||||
|
||||
将服务器私有目录中的 Key 文件复制到受保护的本机位置后,在另一个终端配置客户端。`$TABULARIUM_DOCS_KEY_FILE` 文件须仅本人可读写,不能提交到仓库:
|
||||
|
||||
```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 内置任务令牌且不保存凭据。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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, 1253);
|
||||
assert.equal(research.categories.length, 11);
|
||||
assert.equal(research.evidence.length, 4894);
|
||||
assert.equal(research.observations.length, 491);
|
||||
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("&", "&"),
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }); }
|
||||
@@ -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,187 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import IncomeDistribution from "./IncomeDistribution";
|
||||
import ArrDisclosures from "./ArrDisclosures";
|
||||
import BusinessModelAssessment from "./BusinessModelAssessment";
|
||||
import ResearchCandidates from "./ResearchCandidates";
|
||||
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" | "candidates" | "revenue" | "models" | "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','公司与产品'],['candidates','候选与缺项'],['revenue','收入观测'],['models','商业模式初评'],['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 === "models" && <BusinessModelAssessment data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||||
{tab === "candidates" && <ResearchCandidates data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||||
{tab === "revenue" && <>
|
||||
<IncomeDistribution data={data} onSelect={setSelected} />
|
||||
<ArrDisclosures 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,69 @@
|
||||
import { useState } from "react";
|
||||
import { evidenceUrl, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
import { arrDisclosureCoverage, arrDisclosures, arrFieldGaps, type ArrFieldGap } from "@/lib/research-statistics";
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
signal: "企业或当事方披露", estimate: "第三方估算",
|
||||
historical: "历史披露", "historical-estimate": "历史估算",
|
||||
};
|
||||
const gapLabels: Record<ArrFieldGap, string> = {
|
||||
date: "缺有效财务截至日", currency: "USD 未确认(含其他币种)",
|
||||
amount: "下界、近似或非精确金额", estimate: "第三方估算",
|
||||
};
|
||||
|
||||
export default function ArrDisclosures({ data, onSelect }: { data: ResearchSnapshot; onSelect: (id: string) => void }) {
|
||||
const [category, setCategory] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [gap, setGap] = useState<ArrFieldGap | "">("");
|
||||
const claims = arrDisclosures(data, { category, foundedYear: year ? Number(year) : undefined });
|
||||
const rows = claims.filter(({ observation }) => !gap || arrFieldGaps(observation, data.asOf).includes(gap));
|
||||
const gapCounts = (Object.keys(gapLabels) as ArrFieldGap[]).map((id) => ({
|
||||
id, count: claims.filter(({ observation }) => arrFieldGaps(observation, data.asOf).includes(id)).length,
|
||||
}));
|
||||
const completeFields = claims.filter(({ observation }) => !arrFieldGaps(observation, data.asOf).length).length;
|
||||
const coverage = arrDisclosureCoverage(data, year ? Number(year) : undefined);
|
||||
const companies = new Set(claims.map((row) => row.company.id));
|
||||
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
return <section aria-label="ARR披露分类表">
|
||||
<h3>2025—2026 年成立公司的 ARR 披露</h3>
|
||||
<p className="research-notice">这是跨时点证据表,共 {companies.size} 家公司、{claims.length} 条记录;同一公司的历史里程碑逐条保留,不能相加或视为独立公司。ARR 标签沿用原始披露,未证明经常性收入定义或审计状态;不纳入 MRR 图表,不计算同期行业均值或造假概率。仅收录明确标注的 ARR;定义冲突、目标、承诺、客户和前雇主收入排除,缺失不作零。</p>
|
||||
<div className="research-filters">
|
||||
<label>业务分类<select value={category} onChange={(event) => setCategory(event.target.value)}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</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={gap} onChange={(event) => setGap(event.target.value as ArrFieldGap | "")}><option value="">全部记录</option>{gapCounts.map((item) => <option key={item.id} value={item.id}>{gapLabels[item.id]}({item.count} 条)</option>)}</select></label>
|
||||
</div>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>ARR 披露覆盖:按公司当前分类及所选成立年份、每家公司计一次。点击分类筛选下方证据表。</caption>
|
||||
<thead><tr><th>业务分类</th><th>已采集公司</th><th>企业或当事方披露</th><th>仅第三方估算</th><th>缺少 ARR</th><th>披露覆盖率</th></tr></thead>
|
||||
<tbody>{coverage.filter((item) => item.companies > 0).map((item) => <tr key={item.id}>
|
||||
<td><button onClick={() => setCategory(item.id)} aria-pressed={category === item.id}>{item.name}</button></td>
|
||||
<td>{item.companies}</td><td>{item.reported}</td><td>{item.estimatedOnly}</td><td>{item.missing}</td>
|
||||
<td>{new Intl.NumberFormat("zh-CN", { style: "percent", maximumFractionDigits: 1 }).format(item.disclosed / item.companies)}</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
<p className="research-muted">分母含仅有目录资料的公司,覆盖率反映本次采集深度,不代表行业披露率。分类及收费模式采用公司当前资料;公司转向或同时经营多个产品时,历史 ARR 未必属于当前业务,需打开公司核对产品、主体与期间,不能据当前分类重归历史收入。企业披露含历史里程碑,同一公司兼有披露和估算时归企业披露;缺少 ARR 的公司仍可能有其他收入。披露时点、币种与合同定义未对齐,不能将已披露少数公司的均值外推至整个分类,也不能由离散性判断财务造假。</p>
|
||||
<aside className="research-notice" aria-label="ARR比较前的补证检查">
|
||||
<strong>ARR 比较前的补证检查</strong>
|
||||
<p>当前分类及成立年下有 {claims.length} 条披露,{completeFields} 条同时具有有效日期、明确 USD、精确金额且不是第三方估算。补证筛选后显示 {rows.length} 条。</p>
|
||||
<div className="income-options">{gapCounts.map((item) => <button key={item.id} aria-pressed={gap === item.id} onClick={() => setGap(gap === item.id ? "" : item.id)}>{gapLabels[item.id]}:{item.count}</button>)}</div>
|
||||
<p>各项可重叠,不能相加为独立公司数。日期必须是经营指标截至日,网页访问、发布或更新时间不能代替;其他币种保留原值,不擅自换算。基础字段齐全也不证明可比,还需对齐收入定义、业务期间、收费模式、主体与独立验证;这些缺项不作为造假判定。</p>
|
||||
</aside>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>金额保留原币种、下界和估算身份;日期栏保留业务截至日缺项</caption>
|
||||
<thead><tr><th>公司 / 当前分类</th><th>当前收费与交付模式</th><th>原始 ARR 声明</th><th>时点</th><th>身份 / 限制</th><th>证据</th></tr></thead>
|
||||
<tbody>{rows.map(({ company, observation: item }) => {
|
||||
const source = evidence.get(item.evidence);
|
||||
const url = source && evidenceUrl(source.url);
|
||||
return <tr key={item.id}>
|
||||
<td><button onClick={() => onSelect(company.id)}>{company.name}</button><small>{categories.get(company.cat)} · {company.year}</small></td>
|
||||
<td>{company.businessModel ?? company.price}</td>
|
||||
<td>{item.bound !== "=" && item.bound}{new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(item.value)} {item.unit}<small>{item.kind}</small></td>
|
||||
<td>{item.timing}</td><td>{labels[item.selection]}<small>{item.reason}</small><small>{arrFieldGaps(item, data.asOf).map((id) => gapLabels[id]).join(";") || "基础字段齐全;定义、期间与主体仍须核实"}</small></td>
|
||||
<td>{url ? <a href={url} target="_blank" rel="noopener noreferrer">{item.evidence} ↗</a> : item.evidence}</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
{!rows.length && <p>当前分类、成立年与补证筛选没有符合条件的已记录 ARR 声明;不代表这些公司收入为零。</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { businessModelReviewType, evidenceUrl, reviewCompanyIds, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
|
||||
export default function BusinessModelAssessment({ data, onSelect, onChooseCategory }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
|
||||
}) {
|
||||
const reviews = data.reviews.filter((review) => review.type === businessModelReviewType && review.scope === "companies");
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
return <section aria-labelledby="research-models-title">
|
||||
<h3 id="research-models-title">商业模式初评</h3>
|
||||
<p>从已核个案提炼收费方式、成本约束与下一步验证条件。已知事实与研究判断分列;同一种收费方式可以跨业务分类,以下个案不能代表整个行业。</p>
|
||||
<p className="research-muted">这是基于当前证据的初步评估,不是收入预测或盈利排名。判断能否用于我们的业务,还需用实际客户任务、付费留存与交付成本验证;ARR 或平台 MRR 单独不能证明利润。</p>
|
||||
{reviews.length ? <div className="research-table-wrap"><table>
|
||||
<caption>收费模式与业务分类、公司明细及原始证据相互关联</caption>
|
||||
<thead><tr><th scope="col">模式 / 业务分类</th><th scope="col">已核事实</th><th scope="col">初步判断 / 验证条件</th><th scope="col">参考个案与证据</th></tr></thead>
|
||||
<tbody>{reviews.map((review) => {
|
||||
const companyIds = new Set(reviewCompanyIds(data, review));
|
||||
const companies = data.companies.filter((company) => companyIds.has(company.id));
|
||||
const categories = data.categories.filter((category) => companies.some((company) => company.cat === category.id));
|
||||
return <tr key={review.id}>
|
||||
<td><strong>{review.company}</strong><div>{categories.map((category) => <button key={category.id} onClick={() => onChooseCategory(category.id)}>{category.name}</button>)}</div></td>
|
||||
<td>{review.summary}</td><td>{review.handling}</td>
|
||||
<td><div>{companies.map((company) => <button key={company.id} className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button>)}</div>
|
||||
<div>{review.evidenceIds.map((id) => {
|
||||
const item = evidence.get(id); const url = item && evidenceUrl(item.url);
|
||||
return item && url ? <p key={id}><a href={url} target="_blank" rel="noopener noreferrer">{item.title} ↗</a></p> : null;
|
||||
})}</div>
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div> : <p>当前数据版本尚未记录商业模式初评。</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -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,102 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ResearchSnapshot } from "@/lib/ai-research";
|
||||
import { compareIncomeGroups, 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 comparisons = compareIncomeGroups(groups);
|
||||
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 ? <>
|
||||
<aside aria-label="当前窗口样本解读" className="research-notice">
|
||||
<strong>当前窗口的样本解读</strong>
|
||||
{comparisons.length ? comparisons.map((comparison) => <div key={comparison.key}>
|
||||
<p>{comparison.provider} · {comparison.model}(仅比较各组 n≥3):
|
||||
{comparison.leastDispersed.stats.cv === comparison.mostDispersed.stats.cv
|
||||
? <>各组 CV 相同({comparison.leastDispersed.stats.cv?.toFixed(3)}),没有离散度高低差异。</>
|
||||
: <>{categories.get(comparison.mostDispersed.category)}更离散(CV {comparison.mostDispersed.stats.cv?.toFixed(3)}),{categories.get(comparison.leastDispersed.category)}较集中(CV {comparison.leastDispersed.stats.cv?.toFixed(3)})。</>}
|
||||
{comparison.leastSensitive.stats.largestRemovalDrop === comparison.mostSensitive.stats.largestRemovalDrop
|
||||
? <>各组移除最大值后的均值下降幅度相同({percent(comparison.leastSensitive.stats.largestRemovalDrop)})。</>
|
||||
: <>{categories.get(comparison.mostSensitive.category)}的均值更受最大值影响,移除后下降 {percent(comparison.mostSensitive.stats.largestRemovalDrop)};{categories.get(comparison.leastSensitive.category)}下降 {percent(comparison.leastSensitive.stats.largestRemovalDrop)}。</>}
|
||||
</p>
|
||||
</div>) : <p>当前没有至少两个可对照的统计组。需要相同支付平台、明确的收费模式标签,且各组至少 3 个产品;可调整同步窗口或创立年,不能将单个产品或两点均值当作领域典型。</p>}
|
||||
<p>这些是所选产品样本内的描述,较集中或较不敏感也不证明行业均值可靠。最后同步日并非统一财务结账日,产品年龄、主体独立性、成本和留存仍待核;参考年化也不是已披露 ARR。</p>
|
||||
</aside>
|
||||
<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 className="income-statistics"><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>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { evidenceUrl, type ResearchCandidateBatch, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
|
||||
type CandidateStatus = ResearchCandidateBatch["records"][number]["status"];
|
||||
const statusLabels: Record<CandidateStatus, string> = {
|
||||
"pending-year": "成立年或主体待核", "pending-ai": "AI 主营待核", linked: "已关联公司",
|
||||
"existing-outside-cohort": "已关联范围外对照", "outside-cohort": "范围外候选", promotion: "推广活动",
|
||||
};
|
||||
const pageSize = 20;
|
||||
const sourceNames: Record<string, string> = {
|
||||
S01: "Y Combinator", S02: "TechCrunch AI", S04: "Product Hunt", S05: "Sacra",
|
||||
S10: "机器之心", S11: "36氪", S13: "TrustMRR", S14: "a16z Speedrun",
|
||||
};
|
||||
const sourceLabel = (id: string) => `${id} · ${sourceNames[id] ?? "其他来源"}`;
|
||||
|
||||
function SourceLink({ url, label }: { url: string | null; label: string }) {
|
||||
const safe = url && evidenceUrl(url);
|
||||
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{label} ↗</a> : <span>{label}(链接未取得)</span>;
|
||||
}
|
||||
|
||||
export default function ResearchCandidates({ data, onSelect, onChooseCategory }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const [batchId, setBatchId] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [status, setStatus] = useState<CandidateStatus | "pending" | "">("pending");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const all = useMemo(() => (data.candidateBatches ?? []).flatMap((batch) => batch.records.map((record) => ({ batch, record }))), [data.candidateBatches]);
|
||||
const sources = [...new Set(all.map(({ batch }) => batch.sourceId))].sort();
|
||||
const batches = (data.candidateBatches ?? []).filter((batch) => !source || batch.sourceId === source);
|
||||
const matched = all.filter(({ batch, record }) => (!source || batch.sourceId === source)
|
||||
&& (!batchId || batch.id === batchId) && (!category || record.categoryId === category)
|
||||
&& `${record.name} ${record.id} ${record.note}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const pending = (value: CandidateStatus) => value === "pending-year" || value === "pending-ai";
|
||||
const rows = matched.filter(({ record }) => !status || (status === "pending" ? pending(record.status) : record.status === status));
|
||||
const companies = new Map(data.companies.map((company) => [company.id, company]));
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
|
||||
return <section aria-labelledby="research-candidates-title">
|
||||
<h3 id="research-candidates-title">候选与缺项</h3>
|
||||
<p>展示已登记的有限来源批次及后续核查。默认显示成立年、主体或 AI 主营待核的条目;待核不是确认排除,也不计入公司表。每行是一次发现位置,同一公司或产品可以重复出现。</p>
|
||||
<p className="research-muted">候选初分类与公司表数量分开,来源批次不等全年或全站名单。原始发现范围及后续结论见每行说明;暂无收入资料不作零,推广活动也不算公司。</p>
|
||||
<div className="research-filters">
|
||||
<label className="research-query">搜索候选<input type="search" value={query} placeholder="名称、编号或核查说明" onChange={(event) => { setQuery(event.target.value); setPage(0); }} /></label>
|
||||
<label>发现来源<select value={source} onChange={(event) => { setSource(event.target.value); setBatchId(""); setPage(0); }}><option value="">全部已登记来源</option>{sources.map((id) => <option key={id} value={id}>{sourceLabel(id)}</option>)}</select></label>
|
||||
<label>发现批次<select value={batchId} onChange={(event) => { setBatchId(event.target.value); setPage(0); }}><option value="">全部批次</option>{batches.map((batch) => <option key={batch.id} value={batch.id}>{batch.id}</option>)}</select></label>
|
||||
<label>候选初分类<select value={category} onChange={(event) => { setCategory(event.target.value); setPage(0); }}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
||||
<label>核查状态<select value={status} onChange={(event) => { setStatus(event.target.value as typeof status); setPage(0); }}>
|
||||
<option value="">全部状态({matched.length})</option><option value="pending">全部待核({matched.filter(({ record }) => pending(record.status)).length})</option>
|
||||
{Object.entries(statusLabels).map(([id, label]) => <option key={id} value={id}>{label}({matched.filter(({ record }) => record.status === id).length})</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<p role="status">{rows.length} 个发现位置匹配;不是独立公司数。</p>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>有限来源候选与处理记录;选择关联公司查看主数据,原始证据直接链接来源</caption>
|
||||
<thead><tr><th scope="col">候选</th><th scope="col">初分类 / 状态</th><th scope="col">来源及范围</th><th scope="col">核查结论 / 缺项</th><th scope="col">关联公司 / 证据</th></tr></thead>
|
||||
<tbody>{rows.slice(page * pageSize, (page + 1) * pageSize).map(({ batch, record }) => {
|
||||
const company = record.companyId && companies.get(record.companyId);
|
||||
return <tr key={record.id}>
|
||||
<td><SourceLink url={record.url} label={record.name} /><small>{record.id}</small></td>
|
||||
<td><button onClick={() => onChooseCategory(record.categoryId)} aria-label={`查看${categories.get(record.categoryId)}分类内的公司`}>{categories.get(record.categoryId)}</button><small>{statusLabels[record.status]}</small></td>
|
||||
<td><SourceLink url={batch.url} label={sourceLabel(batch.sourceId)} /><small>{batch.id} · 读取 {batch.accessedAt}</small><details><summary>发现批次范围</summary><p>{batch.scope}</p><p>{batch.complete ? "该批次标记已完成;不代表来源全站覆盖。" : "该批次仍保留覆盖缺项。"}</p></details></td>
|
||||
<td>{record.note}</td>
|
||||
<td>{company ? <button className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button> : <small>尚未关联公司主记录</small>}
|
||||
{record.evidenceIds?.map((id) => { const item = evidence.get(id); return item ? <p key={id}><SourceLink url={item.url} label={id} /></p> : null; })}
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
{!rows.length && <p>当前筛选没有已登记的候选记录;不代表该来源或分类没有公司。</p>}
|
||||
{rows.length > pageSize && <nav className="research-pagination" aria-label="候选与缺项分页"><button disabled={page === 0} onClick={() => setPage((value) => value - 1)}>上一页</button><span>{page + 1} / {Math.ceil(rows.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= rows.length} onClick={() => setPage((value) => value + 1)}>下一页</button></nav>}
|
||||
</section>;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
export type ResearchCategory = { id: string; name: string; description: string };
|
||||
export const businessModelReviewType = "商业模式初评";
|
||||
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" | "pending-ai" | "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", "pending-ai", "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)) ||
|
||||
(["outside-cohort", "pending-ai"].includes(row.status) && !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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
+296
-33
@@ -1,44 +1,307 @@
|
||||
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";
|
||||
import { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
import { sectionSchema, loadSections, reconcileSections, persistSections, splitSections, outline,
|
||||
applySectionOperations, parseSectionEdit, type DocumentSection } from "./document-sections";
|
||||
export { DocumentValidationError, DocumentConflictError } from "./document-errors";
|
||||
|
||||
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 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
|
||||
);
|
||||
`);
|
||||
sectionSchema(db);
|
||||
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, editedSections?: DocumentSection[]): Doc {
|
||||
validateDocument(input);
|
||||
const existing = db.prepare("SELECT id, revision, payload FROM documents WHERE source_slug=?")
|
||||
.get(input.sourceSlug) as { id: string; revision: number; payload: string } | undefined;
|
||||
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
|
||||
const id = existing?.id ?? randomUUID();
|
||||
const previous = existing ? loadSections(db, id, (JSON.parse(existing.payload) as Doc).content) : [];
|
||||
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());
|
||||
persistSections(db, id, doc.revision, editedSections ?? reconcileSections(doc.content, previous));
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Internal summaries never enter Doc, catalog, search or browser page props.
|
||||
export function readDocumentSections(slug: string, revision?: number) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1)) throw new DocumentValidationError("Invalid revision");
|
||||
const db = database();
|
||||
try {
|
||||
db.exec("BEGIN");
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
let doc = JSON.parse(row.payload) as Doc;
|
||||
let sections: DocumentSection[];
|
||||
if (revision === undefined || revision === doc.revision) sections = loadSections(db, row.id, doc.content);
|
||||
else {
|
||||
const prior = db.prepare("SELECT payload FROM document_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
if (!prior) { db.exec("COMMIT"); return null; }
|
||||
doc = JSON.parse(prior.payload) as Doc;
|
||||
const snapshot = db.prepare("SELECT payload FROM document_section_revisions WHERE document_id=? AND revision=?")
|
||||
.get(row.id, revision) as { payload: string } | undefined;
|
||||
sections = snapshot ? JSON.parse(snapshot.payload) as DocumentSection[] : splitSections(doc.content);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
return { slug: doc.slug, sourceSlug: doc.sourceSlug, title: doc.title, revision: doc.revision, sections };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
export function editDocumentSections(slug: string, value: unknown, request?: { namespace: string; id: string }) {
|
||||
if (!validSlug.test(slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const edit = parseSectionEdit(value);
|
||||
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({ slug, edit })).digest("hex");
|
||||
const namespace = request ? request.namespace + ":sections" : undefined;
|
||||
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(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 { ...JSON.parse(prior.response) as { slug: string; sourceSlug: string; revision: number; sections: ReturnType<typeof outline> }, replayed: true };
|
||||
}
|
||||
}
|
||||
const row = db.prepare(`SELECT d.id,d.payload FROM documents d JOIN document_aliases a
|
||||
ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { id: string; payload: string } | undefined;
|
||||
if (!row) { db.exec("COMMIT"); return null; }
|
||||
const doc = JSON.parse(row.payload) as Doc;
|
||||
if (doc.revision !== edit.revision) throw new DocumentConflictError("Document revision conflict");
|
||||
const previous = loadSections(db, row.id, doc.content);
|
||||
const sections = applySectionOperations(previous, edit.operations);
|
||||
// Preserve the initial outline when upgrading an existing document for the first time.
|
||||
db.prepare("INSERT OR IGNORE INTO document_section_revisions VALUES (?,?,?)").run(row.id, doc.revision, JSON.stringify(previous));
|
||||
const saved = write(db, { ...doc, content: sections.map((s) => s.content).join("") }, doc.revision, sections);
|
||||
const receipt = { slug: saved.slug, sourceSlug: saved.sourceSlug, revision: saved.revision, sections: outline(sections) };
|
||||
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
|
||||
.run(namespace!, request.id, digest, JSON.stringify(receipt));
|
||||
db.exec("COMMIT");
|
||||
return { ...receipt, replayed: false };
|
||||
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export class DocumentValidationError extends Error {}
|
||||
export class DocumentConflictError extends Error {}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { DocumentValidationError } from "./document-errors";
|
||||
|
||||
export type DocumentSection = {
|
||||
id: string; title: string; content: string; summary: string;
|
||||
};
|
||||
export type SectionOperation =
|
||||
| { op: "update"; id: string; content?: string; summary?: string }
|
||||
| { op: "insert"; after: string; content: string; summary: string }
|
||||
| { op: "delete"; id: string }
|
||||
| { op: "move"; id: string; after: string };
|
||||
export type SectionEdit = { revision: number; operations: SectionOperation[] };
|
||||
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
// Preserve every byte, including CRLF and separators. H3-H6 belong to their H2 chapter.
|
||||
export function splitSections(content: string): DocumentSection[] {
|
||||
const starts: { offset: number; title: string }[] = [{ offset: 0, title: "导言" }];
|
||||
let offset = 0;
|
||||
let fence: { marker: string; length: number } | undefined;
|
||||
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
||||
const opening = line.match(/^ {0,3}(`{3,}|~{3,})(.*?)(?:\r?\n)?$/);
|
||||
if (fence) {
|
||||
if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*\\r?\\n?$`).test(line)) fence = undefined;
|
||||
} else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
|
||||
fence = { marker: opening[1][0], length: opening[1].length };
|
||||
} else {
|
||||
const heading = line.match(/^##(?:[ \t]+(.*?))?[ \t]*\r?\n?$/);
|
||||
if (heading) starts.push({ offset, title: (heading[1] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim() });
|
||||
}
|
||||
offset += line.length;
|
||||
}
|
||||
const seen = new Map<string, number>();
|
||||
return starts.map((start, index) => {
|
||||
const occurrence = seen.get(start.title) ?? 0;
|
||||
seen.set(start.title, occurrence + 1);
|
||||
return { id: index === 0 ? "preamble" : "s_" + hash(`${start.title}\0${occurrence}`).slice(0, 24),
|
||||
title: start.title, content: content.slice(start.offset, starts[index + 1]?.offset), summary: "" };
|
||||
});
|
||||
}
|
||||
|
||||
export function sectionSchema(db: DatabaseSync) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS document_sections (
|
||||
document_id TEXT PRIMARY KEY REFERENCES documents(id), payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS document_section_revisions (
|
||||
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL, PRIMARY KEY(document_id, revision)
|
||||
);`);
|
||||
}
|
||||
|
||||
export function loadSections(db: DatabaseSync, documentId: string, content: string): DocumentSection[] {
|
||||
const row = db.prepare("SELECT payload FROM document_sections WHERE document_id=?")
|
||||
.get(documentId) as { payload: string } | undefined;
|
||||
if (!row) return splitSections(content);
|
||||
const sections = JSON.parse(row.payload) as DocumentSection[];
|
||||
// A legacy writer must not leave an apparently current outline over different Markdown.
|
||||
return sections.map((s) => s.content).join("") === content ? sections : reconcileSections(content, sections);
|
||||
}
|
||||
|
||||
export function reconcileSections(content: string, previous: DocumentSection[]): DocumentSection[] {
|
||||
const unused = new Set(previous);
|
||||
return splitSections(content).map((section) => {
|
||||
const prior = section.id === "preamble" ? previous.find((p) => p.id === "preamble") :
|
||||
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.content === section.content) ??
|
||||
previous.find((p) => p.id !== "preamble" && unused.has(p) && p.title === section.title);
|
||||
if (prior) unused.delete(prior);
|
||||
return { ...section, id: prior?.id ?? (section.id === "preamble" ? "preamble" : "s_" + randomUUID()),
|
||||
summary: prior?.content === section.content ? prior.summary : "" };
|
||||
});
|
||||
}
|
||||
|
||||
export function persistSections(db: DatabaseSync, documentId: string, revision: number, sections: DocumentSection[]) {
|
||||
const payload = JSON.stringify(sections);
|
||||
db.prepare(`INSERT INTO document_sections VALUES (?,?) ON CONFLICT(document_id)
|
||||
DO UPDATE SET payload=excluded.payload`).run(documentId, payload);
|
||||
db.prepare("INSERT INTO document_section_revisions VALUES (?,?,?)").run(documentId, revision, payload);
|
||||
}
|
||||
|
||||
export function outline(sections: DocumentSection[]) {
|
||||
return sections.map(({ content, ...section }, position) => ({ ...section, position,
|
||||
contentHash: hash(content), summaryStatus: section.summary.trim() ? "current" : "missing" }));
|
||||
}
|
||||
|
||||
export function parseSectionEdit(value: unknown): SectionEdit {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected section edit object");
|
||||
const row = value as Record<string, unknown>;
|
||||
if (Object.keys(row).some((key) => !["revision", "operations"].includes(key)) ||
|
||||
!Number.isSafeInteger(row.revision) || (row.revision as number) < 1 || !Array.isArray(row.operations) ||
|
||||
!row.operations.length || row.operations.length > 100) throw new DocumentValidationError("Provide revision and 1 to 100 operations");
|
||||
for (const raw of row.operations) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DocumentValidationError("Invalid operation");
|
||||
const op = raw as Record<string, unknown>;
|
||||
const fields: Record<string, string[]> = { update: ["op", "id", "content", "summary"],
|
||||
insert: ["op", "after", "content", "summary"], delete: ["op", "id"], move: ["op", "id", "after"] };
|
||||
if (typeof op.op !== "string" || !Object.hasOwn(fields, op.op) || Object.keys(op).some((key) => !fields[op.op as string].includes(key)))
|
||||
throw new DocumentValidationError("Unknown operation or field");
|
||||
for (const key of fields[op.op].filter((key) => !["content", "summary", "op"].includes(key)))
|
||||
if (typeof op[key] !== "string" || !(op[key] as string).length) throw new DocumentValidationError("Missing section identifier");
|
||||
for (const key of ["content", "summary"])
|
||||
if (op[key] !== undefined && typeof op[key] !== "string") throw new DocumentValidationError(`Invalid ${key}`);
|
||||
if (op.op === "update" && op.content === undefined && op.summary === undefined) throw new DocumentValidationError("Empty update");
|
||||
if (op.op === "insert" && (typeof op.content !== "string" || typeof op.summary !== "string" || !op.summary.trim()))
|
||||
throw new DocumentValidationError("Insert requires content and summary");
|
||||
if (typeof op.summary === "string" && (!op.summary.trim() || op.summary.length > 8000))
|
||||
throw new DocumentValidationError("Summary must contain 1 to 8000 characters");
|
||||
}
|
||||
return { revision: row.revision as number, operations: row.operations as SectionOperation[] };
|
||||
}
|
||||
|
||||
export function applySectionOperations(previous: DocumentSection[], operations: SectionOperation[]) {
|
||||
const sections = previous.map((section) => ({ ...section }));
|
||||
function index(id: string) {
|
||||
const result = sections.findIndex((s) => s.id === id);
|
||||
if (result < 0) throw new DocumentValidationError("Unknown section id");
|
||||
return result;
|
||||
}
|
||||
for (const op of operations) {
|
||||
if (op.op === "insert") {
|
||||
sections.splice(index(op.after) + 1, 0, { id: "s_" + randomUUID(), title: "", content: op.content, summary: op.summary });
|
||||
} else {
|
||||
const at = index(op.id);
|
||||
if (op.op === "update") {
|
||||
if (op.content !== undefined && op.content !== sections[at].content) sections[at].summary = "";
|
||||
if (op.content !== undefined) sections[at].content = op.content;
|
||||
if (op.summary !== undefined) sections[at].summary = op.summary;
|
||||
} else {
|
||||
if (op.id === "preamble") throw new DocumentValidationError("Cannot delete or move the preamble");
|
||||
if (op.op === "delete") sections.splice(at, 1);
|
||||
else {
|
||||
if (op.id === op.after) throw new DocumentValidationError("Cannot move section after itself");
|
||||
index(op.after);
|
||||
const [section] = sections.splice(at, 1);
|
||||
sections.splice(index(op.after) + 1, 0, section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const content = sections.map((s) => s.content).join("");
|
||||
const parsed = splitSections(content);
|
||||
if (parsed.length !== sections.length || parsed.some((s, i) => s.content !== sections[i].content))
|
||||
throw new DocumentValidationError("Each chapter must start with one H2 heading; preserve newline boundaries and balanced fences");
|
||||
return sections.map((s, i) => ({ ...s, title: parsed[i].title }));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
|
||||
|
||||
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
|
||||
|
||||
/** An evidence ledger, never a comparable sample: retain historical claims and estimates separately. */
|
||||
export function arrDisclosures(snapshot: ResearchSnapshot, options: { category?: string; foundedYear?: number } = {}) {
|
||||
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||
return snapshot.observations.flatMap((observation) => {
|
||||
const company = companies.get(observation.company);
|
||||
if (!company || ![2025, 2026].includes(company.year ?? 0) || observation.metric !== "arr" ||
|
||||
!["signal", "estimate", "historical", "historical-estimate"].includes(observation.selection) ||
|
||||
!Number.isFinite(observation.value) || observation.value < 0 ||
|
||||
(options.category && company.cat !== options.category) ||
|
||||
(options.foundedYear && company.year !== options.foundedYear)) return [];
|
||||
return [{ company, observation }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Count companies once; missing ARR is a coverage gap, never zero revenue. */
|
||||
export function arrDisclosureCoverage(snapshot: ResearchSnapshot, foundedYear?: number) {
|
||||
const rows = arrDisclosures(snapshot, { foundedYear });
|
||||
return snapshot.categories.map((category) => {
|
||||
const companies = snapshot.companies.filter((company) => company.cat === category.id &&
|
||||
[2025, 2026].includes(company.year ?? 0) && (!foundedYear || company.year === foundedYear));
|
||||
const claims = rows.filter((row) => row.company.cat === category.id);
|
||||
const disclosed = new Set(claims.map((row) => row.company.id));
|
||||
const reported = new Set(claims.filter((row) =>
|
||||
["signal", "historical"].includes(row.observation.selection)).map((row) => row.company.id));
|
||||
return { id: category.id, name: category.name, companies: companies.length,
|
||||
disclosed: disclosed.size, reported: reported.size, estimatedOnly: disclosed.size - reported.size,
|
||||
missing: companies.length - disclosed.size, records: claims.length };
|
||||
});
|
||||
}
|
||||
|
||||
export type ArrFieldGap = "date" | "currency" | "amount" | "estimate";
|
||||
|
||||
/** Prerequisites for inspecting exact, dated USD disclosures; never a comparability or audit verdict. */
|
||||
export function arrFieldGaps(observation: ResearchObservation, asOf: string): ArrFieldGap[] {
|
||||
const gaps: ArrFieldGap[] = [];
|
||||
const day = calendarDay(observation.observedAt);
|
||||
const end = calendarDay(asOf);
|
||||
if (day === null || end === null || day > end) gaps.push("date");
|
||||
if (observation.unit !== "USD") gaps.push("currency");
|
||||
if (observation.bound !== "=" || !Number.isFinite(observation.value) || observation.value < 0) gaps.push("amount");
|
||||
if (["estimate", "historical-estimate"].includes(observation.selection)) gaps.push("estimate");
|
||||
return gaps;
|
||||
}
|
||||
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))! }));
|
||||
}
|
||||
|
||||
/** Compare descriptive statistics only within the same provider and delivery model. */
|
||||
export function compareIncomeGroups(groups: ReturnType<typeof groupIncome>) {
|
||||
const buckets = new Map<string, typeof groups>();
|
||||
for (const group of groups) {
|
||||
if (group.stats.n < 3 || group.stats.cv === null || group.stats.largestRemovalDrop === null ||
|
||||
group.model === "全部收费模式" || group.model === "收费模式未核验") continue;
|
||||
const key = JSON.stringify([group.provider, group.model]);
|
||||
const bucket = buckets.get(key) ?? [];
|
||||
bucket.push(group);
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return [...buckets.entries()].flatMap(([key, rows]) => {
|
||||
if (rows.length < 2) return [];
|
||||
const byCV = [...rows].sort((a, b) => a.stats.cv! - b.stats.cv! || a.key.localeCompare(b.key));
|
||||
const bySensitivity = [...rows].sort((a, b) => a.stats.largestRemovalDrop! - b.stats.largestRemovalDrop! || a.key.localeCompare(b.key));
|
||||
return [{ key, provider: rows[0].provider, model: rows[0].model,
|
||||
leastDispersed: byCV[0], mostDispersed: byCV[byCV.length - 1],
|
||||
leastSensitive: bySensitivity[0], mostSensitive: bySensitivity[bySensitivity.length - 1] }];
|
||||
});
|
||||
}
|
||||
@@ -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,37 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { DocumentValidationError, readDocumentSections, editDocumentSections } from "@/lib/docs";
|
||||
import { outline } from "@/lib/document-sections";
|
||||
import { authorizeDocuments, documentApiError, idempotency } 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" && request.method !== "POST") {
|
||||
response.setHeader("Allow", "GET, POST");
|
||||
return response.status(405).end();
|
||||
}
|
||||
if (!Array.isArray(request.query.slug)) throw new DocumentValidationError("Invalid document slug");
|
||||
const slug = request.query.slug.join("/");
|
||||
if (Object.keys(request.query).some((k) => !["slug", "section", "revision"].includes(k)))
|
||||
throw new DocumentValidationError("Unknown section query");
|
||||
if (request.method === "POST") {
|
||||
if (request.query.section !== undefined || request.query.revision !== undefined)
|
||||
throw new DocumentValidationError("POST uses revision and operations in the body");
|
||||
const saved = editDocumentSections(slug, request.body, idempotency(request, key.id));
|
||||
return saved ? response.status(200).json(saved) : response.status(404).json({ code: "not_found" });
|
||||
}
|
||||
const revision = request.query.revision;
|
||||
if (revision !== undefined && (typeof revision !== "string" || !/^[1-9]\d*$/.test(revision)))
|
||||
throw new DocumentValidationError("Invalid revision");
|
||||
const doc = readDocumentSections(slug, revision === undefined ? undefined : Number(revision));
|
||||
if (!doc) return response.status(404).json({ code: "not_found" });
|
||||
const selected = request.query.section;
|
||||
if (selected === undefined) return response.status(200).json({ ...doc, sections: outline(doc.sections) });
|
||||
const ids = typeof selected === "string" ? [selected] : selected;
|
||||
if (!ids.length || ids.length > 100 || ids.some((id) => !id)) throw new DocumentValidationError("Select 1 to 100 sections");
|
||||
if (ids.some((id) => !doc.sections.some((s) => s.id === id))) return response.status(404).json({ code: "not_found" });
|
||||
return response.status(200).json({ ...doc, sections: doc.sections.filter((s) => ids.includes(s.id)) });
|
||||
} 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: "研究数据库暂时不可用,请稍后重试" }); }
|
||||
}
|
||||
@@ -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: "文档数据库暂时不可用" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,177 @@ 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; }
|
||||
.ai-research .income-statistics { min-width: 1200px; }
|
||||
/* Row-spanning category headings shift cell indexes between rows. */
|
||||
.ai-research .income-statistics td { min-width: 0; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] { min-width: 150px; white-space: nowrap; }
|
||||
.ai-research .income-statistics th[scope="rowgroup"] small { display: block; margin-top: 5px; }
|
||||
.ai-research .income-statistics td:not(:last-child) { white-space: nowrap; }
|
||||
.ai-research .income-statistics td:last-child { min-width: 220px; }
|
||||
.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; }
|
||||
|
||||
@@ -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: "" }, { ...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; }
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
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 sections from "../src/pages/api/documents/sections/[...slug]";
|
||||
import { splitSections } from "../src/lib/document-sections";
|
||||
import { openSiteDatabase } from "../src/lib/site-database";
|
||||
import { createDocumentKey, authenticateDocumentKey, revokeDocumentKey, listDocumentKeys } from "../src/lib/document-keys";
|
||||
import { allDocuments, documentHistory, readDocument, saveDocument, readDocumentSections, 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: "" }, 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);
|
||||
}));
|
||||
|
||||
const chapterContent = "# Example\n\nIntroduction.\n\n## First\n\nFirst body.\n\n### Detail\n\nChild stays in chapter.\n\n## Second\n\nSecond body.\n";
|
||||
type SectionResponse = { revision: number; replayed?: boolean;
|
||||
sections: { id: string; title: string; summary: string; summaryStatus?: string; content?: string }[] };
|
||||
const chapterQuery = { slug: input.slug.split("/") };
|
||||
|
||||
test("chapter parser preserves bytes, fences, duplicate headings and CRLF", () => {
|
||||
const content = "# Title\r\n\r\n## Repeated\r\n~~~text\r\n## Inside fence\r\n~~~\r\n### Nested\r\n```js\r\n## Also inside\r\n````\r\n\r\n## Repeated\r\nTail";
|
||||
const parts = splitSections(content);
|
||||
assert.equal(parts.length, 3);
|
||||
assert.notEqual(parts[1].id, parts[2].id);
|
||||
assert.equal(parts.map((p) => p.content).join(""), content);
|
||||
assert.equal(parts[1].title, "Repeated");
|
||||
assert.ok(parts[1].content.includes("### Nested"));
|
||||
assert.equal(splitSections("").length, 1);
|
||||
assert.equal(splitSections("# Title\n\n- Item\n ## List detail\n\n> ## Quoted\n").length, 1);
|
||||
});
|
||||
|
||||
test("outline reads existing Markdown without changing it and returns only selected bodies", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const db = openSiteDatabase();
|
||||
db.exec("DELETE FROM document_sections; DELETE FROM document_section_revisions;"); db.close();
|
||||
const result = call(sections, { headers, query: chapterQuery });
|
||||
assert.equal(result.status, 200);
|
||||
const outline = result.body as SectionResponse;
|
||||
assert.equal(outline.revision, 1); assert.equal(outline.sections.length, 3);
|
||||
assert.deepEqual(outline.sections.map((s) => s.title), ["导言", "First", "Second"]);
|
||||
assert.ok(outline.sections.every((s) => s.content === undefined && s.summaryStatus === "missing"));
|
||||
const selected = call(sections, { headers, query: { ...chapterQuery, section: outline.sections[1].id } }).body as SectionResponse;
|
||||
assert.equal(selected.sections.length, 1);
|
||||
assert.ok(selected.sections[0].content!.includes("### Detail"));
|
||||
assert.equal(readDocument(input.slug)!.content, chapterContent);
|
||||
assert.equal(call(sections, { headers, query: { ...chapterQuery, section: "missing" } }).status, 404);
|
||||
assert.equal(call(sections, { headers, query: { ...chapterQuery, revision: "0" } }).status, 400);
|
||||
}));
|
||||
|
||||
test("internal summaries persist separately and chapter updates leave other bytes untouched", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const first = before.sections[1]; const second = before.sections[2];
|
||||
const changed = first.content.replace("First body.", "Revised body.");
|
||||
const edited = call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations: [
|
||||
{ op: "update", id: first.id, content: changed, summary: "INTERNAL_SUMMARY_FIRST" },
|
||||
{ op: "update", id: second.id, summary: "INTERNAL_SUMMARY_SECOND" },
|
||||
] } });
|
||||
assert.equal(edited.status, 200);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.equal(after.revision, 2); assert.equal(after.sections[1].id, first.id);
|
||||
assert.equal(after.sections[0].content, before.sections[0].content);
|
||||
assert.equal(after.sections[2].content, before.sections[2].content);
|
||||
assert.equal(after.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
|
||||
assert.equal(readDocument(input.slug)!.content, chapterContent.replace("First body.", "Revised body."));
|
||||
assert.ok(!JSON.stringify(allDocuments()).includes("INTERNAL_SUMMARY"));
|
||||
assert.ok(!JSON.stringify(call(item, { headers, query: chapterQuery }).body).includes("INTERNAL_SUMMARY"));
|
||||
assert.ok(!JSON.stringify(call(documents, { headers }).body).includes("INTERNAL_SUMMARY"));
|
||||
const history = readDocumentSections(input.slug, 1)!;
|
||||
assert.equal(history.sections[1].content, first.content); assert.equal(history.sections[1].summary, "");
|
||||
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "INTERNAL_SUMMARY_FIRST");
|
||||
}));
|
||||
|
||||
test("insert, move and delete chapters commit together with replay and conflict protection", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const first = before.sections[1]; const second = before.sections[2];
|
||||
const body = { revision: 1, operations: [
|
||||
{ op: "move", id: second.id, after: "preamble" },
|
||||
{ op: "delete", id: first.id },
|
||||
{ op: "insert", after: second.id, content: "## New chapter\n\nNew body.\n", summary: "New material" },
|
||||
] };
|
||||
const options = { method: "POST", headers: { ...headers, "idempotency-key": "chapters-1" }, query: chapterQuery, body };
|
||||
assert.equal(call(sections, options).status, 200);
|
||||
const repeated = call(sections, options);
|
||||
assert.equal(repeated.status, 200); assert.equal((repeated.body as SectionResponse).replayed, true);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.deepEqual(after.sections.map((s) => s.title), ["导言", "Second", "New chapter"]);
|
||||
assert.equal(after.sections[1].content, second.content);
|
||||
assert.equal(after.revision, 2); assert.equal(documentHistory(input.sourceSlug).length, 2);
|
||||
assert.equal(call(sections, { ...options, body: { ...body, revision: 2 } }).status, 409);
|
||||
assert.equal(call(sections, { ...options, headers }).status, 409);
|
||||
assert.equal(saveDocument({ ...readDocument(input.slug)!, description: "Changed metadata" }, 2).revision, 3);
|
||||
assert.equal(readDocumentSections(input.slug)!.sections[2].summary, "New material");
|
||||
}));
|
||||
|
||||
test("invalid late operation rolls back content, summary and history; read-only keys cannot edit", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
const invalid = [
|
||||
[{ op: "update", id: before.sections[1].id, summary: "Should roll back" }, { op: "delete", id: "missing" }],
|
||||
[{ op: "delete", id: "preamble" }],
|
||||
[{ op: "move", id: before.sections[1].id, after: before.sections[1].id }],
|
||||
[{ op: "update", id: before.sections[1].id, content: "## First\n\n## Extra\n" }],
|
||||
[{ op: "update", id: before.sections[1].id, content: "## First\n\n" }],
|
||||
[{ op: "insert", after: "preamble", content: "## Missing summary\n" }],
|
||||
[{ op: "delete", id: before.sections[1].id, unexpected: true }],
|
||||
];
|
||||
for (const operations of invalid) {
|
||||
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations } }).status, 400);
|
||||
assert.deepEqual(readDocumentSections(input.slug), before);
|
||||
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
||||
}
|
||||
assert.equal(call(sections, { query: chapterQuery }).status, 401);
|
||||
const reader = createDocumentKey("chapter reader", "read");
|
||||
const readHeaders = { authorization: "Bearer " + reader.token };
|
||||
assert.equal(call(sections, { headers: readHeaders, query: chapterQuery }).status, 200);
|
||||
assert.equal(call(sections, { method: "POST", headers: readHeaders, query: chapterQuery, body: { revision: 1, operations: [] } }).status, 403);
|
||||
}));
|
||||
|
||||
test("full-document saves invalidate changed chapter summaries and preserve unaffected summaries and ids", () => isolated(({ headers }) => {
|
||||
saveDocument({ ...input, content: chapterContent }, 0);
|
||||
const before = readDocumentSections(input.slug)!;
|
||||
assert.equal(call(sections, { method: "POST", headers, query: chapterQuery, body: { revision: 1, operations:
|
||||
before.sections.map((s) => ({ op: "update", id: s.id, summary: "Summary of " + s.title })) } }).status, 200);
|
||||
saveDocument({ ...readDocument(input.slug)!, content: chapterContent.replace("First body.", "Changed externally.") }, 2);
|
||||
const after = readDocumentSections(input.slug)!;
|
||||
assert.equal(after.sections[1].id, before.sections[1].id);
|
||||
assert.equal(after.sections[1].summary, "");
|
||||
assert.equal(after.sections[2].summary, "Summary of Second");
|
||||
assert.equal(readDocumentSections(input.slug, 2)!.sections[1].summary, "Summary of First");
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
# Endor 系统架构
|
||||
|
||||
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。
|
||||
|
||||
> **架构说明**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
|
||||
|
||||
## 架构总览
|
||||
|
||||

|
||||
|
||||
图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示向统一备份系统采集数据。连线表示可达或依赖关系,**不表示请求向所有节点广播**。
|
||||
|
||||
## 本地、自部署与平台挂载
|
||||
|
||||

|
||||
|
||||
这张图将部署规模收起来,强调对象可以在哪里运行、从哪里挂载。两组自部署示例分别使用一个和两个 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。
|
||||
@@ -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 只验证,不部署。
|
||||
@@ -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 的共同协议边界。
|
||||
@@ -1,19 +0,0 @@
|
||||
# 图形与脚本
|
||||
|
||||
Python 绘图项目位于 `tools/figures`,使用 drawsvg 输出可编辑 SVG。脚本与输出都保留在 Git 中。
|
||||
|
||||
```sh
|
||||
pnpm figures
|
||||
```
|
||||
|
||||
下图是制图工作流的最小示例,用于验证从 Python 源码到文档图片的完整链路。
|
||||
|
||||

|
||||
|
||||
## 扩展方式
|
||||
|
||||
在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。
|
||||
|
||||
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
|
||||
|
||||
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
|
||||
@@ -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 出发,许多进程组成不同的程序。每个程序可以对应不同数量的进程;图中分别画了两个、四个和三个。进程区里的许多小圆圈表示对象,它们散布在各进程中。
|
||||
|
||||
用户通常通过程序提供的界面访问能力,也可能通过 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)讨论运行位置和挂载边界;本文关注它们为何能给用户带来值得验证的变化。
|
||||
@@ -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。章节编号沿用原清单。
|
||||
|
||||
@@ -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)
|
||||
|
||||
第一次阅读建议顺着读到第六章,再看第九章。第四、七、八章适合对照源码细读;第十章提供实验与复现方法。
|
||||
@@ -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 排版。
|
||||
|
||||
本章检查:如果宽度变化,文字本身的字体宽度一定要重新测量吗?通常不需要。变的是“每行能装多少”,这就是下一章的设计基础。
|
||||
|
||||
@@ -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) 吗?不是。它通常还要扫描分段。快来自减少每一步要做的工作,而不是完全不看文本。
|
||||
|
||||
@@ -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(' ')`?因为断行机会远不止空格,而且许多语言没有用空格分隔每一个词。
|
||||
|
||||
@@ -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 是两步。
|
||||
|
||||
@@ -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,未必适合最高频的行数路径。
|
||||
|
||||
本章检查:为什么文本支持更多规则,不一定让普通英文同样变慢?因为准备阶段能判断适用路径,复杂处理不必全部进入普通文本的热循环。
|
||||
|
||||
@@ -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 条消息各有多高,是否需要为每条创建所有行字符串?不需要。
|
||||
|
||||
@@ -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)?不能,因为每次测量的输入长度在增长。
|
||||
|
||||
@@ -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` 是否能概括带任意大小图片的富文本?不能,图片或不同字体可能把某一行撑高,需要调用者处理行高模型。
|
||||
|
||||
@@ -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 是否已经替你完成虚拟列表?没有,但它提供了此前很难在挂载前获得的文本几何信息。
|
||||
|
||||
@@ -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 结果当成其正式准确性基线。
|
||||
|
||||
@@ -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 规则。
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 第十二章:把这些设计迁移到自己的代码
|
||||
|
||||
### 12.1 先按变化频率拆工作
|
||||
|
||||
找出输入中很少变的部分与高频变化的部分。这里文字和字体较稳定,宽度频繁变化,于是准备与布局分离。类似思路也适用于图表几何、搜索索引和格式化数据。
|
||||
|
||||
预计算需要内存,也会增加第一次处理的成本。只有被复用、能减少更贵的工作时,它才值得。
|
||||
|
||||
### 12.2 让 API 允许调用者少做事
|
||||
|
||||
如果只要计数,就提供计数路径;只要范围,就不强迫构造字符串;只要可见几行,就允许提前停止。
|
||||
|
||||
这比在一个总会生成全部结果的 API 内微调循环,通常更能减少实际工作。
|
||||
|
||||
### 12.3 给常见输入一条清晰的快路径
|
||||
|
||||
先通过准备阶段建立可验证的前提,再让热循环使用这些前提。完整路径仍负责其余情况。快路径的正确性应来自明确条件,而不是凭输入“看起来简单”猜测。
|
||||
|
||||
### 12.4 衡量总工作量
|
||||
|
||||
除了总耗时,还观察测量次数、提交字符量、扫描范围、对象分配和重复启动次数。它们能解释为什么快,也能暴露短样本看不到的二次增长。
|
||||
|
||||
### 12.5 把结论说到证据能支持的位置
|
||||
|
||||
这次源码研究与实验支持的是:Pretext 用准备阶段的测量和规则数据,使后续宽度变化主要成为数值布局;缓存、专用计数循环和按需生成结果进一步减少工作。完整页面是否获益、获益多少,还取决于首次准备量、复用频率、字体与 CSS 是否匹配,以及应用剩余的 DOM 和绘制成本。
|
||||
|
||||
读完后可以用三个问题检验理解:哪些工作被移到准备阶段?哪些结果被跨调用复用?调用者只要高度时,哪些工作根本没有发生?把这三个问题顺着源码回答出来,就能解释 Pretext 的主要性能来源。
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"sourceVersion": "2026-11-01-sr007-followup-10",
|
||||
"asOf": "2026-11-01",
|
||||
"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",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"category": "C06",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016"
|
||||
],
|
||||
"n": 4,
|
||||
"mean": 34815,
|
||||
"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,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "内容制作服务",
|
||||
"products": [
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"n": 5,
|
||||
"mean": 51674.8,
|
||||
"median": 65381,
|
||||
"sampleDeviation": 24138.82269705795,
|
||||
"cv": 0.4671294847209461,
|
||||
"meanWithoutLargest": 47627.5,
|
||||
"largestRemovalDrop": 0.07832250923080501
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"days": 90,
|
||||
"products": [
|
||||
"MR001",
|
||||
"MR002",
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005",
|
||||
"MR006",
|
||||
"MR007",
|
||||
"MR008",
|
||||
"MR009",
|
||||
"MR010",
|
||||
"MR011",
|
||||
"MR012",
|
||||
"MR015",
|
||||
"MR016",
|
||||
"AI016",
|
||||
"MR017",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"category": "C02",
|
||||
"provider": "Stripe",
|
||||
"model": "模型网关订阅与用量",
|
||||
"products": [
|
||||
"MR017"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 78764,
|
||||
"median": 78764,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"largestRemovalDrop": 0.6911526751687284
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "RevenueCat",
|
||||
"model": "消费者订阅",
|
||||
"products": [
|
||||
"MR001"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 53101,
|
||||
"median": 53101,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "内容制作服务",
|
||||
"products": [
|
||||
"MR002"
|
||||
],
|
||||
"n": 1,
|
||||
"mean": 62423,
|
||||
"median": 62423,
|
||||
"sampleDeviation": null,
|
||||
"cv": null,
|
||||
"meanWithoutLargest": null,
|
||||
"largestRemovalDrop": null
|
||||
},
|
||||
{
|
||||
"category": "C08",
|
||||
"provider": "Stripe",
|
||||
"model": "软件订阅",
|
||||
"products": [
|
||||
"MR003",
|
||||
"MR004",
|
||||
"MR005",
|
||||
"MR018",
|
||||
"MR019"
|
||||
],
|
||||
"n": 5,
|
||||
"mean": 51674.8,
|
||||
"median": 65381,
|
||||
"sampleDeviation": 24138.82269705795,
|
||||
"cv": 0.4671294847209461,
|
||||
"meanWithoutLargest": 47627.5,
|
||||
"largestRemovalDrop": 0.07832250923080501
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"largestRemovalDrop": 0.20920581320120557
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4
-3
@@ -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:
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""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 yc_coverage_summary():
|
||||
companies = {row['id']: row for row in DATA['companies']}
|
||||
imported = [companies[f"YC{row['ycId']}"] for row in AUDIT['records'] if row['status'] == 'imported']
|
||||
profile_only = sum(bool(row.get('profileOnly')) for row in imported)
|
||||
groups = {}
|
||||
for label, prefix in [('unclearAI', 'YC-AI-UNCLEAR-'), ('missingYear', 'YC-MISSING-YEAR-'),
|
||||
('dateConflict', 'YC-DATE-CONFLICT-')]:
|
||||
rows = [row for batch in DATA.get('candidateBatches', []) if batch['id'].startswith(prefix)
|
||||
for row in batch['records']]
|
||||
groups[label] = {'records': len(rows), 'statuses': dict(sorted(Counter(row['status'] for row in rows).items()))}
|
||||
return {'initialProfiles': len(AUDIT['records']), 'originalImported': len(imported),
|
||||
'profileOnly': profile_only, 'deepenedOriginal': len(imported) - profile_only, **groups}
|
||||
|
||||
|
||||
|
||||
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['id'] == 'S01':
|
||||
assert source.get('coverageSummary') == yc_coverage_summary(), 'YC source summary is stale'
|
||||
if source['id'] == 'S14' and source.get('rosterAudit'):
|
||||
roster = source['rosterAudit']
|
||||
batch = next(batch for batch in DATA['candidateBatches'] if batch['id'] == roster['candidateBatchId'])
|
||||
assert batch['sourceId'] == 'S14' and not batch['complete']
|
||||
assert roster['defaultCohort'] == 'SR007' and roster['next'] is None
|
||||
assert len(batch['records']) == roster['recordCount'] == roster['reportedCount']
|
||||
assert len({row['url'] for row in batch['records']}) == roster['recordCount']
|
||||
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']
|
||||
boundary = source['boundary']
|
||||
if source['id'] == 'S01':
|
||||
summary = source['coverageSummary']
|
||||
boundary += (f" 当前原候选池{summary['initialProfiles']}项,原新增{summary['originalImported']}项中"
|
||||
f"{summary['profileOnly']}项目录基础资料、{summary['deepenedOriginal']}项深化个案。")
|
||||
for key, label in [('unclearAI', '原AI不明'), ('missingYear', '原缺年'), ('dateConflict', '原日期冲突')]:
|
||||
group = summary[key]
|
||||
labels = {'linked': '关联公司', 'pending-ai': 'AI主营待核',
|
||||
'pending-year': '成立/主体待核', 'outside-cohort': '范围外'}
|
||||
counts = '、'.join(f'{labels.get(status, status)} {count}' for status, count in group['statuses'].items())
|
||||
boundary += f" {label}{group['records']}项当前:{counts}。"
|
||||
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {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': '成立年待核', 'pending-ai': 'AI主营待核', '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['pending-ai']}条AI主营待核、{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'] in {'outside-cohort', 'pending-ai'}:
|
||||
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 uncertain_ai_followups():
|
||||
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'ai-unclear'}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-AI-UNCLEAR-') for row in batch['records']]
|
||||
if not rows:
|
||||
return []
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls), 'Duplicate AI-relevance follow-up URL'
|
||||
rechecks = {row['ycId']: row for row in DATA.get('directoryRechecks', [])}
|
||||
counts = Counter(row['status'] for row in rows)
|
||||
assert set(counts) <= {'linked', 'pending-ai'}
|
||||
for row in rows:
|
||||
assert row['url'] in original and row.get('evidenceIds')
|
||||
baseline = original[row['url']]
|
||||
if row['status'] == 'linked':
|
||||
assert rechecks[baseline['ycId']]['companyId'] == row['companyId']
|
||||
else:
|
||||
assert not row.get('companyId') and baseline['ycId'] not in rechecks
|
||||
unread = len(original) - len(rows)
|
||||
assert unread >= 0
|
||||
return ['', '## 原AI主营不明名单的后续覆盖', '',
|
||||
f"原名单{len(original)}项,已有后续记录{len(rows)}项:{counts['linked']}项关联公司、{counts['pending-ai']}项仍待核AI主营,{unread}项尚无本批系列的后续记录。计数按原始YC页面去重;EveryApp此前的独立复核与末批记录不重复相加。", '',
|
||||
'读完名单只证明每项有可回查的处理记录,不证明待核项均为AI公司,也不证明法律主体、收入或技术效果已独立验证。原始发现审计保持不变,成立年范围外/缺失、成立冲突及其他来源的缺项仍单列。', '']
|
||||
|
||||
|
||||
def founding_year_followups():
|
||||
original = {row['source']: row for row in AUDIT['records'] if row['status'] == 'date-conflict'}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-DATE-CONFLICT-') for row in batch['records']]
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls), 'Duplicate founding-conflict follow-up URL'
|
||||
assert set(urls) <= set(original), 'Unknown founding-conflict follow-up URL'
|
||||
evidence = {row['id']: row for row in DATA['evidence']}
|
||||
for row in rows:
|
||||
assert row['status'] == 'pending-year' and not row.get('companyId')
|
||||
assert row.get('evidenceIds') and all(eid in evidence for eid in row['evidenceIds'])
|
||||
assert all(evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
|
||||
unread = len(original) - len(rows)
|
||||
return ['', '## 原成立年冲突名单的后续覆盖', '',
|
||||
f"原名单{len(original)}项,已有后续记录{len(rows)}项,均保留成立年待核,{unread}项尚无本系列后续记录。当前业务初分类见候选表,不计入主公司表及收入统计。", '',
|
||||
'2025旧品牌发布可证明当时已有产品材料;当前目录的2026字段、批次、条款日期和运营者名称不能单独证明法律主体成立或新旧主体连续。未据此强改成立年。Layers当前AI核心仍另待核。', '']
|
||||
|
||||
|
||||
def missing_year_followups():
|
||||
original = {row['source'] for row in AUDIT['records']
|
||||
if row['status'] == 'year-outside-or-missing' and row['foundedYear'] is None}
|
||||
rows = [row for batch in DATA.get('candidateBatches', [])
|
||||
if batch['id'].startswith('YC-MISSING-YEAR-') for row in batch['records']]
|
||||
urls = [row['url'] for row in rows]
|
||||
assert len(set(urls)) == len(urls) and set(urls) <= original
|
||||
evidence = {e['id']: e for e in DATA['evidence']}
|
||||
for row in rows:
|
||||
assert row['status'] in {'outside-cohort', 'pending-year', 'pending-ai', 'linked'}
|
||||
assert row.get('evidenceIds')
|
||||
if row['status'] == 'linked':
|
||||
matches = [r for r in DATA.get('directoryRechecks', []) if r['companyId'] == row.get('companyId')]
|
||||
assert len(matches) == 1 and matches[0]['originalStatus'] == 'year-outside-or-missing'
|
||||
assert all(eid in evidence and evidence[eid]['companyId'] == row['companyId'] for eid in row['evidenceIds'])
|
||||
else:
|
||||
assert not row.get('companyId')
|
||||
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in row['evidenceIds'])
|
||||
counts = Counter(row['status'] for row in rows)
|
||||
return ['', '## 原缺少成立年名单的后续覆盖', '',
|
||||
f"原快照缺年{len(original)}项,外部补核已有{len(rows)}项:{counts['linked']}项关联公司、{counts['outside-cohort']}项既有主体/业务范围外、{counts['pending-year']}项成立年待核、{counts['pending-ai']}项AI主营待核,{len(original)-len(rows)}项尚无本系列外部补核记录。", '',
|
||||
'当前YC缺年字段复读与外部主体核查分别统计。登记创建日、公司自述年、产品发布、条款更新和批次不相互替代;已查英国或法国主体不代表完整集团沿革已核。原54项发现计数不回写,也不以Inactive补零收入。', '']
|
||||
|
||||
|
||||
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'])
|
||||
excluded_years = Counter(x['foundedYear'] for x in AUDIT['records'] if x['status'] == 'year-outside-or-missing')
|
||||
outside_years = {year: count for year, count in excluded_years.items() if year is not None}
|
||||
assert all(year not in (2025, 2026) for year in outside_years)
|
||||
assert sum(outside_years.values()) + excluded_years[None] == cov['otherOrMissingYear']
|
||||
year_detail = '、'.join(f'{year}年{count}项' for year, count in sorted(outside_years.items()))
|
||||
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"| 原目录列为范围外年份 | {sum(outside_years.values())} | {year_detail};原快照自述,不是独立登记核验 |",
|
||||
f"| 原目录未给成立年 | {excluded_years[None]} | 与已列范围外年份分开,仍待补证 |",
|
||||
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 += uncertain_ai_followups()
|
||||
lines += founding_year_followups()
|
||||
lines += missing_year_followups()
|
||||
lines += directory_rechecks()
|
||||
lines += source_register()
|
||||
lines += context_methods()
|
||||
lines += candidate_register()
|
||||
lines += ['', '## 尚未满足的研究条件', '',
|
||||
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
|
||||
'下一轮优先补收入线索的日期及合同定义、成立年冲突、AI主营仍待核项、非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()
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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)
|
||||
sections = sub.add_parser('outline', help='Read chapter titles and internal summaries without bodies')
|
||||
sections.add_argument('slug')
|
||||
sections.add_argument('--revision', type=int)
|
||||
section = sub.add_parser('sections', help='Read selected chapter bodies')
|
||||
section.add_argument('slug')
|
||||
section.add_argument('ids', nargs='+')
|
||||
section.add_argument('--revision', type=int)
|
||||
editing = sub.add_parser('edit-sections', help='Atomically apply chapter operations from private JSON')
|
||||
editing.add_argument('slug')
|
||||
editing.add_argument('file')
|
||||
editing.add_argument('--request-id')
|
||||
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)
|
||||
elif args.command in ('outline', 'sections', 'edit-sections'):
|
||||
path = '/api/documents/sections/' + urllib.parse.quote(args.slug, safe='/')
|
||||
if args.command == 'edit-sections':
|
||||
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)
|
||||
result = request(base, token, 'POST', path, payload, request_id)
|
||||
else:
|
||||
query = []
|
||||
if args.revision is not None:
|
||||
query.append(('revision', args.revision))
|
||||
if args.command == 'sections':
|
||||
query.extend(('section', section_id) for section_id in args.ids)
|
||||
if query:
|
||||
path += '?' + urllib.parse.urlencode(query)
|
||||
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()
|
||||
@@ -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
@@ -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"]))
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""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)
|
||||
grouped = groups(points)
|
||||
described = [(key, stats(values)) for key, values in sorted(grouped.items())]
|
||||
sizes = [value['n'] for _, value in described]
|
||||
eligible = [(key, value) for key, value in described if value['n'] >= 3]
|
||||
interpretation = '各组均不足3个样本,暂不比较分组离散度或收入高低。'
|
||||
if eligible:
|
||||
highest_key, highest = max(eligible, key=lambda item: item[1]['mean'])
|
||||
sensitive_key, sensitive = max(eligible, key=lambda item: item[1]['largestRemovalDrop'] or 0)
|
||||
cvs = [value['cv'] for _, value in eligible if value['cv'] is not None]
|
||||
cv_text = f"CV范围为{min(cvs):.3f}—{max(cvs):.3f}。" if cvs else '各组CV均不可计算。'
|
||||
interpretation = (
|
||||
f"在至少3个样本的组内,当前均值最高的是{CATEGORIES[highest_key[0]]} · {highest_key[2]}"
|
||||
f"({highest_key[1]},{money(highest['mean'])}美元每月)。{cv_text}"
|
||||
f"剔除最大值后均值下降最多的是{CATEGORIES[sensitive_key[0]]} · {sensitive_key[2]}"
|
||||
f"({sensitive_key[1]},下降{sensitive['largestRemovalDrop'] or 0:.1%})。"
|
||||
'这些只是当前采集点的描述,不据微小CV差异宣称某领域更稳定。'
|
||||
)
|
||||
size_text = f"各组目前只有{min(sizes)}至{max(sizes)}个产品" if sizes else '当前没有可比较的产品'
|
||||
lines = [
|
||||
'# AI 公司收入样本分布快照', '',
|
||||
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。按业务、支付平台和收费模式分别计算均值、中位数与离散度。{size_text},尚不足以估计行业总体或造假概率。", '',
|
||||
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
|
||||
'## 7日窗口的分组结果', '',
|
||||
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
|
||||
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||
]
|
||||
for key, values in sorted(grouped.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 += ['',
|
||||
interpretation, '',
|
||||
'单个样本不计算离散度,也不能作为典型收入或领域排名;不同收费模式和支付平台的分组保留各自含义。', '',
|
||||
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
|
||||
'## 样本与证据入口', '',
|
||||
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
|
||||
'| 产品 | 成立年 | 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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Lightweight client tests: no HTTP server, compilation or real credentials."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import sys
|
||||
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_section_reads_select_bodies_and_revision_without_full_document_fetch(self):
|
||||
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
|
||||
'sections', 'endor/example', 'preamble', 's_one', '--revision', '3']
|
||||
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
|
||||
patch.object(client, 'request', return_value={'revision': 3, 'sections': []}) as request, \
|
||||
patch.object(sys, 'stdout', io.StringIO()):
|
||||
client.main()
|
||||
request.assert_called_once_with('https://docs.example', 'test-token', 'GET',
|
||||
'/api/documents/sections/endor/example?revision=3§ion=preamble§ion=s_one')
|
||||
|
||||
def test_section_edit_keeps_payload_and_retry_id(self):
|
||||
payload = {'revision': 3, 'operations': [{'op': 'update', 'id': 's_one', 'summary': 'Internal summary'}]}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'edit.json'
|
||||
path.write_text(json.dumps(payload))
|
||||
argv = ['client', '--url', 'https://docs.example', '--key-file', '/private/key',
|
||||
'edit-sections', 'endor/example', str(path), '--request-id', 'edit-1']
|
||||
with patch.object(sys, 'argv', argv), patch.object(client, 'read_token', return_value='test-token'), \
|
||||
patch.object(client, 'request', return_value={'revision': 4}) as request, \
|
||||
patch.object(sys, 'stdout', io.StringIO()), patch.object(sys, 'stderr', io.StringIO()):
|
||||
client.main()
|
||||
request.assert_called_once_with('https://docs.example', 'test-token', 'POST',
|
||||
'/api/documents/sections/endor/example', payload, 'edit-1')
|
||||
|
||||
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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user