D0011: Analyzer and Workspace Snapshot

同一份正在编辑的文档,可能同时被 diagnostics、definition、preview、静态构建和 Agent 查询读取。它们不能分别在不同时间重新读盘,再把各自得到的 source、Parse 与 reference graph 拼在一起;否则一个结果中的 byte range 可能已经不再对应另一个结果中的文本。

Notist 因此把分析分成三个角色:VaultEngine 持有可跨客户端复用的磁盘状态和缓存;每个 Analyzer View 接受自己的 overlay 等变化并发布新 revision;WorkspaceSnapshot 则表示某一个 View revision 下不可变、自洽的语义世界。进程与 client interface 由 vault::designs::D0012-daemon-and-client-interfaces 定义。

filesystem sources + configuration + function schemas
                              |
                              v
                         VaultEngine
                     shared identities/cache
                              |
                 client overlays and View config
                              |
                              v
                        Analyzer View
                    build candidate revision N
                              |
                              v
                    Arc<WorkspaceSnapshot N>

消费者捕获 snapshot 后,只在这个 snapshot 上完成一次查询或渲染。即使 Analyzer 同时发布了 revision N+1,旧查询得到的 source、range、Module 与 diagnostics 仍然彼此对应;消费者可以选择丢弃旧结果,但不能把两个 revision 的对象混合使用。

Vault、Engine、Analyzer 与 Snapshot

Vault 是语言层面的知识边界,由 root、vault::designs::D0007-notist-toml 定义的 marker 规则以及 vault::designs::D0003-vault-and-module 定义的 Module tree 共同确定。

VaultEngine 是这个 Vault 的共享长期状态。它拥有磁盘 source catalog、稳定 identity、可复用 cache 和派生 index,但不假设所有客户端看到相同的未保存文本。

Analyzer 是一个客户端 View 的长期分析会话。它在 VaultEngine 的磁盘状态上叠加自己的 overlay、配置投影和 revision counter。文件 watcher、LSP notification 或桌面应用可以改变这些输入,但它们不直接修改一个已经发布的 snapshot。多个 Analyzer View 可以共享 Engine 中以 source content 为 key 的 Parse 与分析缓存,同时发布彼此独立的 snapshot。

WorkspaceSnapshot 是 Analyzer 对一组确定输入完成分析后的不可变结果。它至少包含:

  • Vault root 与该 revision 使用的配置。

  • 每个 source 的不可变文本、Parse 和 line index。

  • 由 source tree 推导出的 Module tree,包括 Virtual Module。

  • Annotation definition、resolved reference、backlink 与其他语义索引。

  • syntax、name resolution、signature 和 type diagnostics。

  • 完成后续 lowering、structuring 与查询所需的 Function schema 视图。

Workspace 作为“某个目录中的文档集合”适合产品描述,却不足以说明一个值究竟是可变会话还是不可变结果。核心 API 应明确使用 Analyzer 与 WorkspaceSnapshot 这两个名字,避免调用方误以为可以原地更新 snapshot。

输入首先成为 Source Set

Analyzer 不允许每个分析阶段自行读取文件。一次 candidate build 开始时,先形成确定的 source set:

SourceInput {
  file_id
  canonical_path
  text
  origin          // disk or overlay
  document_version?
}

Overlay 按 canonical source path 覆盖磁盘文本。只存在于 overlay 中的新 .not 文件也可以进入 source set;移除 overlay 后,下一 revision 重新采用磁盘状态。Nested Vault 的 source 不进入外层 Analyzer,这一边界在收集输入时就应完成,而不是在 reference resolution 时补救。

source set 一旦开始分析便保持不变。Parser、module discovery、static checking、lowering 和 renderer 都只能读取这里捕获的文本。读取失败、路径逃逸或无法确定 Vault 边界属于 candidate build failure;Analyzer 保留上一个可用 snapshot,而不是发布一个混合了新旧文件的结果。

Syntax error、unresolved reference 和 type mismatch 不属于 build failure。它们是该 revision 对用户 source 的合法分析结果,因此进入 snapshot diagnostics,其他没有错误的内容仍然可以查询和渲染。

一次 Revision 是整个语义世界

Revision 是 Analyzer View 内单调递增的不透明编号。它表达“这些对象来自同一次已发布分析”,不表达 wall-clock time,也不是文件内容 hash,更不能在不同 View、Vault 或 daemon instance 之间直接比较。

Revision(17)
  source(file A)       ─┐
  parse(file A)         |
  module graph          | same semantic world
  references            |
  diagnostics          ─┘

只有完整 candidate 构造成功并成为当前结果时,revision 才对消费者可见。输入连续变化时,可以同时存在多个待构造 candidate;较旧任务即使更晚完成,也不能覆盖较新的已请求状态。取消旧任务是性能优化,publication check 才是一致性边界。

Preview 使用的 SSE generation、LSP document version 和 snapshot revision 是不同概念:

  • document version 由编辑器为单个打开文档提供。

  • snapshot revision 标识整个 Vault 的一致语义状态。

  • preview generation 标识一次可供浏览器读取的完整站点产物。

它们可以互相记录关联,但不应共用一个 counter 或被当作彼此的替代品。

身份分为稳定身份与 Snapshot-local 身份

路径、显示名称和 source range 都不足以独自充当所有语义对象的身份。另一方面,并非每个语法节点都能在任意编辑后保持永久身份。Snapshot 对二者作明确区分。

FileId 由 VaultEngine 为 canonical source identity 分配,在 Engine 生命周期内跨 View 与 revision 稳定。它避免查询结果和内部索引重复携带、比较 PathBuf。文件被删除后 ID 不会在同一 Engine 中立即复用;普通路径重命名产生新的 FileId,除非上层以明确的 rename event 保留身份。

ModuleId 表示 Vault 内的逻辑 Module identity,并对应一个 ModulePath。Virtual Module 与 source-backed Module 都有 ModuleId;给 Virtual Module 增加 README.not 不改变其 Module identity。普通 source move 如果改变 ModulePath,就是语言层面的 Module rename,而不只是文件系统细节。

作者显式声明的 Annotation ID 可以形成跨 revision、可引用的 element identity。没有显式 ID 的 Heading、Call 或 syntax node 只获得 snapshot-local identity,并始终与 revision 一起使用:

SnapshotNodeId {
  revision
  file_id
  local_id
}

不能通过“相同 byte range”假装一个无 ID Element 在编辑前后仍是同一个对象。未来若需要稳定的自动 ElementId,必须单独设计匹配与冲突语义;WorkspaceSnapshot 不把启发式匹配伪装成语言保证。

分析管线

Analyzer 从 source facts 逐步建立更高层语义,每个阶段都只引用同一 candidate 中的对象:

captured sources
  -> Parse + LineIndex per FileId
  -> Module tree and source-to-module mapping
  -> definitions and Function schema environment
  -> reference and name resolution
  -> signature and static type checking
  -> immutable indexes and diagnostics
  -> publish WorkspaceSnapshot

Parse 保留源码事实和 recoverable syntax errors;Module graph 决定名称解析空间;static analysis 验证 Function call,但不为了回答编辑器查询而执行具有外部副作用的插件。类型系统与 partial semantics 的边界继续由 vault::designs::D0006-type-system 定义。

Lowering 与 structuring 仍遵循 vault::designs::D0005-lowering-and-structuring。它们可以按 Module 惰性计算,也可以在 build 中批量计算,但必须以捕获的 snapshot 为输入,且缓存 key 至少包含 snapshot revision、ModuleId 与 Function environment identity。任何需要重新读取 source 或调用未包含在 snapshot 输入中的动态环境的求值,都不能被称为这个 snapshot 的确定结果。

Analyzer 负责 orchestration,不成为新的语言语义层。Parser、resolver、checker 和 evaluator 的规则仍由各自组件定义;Analyzer 只保证它们看到同一组输入,并将结果组织成可查询的世界。

查询属于 Snapshot

definition、references、completion、Hover、outline、diagnostics、render 与 search context 都应表现为 snapshot query。协议适配层只负责把外部位置和结果格式转换为核心类型:

snapshot.module_at(file_id)
snapshot.source(file_id)
snapshot.definition_at(file_id, byte_offset)
snapshot.references_to(module_id)
snapshot.hover_at(file_id, byte_offset)
snapshot.document_symbols(file_id)
snapshot.structured_document(module_id)

查询结果携带 snapshot revision,并优先返回 FileId、ModuleId、显式 Annotation identity 与 TextRange。Path、ModulePath、标题和 snippet 是方便展示与序列化的信息,不取代核心 identity。

Snapshot query 不修改 Analyzer 输入,也不偷偷推进 revision。Completion 或 search 需要额外预算时,可以在 snapshot 上建立可丢弃的 memoization cache;缓存只影响性能,不能改变结果语义。

Delta 从两个 Snapshot 推导

Watcher event 只说明“某个路径可能变化”,不能直接充当语义增量。WorkspaceDelta 应由两个已完成 snapshot 比较得出:

WorkspaceDelta {
  from_revision
  to_revision
  added_files / changed_files / removed_files
  added_modules / changed_modules / removed_modules
  changed_references
  changed_diagnostics
}

LSP 可以据此只重新发布受影响文件的 diagnostics,preview 可以只重建受影响页面,全文或向量索引可以更新对应 Module。Delta 是优化与通知接口;任何消费者都必须能丢弃派生状态并从完整 snapshot 重建,不能让增量日志成为新的真相来源。

并发与发布

发布后的 WorkspaceSnapshot 可以由 Arc 共享,不需要消费者持有 Analyzer 的可变锁。典型生命周期是:

input change
  -> update Analyzer inputs
  -> reserve revision N
  -> build candidate N outside publication lock
  -> if N is still current, atomically publish Arc<Snapshot N>
  -> notify consumers with Delta(N-1, N)

一个请求开始时读取当前 Arc<WorkspaceSnapshot>,随后不再追随 Analyzer 的 current pointer。这样长时间 render 或 Agent search 不会阻塞编辑器继续产生新 snapshot,也不会在执行中途改变语义世界。

每个 Vault 拥有独立 VaultEngine,每个需要不同 overlay 或配置投影的客户端拥有自己的 Analyzer View。跨 Vault 搜索或桌面应用的多 Vault 视图位于更高层:它们可以捕获多个 snapshot 并记录各自 revision,但不能制造一个看似原子的全局 revision。Vault 隔离也意味着 reference resolution、Function capability 与权限检查默认不跨 Engine 边界。

消费者边界

vault::designs::D0008-language-server 负责 overlay lifecycle、LSP Position 转换和请求路由;它向自己的 Analyzer View 提交输入变化,并把 snapshot query 转成 LSP response,不维护第二套 module graph。CLI、LSP、MCP 与 daemon 的进程关系见 vault::designs::D0012-daemon-and-client-interfaces

vault::designs::D0009-preview-and-html 的 build 与 preview 捕获一个 snapshot,再从中得到所有页面的 StructuredDocument、链接和 diagnostics。站点生成期间不重新扫描 Vault;生成完成后,完整站点 generation 才对 HTTP server 可见。

全文索引、向量、graph projection、摘要与 MCP resource 都是 snapshot 的派生消费者。每条派生记录必须带来源 revision 和稳定 identity,并能从 snapshot 重建。它们不能把自己的缓存、embedding 或摘要写回 snapshot,Agent 写入也必须先改变 Analyzer input,再经过新的 revision 才成为可查询事实。

这个边界使 WorkspaceSnapshot 成为 Notist application core 的只读语义接口:外部协议和产品可以不同,但它们看到的是同一个 Vault、同一组 source 和同一套语言判断。