mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(persistence): add stable catalogs and policy-aware schema review
Traversal ordinals and first-owner paths gave shared persistence types unstable display names. Keep authored names and source locations as metadata, use structural digests for identity, and render complete kind/form groups with bounded array labels. Preserve historical rendering and stored graphs. Record supported source compatibility promises with their property binding, policy version, discriminator, preservation rule, and qualified kind set. The extractor and classifier require explicit opt-ins in both compared schemas; ordinary or unsupported changes retain strict classification. Policy-bearing graphs use the format-2 fingerprint domain while unannotated graphs keep their format-1 fingerprints. Runtime owners activate bindings only with the corresponding reader behavior; this tooling layer activates none and changes no package source or Session generation. Provide a read-only before/after review command that groups shared structural evidence without replacing authoritative compatibility diagnostics. Match changed alternatives only through unique literal discriminators and retain ambiguous additions and removals as independent facts. At the X baseline, all 61 roots and 472 schemas, digests, and source fields are unchanged. The inventory remains format 1 and removes only 296 synthetic names. Existing acknowledgement history still matches every root. All 195 focused tooling tests, Host build/full lint, and bilingual pairing pass.
This commit is contained in:
1 parent
bc710ac726
commit
a6981c56f6
32 files changed
+11428
-9211
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md
|
||||
2026-09-11-persistence-type-history.md: 251ac7e1ba9f87e712c5e1d92a80640b527ed4a3
|
||||
2026-09-11-persistence-type-history.zh.md: fee3bf7f7babf3dc9bece0b66880155318bdad70
|
||||
2026-09-11-persistence-type-history.md: e1fd4ac704f6908daab573ee63eacc1c93780896
|
||||
2026-09-11-persistence-type-history.zh.md: d54c0d845f24ca23b17bf8f386995b68f718d43a
|
||||
@@ -10,11 +10,11 @@ A persisted event can retain the same payload type expression while a referenced
|
||||
|
||||
## Decision
|
||||
|
||||
One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, comments, and property order are presentation details outside the digest. Historical records retain source file paths without line numbers because their selected source trees differ from the current checkout. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
|
||||
One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, ordinary comments, and property order are presentation details outside the digest. Explicit compatibility annotations carry fingerprinted policy data under the [source-attribution policy](2026-09-17-persistence-schema-review.md). Historical records retain source file paths without line numbers because their selected source trees differ from the current checkout. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
|
||||
|
||||
The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services.
|
||||
|
||||
Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
|
||||
Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version.
|
||||
|
||||
The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、注释和属性顺序属于摘要之外的展示细节。历史记录保留源码文件路径而不包含行号,因为其选定的源码树与当前工作树不同。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。
|
||||
一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、普通注释和属性顺序属于摘要之外的展示细节。历史记录保留源码文件路径而不包含行号,因为其选定的源码树与当前工作树不同。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。显式兼容性标记依照[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)携带纳入指纹的策略数据。
|
||||
|
||||
专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
|
||||
|
||||
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
|
||||
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
|
||||
|
||||
新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-persistence-schema-review.md
|
||||
2026-09-17-persistence-schema-review.md: c4b5f87e0bc37a7f1bba8999317b045f4a367847
|
||||
2026-09-17-persistence-schema-review.zh.md: 8707d304eb8e3653a97c73b5876b6977fea3ac83
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Persistence schema policy and review
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-persistence-schema-review.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Structural fingerprints identify changed persistence types, but a fingerprint alone cannot explain a shared change or distinguish a declared reader promise from an assumption about runtime behavior. Names derived from union positions and traversal paths also change when unrelated siblings are added, obscuring the fields a reviewer needs to inspect.
|
||||
|
||||
## Decision
|
||||
|
||||
Inventory format 2 records supported compatibility metadata with the affected property. The metadata names its binding, policy version, literal discriminator, unknown-kind preservation promise, and qualified attribution kinds. Policy-bearing graphs use a separate fingerprint domain; graphs without a bound policy retain format-1 normalization and fingerprints. Inventory versions describe schema tooling, independently of Session format versions.
|
||||
|
||||
The extractor accepts explicit source bindings only at the core-owned source declaration and requires a literal user or developer role at each use. Producer annotations qualify complete wire-kind groups. The classifier permits a qualified new attribution kind only when both saved schemas carry matching promises. Existing kinds remain subject to structural comparison; removals, retroactive qualification, reserved identities, and unrelated breaking changes retain strict classification. Runtime owners establish and test the promised preservation behavior before activating a binding.
|
||||
|
||||
Current inventories retain authored names and source locations as descriptive metadata. Structural digests identify types and provide catalog anchors. Catalogs group source alternatives by literal `kind` and retain every `form` alternative, required field, index signature, and link to its complete definition. Bounded array labels describe their elements instead of inheriting the first event that reached the array. Historical references retain their frozen rendering.
|
||||
|
||||
The read-only `persistence-review` command accepts explicit before and after inventories. It matches unchanged schemas by digest and changed alternatives only when literal discriminators identify them uniquely. Ambiguous alternatives remain separate additions and removals. Shared structural evidence lists every affected root, while a separate section preserves the authoritative classifier diagnostics. The [history mechanism](2026-09-11-persistence-type-history.md) continues to own acknowledgement and version requirements.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Ignore all source changes.** A source can carry replay or validation meaning. Explicit, fingerprinted opt-ins constrain the supported exception and leave other changes visible.
|
||||
|
||||
**Use traversal paths as type identities.** Union positions and first-owner paths describe one walk through a graph. Unrelated insertions and shared references can change them without changing the persisted type.
|
||||
|
||||
**Infer renames from similar fields.** Several alternatives can have the same discriminator or similar structure. Keeping ambiguous additions and removals avoids presenting an unsupported correspondence as fact.
|
||||
|
||||
**Replace compatibility checks with a readable diff.** Review explanations group evidence for a human reader. They cannot relax the classifier or replace the in-tree acknowledgement history.
|
||||
|
||||
## Consequences
|
||||
|
||||
Declared compatibility promises participate in fingerprints and remain available when comparing historical snapshots. The tooling does not infer or enforce runtime preservation; the owner of an activated binding supplies that behavior. Ordinary comments, labels, and source locations remain outside structural identity.
|
||||
|
||||
Complete nested schema graphs remain stored in each inventory. Stable labels and grouped review output reduce presentation noise without changing that storage encoding or rewriting frozen historical records. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) describes the review command and acknowledgement workflow.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 持久化 schema 策略与审阅
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-persistence-schema-review.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
结构指纹能标识变化的持久化类型,但单凭指纹无法解释共享变更,也无法区分已声明的读取承诺与对运行时行为的假设。由联合类型位置和遍历路径生成的名称还会因无关分支新增而变化,遮蔽审阅者需要检查的字段。
|
||||
|
||||
## 决策
|
||||
|
||||
Inventory format 2 在受影响属性上记录受支持的兼容性元数据。元数据包含绑定、策略版本、字面量判别字段、未知 kind 保留承诺和符合条件的归属 kind。带策略的图使用独立指纹域;没有绑定策略的图保留 format-1 规范化和指纹。Inventory 版本描述 schema 工具,独立于 Session 格式版本。
|
||||
|
||||
提取器仅接受核心拥有的 source 声明上的显式绑定,并要求每个使用位置具有 user 或 developer 字面量角色。生产者标记对完整 wire-kind 分组赋予资格。仅当双方保存的 schema 携带匹配承诺时,分类器才允许新增符合条件的归属 kind。已有 kind 仍须进行结构比较;删除、追认资格、保留标识和无关破坏性变更继续采用严格分类。运行时所有者在激活绑定前实现并验证承诺的保留行为。
|
||||
|
||||
当前 inventory 将人工声明名称和源码位置保留为描述性元数据。结构摘要标识类型并提供目录锚点。目录按字面量 `kind` 对消息源分支分组,并保留每个 `form` 分支、必需字段、索引签名及指向完整定义的链接。有界数组标签描述元素,不继承最先到达该数组的事件名称。历史参考保留冻结的渲染方式。
|
||||
|
||||
只读 `persistence-review` 命令接受显式的变更前后 inventory。它按摘要匹配未变 schema,仅当字面量判别字段唯一标识分支时才匹配变化分支。有歧义的分支保留为独立新增与删除。共享结构证据列出每个受影响根,独立章节保留权威分类器诊断。[历史机制](2026-09-11-persistence-type-history.zh.md)继续负责确认和版本要求。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**忽略所有 source 变更。** 消息源可能承载回放或校验语义。显式且纳入指纹的选择加入约束受支持的例外,并使其他变更保持可见。
|
||||
|
||||
**将遍历路径用作类型身份。** 联合类型位置和首个引用者路径描述的是一次图遍历。无关插入和共享引用可能改变它们,而持久化类型并未改变。
|
||||
|
||||
**从相似字段推断重命名。** 多个分支可能具有相同判别值或相似结构。保留有歧义的新增与删除,避免将缺乏依据的对应关系呈现为事实。
|
||||
|
||||
**用可读 diff 取代兼容性检查。** 审阅说明为读者归并证据,不能放宽分类器,也不能取代目录内的确认历史。
|
||||
|
||||
## 影响
|
||||
|
||||
声明的兼容性承诺参与指纹,并在比较历史快照时保持可用。工具不会推断或强制运行时保留行为;激活绑定的所有者提供该行为。普通注释、标签和源码位置仍处于结构身份之外。
|
||||
|
||||
每份 inventory 继续存储完整嵌套 schema 图。稳定标签和分组审阅输出减少展示噪音,不改变存储编码,也不重写冻结历史记录。[实操手册](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)介绍审阅命令和确认流程。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/cookbook/reviewing-persistence-type-changes.md
|
||||
reviewing-persistence-type-changes.md: 881bc689af656f0c4976e909a49848e38d53c453
|
||||
reviewing-persistence-type-changes.zh.md: f1961013886cfab1abdbd7e2cd7e09bcfce2101d
|
||||
reviewing-persistence-type-changes.md: 28c1600fac95b30d667073889ddbca4892c53ac5
|
||||
reviewing-persistence-type-changes.zh.md: 9abd1630a1ae70b5df26c19d1cdb7fda68230c3b
|
||||
@@ -33,6 +33,14 @@ Use `--silent` when consuming JSON: pnpm otherwise appends lifecycle failure tex
|
||||
|
||||
Read the reported root, path, change kind, and version requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If `changes` is empty after reordering fields or union alternatives, run `pnpm run gen-persistence-catalog` and repeat the check. An unchanged digest needs no new acknowledgement even when copied declarations or source locations produce a catalog diff.
|
||||
|
||||
To review a PR independently of its acknowledgement history, save the base and head inventories as local JSON files and run:
|
||||
|
||||
```sh
|
||||
pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json
|
||||
```
|
||||
|
||||
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. Record the change
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ pnpm --silent run verify-persistence-changes --json
|
||||
|
||||
阅读报告中的根、路径、变更种类和版本要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。
|
||||
|
||||
要独立于确认历史评审 PR,先将 base 和 head 的目录保存为本地 JSON 文件,再运行:
|
||||
|
||||
```sh
|
||||
pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json
|
||||
```
|
||||
|
||||
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
|
||||
|
||||
<a id="acknowledge"></a>
|
||||
## 1. 记录变更
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: 7938e1e9dad5c728603dcda78997afa8487d5e93
|
||||
persistence-catalog.zh.md: 04952406fe691ce8409abc4b313305a7d92f19bd
|
||||
persistence-catalog.md: ba70d5713012d09708882fe6dbd1fa9356099327
|
||||
persistence-catalog.zh.md: aa6cc3fe44753dff0408e5271f1e068e2de92ad1
|
||||
+4872
-4234
File diff suppressed because it is too large.
Load diff
+4872
-4234
File diff suppressed because it is too large.
Load diff
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-changes/README.md
|
||||
README.md: 7eb46efe69e139c294ad76e1b584e2d7384151b5
|
||||
README.zh.md: d91cfff9e5010bfff92b905ce0f075e7e419eb52
|
||||
README.md: b4d8fdf4cc264e2d9f63188e461ecaa5599547dc
|
||||
README.zh.md: 0ab14a9ccb315f57359eaf897d8366d39c96e458
|
||||
@@ -49,9 +49,12 @@ Every detected structural change requires an acknowledgement. Record creation an
|
||||
| Add an optional event-body property, including its complete subtree | `same-version` |
|
||||
| Make a required event-body property optional | `same-version` |
|
||||
| Add an ordinary event type | `same-version` |
|
||||
| Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` |
|
||||
| Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` |
|
||||
| Change the Session header or event envelope | `version-bump` |
|
||||
|
||||
The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group.
|
||||
|
||||
A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md).
|
||||
|
||||
<a id="history-and-limitations"></a>
|
||||
@@ -61,7 +64,7 @@ One baseline records the complete initial inventory. Later records use their pre
|
||||
|
||||
Accepted records describe historical transitions; preserve their machine declarations and schema snapshots when adding a successor. An unaccepted terminal record can be refreshed explicitly; the command rejects baselines and records with dependants. The tree cannot establish review acceptance. Verification proves consistency within the tree, not that history was never rewritten. No Git ref, remote service, or released checkout supplies the baseline.
|
||||
|
||||
Digests describe declared persistence types, not runtime validation or behavior. Comments, source locations, alias names, and harmless declaration reordering do not affect them. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs.
|
||||
Digests describe declared persistence types, not runtime validation or behavior. Ordinary comments, source locations, alias names, and harmless declaration reordering do not affect them. Compatibility annotations are recorded policy data and do affect digests. Unannotated version-1 snapshots retain their original normalization, fingerprints, and strict classification; policy-bearing graphs use a separate fingerprint domain. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## Dev Note
|
||||
|
||||
@@ -49,9 +49,12 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
|
||||
| 添加可选事件体属性,包括其完整子树 | `same-version` |
|
||||
| 将必选事件体属性改为可选 | `same-version` |
|
||||
| 添加普通事件类型 | `same-version` |
|
||||
| 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` |
|
||||
| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` |
|
||||
| 更改会话头或事件封装 | `version-bump` |
|
||||
|
||||
提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。
|
||||
|
||||
同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。
|
||||
|
||||
<a id="history-and-limitations"></a>
|
||||
@@ -61,7 +64,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
|
||||
|
||||
已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。尚未接受的末端记录可以显式刷新;命令拒绝基线及被依赖的记录。目录本身无法证明审阅接受状态。验证证明目录内的一致性,不证明历史从未被改写。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。
|
||||
|
||||
摘要描述声明的持久化类型,不描述运行时验证或行为。注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
|
||||
摘要描述声明的持久化类型,不描述运行时验证或行为。普通注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。兼容性标记是记录的策略数据,会影响摘要。不带标记的版本 1 快照保留原有规范化、指纹和严格分类;包含策略的图使用独立的指纹域。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## 开发备注
|
||||
|
||||
Generated
+181
-658
File diff suppressed because it is too large.
Load diff
@@ -165,6 +165,7 @@
|
||||
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
|
||||
"verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
|
||||
"persistence-changes": "tsx scripts/persistence-changes.ts",
|
||||
"persistence-review": "tsx scripts/persistence-review.ts",
|
||||
"verify-persistence-changes": "tsx scripts/persistence-changes.ts --check",
|
||||
"verify-persistence-releases": "tsx scripts/persistence-releases.ts",
|
||||
"verify-persistence-formats": "tsx scripts/persistence-formats.ts",
|
||||
|
||||
@@ -102,7 +102,7 @@ export function render(
|
||||
'',
|
||||
text.envelopeIntro,
|
||||
'',
|
||||
...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
|
||||
...(schema ? [renderPersistenceSchemaIndex(schema, locale, undefined, 2, 'current')] : []),
|
||||
`## ${text.envelope}`,
|
||||
'',
|
||||
'```' + FENCE,
|
||||
@@ -121,7 +121,7 @@ export function render(
|
||||
lines.push(...renderEvent(e, locale))
|
||||
}
|
||||
}
|
||||
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
|
||||
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale, undefined, 2, 'current'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ const english = {
|
||||
optional: 'optional', required: 'required', rest: 'rest', index: 'index signature',
|
||||
emptyObject: 'Object with no declared properties.', arrayPrefix: 'Array of ', arraySuffix: '.',
|
||||
oneOf: 'One of:', opaque: ' (opaque)', opaqueExplanation: ": the declaration does not expose the stored value's internal fields.",
|
||||
sourceCompatibility: 'Source compatibility: ', attributionAdditions: 'Attribution-only additions: ',
|
||||
sourceColumns: '| kind | Form property | Other required fields | Full definition |',
|
||||
notDeclared: 'not declared', none: 'none',
|
||||
}
|
||||
|
||||
const chinese: Record<keyof typeof english, string> = {
|
||||
@@ -37,6 +40,9 @@ const chinese: Record<keyof typeof english, string> = {
|
||||
optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
|
||||
emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
|
||||
oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
|
||||
sourceCompatibility: '消息源兼容策略:', attributionAdditions: '仅表示归属的新增 kind:',
|
||||
sourceColumns: '| kind | form 属性 | 其他必需字段 | 完整定义 |',
|
||||
notDeclared: '未声明', none: '无',
|
||||
}
|
||||
|
||||
/** Complete translated prose; adding an English key requires its Chinese counterpart. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
import {
|
||||
@@ -371,7 +371,7 @@ describe('persistence history verification', () => {
|
||||
const tampered = structuredClone(schema)
|
||||
Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
|
||||
expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
|
||||
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 2 })).toThrow('normalization version')
|
||||
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 3 })).toThrow('normalization version')
|
||||
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
|
||||
expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
|
||||
})
|
||||
@@ -609,3 +609,144 @@ describe('persistence changes current-tree commands', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
interface SourceAlternative {
|
||||
readonly kind: string
|
||||
readonly value?: 'string' | 'number'
|
||||
readonly extra?: 'required' | 'optional'
|
||||
}
|
||||
|
||||
function attributedRoot(
|
||||
alternatives: readonly SourceAlternative[],
|
||||
options: { policy?: SourceCompatibility | null; role?: 'user' | 'developer'; extra?: boolean } = {},
|
||||
): PersistenceRoot {
|
||||
const role = options.role ?? 'user'
|
||||
const policy: SourceCompatibility | undefined = options.policy === null ? undefined : options.policy ?? {
|
||||
version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: [],
|
||||
}
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 2, optional: false }, { name: 'data', type: 1, optional: false }] },
|
||||
{ kind: 'object', indices: [], properties: [
|
||||
{ name: 'role', type: 4, optional: false }, { name: 'source', type: 3, optional: false, ...(policy === undefined ? {} : { compatibility: policy }) },
|
||||
...(options.extra ? [{ name: 'unrelated', type: 5, optional: false }] : []),
|
||||
] },
|
||||
{ kind: 'literal', value: 'example/source' },
|
||||
{ kind: 'primitive', type: 'never' },
|
||||
{ kind: 'literal', value: role },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
{ kind: 'primitive', type: 'number' },
|
||||
]
|
||||
const types = alternatives.map((alternative) => {
|
||||
const index = nodes.length
|
||||
nodes.push({ kind: 'object', indices: [], properties: [
|
||||
{ name: 'kind', type: index + 1, optional: false },
|
||||
...(alternative.value === undefined ? [] : [{ name: 'value', type: alternative.value === 'string' ? 5 : 6, optional: false }]),
|
||||
...(alternative.extra === undefined ? [] : [{ name: 'extra', type: 5, optional: alternative.extra === 'optional' }]),
|
||||
] }, { kind: 'literal', value: alternative.kind })
|
||||
return index
|
||||
})
|
||||
nodes[3] = { kind: 'union', types }
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { key: 'event:example/source', kind: 'event', event: 'example/source', surface: false, schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
|
||||
function attributionPolicy(kinds: readonly string[], role: 'user' | 'developer' = 'user'): SourceCompatibility {
|
||||
return { version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: kinds }
|
||||
}
|
||||
|
||||
const EXISTING_SOURCE: SourceAlternative = { kind: 'semantic', value: 'string' }
|
||||
const NEW_SOURCE: SourceAlternative = { kind: 'new-attribution', value: 'number' }
|
||||
|
||||
describe('recorded source compatibility policy', () => {
|
||||
it.each(['user', 'developer'] as const)('accepts a qualified new %s source and records its changed digest', (role) => {
|
||||
const before = attributedRoot([EXISTING_SOURCE], { role })
|
||||
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { role, policy: attributionPolicy([NEW_SOURCE.kind], role) })
|
||||
expect(after.digest).not.toBe(before.digest)
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [after], types: [] }
|
||||
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('allows ordinary optional fields in an existing group alongside a qualified addition', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE])
|
||||
const after = attributedRoot([{ ...EXISTING_SOURCE, extra: 'optional' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
const changes = classifyPersistenceChange(before, after)
|
||||
expect(changes.map(change => change.kind).sort()).toEqual(['attribution-kind-added', 'optional-property-added'])
|
||||
expect(changes.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unmarked addition', [EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([]) }],
|
||||
['required existing field', [{ ...EXISTING_SOURCE, extra: 'required' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
|
||||
['changed existing field', [{ ...EXISTING_SOURCE, value: 'number' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
|
||||
['removed existing kind', [NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
|
||||
['renamed existing kind', [{ ...EXISTING_SOURCE, kind: 'renamed' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
|
||||
['unrelated required payload', [EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]), extra: true }],
|
||||
['removed promise', [EXISTING_SOURCE, NEW_SOURCE], { policy: null }],
|
||||
] as const)('keeps %s breaking despite qualified additions', (_name, sources, options) => {
|
||||
const changes = classifyPersistenceChange(attributedRoot([EXISTING_SOURCE]), attributedRoot(sources, options))
|
||||
expect(changes.some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps variants within an existing wire-kind group strict', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE])
|
||||
const after = attributedRoot([EXISTING_SOURCE, { ...EXISTING_SOURCE, value: 'number' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'union-variants-changed', requiresVersionBump: true }),
|
||||
expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('treats qualification changes for an existing kind as policy changes', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE])
|
||||
const after = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'source-policy-changed', requiresVersionBump: true })])
|
||||
})
|
||||
|
||||
it('keeps legacy source transitions strict and never infers promises from the successor', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE], { policy: null })
|
||||
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
const legacyAfter = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: null })
|
||||
const snapshots = [before, legacyAfter].map(root => ({ formatVersion: 1 as const, roots: [root], types: [] }))
|
||||
expect(() => validatePersistenceHistory([
|
||||
entry(BASE_ID, snapshots[0]!, null, true), entry(NEXT_ID, snapshots[1]!, BASE_ID),
|
||||
])).toThrow('requires a format version bump')
|
||||
})
|
||||
|
||||
it('validates policy-bearing history without source names or type records', () => {
|
||||
const before: PersistenceSchemaInventory = { formatVersion: 2, types: [],
|
||||
roots: [...inventory().roots.filter(root => root.kind !== 'event'), attributedRoot([EXISTING_SOURCE])],
|
||||
}
|
||||
const after: PersistenceSchemaInventory = { formatVersion: 2, types: [],
|
||||
roots: [attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })],
|
||||
}
|
||||
expect(validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, after, BASE_ID)]).tips.get('event:example/source')?.root).toEqual(after.roots[0])
|
||||
})
|
||||
|
||||
it.each(['auto-review', 'compact-basic', 'code-mode'])('rejects saved qualification of historical kind %s', (kind) => {
|
||||
const root = attributedRoot([EXISTING_SOURCE, { kind }], { policy: attributionPolicy([kind]) })
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
|
||||
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('invalid source compatibility')
|
||||
})
|
||||
|
||||
it('rejects old-format policy fields, policy tampering, unsupported promises and invalid bindings', () => {
|
||||
const root = attributedRoot([EXISTING_SOURCE])
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, formatVersion: 1 })).toThrow('unknown field compatibility')
|
||||
for (const patch of [{ version: 2 }, { policy: 'arbitrary' }, { discriminator: 'type' }, { unknownKinds: 'discard' }]) {
|
||||
const tampered = JSON.parse(JSON.stringify(snapshot)) as PersistenceSchemaInventory
|
||||
const property = tampered.roots[0]!.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
|
||||
Object.assign(property.compatibility!, patch)
|
||||
expect(() => parsePersistenceSnapshot(tampered)).toThrow('unsupported source compatibility')
|
||||
}
|
||||
const wrongRole = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([], 'developer') })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [wrongRole] })).toThrow('invalid source compatibility')
|
||||
const changedQualification = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [{ ...changedQualification, digest: root.digest }] })).toThrow('digest mismatch')
|
||||
const unknownKind = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy(['absent']) })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [unknownKind] })).toThrow('invalid source compatibility')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
|
||||
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
@@ -65,6 +66,8 @@ const CHANGE_DESCRIPTIONS = {
|
||||
'tuple-length-changed': 'tuple length changed',
|
||||
'tuple-element-cardinality-changed': 'tuple element cardinality changed',
|
||||
'union-variants-changed': 'union variants changed',
|
||||
'source-policy-changed': 'source compatibility policy changed',
|
||||
'attribution-kind-added': 'attribution-only source kind added',
|
||||
} as const
|
||||
|
||||
/** Stable structural classification independent of diagnostic prose. */
|
||||
@@ -169,7 +172,7 @@ function reference(value: unknown, count: number, label: string): number {
|
||||
return value as number
|
||||
}
|
||||
|
||||
function parseSchema(value: unknown, label: string): CanonicalSchema {
|
||||
function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): CanonicalSchema {
|
||||
const input = record(value, label)
|
||||
keys(input, ['root', 'nodes'], label)
|
||||
if (input.root !== 0) throw new Error(`${label}.root must be zero`)
|
||||
@@ -211,7 +214,17 @@ function parseSchema(value: unknown, label: string): CanonicalSchema {
|
||||
const names = new Set<string>()
|
||||
for (const rawProperty of array(node.properties, label)) {
|
||||
const property = record(rawProperty, label)
|
||||
keys(property, ['name', 'type', 'optional'], label)
|
||||
keys(property, ['name', 'type', 'optional'], label, formatVersion === 2 ? ['compatibility'] : [])
|
||||
if (property.compatibility !== undefined) {
|
||||
const policy = record(property.compatibility, `${label} source compatibility`)
|
||||
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
|
||||
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|
||||
|| !['session.user-message.source', 'session.developer-message.source'].includes(String(policy.binding))
|
||||
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
|
||||
throw new Error(`${label}: unsupported source compatibility policy`)
|
||||
}
|
||||
for (const kind of array(policy.attributionKinds, label)) textValue(kind, label)
|
||||
}
|
||||
if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
|
||||
const name = property.name
|
||||
if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
|
||||
@@ -237,6 +250,14 @@ function parseSchema(value: unknown, label: string): CanonicalSchema {
|
||||
}
|
||||
}
|
||||
const schema = input as unknown as CanonicalSchema
|
||||
for (const node of schema.nodes) {
|
||||
if (node.kind !== 'object') continue
|
||||
for (const property of node.properties) {
|
||||
if (property.compatibility !== undefined && !validSourceCompatibility(schema.nodes, node, property)) {
|
||||
throw new Error(`${label}: invalid source compatibility binding or attribution kinds`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const canonical = canonicalizeSchema(schema.nodes, schema.root)
|
||||
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
|
||||
return schema
|
||||
@@ -261,7 +282,7 @@ export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceS
|
||||
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
|
||||
const input = record(value, 'schema inventory')
|
||||
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
|
||||
if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
|
||||
if (input.formatVersion !== 1 && input.formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
|
||||
const names = new Set<string>()
|
||||
for (const rawRoot of array(input.roots, 'schema roots')) {
|
||||
const root = record(rawRoot, 'schema root')
|
||||
@@ -275,14 +296,14 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
|
||||
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
|
||||
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
|
||||
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
|
||||
const schema = parseSchema(root.schema, key)
|
||||
const schema = parseSchema(root.schema, key, input.formatVersion)
|
||||
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
|
||||
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
|
||||
}
|
||||
for (const rawType of array(input.types, 'schema types')) {
|
||||
const type = record(rawType, 'schema type')
|
||||
keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
|
||||
const schema = parseSchema(type.schema, 'shared schema')
|
||||
const schema = parseSchema(type.schema, 'shared schema', input.formatVersion)
|
||||
if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
|
||||
for (const name of array(type.names, 'type names')) textValue(name, 'type name')
|
||||
for (const source of array(type.sources, 'type sources')) {
|
||||
@@ -339,7 +360,7 @@ function matchUnionVariants(candidates: readonly (readonly number[])[]): number[
|
||||
return matches
|
||||
}
|
||||
|
||||
/** Classify structural differences; only optional payload properties and ordinary event additions are additive.
|
||||
/** Classify structural differences using the reader promises saved with each schema.
|
||||
* @param before - predecessor root, or absence for an addition.
|
||||
* @param after - successor root, or absence for deletion.
|
||||
* @returns concrete changes and their format-bump requirement.
|
||||
@@ -392,7 +413,30 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
|
||||
const child = `${path}.${name}`
|
||||
if (next === undefined) { add(child, 'property-removed'); continue }
|
||||
if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
|
||||
descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
|
||||
const childScope = scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict'
|
||||
if (property.compatibility !== undefined || next.compatibility !== undefined) {
|
||||
const oldGroups = sourceKindGroups(oldRoot.schema.nodes, property.type)
|
||||
const newGroups = sourceKindGroups(newRoot.schema.nodes, next.type)
|
||||
if (childScope === 'body' && property.compatibility !== undefined && next.compatibility !== undefined
|
||||
&& oldGroups !== undefined && newGroups !== undefined
|
||||
&& validSourceCompatibility(oldRoot.schema.nodes, oldNode, property)
|
||||
&& validSourceCompatibility(newRoot.schema.nodes, newNode, next)
|
||||
&& matchingSourceCompatibility(property.compatibility, next.compatibility, oldGroups.keys())) {
|
||||
for (const [kind, oldTypes] of oldGroups) {
|
||||
const newTypes = newGroups.get(kind)
|
||||
if (newTypes === undefined) add(`${child}[kind=${JSON.stringify(kind)}]`, 'union-variants-changed')
|
||||
else differences.push(...compareAlternatives(oldTypes, newTypes, `${child}[kind=${JSON.stringify(kind)}]`, childScope, active))
|
||||
}
|
||||
for (const kind of newGroups.keys()) {
|
||||
if (!oldGroups.has(kind)) add(`${child}[kind=${JSON.stringify(kind)}]`,
|
||||
next.compatibility.attributionKinds.includes(kind) ? 'attribution-kind-added' : 'union-variants-changed',
|
||||
!next.compatibility.attributionKinds.includes(kind))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (JSON.stringify(property.compatibility) !== JSON.stringify(next.compatibility)) add(child, 'source-policy-changed')
|
||||
}
|
||||
descend(property.type, next.type, child, childScope)
|
||||
}
|
||||
for (const [name, property] of newProps) {
|
||||
if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
|
||||
@@ -419,20 +463,25 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
|
||||
return differences
|
||||
}
|
||||
if (oldNode.kind === 'union' && newNode.kind === 'union') {
|
||||
if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
|
||||
const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
|
||||
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
|
||||
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
|
||||
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
|
||||
const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
|
||||
const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
|
||||
const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
|
||||
const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
|
||||
if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
|
||||
return [describe(path, 'union-variants-changed')]
|
||||
return compareAlternatives(oldNode.types, newNode.types, path, scope, active)
|
||||
}
|
||||
return [describe(path, 'type-changed')]
|
||||
}
|
||||
function compareAlternatives(
|
||||
oldTypes: readonly number[], newTypes: readonly number[], path: string, scope: Scope, active: ReadonlySet<string>,
|
||||
): PersistenceTypeChange[] {
|
||||
if (oldTypes.length !== newTypes.length) return [describe(path, 'union-variants-changed')]
|
||||
const candidates = oldTypes.map(oldType => newTypes.map(newType => compare(oldType, newType, path, scope, active)))
|
||||
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
|
||||
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
|
||||
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
|
||||
const oldByHash = new Map(oldTypes.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
|
||||
const newByHash = new Map(newTypes.map(index => [fingerprint(newRoot.schema, index, 1), index]))
|
||||
const removed = [...oldByHash].filter(([hash]) => !newByHash.has(hash)).map(([, index]) => index)
|
||||
const added = [...newByHash].filter(([hash]) => !oldByHash.has(hash)).map(([, index]) => index)
|
||||
if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
|
||||
return [describe(path, 'union-variants-changed')]
|
||||
}
|
||||
changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
|
||||
if (changes.length === 0) changes.push(describe(key, 'type-changed'))
|
||||
return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
|
||||
@@ -743,7 +792,7 @@ function executeCommand(
|
||||
root: key, previous: history?.tips.get(key)?.id ?? null,
|
||||
after: roots.find(root => root.key === key)?.digest ?? null, decision,
|
||||
})) }
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: current.formatVersion, roots, types: [] }
|
||||
validatePersistenceHistory([...prior, { record: change, snapshot }])
|
||||
const document = (chinese: boolean): string => {
|
||||
const supplied = chinese ? prose?.zh : prose?.en
|
||||
|
||||
@@ -367,7 +367,7 @@ describe('release facts', () => {
|
||||
const data = fixture()
|
||||
const snapshotPath = join(data.directory, TAGS[2] + '.schema.json')
|
||||
const snapshot = readFileSync(snapshotPath, 'utf8')
|
||||
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":2'))
|
||||
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":3'))
|
||||
const corrupt = directoryBytes(data.directory)
|
||||
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('normalization version')
|
||||
expect(directoryBytes(data.directory)).toEqual(corrupt)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Semantic review regressions independent of traversal ordinals and display names. */
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyPersistenceChange } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceReview, reviewPersistenceSchemas, runPersistenceReview } from './persistence-review.ts'
|
||||
|
||||
function fixture(options: {
|
||||
extraKind?: boolean
|
||||
changed?: boolean
|
||||
ambiguous?: boolean
|
||||
reorder?: boolean
|
||||
tuple?: boolean
|
||||
recursive?: boolean
|
||||
} = {}): PersistenceSchemaInventory {
|
||||
const nodes: SchemaNode[] = []
|
||||
const add = (node: SchemaNode): number => nodes.push(node) - 1
|
||||
const literal = (value: string): number => add({ kind: 'literal', value })
|
||||
const text = add({ kind: 'primitive', type: 'string' })
|
||||
const number = add({ kind: 'primitive', type: 'number' })
|
||||
const object = (properties: SchemaProperty[]): number => add({ kind: 'object', properties, indices: [] })
|
||||
const property = (name: string, type: number, optional = false): SchemaProperty => ({ name, type, optional })
|
||||
const snapshot = object([property('kind', literal('hooks-codex')), property('form', literal('snapshot')), property('sections', add({ kind: 'array', element: text }))])
|
||||
const notice = object([property('kind', literal('hooks-codex')), property('form', literal('notice')), property('summary', text)])
|
||||
const changing = object([property('kind', literal('changing')), property('value', options.changed ? number : text)])
|
||||
const variants = [snapshot, notice, changing]
|
||||
if (options.ambiguous) variants.push(object([property('kind', literal('changing')), property('extra', text)]))
|
||||
if (options.extraKind) variants.unshift(object([property('kind', literal('tool-registry'))]))
|
||||
if (options.reorder) variants.reverse()
|
||||
const source = add({ kind: 'union', types: variants })
|
||||
const payloadProperties = [property('source', source)]
|
||||
if (options.tuple) payloadProperties.push(property('tuple', add({ kind: 'tuple', elements: (options.changed ? [number, text] : [text, number]).map(type => ({ type, optional: false, rest: false })) })))
|
||||
if (options.recursive) payloadProperties.push(property('next', nodes.length, true))
|
||||
const payload = object(payloadProperties)
|
||||
const roots = ['agent/inbox/spliced', 'user/message'].map((event) => {
|
||||
const data = event === 'agent/inbox/spliced' ? object([property('inserted', add({ kind: 'array', element: payload }))]) : payload
|
||||
const index = object([property('type', literal(event)), property('data', data)])
|
||||
const schema = canonicalizeSchema(nodes, index)
|
||||
return { key: `event:${event}`, event, kind: 'event' as const, surface: false, digest: schemaDigest(schema), schema }
|
||||
})
|
||||
const types = [...new Map(nodes.map((_, index) => {
|
||||
const schema = canonicalizeSchema(nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
const ordinal = index === snapshot ? options.extraKind ? 107 : 106 : index === notice ? options.extraKind ? 108 : 107 : undefined
|
||||
return [digest, { digest, schema, names: ordinal === undefined ? [] : [`event:agent/inbox/spliced.data.inserted[0].source[${String(ordinal)}]`], sources: [] }]
|
||||
})).values()]
|
||||
return { formatVersion: 1, roots, types }
|
||||
}
|
||||
|
||||
describe('persistence review', () => {
|
||||
it('reports a new kind once across containing roots and never renames unchanged ordinal 107 alternatives', () => {
|
||||
const before = fixture()
|
||||
const after = fixture({ extraKind: true })
|
||||
const saved = JSON.stringify([before, after])
|
||||
const old107 = before.types.find(type => type.names.some(name => name.endsWith('[107]')))
|
||||
const new107 = after.types.find(type => type.names.some(name => name.endsWith('[107]')))
|
||||
expect(old107?.digest).not.toBe(new107?.digest)
|
||||
expect(after.types.find(type => type.digest === old107?.digest)?.names[0]).toMatch(/\[108\]$/u)
|
||||
const report = reviewPersistenceSchemas(before, after)
|
||||
expect(report.evidence).toHaveLength(1)
|
||||
expect(report.evidence[0]).toMatchObject({ kind: 'kind-added', before: null, locations: [
|
||||
{ root: 'event:agent/inbox/spliced', path: 'event:agent/inbox/spliced.data.inserted[].source[kind="tool-registry"]' },
|
||||
{ root: 'event:user/message', path: 'event:user/message.data.source[kind="tool-registry"]' },
|
||||
] })
|
||||
const markdown = renderPersistenceReview(report)
|
||||
expect(markdown).not.toContain('hooks-codex')
|
||||
expect(markdown).not.toMatch(/source\[\d+\]/u)
|
||||
for (const root of report.roots) expect(root.changes).toEqual(classifyPersistenceChange(
|
||||
before.roots.find(item => item.key === root.root)!, after.roots.find(item => item.key === root.root)!,
|
||||
))
|
||||
expect(JSON.stringify([before, after])).toBe(saved)
|
||||
expect(reviewPersistenceSchemas(before, {
|
||||
...fixture({ extraKind: true, reorder: true }), roots: [...after.roots].reverse(), types: [...after.types].reverse(),
|
||||
})).toEqual(report)
|
||||
})
|
||||
|
||||
it('shows existing kind changes alongside additions without weakening their compatibility result', () => {
|
||||
const report = reviewPersistenceSchemas(fixture(), fixture({ extraKind: true, changed: true }))
|
||||
expect(report.evidence.map(item => item.kind).sort()).toEqual(['kind-added', 'type-changed'])
|
||||
expect(report.evidence.find(item => item.kind === 'type-changed')?.locations[0]?.path).toBe('event:agent/inbox/spliced.data.inserted[].source[kind="changing"].value')
|
||||
expect(report.roots.every(root => root.changes.some(change => change.requiresVersionBump))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps ambiguous kind matches as additions and removals even when another arm is unchanged', () => {
|
||||
const report = reviewPersistenceSchemas(fixture({ ambiguous: true }), fixture({ ambiguous: true, changed: true }))
|
||||
expect(report.evidence.map(item => item.kind).sort()).toEqual(['variant-added', 'variant-removed'])
|
||||
expect(report.evidence.every(item => item.locations.every(location => !location.path.endsWith('.value')))).toBe(true)
|
||||
})
|
||||
|
||||
it('retains numeric tuple positions and stops recursion without losing sibling evidence', () => {
|
||||
const report = reviewPersistenceSchemas(
|
||||
fixture({ tuple: true, recursive: true }), fixture({ changed: true, tuple: true, recursive: true }),
|
||||
)
|
||||
const paths = report.evidence.flatMap(item => item.locations.map(location => location.path))
|
||||
expect(paths).toContain('event:user/message.data.tuple[0]')
|
||||
expect(paths).toContain('event:user/message.data.tuple[1]')
|
||||
expect(paths.every(path => path.length < 200)).toBe(true)
|
||||
})
|
||||
|
||||
it('separates new roots from structural definitions and preserves classifier diagnostics', () => {
|
||||
const before = fixture()
|
||||
const after = { ...before, roots: before.roots.slice(1) }
|
||||
const report = reviewPersistenceSchemas(before, after)
|
||||
expect(report.roots).toHaveLength(1)
|
||||
expect(report.roots[0]?.changes).toEqual(classifyPersistenceChange(before.roots[0]!, null))
|
||||
expect(report.evidence).toEqual([])
|
||||
expect(renderPersistenceReview(report)).toContain('Root removed: `event:agent/inbox/spliced`')
|
||||
})
|
||||
|
||||
it('reads explicit files without writes and rejects invalid inventories and arguments', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'persistence-review-'))
|
||||
try {
|
||||
const before = join(directory, 'before.json')
|
||||
const after = join(directory, 'after.json')
|
||||
const oldText = JSON.stringify(fixture())
|
||||
const newText = JSON.stringify(fixture({ extraKind: true }))
|
||||
writeFileSync(before, oldText)
|
||||
writeFileSync(after, newText)
|
||||
const args = ['--before', before, '--after', after]
|
||||
expect(JSON.parse(runPersistenceReview([...args, '--json']))).toEqual(reviewPersistenceSchemas(fixture(), fixture({ extraKind: true })))
|
||||
expect(runPersistenceReview(args)).toContain('# Persistence schema review')
|
||||
expect(readFileSync(before, 'utf8')).toBe(oldText)
|
||||
expect(readFileSync(after, 'utf8')).toBe(newText)
|
||||
expect(() => runPersistenceReview(['--before', before])).toThrow(/--after/u)
|
||||
expect(() => runPersistenceReview([...args, '--write'])).toThrow()
|
||||
writeFileSync(after, '{"formatVersion":99}')
|
||||
expect(() => runPersistenceReview(args)).toThrow()
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Read-only structural explanations alongside the authoritative persistence classification. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { classifyPersistenceChange, parsePersistenceSnapshot, type PersistenceTypeChange } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest, type CanonicalSchema, type PersistenceSchemaInventory, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { sourceKindGroups } from './persistence-source-policy.ts'
|
||||
|
||||
/** One structural observation, shared across its containing roots. */
|
||||
export interface PersistenceReviewEvidence {
|
||||
readonly kind: string
|
||||
readonly field?: string
|
||||
readonly before: string | null
|
||||
readonly after: string | null
|
||||
readonly locations: readonly { readonly root: string; readonly path: string }[]
|
||||
}
|
||||
|
||||
/** Human explanations cannot override the unchanged classifier results carried per root. */
|
||||
export interface PersistenceReview {
|
||||
readonly schemaVersion: 1
|
||||
readonly roots: readonly {
|
||||
readonly root: string
|
||||
readonly before: string | null
|
||||
readonly after: string | null
|
||||
readonly changes: readonly PersistenceTypeChange[]
|
||||
}[]
|
||||
readonly evidence: readonly PersistenceReviewEvidence[]
|
||||
readonly unchangedTypes: number
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function selector(schema: CanonicalSchema, index: number): string | undefined {
|
||||
const node = schema.nodes[index]
|
||||
if (node?.kind !== 'object') return undefined
|
||||
const parts = ['kind', 'type', 'role', 'form'].flatMap((name) => {
|
||||
const property = node.properties.find(property => property.name === name && !property.optional)
|
||||
const value = property === undefined ? undefined : schema.nodes[property.type]
|
||||
return value?.kind === 'literal' && typeof value.value === 'string' ? [`[${name}=${JSON.stringify(value.value)}]`] : []
|
||||
})
|
||||
return parts.length === 0 ? undefined : parts.join('')
|
||||
}
|
||||
|
||||
function propertyPath(path: string, name: string): string {
|
||||
return /^[A-Za-z_$][\w$]*$/u.test(name) ? `${path}.${name}` : `${path}[${JSON.stringify(name)}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare explicit inventories without editing records or changing compatibility decisions.
|
||||
* @param before - parsed inventory from the chosen base.
|
||||
* @param after - parsed inventory from the chosen head.
|
||||
* @returns deduplicated structural evidence and exact classifier output for every changed root.
|
||||
*/
|
||||
export function reviewPersistenceSchemas(before: PersistenceSchemaInventory, after: PersistenceSchemaInventory): PersistenceReview {
|
||||
const oldRoots = new Map(before.roots.map(root => [root.key, root]))
|
||||
const newRoots = new Map(after.roots.map(root => [root.key, root]))
|
||||
const evidence = new Map<string, Omit<PersistenceReviewEvidence, 'locations'> & { locations: Map<string, { root: string; path: string }> }>()
|
||||
const roots = [...new Set([...oldRoots.keys(), ...newRoots.keys()])].sort().flatMap((root) => {
|
||||
const oldRoot = oldRoots.get(root) ?? null
|
||||
const newRoot = newRoots.get(root) ?? null
|
||||
const changes = classifyPersistenceChange(oldRoot, newRoot)
|
||||
if (changes.length === 0) return []
|
||||
if (oldRoot !== null && newRoot !== null) explain(oldRoot.schema, newRoot.schema, root)
|
||||
return [{ root, before: oldRoot?.digest ?? null, after: newRoot?.digest ?? null, changes }]
|
||||
})
|
||||
|
||||
function explain(oldSchema: CanonicalSchema, newSchema: CanonicalSchema, root: string): void {
|
||||
const oldHashes = new Map<number, string>()
|
||||
const newHashes = new Map<number, string>()
|
||||
const digest = (side: 'before' | 'after', index: number): string => {
|
||||
const cache = side === 'before' ? oldHashes : newHashes
|
||||
let hash = cache.get(index)
|
||||
if (hash === undefined) {
|
||||
hash = schemaDigest(canonicalizeSchema((side === 'before' ? oldSchema : newSchema).nodes, index))
|
||||
cache.set(index, hash)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
const emit = (kind: string, left: number | null, right: number | null, path: string, field?: string): void => {
|
||||
const change = { kind, ...(field === undefined ? {} : { field }), before: left === null ? null : digest('before', left), after: right === null ? null : digest('after', right) }
|
||||
const key = JSON.stringify(change)
|
||||
const item = evidence.get(key) ?? { ...change, locations: new Map() }
|
||||
item.locations.set(JSON.stringify([root, path]), { root, path })
|
||||
evidence.set(key, item)
|
||||
}
|
||||
const walk = (left: number, right: number, path: string, ancestors: ReadonlySet<string>): void => {
|
||||
if (digest('before', left) === digest('after', right)) return
|
||||
const pair = `${left}:${right}`
|
||||
if (ancestors.has(pair)) return
|
||||
const active = new Set(ancestors).add(pair)
|
||||
const oldNode = oldSchema.nodes[left] as SchemaNode
|
||||
const newNode = newSchema.nodes[right] as SchemaNode
|
||||
if (oldNode.kind === 'object' && newNode.kind === 'object') {
|
||||
const oldProperties = new Map(oldNode.properties.map(property => [property.name, property]))
|
||||
const newProperties = new Map(newNode.properties.map(property => [property.name, property]))
|
||||
for (const name of [...new Set([...oldProperties.keys(), ...newProperties.keys()])].sort()) {
|
||||
const previous = oldProperties.get(name)
|
||||
const next = newProperties.get(name)
|
||||
const child = propertyPath(path, name)
|
||||
if (previous === undefined || next === undefined) {
|
||||
emit(previous === undefined ? 'property-added' : 'property-removed', left, right, child, name)
|
||||
continue
|
||||
}
|
||||
if (previous.optional !== next.optional) emit(next.optional ? 'property-made-optional' : 'property-made-required', left, right, child, name)
|
||||
if (JSON.stringify(previous.compatibility) !== JSON.stringify(next.compatibility)) emit('source-policy-changed', left, right, child, name)
|
||||
walk(previous.type, next.type, child, active)
|
||||
}
|
||||
const oldIndices = new Map(oldNode.indices.map(index => [digest('before', index.key), index]))
|
||||
const newIndices = new Map(newNode.indices.map(index => [digest('after', index.key), index]))
|
||||
if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(key => !newIndices.has(key))) emit('index-signature-changed', left, right, path)
|
||||
for (const [key, index] of oldIndices) {
|
||||
const next = newIndices.get(key)
|
||||
if (next !== undefined) walk(index.value, next.value, `${path}[*]`, active)
|
||||
}
|
||||
} else if (oldNode.kind === 'array' && newNode.kind === 'array') {
|
||||
walk(oldNode.element, newNode.element, `${path}[]`, active)
|
||||
} else if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
|
||||
if (oldNode.elements.length !== newNode.elements.length) emit('tuple-length-changed', left, right, path)
|
||||
for (const [position, previous] of oldNode.elements.entries()) {
|
||||
const next = newNode.elements[position]
|
||||
if (next === undefined) continue
|
||||
const child = `${path}[${String(position)}]`
|
||||
if (previous.optional !== next.optional || previous.rest !== next.rest) emit('tuple-element-cardinality-changed', left, right, child)
|
||||
walk(previous.type, next.type, child, active)
|
||||
}
|
||||
} else if (oldNode.kind === 'union' && newNode.kind === 'union') {
|
||||
const oldByHash = new Map(oldNode.types.map(index => [digest('before', index), index]))
|
||||
const newByHash = new Map(newNode.types.map(index => [digest('after', index), index]))
|
||||
const removed = [...oldByHash].filter(([hash]) => !newByHash.has(hash)).map(([, index]) => index)
|
||||
const added = [...newByHash].filter(([hash]) => !oldByHash.has(hash)).map(([, index]) => index)
|
||||
// Uniqueness includes unchanged arms: a shared discriminator cannot identify a changed sibling.
|
||||
const unique = (schema: CanonicalSchema, indices: readonly number[]): Map<string, number> => {
|
||||
const candidates = new Map<string, number[]>()
|
||||
for (const index of indices) {
|
||||
const key = selector(schema, index)
|
||||
if (key !== undefined) candidates.set(key, [...candidates.get(key) ?? [], index])
|
||||
}
|
||||
return new Map([...candidates].flatMap(([key, values]) => values.length === 1 ? [[key, values[0] as number]] : []))
|
||||
}
|
||||
const oldSelectors = unique(oldSchema, oldNode.types)
|
||||
const newSelectors = unique(newSchema, newNode.types)
|
||||
const matchedOld = new Set<number>()
|
||||
const matchedNew = new Set<number>()
|
||||
for (const [key, previous] of oldSelectors) {
|
||||
const next = newSelectors.get(key)
|
||||
if (next === undefined || !removed.includes(previous) || !added.includes(next)) continue
|
||||
walk(previous, next, path + key, active)
|
||||
matchedOld.add(previous)
|
||||
matchedNew.add(next)
|
||||
}
|
||||
const oldKinds = sourceKindGroups(oldSchema.nodes, left)
|
||||
const newKinds = sourceKindGroups(newSchema.nodes, right)
|
||||
for (const [side, indices, matched, schema] of [
|
||||
['before', removed, matchedOld, oldSchema], ['after', added, matchedNew, newSchema],
|
||||
] as const) {
|
||||
for (const index of indices) {
|
||||
if (matched.has(index)) continue
|
||||
const ownKinds = side === 'before' ? oldKinds : newKinds
|
||||
const otherKinds = side === 'before' ? newKinds : oldKinds
|
||||
const kind = [...ownKinds ?? []].find(([, members]) => members.includes(index))?.[0]
|
||||
const wholeKind = kind !== undefined && otherKinds !== undefined && !otherKinds.has(kind)
|
||||
const suffix = selector(schema, index) ?? ''
|
||||
emit(`${wholeKind ? 'kind' : 'variant'}-${side === 'before' ? 'removed' : 'added'}`,
|
||||
side === 'before' ? index : null, side === 'after' ? index : null, path + suffix)
|
||||
}
|
||||
}
|
||||
} else emit('type-changed', left, right, path)
|
||||
}
|
||||
walk(0, 0, root, new Set())
|
||||
}
|
||||
|
||||
const oldTypes = new Set(before.types.map(type => type.digest))
|
||||
return { schemaVersion: 1, roots, unchangedTypes: after.types.filter(type => oldTypes.has(type.digest)).length,
|
||||
evidence: [...evidence].sort(([a], [b]) => compareText(a, b)).map(([, item]) => ({ ...item,
|
||||
locations: [...item.locations.values()].sort((a, b) => compareText(a.root, b.root) || compareText(a.path, b.path)),
|
||||
})) }
|
||||
}
|
||||
|
||||
function code(value: string): string {
|
||||
return '`' + value.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render concise evidence separately from every authoritative root diagnostic.
|
||||
* @param report - result of the explicit inventory comparison.
|
||||
* @returns Markdown with full fingerprints in expandable evidence.
|
||||
*/
|
||||
export function renderPersistenceReview(report: PersistenceReview): string {
|
||||
const lines = ['# Persistence schema review', '', `${String(report.roots.length)} changed roots; ${String(report.unchangedTypes)} unchanged type fingerprints.`, '',
|
||||
'## Structural evidence', '', 'Display paths use literal discriminators. Ambiguous alternatives remain separate additions and removals; this explanation does not decide compatibility.', '']
|
||||
for (const root of report.roots) {
|
||||
if (root.before === null || root.after === null) lines.push(`- Root ${root.before === null ? 'added' : 'removed'}: ${code(root.root)}.`)
|
||||
}
|
||||
for (const item of report.evidence) {
|
||||
lines.push('', `<details><summary>${item.kind}${item.field === undefined ? '' : `: ${item.field.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')}`}</summary>`, '',
|
||||
`Before: ${item.before === null ? 'absent' : code(item.before)}; after: ${item.after === null ? 'absent' : code(item.after)}.`, '',
|
||||
...item.locations.map(location => `- ${code(location.path)}`), '', '</details>')
|
||||
}
|
||||
lines.push('', '## Authoritative compatibility results', '')
|
||||
for (const root of report.roots) {
|
||||
lines.push(`### ${code(root.root)}`, '', `Decision: **${root.changes.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version'}**.`, '',
|
||||
...root.changes.map(change => `- ${code(change.path)}: ${change.description} (${code(change.kind)}; requiresVersionBump: ${String(change.requiresVersionBump)}).`), '')
|
||||
}
|
||||
return lines.join('\n').trimEnd() + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read two explicit inventory files and return Markdown or JSON without mutating either.
|
||||
* @param args - --before and --after paths, and optional --json.
|
||||
* @returns structural evidence and exact compatibility results.
|
||||
*/
|
||||
export function runPersistenceReview(args: readonly string[]): string {
|
||||
const { values } = parseArgs({ args: [...args], options: { before: { type: 'string' }, after: { type: 'string' }, json: { type: 'boolean' } }, strict: true, allowPositionals: false })
|
||||
if (values.before === undefined || values.after === undefined) throw new Error('persistence review: --before and --after inventory paths are required')
|
||||
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(JSON.parse(readFileSync(path, 'utf8')))
|
||||
const report = reviewPersistenceSchemas(read(values.before), read(values.after))
|
||||
return values.json ? JSON.stringify(report, null, 2) + '\n' : renderPersistenceReview(report)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
try {
|
||||
process.stdout.write(runPersistenceReview(process.argv.slice(2)))
|
||||
} catch (error: unknown) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode, type SourceCompatibility } from './persistence-schema-model.ts'
|
||||
|
||||
const string: SchemaNode = { kind: 'primitive', type: 'string' }
|
||||
const number: SchemaNode = { kind: 'primitive', type: 'number' }
|
||||
@@ -87,3 +87,23 @@ describe('canonical persisted type graphs', () => {
|
||||
expect(() => canonicalizeSchema([{ kind: 'union', types: [0] }], 0)).toThrow('union cycle')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('policy-aware graph normalization', () => {
|
||||
it('normalizes recorded policy fields and kind order without changing unrelated graph digests', () => {
|
||||
const policy: SourceCompatibility = { version: 1, policy: 'session-source-attribution', binding: 'session.user-message.source',
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['b', 'a'] }
|
||||
const object = (compatibility: SourceCompatibility): SchemaNode => ({ kind: 'object', indices: [], properties: [
|
||||
{ name: 'source', type: 1, optional: false, compatibility },
|
||||
] })
|
||||
const reordered = { attributionKinds: ['a', 'b', 'a'], unknownKinds: policy.unknownKinds, discriminator: policy.discriminator,
|
||||
binding: policy.binding, policy: policy.policy, version: policy.version }
|
||||
const left = canonicalizeSchema([object(policy), string], 0)
|
||||
const right = canonicalizeSchema([object(reordered), string], 0)
|
||||
expect(left).toEqual(right)
|
||||
expect(schemaDigest(left)).toBe(schemaDigest(right))
|
||||
const unannotated = canonicalizeSchema([{ kind: 'object', indices: [], properties: [{ name: 'source', type: 1, optional: false }] }, string], 0)
|
||||
expect(schemaDigest(left)).not.toBe(schemaDigest(unannotated))
|
||||
expect(schemaDigest(canonicalizeSchema(left.nodes, 1))).toBe(schemaDigest(canonicalizeSchema([string], 0)))
|
||||
})
|
||||
})
|
||||
@@ -5,11 +5,22 @@
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** Recorded reader promise for explicitly attributed source additions. */
|
||||
export interface SourceCompatibility {
|
||||
readonly version: 1
|
||||
readonly policy: 'session-source-attribution'
|
||||
readonly binding: 'session.user-message.source' | 'session.developer-message.source'
|
||||
readonly discriminator: 'kind'
|
||||
readonly unknownKinds: 'preserve'
|
||||
readonly attributionKinds: readonly string[]
|
||||
}
|
||||
|
||||
/** One JSON property, with absence represented independently of its value type. */
|
||||
export interface SchemaProperty {
|
||||
readonly name: string
|
||||
readonly type: number
|
||||
readonly optional: boolean
|
||||
readonly compatibility?: SourceCompatibility
|
||||
}
|
||||
|
||||
/** One positional tuple element. */
|
||||
@@ -55,7 +66,7 @@ export interface PersistenceType {
|
||||
|
||||
/** Complete current-source persistence inventory; version pins normalization too. */
|
||||
export interface PersistenceSchemaInventory {
|
||||
readonly formatVersion: 1
|
||||
readonly formatVersion: 1 | 2
|
||||
readonly roots: readonly PersistenceRoot[]
|
||||
readonly types: readonly PersistenceType[]
|
||||
}
|
||||
@@ -85,7 +96,13 @@ function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
|
||||
case 'object': return {
|
||||
kind: 'object',
|
||||
properties: [...node.properties].sort((left, right) => compare(left.name, right.name))
|
||||
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional })),
|
||||
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional,
|
||||
...(property.compatibility === undefined ? {} : { compatibility: {
|
||||
version: property.compatibility.version, policy: property.compatibility.policy, binding: property.compatibility.binding,
|
||||
discriminator: property.compatibility.discriminator, unknownKinds: property.compatibility.unknownKinds,
|
||||
attributionKinds: [...new Set(property.compatibility.attributionKinds)].sort(compare),
|
||||
} }),
|
||||
})),
|
||||
indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
|
||||
.sort((left, right) => left.key - right.key || left.value - right.value),
|
||||
}
|
||||
@@ -215,7 +232,17 @@ function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
|
||||
* @returns lowercase hexadecimal digest.
|
||||
*/
|
||||
export function schemaDigest(schema: CanonicalSchema): string {
|
||||
return createHash('sha256').update('dsh-persistence-schema-v1\n').update(JSON.stringify(schema)).digest('hex')
|
||||
const version = schemaHasCompatibility(schema) ? 2 : 1
|
||||
return createHash('sha256').update(`dsh-persistence-schema-v${String(version)}\n`).update(JSON.stringify(schema)).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify graphs that require the policy-aware fingerprint domain.
|
||||
* @param schema - resolved persisted type.
|
||||
* @returns whether any reachable property records compatibility metadata.
|
||||
*/
|
||||
export function schemaHasCompatibility(schema: CanonicalSchema): boolean {
|
||||
return schema.nodes.some(node => node.kind === 'object' && node.properties.some(property => property.compatibility !== undefined))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectLogEvents } from './gen-persistence-catalog.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -55,6 +56,16 @@ function event(inventory: PersistenceSchemaInventory, name = 'test/record'): str
|
||||
}
|
||||
|
||||
describe('persistent source type extraction', () => {
|
||||
it('keeps authored names and anonymous declaration locations without synthesized traversal names', () => {
|
||||
const root = fixture('interface Section { text: string }\nexport interface Payload { inserted: { source: { kind: "hooks-codex"; sections: Section[] } }[] }')
|
||||
const inventory = extractPersistenceSchema(root)
|
||||
expect(inventory.types.flatMap(type => type.names).every(name => name.includes('#'))).toBe(true)
|
||||
expect(inventory.types.some(type => type.names.includes('packages/domain/payload/src/types.ts#Section'))).toBe(true)
|
||||
expect(inventory.types.some(type => type.names.length === 0 && type.sources.includes('packages/domain/payload/src/types.ts:2'))).toBe(true)
|
||||
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'array' && type.names.length === 0)).toBe(true)
|
||||
expect(parsePersistenceSnapshot(inventory)).toEqual(inventory)
|
||||
})
|
||||
|
||||
it('ignores alias names, files, documentation, readonly, brands and property order', () => {
|
||||
const left = fixture('declare const brand: unique symbol; type Id = string & {readonly [brand]: "Id"}; export interface Payload { readonly id: Id; value?: number }')
|
||||
const right = fixture('type Renamed = string; /** Different documentation. */ export type Payload = {value?: number; id: Renamed}')
|
||||
@@ -322,3 +333,165 @@ interface SessionEventMap {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function sourceFixture(producers: string, role: 'user' | 'developer' = 'user'): string {
|
||||
const root = fixture(`export type Payload = import('../../../llm/llm/src/message.js').${role === 'user' ? 'UserMessage' : 'DeveloperMessage'}`)
|
||||
put(root, 'packages/llm/llm/src/message.ts', `
|
||||
export interface MessageSourceMap {
|
||||
semantic: {kind: 'semantic'; value: string}
|
||||
${producers}
|
||||
}
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
export interface MessageBase {
|
||||
/** @persistenceSource user developer */
|
||||
source: MessageSource
|
||||
}
|
||||
export interface UserMessage extends MessageBase {
|
||||
role: 'user'
|
||||
}
|
||||
export interface DeveloperMessage extends MessageBase {
|
||||
role: 'developer'
|
||||
}
|
||||
`)
|
||||
return root
|
||||
}
|
||||
|
||||
const ATTRIBUTION = '/** @persistenceAttribution */'
|
||||
|
||||
describe('source compatibility authoring', () => {
|
||||
it.each(['user', 'developer'] as const)('records the %s binding and qualifies complete wire-kind groups', (role) => {
|
||||
const before = extractPersistenceSchema(sourceFixture('', role))
|
||||
const after = extractPersistenceSchema(sourceFixture(`${ATTRIBUTION} unrelatedMapKey: {kind: 'attribution'} & ({form?: never} | {form: 'notice'; summary: string})`, role))
|
||||
expect(after.formatVersion).toBe(2)
|
||||
const root = after.roots.find(root => root.kind === 'event')!
|
||||
const property = root.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
|
||||
expect(property.compatibility).toEqual({ version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['attribution'] })
|
||||
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, root))
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
expect(parsePersistenceSnapshot({ ...after, types: [] })).toEqual({ ...after, types: [] })
|
||||
})
|
||||
|
||||
it('binds inherited source declarations only on explicitly eligible required roles', () => {
|
||||
const root = sourceFixture("system: {kind: 'system-prompt'}; model: {kind: 'model'}; tool: {kind: 'tool'}")
|
||||
const core = 'packages/llm/llm/src/message.ts'
|
||||
put(root, core, readFileSync(join(root, core), 'utf8') + `
|
||||
export interface SystemMessage extends MessageBase {role: 'system'; source: MessageSourceMap['system']}
|
||||
export interface AssistantMessage extends MessageBase {role: 'assistant'; source: MessageSourceMap['model']}
|
||||
export interface ToolMessage extends MessageBase {role: 'tool'; source: MessageSourceMap['tool']}
|
||||
export interface UnlistedRole extends MessageBase {role: 'other'}
|
||||
export interface OptionalRole extends MessageBase {role?: 'user'}
|
||||
export interface BroadRole extends MessageBase {role: 'user' | 'developer'}
|
||||
export interface OverriddenUser extends MessageBase {role: 'user'; source: MessageSource}
|
||||
`)
|
||||
put(root, 'packages/domain/payload/src/types.ts', `
|
||||
import type * as M from '../../../llm/llm/src/message.js'
|
||||
export interface Payload {
|
||||
user: M.UserMessage
|
||||
developer: M.DeveloperMessage
|
||||
system: M.SystemMessage
|
||||
assistant: M.AssistantMessage
|
||||
tool: M.ToolMessage
|
||||
bare: M.MessageBase
|
||||
unlisted: M.UnlistedRole
|
||||
optional: M.OptionalRole
|
||||
broad: M.BroadRole
|
||||
override: M.OverriddenUser
|
||||
unrelated: {role: 'user'; source: M.MessageSource}
|
||||
}
|
||||
`)
|
||||
const schema = extractPersistenceSchema(root).roots.find(root => root.kind === 'event')!.schema
|
||||
const payload = schema.nodes.find(node => node.kind === 'object' && node.properties.some(property => property.name === 'unrelated'))!
|
||||
if (payload.kind !== 'object') throw new Error('missing payload fixture')
|
||||
const bindings = Object.fromEntries(payload.properties.map((property) => {
|
||||
const message = schema.nodes[property.type]
|
||||
if (message?.kind !== 'object') throw new Error('missing message fixture')
|
||||
return [property.name, message.properties.find(property => property.name === 'source')?.compatibility?.binding]
|
||||
}))
|
||||
expect(bindings).toEqual({
|
||||
user: 'session.user-message.source', developer: 'session.developer-message.source',
|
||||
system: undefined, assistant: undefined, tool: undefined, bare: undefined, unlisted: undefined,
|
||||
optional: undefined, broad: undefined, override: undefined, unrelated: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps roles outside the annotation list strict', () => {
|
||||
const root = sourceFixture('', 'developer')
|
||||
const core = 'packages/llm/llm/src/message.ts'
|
||||
put(root, core, readFileSync(join(root, core), 'utf8').replace('@persistenceSource user developer', '@persistenceSource user'))
|
||||
const schema = extractPersistenceSchema(root)
|
||||
expect(schema.formatVersion).toBe(1)
|
||||
expect(schema.roots.flatMap(root => root.schema.nodes).flatMap(node => node.kind === 'object' ? node.properties : [])
|
||||
.every(property => property.compatibility === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['', 'user user', 'assistant', 'user developer tool'])('rejects invalid eligible role list %s', (roles) => {
|
||||
const root = sourceFixture('')
|
||||
const core = 'packages/llm/llm/src/message.ts'
|
||||
put(root, core, readFileSync(join(root, core), 'utf8').replace('@persistenceSource user developer', `@persistenceSource ${roles}`))
|
||||
expect(() => extractPersistenceSchema(root)).toThrow('invalid @persistenceSource binding')
|
||||
})
|
||||
|
||||
it('keeps qualification independent of map-key spelling, source order and union-arm order', () => {
|
||||
const left = sourceFixture(`${ATTRIBUTION} left: {kind: 'attribution'} & ({form?: never} | {form: 'notice'; summary: string})`)
|
||||
const right = sourceFixture(`${ATTRIBUTION} right: ({summary: string; form: 'notice'} | {form?: never}) & {kind: 'attribution'}`)
|
||||
expect(extractPersistenceSchema(left).roots).toEqual(extractPersistenceSchema(right).roots)
|
||||
})
|
||||
|
||||
it('finds producer-owned qualifications in independently compiled module augmentations', () => {
|
||||
const root = sourceFixture('')
|
||||
const before = extractPersistenceSchema(root)
|
||||
put(root, 'packages/llm/llm/src/producer.ts', `import './message.js'
|
||||
declare module './message.js' {
|
||||
interface MessageSourceMap {
|
||||
${ATTRIBUTION}
|
||||
catalogKey: {kind: 'external-producer'}
|
||||
}
|
||||
}`)
|
||||
const after = extractPersistenceSchema(root)
|
||||
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, after.roots.find(root => root.kind === 'event')!))
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
})
|
||||
|
||||
it('does not qualify unmarked additions or structurally equal unbound fields', () => {
|
||||
const before = sourceFixture('')
|
||||
const after = sourceFixture("unmarked: {kind: 'new'}")
|
||||
expect(classifyPersistenceChange(extractPersistenceSchema(before).roots.find(root => root.kind === 'event')!,
|
||||
extractPersistenceSchema(after).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
|
||||
const left = fixture("export interface Payload { source: {kind: 'old'} }")
|
||||
const right = fixture("export interface Payload { source: {kind: 'old'} | {kind: 'new'} }")
|
||||
expect(classifyPersistenceChange(extractPersistenceSchema(left).roots.find(root => root.kind === 'event')!,
|
||||
extractPersistenceSchema(right).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[`${ATTRIBUTION} broad: {kind: string}`, 'one literal wire kind'],
|
||||
[`${ATTRIBUTION} optional: {kind?: 'new'}`, 'one literal wire kind'],
|
||||
[`${ATTRIBUTION} ambiguous: {kind: 'a'} | {kind: 'b'}`, 'one literal wire kind'],
|
||||
[`${ATTRIBUTION} duplicate: {kind: 'semantic'}`, 'conflicting attribution'],
|
||||
[`${ATTRIBUTION} first: {kind: 'same'};\n${ATTRIBUTION} second: {kind: 'same'; value: number}`, 'conflicting attribution'],
|
||||
['broadUnmarked: {kind: string}', 'invalid source compatibility'],
|
||||
...['model', 'tool', 'system-prompt', 'compact-checkpoint', 'dsh-session-title-llm', 'tool-registry', 'runtime-context', 'plugin']
|
||||
.map(kind => [`${ATTRIBUTION} forbidden: {kind: '${kind}'}`, 'invalid source compatibility']),
|
||||
["/** @persistenceAttribution yes */ invalid: {kind: 'new'}", 'invalid @persistenceAttribution'],
|
||||
['/** @persistenceAttribution\n * @persistenceAttribution */ duplicateTag: {kind: \'new\'}', 'invalid @persistenceAttribution'],
|
||||
])('rejects invalid producer declaration %s', (producer, error) => {
|
||||
expect(() => extractPersistenceSchema(sourceFixture(producer))).toThrow(error)
|
||||
})
|
||||
|
||||
it.each(['auto-review', 'compact-basic', 'code-mode'])('rejects reintroduced historical kind %s as new attribution', (kind) => {
|
||||
for (const role of ['user', 'developer'] as const) {
|
||||
const root = sourceFixture(`${ATTRIBUTION} reintroduced: {kind: '${kind}'}`, role)
|
||||
expect(() => extractPersistenceSchema(root)).toThrow('invalid source compatibility')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects attribution and binding annotations outside their declared owners', () => {
|
||||
const misplaced = sourceFixture('')
|
||||
put(misplaced, 'packages/domain/payload/src/types.ts', `${ATTRIBUTION} export interface Payload {kind: 'new'}`)
|
||||
expect(() => extractPersistenceSchema(misplaced)).toThrow('must annotate a MessageSourceMap producer property')
|
||||
const source = sourceFixture('')
|
||||
put(source, 'packages/domain/payload/src/types.ts', "export interface Payload {role: 'user';\n/** @persistenceSource user */\nsource: {kind: 'new'}}")
|
||||
expect(() => extractPersistenceSchema(source)).toThrow('invalid @persistenceSource binding')
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,19 @@ import { readFileSync } from 'node:fs'
|
||||
import { dirname, extname, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectLogEvents } from './persistence-catalog-source.ts'
|
||||
import { sourceCompatibilityAnnotations } from './persistence-source-annotations.ts'
|
||||
import { validSourceCompatibility } from './persistence-source-policy.ts'
|
||||
import {
|
||||
canonicalizeSchema,
|
||||
schemaChildren,
|
||||
schemaDigest,
|
||||
schemaHasCompatibility,
|
||||
type PersistenceRoot,
|
||||
type PersistenceSchemaInventory,
|
||||
type PersistenceType,
|
||||
type SchemaNode,
|
||||
type SchemaProperty,
|
||||
type SourceCompatibility,
|
||||
} from './persistence-schema-model.ts'
|
||||
|
||||
interface DeclarationMetadata {
|
||||
@@ -207,6 +211,7 @@ class SchemaExtractor {
|
||||
private readonly declarationMetadata = new Map<number, DeclarationMetadata>()
|
||||
|
||||
private readonly checker: ts.TypeChecker
|
||||
private readonly sourcePolicies: ReadonlyMap<ts.Declaration, readonly SourceCompatibility[]>
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
@@ -214,6 +219,7 @@ class SchemaExtractor {
|
||||
private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
|
||||
) {
|
||||
this.checker = program.getTypeChecker()
|
||||
this.sourcePolicies = sourceCompatibilityAnnotations(root, program)
|
||||
}
|
||||
|
||||
convert(type: ts.Type, site: ts.Node): number {
|
||||
@@ -294,7 +300,11 @@ class SchemaExtractor {
|
||||
if (optional && (propertyType.isUnion() ? propertyType.types : [propertyType])
|
||||
.every(member => (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0)) continue
|
||||
const child = this.valueType(propertyType, declaration, optional)
|
||||
properties.push({ name: property.getName(), type: child, optional })
|
||||
const policies = this.propertyPolicies(type, property, declaration)
|
||||
if (policies.length > 1) this.fail(type, declaration, 'ambiguous source compatibility binding')
|
||||
properties.push({ name: property.getName(), type: child, optional,
|
||||
...(policies[0] === undefined ? {} : { compatibility: policies[0] }),
|
||||
})
|
||||
}
|
||||
const indices = this.checker.getIndexInfosOfType(type).map(info => ({
|
||||
key: this.convert(info.keyType, site),
|
||||
@@ -304,6 +314,19 @@ class SchemaExtractor {
|
||||
return { kind: 'object', properties, indices }
|
||||
}
|
||||
|
||||
private propertyPolicies(type: ts.Type, property: ts.Symbol, site: ts.Node): readonly SourceCompatibility[] {
|
||||
const declared = (property.declarations ?? []).flatMap(declaration => this.sourcePolicies.get(declaration) ?? [])
|
||||
if (declared.length === 0) return []
|
||||
const roleProperty = this.checker.getPropertyOfType(type, 'role')
|
||||
if (roleProperty === undefined || (roleProperty.flags & ts.SymbolFlags.Optional) !== 0) return []
|
||||
const role = this.checker.getTypeOfSymbolAtLocation(roleProperty, site)
|
||||
if (!(role.flags & ts.TypeFlags.StringLiteral)) return []
|
||||
const binding = (role as ts.StringLiteralType).value === 'user' ? 'session.user-message.source'
|
||||
: (role as ts.StringLiteralType).value === 'developer' ? 'session.developer-message.source'
|
||||
: undefined
|
||||
return declared.filter(policy => policy.binding === binding)
|
||||
}
|
||||
|
||||
private rejectClass(type: ts.Type, site: ts.Node): void {
|
||||
const symbol = type.getSymbol()
|
||||
if (symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
|
||||
@@ -354,6 +377,14 @@ class SchemaExtractor {
|
||||
}
|
||||
|
||||
inventory(inputs: readonly RootInput[]): PersistenceSchemaInventory {
|
||||
for (const node of this.nodes) {
|
||||
if (node.kind !== 'object') continue
|
||||
for (const property of node.properties) {
|
||||
if (property.compatibility !== undefined && !validSourceCompatibility(this.nodes, node, property)) {
|
||||
throw new PersistenceSchemaError('persistence schema: invalid source compatibility binding or attribution kinds')
|
||||
}
|
||||
}
|
||||
}
|
||||
const roots = inputs.map((input) => {
|
||||
const schema = canonicalizeSchema(this.nodes, input.node)
|
||||
return {
|
||||
@@ -366,22 +397,13 @@ class SchemaExtractor {
|
||||
}
|
||||
})
|
||||
const found = new Set<number>()
|
||||
const paths = new Map<number, Set<string>>()
|
||||
const visit = (id: number, path: string): void => {
|
||||
const names = paths.get(id) ?? new Set<string>()
|
||||
names.add(path)
|
||||
paths.set(id, names)
|
||||
const visit = (id: number): void => {
|
||||
if (found.has(id)) return
|
||||
found.add(id)
|
||||
const node = this.nodes[id] as SchemaNode
|
||||
if (node.kind === 'object') {
|
||||
for (const property of node.properties) visit(property.type, `${path}.${property.name}`)
|
||||
for (const index of node.indices) { visit(index.key, `${path}.[key]`); visit(index.value, `${path}.[value]`) }
|
||||
} else {
|
||||
for (const [index, child] of schemaChildren(node).entries()) visit(child, `${path}[${String(index)}]`)
|
||||
}
|
||||
for (const child of schemaChildren(node)) visit(child)
|
||||
}
|
||||
for (const input of inputs) visit(input.node, input.key)
|
||||
for (const input of inputs) visit(input.node)
|
||||
const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>()
|
||||
for (const id of found) {
|
||||
const schema = canonicalizeSchema(this.nodes, id)
|
||||
@@ -390,14 +412,10 @@ class SchemaExtractor {
|
||||
const declarationMetadata = this.declarationMetadata.get(id)
|
||||
for (const name of declarationMetadata?.names ?? []) item.names.add(name)
|
||||
for (const source of declarationMetadata?.sources ?? []) item.sources.add(source)
|
||||
const kind = schema.nodes[0]?.kind
|
||||
if (kind !== 'primitive' && kind !== 'literal' && (declarationMetadata === undefined || declarationMetadata.names.size === 0)) {
|
||||
for (const path of paths.get(id) ?? []) item.names.add(path)
|
||||
}
|
||||
types.set(digest, item)
|
||||
}
|
||||
return {
|
||||
formatVersion: 1,
|
||||
formatVersion: roots.some(root => schemaHasCompatibility(root.schema)) ? 2 : 1,
|
||||
roots,
|
||||
types: [...types].sort(([left], [right]) => compare(left, right)).map(([digest, item]) => ({
|
||||
digest,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Resolve producer opt-ins and core-owned source bindings before schema extraction. */
|
||||
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import type { SourceCompatibility } from './persistence-schema-model.ts'
|
||||
|
||||
const CORE_SOURCE = 'packages/llm/llm/src/message.ts'
|
||||
|
||||
/**
|
||||
* Resolve explicit authoring annotations without using type names in recorded history.
|
||||
* @param root - repository root.
|
||||
* @param program - the complete source-only persistence compiler program.
|
||||
* @returns policies keyed by the original source property declaration.
|
||||
*/
|
||||
export function sourceCompatibilityAnnotations(
|
||||
root: string, program: ts.Program,
|
||||
): ReadonlyMap<ts.Declaration, readonly SourceCompatibility[]> {
|
||||
const checker = program.getTypeChecker()
|
||||
const bindings = new Map<ts.Declaration, readonly ('user' | 'developer')[]>()
|
||||
const qualifications = new Set<ts.Node>()
|
||||
const core = program.getSourceFile(resolve(root, CORE_SOURCE))
|
||||
for (const file of program.getSourceFiles()) {
|
||||
const path = relative(root, file.fileName).split(sep).join('/')
|
||||
if (!path.startsWith('packages/') || path.includes('/node_modules/')) continue
|
||||
const visit = (node: ts.Node): void => {
|
||||
for (const tag of ts.getJSDocTags(node)) {
|
||||
if (tag.tagName.text === 'persistenceSource') {
|
||||
const roles = typeof tag.comment === 'string' ? tag.comment.trim().split(/\s+/u) : []
|
||||
if (!ts.isPropertySignature(node) || file !== core || node.name.getText() !== 'source'
|
||||
|| !ts.isInterfaceDeclaration(node.parent) || node.parent.name.text !== 'MessageBase'
|
||||
|| roles.length === 0 || roles.some(role => role !== 'user' && role !== 'developer')
|
||||
|| new Set(roles).size !== roles.length || bindings.has(node)) {
|
||||
throw new Error(`persistence schema: invalid @persistenceSource binding in ${path}`)
|
||||
}
|
||||
bindings.set(node, roles as ('user' | 'developer')[])
|
||||
}
|
||||
if (tag.tagName.text === 'persistenceAttribution') {
|
||||
if (tag.comment !== undefined || qualifications.has(node)) throw new Error(`persistence schema: invalid @persistenceAttribution in ${path}`)
|
||||
qualifications.add(node)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(file)
|
||||
}
|
||||
const sourceMap = core?.statements.find((node): node is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(node) && node.name.text === 'MessageSourceMap')
|
||||
const byKind = new Map<string, boolean>()
|
||||
if (sourceMap !== undefined) {
|
||||
for (const property of checker.getPropertiesOfType(checker.getTypeAtLocation(sourceMap))) {
|
||||
const declarations = property.declarations ?? []
|
||||
const marked = declarations.filter(declaration => qualifications.has(declaration))
|
||||
for (const declaration of marked) qualifications.delete(declaration)
|
||||
if (marked.length !== 0 && marked.length !== declarations.length) throw new Error(`persistence schema: conflicting attribution for ${property.name}`)
|
||||
const site = property.valueDeclaration ?? declarations[0] ?? sourceMap
|
||||
const type = checker.getTypeOfSymbolAtLocation(property, site)
|
||||
const kinds = new Set<string>()
|
||||
for (const alternative of type.isUnion() ? type.types : [type]) {
|
||||
const discriminator = alternative.getProperty('kind')
|
||||
const kind = discriminator === undefined ? undefined : checker.getTypeOfSymbolAtLocation(discriminator, site)
|
||||
if (discriminator === undefined || (discriminator.flags & ts.SymbolFlags.Optional) !== 0 || kind === undefined
|
||||
|| !(kind.flags & ts.TypeFlags.StringLiteral) || (kind as ts.StringLiteralType).value.length === 0) {
|
||||
if (marked.length > 0) throw new Error(`persistence schema: attribution ${property.name} requires one literal wire kind`)
|
||||
continue
|
||||
}
|
||||
kinds.add((kind as ts.StringLiteralType).value)
|
||||
}
|
||||
if (marked.length > 0 && kinds.size !== 1) throw new Error(`persistence schema: attribution ${property.name} requires one literal wire kind`)
|
||||
for (const kind of kinds) {
|
||||
const qualified = marked.length > 0
|
||||
if (byKind.has(kind) && (byKind.get(kind) === true || qualified)) throw new Error(`persistence schema: conflicting attribution for wire kind ${kind}`)
|
||||
byKind.set(kind, qualified)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (qualifications.size > 0) throw new Error('persistence schema: @persistenceAttribution must annotate a MessageSourceMap producer property')
|
||||
const attributionKinds = [...byKind].filter(([, qualified]) => qualified).map(([kind]) => kind).sort()
|
||||
return new Map([...bindings].map(([declaration, roles]) => [declaration, roles.map(role => ({
|
||||
version: 1, policy: 'session-source-attribution', binding: role === 'user' ? 'session.user-message.source' : 'session.developer-message.source',
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds,
|
||||
}))]))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Literal source groups and the supported reader promise saved in schema history. */
|
||||
|
||||
import type { SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
|
||||
|
||||
// Reserve role and shared-consumer identities, frozen rename targets, and request-only producer identities against reuse.
|
||||
// Existing kinds outside this set also reject retroactive qualification during comparison.
|
||||
const RESERVED_SOURCE_KINDS = new Set([
|
||||
'user', 'model', 'tool', 'system-prompt', 'compact-checkpoint', 'dsh-session-title-llm', 'tool-registry', 'runtime-context', 'plugin',
|
||||
'auto-review', 'compact-basic', 'code-mode',
|
||||
])
|
||||
|
||||
/**
|
||||
* Group source alternatives by their required literal wire discriminator.
|
||||
* @param nodes - self-contained type graph.
|
||||
* @param index - source property value.
|
||||
* @returns kind groups, or undefined when any alternative is not a closed source record.
|
||||
*/
|
||||
export function sourceKindGroups(nodes: readonly SchemaNode[], index: number): Map<string, number[]> | undefined {
|
||||
const source = nodes[index]
|
||||
const alternatives = source?.kind === 'union' ? source.types : [index]
|
||||
const groups = new Map<string, number[]>()
|
||||
for (const alternative of alternatives) {
|
||||
const node = nodes[alternative]
|
||||
if (node?.kind !== 'object') return undefined
|
||||
const property = node.properties.find(property => property.name === 'kind')
|
||||
const kind = property === undefined ? undefined : nodes[property.type]
|
||||
if (property?.optional !== false || kind?.kind !== 'literal' || typeof kind.value !== 'string' || kind.value.length === 0) return undefined
|
||||
const group = groups.get(kind.value) ?? []
|
||||
group.push(alternative)
|
||||
groups.set(kind.value, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the supported policy and its containing message/source declaration.
|
||||
* @param nodes - self-contained graph.
|
||||
* @param owner - object carrying the explicitly bound source property.
|
||||
* @param property - bound source property.
|
||||
* @returns whether the metadata authorizes source-group comparison.
|
||||
*/
|
||||
export function validSourceCompatibility(
|
||||
nodes: readonly SchemaNode[], owner: SchemaNode, property: SchemaProperty,
|
||||
): boolean {
|
||||
const policy = property.compatibility
|
||||
if (policy === undefined || property.name !== 'source' || property.optional || owner.kind !== 'object') return false
|
||||
const roleProperty = owner.properties.find(property => property.name === 'role')
|
||||
const role = roleProperty === undefined ? undefined : nodes[roleProperty.type]
|
||||
if (roleProperty?.optional !== false || role?.kind !== 'literal'
|
||||
|| !(role.value === 'user' && policy.binding === 'session.user-message.source'
|
||||
|| role.value === 'developer' && policy.binding === 'session.developer-message.source')) return false
|
||||
const groups = sourceKindGroups(nodes, property.type)
|
||||
return groups !== undefined && policy.attributionKinds.every(kind => groups.has(kind) && !RESERVED_SOURCE_KINDS.has(kind))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that both recorded promises agree and no existing kind changes qualification.
|
||||
* Both inputs are validated v1 policies with fixed policy, discriminator, and preservation fields.
|
||||
* @param before - predecessor promise.
|
||||
* @param after - successor promise.
|
||||
* @param existingKinds - predecessor wire kinds, including semantic groups.
|
||||
* @returns whether the additive rule applies to this transition.
|
||||
*/
|
||||
export function matchingSourceCompatibility(
|
||||
before: SourceCompatibility, after: SourceCompatibility, existingKinds: Iterable<string>,
|
||||
): boolean {
|
||||
return before.binding === after.binding
|
||||
&& [...existingKinds].every(kind => before.attributionKinds.includes(kind) === after.attributionKinds.includes(kind))
|
||||
}
|
||||
@@ -1,8 +1,35 @@
|
||||
/** Linked catalog regressions for transitive changes and shared definitions. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type PersistenceType, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, schemaDigest, type PersistenceRoot, type PersistenceSchemaInventory, type PersistenceType, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
import { render } from './gen-persistence-catalog.ts'
|
||||
import { classifyPersistenceChange } from './persistence-changes.ts'
|
||||
|
||||
function eventRoot(
|
||||
event: string,
|
||||
nodes: readonly SchemaNode[],
|
||||
types: Map<string, PersistenceType>,
|
||||
metadata: (index: number) => Pick<PersistenceType, 'names' | 'sources'>,
|
||||
): PersistenceRoot {
|
||||
nodes.forEach((_, index) => {
|
||||
const schema = canonicalizeSchema(nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
if (!types.has(digest)) types.set(digest, { digest, schema, ...metadata(index) })
|
||||
})
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { key: `event:${event}`, kind: 'event', event, surface: false, digest: schemaDigest(schema), schema }
|
||||
}
|
||||
|
||||
function recursiveFixture(sources: readonly string[] = []): PersistenceSchemaInventory {
|
||||
const schema = canonicalizeSchema([{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }], indices: [] }], 0)
|
||||
const digest = schemaDigest(schema)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
roots: [{ key: 'SessionHeader', kind: 'header', digest, schema }],
|
||||
types: [{ digest, schema, names: ['Recursive'], sources }],
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(optional = true): PersistenceSchemaInventory {
|
||||
const types = new Map<string, PersistenceType>()
|
||||
@@ -14,13 +41,9 @@ function fixture(optional = true): PersistenceSchemaInventory {
|
||||
{ kind: 'object', properties: [{ name: 'reason', type: 4, optional }], indices: [] },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
nodes.forEach((_, index) => {
|
||||
const schema = canonicalizeSchema(nodes, index)
|
||||
const digest = schemaDigest(schema)
|
||||
if (!types.has(digest)) types.set(digest, { digest, schema, names: index === 3 ? ['ErrorInfo'] : [], sources: index === 3 ? ['packages/core/example/src/types.ts:8'] : [] })
|
||||
})
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { key: `event:${event}`, kind: 'event' as const, event, surface: false, digest: schemaDigest(schema), schema }
|
||||
return eventRoot(event, nodes, types, index => ({
|
||||
names: index === 3 ? ['ErrorInfo'] : [], sources: index === 3 ? ['packages/core/example/src/types.ts:8'] : [],
|
||||
}))
|
||||
})
|
||||
return { formatVersion: 1, roots, types: [...types.values()] }
|
||||
}
|
||||
@@ -71,13 +94,7 @@ describe('persistence schema catalog', () => {
|
||||
})
|
||||
|
||||
it('renders a recursive definition through a finite self-reference', () => {
|
||||
const schema = canonicalizeSchema([{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }], indices: [] }], 0)
|
||||
const digest = schemaDigest(schema)
|
||||
const inventory: PersistenceSchemaInventory = {
|
||||
formatVersion: 1,
|
||||
roots: [{ key: 'SessionHeader', kind: 'header', digest, schema }],
|
||||
types: [{ digest, schema, names: ['Recursive'], sources: [] }],
|
||||
}
|
||||
const inventory = recursiveFixture()
|
||||
expect(renderPersistenceSchemaDefinitions(inventory)).toContain('| `next` | optional | [`Recursive`](#persistence-type-recursive) |')
|
||||
})
|
||||
|
||||
@@ -94,3 +111,239 @@ describe('persistence schema catalog', () => {
|
||||
expect(rendered).not.toContain('### `SessionHeader.agentPreset`')
|
||||
})
|
||||
})
|
||||
|
||||
function producerFixture(kinds: readonly string[], events: readonly string[]): PersistenceSchemaInventory {
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const roots = events.map((event) => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: event },
|
||||
{ kind: 'object', properties: [{ name: 'source', type: 3, optional: false }], indices: [] },
|
||||
{ kind: 'union', types: kinds.map((_, index) => 4 + index * 2) },
|
||||
...kinds.flatMap((kind, index): SchemaNode[] => [
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 5 + index * 2, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: kind },
|
||||
]),
|
||||
]
|
||||
return eventRoot(event, nodes, types, index => ({
|
||||
names: index >= 4 && index % 2 === 0 ? [`event:${event}.data.source[${String((index - 4) / 2)}]`] : [], sources: [],
|
||||
}))
|
||||
})
|
||||
return { formatVersion: 1, roots, types: [...types.values()] }
|
||||
}
|
||||
|
||||
function currentDefinitions(inventory: PersistenceSchemaInventory): string {
|
||||
return renderPersistenceSchemaDefinitions(inventory, 'en', undefined, 2, 'current')
|
||||
}
|
||||
|
||||
function assertLinkedOnce(markdown: string): void {
|
||||
const anchors = [...markdown.matchAll(/<a id="([^"]+)"><\/a>/gu)].map(match => match[1])
|
||||
expect(new Set(anchors).size).toBe(anchors.length)
|
||||
for (const match of markdown.matchAll(/\]\(#(persistence-type-[^)]+)\)/gu)) {
|
||||
expect(anchors, `missing target ${match[1]}`).toContain(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
describe('current persistence schema anchors', () => {
|
||||
it('names shared arrays by their declared elements independent of the first reaching root', () => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'array', element: 1 },
|
||||
{ kind: 'object', properties: [{ name: 'text', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const root = eventRoot('second/event', nodes, types, index => ({ names: index === 1 ? ['ContextSnapshotSection'] : [], sources: [] }))
|
||||
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
|
||||
const rendered = currentDefinitions(inventory)
|
||||
expect(rendered).toContain('### `ContextSnapshotSection[]`')
|
||||
const withEarlierRoot = { ...inventory, roots: [{ ...root, key: 'event:first/event' }, root] }
|
||||
expect(currentDefinitions(withEarlierRoot)).toContain('### `ContextSnapshotSection[]`')
|
||||
expect(rendered).not.toContain('### `event:second/event`')
|
||||
assertLinkedOnce(currentDefinitions(withEarlierRoot))
|
||||
})
|
||||
|
||||
it('shows every kind/form arm, including absent, optional, broad and colliding forms', () => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'union', types: [1, 2, 3, 4, 5] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 7, optional: false }, { name: 'summary', type: 8, optional: false }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 7, optional: false }, { name: 'other', type: 8, optional: false }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 8, optional: true }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 9, optional: false }], indices: [{ key: 8, value: 8 }] },
|
||||
{ kind: 'literal', value: 'hook|"quoted' },
|
||||
{ kind: 'literal', value: 'notice' },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
{ kind: 'union', types: [7, 10] },
|
||||
{ kind: 'literal', value: 'snapshot' },
|
||||
]
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const root = eventRoot('source/event', nodes, types, () => ({ names: [], sources: [] }))
|
||||
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
|
||||
const rendered = currentDefinitions(inventory)
|
||||
expect(rendered).toContain('| `hook\\|"quoted` | not declared | none |')
|
||||
expect(rendered).toContain('| `hook\\|"quoted` | `string` (optional) | none |')
|
||||
expect(rendered.match(/\| `hook\\\|"quoted` \|/gu)).toHaveLength(5)
|
||||
expect(rendered).toContain('index signature')
|
||||
expect(rendered).toContain('other }`')
|
||||
expect(rendered).toContain('summary }`')
|
||||
assertLinkedOnce(rendered)
|
||||
const reordered = { ...inventory, types: [...inventory.types].reverse() }
|
||||
expect(currentDefinitions(reordered)).toBe(rendered)
|
||||
})
|
||||
|
||||
it('falls back to complete unions for optional or broad kind discriminators', () => {
|
||||
for (const optional of [true, false]) {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'union', types: [1, 2] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: optional ? 3 : 4, optional }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 3, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: 'example' },
|
||||
{ kind: 'primitive', type: 'string' },
|
||||
]
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const root = eventRoot('broad/event', nodes, types, () => ({ names: [], sources: [] }))
|
||||
const rendered = currentDefinitions({ formatVersion: 1, roots: [root], types: [...types.values()] })
|
||||
expect(rendered).toContain('One of:')
|
||||
expect(rendered).not.toContain('| kind | Form property |')
|
||||
assertLinkedOnce(rendered)
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds labels for anonymous recursive arrays', () => {
|
||||
const schema = canonicalizeSchema([{ kind: 'array', element: 0 }], 0)
|
||||
const digest = schemaDigest(schema)
|
||||
const rendered = currentDefinitions({ formatVersion: 1, roots: [], types: [{ schema, digest, names: [], sources: [] }] })
|
||||
expect(rendered).toContain('### `array[]`')
|
||||
assertLinkedOnce(rendered)
|
||||
expect(rendered.length).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('labels empty objects without padding while preserving structural links and historical labels', () => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: 'empty/event' },
|
||||
{ kind: 'array', element: 3 },
|
||||
{ kind: 'object', properties: [], indices: [] },
|
||||
]
|
||||
const types = new Map<string, PersistenceType>()
|
||||
const root = eventRoot('empty/event', nodes, types, () => ({ names: [], sources: [] }))
|
||||
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
|
||||
const saved = JSON.stringify(inventory)
|
||||
const emptyDigest = schemaDigest(canonicalizeSchema(nodes, 3))
|
||||
const current = currentDefinitions(inventory)
|
||||
expect(current).toContain('### `{}`')
|
||||
expect(current).not.toContain('{ }')
|
||||
expect(current).toContain('[`{}`](#persistence-type-sha256-' + emptyDigest + ')')
|
||||
assertLinkedOnce(current)
|
||||
const historical = renderPersistenceSchemaDefinitions(inventory)
|
||||
expect(historical).toContain('### `event:empty/event.data.item`')
|
||||
expect(historical).toContain('Array of [`event:empty/event.data.item`](#persistence-type-eventemptyeventdataitem).')
|
||||
expect(JSON.stringify(inventory)).toBe(saved)
|
||||
expect(schemaDigest(root.schema)).toBe(root.digest)
|
||||
})
|
||||
|
||||
it('keeps anonymous producer references when siblings and the first owning root change', () => {
|
||||
const before = producerFixture(['alpha', 'beta'], ['second/event'])
|
||||
const after = producerFixture(['new', 'beta', 'alpha'], ['first/event', 'second/event'])
|
||||
const alphaSchema = canonicalizeSchema([
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 1, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: 'alpha' },
|
||||
], 0)
|
||||
const alphaDigest = schemaDigest(alphaSchema)
|
||||
const link = '[`{ kind: "alpha" }`](#persistence-type-sha256-' + alphaDigest + ')'
|
||||
expect(currentDefinitions(before)).toContain(link)
|
||||
expect(currentDefinitions(after)).toContain(link)
|
||||
expect(currentDefinitions(after)).not.toContain('.source[')
|
||||
assertLinkedOnce(currentDefinitions(after))
|
||||
const reordered = { ...after, roots: [...after.roots].reverse(), types: [...after.types].reverse() }
|
||||
expect(currentDefinitions(reordered)).toBe(currentDefinitions(after))
|
||||
})
|
||||
|
||||
it('retains logical root and named aliases while structural links track changed content', () => {
|
||||
const before = fixture()
|
||||
const after = fixture(false)
|
||||
const old = currentDefinitions(before)
|
||||
const updated = currentDefinitions(after)
|
||||
for (const definitions of [old, updated]) {
|
||||
expect(definitions).toContain('<a id="persistence-type-errorinfo"></a>')
|
||||
expect(definitions).toContain('<a id="persistence-type-eventfirstevent"></a>')
|
||||
}
|
||||
expect(old).not.toBe(updated)
|
||||
const document = render([], [], after)
|
||||
expect(document).toContain('](#persistence-type-sha256-' + after.roots[0]!.digest + ')')
|
||||
assertLinkedOnce(document)
|
||||
})
|
||||
|
||||
it('omits ambiguous aliases and keeps colliding labels linked to distinct definitions', () => {
|
||||
const complete = fixture()
|
||||
const first = complete.types[0] as PersistenceType
|
||||
const second = complete.types[1] as PersistenceType
|
||||
const inventory = { ...complete, types: complete.types.map(type => type === first
|
||||
? { ...type, names: ['A.B', 'sha256-' + second.digest] }
|
||||
: type === second ? { ...type, names: ['AB'] } : type) }
|
||||
const definitions = currentDefinitions(inventory)
|
||||
expect(definitions).not.toContain('<a id="persistence-type-ab"></a>')
|
||||
expect(definitions).toContain('<a id="persistence-type-sha256-' + first.digest + '"></a>')
|
||||
expect(definitions).toContain('<a id="persistence-type-sha256-' + second.digest + '"></a>')
|
||||
assertLinkedOnce(definitions)
|
||||
})
|
||||
|
||||
it('renders recursion once and leaves inventories, digests and classifications unchanged', () => {
|
||||
const inventory = recursiveFixture()
|
||||
const { schema, digest } = inventory.roots[0]!
|
||||
const saved = JSON.stringify(inventory)
|
||||
const before = fixture()
|
||||
const after = fixture(false)
|
||||
const classification = classifyPersistenceChange(before.roots[0]!, after.roots[0]!)
|
||||
const rendered = currentDefinitions(inventory)
|
||||
expect(rendered.match(/### `Recursive`/gu)).toHaveLength(1)
|
||||
expect(rendered).toContain('[`Recursive`](#persistence-type-sha256-' + digest + ')')
|
||||
assertLinkedOnce(rendered)
|
||||
currentDefinitions(after)
|
||||
expect(JSON.stringify(inventory)).toBe(saved)
|
||||
expect(schemaDigest(schema)).toBe(digest)
|
||||
expect(classifyPersistenceChange(before.roots[0]!, after.roots[0]!)).toEqual(classification)
|
||||
})
|
||||
|
||||
it('shows recorded source policies only in the current catalog and preserves their fields', () => {
|
||||
const schema = canonicalizeSchema([
|
||||
{ kind: 'object', properties: [{ name: 'source', type: 1, optional: false, compatibility: {
|
||||
version: 1, policy: 'session-source-attribution', binding: 'session.user-message.source',
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['build-context'],
|
||||
} }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'kind', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'literal', value: 'build-context' },
|
||||
], 0)
|
||||
const types = schema.nodes.map((_, index) => {
|
||||
const nested = canonicalizeSchema(schema.nodes, index)
|
||||
return { digest: schemaDigest(nested), schema: nested, names: [], sources: [] }
|
||||
})
|
||||
const inventory: PersistenceSchemaInventory = { formatVersion: 2, roots: [], types }
|
||||
const saved = JSON.stringify(inventory)
|
||||
expect(currentDefinitions(inventory)).toContain('Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.')
|
||||
expect(currentDefinitions(inventory)).toContain('Attribution-only additions: `build-context`.')
|
||||
expect(renderPersistenceSchemaDefinitions(inventory, 'zh', undefined, 2, 'current')).toContain('仅表示归属的新增 kind:`build-context`.')
|
||||
expect(renderPersistenceSchemaDefinitions(inventory)).not.toContain('Source compatibility:')
|
||||
expect(JSON.stringify(inventory)).toBe(saved)
|
||||
})
|
||||
|
||||
it('keeps the captured historical Markdown byte-for-byte in both historical call forms', () => {
|
||||
const inventory = recursiveFixture(['packages/core/example/src/types.ts'])
|
||||
const expected = [
|
||||
'### Persistence type fingerprints', '', 'Frozen reference.', '',
|
||||
'| Root | Kind | SHA-256 | Resolved type |', '|---|---|---|---|',
|
||||
'| `SessionHeader` | header | `c900da63847d1d27572277817e727f3a471d08f0abaf0b61b4984c32ffc6a332` | [`Recursive`](#persistence-type-recursive) |', '',
|
||||
'### Resolved persistence types', '',
|
||||
'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.', '',
|
||||
'<a id="persistence-type-recursive"></a>', '', '#### `Recursive`', '',
|
||||
'SHA-256: `c900da63847d1d27572277817e727f3a471d08f0abaf0b61b4984c32ffc6a332`', '',
|
||||
'Sources: `packages/core/example/src/types.ts`', '',
|
||||
'| Property | Presence | Type |', '|---|---|---|',
|
||||
'| `next` | optional | [`Recursive`](#persistence-type-recursive) |', '',
|
||||
].join('\n')
|
||||
const index = renderPersistenceSchemaIndex(inventory, 'en', ['Frozen reference.'], 3)
|
||||
expect(index + '\n' + renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined, 3)).toBe(expected)
|
||||
expect(renderPersistenceSchemaIndex(inventory, 'en', ['Frozen reference.'], 3, 'historical') + '\n'
|
||||
+ renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined, 3, 'historical')).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { githubSlug } from './verify-md-links.ts'
|
||||
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
|
||||
import { sourceKindGroups } from './persistence-source-policy.ts'
|
||||
import {
|
||||
canonicalizeSchema,
|
||||
schemaChildren,
|
||||
@@ -16,8 +17,12 @@ interface TypeDisplay {
|
||||
readonly type: PersistenceType
|
||||
readonly label: string
|
||||
readonly anchor: string
|
||||
readonly aliases: readonly string[]
|
||||
}
|
||||
|
||||
/** Current catalogs use structural links; historical references retain their original labels and anchors. */
|
||||
export type PersistenceSchemaRendering = 'current' | 'historical'
|
||||
|
||||
function code(text: string): string {
|
||||
return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
|
||||
}
|
||||
@@ -45,7 +50,7 @@ function childPath(node: SchemaNode, position: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
|
||||
function historicalDisplays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
|
||||
const paths = new Map<string, string>()
|
||||
for (const root of inventory.roots) {
|
||||
const seen = new Set<number>()
|
||||
@@ -78,10 +83,87 @@ function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDispla
|
||||
let anchor = base
|
||||
for (let suffix = 2; used.has(anchor); suffix += 1) anchor = `${base}-${String(suffix)}`
|
||||
used.add(anchor)
|
||||
return [type.digest, { type, label, anchor }]
|
||||
return [type.digest, { type, label, anchor, aliases: [] }]
|
||||
}))
|
||||
}
|
||||
|
||||
function structuralLabel(schema: CanonicalSchema, includeFields = false): string {
|
||||
const node = nodeAt(schema, 0)
|
||||
switch (node.kind) {
|
||||
case 'primitive': return node.type
|
||||
case 'literal': return JSON.stringify(node.value)
|
||||
case 'opaque': return node.reason
|
||||
case 'object': {
|
||||
const tags = ['type', 'kind', 'role', 'form'].flatMap((name) => {
|
||||
const property = node.properties.find(candidate => candidate.name === name && !candidate.optional)
|
||||
const value = property === undefined ? undefined : nodeAt(schema, property.type)
|
||||
return value?.kind === 'literal' ? [`${name}: ${JSON.stringify(value.value)}`] : []
|
||||
})
|
||||
const fields = tags.length > 0 && !includeFields ? tags : node.properties.map((property) => {
|
||||
const tag = tags.find(tag => tag.startsWith(property.name + ':'))
|
||||
return tag ?? `${property.name}${property.optional ? '?' : ''}`
|
||||
})
|
||||
if (fields.length === 0) return '{}'
|
||||
return `{ ${fields.slice(0, 4).join(', ')}${fields.length > 4 ? ', …' : ''} }`
|
||||
}
|
||||
case 'array': return 'array'
|
||||
case 'tuple': return `tuple (${String(node.elements.length)} positions)`
|
||||
case 'union': return `union (${String(node.types.length)} variants)`
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
function currentDisplays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
|
||||
const roots = new Map<string, string[]>()
|
||||
for (const root of inventory.roots) roots.set(root.digest, [...roots.get(root.digest) ?? [], root.key])
|
||||
const aliases = new Map<string, Set<string>>()
|
||||
const namesByDigest = new Map(inventory.types.map((type) => {
|
||||
const names = type.names.filter(name => !inventory.roots.some(root => name.startsWith(root.key + '.') || name.startsWith(root.key + '[')))
|
||||
const logicalNames = [...new Set([...names, ...roots.get(type.digest) ?? []])].sort()
|
||||
for (const name of logicalNames) {
|
||||
for (const value of new Set([name, name.slice(name.lastIndexOf('#') + 1)])) {
|
||||
const alias = `persistence-type-${githubSlug(value)}`
|
||||
const owners = aliases.get(alias) ?? new Set<string>()
|
||||
owners.add(type.digest)
|
||||
aliases.set(alias, owners)
|
||||
}
|
||||
}
|
||||
return [type.digest, names.filter(name => !roots.get(type.digest)?.includes(name)).sort()]
|
||||
}))
|
||||
const types = new Map(inventory.types.map(type => [type.digest, type]))
|
||||
const labelFor = (schema: CanonicalSchema, seen: ReadonlySet<string>, includeFields = false): string => {
|
||||
const digest = schemaDigest(schema)
|
||||
const node = nodeAt(schema, 0)
|
||||
if (node.kind === 'primitive' || node.kind === 'literal' || node.kind === 'opaque') return structuralLabel(schema)
|
||||
const named = namesByDigest.get(digest)?.find(name => aliases.get(`persistence-type-${githubSlug(name.slice(name.lastIndexOf('#') + 1))}`)?.size === 1)
|
||||
if (named !== undefined) return named.slice(named.lastIndexOf('#') + 1)
|
||||
if (seen.has(digest) || seen.size >= 4) return structuralLabel(schema, includeFields)
|
||||
if (node.kind === 'array') {
|
||||
const element = canonicalizeSchema(schema.nodes, node.element)
|
||||
const label = labelFor(types.get(schemaDigest(element))?.schema ?? element, new Set(seen).add(digest), includeFields)
|
||||
return `${label}[]`
|
||||
}
|
||||
return structuralLabel(schema, includeFields)
|
||||
}
|
||||
const labels = inventory.types.map(type => ({ type, label: labelFor(type.schema, new Set()), anchor: `persistence-type-sha256-${type.digest}` }))
|
||||
const counts = new Map<string, number>()
|
||||
for (const entry of labels) counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1)
|
||||
for (const entry of labels) {
|
||||
if ((counts.get(entry.label) ?? 0) > 1) entry.label = labelFor(entry.type.schema, new Set(), true)
|
||||
}
|
||||
const structuralAnchors = new Set(labels.map(entry => entry.anchor))
|
||||
return new Map(labels.map(entry => [entry.type.digest, {
|
||||
...entry,
|
||||
// A logical name shared by different structures has no unambiguous target.
|
||||
aliases: [...aliases].filter(([alias, owners]) => owners.size === 1 && owners.has(entry.type.digest) && !structuralAnchors.has(alias))
|
||||
.map(([alias]) => alias).sort(),
|
||||
}]))
|
||||
}
|
||||
|
||||
function displays(inventory: PersistenceSchemaInventory, rendering: PersistenceSchemaRendering): Map<string, TypeDisplay> {
|
||||
return rendering === 'current' ? currentDisplays(inventory) : historicalDisplays(inventory)
|
||||
}
|
||||
|
||||
function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): string {
|
||||
const entry = entries.get(digest)
|
||||
if (!entry) throw new Error(`persistence catalog: reachable type ${digest} is absent from the inventory`)
|
||||
@@ -107,11 +189,12 @@ function definition(
|
||||
locale: PersistenceCatalogLocale,
|
||||
sourceLink: (source: string) => string | undefined,
|
||||
headingLevel: number,
|
||||
rendering: PersistenceSchemaRendering,
|
||||
): string[] {
|
||||
const text = persistenceCatalogText[locale]
|
||||
const schema = entry.type.schema
|
||||
const node = nodeAt(schema, 0)
|
||||
const lines = [`<a id="${entry.anchor}"></a>`, '', `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
|
||||
const lines = [...[entry.anchor, ...entry.aliases].flatMap(anchor => [`<a id="${anchor}"></a>`, '']), `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
|
||||
if (entry.type.sources.length > 0) {
|
||||
lines.push(`${text.sources}${entry.type.sources.map((source) => {
|
||||
const href = sourceLink(sourcePath(source))
|
||||
@@ -129,6 +212,14 @@ function definition(
|
||||
}
|
||||
for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
|
||||
lines.push('')
|
||||
if (rendering === 'current') {
|
||||
for (const property of node.properties) {
|
||||
const policy = property.compatibility
|
||||
if (policy === undefined) continue
|
||||
lines.push(`${text.sourceCompatibility}${code(property.name)} — ${code(policy.policy)} v${String(policy.version)}; ${code(policy.binding)}; ${code(policy.discriminator)}; ${code(policy.unknownKinds)}.`, '',
|
||||
`${text.attributionAdditions}${policy.attributionKinds.map(code).join(', ') || code('[]')}.`, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
|
||||
@@ -140,9 +231,28 @@ function definition(
|
||||
})
|
||||
lines.push('')
|
||||
break
|
||||
case 'union':
|
||||
lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
|
||||
case 'union': {
|
||||
const groups = rendering === 'current' ? sourceKindGroups(schema.nodes, 0) : undefined
|
||||
if (groups !== undefined) {
|
||||
lines.push(text.sourceColumns, '|---|---|---|---|')
|
||||
for (const [kind, alternatives] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const rows = alternatives.map((index) => {
|
||||
const variant = nodeAt(schema, index)
|
||||
if (variant.kind !== 'object') throw new Error('persistence catalog: source group must contain objects')
|
||||
const form = variant.properties.find(property => property.name === 'form')
|
||||
const formLabel = form === undefined ? text.notDeclared
|
||||
: `${expression(form.type)}${form.optional ? ` (${text.optional})` : ''}`
|
||||
const fields = variant.properties.filter(property => property.name !== 'kind' && property.name !== 'form' && !property.optional)
|
||||
.map(property => `${code(property.name)}: ${expression(property.type)}`)
|
||||
if (variant.indices.length > 0) fields.push(text.index)
|
||||
return { index, formLabel, fields, digest: schemaDigest(canonicalizeSchema(schema.nodes, index)) }
|
||||
}).sort((a, b) => a.formLabel.localeCompare(b.formLabel) || a.digest.localeCompare(b.digest))
|
||||
for (const row of rows) lines.push(`| ${code(kind)} | ${row.formLabel} | ${row.fields.join('; ') || text.none} | ${expression(row.index)} |`)
|
||||
}
|
||||
lines.push('')
|
||||
} else lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
|
||||
break
|
||||
}
|
||||
case 'primitive': lines.push(code(node.type), ''); break
|
||||
case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
|
||||
case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
|
||||
@@ -157,6 +267,7 @@ function definition(
|
||||
* @param locale - generated document language.
|
||||
* @param introduction - paragraphs before the root table; defaults to current-source links.
|
||||
* @param headingLevel - section depth within the containing reference.
|
||||
* @param rendering - explicit current mode selects structural links; omission preserves historical output.
|
||||
* @returns Markdown index including the history and contributor workflow links.
|
||||
*/
|
||||
export function renderPersistenceSchemaIndex(
|
||||
@@ -164,8 +275,9 @@ export function renderPersistenceSchemaIndex(
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
introduction: readonly string[] = [persistenceCatalogText[locale].fingerprintsIntro, persistenceCatalogText[locale].historyIntro],
|
||||
headingLevel: 2 | 3 = 2,
|
||||
rendering: PersistenceSchemaRendering = 'historical',
|
||||
): string {
|
||||
const entries = displays(inventory)
|
||||
const entries = displays(inventory, rendering)
|
||||
const text = persistenceCatalogText[locale]
|
||||
return [
|
||||
`${'#'.repeat(headingLevel)} ${text.fingerprints}`, '', ...introduction.flatMap(paragraph => [paragraph, '']),
|
||||
@@ -180,20 +292,25 @@ export function renderPersistenceSchemaIndex(
|
||||
* @param locale - generated document language.
|
||||
* @param sourceLink - source path to URL; undefined keeps historical locations as text.
|
||||
* @param headingLevel - section depth; individual definitions use the next heading level.
|
||||
* @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
|
||||
* @param rendering - explicit current mode selects structural links and unambiguous logical aliases.
|
||||
* @returns linked Markdown definitions; historical mode preserves the original named and owning-path anchors.
|
||||
*/
|
||||
export function renderPersistenceSchemaDefinitions(
|
||||
inventory: PersistenceSchemaInventory,
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
sourceLink: (source: string) => string | undefined = source => `../${source}`,
|
||||
headingLevel: 2 | 3 = 2,
|
||||
rendering: PersistenceSchemaRendering = 'historical',
|
||||
): string {
|
||||
const entries = displays(inventory)
|
||||
const entries = displays(inventory, rendering)
|
||||
const text = persistenceCatalogText[locale]
|
||||
const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
|
||||
const sorted = [...entries.values()].sort((left, right) => {
|
||||
if (rendering === 'current' && left.label !== right.label) return left.label < right.label ? -1 : 1
|
||||
return left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0
|
||||
})
|
||||
return [
|
||||
`${'#'.repeat(headingLevel)} ${text.definitions}`, '', text.definitionsIntro, '',
|
||||
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1)),
|
||||
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1, rendering)),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user