Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ee2bd219a | |||
| 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 |
@@ -8,3 +8,7 @@
|
|||||||
.artifacts
|
.artifacts
|
||||||
apps/docs/public/figures/**/*.webp
|
apps/docs/public/figures/**/*.webp
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-*
|
||||||
|
**/*.db
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ name: Verify and deploy Tabularium
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -11,6 +10,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify-and-deploy:
|
verify-and-deploy:
|
||||||
|
if: gitea.ref == 'refs/heads/main'
|
||||||
runs-on: ces-control
|
runs-on: ces-control
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
@@ -22,6 +22,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
github-server-url: ${{ gitea.server_url }}
|
github-server-url: ${{ gitea.server_url }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
- name: Verify with persistent caches or reuse verified image
|
- name: Verify with persistent caches or reuse verified image
|
||||||
run: python3 scripts/ci.py
|
run: python3 scripts/ci.py
|
||||||
- name: Deploy latest main with health checks and rollback
|
- name: Deploy latest main with health checks and rollback
|
||||||
|
|||||||
@@ -14,3 +14,8 @@ __pycache__/
|
|||||||
|
|
||||||
# Browser raster derivatives are generated on the confirmed remote runner.
|
# Browser raster derivatives are generated on the confirmed remote runner.
|
||||||
/apps/docs/public/figures/**/*.webp
|
/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
|
# 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。
|
- Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。
|
||||||
- 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。
|
- 使用 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 配置。
|
- 本地开发与预览只监听 `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`。
|
- 对 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 · 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。
|
文档正文、目录元数据和修订记录由站点 SQLite 数据库管理,日常写作直接更新数据库,无需提交代码、构建或部署。研究业务数据仍沿用版本化 JSON 导入机制,后续单独迁移。
|
||||||
首次安装依赖需要访问软件包源。本地开发服务只监听 127.0.0.1。
|
|
||||||
|
| 内容 | 权威来源与维护方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文档正文、分类、合集、分组、摘要、草案和推荐状态 | 数据库;通过文档 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
|
```sh
|
||||||
pnpm install --frozen-lockfile
|
sudo /opt/endorphin-tabularium-docs/docs list
|
||||||
uv sync --project tools/figures --locked
|
sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft
|
||||||
pnpm dev
|
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> 路由
|
| `slug` | 语义化公开地址,不含数字章节前缀 |
|
||||||
tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
|
| `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 后刷新页面即可读取新内容。
|
## 文档上传 API
|
||||||
- `pnpm figures`:运行 Python 制图,更新 `apps/docs/public/figures`。
|
|
||||||
- `pnpm lint` / `pnpm typecheck`:前端静态检查。
|
|
||||||
- `pnpm build`:构建文档站。
|
|
||||||
- `pnpm start`:在本地预览已构建站点。
|
|
||||||
- `pnpm check`:依次执行 lint、类型检查、制图与构建。
|
|
||||||
|
|
||||||
站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/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
|
```sh
|
||||||
sudo /opt/endorphin-tabularium-docs/auth qr
|
sudo /opt/endorphin-tabularium-docs/auth qr
|
||||||
sudo /opt/endorphin-tabularium-docs/auth verify
|
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 和构建脚本。
|
`pnpm check`、`pnpm typecheck`、`pnpm test:auth`、`pnpm test:research`、`pnpm test:docs`、`pnpm figures`、`pnpm build` 和 `pnpm dev` 等命令涉及编译、构建或产物生成,不在本机运行。图形源码与生成资源保持可复现,前端继续使用 Pages Router。
|
||||||
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` 启动,失败恢复上一版镜像。
|
|
||||||
|
|
||||||
根 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 或其他服务,也不自动修改防火墙、域名和反向代理。
|
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。Git 操作仅使用 `codex-bot` 专用 SSH 身份与仓库级提交配置;Actions 检出使用 Gitea 内置任务令牌且不保存凭据。
|
||||||
|
|
||||||
## Git 远程
|
|
||||||
|
|
||||||
仓库:[endorphin-tabularium-docs](https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs)。开发者 Git 操作使用 codex-bot 专用 SSH;Actions 检出使用 Gitea 内置任务令牌,不保存检出凭据。
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
totp,
|
totp,
|
||||||
isLoginRedirect,
|
isLoginRedirect,
|
||||||
} from "../src/lib/auth";
|
} 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";
|
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -132,11 +134,6 @@ async function main() {
|
|||||||
join(__dirname, ".next/BUILD_ID"),
|
join(__dirname, ".next/BUILD_ID"),
|
||||||
"utf8",
|
"utf8",
|
||||||
).trim();
|
).trim();
|
||||||
const routes = Object.keys(
|
|
||||||
JSON.parse(
|
|
||||||
readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"),
|
|
||||||
).routes,
|
|
||||||
) as string[];
|
|
||||||
for (const path of [
|
for (const path of [
|
||||||
"/",
|
"/",
|
||||||
"/docs/00-endor-concepts-draft",
|
"/docs/00-endor-concepts-draft",
|
||||||
@@ -160,7 +157,7 @@ async function main() {
|
|||||||
path,
|
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((await request(path)).status, 401, path);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
@@ -181,6 +178,21 @@ async function main() {
|
|||||||
const headers = {
|
const headers = {
|
||||||
Authorization: "Bearer " + signSession(config, undefined, 600),
|
Authorization: "Bearer " + signSession(config, undefined, 600),
|
||||||
};
|
};
|
||||||
|
const researchResponse = await request("/api/research/ai-companies", { headers });
|
||||||
|
assert.equal(researchResponse.status, 200, "authenticated research API");
|
||||||
|
assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store"));
|
||||||
|
const research = await researchResponse.json();
|
||||||
|
assert.equal(research.companies.length, 1023);
|
||||||
|
assert.equal(research.categories.length, 11);
|
||||||
|
assert.equal(research.evidence.length, 1318);
|
||||||
|
assert.equal(research.observations.length, 189);
|
||||||
|
assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405);
|
||||||
|
const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers });
|
||||||
|
assert.equal(researchPage.status, 200);
|
||||||
|
const researchHtml = await researchPage.text();
|
||||||
|
assert.ok(researchHtml.includes('data-category="其他研究"'));
|
||||||
|
assert.ok(researchHtml.includes("AI 公司研究"));
|
||||||
|
assert.ok(researchHtml.includes("正在读取研究数据库"));
|
||||||
const searchResponse = await request("/api/search", { headers });
|
const searchResponse = await request("/api/search", { headers });
|
||||||
assert.equal(searchResponse.status, 200);
|
assert.equal(searchResponse.status, 200);
|
||||||
const searchIndex = await searchResponse.json();
|
const searchIndex = await searchResponse.json();
|
||||||
@@ -194,6 +206,34 @@ async function main() {
|
|||||||
assert.equal(destination.searchParams.get("from"), "legacy");
|
assert.equal(destination.searchParams.get("from"), "legacy");
|
||||||
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
|
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 home = "";
|
||||||
let purpose = "";
|
let purpose = "";
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
@@ -203,8 +243,11 @@ async function main() {
|
|||||||
if (route === "/") home = html;
|
if (route === "/") home = html;
|
||||||
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
|
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
|
||||||
if (route.startsWith("/docs/")) {
|
if (route.startsWith("/docs/")) {
|
||||||
assert.ok(html.includes('aria-label="文档选择"'), route);
|
assert.ok(html.includes('aria-label="文档分类"'), route);
|
||||||
assert.ok(html.includes('<optgroup 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);
|
assert.ok(!html.includes('class="nav-back"'), route);
|
||||||
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
|
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
|
||||||
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
|
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
|
||||||
@@ -315,3 +358,42 @@ main().catch((error) => {
|
|||||||
);
|
);
|
||||||
process.exitCode = 1;
|
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",
|
format: "cjs",
|
||||||
outfile: ".next/standalone/apps/docs/auth-cli.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",
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,19 +23,6 @@ for (const name of await files(path.join(root, "apps/docs/public"))) {
|
|||||||
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
|
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const name of await files(path.join(root, "content/docs"))) {
|
|
||||||
if (!name.endsWith(".md")) continue;
|
|
||||||
const content = await readFile(name, "utf8");
|
|
||||||
const images = [
|
|
||||||
...content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
|
||||||
...content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
|
||||||
];
|
|
||||||
for (const [, source] of images) {
|
|
||||||
if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) {
|
|
||||||
throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(output, { recursive: true });
|
await mkdir(output, { recursive: true });
|
||||||
for (const source of await files(sources)) {
|
for (const source of await files(sources)) {
|
||||||
|
|||||||
@@ -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,181 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import IncomeDistribution from "./IncomeDistribution";
|
||||||
|
import {
|
||||||
|
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
|
||||||
|
} from "@/lib/ai-research";
|
||||||
|
|
||||||
|
const pageSize = 12;
|
||||||
|
const evidencePageSize = 30;
|
||||||
|
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
|
||||||
|
const useLabels: Record<string, string> = {
|
||||||
|
platform: "支付平台记录", signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
|
||||||
|
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
|
||||||
|
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
|
||||||
|
};
|
||||||
|
const money = (value: number, unit = "USD") => unit === "USD"
|
||||||
|
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
|
||||||
|
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
|
||||||
|
|
||||||
|
function External({ url, children }: { url: string; children: React.ReactNode }) {
|
||||||
|
const safe = evidenceUrl(url);
|
||||||
|
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
|
||||||
|
}
|
||||||
|
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
|
||||||
|
return <li id={`${prefix}-${item.id}`} className="research-evidence">
|
||||||
|
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
|
||||||
|
<strong><External url={item.url}>{item.title}</External></strong>
|
||||||
|
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
|
||||||
|
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
|
||||||
|
</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AICompanyResearch() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<ResearchSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [categoryChoice, setCategoryChoice] = useState<{ route: string; id: string } | null>(null);
|
||||||
|
const linkedCategory = router.query.category;
|
||||||
|
const category = categoryChoice && categoryChoice.route === router.asPath ? categoryChoice.id
|
||||||
|
: router.isReady && typeof linkedCategory === "string" && data?.categories.some((c) => c.id === linkedCategory)
|
||||||
|
? linkedCategory : "";
|
||||||
|
function setCategory(id: string) { setCategoryChoice({ route: router.asPath, id }); }
|
||||||
|
const [cohort, setCohort] = useState("");
|
||||||
|
const [income, setIncome] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [sourcePage, setSourcePage] = useState(0);
|
||||||
|
const [depth, setDepth] = useState("");
|
||||||
|
const [teamBand, setTeamBand] = useState("");
|
||||||
|
const [technicalLevel, setTechnicalLevel] = useState("");
|
||||||
|
const [selection, setSelection] = useState<{ route: string; id: string | null } | null>(null);
|
||||||
|
const linkedCompany = router.query.company;
|
||||||
|
const selected = selection && selection.route === router.asPath ? selection.id
|
||||||
|
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
|
||||||
|
? linkedCompany : null;
|
||||||
|
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
|
||||||
|
const [tab, setTab] = useState<"companies" | "revenue" | "sources" | "reviews">("companies");
|
||||||
|
const companySection = useRef<HTMLElement>(null);
|
||||||
|
const detailSection = useRef<HTMLElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error("Research unavailable");
|
||||||
|
const snapshot: ResearchSnapshot = await response.json();
|
||||||
|
setData(snapshot);
|
||||||
|
}).catch(() => { if (!controller.signal.aborted) setError(true); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [attempt]);
|
||||||
|
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
|
||||||
|
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
|
||||||
|
const filtered = useMemo(() => data?.companies.filter((c) => {
|
||||||
|
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
|
||||||
|
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
|
||||||
|
const depthMatch = !depth || (depth === "profile" ? c.profileOnly : !c.profileOnly);
|
||||||
|
const teamMatch = !teamBand || (teamBand === "unknown" ? c.team === null || (!!c.teamBound && c.team < 51)
|
||||||
|
: c.team !== null && (teamBand === "xl" ? c.team >= 51 : !c.teamBound && (teamBand === "small" ? c.team <= 5 : teamBand === "medium" ? c.team >= 6 && c.team <= 20 : c.team >= 21 && c.team <= 50)));
|
||||||
|
return (!technicalLevel || (c.technicalReview?.level ?? "unreviewed") === technicalLevel) && depthMatch && teamMatch && (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
|
||||||
|
&& search.includes(query.trim().toLocaleLowerCase());
|
||||||
|
}) ?? [], [data, query, category, cohort, income, depth, teamBand, technicalLevel]);
|
||||||
|
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
|
||||||
|
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
|
||||||
|
const categories = new Map(data.categories.map((c) => [c.id, c]));
|
||||||
|
const selectedCompany = data.companies.find((c) => c.id === selected);
|
||||||
|
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
|
||||||
|
const disclosedCompanies = new Set(observed.map((o) => o.company)).size;
|
||||||
|
const revenueCompanies = data.companies.filter((c) => c.revenueStatus !== "unknown").length;
|
||||||
|
const selectedObservations = data.observations.filter((o) => o.company === selected);
|
||||||
|
const selectedEvidenceIds = new Set([
|
||||||
|
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
|
||||||
|
...(selectedCompany?.technicalReview?.evidenceIds ?? []),
|
||||||
|
...selectedObservations.map((o) => o.evidence),
|
||||||
|
]);
|
||||||
|
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
|
||||||
|
const reviewCompanies = (review: ResearchReview) => {
|
||||||
|
const ids = new Set(reviewCompanyIds(data, review));
|
||||||
|
return data.companies.filter((c) => ids.has(c.id));
|
||||||
|
};
|
||||||
|
const selectedReviews = data.reviews.filter((r) => selected && reviewCompanyIds(data, r).includes(selected));
|
||||||
|
function chooseCategory(id: string) {
|
||||||
|
setCategory(id); setPage(0); setTab("companies");
|
||||||
|
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
return <div className="ai-research not-prose">
|
||||||
|
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
|
||||||
|
<div className="research-stats">
|
||||||
|
<div><strong>{data.companies.length}</strong><span>公司/产品记录</span></div>
|
||||||
|
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
|
||||||
|
<div><strong>{revenueCompanies}</strong><span>有收入线索的记录</span></div>
|
||||||
|
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="research-scope"><p>{data.scope}</p>
|
||||||
|
{data.coverage && <p>YC 候选池 {data.coverage.candidateRecords} 条,官方页面读取 {data.coverage.profilesRead} 条;初筛匹配 {data.coverage.matchingProfiles} 条,新增 {data.coverage.newProfiles} 条目录记录。年份不符或缺失 {data.coverage.otherOrMissingYear} 条、AI 业务待确认 {data.coverage.unclearAI} 条、日期冲突 {data.coverage.dateConflicts} 条。目录基础资料尚未逐家核验收入和交付能力。</p>}
|
||||||
|
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
|
||||||
|
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<section aria-labelledby="research-categories-title" className="research-section">
|
||||||
|
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
|
||||||
|
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}条记录`}>
|
||||||
|
<div><span>{c.name}</span><strong>{c.count}</strong></div>
|
||||||
|
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
|
||||||
|
<small>{c.description}</small>
|
||||||
|
</button>)}</div>
|
||||||
|
</section>
|
||||||
|
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
|
||||||
|
<h2 id="research-records-title">研究数据</h2>
|
||||||
|
<div className="research-tabs" role="group" aria-label="研究数据视图">
|
||||||
|
{([['companies','公司与产品'],['revenue','收入观测'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||||||
|
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
{tab === "companies" && <>
|
||||||
|
<div className="research-filters">
|
||||||
|
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
|
||||||
|
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
|
||||||
|
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
|
||||||
|
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
|
||||||
|
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
|
||||||
|
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||||
|
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
|
||||||
|
</div>
|
||||||
|
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
|
||||||
|
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
|
||||||
|
<div className="research-table-wrap"><table><caption className="sr-only">公司/产品分类与经营资料。选择名称查看指标和原始证据。</caption><thead><tr><th>公司或产品 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>交付与技术验证证据</th><th>收入资料</th></tr></thead><tbody>
|
||||||
|
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
|
||||||
|
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
|
||||||
|
<td><span className="research-tag">{categories.get(c.cat)?.name}</span>{c.profileOnly && <small>目录基础资料 · 分类依据见明细</small>}<p>{c.business}</p><small>买家:{c.buyer}</small></td>
|
||||||
|
<td>{c.cohort}</td><td>{c.team !== null && c.teamBound}{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{c.stage}<small>{technicalReviewLabels[c.technicalReview?.level ?? "unreviewed"]}</small></td><td>{c.profileOnly ? "收入尚未逐一核验" : revenueLabels[c.revenueStatus]}</td>
|
||||||
|
</tr>)}
|
||||||
|
</tbody></table></div>
|
||||||
|
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
|
||||||
|
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
|
||||||
|
</>}
|
||||||
|
{tab === "revenue" && <>
|
||||||
|
<IncomeDistribution data={data} onSelect={setSelected} />
|
||||||
|
<h3>公司披露与估算的历史里程碑</h3>
|
||||||
|
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号,多为异时点历史里程碑,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
|
||||||
|
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
|
||||||
|
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
|
||||||
|
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
|
||||||
|
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
|
||||||
|
</div>)}</div>
|
||||||
|
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>公司披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
|
||||||
|
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
|
||||||
|
</>}
|
||||||
|
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
|
||||||
|
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3>{r.scope === "dataset" && <small>全局数据方法核查</small>}{r.scope === "excluded-candidate" && <small>未纳入公司表的候选</small>}<p>{r.summary}</p><p>{r.handling}</p><div>{reviewCompanies(r).map((c) => <button key={c.id} className="research-company-name" onClick={() => setSelected(c.id)}>查看 {c.name} 明细</button>)}</div><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
|
||||||
|
</section>
|
||||||
|
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}资料明细`}>
|
||||||
|
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
|
||||||
|
<p><External url={selectedCompany.url}>资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
|
||||||
|
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费/交付模式</dt><dd>{selectedCompany.businessModel ?? "待进一步核验"}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>分类依据</dt><dd>{selectedCompany.classificationBasis ?? "原有个案分类;随研究复核"}</dd></div><div><dt>技术验证证据</dt><dd>{technicalReviewLabels[selectedCompany.technicalReview?.level ?? "unreviewed"]}<p>{selectedCompany.technicalReview?.summary ?? "本轮未形成专项技术评审记录;不表示公司没有技术能力。"}</p>{selectedCompany.technicalReview && <small>评审日期:{selectedCompany.technicalReview.reviewedAt}</small>}{selectedCompany.technicalReview?.testScope && <p>测试范围:{selectedCompany.technicalReview.testScope}</p>}<p>材料可访问仅确认可查看,不证明性能、可靠性或商业交付质量;不同任务不作统一能力排名。</p></dd></div><div><dt>观测主体</dt><dd>{selectedCompany.entityBasis ?? "公司或经营品牌,法律主体未逐一核验"}</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
|
||||||
|
<h3>经营与技术观测</h3>
|
||||||
|
{!!selectedReviews.length && <details><summary>关联核查记录({selectedReviews.length})</summary><ul>{selectedReviews.map((r) => <li key={r.id}><strong>{r.type}:{r.summary}</strong><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const item = data.evidence.find((e) => e.id === id); return item ? <External key={id} url={item.url}>{id}</External> : null; })}</div></li>)}</ul></details>}
|
||||||
|
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
|
||||||
|
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
|
||||||
|
</section>}
|
||||||
|
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import type { DocLink } from "@/lib/docs";
|
||||||
|
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
|
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
|
||||||
|
const navigation = useRef<HTMLElement>(null);
|
||||||
|
const triggers = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
|
const id = useId();
|
||||||
|
const categories = [...new Set(docs.map((doc) => doc.category))];
|
||||||
|
const collections = collectionEntries(docs);
|
||||||
|
const selected = docs.find((doc) => doc.slug === current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function outside(event: PointerEvent) {
|
||||||
|
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
|
||||||
|
}
|
||||||
|
function escape(event: globalThis.KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape" || !open) return;
|
||||||
|
if (navigation.current?.contains(document.activeElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
triggers.current.get(open)?.focus();
|
||||||
|
}
|
||||||
|
setOpen(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", outside);
|
||||||
|
document.addEventListener("keydown", escape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", outside);
|
||||||
|
document.removeEventListener("keydown", escape);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function focusItem(panel: string, last = false) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
|
||||||
|
links?.[last ? links.length - 1 : 0]?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function move(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||||
|
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
|
||||||
|
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
|
||||||
|
if (index < 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
|
||||||
|
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
|
||||||
|
links[next]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
|
||||||
|
}}>
|
||||||
|
<ul>
|
||||||
|
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
|
||||||
|
{categories.map((category, index) => {
|
||||||
|
const panel = `${id}-category-${index}`;
|
||||||
|
const expanded = open === category;
|
||||||
|
return <li className="category-menu" key={category}
|
||||||
|
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
|
||||||
|
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
|
||||||
|
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
|
||||||
|
type="button" className="category-trigger" data-category={category}
|
||||||
|
data-active={selected?.category === category || undefined}
|
||||||
|
aria-expanded={expanded} aria-controls={panel}
|
||||||
|
onClick={() => setOpen(expanded ? null : category)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(category);
|
||||||
|
focusItem(panel, event.key === "ArrowUp");
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{category}<Icon name="chevron" size={11} />
|
||||||
|
</button>
|
||||||
|
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
|
||||||
|
<p className="category-panel-title">{category}</p>
|
||||||
|
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
|
||||||
|
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
|
||||||
|
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
|
||||||
|
onClick={() => setOpen(null)}>
|
||||||
|
<Icon name={collectionIcon(name)} size={19} />
|
||||||
|
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
|
||||||
|
</Link>)}
|
||||||
|
</div>
|
||||||
|
</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { ResearchSnapshot } from "@/lib/ai-research";
|
||||||
|
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
|
||||||
|
|
||||||
|
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
|
||||||
|
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
export default function IncomeDistribution({ data, onSelect }: {
|
||||||
|
data: ResearchSnapshot; onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [dateWindow, setDateWindow] = useState("7");
|
||||||
|
const [year, setYear] = useState("");
|
||||||
|
const [annualize, setAnnualize] = useState(false);
|
||||||
|
const [splitModels, setSplitModels] = useState(true);
|
||||||
|
const [logScale, setLogScale] = useState(true);
|
||||||
|
const sample = useMemo(() => platformIncomeSample(data, {
|
||||||
|
windowDays: dateWindow === "all" ? null : Number(dateWindow), foundedYear: year ? Number(year) : undefined, annualize,
|
||||||
|
}), [data, dateWindow, year, annualize]);
|
||||||
|
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
|
||||||
|
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
|
||||||
|
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
|
||||||
|
category, rows: groups.filter((group) => group.category === category),
|
||||||
|
}));
|
||||||
|
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
|
||||||
|
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
|
||||||
|
const values = sample.points.map((point) => point.value);
|
||||||
|
const minValue = Math.min(...values);
|
||||||
|
const maxValue = Math.max(1, ...values);
|
||||||
|
const axisMin = logScale && minValue > 0 && Number.isFinite(minValue) ? 10 ** Math.floor(Math.log10(minValue)) : 0;
|
||||||
|
const axisMax = Math.max(axisMin * 10, 10 ** Math.ceil(Math.log10(maxValue)));
|
||||||
|
const scale = logScale ? (value: number) => Math.log10(value + 1) : (value: number) => value;
|
||||||
|
const left = 250, right = 865;
|
||||||
|
const x = (value: number) => left + (scale(value) - scale(axisMin)) / (scale(axisMax) - scale(axisMin)) * (right - left);
|
||||||
|
const ticks = logScale ? [0, 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]
|
||||||
|
.filter((value) => value >= axisMin && value <= axisMax) : [0, .25, .5, .75, 1].map((fraction) => fraction * axisMax);
|
||||||
|
return <section className="research-distribution" aria-labelledby="income-distribution-title">
|
||||||
|
<h3 id="income-distribution-title">收入分布与均值敏感性</h3>
|
||||||
|
<p>单独比较支付平台显示的 USD MRR。每个点是一个产品或业务,法律主体与支付账户的独立性尚未全部核实;这不是行业总体或公司财报样本。</p>
|
||||||
|
<div className="research-filters">
|
||||||
|
<label>最后同步日期窗口<select value={dateWindow} onChange={(event) => setDateWindow(event.target.value)}><option value="7">截止日前 7 天内</option><option value="30">截止日前 30 天内</option><option value="90">截止日前 90 天内</option><option value="all">全部已知日期(混合历史)</option></select></label>
|
||||||
|
<label>资料中的创立年<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
|
||||||
|
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
|
||||||
|
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
|
||||||
|
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
|
||||||
|
{sample.points.length ? <>
|
||||||
|
<figure>
|
||||||
|
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
|
||||||
|
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
|
||||||
|
{groups.map((group, index) => {
|
||||||
|
const y = index * 70 + 40;
|
||||||
|
const firstInCategory = index === 0 || groups[index - 1].category !== group.category;
|
||||||
|
const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length;
|
||||||
|
return <g key={group.key} role="group" aria-label={`${categories.get(group.category)},${group.provider},${group.model},${group.stats.n}个产品`}>
|
||||||
|
{firstInCategory && <>
|
||||||
|
{index > 0 && <line x1={8} x2={right} y1={y - 34} y2={y - 34} stroke="#dfe6e0" />}
|
||||||
|
<text x={8} y={y - 8} fontSize="12" fontWeight="600" fill="#202724">{categories.get(group.category)}({categoryRowCount}组)</text>
|
||||||
|
{categoryRowCount > 1 && <line x1={10} x2={10} y1={y + 3} y2={y + (categoryRowCount - 1) * 70 + 12} stroke="#95b4a6" strokeWidth={2} />}
|
||||||
|
</>}
|
||||||
|
<text x={20} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
|
||||||
|
{group.stats.n < 3 && <text x={20} y={y + 26} fontSize="9" fill="#637066">{group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}</text>}
|
||||||
|
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
|
||||||
|
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
|
||||||
|
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
|
||||||
|
{group.points.map((point, position) => <g key={point.company.id} role="button" tabIndex={0} aria-label={`查看${point.company.name},${metric} ${usd(point.value)}`} onClick={() => onSelect(point.company.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(point.company.id); } }}>
|
||||||
|
<title>{point.company.name} · {usd(point.value)} · 数据日期 {point.observation.observedAt}{point.observation.connectionExpired ? " · 连接提示过期" : ""}</title>
|
||||||
|
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={10} fill="transparent" />
|
||||||
|
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={4.5} fill="#27745b" stroke="white" />
|
||||||
|
</g>)}
|
||||||
|
</g>;
|
||||||
|
})}
|
||||||
|
</svg></div>
|
||||||
|
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div className="research-table-wrap"><table><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||||||
|
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
|
||||||
|
<td>{group.provider}<small>{group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
|
||||||
|
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
|
||||||
|
</tr>)}</tbody>)}</table></div>
|
||||||
|
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
|
||||||
|
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
|
||||||
|
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { CategoryNavigation } from "./CategoryNavigation";
|
||||||
import { Icon } from "@/components/Icon";
|
import { Icon } from "@/components/Icon";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { DocLink } from "@/lib/docs";
|
import type { DocLink } from "@/lib/docs";
|
||||||
@@ -69,7 +69,6 @@ export function Shell({
|
|||||||
current?: string;
|
current?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const dialog = useRef<HTMLDialogElement>(null);
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
const trigger = useRef<HTMLButtonElement>(null);
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
@@ -80,7 +79,6 @@ export function Shell({
|
|||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const collection = docs.find((d) => d.slug === current)?.collection;
|
const collection = docs.find((d) => d.slug === current)?.collection;
|
||||||
const collections = collectionEntries(docs);
|
|
||||||
const results = searchDocs.filter((d) =>
|
const results = searchDocs.filter((d) =>
|
||||||
(!collection || d.collection === collection) &&
|
(!collection || d.collection === collection) &&
|
||||||
`${d.title} ${d.description} ${d.searchText ?? ""}`
|
`${d.title} ${d.description} ${d.searchText ?? ""}`
|
||||||
@@ -125,22 +123,7 @@ export function Shell({
|
|||||||
</a>
|
</a>
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<Brand />
|
<Brand />
|
||||||
<nav className="top-nav" aria-label="文档选择">
|
<CategoryNavigation docs={docs} current={current} />
|
||||||
<label className="collection-picker">
|
|
||||||
<span className="picker-category">{docs.find((d) => d.slug === current)?.category ?? "全部分类"}</span>
|
|
||||||
<span className="sr-only">选择分类与文档合集</span>
|
|
||||||
<select value={collection ?? ""} onChange={(event) => {
|
|
||||||
const entry = collections.find((c) => c.name === event.target.value)?.entry;
|
|
||||||
void router.push(entry ? `/docs/${entry.slug}` : "/");
|
|
||||||
}}>
|
|
||||||
<option value="">文档中心</option>
|
|
||||||
{[...new Set(docs.map((d) => d.category))].map((category) => <optgroup label={category} key={category}>
|
|
||||||
{collections.filter((c) => c.entry.category === category).map(({name}) => <option value={name} key={name}>{collectionLabel(name)}</option>)}
|
|
||||||
</optgroup>)}
|
|
||||||
</select>
|
|
||||||
<Icon name="chevron" size={13} />
|
|
||||||
</label>
|
|
||||||
</nav>
|
|
||||||
<button
|
<button
|
||||||
ref={trigger}
|
ref={trigger}
|
||||||
className="search-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,180 @@
|
|||||||
|
export type ResearchCategory = { id: string; name: string; description: string };
|
||||||
|
export const technicalReviewLabels = {
|
||||||
|
unreviewed: "技术证据尚未专项评审",
|
||||||
|
description: "已读技术自述",
|
||||||
|
"public-artifact": "公开代码/模型材料可访问",
|
||||||
|
"independent-test": "有第三方独立评测记录",
|
||||||
|
};
|
||||||
|
export type ResearchCompany = {
|
||||||
|
id: string; name: string; url: string; cat: string; year: number | null;
|
||||||
|
team: number | null; teamBound?: ">" | "≥"; location: string | null; business: string; buyer: string;
|
||||||
|
delivery: string; notes: string; cohort: string; teamBasis: string | null;
|
||||||
|
price: string; stage: string; sourceType: string; evidenceIds: string[];
|
||||||
|
revenueStatus: "self" | "estimate" | "platform" | "unknown";
|
||||||
|
extraUrl?: string; pricingEvidence?: string;
|
||||||
|
businessModel?: string; entityBasis?: string;
|
||||||
|
profileOnly?: boolean; classificationBasis?: string; directoryTags?: string[];
|
||||||
|
directoryOrigin?: "yc-2025-2026";
|
||||||
|
technicalReview?: {
|
||||||
|
level: "description" | "public-artifact" | "independent-test";
|
||||||
|
reviewedAt: string; summary: string; evidenceIds: string[]; testScope?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type ResearchObservation = {
|
||||||
|
id: string; company: string; name: string; kind: string; value: number;
|
||||||
|
unit: string; timing: string; evidence: string; scope: string;
|
||||||
|
selection: string; reason: string; bound: string; comparisonGroup: string | null;
|
||||||
|
metric?: "mrr" | "arr" | "revenue-30d" | "annualized-revenue";
|
||||||
|
observedAt?: string; provider?: string; connectionExpired?: boolean; origin?: "payment-platform";
|
||||||
|
};
|
||||||
|
export type ResearchEvidence = {
|
||||||
|
id: string; companyId: string | null; title: string; url: string;
|
||||||
|
publishedAt: string | null; accessedAt: string; kind: string;
|
||||||
|
summary: string; limitation: string;
|
||||||
|
};
|
||||||
|
export type ResearchReview = {
|
||||||
|
id: string; type: string; company: string; summary: string;
|
||||||
|
evidenceIds: string[]; handling: string;
|
||||||
|
scope?: "companies" | "dataset" | "excluded-candidate"; companyIds?: string[];
|
||||||
|
};
|
||||||
|
export type ResearchCoverage = {
|
||||||
|
source: string; accessedAt: string; candidateRecords: number; profilesRead: number;
|
||||||
|
matchingProfiles: number; existingProfiles: number; newProfiles: number;
|
||||||
|
otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number;
|
||||||
|
discoverySha256: string;
|
||||||
|
};
|
||||||
|
export type ResearchCandidateBatch = {
|
||||||
|
id: string; sourceId: string; url: string; accessedAt: string; scope: string; complete: boolean;
|
||||||
|
records: {
|
||||||
|
id: string; name: string; url: string | null; categoryId: string;
|
||||||
|
kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only";
|
||||||
|
status: "pending-year" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion";
|
||||||
|
companyId?: string; evidenceIds?: string[]; note: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
export type ResearchSnapshot = {
|
||||||
|
version: string; asOf: string; scope: string; limitations: string[];
|
||||||
|
categories: ResearchCategory[]; companies: ResearchCompany[];
|
||||||
|
observations: ResearchObservation[]; evidence: ResearchEvidence[];
|
||||||
|
reviews: ResearchReview[]; coverage?: ResearchCoverage; candidateBatches?: ResearchCandidateBatch[];
|
||||||
|
directoryRechecks?: {
|
||||||
|
ycId: number; originalStatus: "ai-unclear" | "year-outside-or-missing" | "date-conflict";
|
||||||
|
companyId: string; reviewedAt: string; evidenceIds: string[]; reason: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function evidenceUrl(value: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateResearch(snapshot: ResearchSnapshot) {
|
||||||
|
const unique = (values: string[], label: string) => {
|
||||||
|
if (new Set(values).size !== values.length || values.some((v) => !v))
|
||||||
|
throw new Error(`Duplicate or missing research ${label}`);
|
||||||
|
return new Set(values);
|
||||||
|
};
|
||||||
|
if (!snapshot.version || !/^\d{4}-\d{2}-\d{2}$/.test(snapshot.asOf))
|
||||||
|
throw new Error("Invalid research dataset metadata");
|
||||||
|
const categories = unique(snapshot.categories.map((c) => c.id), "categories");
|
||||||
|
const companies = unique(snapshot.companies.map((c) => c.id), "companies");
|
||||||
|
const evidence = unique(snapshot.evidence.map((e) => e.id), "evidence");
|
||||||
|
unique(snapshot.observations.map((o) => o.id), "observations");
|
||||||
|
unique(snapshot.reviews.map((r) => r.id), "reviews");
|
||||||
|
for (const c of snapshot.companies) {
|
||||||
|
const technical = c.technicalReview;
|
||||||
|
if (technical && (!["description", "public-artifact", "independent-test"].includes(technical.level) ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}$/.test(technical.reviewedAt) ||
|
||||||
|
Number.isNaN(Date.parse(technical.reviewedAt)) ||
|
||||||
|
new Date(technical.reviewedAt).toISOString().slice(0, 10) !== technical.reviewedAt ||
|
||||||
|
technical.reviewedAt > snapshot.asOf || !technical.summary.trim() ||
|
||||||
|
!technical.evidenceIds.length || technical.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||||
|
(technical.level === "independent-test" && !technical.testScope?.trim())))
|
||||||
|
throw new Error(`Invalid technical review: ${c.id}`);
|
||||||
|
if (c.profileOnly && (c.revenueStatus !== "unknown" || !c.classificationBasis))
|
||||||
|
throw new Error(`Invalid profile-only research record: ${c.id}`);
|
||||||
|
if (c.directoryOrigin && (c.directoryOrigin !== "yc-2025-2026" || !/^YC\d+$/.test(c.id)))
|
||||||
|
throw new Error(`Invalid research directory origin: ${c.id}`);
|
||||||
|
if (!categories.has(c.cat) || !evidenceUrl(c.url) ||
|
||||||
|
c.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||||
|
(c.pricingEvidence && !evidence.has(c.pricingEvidence)))
|
||||||
|
throw new Error(`Invalid research company reference: ${c.id}`);
|
||||||
|
}
|
||||||
|
for (const e of snapshot.evidence) {
|
||||||
|
if (!evidenceUrl(e.url) || (e.companyId && !companies.has(e.companyId)))
|
||||||
|
throw new Error(`Invalid research evidence: ${e.id}`);
|
||||||
|
}
|
||||||
|
for (const o of snapshot.observations) {
|
||||||
|
if (!companies.has(o.company) || !evidence.has(o.evidence) ||
|
||||||
|
!Number.isFinite(o.value))
|
||||||
|
throw new Error(`Invalid research observation: ${o.id}`);
|
||||||
|
}
|
||||||
|
unique((snapshot.candidateBatches ?? []).map((b) => b.id), "candidate batches");
|
||||||
|
unique((snapshot.candidateBatches ?? []).flatMap((b) => b.records.map((r) => r.id)), "candidates");
|
||||||
|
for (const batch of snapshot.candidateBatches ?? []) {
|
||||||
|
if (!/^S\d{2}$/.test(batch.sourceId) || !evidenceUrl(batch.url) || !batch.scope.trim() ||
|
||||||
|
typeof batch.complete !== "boolean" || !/^\d{4}-\d{2}-\d{2}$/.test(batch.accessedAt) ||
|
||||||
|
Number.isNaN(Date.parse(batch.accessedAt)) ||
|
||||||
|
new Date(batch.accessedAt).toISOString().slice(0, 10) !== batch.accessedAt ||
|
||||||
|
batch.accessedAt > snapshot.asOf)
|
||||||
|
throw new Error(`Invalid research candidate batch: ${batch.id}`);
|
||||||
|
for (const row of batch.records) {
|
||||||
|
const linked = ["linked", "existing-outside-cohort"].includes(row.status);
|
||||||
|
if (!row.name.trim() || !row.note.trim() || !categories.has(row.categoryId) ||
|
||||||
|
!["product", "company", "promotion"].includes(row.kind) ||
|
||||||
|
!["readable", "unavailable", "archive-only"].includes(row.profileStatus) ||
|
||||||
|
!["pending-year", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) ||
|
||||||
|
(row.kind !== "promotion" && (!row.url || !evidenceUrl(row.url))) ||
|
||||||
|
(row.url !== null && !evidenceUrl(row.url)) ||
|
||||||
|
linked !== !!row.companyId || (row.companyId && !companies.has(row.companyId)) ||
|
||||||
|
(row.kind === "promotion") !== (row.status === "promotion") ||
|
||||||
|
(row.evidenceIds ?? []).some((id) => !evidence.has(id)) ||
|
||||||
|
(row.status === "outside-cohort" && !row.evidenceIds?.length))
|
||||||
|
throw new Error(`Invalid research candidate: ${row.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const coverage = snapshot.coverage;
|
||||||
|
unique((snapshot.directoryRechecks ?? []).map((row) => String(row.ycId)), "directory rechecks");
|
||||||
|
for (const row of snapshot.directoryRechecks ?? []) {
|
||||||
|
if (!Number.isSafeInteger(row.ycId) || row.ycId <= 0 || !companies.has(row.companyId) ||
|
||||||
|
!["ai-unclear", "year-outside-or-missing", "date-conflict"].includes(row.originalStatus) ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}$/.test(row.reviewedAt) || Number.isNaN(Date.parse(row.reviewedAt)) ||
|
||||||
|
new Date(row.reviewedAt).toISOString().slice(0, 10) !== row.reviewedAt || row.reviewedAt > snapshot.asOf ||
|
||||||
|
!row.reason.trim() || !row.evidenceIds.length || row.evidenceIds.some((id) => !evidence.has(id)))
|
||||||
|
throw new Error(`Invalid directory recheck: ${row.ycId}`);
|
||||||
|
}
|
||||||
|
if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords ||
|
||||||
|
coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles ||
|
||||||
|
coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead ||
|
||||||
|
snapshot.companies.filter((c) => c.profileOnly || c.directoryOrigin === "yc-2025-2026").length !== coverage.newProfiles || !evidenceUrl(coverage.source)))
|
||||||
|
throw new Error("Invalid research coverage accounting");
|
||||||
|
for (const r of snapshot.reviews)
|
||||||
|
if (r.evidenceIds.some((id) => !evidence.has(id)) ||
|
||||||
|
(r.companyIds && (new Set(r.companyIds).size !== r.companyIds.length ||
|
||||||
|
r.companyIds.some((id) => !companies.has(id)))) ||
|
||||||
|
(r.scope && !["companies", "dataset", "excluded-candidate"].includes(r.scope)) ||
|
||||||
|
(r.scope === "companies" && !r.companyIds?.length) ||
|
||||||
|
(r.scope && r.scope !== "companies" && !!r.companyIds?.length))
|
||||||
|
throw new Error(`Invalid research review: ${r.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit reviewed associations take priority; older revisions use evidence ownership. */
|
||||||
|
export function reviewCompanyIds(snapshot: ResearchSnapshot, review: ResearchReview): string[] {
|
||||||
|
if (review.companyIds) return review.companyIds;
|
||||||
|
const evidenceIds = new Set(review.evidenceIds);
|
||||||
|
return [...new Set(snapshot.evidence.flatMap((e) =>
|
||||||
|
e.companyId && evidenceIds.has(e.id) ? [e.companyId] : []))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categorySummary(snapshot: ResearchSnapshot) {
|
||||||
|
return snapshot.categories.map((category) => {
|
||||||
|
const companies = snapshot.companies.filter((c) => c.cat === category.id);
|
||||||
|
return { ...category, count: companies.length,
|
||||||
|
self: companies.filter((c) => c.revenueStatus === "self").length,
|
||||||
|
platform: companies.filter((c) => c.revenueStatus === "platform").length,
|
||||||
|
estimate: companies.filter((c) => c.revenueStatus === "estimate").length,
|
||||||
|
unknown: companies.filter((c) => c.revenueStatus === "unknown").length };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { authId, matchingStep, type AuthConfig } from "./auth";
|
||||||
|
import { openSiteDatabase } from "./site-database";
|
||||||
|
|
||||||
function database() {
|
function database() {
|
||||||
const configFile = process.env.AUTH_CONFIG_FILE;
|
const db = openSiteDatabase();
|
||||||
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);
|
|
||||||
db.exec(`PRAGMA busy_timeout=5000;
|
db.exec(`PRAGMA busy_timeout=5000;
|
||||||
CREATE TABLE IF NOT EXISTS login_state (
|
CREATE TABLE IF NOT EXISTS login_state (
|
||||||
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
|
id TEXT PRIMARY KEY, window_start INTEGER NOT NULL,
|
||||||
|
|||||||
+295
-38
@@ -1,50 +1,307 @@
|
|||||||
import fs from "node:fs/promises";
|
import { randomUUID, createHash } from "node:crypto";
|
||||||
import path from "node:path";
|
import { openSiteDatabase } from "./site-database";
|
||||||
import catalog from "../../../../content/catalog.json";
|
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 = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
|
|
||||||
export type DocLink = {
|
export type DocLink = {
|
||||||
slug: string; sourceSlug: string; category: 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;
|
group: string; collection: string; description: string; draft: boolean; featured: boolean;
|
||||||
|
revision: number;
|
||||||
};
|
};
|
||||||
export type Doc = DocLink & { content: string };
|
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> {
|
export async function readDoc(slug: string): Promise<Doc> {
|
||||||
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
|
const doc = readDocument(slug);
|
||||||
const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug;
|
if (!doc) throw Error("Document not found");
|
||||||
const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8");
|
return doc;
|
||||||
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
|
|
||||||
const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!;
|
|
||||||
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[sourceSlug] ?? {};
|
|
||||||
if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) {
|
|
||||||
throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`);
|
|
||||||
}
|
|
||||||
return { slug: metadata.slug, sourceSlug, category: metadata.category, 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDocs(): Promise<DocLink[]> {
|
export async function listDocs(): Promise<DocLink[]> {
|
||||||
async function walk(folder: string): Promise<string[]> {
|
return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; });
|
||||||
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;
|
// Explicit, atomic bootstrap only. Runtime reads never import repository data.
|
||||||
if (entry.isDirectory()) return walk(relative);
|
export function importDocuments(bundle: DocumentExport, onlyIfEmpty = false): number {
|
||||||
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
|
if (bundle?.format !== "tabularium-documents-v1" || !Array.isArray(bundle.documents) || !bundle.documents.length)
|
||||||
return [relative.slice(0, -3)];
|
throw Error("Invalid or empty document export");
|
||||||
return [];
|
bundle.documents.forEach(validateDocument);
|
||||||
}));
|
const digest = createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
|
||||||
return files.flat();
|
const db = database();
|
||||||
}
|
try {
|
||||||
const docs = await Promise.all((await walk("")).sort().map(async (slug) => {
|
db.exec("BEGIN IMMEDIATE");
|
||||||
const { content: _, searchText: __, ...doc } = await readDoc(slug);
|
try {
|
||||||
void _; void __;
|
const imported = db.prepare("SELECT digest FROM document_imports WHERE digest=?").get(digest);
|
||||||
return doc;
|
if (imported) { db.exec("COMMIT"); return 0; }
|
||||||
}));
|
const count = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
|
||||||
if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug");
|
if (count) {
|
||||||
return docs;
|
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,82 @@
|
|||||||
|
import type { ResearchCompany, ResearchObservation, ResearchSnapshot } from "./ai-research";
|
||||||
|
|
||||||
|
export type IncomePoint = { company: ResearchCompany; observation: ResearchObservation; value: number };
|
||||||
|
export type IncomeStats = {
|
||||||
|
n: number; mean: number; median: number; min: number; max: number;
|
||||||
|
sampleDeviation: number | null; cv: number | null; meanMedianRatio: number | null;
|
||||||
|
largestShare: number | null; meanWithoutLargest: number | null; largestRemovalDrop: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Describes the supplied sample only; missing values never become zero. */
|
||||||
|
export function describeIncome(values: number[]): IncomeStats | null {
|
||||||
|
if (!values.length || values.some((value) => !Number.isFinite(value) || value < 0)) return null;
|
||||||
|
const ordered = [...values].sort((a, b) => a - b);
|
||||||
|
const n = ordered.length;
|
||||||
|
const total = ordered.reduce((sum, value) => sum + value, 0);
|
||||||
|
const mean = total / n;
|
||||||
|
const median = n % 2 ? ordered[(n - 1) / 2] : (ordered[n / 2 - 1] + ordered[n / 2]) / 2;
|
||||||
|
const sampleDeviation = n > 1 ? Math.sqrt(ordered.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)) : null;
|
||||||
|
const max = ordered[n - 1];
|
||||||
|
const meanWithoutLargest = n > 1 ? (total - max) / (n - 1) : null;
|
||||||
|
return { n, mean, median, min: ordered[0], max, sampleDeviation,
|
||||||
|
cv: sampleDeviation !== null && mean > 0 ? sampleDeviation / mean : null,
|
||||||
|
meanMedianRatio: median > 0 ? mean / median : null,
|
||||||
|
largestShare: total > 0 ? max / total : null, meanWithoutLargest,
|
||||||
|
largestRemovalDrop: meanWithoutLargest !== null && mean > 0 ? (mean - meanWithoutLargest) / mean : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDay(value: string | undefined): number | null {
|
||||||
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||||
|
const time = Date.parse(`${value}T00:00:00Z`);
|
||||||
|
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? time / 86400000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest dated platform MRR per product, never ARR, cash collections or estimates. */
|
||||||
|
export function platformIncomeSample(snapshot: ResearchSnapshot, options: {
|
||||||
|
windowDays: number | null; foundedYear?: number; annualize?: boolean;
|
||||||
|
}) {
|
||||||
|
const end = calendarDay(snapshot.asOf);
|
||||||
|
if (end === null || (options.windowDays !== null && (!Number.isInteger(options.windowDays) || options.windowDays < 0)))
|
||||||
|
throw new Error("Invalid income sample window");
|
||||||
|
const companies = new Map(snapshot.companies.map((company) => [company.id, company]));
|
||||||
|
const latest = new Map<string, ResearchObservation>();
|
||||||
|
const excluded: { observation: ResearchObservation; reason: string }[] = [];
|
||||||
|
const candidates = snapshot.observations.filter((o) => o.selection === "platform" && o.metric === "mrr");
|
||||||
|
for (const observation of candidates) {
|
||||||
|
const company = companies.get(observation.company);
|
||||||
|
const day = calendarDay(observation.observedAt);
|
||||||
|
let reason = "";
|
||||||
|
if (!company || ![2025, 2026].includes(company.year ?? 0)) reason = "创立年份未纳入2025—2026组";
|
||||||
|
else if (options.foundedYear && company.year !== options.foundedYear) reason = "不属于所选创立年份";
|
||||||
|
else if (observation.origin !== "payment-platform" || observation.unit !== "USD" || observation.bound !== "=" ||
|
||||||
|
observation.comparisonGroup !== "trustmrr-usd-mrr" || !observation.provider || !Number.isFinite(observation.value) || observation.value < 0)
|
||||||
|
reason = "来源、币种、指标或数值口径不满足条件";
|
||||||
|
else if (day === null) reason = "缺少有效数据日期";
|
||||||
|
else if (day > end) reason = "数据日期晚于研究截止日";
|
||||||
|
else if (options.windowDays !== null && end - day > options.windowDays) reason = "最后同步日期超出所选窗口";
|
||||||
|
if (reason) { excluded.push({ observation, reason }); continue; }
|
||||||
|
const previous = latest.get(observation.company);
|
||||||
|
if (!previous || observation.observedAt! > previous.observedAt!) {
|
||||||
|
if (previous) excluded.push({ observation: previous, reason: "同一产品的较早观测" });
|
||||||
|
latest.set(observation.company, observation);
|
||||||
|
} else excluded.push({ observation, reason: "同一产品已选较新或同日观测" });
|
||||||
|
}
|
||||||
|
const points: IncomePoint[] = [...latest.values()].map((observation) => ({
|
||||||
|
company: companies.get(observation.company)!, observation,
|
||||||
|
value: observation.value * (options.annualize ? 12 : 1),
|
||||||
|
}));
|
||||||
|
return { points, excluded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupIncome(points: IncomePoint[], splitModels = true) {
|
||||||
|
const groups = new Map<string, { category: string; provider: string; model: string; points: IncomePoint[] }>();
|
||||||
|
for (const point of points) {
|
||||||
|
const category = point.company.cat;
|
||||||
|
const provider = point.observation.provider!;
|
||||||
|
const model = splitModels ? point.company.businessModel ?? "收费模式未核验" : "全部收费模式";
|
||||||
|
const key = JSON.stringify([category, provider, model]);
|
||||||
|
if (!groups.has(key)) groups.set(key, { category, provider, model, points: [] });
|
||||||
|
groups.get(key)!.points.push(point);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([key, group]) => ({ key, ...group, stats: describeIncome(group.points.map((point) => point.value))! }));
|
||||||
|
}
|
||||||
@@ -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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { listDocs, readDoc } from "@/lib/docs";
|
import { allDocuments } from "@/lib/docs";
|
||||||
|
|
||||||
// The proxy authenticates this endpoint, including index content.
|
// The proxy authenticates this endpoint, including index content.
|
||||||
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
|
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");
|
response.setHeader("Allow", "GET");
|
||||||
return response.status(405).end();
|
return response.status(405).end();
|
||||||
}
|
}
|
||||||
const docs = await listDocs();
|
try {
|
||||||
const index = await Promise.all(docs.map(async (doc) => ({
|
const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content }));
|
||||||
...doc, searchText: (await readDoc(doc.slug)).content,
|
return response.status(200).json(index);
|
||||||
})));
|
} catch {
|
||||||
response.status(200).json(index);
|
return response.status(503).json({ error: "文档数据库暂时不可用" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
GetStaticPaths,
|
GetServerSideProps,
|
||||||
GetStaticProps,
|
InferGetServerSidePropsType,
|
||||||
InferGetStaticPropsType,
|
|
||||||
} from "next";
|
} from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
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 { Shell, Navigation } from "@/components/Shell";
|
||||||
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
|
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
|
||||||
import { FigureGallery } from "@/components/FigureGallery";
|
import { FigureGallery } from "@/components/FigureGallery";
|
||||||
@@ -24,16 +24,18 @@ import { Icon } from "@/components/Icon";
|
|||||||
import { headingsFor } from "@/lib/navigation";
|
import { headingsFor } from "@/lib/navigation";
|
||||||
|
|
||||||
type Props = { doc: Doc; docs: DocLink[] };
|
type Props = { doc: Doc; docs: DocLink[] };
|
||||||
export const getStaticPaths: GetStaticPaths = async () => ({
|
const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch"));
|
||||||
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
|
export const getServerSideProps: GetServerSideProps<Props> = async ({ params, res, resolvedUrl }) => {
|
||||||
fallback: false,
|
res.setHeader("Cache-Control", "private, no-store");
|
||||||
});
|
|
||||||
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
|
||||||
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
|
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
|
||||||
if (typeof slug !== "string") return { notFound: true };
|
if (typeof slug !== "string") return { notFound: true };
|
||||||
const docs = await listDocs();
|
const doc = readDocument(slug);
|
||||||
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
|
if (!doc) return { notFound: true };
|
||||||
return { props: { doc: await readDoc(slug), docs } };
|
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 {
|
function textOf(value: ReactNode): string {
|
||||||
return Children.toArray(value)
|
return Children.toArray(value)
|
||||||
@@ -47,7 +49,7 @@ function textOf(value: ReactNode): string {
|
|||||||
export default function DocPage({
|
export default function DocPage({
|
||||||
doc,
|
doc,
|
||||||
docs,
|
docs,
|
||||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
const headings = headingsFor(doc.content);
|
const headings = headingsFor(doc.content);
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState("");
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export default function DocPage({
|
|||||||
<meta name="description" content={doc.description} />
|
<meta name="description" content={doc.description} />
|
||||||
</Head>
|
</Head>
|
||||||
<Shell key={doc.slug} docs={docs} current={doc.slug}>
|
<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">
|
<aside className="sidebar">
|
||||||
<Navigation docs={docs} current={doc.slug} />
|
<Navigation docs={docs} current={doc.slug} />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -225,12 +227,13 @@ export default function DocPage({
|
|||||||
{doc.content}
|
{doc.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</article>
|
</article>
|
||||||
|
{doc.slug === "other-research/ai-company-research/overview" && <AICompanyResearch />}
|
||||||
<div className="article-end">
|
<div className="article-end">
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.sourceSlug}.md`}
|
href={`/api/documents/source?slug=${encodeURIComponent(doc.slug)}`}
|
||||||
>
|
>
|
||||||
查看文档源码 ↗
|
查看 Markdown 原文 ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav className="pagination" aria-label="相邻文档">
|
<nav className="pagination" aria-label="相邻文档">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GetStaticProps, InferGetStaticPropsType } from "next";
|
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -7,11 +7,12 @@ import { Shell, Navigation } from "@/components/Shell";
|
|||||||
import { Icon } from "@/components/Icon";
|
import { Icon } from "@/components/Icon";
|
||||||
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
|
export const getServerSideProps: GetServerSideProps<{ docs: DocLink[] }> = async ({ res }) => {
|
||||||
props: { docs: await listDocs() },
|
res.setHeader("Cache-Control", "private, no-store");
|
||||||
});
|
return { props: { docs: await listDocs() } };
|
||||||
|
};
|
||||||
const PAGE_SIZE = 8;
|
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 [collection, setCollection] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
|||||||
+9
-13
@@ -1,11 +1,6 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
|
||||||
import catalog from "../../../content/catalog.json";
|
import { authenticateDocumentKey } from "./lib/document-keys";
|
||||||
|
|
||||||
const legacyRoutes = new Map(Object.entries(catalog.documents).map(([source, metadata]) => [
|
|
||||||
`/docs/${source}`, `/docs/${metadata.slug}`,
|
|
||||||
]));
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname;
|
const path = request.nextUrl.pathname;
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -37,6 +32,14 @@ export function proxy(request: NextRequest) {
|
|||||||
? authorization.slice(7)
|
? authorization.slice(7)
|
||||||
: undefined;
|
: undefined;
|
||||||
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
|
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 (validSession(token, config)) {
|
||||||
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
|
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
|
||||||
try {
|
try {
|
||||||
@@ -54,13 +57,6 @@ export function proxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep legacy URLs behind authentication and out of static generation.
|
|
||||||
const destination = legacyRoutes.get(path);
|
|
||||||
if (destination && ["GET", "HEAD"].includes(request.method)) {
|
|
||||||
const target = request.nextUrl.clone();
|
|
||||||
target.pathname = destination;
|
|
||||||
return NextResponse.redirect(target, { status: 308, headers });
|
|
||||||
}
|
|
||||||
return NextResponse.next({ headers });
|
return NextResponse.next({ headers });
|
||||||
}
|
}
|
||||||
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
|
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
|
||||||
|
|||||||
@@ -1319,11 +1319,6 @@ svg { flex-shrink: 0; }
|
|||||||
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
|
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
|
||||||
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
|
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
|
||||||
}
|
}
|
||||||
.collection-picker { display: flex; flex-wrap: wrap; align-items: center; position: relative; min-width: 180px; max-width: 280px; }
|
|
||||||
.collection-picker select { appearance: none; width: 100%; cursor: pointer; padding: 8px 30px 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font: inherit; font-size: 13px; color: var(--ink); text-overflow: ellipsis; }
|
|
||||||
.picker-category { width: 100%; font-size: 10px; line-height: 1.4; color: var(--muted); margin-bottom: 2px; }
|
|
||||||
.collection-picker > svg { position: absolute; right: 10px; bottom: 12px; pointer-events: none; transform: rotate(90deg); color: var(--muted); }
|
|
||||||
.collection-picker select:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
|
|
||||||
.top-nav { margin-left: 0; }
|
.top-nav { margin-left: 0; }
|
||||||
.sidebar { padding: 22px 16px 32px; }
|
.sidebar { padding: 22px 16px 32px; }
|
||||||
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
|
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
|
||||||
@@ -1347,8 +1342,141 @@ svg { flex-shrink: 0; }
|
|||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.top-nav { overflow: visible; }
|
.top-nav { overflow: visible; }
|
||||||
.collection-picker { width: 100%; max-width: 100%; }
|
|
||||||
.picker-category { display: none; }
|
|
||||||
.collection-picker select { padding-block: 6px; }
|
|
||||||
.mobile-navigation .nav-group { margin-top: 16px; }
|
.mobile-navigation .nav-group { margin-top: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Categories stay visible across the header, each with its own disclosure panel. */
|
||||||
|
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
|
||||||
|
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
|
||||||
|
.category-navigation > ul > li { display: flex; align-items: stretch; }
|
||||||
|
.category-menu { position: relative; }
|
||||||
|
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
|
||||||
|
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
|
||||||
|
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
|
||||||
|
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
|
||||||
|
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
|
||||||
|
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
|
||||||
|
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
|
||||||
|
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
|
||||||
|
.category-panel[hidden] { display: none; }
|
||||||
|
.category-menu:last-child .category-panel { left: auto; right: -16px; }
|
||||||
|
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
|
||||||
|
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
|
||||||
|
.top-nav .category-panel a::after { display: none; }
|
||||||
|
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
|
||||||
|
.category-panel a > svg { margin-top: 2px; color: var(--green); }
|
||||||
|
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||||
|
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
|
||||||
|
@media (min-width: 761px) and (max-width: 1100px) {
|
||||||
|
.site-header { gap: 20px; }
|
||||||
|
.category-navigation > ul { gap: 16px; }
|
||||||
|
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||||
|
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
|
||||||
|
.search-trigger span, .search-trigger kbd { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.top-nav.category-navigation { overflow: visible; }
|
||||||
|
.category-navigation > ul { width: 100%; gap: 18px; }
|
||||||
|
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||||
|
.category-menu { position: static; }
|
||||||
|
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Research keeps the documentation navigation and uses the remaining reading width. */
|
||||||
|
.research-layout { grid-template-columns: 240px minmax(0, 1fr); }
|
||||||
|
.research-layout .reading-main { max-width: 1440px; width: 100%; }
|
||||||
|
.research-layout > .toc { display: none; }
|
||||||
|
.ai-research { margin-top: 28px; font-size: 13px; line-height: 1.65; }
|
||||||
|
.ai-research h2 { font-size: 20px; font-weight: 600; margin: 0 0 10px; letter-spacing: -.4px; }
|
||||||
|
.ai-research h3 { font-size: 15px; font-weight: 600; margin: 18px 0 8px; }
|
||||||
|
.ai-research p { margin: 6px 0 10px; }
|
||||||
|
.ai-research a, .research-company-name { color: var(--green); }
|
||||||
|
.ai-research a:hover, .research-company-name:hover { text-decoration: underline; }
|
||||||
|
.ai-research button { text-align: left; }
|
||||||
|
.research-meta { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--muted); font-size: 12px; }
|
||||||
|
.research-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin: 22px 0; }
|
||||||
|
.research-stats > div { border-top: 1px solid var(--line); padding: 14px 0; }
|
||||||
|
.research-stats strong { display: block; font-size: 29px; line-height: 1.3; font-weight: 550; font-variant-numeric: tabular-nums; }
|
||||||
|
.research-stats span { color: var(--muted); font-size: 12px; }
|
||||||
|
.research-scope { padding: 14px 18px; background: var(--soft); border-radius: 7px; }
|
||||||
|
.research-scope summary { color: var(--green); font-weight: 500; }
|
||||||
|
.research-scope ul { padding-left: 20px; }
|
||||||
|
.research-section { margin: 38px 0; scroll-margin-top: 92px; }
|
||||||
|
.research-muted, .research-footnote { color: var(--muted); font-size: 12px; }
|
||||||
|
.research-categories { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 26px; margin-top: 20px; }
|
||||||
|
.research-categories button { padding: 12px 0; background: transparent; border: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.research-categories button:hover { color: var(--green); }
|
||||||
|
.research-categories button > div, .research-revenue-chart > div > div { display: flex; justify-content: space-between; gap: 16px; }
|
||||||
|
.research-categories strong { font-variant-numeric: tabular-nums; }
|
||||||
|
.research-categories small { font-size: 11px; color: var(--muted); }
|
||||||
|
.research-bar { display: block; height: 5px; background: #f0f3f1; margin: 8px 0; border-radius: 3px; overflow: hidden; }
|
||||||
|
.research-bar i { display: block; height: 100%; background: #47856d; border-radius: 3px; }
|
||||||
|
.research-bar[data-estimate] i { background: #b98a40; }
|
||||||
|
.research-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin: 18px 0; overflow-x: auto; }
|
||||||
|
.research-tabs button { padding: 10px 0; white-space: nowrap; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); }
|
||||||
|
.research-tabs button[aria-pressed="true"] { border-bottom-color: var(--green); color: var(--green); font-weight: 600; }
|
||||||
|
.research-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
|
||||||
|
.research-filters label { display: flex; flex-direction: column; gap: 5px; color: var(--muted); font-size: 11px; flex: 1 1 140px; }
|
||||||
|
.research-filters .research-query { flex: 2 1 210px; }
|
||||||
|
.research-filters :is(input,select) { min-width: 0; width: 100%; padding: 8px 10px; font-size: 12px; color: var(--ink); background: #fff; border: 1px solid #dfe6e0; border-radius: 5px; height: 36px; }
|
||||||
|
.research-result-count { display: flex; justify-content: space-between; margin: 14px 0 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.research-result-count button { border: 0; background: none; color: var(--green); }
|
||||||
|
.research-table-wrap { overflow-x: auto; max-width: 100%; }
|
||||||
|
.ai-research table { width: 100%; border-collapse: collapse; min-width: 720px; text-align: left; font-size: 12px; }
|
||||||
|
.ai-research caption { text-align: left; color: var(--muted); padding: 12px 0; font-size: 12px; }
|
||||||
|
.ai-research th { color: var(--muted); background: #f7f9f7; font-weight: 500; }
|
||||||
|
.ai-research :is(th,td) { padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
.ai-research td:first-child { min-width: 140px; }
|
||||||
|
.ai-research td:nth-child(2) { min-width: 220px; }
|
||||||
|
.ai-research td p { margin: 6px 0; }
|
||||||
|
.ai-research td small { display: block; color: var(--muted); margin-top: 5px; }
|
||||||
|
.ai-research tr[data-selected] { background: #f0f8f3; }
|
||||||
|
.research-company-name { font-weight: 600; background: none; border: 0; padding: 0; }
|
||||||
|
.research-tag { display: inline-block; font-size: 10px; line-height: 1.7; padding: 1px 6px; border-radius: 3px; background: #eef3ef; color: #596e60; }
|
||||||
|
.research-pagination { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 16px; }
|
||||||
|
.research-pagination button, .research-message button, .research-detail-heading button { padding: 6px 11px; border-radius: 4px; border: 1px solid var(--line); background: white; }
|
||||||
|
.research-pagination button:disabled { color: #a8afa9; cursor: default; }
|
||||||
|
.research-message { padding: 24px 0; color: var(--muted); }
|
||||||
|
.research-message button { margin-left: 12px; color: var(--green); }
|
||||||
|
.research-notice { border-left: 3px solid #c6a063; padding: 10px 16px; background: #faf8f2; }
|
||||||
|
.research-revenue-chart { display: grid; gap: 18px; padding: 18px 0 30px; }
|
||||||
|
.research-revenue-chart button { padding: 0; border: 0; background: none; color: var(--green); }
|
||||||
|
.research-revenue-chart strong { font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||||
|
.research-revenue-chart small { color: var(--muted); font-size: 11px; }
|
||||||
|
.research-evidence-list, .research-review-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.research-evidence { padding: 18px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 92px; }
|
||||||
|
.research-evidence > div { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.research-evidence code { font-size: 11px; color: var(--muted); }
|
||||||
|
.research-evidence strong { font-weight: 500; }
|
||||||
|
.research-evidence small { color: var(--muted); font-size: 11px; }
|
||||||
|
.research-review-list > li { padding: 20px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.research-review-list h3 { margin: 8px 0; }
|
||||||
|
.research-review-list > li > div { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.research-detail { padding-top: 24px; border-top: 2px solid #dbe8df; }
|
||||||
|
.research-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.research-company-facts { margin: 24px 0; }
|
||||||
|
.research-company-facts > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.research-company-facts dt { color: var(--muted); }
|
||||||
|
.research-company-facts dd { margin: 0; }
|
||||||
|
.research-footnote { border-top: 1px solid var(--line); padding: 18px 0; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.research-layout { grid-template-columns: 1fr; }
|
||||||
|
.research-layout .reading-main { padding: 26px 24px 0; }
|
||||||
|
.research-section, .research-evidence { scroll-margin-top: 176px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.research-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 18px; }
|
||||||
|
.research-categories { grid-template-columns: 1fr; }
|
||||||
|
.research-tabs { gap: 16px; }
|
||||||
|
.research-layout .reading-main { padding: 22px 16px 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-distribution { margin: 24px 0 38px; }
|
||||||
|
.research-distribution figure { margin: 20px 0; }
|
||||||
|
.income-distribution-chart { display: block; width: 100%; min-width: 900px; height: auto; }
|
||||||
|
.income-distribution-chart [role="button"] { cursor: pointer; outline: none; }
|
||||||
|
.income-distribution-chart [role="button"]:focus circle:last-child { stroke: #b17725; stroke-width: 3; }
|
||||||
|
.research-distribution figcaption { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 10px 0; }
|
||||||
|
.income-options { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0; font-size: 12px; }
|
||||||
|
.income-options label { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.research-distribution details { margin: 16px 0; font-size: 12px; line-height: 1.8; }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}));
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import seed from "../../../content/research/ai-companies.seed.json";
|
||||||
|
import coverageAudit from "../../../content/research/ai-companies.coverage.json";
|
||||||
|
import incomeReference from "../../../content/research/ai-income-analysis.json";
|
||||||
|
import { readResearchSnapshot } from "../src/lib/ai-research-store";
|
||||||
|
import { openSiteDatabase } from "../src/lib/site-database";
|
||||||
|
import { categorySummary, evidenceUrl, reviewCompanyIds, validateResearch, type ResearchSnapshot } from "../src/lib/ai-research";
|
||||||
|
import { consumeCode, initializeStore } from "../src/lib/auth-store";
|
||||||
|
import { newAuthConfig } from "../src/lib/auth";
|
||||||
|
import { describeIncome, groupIncome, platformIncomeSample } from "../src/lib/research-statistics";
|
||||||
|
|
||||||
|
test("research revisions share the authentication database and preserve replay limits", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "site-research-"));
|
||||||
|
const previous = process.env.AUTH_CONFIG_FILE;
|
||||||
|
process.env.AUTH_CONFIG_FILE = join(dir, "config.json");
|
||||||
|
try {
|
||||||
|
initializeStore();
|
||||||
|
const config = newAuthConfig();
|
||||||
|
for (let i = 0; i < 20; i++) consumeCode(config, "invalid");
|
||||||
|
assert.equal(consumeCode(config, "invalid"), "limited");
|
||||||
|
const initial = readResearchSnapshot();
|
||||||
|
assert.equal(initial.companies.length, 1023);
|
||||||
|
assert.equal(initial.evidence.length, 1318);
|
||||||
|
assert.equal(initial.observations.length, 189);
|
||||||
|
assert.deepEqual(initial.candidateBatches, seed.candidateBatches, "candidate gaps persist in the same database metadata");
|
||||||
|
assert.deepEqual(initial.directoryRechecks, seed.directoryRechecks, "follow-up classifications persist without rewriting the discovery audit");
|
||||||
|
assert.deepEqual(readResearchSnapshot(), initial, "reopening does not duplicate records");
|
||||||
|
const db = openSiteDatabase();
|
||||||
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
||||||
|
assert.ok(tables.some((r) => r.name === "login_state"));
|
||||||
|
assert.ok(tables.some((r) => r.name === "research_companies"));
|
||||||
|
assert.equal(db.prepare("PRAGMA foreign_keys").get()?.foreign_keys, 1);
|
||||||
|
db.close();
|
||||||
|
const next = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
next.version += "-revision-test";
|
||||||
|
next.companies[0].name = "Revised company";
|
||||||
|
assert.equal(readResearchSnapshot(next).companies[0].name, "Revised company");
|
||||||
|
assert.deepEqual(readResearchSnapshot(), initial, "old release still reads its original revision");
|
||||||
|
assert.equal(consumeCode(config, "invalid"), "limited", "research import must not reset auth limits");
|
||||||
|
const collision = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
collision.companies[0].name = "Changed without revision";
|
||||||
|
assert.throws(() => readResearchSnapshot(collision), /new revision/);
|
||||||
|
assert.deepEqual(readResearchSnapshot(), initial, "failed import leaves valid revision intact");
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
||||||
|
else process.env.AUTH_CONFIG_FILE = previous;
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("research validation rejects broken evidence and unsafe source links", () => {
|
||||||
|
validateResearch(seed as ResearchSnapshot);
|
||||||
|
const invalid = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
invalid.observations[0].evidence = "missing";
|
||||||
|
assert.throws(() => validateResearch(invalid), /observation/);
|
||||||
|
assert.equal(evidenceUrl("javascript:alert(1)"), undefined);
|
||||||
|
assert.equal(evidenceUrl("/local-link"), undefined);
|
||||||
|
assert.equal(evidenceUrl("https://example.com/evidence"), "https://example.com/evidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviews link explicit companies without turning global or excluded cases into companies", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
assert.equal(snapshot.reviews.filter((r) => r.scope === "companies").length, 60);
|
||||||
|
assert.equal(snapshot.reviews.filter((r) => r.scope === "dataset").length, 10);
|
||||||
|
assert.equal(snapshot.reviews.filter((r) => r.scope === "excluded-candidate").length, 31);
|
||||||
|
const review = snapshot.reviews.find((r) => r.id === "R4")!;
|
||||||
|
assert.deepEqual(reviewCompanyIds(snapshot, review), ["AI007", "AI013", "AI021", "AI034"]);
|
||||||
|
assert.deepEqual(reviewCompanyIds(snapshot, snapshot.reviews.find((r) => r.id === "CN-R-PSI")!), []);
|
||||||
|
const oldReview = structuredClone(snapshot.reviews.find((r) => r.id === "TECH-GRAD-R-SCOPE")!);
|
||||||
|
delete oldReview.scope;
|
||||||
|
delete oldReview.companyIds;
|
||||||
|
assert.deepEqual(reviewCompanyIds(snapshot, oldReview), ["AI051"], "old revisions retain evidence-owner fallback");
|
||||||
|
review.companyIds = ["unknown-company"];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||||
|
review.companyIds = ["AI007", "AI007"];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||||
|
review.companyIds = [];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research review/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disclosure counts exclude client income, biographies, targets and duplicate milestones", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const summary = categorySummary(snapshot);
|
||||||
|
assert.equal(summary.reduce((n, c) => n + c.count, 0), 1023);
|
||||||
|
assert.equal(summary.reduce((n, c) => n + c.self, 0), 26);
|
||||||
|
assert.equal(summary.reduce((n, c) => n + c.estimate, 0), 1);
|
||||||
|
assert.equal(summary.reduce((n, c) => n + c.platform, 0), 16);
|
||||||
|
assert.equal(summary.reduce((n, c) => n + c.unknown, 0), 980);
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.name === "Rork")?.revenueStatus, "unknown");
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.name === "Result")?.revenueStatus, "unknown");
|
||||||
|
assert.ok(snapshot.observations.filter((o) => o.selection === "signal").every((o) => o.scope === "公司整体"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("technical reviews keep accessible materials separate from tested capabilities", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
assert.equal(snapshot.companies.filter((c) => c.technicalReview).length, 16);
|
||||||
|
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "public-artifact").length, 6);
|
||||||
|
assert.equal(snapshot.companies.filter((c) => c.technicalReview?.level === "independent-test").length, 1);
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.id === "AI051")?.revenueStatus, "unknown");
|
||||||
|
assert.match(snapshot.companies.find((c) => c.id === "AI051")!.technicalReview!.testScope!, /Sep 2026/);
|
||||||
|
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("TECH-GRAD-O-")).every((o) => o.selection === "technical" && o.comparisonGroup === null));
|
||||||
|
const company = snapshot.companies.find((c) => c.id === "AI061")!;
|
||||||
|
const review = company.technicalReview!;
|
||||||
|
review.evidenceIds = ["missing-technical-evidence"];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||||
|
review.evidenceIds = ["TECH-NAIVE-HF"];
|
||||||
|
review.reviewedAt = "2026-02-30";
|
||||||
|
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||||
|
review.reviewedAt = "2099-01-01";
|
||||||
|
assert.throws(() => validateResearch(snapshot), /technical review/);
|
||||||
|
review.reviewedAt = snapshot.asOf;
|
||||||
|
review.level = "independent-test";
|
||||||
|
assert.throws(() => validateResearch(snapshot), /technical review/,
|
||||||
|
"an accessible artifact cannot become a test record without a stated test scope");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ambiguous ARR claims retain currency, precision and attribution limits", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const variant = snapshot.observations.filter((o) => o.company === "AI066");
|
||||||
|
assert.deepEqual(variant.filter((o) => o.selection === "crosscheck").map((o) => o.value).sort(), [500000, 600000]);
|
||||||
|
assert.equal(variant.filter((o) => o.selection === "signal").length, 0);
|
||||||
|
assert.ok(variant.filter((o) => [2500000, 40000000].includes(o.value)).every((o) => o.selection === "excluded"));
|
||||||
|
const codos = snapshot.observations.find((o) => o.id === "SR-O-AI065-arr")!;
|
||||||
|
assert.equal(codos.bound, "≥");
|
||||||
|
assert.equal(codos.unit, "币种未注明");
|
||||||
|
assert.equal(codos.comparisonGroup, null);
|
||||||
|
assert.ok(snapshot.observations.filter((o) => o.id.startsWith("SR-O-")).every((o) => o.comparisonGroup === null && o.unit !== "USD"));
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.id === "AI071")?.year, 2021);
|
||||||
|
const rocket = snapshot.observations.find((o) => o.id === "ROCKET-O-arr")!;
|
||||||
|
assert.equal(rocket.selection, "historical");
|
||||||
|
assert.equal(rocket.comparisonGroup, null);
|
||||||
|
assert.notEqual(rocket.scope, "公司整体");
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.id === "AI070")?.revenueStatus, "unknown");
|
||||||
|
assert.equal(snapshot.observations.filter((o) => o.company === "AI070").length, 0,
|
||||||
|
"unreadable announcement summaries do not become numeric ARR");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retractions and forecasts must stay visible for review without becoming revenue signals.
|
||||||
|
test("retracted ARR and projected margins remain excluded", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const cluely = snapshot.companies.find((c) => c.name === "Cluely")!;
|
||||||
|
assert.equal(cluely.revenueStatus, "unknown");
|
||||||
|
assert.ok(snapshot.reviews.some((r) => r.company === "Cluely" && r.evidenceIds.includes("E100")));
|
||||||
|
const retracted = snapshot.observations.filter((o) => o.company === cluely.id);
|
||||||
|
assert.ok(retracted.length > 0);
|
||||||
|
assert.ok(retracted.every((o) => o.selection === "excluded"));
|
||||||
|
const forecast = snapshot.observations.find((o) => o.kind === "非GAAP毛利率预期")!;
|
||||||
|
assert.equal(forecast.selection, "excluded");
|
||||||
|
const peec = snapshot.companies.find((c) => c.name === "Peec AI")!;
|
||||||
|
assert.equal(peec.team, 100);
|
||||||
|
assert.equal(peec.teamBound, "≥");
|
||||||
|
const currentTeam = snapshot.observations.find((o) => o.id === "PEEC-JOB-TEAM")!;
|
||||||
|
assert.equal(currentTeam.value, peec.team);
|
||||||
|
assert.equal(currentTeam.bound, peec.teamBound);
|
||||||
|
const historicalTeam = snapshot.observations.find((o) => o.id === "O067")!;
|
||||||
|
assert.equal(historicalTeam.value, 70);
|
||||||
|
assert.equal(historicalTeam.bound, ">");
|
||||||
|
assert.equal(snapshot.observations.filter((o) => o.company === peec.id && o.selection === "signal").length, 1);
|
||||||
|
assert.ok(snapshot.observations.some((o) => o.company === peec.id && o.selection === "historical-estimate"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("directory coverage reconciles every candidate and keeps profile-only income unknown", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const coverage = snapshot.coverage!;
|
||||||
|
assert.equal(coverageAudit.records.length, coverage.candidateRecords);
|
||||||
|
assert.equal(new Set(coverageAudit.records.map((r) => r.ycId)).size, coverage.candidateRecords);
|
||||||
|
const count = (status: string) => coverageAudit.records.filter((r) => r.status === status).length;
|
||||||
|
assert.equal(count("imported"), coverage.newProfiles);
|
||||||
|
assert.equal(count("existing"), coverage.existingProfiles);
|
||||||
|
assert.equal(count("date-conflict"), coverage.dateConflicts);
|
||||||
|
assert.equal(count("year-outside-or-missing"), coverage.otherOrMissingYear);
|
||||||
|
assert.equal(count("ai-unclear"), coverage.unclearAI);
|
||||||
|
for (const row of coverageAudit.records.filter((r) => r.status === "imported")) {
|
||||||
|
const company = snapshot.companies.find((c) => c.id === `YC${row.ycId}`)!;
|
||||||
|
assert.ok(company.profileOnly || company.directoryOrigin === "yc-2025-2026");
|
||||||
|
assert.equal(company.url, row.source);
|
||||||
|
if (company.profileOnly) assert.equal(company.revenueStatus, "unknown");
|
||||||
|
assert.equal(company.year, row.foundedYear);
|
||||||
|
assert.ok([2025, 2026].includes(company.year!));
|
||||||
|
}
|
||||||
|
const invalid = structuredClone(snapshot);
|
||||||
|
invalid.coverage!.newProfiles += 1;
|
||||||
|
assert.throws(() => validateResearch(invalid), /coverage accounting/);
|
||||||
|
const promoted = snapshot.companies.find((c) => c.id === "YC30215")!;
|
||||||
|
assert.equal(promoted.profileOnly, false);
|
||||||
|
assert.equal(promoted.directoryOrigin, "yc-2025-2026");
|
||||||
|
assert.equal(promoted.revenueStatus, "self");
|
||||||
|
const lostOrigin = structuredClone(snapshot);
|
||||||
|
delete lostOrigin.companies.find((c) => c.id === promoted.id)!.directoryOrigin;
|
||||||
|
assert.throws(() => validateResearch(lostOrigin), /coverage accounting/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("income descriptions report sample dispersion and maximum-value sensitivity", () => {
|
||||||
|
const stats = describeIncome([100, 200, 300])!;
|
||||||
|
assert.equal(stats.mean, 200);
|
||||||
|
assert.equal(stats.median, 200);
|
||||||
|
assert.equal(stats.sampleDeviation, 100);
|
||||||
|
assert.equal(stats.cv, .5);
|
||||||
|
assert.equal(stats.largestShare, .5);
|
||||||
|
assert.equal(stats.meanWithoutLargest, 150);
|
||||||
|
assert.equal(stats.largestRemovalDrop, .25);
|
||||||
|
assert.equal(describeIncome([5])!.cv, null);
|
||||||
|
assert.equal(describeIncome([0, 0])!.mean, 0, "observed zero is distinct from missing");
|
||||||
|
assert.equal(describeIncome([0, 0])!.cv, null);
|
||||||
|
assert.equal(describeIncome([]), null);
|
||||||
|
assert.equal(describeIncome([100, NaN]), null);
|
||||||
|
assert.equal(describeIncome([-1]), null);
|
||||||
|
assert.ok(describeIncome([1, 2, 100])!.largestRemovalDrop! > .95);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("platform samples exclude stale data and keep ARR, estimates and collections separate", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const recent = platformIncomeSample(snapshot, { windowDays: 7 });
|
||||||
|
assert.equal(recent.points.length, 14);
|
||||||
|
assert.equal(recent.excluded.length, 3);
|
||||||
|
assert.ok(recent.points.every((p) => p.observation.metric === "mrr" && p.observation.selection === "platform"));
|
||||||
|
assert.deepEqual(new Set(recent.excluded.map((p) => p.observation.company)), new Set(["AI016", "MR013", "MR014"]));
|
||||||
|
assert.ok(recent.points.some((p) => p.company.name === "TrackAI" && p.observation.connectionExpired), "dated recent value is not a claim of active connection");
|
||||||
|
const older = platformIncomeSample(snapshot, { windowDays: 90 });
|
||||||
|
assert.equal(older.points.length, 15);
|
||||||
|
assert.ok(older.points.some((p) => p.company.id === "AI016"));
|
||||||
|
const scaled = platformIncomeSample(snapshot, { windowDays: 7, annualize: true });
|
||||||
|
const monthlyGroups = groupIncome(recent.points);
|
||||||
|
const annualGroups = groupIncome(scaled.points);
|
||||||
|
for (const group of monthlyGroups) {
|
||||||
|
const annual = annualGroups.find((g) => g.key === group.key)!;
|
||||||
|
assert.ok(Math.abs(annual.stats.mean - group.stats.mean * 12) < 1e-6);
|
||||||
|
if (group.stats.cv !== null) assert.ok(Math.abs(annual.stats.cv! - group.stats.cv) < 1e-10);
|
||||||
|
}
|
||||||
|
const invalid = structuredClone(snapshot);
|
||||||
|
invalid.observations = invalid.observations.filter((o) => o.company === "MR001" && o.metric === "mrr");
|
||||||
|
const first = invalid.observations[0];
|
||||||
|
invalid.observations.push({ ...first, id: "future", observedAt: "2026-11-01", value: 999999 });
|
||||||
|
invalid.observations.push({ ...first, id: "bad-date", observedAt: "2026-02-30", value: 999999 });
|
||||||
|
invalid.observations.push({ ...first, id: "currency", unit: "EUR", value: 999999 });
|
||||||
|
invalid.observations.push({ ...first, id: "older", observedAt: "2026-10-28", value: 1 });
|
||||||
|
const selected = platformIncomeSample(invalid, { windowDays: 7 });
|
||||||
|
assert.equal(selected.points.length, 1);
|
||||||
|
assert.equal(selected.points[0].observation.id, first.id, "bad newer data and older duplicate must not replace latest valid point");
|
||||||
|
assert.equal(selected.excluded.length, 4);
|
||||||
|
assert.throws(() => platformIncomeSample(snapshot, { windowDays: -1 }), /window/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distribution statistics match independently calculated snapshot references", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
assert.equal(incomeReference.sourceVersion, snapshot.version);
|
||||||
|
for (const reference of incomeReference.windows) {
|
||||||
|
const sample = platformIncomeSample(snapshot, { windowDays: reference.days });
|
||||||
|
assert.deepEqual(new Set(sample.points.map((p) => p.company.id)), new Set(reference.products));
|
||||||
|
const groups = groupIncome(sample.points);
|
||||||
|
assert.equal(groups.length, reference.groups.length);
|
||||||
|
for (const expected of reference.groups) {
|
||||||
|
const actual = groups.find((g) => g.category === expected.category && g.provider === expected.provider && g.model === expected.model)!;
|
||||||
|
assert.equal(actual.stats.n, expected.n);
|
||||||
|
for (const metric of ["mean", "median", "sampleDeviation", "cv", "meanWithoutLargest", "largestRemovalDrop"] as const) {
|
||||||
|
const value = expected[metric];
|
||||||
|
if (value === null) assert.equal(actual.stats[metric], null);
|
||||||
|
else assert.ok(Math.abs(actual.stats[metric]! - value) < 1e-6, metric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("product launch candidates preserve unresolved years and many launches per company", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
const batch = snapshot.candidateBatches![0];
|
||||||
|
assert.equal(batch.complete, false);
|
||||||
|
assert.equal(batch.records.length, 19);
|
||||||
|
const kilo = batch.records.filter((r) => r.companyId === "AI072");
|
||||||
|
assert.equal(kilo.length, 2, "IDE and hosted products resolve to one company");
|
||||||
|
assert.equal(snapshot.companies.filter((c) => c.id === "AI072").length, 1);
|
||||||
|
assert.ok(batch.records.some((r) => r.status === "pending-year" && !r.companyId));
|
||||||
|
assert.ok(batch.records.some((r) => r.status === "existing-outside-cohort" && r.companyId === "AI054"));
|
||||||
|
kilo[0].companyId = "missing-company";
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research candidate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("annual launch archives exclude older companies only with evidence and reuse existing records", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
const batch = snapshot.candidateBatches!.find((b) => b.id === "PH-2025-visible-1")!;
|
||||||
|
assert.equal(batch.complete, false);
|
||||||
|
assert.equal(batch.records.length, 19);
|
||||||
|
const older = batch.records.filter((r) => r.status === "outside-cohort");
|
||||||
|
assert.equal(older.length, 16);
|
||||||
|
assert.ok(older.every((r) => !r.companyId && r.evidenceIds?.length));
|
||||||
|
assert.equal(batch.records.find((r) => r.name === "Trace")?.companyId, "YC30687");
|
||||||
|
const trace = snapshot.companies.find((c) => c.id === "YC30687")!;
|
||||||
|
assert.equal(trace.profileOnly, false);
|
||||||
|
assert.equal(trace.directoryOrigin, "yc-2025-2026");
|
||||||
|
assert.equal(trace.revenueStatus, "unknown");
|
||||||
|
assert.ok(!snapshot.observations.some((o) => o.evidence === "TRACE-HOME"), "demo metrics must not become reported results");
|
||||||
|
older[0].evidenceIds = [];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research candidate/);
|
||||||
|
older[0].evidenceIds = ["missing-proof"];
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research candidate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unreconciled directory financials remain source reviews instead of income observations", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const review = snapshot.reviews.find((r) => r.id === "PH-R-SOURCE-QUALITY")!;
|
||||||
|
assert.equal(review.scope, "dataset");
|
||||||
|
assert.ok(review.evidenceIds.includes("PH-TANA-DR"));
|
||||||
|
assert.equal(reviewCompanyIds(snapshot, review).length, 0);
|
||||||
|
assert.ok(!snapshot.observations.some((o) => o.evidence === "PH-TANA-DR"));
|
||||||
|
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).filter((r) => r.status === "pending-year").length, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("product pivots and founder histories do not become new company years or current revenue", () => {
|
||||||
|
const snapshot = seed as ResearchSnapshot;
|
||||||
|
const pivot = snapshot.companies.find((c) => c.id === "AI073")!;
|
||||||
|
assert.equal(pivot.year, null, "CRM launch is not a verified company founding year");
|
||||||
|
assert.equal(pivot.revenueStatus, "unknown");
|
||||||
|
const candidate = snapshot.candidateBatches!.flatMap((b) => b.records).find((r) => r.id === "PH26-11")!;
|
||||||
|
assert.equal(candidate.status, "existing-outside-cohort");
|
||||||
|
assert.equal(candidate.companyId, pivot.id);
|
||||||
|
assert.ok(!platformIncomeSample(snapshot, { windowDays: null }).points.some((point) => point.company.id === pivot.id));
|
||||||
|
assert.ok(!snapshot.observations.some((o) => ["PH-FUNDRAISLY-ABOUT", "LF-PIVOT", "LF-HISTORY"].includes(o.evidence)), "other ventures' ARR, users and funding must not transfer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follow-up evidence can admit an uncertain directory candidate without rewriting its original status", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
const followup = snapshot.directoryRechecks!.find((row) => row.ycId === 36088)!;
|
||||||
|
const original = coverageAudit.records.find((row) => row.ycId === followup.ycId)!;
|
||||||
|
assert.equal(original.status, "ai-unclear");
|
||||||
|
assert.equal(followup.originalStatus, original.status);
|
||||||
|
assert.equal(snapshot.companies.find((c) => c.id === followup.companyId)?.year, 2026);
|
||||||
|
assert.equal(snapshot.candidateBatches!.flatMap((b) => b.records).find((r) => r.name === "OpenSEO")?.companyId, followup.companyId);
|
||||||
|
assert.ok(!snapshot.observations.some((o) => ["SEO-PRICE", "UP-YC", "PAZI-PRICE"].includes(o.evidence)), "pricing, other ventures' ARR and prepaid balances stay outside income observations");
|
||||||
|
followup.companyId = "missing-company";
|
||||||
|
assert.throws(() => validateResearch(snapshot), /directory recheck/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Chinese discovery entries preserve unread articles and use separate primary evidence", () => {
|
||||||
|
const snapshot = structuredClone(seed) as ResearchSnapshot;
|
||||||
|
const batch = snapshot.candidateBatches!.find((b) => b.id === "JQ-2026-targeted-1")!;
|
||||||
|
assert.equal(batch.complete, false);
|
||||||
|
assert.equal(batch.records.length, 4);
|
||||||
|
assert.ok(batch.records.every((row) => row.kind === "company" && row.profileStatus === "unavailable"));
|
||||||
|
assert.equal(batch.records.filter((row) => row.status === "linked").length, 2);
|
||||||
|
assert.equal(batch.records.find((row) => row.name === "Utopai")?.status, "outside-cohort");
|
||||||
|
assert.ok(!snapshot.observations.some((o) => ["PS-CODE", "SG-START", "SG-APR", "SSI-HISTORY"].includes(o.evidence)), "task costs, investment, capacity and financing are not revenue");
|
||||||
|
const prism = snapshot.companies.find((c) => c.id === "AI075")!;
|
||||||
|
assert.equal(prism.team, null, "a team range or linked employees must not become exact headcount");
|
||||||
|
batch.records[0].url = null;
|
||||||
|
assert.throws(() => validateResearch(snapshot), /research candidate/);
|
||||||
|
});
|
||||||
@@ -1,200 +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,
|
|
||||||
"slug": "endor/endor-concepts-draft",
|
|
||||||
"category": "Endor"
|
|
||||||
},
|
|
||||||
"01-getting-started": {
|
|
||||||
"group": "开始使用",
|
|
||||||
"description": "本地开发、文档编写与发布。",
|
|
||||||
"slug": "endor/getting-started",
|
|
||||||
"category": "Endor"
|
|
||||||
},
|
|
||||||
"02-mounting-draft": {
|
|
||||||
"group": "架构设计",
|
|
||||||
"description": "节点、对象与统一接入协议。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "endor/mounting-draft",
|
|
||||||
"category": "Endor"
|
|
||||||
},
|
|
||||||
"03-figures": {
|
|
||||||
"group": "图形档案",
|
|
||||||
"description": "Python 源码与可编辑 SVG 的制图工作流。",
|
|
||||||
"slug": "endor/figures",
|
|
||||||
"category": "Endor"
|
|
||||||
},
|
|
||||||
"04-endor-purpose-draft": {
|
|
||||||
"group": "项目讨论",
|
|
||||||
"description": "从程序入口走向用户与 AI 的对象操作。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "endor/endor-purpose-draft",
|
|
||||||
"category": "Endor"
|
|
||||||
},
|
|
||||||
"05-object-graphs-gc": {
|
|
||||||
"collection": "对象关系与垃圾回收",
|
|
||||||
"group": "运行时基础",
|
|
||||||
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。",
|
|
||||||
"slug": "language-and-runtime/object-relations/object-graphs-gc",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"pretext/00-overview": {
|
|
||||||
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
|
|
||||||
"featured": true,
|
|
||||||
"slug": "source-studies/pretext/overview",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"language-design/00-overview": {
|
|
||||||
"description": "从名称与作用域到反射与表示,按 271 项议题逐步讨论语言设计。",
|
|
||||||
"featured": true,
|
|
||||||
"slug": "language-and-runtime/language-design/overview",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/02-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/names-and-scope",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/03-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/values-and-types",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/04-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/storage-and-lifetimes",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/05-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/objects-and-members",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/06-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/expressions-and-operators",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/07-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/functions-and-calls",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/08-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/control-flow",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/09-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/collections-and-access",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/10-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/errors-and-resources",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/11-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/modules-and-execution",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/12-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/concurrency-and-async",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/13-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/external-interaction",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"language-design/14-questions": {
|
|
||||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
|
||||||
"draft": true,
|
|
||||||
"slug": "language-and-runtime/language-design/reflection-and-representation",
|
|
||||||
"category": "语言与运行时"
|
|
||||||
},
|
|
||||||
"pretext/01-chapter": {
|
|
||||||
"slug": "source-studies/pretext/text-measurement",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/02-chapter": {
|
|
||||||
"slug": "source-studies/pretext/prepare-and-layout",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/03-chapter": {
|
|
||||||
"slug": "source-studies/pretext/text-segmentation",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/04-chapter": {
|
|
||||||
"slug": "source-studies/pretext/measurement-cache",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/05-chapter": {
|
|
||||||
"slug": "source-studies/pretext/line-breaking",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/06-chapter": {
|
|
||||||
"slug": "source-studies/pretext/height-only-layout",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/07-chapter": {
|
|
||||||
"slug": "source-studies/pretext/long-text",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/08-chapter": {
|
|
||||||
"slug": "source-studies/pretext/accuracy",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/09-chapter": {
|
|
||||||
"slug": "source-studies/pretext/virtual-lists",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/10-chapter": {
|
|
||||||
"slug": "source-studies/pretext/performance-verification",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/11-chapter": {
|
|
||||||
"slug": "source-studies/pretext/source-reading",
|
|
||||||
"category": "源码研究"
|
|
||||||
},
|
|
||||||
"pretext/12-chapter": {
|
|
||||||
"slug": "source-studies/pretext/applying-the-design",
|
|
||||||
"category": "源码研究"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。在 `content/catalog.json` 的 `documents` 中以源文件路径登记 `slug` 和 `category`,例如 `slug: "endor/runtime"`、`category: "Endor"`。公开地址为 `/docs/endor/runtime`;源文件编号仅用于排序,不进入公开 URL。既有源文件地址保留永久跳转,修改已有公开 slug 时需另行保留旧地址映射。
|
|
||||||
|
|
||||||
开发模式下刷新页面读取修改;本地生产预览需要重新构建。
|
|
||||||
|
|
||||||
## 检查
|
|
||||||
|
|
||||||
```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,42 +0,0 @@
|
|||||||
# 图形与脚本
|
|
||||||
|
|
||||||
Python 绘图项目位于 `tools/figures`,使用 drawsvg 输出可编辑 SVG。脚本与输出都保留在 Git 中。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm figures
|
|
||||||
```
|
|
||||||
|
|
||||||
下图是制图工作流的最小示例,用于验证从 Python 源码到文档图片的完整链路。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 扩展方式
|
|
||||||
|
|
||||||
在 `tools/figures/src/endor_figures` 下保留各张图的生成逻辑,并在 CLI 中调用。输出目录通过源码位置计算,不依赖运行命令时所在的目录。
|
|
||||||
|
|
||||||
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
|
|
||||||
|
|
||||||
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局或 TypeScript SVG 工具。
|
|
||||||
|
|
||||||
## 位图默认发布压缩版本
|
|
||||||
|
|
||||||
SVG 保留可编辑矢量格式;漫画、照片等位图的原始 PNG、JPEG 放在 `tools/figures/assets`,不放进 `public`。浏览器只使用生成的 WebP,正文、点击放大和图片适应屏幕都使用同一个压缩地址。
|
|
||||||
|
|
||||||
共享策略位于 `content/image-policy.json`:默认最大宽度 1600 像素、WebP 质量 82、每张图最多 192 KiB,不放大小图。修改策略后重新生成并检查公式、细线和小字是否清晰。
|
|
||||||
|
|
||||||
以下涉及图像编码与构建产物生成的命令在已确认的远程 CI/CD runner 上执行;未获明确许可时不在用户本机运行。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm --filter @endor/docs images
|
|
||||||
pnpm --filter @endor/docs test:images
|
|
||||||
```
|
|
||||||
|
|
||||||
原图相对 `tools/figures/assets` 的路径会映射到 `apps/docs/public/figures` 下同名的 `.webp`。例如 `endor-review-gate.png` 对应 `/figures/endor-review-gate.webp`。文档只嵌入后一个地址,制作原稿不随网页发布。
|
|
||||||
|
|
||||||
远程生产构建会运行压缩脚本;CI 先生成 WebP,再重新编码核对产物与原稿、策略是否一致。WebP 是构建派生文件,不必在本机生成或提交;Git 保存原稿、制作记录与共享策略。检查同时拒绝公开目录里的原始位图及 Markdown 图片或 HTML img 直接引用的原始 PNG、JPEG。SVG 无需转成位图,仍按前述流程提交可编辑输出。
|
|
||||||
|
|
||||||
## 同一位置切换多张配图
|
|
||||||
|
|
||||||
把两张或更多 `/figures/endor-` 图片写在同一段,中间换行但不插入空行,正文会将它们呈现为图组。左右按钮和键盘左右方向键可以切换,下方圆点表示当前图片,也可点击直接选择。图片保留点击放大能力。
|
|
||||||
|
|
||||||
图组一次只挂载当前图片,切换后才加载下一张压缩图,不预先下载整组原图。
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
# Endor 的意义:从程序入口到对象操作
|
|
||||||
|
|
||||||
这篇文档用来逐步整理我们为什么要做 Endor。先记录一个切入点:**把对象的操作权交还给用户与 AI**。后续讨论可以在这里补充、修正,暂不把它写成一份已经定稿的项目宣言。
|
|
||||||
|
|
||||||
> **讨论草案**:以下是希望探索的系统组织方式。图中进程和对象的数量、对象关系与操作路径都是示意,不表示当前实现或性能测量。
|
|
||||||
|
|
||||||
## Endor:面向 AI 的操作系统
|
|
||||||
|
|
||||||
**我们可以把 Endor 理解为面向 AI 的操作系统:以对象池组织工作对象,以 Agent 承载和推进任务。** 用户与 AI 不必始终围绕一个个程序和进程组织工作,而是能够围绕对象、关系和可执行的能力继续处理同一件事。
|
|
||||||
|
|
||||||
在这个定位下,我们希望把用户需要面对的进程管理,简化为对 **Object data pool(对象数据池)** 中对象及其关系、状态的管理。工作材料和结果不必只存在于某个程序的私有上下文里;它们可以作为有身份、可引用的对象,由不同入口和任务继续使用。对象池是统一的逻辑组织方式,不要求所有对象都位于同一台机器或同一个物理存储实例。
|
|
||||||
|
|
||||||
**Agent 充当以往操作系统中进程在任务层面承担的角色**:承载一项正在进行的工作,组织所需对象与能力,保留相关上下文,并推进执行。用户关注的可以是“这项工作处理哪些对象、进行到哪里、下一步做什么”,而不只是“启动了哪个程序、背后有几个进程”。这些是希望建立的任务与生命周期模型,具体的暂停、恢复、调度和状态保留机制仍需定义。
|
|
||||||
|
|
||||||
这是一种上层运行环境的组织方式。对象池负责数据与对象关系,Agent 负责组织任务行为,实际执行仍需要运行时、调度和资源隔离。它们不意味着用存储替代全部执行机制,也不要求 Agent 与底层 OS 进程一一对应;一个 Agent 的工作可以包含普通函数调用,普通计算也不必全部改写成 Agent。
|
|
||||||
|
|
||||||
这一定位希望形成的体验是:**对象池构成持续可用的工作世界,Agent 成为其中执行工作的主体,Endor 语言让对象和行为能够被表达、检查与组合。** 用户与 AI 因而可以在同一套对象基础上发起、接续和检查工作。这是本文的设计方向,不表示所有系统能力已经实现。
|
|
||||||
|
|
||||||
## 让产出成为检查下一次产出的帮手
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
两张漫画用检查门与检查台表达同一个目标,可用左右按钮或下方圆点切换。
|
|
||||||
|
|
||||||
上方把 Coding Agent 比作一台不断吐出工作结果的机器。机器可以持续产出,但人的时间和精力没有同步增长;等待检查的材料越积越多。虚线圈出的 **∫dW** 表示累积的待处理工作,门上的 **C₀** 表示在这个对比中保持不变的检查能力。通过门、带上勾的文件才表示已处理的结果。
|
|
||||||
|
|
||||||
下方的机器吐出同样的原始文件,改变的是 Endor 如何组织和接续这些产出。人和检查门直接面对的是这次新增的 **dW**;已经处理的文件被组织成有类型、来源、关系与可调用能力的对象,成为图里的蓝色帮手。帮手的箭头指向检查门,表达它们为后续检查提供支持,而不是让新生成的结果自己证明自己正确。
|
|
||||||
|
|
||||||
例如,已确认的需求可以成为验收条件;稳定的接口约定可以帮助发现不兼容修改;图片发布规则可以检查下一份文档有没有直接嵌入原图。**过去的产出从待处理的负担,逐步变成帮助人处理新工作的能力。** 新结果是否符合要求,仍需要适用的检查逻辑与人的判断。
|
|
||||||
|
|
||||||
门上的 **C₀ + k∫dW** 表示我们希望实现的积累效应:在原有能力上,增加由历史成果转化而来的可复用检查能力。这里的积分、微分和系数 k 是设计隐喻,不是性能公式或实测结论;只有经过确认、适合复用并保持有效的成果才能贡献能力,产出数量不等于正确性,实际检查也可能需要重新覆盖受影响的历史对象。
|
|
||||||
|
|
||||||
> **设计目标**:这张漫画表达希望建立的工作组织方式,不表示当前 Endor 已能自动把任意文件变成检查工具。要兑现它,需要保存来源和依赖、表达检查规则、执行检查,并把失败与待确认事项呈现给人。
|
|
||||||
|
|
||||||
## 从压缩图片想到的继承:让要求成为共同能力
|
|
||||||
|
|
||||||
这张漫画也带来一个具体要求:**文档在浏览器中默认使用压缩图,原图留作制作与归档,不因打开正文或放大图片就下载原图。** 如果很多文档都需要这条规则,用户不应该每次写文档时重新提醒一次。
|
|
||||||
|
|
||||||
单次 Coding Agent 对话中的“记得压缩图片”,通常首先是一项需要读取、理解和落实的指令。指令可以放进项目约定、技能或模板,也可以让 Agent 写出共享压缩脚本;这些都能减少重复劳动。但一句自然语言要求本身,并没有自动建立“这类文档如何派生、谁沿用默认行为、哪个文档允许覆盖”的类型与行为关系。这里讨论的是这种**可持续、可检查的继承**,不是说 Agent 不能编写使用继承的程序。
|
|
||||||
|
|
||||||
### 为什么 Endor 的设计能承载继承
|
|
||||||
|
|
||||||
在现有设计中,文档、图片、类型定义、函数和 Agent 都进入同一对象体系。对象具有可引用的身份,类型可以表达对象的结构与能力,函数可以表达处理行为;继承、实例化与代码依赖可以保存为有不同含义的关联。因此,“默认如何发布文档图片”可以被表达为一个可引用的共同定义,以及读取和执行它的行为,而不只是一段随对话流逝的提醒。
|
|
||||||
|
|
||||||
沿着这个设计方向,可以把图片发布要求放到共享文档类型或其呈现策略上:普通文档沿用“优先压缩图”的默认行为,需要精确查看像素的专业文档再显式采用另一种策略。具体通过类型继承、策略对象委托还是能力组合实现,需要结合语言规则确定;不是因为对象之间有一个链接,就天然获得了继承。
|
|
||||||
|
|
||||||
| 层次 | 压缩图例子中承担的职责 |
|
|
||||||
| --- | --- |
|
|
||||||
| 共同定义 | 保存默认图片尺寸、编码质量、字节预算及浏览器应使用的派生版本 |
|
|
||||||
| 继承或委托关系 | 让采用共同定义的文档持续引用它,明确哪些行为来自共同来源 |
|
|
||||||
| 实际处理能力 | 根据策略生成压缩版本,保留它与原图的来源关系,并让呈现入口选择压缩版本 |
|
|
||||||
| 检查与覆盖 | 检查新产出是否符合默认要求;特例显式选择覆盖,并能说明生效的规则来自哪里 |
|
|
||||||
|
|
||||||
关键不只是“记住用户偏好”,而是**规则有归属,使用者有关系,行为可以执行,结果能够检查**。当 Agent 新建同类文档时,它使用已有文档能力与策略,就能让之前确认的要求继续参与这次工作;人主要判断本次差异和真正的例外。这也是漫画里历史成果增强检查门的一个具体例子。
|
|
||||||
|
|
||||||
定义和依赖是对象,使系统有基础追踪受影响的文档,并通过名称、类型与调用检查发现部分不一致;图片编码与发布仍需实际运行相应能力。“一切皆对象”本身不保证每份文档都调用正确的渲染器,也不自动决定规则修改后是否追溯更新历史文档。
|
|
||||||
|
|
||||||
> **语义边界**:继承与委托、覆盖与冲突规则仍在[语言设计问题集的对象模型部分](/docs/language-design/05-questions)讨论中。父定义更新如何生效、文档是否绑定特定版本、多来源冲突如何处理,需要明确后才能成为稳定承诺。这里解释 Endor 为什么有承载这类继承的共同基础,以及希望形成的体验,不宣称这些规则已全部实现。
|
|
||||||
|
|
||||||
### 本站现在怎样落实这条规则
|
|
||||||
|
|
||||||
Tabularium 目前用共享构建策略落实这项要求:原图保存在非公开的制作目录,脚本统一生成有尺寸、质量与字节预算的 WebP,文档正文和放大查看都使用这个压缩版本。新增文档直接引用生成的图片,不需要逐篇重新实现压缩;构建检查会拒绝公开原始位图或直接嵌入原始 PNG、JPEG。
|
|
||||||
|
|
||||||
这是今天在文档仓库里可以执行和验证的共享规则,具体工作流见[图形与脚本](/docs/03-figures)。它示范了“把一次提醒变成后续工作可复用的能力”,并不表示 Tabularium 这个 Next.js 网站已经运行了 Endor 的继承机制。
|
|
||||||
|
|
||||||
## 一对图:我们希望改变什么
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 第一张图:用户面对程序
|
|
||||||
|
|
||||||
从 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 的主要性能来源。
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"version": "2026-10-30-22",
|
||||||
|
"scopeConfirmedAt": "2026-10-30",
|
||||||
|
"acceptance": "以确认的公开来源清单为边界,逐条核对并标明缺项。",
|
||||||
|
"provenance": "S01—S12恢复自已提交用户的《AI公司研究_来源与分类模板》来源清单;S13—S16单列后续已使用的补充证据渠道,不将使用次数当作全量覆盖。",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "S01",
|
||||||
|
"name": "Y Combinator",
|
||||||
|
"url": "https://www.ycombinator.com/companies",
|
||||||
|
"role": "discovery",
|
||||||
|
"status": "candidate-audit",
|
||||||
|
"hosts": [
|
||||||
|
"www.ycombinator.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "官网目录可访问",
|
||||||
|
"boundary": "已读取八个2025—2026批次镜像候选池1428条,再核YC官方页面;不声称覆盖全部YC历年或未公开公司。",
|
||||||
|
"nextCheck": "当前快照三条原待分类经官网方向补证归类;继续复核规则初分、具体产品与财务缺项,不把领域粗分类当独立能力或收入验证。",
|
||||||
|
"limitation": "批次不等于成立年;不代表全部公司"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S02",
|
||||||
|
"name": "TechCrunch AI",
|
||||||
|
"url": "https://techcrunch.com/category/artificial-intelligence/",
|
||||||
|
"role": "reporting",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"techcrunch.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "AI栏目可读取",
|
||||||
|
"boundary": "2026首页定向3项已登记。另已读2025年度档案入口与AI第二页,并完整登记一篇2025企业竞赛名单32项:复用1项、范围外25项、待核6项;每项均尝试原始链接,未知与不可读保留。单篇32项不等全部200参赛者、全部分页或两年全量;RSS/API普通请求受限。",
|
||||||
|
"nextCheck": "继续核本篇6项的成立、主体及AI主营,优先解决Sponstar原始年份冲突、域名更名与不可读项;沿普通2025档案和AI分页扩展其余来源台账,区分参赛年、批次与公司成立年。",
|
||||||
|
"limitation": "需追溯原始披露,部分文章受访问限制",
|
||||||
|
"accessRecheckedAt": "2026-10-30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S03",
|
||||||
|
"name": "Dealroom",
|
||||||
|
"url": "https://dealroom.co/",
|
||||||
|
"role": "discovery",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"dealroom.co",
|
||||||
|
"app.dealroom.co"
|
||||||
|
],
|
||||||
|
"initialAccess": "官网可读取",
|
||||||
|
"boundary": "已读Tana与Wabi公开公司档案:前者财务字段不一致留缺,后者2025成立字段与官方业务关联入表;尚未取得两年成立筛选结果集,两个个案不等于目录全量。",
|
||||||
|
"nextCheck": "确认公开可取得的两年成立筛选清单;逐项追溯财务原始凭证及字段口径,受权限限制保留缺项。",
|
||||||
|
"limitation": "完整档案需权限;概览及财务表不能未经勾稽直接导入ARR",
|
||||||
|
"accessRecheckedAt": "2026-10-30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S04",
|
||||||
|
"name": "Product Hunt",
|
||||||
|
"url": "https://www.producthunt.com/",
|
||||||
|
"role": "discovery",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"www.producthunt.com",
|
||||||
|
"producthunt.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "官网可读取",
|
||||||
|
"boundary": "两年公开响应各19条,共38条。当前5条关联公司、2条范围外对照、25条范围外/既有业务、4条年份待核、2条推广。旧版本、品牌与法律主体分开,Tapflow据2024原版记录排除,Happycapy仍待核;非两年全量。",
|
||||||
|
"nextCheck": "继续补两年其余分页/日期、仅图片位置及4条年份待核候选;保留Pazi日期冲突,核清品牌与主体,避免转移历史业绩。",
|
||||||
|
"limitation": "上线时间不等于成立时间",
|
||||||
|
"accessRecheckedAt": "2026-10-30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S05",
|
||||||
|
"name": "Sacra",
|
||||||
|
"url": "https://sacra.com/",
|
||||||
|
"role": "estimation",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"sacra.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "官网可读取",
|
||||||
|
"boundary": "已用于定向估算及日期口径交叉核查;无全站公司名录读取台账。",
|
||||||
|
"nextCheck": "保留估算与公司披露差异,核实历史截至日。",
|
||||||
|
"limitation": "部分付费;不能把估算当审计数"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S06",
|
||||||
|
"name": "Bessemer",
|
||||||
|
"url": "https://www.bvp.com/atlas/the-state-of-ai-2025",
|
||||||
|
"role": "context",
|
||||||
|
"status": "context",
|
||||||
|
"hosts": [
|
||||||
|
"www.bvp.com",
|
||||||
|
"bvp.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "报告页面可读取",
|
||||||
|
"boundary": "主动选取20家高增长AI企业,含组合内外公司;非随机普查;已核方法与增长/毛利基准段;未取得20家逐公司底表。仅背景基准,不构成两年新公司名录。",
|
||||||
|
"nextCheck": "高增长选样和投资机构利益关系;不能把头部样本均值当各领域平均或新成立公司总体;需要公开可核底表时另补,不把缺项当已完成。",
|
||||||
|
"limitation": "投资机构选样偏差",
|
||||||
|
"accessRecheckedAt": "2026-10-30",
|
||||||
|
"methodology": {
|
||||||
|
"sample": "主动选取20家高增长AI企业,含组合内外公司;非随机普查",
|
||||||
|
"unit": "企业增长与毛利基准,按商业化后的第几年比较",
|
||||||
|
"period": "2025-08-13报告;商业化年数不等法律成立年",
|
||||||
|
"access": "已核方法与增长/毛利基准段;未取得20家逐公司底表",
|
||||||
|
"use": "用于提出留存、毛利、资本效率假设及分类线索",
|
||||||
|
"limit": "高增长选样和投资机构利益关系;不能把头部样本均值当各领域平均或新成立公司总体",
|
||||||
|
"evidenceIds": [
|
||||||
|
"CTX-BVP"
|
||||||
|
],
|
||||||
|
"checkedAt": "2026-10-30"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S07",
|
||||||
|
"name": "Menlo Ventures",
|
||||||
|
"url": "https://menlovc.com/perspective/2025-the-state-of-generative-ai-in-the-enterprise/",
|
||||||
|
"role": "context",
|
||||||
|
"status": "context",
|
||||||
|
"hosts": [
|
||||||
|
"menlovc.com",
|
||||||
|
"www.menlovc.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "报告页面可读取",
|
||||||
|
"boundary": "495名美国企业AI采购/开发决策者,企业已主动使用AI;已核公开方法、范围及限制;受访个体与公司估算底表未取得。仅背景基准,不构成两年新公司名录。",
|
||||||
|
"nextCheck": "仅美国企业;排除芯片、推理/模型服务及既有软件内嵌AI功能,不能与包含这些项目的总量直接比较或分摊为单家公司ARR;需要公开可核底表时另补,不把缺项当已完成。",
|
||||||
|
"limitation": "口径与样本需查看方法说明",
|
||||||
|
"accessRecheckedAt": "2026-10-30",
|
||||||
|
"methodology": {
|
||||||
|
"sample": "495名美国企业AI采购/开发决策者,企业已主动使用AI",
|
||||||
|
"unit": "采购调查与自下而上的市场支出估算,非公司逐项ARR",
|
||||||
|
"period": "2025-11-07至11-25调查,市场判断截至2025-12",
|
||||||
|
"access": "已核公开方法、范围及限制;受访个体与公司估算底表未取得",
|
||||||
|
"use": "用于买家、采购方式和任务方向假设",
|
||||||
|
"limit": "仅美国企业;排除芯片、推理/模型服务及既有软件内嵌AI功能,不能与包含这些项目的总量直接比较或分摊为单家公司ARR",
|
||||||
|
"evidenceIds": [
|
||||||
|
"CTX-MENLO"
|
||||||
|
],
|
||||||
|
"checkedAt": "2026-10-30"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S08",
|
||||||
|
"name": "Stripe",
|
||||||
|
"url": "https://stripe.com/annual-updates/2025",
|
||||||
|
"role": "context",
|
||||||
|
"status": "context",
|
||||||
|
"hosts": [
|
||||||
|
"stripe.com",
|
||||||
|
"www.stripe.com",
|
||||||
|
"assets.stripeassets.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "年度信可读取",
|
||||||
|
"boundary": "2025年开始使用Stripe的商户分组,另有Atlas注册企业分组;已读并视觉核对PDF第1、3页;未取得商户明细与完整分组计算底表。仅背景基准,不构成两年新公司名录。",
|
||||||
|
"nextCheck": "开始使用平台不等成立年,也非AI公司专属样本;交易量不等Stripe收入,门槛数量翻倍不等ARR均值翻倍;不与TrustMRR公开者样本混算;需要公开可核底表时另补,不把缺项当已完成。",
|
||||||
|
"limitation": "平台样本不能代表全部AI公司",
|
||||||
|
"accessRecheckedAt": "2026-10-30",
|
||||||
|
"methodology": {
|
||||||
|
"sample": "2025年开始使用Stripe的商户分组,另有Atlas注册企业分组",
|
||||||
|
"unit": "平台交易量、商户增长及达到收入门槛的数量;分母不同",
|
||||||
|
"period": "2025年度;2026-02-24年度信",
|
||||||
|
"access": "已读并视觉核对PDF第1、3页;未取得商户明细与完整分组计算底表",
|
||||||
|
"use": "用于支付、全球市场及首次收费速度假设",
|
||||||
|
"limit": "开始使用平台不等成立年,也非AI公司专属样本;交易量不等Stripe收入,门槛数量翻倍不等ARR均值翻倍;不与TrustMRR公开者样本混算",
|
||||||
|
"evidenceIds": [
|
||||||
|
"CTX-STRIPE"
|
||||||
|
],
|
||||||
|
"checkedAt": "2026-10-30"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S09",
|
||||||
|
"name": "SaaS Capital",
|
||||||
|
"url": "https://www.saas-capital.com/research/",
|
||||||
|
"role": "context",
|
||||||
|
"status": "context",
|
||||||
|
"hosts": [
|
||||||
|
"www.saas-capital.com",
|
||||||
|
"saas-capital.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "研究页可读取",
|
||||||
|
"boundary": "第15次年度调查,超过1000家私营B2B SaaS公司;仅读公开摘要;完整下载需填写表单,未提交表单或取得完整底表。仅背景基准,不构成两年新公司名录。",
|
||||||
|
"nextCheck": "样本不是两年新AI公司;增长率中位数不能当ARR中位数;完整抽样与指标细节仍缺,不外推至硬件、项目服务或所有AI模式;需要公开可核底表时另补,不把缺项当已完成。",
|
||||||
|
"limitation": "不直接套用硬件与项目制业务",
|
||||||
|
"accessRecheckedAt": "2026-10-30",
|
||||||
|
"methodology": {
|
||||||
|
"sample": "第15次年度调查,超过1000家私营B2B SaaS公司",
|
||||||
|
"unit": "2025年度增长率与留存等公开摘要;非ARR金额分布",
|
||||||
|
"period": "2026报告回顾2025增长;不把报告年当公司成立年",
|
||||||
|
"access": "仅读公开摘要;完整下载需填写表单,未提交表单或取得完整底表",
|
||||||
|
"use": "用于B2B订阅软件的规模、融资和留存分层假设",
|
||||||
|
"limit": "样本不是两年新AI公司;增长率中位数不能当ARR中位数;完整抽样与指标细节仍缺,不外推至硬件、项目服务或所有AI模式",
|
||||||
|
"evidenceIds": [
|
||||||
|
"CTX-SAASCAP"
|
||||||
|
],
|
||||||
|
"checkedAt": "2026-10-30"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S10",
|
||||||
|
"name": "机器之心",
|
||||||
|
"url": "https://www.jiqizhixin.com/",
|
||||||
|
"role": "reporting",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"www.jiqizhixin.com",
|
||||||
|
"jiqizhixin.com",
|
||||||
|
"pro.jiqizhixin.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "首页可读取",
|
||||||
|
"boundary": "已登记4篇定向检索线索;三篇直达仅登录外壳,一篇读取失败。2项经公开原始资料关联公司,SSI与Utopai经原始历史材料判为范围外,0项成立年待核;未阅读全文或穷尽两年档案。",
|
||||||
|
"nextCheck": "补公开可读文章及2025—2026档案;受限正文留缺,不以搜索摘录代全文,更名与产品发布不新增公司。",
|
||||||
|
"limitation": "全文访问受限;仅作发现时明确标识,技术和财务另追原始资料",
|
||||||
|
"accessRecheckedAt": "2026-10-30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S11",
|
||||||
|
"name": "36氪",
|
||||||
|
"url": "https://www.36kr.com/",
|
||||||
|
"role": "reporting",
|
||||||
|
"status": "access-limited",
|
||||||
|
"hosts": [
|
||||||
|
"www.36kr.com",
|
||||||
|
"36kr.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "安全检测,内容未核验",
|
||||||
|
"boundary": "2026-10-30仍返回安全检测页面,未把首页作为已读文章。",
|
||||||
|
"nextCheck": "使用可公开读取的具体文章或其他原始来源;不绕过访问保护。",
|
||||||
|
"limitation": "暂不作为已读取内容证据",
|
||||||
|
"accessRecheckedAt": "2026-10-30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S12",
|
||||||
|
"name": "公司官网与官方博客",
|
||||||
|
"url": null,
|
||||||
|
"role": "primary",
|
||||||
|
"status": "per-company",
|
||||||
|
"hosts": [],
|
||||||
|
"initialAccess": "按公司逐一核验",
|
||||||
|
"boundary": "公司官网和官方材料逐条绑定证据;不是可预先列尽的全站候选池。",
|
||||||
|
"nextCheck": "核定价、主体、交付与收入定义,自述与独立验证分开。",
|
||||||
|
"limitation": "自述不等于独立审计"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S13",
|
||||||
|
"name": "TrustMRR",
|
||||||
|
"url": "https://trustmrr.com/",
|
||||||
|
"role": "platform",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"trustmrr.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "见具体证据访问记录",
|
||||||
|
"boundary": "已采集产品与支付快照;公开者自选,不是全部商户或独立财务审计。",
|
||||||
|
"nextCheck": "核同步日、连接失效、支付平台公式及产品/法人归属。",
|
||||||
|
"limitation": "来源可读不等于声明已独立验证。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S14",
|
||||||
|
"name": "a16z Speedrun",
|
||||||
|
"url": "https://speedrun.a16z.com/",
|
||||||
|
"role": "investor",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"speedrun.a16z.com"
|
||||||
|
],
|
||||||
|
"initialAccess": "见具体证据访问记录",
|
||||||
|
"boundary": "投资机构公司介绍定向样本,收入数字仍多来自公司。",
|
||||||
|
"nextCheck": "核币种、相对时间及管线/履历;未读取的页面保持缺项。",
|
||||||
|
"limitation": "来源可读不等于声明已独立验证。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S15",
|
||||||
|
"name": "Artificial Analysis",
|
||||||
|
"url": "https://artificialanalysis.ai/",
|
||||||
|
"role": "technical",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [
|
||||||
|
"artificialanalysis.ai"
|
||||||
|
],
|
||||||
|
"initialAccess": "见具体证据访问记录",
|
||||||
|
"boundary": "第三方已发表技术评测,仅覆盖所列模型版本和任务。",
|
||||||
|
"nextCheck": "核榜单版本、测评方法及置信区间,不将排名变成企业整体能力。",
|
||||||
|
"limitation": "来源可读不等于声明已独立验证。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S16",
|
||||||
|
"name": "其他公开补充资料",
|
||||||
|
"url": null,
|
||||||
|
"role": "supplement",
|
||||||
|
"status": "targeted",
|
||||||
|
"hosts": [],
|
||||||
|
"initialAccess": "见具体证据访问记录",
|
||||||
|
"boundary": "未归入上述来源的公开材料按原始证据链接逐条保留;不视为新增已批准的完整候选池。",
|
||||||
|
"nextCheck": "逐项核来源归属、可靠性及可读正文,不能将搜索摘要当全文。",
|
||||||
|
"limitation": "来源可读不等于声明已独立验证。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
{
|
||||||
|
"sourceVersion": "2026-10-30-battlefield-3",
|
||||||
|
"asOf": "2026-10-30",
|
||||||
|
"metric": "platform-mrr-usd",
|
||||||
|
"sampleUnit": "product-or-business",
|
||||||
|
"note": "独立Python复算参考值,仅验证样本计算;不是行业估计或财务审计。",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"days": 7,
|
||||||
|
"products": [
|
||||||
|
"MR001",
|
||||||
|
"MR002",
|
||||||
|
"MR003",
|
||||||
|
"MR004",
|
||||||
|
"MR005",
|
||||||
|
"MR006",
|
||||||
|
"MR007",
|
||||||
|
"MR008",
|
||||||
|
"MR009",
|
||||||
|
"MR010",
|
||||||
|
"MR011",
|
||||||
|
"MR012",
|
||||||
|
"MR015",
|
||||||
|
"MR016"
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"category": "C06",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "软件订阅",
|
||||||
|
"products": [
|
||||||
|
"MR011",
|
||||||
|
"MR012",
|
||||||
|
"MR015",
|
||||||
|
"MR016"
|
||||||
|
],
|
||||||
|
"n": 4,
|
||||||
|
"mean": 34815.0,
|
||||||
|
"median": 36723.0,
|
||||||
|
"sampleDeviation": 16334.54845820151,
|
||||||
|
"cv": 0.46918134304758036,
|
||||||
|
"meanWithoutLargest": 28940.666666666668,
|
||||||
|
"largestRemovalDrop": 0.16872995356407675
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "RevenueCat",
|
||||||
|
"model": "消费者订阅",
|
||||||
|
"products": [
|
||||||
|
"MR001"
|
||||||
|
],
|
||||||
|
"n": 1,
|
||||||
|
"mean": 53101.0,
|
||||||
|
"median": 53101,
|
||||||
|
"sampleDeviation": null,
|
||||||
|
"cv": null,
|
||||||
|
"meanWithoutLargest": null,
|
||||||
|
"largestRemovalDrop": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "内容制作服务",
|
||||||
|
"products": [
|
||||||
|
"MR002"
|
||||||
|
],
|
||||||
|
"n": 1,
|
||||||
|
"mean": 62423.0,
|
||||||
|
"median": 62423,
|
||||||
|
"sampleDeviation": null,
|
||||||
|
"cv": null,
|
||||||
|
"meanWithoutLargest": null,
|
||||||
|
"largestRemovalDrop": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "软件订阅",
|
||||||
|
"products": [
|
||||||
|
"MR003",
|
||||||
|
"MR004",
|
||||||
|
"MR005"
|
||||||
|
],
|
||||||
|
"n": 3,
|
||||||
|
"mean": 41693.0,
|
||||||
|
"median": 49069,
|
||||||
|
"sampleDeviation": 28111.375064197768,
|
||||||
|
"cv": 0.6742468775141575,
|
||||||
|
"meanWithoutLargest": 29849.0,
|
||||||
|
"largestRemovalDrop": 0.2840764636749574
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C09",
|
||||||
|
"provider": "RevenueCat",
|
||||||
|
"model": "消费者订阅",
|
||||||
|
"products": [
|
||||||
|
"MR006",
|
||||||
|
"MR007",
|
||||||
|
"MR008",
|
||||||
|
"MR009",
|
||||||
|
"MR010"
|
||||||
|
],
|
||||||
|
"n": 5,
|
||||||
|
"mean": 25349.2,
|
||||||
|
"median": 20483,
|
||||||
|
"sampleDeviation": 11942.947278624319,
|
||||||
|
"cv": 0.4711370488466823,
|
||||||
|
"meanWithoutLargest": 20046.0,
|
||||||
|
"largestRemovalDrop": 0.20920581320120557
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days": 90,
|
||||||
|
"products": [
|
||||||
|
"MR001",
|
||||||
|
"MR002",
|
||||||
|
"MR003",
|
||||||
|
"MR004",
|
||||||
|
"MR005",
|
||||||
|
"MR006",
|
||||||
|
"MR007",
|
||||||
|
"MR008",
|
||||||
|
"MR009",
|
||||||
|
"MR010",
|
||||||
|
"MR011",
|
||||||
|
"MR012",
|
||||||
|
"MR015",
|
||||||
|
"MR016",
|
||||||
|
"AI016"
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"category": "C06",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "软件订阅",
|
||||||
|
"products": [
|
||||||
|
"MR011",
|
||||||
|
"MR012",
|
||||||
|
"MR015",
|
||||||
|
"MR016",
|
||||||
|
"AI016"
|
||||||
|
],
|
||||||
|
"n": 5,
|
||||||
|
"mean": 112725.6,
|
||||||
|
"median": 40112,
|
||||||
|
"sampleDeviation": 174786.78744916618,
|
||||||
|
"cv": 1.5505509613536426,
|
||||||
|
"meanWithoutLargest": 34815.0,
|
||||||
|
"largestRemovalDrop": 0.6911526751687284
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "RevenueCat",
|
||||||
|
"model": "消费者订阅",
|
||||||
|
"products": [
|
||||||
|
"MR001"
|
||||||
|
],
|
||||||
|
"n": 1,
|
||||||
|
"mean": 53101.0,
|
||||||
|
"median": 53101,
|
||||||
|
"sampleDeviation": null,
|
||||||
|
"cv": null,
|
||||||
|
"meanWithoutLargest": null,
|
||||||
|
"largestRemovalDrop": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "内容制作服务",
|
||||||
|
"products": [
|
||||||
|
"MR002"
|
||||||
|
],
|
||||||
|
"n": 1,
|
||||||
|
"mean": 62423.0,
|
||||||
|
"median": 62423,
|
||||||
|
"sampleDeviation": null,
|
||||||
|
"cv": null,
|
||||||
|
"meanWithoutLargest": null,
|
||||||
|
"largestRemovalDrop": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C08",
|
||||||
|
"provider": "Stripe",
|
||||||
|
"model": "软件订阅",
|
||||||
|
"products": [
|
||||||
|
"MR003",
|
||||||
|
"MR004",
|
||||||
|
"MR005"
|
||||||
|
],
|
||||||
|
"n": 3,
|
||||||
|
"mean": 41693.0,
|
||||||
|
"median": 49069,
|
||||||
|
"sampleDeviation": 28111.375064197768,
|
||||||
|
"cv": 0.6742468775141575,
|
||||||
|
"meanWithoutLargest": 29849.0,
|
||||||
|
"largestRemovalDrop": 0.2840764636749574
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "C09",
|
||||||
|
"provider": "RevenueCat",
|
||||||
|
"model": "消费者订阅",
|
||||||
|
"products": [
|
||||||
|
"MR006",
|
||||||
|
"MR007",
|
||||||
|
"MR008",
|
||||||
|
"MR009",
|
||||||
|
"MR010"
|
||||||
|
],
|
||||||
|
"n": 5,
|
||||||
|
"mean": 25349.2,
|
||||||
|
"median": 20483,
|
||||||
|
"sampleDeviation": 11942.947278624319,
|
||||||
|
"cv": 0.4711370488466823,
|
||||||
|
"meanWithoutLargest": 20046.0,
|
||||||
|
"largestRemovalDrop": 0.20920581320120557
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+4
-2
@@ -13,7 +13,9 @@
|
|||||||
"lint": "pnpm --filter @endor/docs run lint",
|
"lint": "pnpm --filter @endor/docs run lint",
|
||||||
"typecheck": "pnpm --filter @endor/docs run typecheck",
|
"typecheck": "pnpm --filter @endor/docs run typecheck",
|
||||||
"figures": "uv run --locked --project tools/figures endor-figures",
|
"figures": "uv run --locked --project tools/figures endor-figures",
|
||||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
"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: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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Summarize observed research coverage; does not claim an exhaustive AI census."""
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
|
||||||
|
AUDIT = json.loads((ROOT / 'content/research/ai-companies.coverage.json').read_text())
|
||||||
|
SOURCES = json.loads((ROOT / 'content/research/ai-companies.sources.json').read_text())
|
||||||
|
TARGET = ROOT / '.artifacts/reports/02-coverage-and-gaps.md'
|
||||||
|
SOURCE_SLUG = 'ai-company-research/02-coverage-and-gaps'
|
||||||
|
|
||||||
|
BASE = '/docs/other-research/ai-company-research/overview'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def source_register():
|
||||||
|
sources = SOURCES['sources']
|
||||||
|
ids = {s['id'] for s in sources}
|
||||||
|
assert len(ids) == len(sources) and ids == {f'S{i:02}' for i in range(1, 17)}
|
||||||
|
assert SOURCES['scopeConfirmedAt'] <= DATA['asOf']
|
||||||
|
host_map = {}
|
||||||
|
for source in sources:
|
||||||
|
assert source['status'] in {'candidate-audit', 'targeted', 'pending', 'context', 'access-limited', 'per-company'}
|
||||||
|
assert source['boundary'] and source['nextCheck']
|
||||||
|
if source['url']:
|
||||||
|
assert urlsplit(source['url']).scheme in {'http', 'https'} and urlsplit(source['url']).netloc
|
||||||
|
for host in source['hosts']:
|
||||||
|
assert host not in host_map, f'Duplicate source host: {host}'
|
||||||
|
host_map[host] = source['id']
|
||||||
|
assigned = {key: [] for key in ids}
|
||||||
|
for evidence in DATA['evidence']:
|
||||||
|
owner = host_map.get(urlsplit(evidence['url']).netloc)
|
||||||
|
if owner is None:
|
||||||
|
owner = 'S12' if any(word in evidence['kind'] for word in ('官方', '公司自述', '公司披露')) else 'S16'
|
||||||
|
assigned[owner].append(evidence)
|
||||||
|
assert sum(len(v) for v in assigned.values()) == len(DATA['evidence'])
|
||||||
|
statuses = {'candidate-audit': '候选池逐条已读', 'targeted': '定向使用,未穷尽', 'pending': '候选台账待建',
|
||||||
|
'context': '背景研究', 'access-limited': '访问受限', 'per-company': '随公司逐条核对'}
|
||||||
|
lines = ['', '## 来源清单与验收边界', '', SOURCES['provenance'], '',
|
||||||
|
'每条证据在下表只计入一个渠道:先按所列域名归属,再将类型明确标为官方/公司自述/公司披露的剩余项计入S12,其余计入S16。公司可跨渠道出现,所以公司数不可跨行相加。零条证据表示尚未进入研究表,不表示该来源没有相关公司。背景报告不承担公司发现全集的角色。', '',
|
||||||
|
'| 来源 | 当前状态 | 证据 / 公司 / 观测数 | 已核对范围 | 剩余核查 |', '| --- | --- | --- | --- | --- |']
|
||||||
|
for source in sources:
|
||||||
|
evidence = assigned[source['id']]
|
||||||
|
evidence_ids = {e['id'] for e in evidence}
|
||||||
|
company_ids = {e['companyId'] for e in evidence if e['companyId']}
|
||||||
|
observations = sum(o['evidence'] in evidence_ids for o in DATA['observations'])
|
||||||
|
name = f"[{source['name']}]({source['url']})" if source['url'] else source['name']
|
||||||
|
lines.append(f"| {source['id']} {name} | {statuses[source['status']]} | {len(evidence)} / {len(company_ids)} / {observations} | {source['boundary']} | {source['nextCheck']} |")
|
||||||
|
lines += ['', 'Dealroom、Product Hunt、机器之心与36氪入口于2026-10-30重新访问。官网首页可读不证明已取得完整筛选结果;安全检测页面不作为文章内容。候选池之外的来源边界尚需建立可回查条目,完成前不宣布来源清单已全部覆盖。', '']
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def context_methods():
|
||||||
|
evidence = {e['id']: e for e in DATA['evidence']}
|
||||||
|
rows = [s for s in SOURCES['sources'] if s.get('methodology')]
|
||||||
|
assert {s['id'] for s in rows} == {'S06', 'S07', 'S08', 'S09'}
|
||||||
|
lines = ['', '## 背景基准适用性与缺项', '',
|
||||||
|
'以下核对背景资料的方法及适用范围,不是公司名录的完整覆盖。原始方法记录与全局核查随研究版本进入同一数据库;没有把行业汇总值新增为任何公司的收入观测。', '',
|
||||||
|
'| 来源与证据 | 样本与统计单位 | 时间 | 本次读取范围 | 可用于什么 | 主要限制 |',
|
||||||
|
'| --- | --- | --- | --- | --- | --- |']
|
||||||
|
for source in rows:
|
||||||
|
m = source['methodology']
|
||||||
|
assert m['checkedAt'] <= DATA['asOf']
|
||||||
|
assert all(m.get(key) for key in ('sample', 'unit', 'period', 'access', 'use', 'limit', 'evidenceIds'))
|
||||||
|
assert all(eid in evidence and evidence[eid]['companyId'] is None for eid in m['evidenceIds'])
|
||||||
|
assert not any(o['evidence'] in m['evidenceIds'] for o in DATA['observations'])
|
||||||
|
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in m['evidenceIds'])
|
||||||
|
lines.append(f"| {source['id']} {source['name']}:{proof} | {m['sample']};{m['unit']} | {m['period']} | {m['access']} | {m['use']} | {m['limit']} |")
|
||||||
|
lines += ['', '本表的应用解释属于研究判断。选样、分母、时间或指标不同会造成表观差异;这些差异不单独证明财务造假,也不能作为给未知公司填补收入的依据。', '']
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_register():
|
||||||
|
batches = DATA.get('candidateBatches', [])
|
||||||
|
companies = {c['id']: c for c in DATA['companies']}
|
||||||
|
categories = {c['id']: c['name'] for c in DATA['categories']}
|
||||||
|
known_sources = {s['id'] for s in SOURCES['sources']}
|
||||||
|
evidence = {e['id']: e for e in DATA['evidence']}
|
||||||
|
assert len({b['id'] for b in batches}) == len(batches)
|
||||||
|
all_ids = [r['id'] for b in batches for r in b['records']]
|
||||||
|
assert len(set(all_ids)) == len(all_ids)
|
||||||
|
lines = []
|
||||||
|
statuses = {'pending-year': '成立年待核', 'linked': '已关联公司',
|
||||||
|
'existing-outside-cohort': '已有关联,年份范围外',
|
||||||
|
'outside-cohort': '范围外或既有业务,不新增公司', 'promotion': '推广活动,非公司新增'}
|
||||||
|
for batch in batches:
|
||||||
|
assert batch['sourceId'] in known_sources
|
||||||
|
assert batch['accessedAt'] <= DATA['asOf']
|
||||||
|
assert any(e['url'] == batch['url'] for e in DATA['evidence'])
|
||||||
|
rows = batch['records']
|
||||||
|
counts = Counter(r['status'] for r in rows)
|
||||||
|
assert sum(counts.values()) == len(rows)
|
||||||
|
lines += ['', f"## {batch['sourceId']} 候选核对({batch['id']})", '',
|
||||||
|
f"[发现页面]({batch['url']}),读取于{batch['accessedAt']}。{batch['scope']}", '',
|
||||||
|
f"本批{len(rows)}条:{counts['linked']}条关联公司、{counts['existing-outside-cohort']}条关联范围外对照、{counts['outside-cohort']}条范围外不新增公司、{counts['pending-year']}条成立年待核、{counts['promotion']}条推广活动。完整来源覆盖标记:{'已完成' if batch['complete'] else '未完成'}。主营或产品任务的初分类不计入主公司表数量,只有已关联的公司记录进入对应公司明细。候选与缺项随研究版本保存在文档站同一SQLite的研究元数据中。", '',
|
||||||
|
'| 发现条目 | 主营或产品任务初分 | 核对状态 | 关联公司 | 核对证据 | 限制 |', '| --- | --- | --- | --- | --- | --- |']
|
||||||
|
for row in rows:
|
||||||
|
assert row['categoryId'] in categories
|
||||||
|
assert row['status'] in statuses
|
||||||
|
assert all(eid in evidence for eid in row.get('evidenceIds', []))
|
||||||
|
if row['status'] == 'outside-cohort':
|
||||||
|
assert row.get('evidenceIds') and not row.get('companyId')
|
||||||
|
name = f"[{row['name']}]({row['url']})" if row['url'] else row['name']
|
||||||
|
company = '—'
|
||||||
|
if row.get('companyId'):
|
||||||
|
assert row['companyId'] in companies
|
||||||
|
c = companies[row['companyId']]
|
||||||
|
company = f"[{c['name']}]({BASE}?company={c['id']}#research-company-detail)"
|
||||||
|
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row.get('evidenceIds', [])) or '成立证据待补,产品页读取状态见候选记录'
|
||||||
|
category = f"[{categories[row['categoryId']]}]({BASE}?category={row['categoryId']})"
|
||||||
|
lines.append(f"| {name} | {category} | {statuses[row['status']]} | {company} | {proof} | {row['note']} |")
|
||||||
|
if batch['sourceId'] == 'S04':
|
||||||
|
lines += ['', '产品页可读只表示有可访问的资料;本批未将所有产品页完成公司、技术或财务深查。既有直接HTTP采集返回403,当前采用可读的公开页面响应,不声称取得底层全量列表;后续公开档案与未读页面继续保留缺项。', '']
|
||||||
|
else:
|
||||||
|
lines += ['', '发现条目和可读正文分开记录;登录外壳、读取失败或搜索摘录不充当已阅读全文。关联公司以列出的原始材料补证,仍保留技术、主体与收入未核事项。', '']
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def directory_rechecks():
|
||||||
|
rows = DATA.get('directoryRechecks', [])
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
original = {row['ycId']: row for row in AUDIT['records']}
|
||||||
|
companies = {c['id']: c for c in DATA['companies']}
|
||||||
|
evidence = {e['id']: e for e in DATA['evidence']}
|
||||||
|
assert len({row['ycId'] for row in rows}) == len(rows)
|
||||||
|
lines = ['', '## 原候选池的后续复核', '',
|
||||||
|
'原始1,428条读取快照与初筛计数保持不变;下表记录当时未入表、后续补足证据后进入公司表的项目。初筛未通过不是永久排除。后续入表不回写为当时已匹配,也不重复计入928条原始新增目录项。', '',
|
||||||
|
'| 原候选 | 原始状态 | 后续公司记录 | 复核证据 | 处理与限制 |', '| --- | --- | --- | --- | --- |']
|
||||||
|
for row in rows:
|
||||||
|
baseline = original[row['ycId']]
|
||||||
|
assert baseline['status'] == row['originalStatus']
|
||||||
|
assert row['reviewedAt'] <= DATA['asOf'] and row['reason']
|
||||||
|
assert row['originalStatus'] in {'ai-unclear', 'year-outside-or-missing', 'date-conflict'}
|
||||||
|
company = companies[row['companyId']]
|
||||||
|
assert row['evidenceIds'] and all(eid in evidence for eid in row['evidenceIds'])
|
||||||
|
proof = '、'.join(f"[{eid}]({evidence[eid]['url']})" for eid in row['evidenceIds'])
|
||||||
|
lines.append(f"| [{baseline['name']} · YC{row['ycId']}]({baseline['source']}) | {row['originalStatus']} | [{company['name']}]({BASE}?company={company['id']}#research-company-detail) | {proof} | {row['reviewedAt']}:{row['reason']} |")
|
||||||
|
lines += ['', f"已记录{len(rows)}条后续复核入表;其余未入表候选仍按原状态保留缺项,不能把这一批复核视为完成全部模糊项。", '']
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render():
|
||||||
|
cov = DATA['coverage']
|
||||||
|
companies = DATA['companies']
|
||||||
|
depth = sum(bool(c.get('profileOnly')) for c in companies)
|
||||||
|
origin = sum(bool(c.get('profileOnly')) or c.get('directoryOrigin') == 'yc-2025-2026' for c in companies)
|
||||||
|
promoted = origin - depth
|
||||||
|
reviewed = sum(bool(c.get('profileOnly')) and '人工复核' in c.get('classificationBasis', '') for c in companies)
|
||||||
|
years = Counter(c['year'] for c in companies)
|
||||||
|
income = Counter(c['revenueStatus'] for c in companies)
|
||||||
|
technical = Counter(c.get('technicalReview', {}).get('level', 'unreviewed') for c in companies)
|
||||||
|
assert cov['profilesRead'] + cov['failedProfiles'] == cov['candidateRecords']
|
||||||
|
assert cov['matchingProfiles'] == cov['existingProfiles'] + cov['newProfiles']
|
||||||
|
assert cov['matchingProfiles'] + cov['otherOrMissingYear'] + cov['unclearAI'] + cov['dateConflicts'] == cov['profilesRead']
|
||||||
|
assert origin == cov['newProfiles']
|
||||||
|
assert AUDIT['accessedAt'] == cov['accessedAt']
|
||||||
|
assert AUDIT['counts'] == cov
|
||||||
|
assert len(AUDIT['records']) == cov['profilesRead']
|
||||||
|
assert Counter(x['status'] for x in AUDIT['records']) == {
|
||||||
|
'imported': cov['newProfiles'], 'existing': cov['existingProfiles'],
|
||||||
|
'year-outside-or-missing': cov['otherOrMissingYear'],
|
||||||
|
'ai-unclear': cov['unclearAI'], 'date-conflict': cov['dateConflicts']}
|
||||||
|
assert len({x['ycId'] for x in AUDIT['records']}) == len(AUDIT['records'])
|
||||||
|
lines = ['# AI 公司资料覆盖与缺项', '',
|
||||||
|
f"截至 {DATA['asOf']},研究表含{len(companies):,}条公司或产品记录。YC候选池有逐条读取审计;其他来源为定向补充。清单尚不能作为全球AI公司普查,也不能将收入未知解释为零。", '',
|
||||||
|
'验收范围已于2026-10-30确认为:以确认的公开来源清单为边界,逐条核对并标明缺项。下方区分已逐条审计的候选池与定向补充来源;已使用某来源不代表该来源已被完整覆盖。', '',
|
||||||
|
f"[分类和数据工作台]({BASE})链接原始证据,[收入样本快照](/docs/other-research/ai-company-research/income-snapshot)展示当前可计算的MRR样本。", '',
|
||||||
|
'## 候选池与公司表的关系', '',
|
||||||
|
'| 环节 | 记录数 | 含义 |', '| --- | --- | --- |',
|
||||||
|
f"| YC批次候选 | {cov['candidateRecords']:,} | 从公开镜像发现,随后读取YC官方页面 |",
|
||||||
|
f"| 官方页面读取 | {cov['profilesRead']:,} | 此候选池读取失败{cov['failedProfiles']}条 |",
|
||||||
|
f"| 初筛匹配 | {cov['matchingProfiles']:,} | 成立年及AI相关描述通过初筛,尚非逐家商业验证 |",
|
||||||
|
f"| 与原表重合 | {cov['existingProfiles']} | URL匹配,未再次新增 |",
|
||||||
|
f"| 新增目录记录 | {cov['newProfiles']:,} | 原始入表来源,后续深化不改变发现计数 |",
|
||||||
|
f"| 年份不符或缺失 | {cov['otherOrMissingYear']} | 此轮不作为成立年匹配项 |",
|
||||||
|
f"| AI业务待确认 | {cov['unclearAI']} | 不强行把背景关键词当主营 |",
|
||||||
|
f"| 成立年与批次冲突 | {cov['dateConflicts']} | 另保留核查记录 |", '',
|
||||||
|
f"全表由{depth}条目录基础项和{len(companies)-depth}条个案或平台记录组成。候选池已匹配项之外另有{len(companies)-cov['matchingProfiles']}条记录,包含其他来源及发布/转型对照;其中已有{promoted}条原目录记录深化为个案,仍保留目录来源。两种统计边界不同。候选池数字只是该镜像快照的覆盖,Bud等后续定向补充不回填为原候选池已覆盖。", '',
|
||||||
|
'## 成立年与证据深度', '',
|
||||||
|
'| 维度 | 当前记录 | 解读 |', '| --- | --- | --- |',
|
||||||
|
f"| 记录为2025年成立 | {years[2025]} | 成立来源和精度见公司明细 |",
|
||||||
|
f"| 记录为2026年成立 | {years[2026]} | 不将产品发布日期代替成立日 |",
|
||||||
|
f"| 成立年未知或范围外 | {sum(v for k,v in years.items() if k not in (2025,2026))} | 发布/转型对照单列,不进入两年成立分组 |",
|
||||||
|
f"| 目录业务分类人工复核 | {reviewed} | 仅复核主营分类,不代表技术或财务审计 |",
|
||||||
|
f"| 其他目录基础项 | {depth-reviewed} | 规则初分或资料不足,继续待复核 |",
|
||||||
|
f"| 有团队数字 | {sum(c['team'] is not None for c in companies)} | 多为自报Team Size或Employees,时点与口径不统一 |",
|
||||||
|
f"| 专项技术证据记录 | {len(companies)-technical['unreviewed']} | 其中{technical['description']}条自述、{technical['public-artifact']}条公开材料、{technical['independent-test']}条第三方评测 |",
|
||||||
|
f"| 公司披露收入线索 | {income['self']} | 含历史、下界、币种不明及产品业务声明,不等于可比ARR |",
|
||||||
|
f"| 仅平台收入线索 | {income['platform']} | 同时有公司披露者计入上一行,不重复计数 |",
|
||||||
|
f"| 仅第三方估算 | {income['estimate']} | 不与直接披露混合 |",
|
||||||
|
f"| 暂无可用收入数字 | {income['unknown']} | 包含尚未逐家搜索的目录项,不代表没有收入 |", '',
|
||||||
|
'## 业务分类与数据入口', '',
|
||||||
|
'| 业务类别 | 记录数 |', '| --- | --- |',
|
||||||
|
]
|
||||||
|
counts = Counter(c['cat'] for c in companies)
|
||||||
|
for category in DATA['categories']:
|
||||||
|
lines.append(f"| [{category['name']}]({BASE}?category={category['id']}) | {counts[category['id']]} |")
|
||||||
|
lines += ['', '## 已使用来源的分布', '',
|
||||||
|
f"{len(DATA['evidence']):,}条证据记录对应{len({e['url'] for e in DATA['evidence']}):,}个不同URL。不同URL不自动构成独立验证;同一声明可能由公司、投资方和媒体重复转述。", '',
|
||||||
|
'| 来源 | 证据记录 | 不同URL | 用途及限制 |', '| --- | --- | --- | --- |',
|
||||||
|
]
|
||||||
|
families = [('YC官方目录', 'www.ycombinator.com', '公司自述的成立、团队与业务;英文及加速器样本偏多'),
|
||||||
|
('TrustMRR', 'trustmrr.com', '支付平台重建指标及方法;公开者存在选择偏差,非财务审计'),
|
||||||
|
('a16z Speedrun', 'speedrun.a16z.com', '投资机构中的公司介绍及收入声明;存在利益关系'),
|
||||||
|
('TechCrunch', 'techcrunch.com', '原始采访或报道;转述公司数字不等于查账'),
|
||||||
|
('Sacra', 'sacra.com', '第三方估算;与直接披露分开')]
|
||||||
|
included = set()
|
||||||
|
for name, host, note in families:
|
||||||
|
entries = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc == host]
|
||||||
|
included.add(host)
|
||||||
|
lines.append(f"| {name} | {len(entries)} | {len({e['url'] for e in entries})} | {note} |")
|
||||||
|
others = [e for e in DATA['evidence'] if urlsplit(e['url']).netloc not in included]
|
||||||
|
lines.append(f"| 其他官网、评测及公开资料 | {len(others)} | {len({e['url'] for e in others})} | 按每条来源限制理解,不能统一当作独立证据 |")
|
||||||
|
lines += directory_rechecks()
|
||||||
|
lines += source_register()
|
||||||
|
lines += context_methods()
|
||||||
|
lines += candidate_register()
|
||||||
|
lines += ['', '## 尚未满足的研究条件', '',
|
||||||
|
'行业ARR比较仍缺统一业务截止日、币种、经常性定义和业务边界。现有平台MRR可描述已采集产品,不足以代表全部新公司。技术评测只覆盖所列版本与任务,不宜跨领域汇总为统一强弱分数。', '',
|
||||||
|
'下一轮优先补收入线索的日期及合同定义、非YC和非英文来源、规则初分的人工复核,以及停止服务或被收购案例。这样能减少“只见赢家”和“只见融资公司”的偏差。公开来源池之外的未披露公司仍无法证明已被穷尽。', '',
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--check', action='store_true')
|
||||||
|
parser.add_argument('--document-export', type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
content = render()
|
||||||
|
if args.check:
|
||||||
|
if args.document_export is not None:
|
||||||
|
bundle = json.loads(args.document_export.read_text())
|
||||||
|
stored = next((d['content'] for d in bundle['documents'] if d['sourceSlug'] == SOURCE_SLUG), None)
|
||||||
|
if stored != content:
|
||||||
|
sys.exit('Research coverage report differs; review and regenerate')
|
||||||
|
print('Research coverage accounting verified; supplied document export compared when requested')
|
||||||
|
else:
|
||||||
|
TARGET.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
TARGET.write_text(content)
|
||||||
|
print(TARGET.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -6,6 +6,8 @@ uv sync --project tools/figures --locked
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
pnpm run typecheck
|
pnpm run typecheck
|
||||||
pnpm --filter @endor/docs run test:auth
|
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 images
|
||||||
pnpm --filter @endor/docs run test:images
|
pnpm --filter @endor/docs run test:images
|
||||||
cp -R apps/docs/public/figures /tmp/expected-figures
|
cp -R apps/docs/public/figures /tmp/expected-figures
|
||||||
|
|||||||
+17
-2
@@ -14,7 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
APP = "endorphin-tabularium-docs"
|
APP = "endorphin-tabularium-docs"
|
||||||
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
|
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
|
||||||
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures",
|
"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:
|
def run(*args: str, capture: bool = False) -> str:
|
||||||
@@ -35,9 +36,17 @@ def fingerprint() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def smoke_image(image: str, name: str) -> None:
|
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:
|
try:
|
||||||
run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "init")
|
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):
|
for _ in range(60):
|
||||||
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
|
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
|
||||||
if state.get("Health", {}).get("Status") == "healthy":
|
if state.get("Health", {}).get("Status") == "healthy":
|
||||||
@@ -63,6 +72,12 @@ def smoke_image(image: str, name: str) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
os.chdir(ROOT)
|
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")
|
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
|
||||||
scope = os.environ.get("CI_CACHE_SCOPE", "main")
|
scope = os.environ.get("CI_CACHE_SCOPE", "main")
|
||||||
if not re.fullmatch(r"main|pr-[0-9]+", scope):
|
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 shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from ci import APP, ROOT, fingerprint, run
|
from ci import APP, ROOT, fingerprint, run
|
||||||
|
|
||||||
@@ -108,6 +109,21 @@ def main() -> None:
|
|||||||
run("docker", "run", "--rm", "--network", "none", "--mount",
|
run("docker", "run", "--rm", "--network", "none", "--mount",
|
||||||
f"type=bind,src={auth_dir},dst=/auth", actual,
|
f"type=bind,src={auth_dir},dst=/auth", actual,
|
||||||
"node", "apps/docs/auth-cli.cjs", "init")
|
"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")
|
shutil.copyfile(ROOT / "scripts/auth-admin.sh", DEPLOY_ROOT / "auth")
|
||||||
(DEPLOY_ROOT / "auth").chmod(0o750)
|
(DEPLOY_ROOT / "auth").chmod(0o750)
|
||||||
current = DEPLOY_ROOT / "current"
|
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")
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Render a fixed research snapshot; --check performs data analysis only, no build."""
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import statistics as st
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DATA = json.loads((ROOT / 'content/research/ai-companies.seed.json').read_text())
|
||||||
|
REFERENCE = json.loads((ROOT / 'content/research/ai-income-analysis.json').read_text())
|
||||||
|
TARGET = ROOT / '.artifacts/reports/01-income-snapshot.md'
|
||||||
|
SOURCE_SLUG = 'ai-company-research/01-income-snapshot'
|
||||||
|
BASE = '/docs/other-research/ai-company-research/overview'
|
||||||
|
COMPANIES = {c['id']: c for c in DATA['companies']}
|
||||||
|
CATEGORIES = {c['id']: c['name'] for c in DATA['categories']}
|
||||||
|
END = dt.date.fromisoformat(DATA['asOf'])
|
||||||
|
|
||||||
|
|
||||||
|
def sample(days):
|
||||||
|
latest, excluded = {}, []
|
||||||
|
for o in DATA['observations']:
|
||||||
|
if o.get('selection') != 'platform' or o.get('metric') != 'mrr':
|
||||||
|
continue
|
||||||
|
c = COMPANIES[o['company']]
|
||||||
|
day = o.get('observedAt')
|
||||||
|
try:
|
||||||
|
parsed = dt.date.fromisoformat(day)
|
||||||
|
valid_date = parsed.isoformat() == day
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
valid_date = False
|
||||||
|
valid = (c['year'] in (2025, 2026) and o.get('origin') == 'payment-platform'
|
||||||
|
and o['unit'] == 'USD' and o['bound'] == '='
|
||||||
|
and o.get('comparisonGroup') == 'trustmrr-usd-mrr'
|
||||||
|
and o.get('provider') and isinstance(o['value'], (int, float))
|
||||||
|
and 0 <= o['value'] < float('inf') and valid_date
|
||||||
|
and 0 <= (END - parsed).days <= days)
|
||||||
|
if not valid:
|
||||||
|
excluded.append(o)
|
||||||
|
continue
|
||||||
|
prior = latest.get(c['id'])
|
||||||
|
if prior is None or day > prior['observedAt']:
|
||||||
|
latest[c['id']] = o
|
||||||
|
return latest, excluded
|
||||||
|
|
||||||
|
|
||||||
|
def groups(points):
|
||||||
|
result = {}
|
||||||
|
for cid, o in points.items():
|
||||||
|
c = COMPANIES[cid]
|
||||||
|
key = (c['cat'], o['provider'], c.get('businessModel', '收费模式未核验'))
|
||||||
|
result.setdefault(key, []).append(o['value'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def stats(values):
|
||||||
|
mean = st.mean(values)
|
||||||
|
sd = st.stdev(values) if len(values) > 1 else None
|
||||||
|
reduced = st.mean(sorted(values)[:-1]) if len(values) > 1 else None
|
||||||
|
return dict(n=len(values), mean=mean, median=st.median(values),
|
||||||
|
sampleDeviation=sd, cv=sd / mean if sd is not None and mean else None,
|
||||||
|
meanWithoutLargest=reduced,
|
||||||
|
largestRemovalDrop=(mean - reduced) / mean if reduced is not None and mean else None)
|
||||||
|
|
||||||
|
|
||||||
|
def verify():
|
||||||
|
assert REFERENCE['sourceVersion'] == DATA['version'], 'Reference revision mismatch'
|
||||||
|
for window in REFERENCE['windows']:
|
||||||
|
points, _ = sample(window['days'])
|
||||||
|
assert set(points) == set(window['products']), 'Sample selection mismatch'
|
||||||
|
computed = groups(points)
|
||||||
|
assert len(computed) == len(window['groups'])
|
||||||
|
for group in window['groups']:
|
||||||
|
values = computed[(group['category'], group['provider'], group['model'])]
|
||||||
|
for key, value in stats(values).items():
|
||||||
|
expected = group[key]
|
||||||
|
assert (value is None and expected is None) or (
|
||||||
|
value is not None and expected is not None
|
||||||
|
and abs(value - expected) <= 1e-8 * max(1, abs(expected))), key
|
||||||
|
|
||||||
|
|
||||||
|
def money(value):
|
||||||
|
return f'{value:,.0f}' if value is not None else '不计算'
|
||||||
|
|
||||||
|
|
||||||
|
def link(cid):
|
||||||
|
return f"[{COMPANIES[cid]['name']}]({BASE}?company={cid}#research-company-detail)"
|
||||||
|
|
||||||
|
|
||||||
|
def render():
|
||||||
|
points, excluded = sample(7)
|
||||||
|
lines = [
|
||||||
|
'# AI 公司收入样本分布快照', '',
|
||||||
|
f"截至 {DATA['asOf']},7日窗口内有 {len(points)} 个产品的美元平台MRR可纳入分组描述。创意软件订阅组的样本均值较高、CV也较大;营销软件组与消费者个人应用组的CV接近。各组只有1至5个产品,尚不足以估计行业总体或造假概率。", '',
|
||||||
|
f"这是已采集资料的固定快照。[动态工作台]({BASE})提供筛选与原始证据。窗口按最后同步日计算,不保证所有点同日,也不是{len(DATA['companies']):,}条公司/产品记录的收入普查。", '',
|
||||||
|
'## 7日窗口的分组结果', '',
|
||||||
|
'MRR单位为美元每月。CV为样本标准差除以均值;最大值剔除降幅表示移除最高收入产品后均值下降的比例。单个样本不计算离散度。', '',
|
||||||
|
'| 业务及收费模式 | 平台 | 样本数 | 均值 | 中位数 | CV | 最大值剔除降幅 |',
|
||||||
|
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||||
|
]
|
||||||
|
for key, values in sorted(groups(points).items()):
|
||||||
|
cat, provider, model = key
|
||||||
|
v = stats(values)
|
||||||
|
cv = f"{v['cv']:.3f}" if v['cv'] is not None else '不计算'
|
||||||
|
drop = f"{v['largestRemovalDrop']:.1%}" if v['largestRemovalDrop'] is not None else '不计算'
|
||||||
|
lines.append(f"| {CATEGORIES[cat]} · {model} | {provider} | {v['n']} | {money(v['mean'])} | {money(v['median'])} | {cv} | {drop} |")
|
||||||
|
lines += ['',
|
||||||
|
'在至少3个样本的组内,内容创意软件订阅的均值较高,但移除最大样本后的下降也最大。营销软件与个人应用的CV仅有微小差别,不据此宣称哪个领域更稳定。两个内容类单样本组虽有更高数值,不能作为典型收入或领域排名。', '',
|
||||||
|
'均值和中位数接近只描述当前采集点,并不证明样本具有代表性。平台公开者、成立年、规模、获客渠道与收费方式均可能影响结果。', '',
|
||||||
|
'## 样本与证据入口', '',
|
||||||
|
'点击产品名称可打开公司明细,再查看平台记录及其限制。连接已过期的近期快照仍按所标日期保留,并不表示连接当前有效。', '',
|
||||||
|
'| 产品 | 成立年 | MRR美元每月 | 最后同步 | 平台 | 连接状态 |',
|
||||||
|
'| --- | --- | --- | --- | --- | --- |',
|
||||||
|
]
|
||||||
|
for cid, o in sorted(points.items()):
|
||||||
|
status = '已过期的日期快照' if o.get('connectionExpired') else '采集时未标过期'
|
||||||
|
lines.append(f"| {link(cid)} | {COMPANIES[cid]['year']} | {money(o['value'])} | {o['observedAt']} | {o['provider']} | {status} |")
|
||||||
|
fresh = stats(groups(points)[('C06', 'Stripe', '软件订阅')])
|
||||||
|
broad, _ = sample(90)
|
||||||
|
older = stats(groups(broad)[('C06', 'Stripe', '软件订阅')])
|
||||||
|
lines += ['', '## 扩大窗口后的敏感性', '',
|
||||||
|
f"营销软件订阅组从7日窗口的{fresh['n']}个产品扩大到90日窗口的{older['n']}个产品,均值从{money(fresh['mean'])}升至{money(older['mean'])}美元每月,中位数从{money(fresh['median'])}变为{money(older['median'])},CV从{fresh['cv']:.3f}升至{older['cv']:.3f}。", '',
|
||||||
|
f"新增点来自{link('AI016')}的8月历史快照,连接已过期。移除该最大值后,均值回到{money(older['meanWithoutLargest'])}美元,下降{older['largestRemovalDrop']:.1%}。这是日期与样本构成的敏感性,不能解释为行业风险变化。", '',
|
||||||
|
'## 默认窗口排除的历史记录', '',
|
||||||
|
'| 产品 | MRR美元每月 | 最后同步 | 排除原因 |',
|
||||||
|
'| --- | --- | --- | --- |',
|
||||||
|
]
|
||||||
|
for o in excluded:
|
||||||
|
lines.append(f"| {link(o['company'])} | {money(o['value'])} | {o.get('observedAt', '未知')} | 超过7日窗口,保留历史观测 |")
|
||||||
|
lines += ['', '## 对 ARR 与商业模式的含义', '',
|
||||||
|
'MRR乘12只是参考年化。公司声明的ARR、合同管线、近30日收款、一次性额度和服务交付收入仍需分开;支付平台重建指标也不等于财务审计。方法与平台限制见[TrustMRR FAQ](https://trustmrr.com/faq)。', '',
|
||||||
|
'当前更适合选择访谈对象和设计收费试验,不能据最高均值直接决定业务方向。软件订阅优先核留存与推理成本,服务交付优先核人工工时和返工,消费者应用优先核渠道成本、退款和平台抽成。', '',
|
||||||
|
f"行业ARR比较仍需补齐同一业务截止日、币种、经常性定义及业务边界。收入日期不完整或同页数值冲突的声明,继续保留在[核查工作台]({BASE}),不补成零值或精确数字。", '',
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--check', action='store_true')
|
||||||
|
parser.add_argument('--document-export', type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
verify()
|
||||||
|
content = render()
|
||||||
|
if args.check:
|
||||||
|
if args.document_export is not None:
|
||||||
|
bundle = json.loads(args.document_export.read_text())
|
||||||
|
stored = next((d['content'] for d in bundle['documents'] if d['sourceSlug'] == SOURCE_SLUG), None)
|
||||||
|
if stored != content:
|
||||||
|
sys.exit('Income snapshot differs; regenerate after reviewing data changes')
|
||||||
|
print('Income reference and samples verified; supplied document export compared when requested')
|
||||||
|
else:
|
||||||
|
TARGET.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
TARGET.write_text(content)
|
||||||
|
print(TARGET.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+2
-2
@@ -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
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,7 +13,7 @@ class FingerprintTests(unittest.TestCase):
|
|||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||||
(root / ".gitignore").write_text(".artifacts/\n**/.next/\n")
|
(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.parent.mkdir(parents=True)
|
||||||
doc.write_text("# Original\n")
|
doc.write_text("# Original\n")
|
||||||
package = root / "package.json"
|
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()
|
||||||
Reference in New Issue
Block a user