docs(session): backfill and gate persistence format references

This commit is contained in:
Tianyi Cui committed 2026-09-13 12:08:07 +08:00
1 parent 99a73ca1d4
commit b08f6c421b
47 files changed
+187380 -25

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: f82671f24b5a09050703493c35aaed82842f6399
2026-09-11-persistence-type-history.zh.md: b48c1bae3990ac41366e5347580b516704d07a32
2026-09-11-persistence-type-history.md: fc7374abb4fb6502efb3830c9d38567a076e1647
2026-09-11-persistence-type-history.zh.md: 1b8ba5ed74fc757b987d420a1707b57177bb206f
@@ -20,6 +20,8 @@ The new document kind preserves the compatibility reasoning and evidence for a h
Retrospective [release comparisons](../../../../docs/persistence-changes/releases/README.md) use a separate `persistence-release` kind and pinned tag manifest. They retain changed after schemas and reconstruct adjacent releases, including unchanged tags. Earlier releases predate these acknowledgement rules, so their classifications are informational and their observed versions are not rewritten to satisfy a modern version floor. Historical parsing preserves optional surface operations; the current parser and acknowledgement chain remain strict. This separation permits approximate backfills without turning them into authorization for current changes.
The [format references](../../../../docs/persistence-formats/README.md) preserve complete inventories for every historical integer below the writer constant. The current writer retains the existing generated catalog. Coverage is derived from the constant rather than a hand-maintained version manifest, so advancing the writer requires archiving its predecessor. Source checkpoints remain explicit because event additions and optional payload changes can occur within one format; these snapshots do not authorize current changes or claim migration safety. Historical identifiers are retained in machine schemas and exactly generated schema regions, while current terminology checks still cover the surrounding authored prose.
Record commands generate the bilingual catalog and consistency records from repository-owned templates. Authors can supply the two languages' summary, compatibility reasoning, and actual verification evidence as structured input. Generation supplies identifiers, digests, and snapshots; it never invents a compatibility explanation or test result. Structured check output retains a nonzero failure exit status and reports stable change kinds independently of human-readable descriptions.
Explicit update refreshes an unaccepted terminal record without deleting its authored prose. It recomputes the transition against the remaining history and rejects the baseline or any record with dependants. Review acceptance is not a fact available from the tree, so authors preserve accepted records and add successors.
@@ -20,6 +20,8 @@ Status: implemented
回溯性的[发行版本比较](../../../../docs/persistence-changes/releases/README.zh.md)使用独立的 `persistence-release` 类型和固定 tag 清单。它们保留变化后的 schema,并重建相邻版本,包括未变的 tag。早期版本先于这些确认规则,因此其分类只提供信息,观察到的版本号不会为满足当前最低版本要求而被改写。历史解析保留可选的 surface 操作;当前解析器和确认链仍保持严格。这一区分允许近似回填,同时不会将其变成当前变更的授权。
[格式参考](../../../../docs/persistence-formats/README.zh.md)为低于写入器常量的每个历史整数版本保留完整目录。当前写入器继续使用现有生成目录。覆盖范围由常量推导,而非手工维护的版本清单,因此推进写入器必须归档其前驱。同一格式内可能新增事件或改变可选载荷,因此来源检查点保持明确;这些快照不授权当前变更,也不声称迁移安全。机器 schema 与精确生成的 schema 区间保留历史标识符,当前术语检查仍覆盖周围的人工说明。
记录命令从仓库内模板生成双语目录和一致性记录。作者可以将两种语言的概述、兼容性说明和实际验证证据作为结构化输入。生成过程提供标识符、摘要和快照,不编造兼容性说明或测试结果。结构化检查输出保留非零失败退出码,并报告独立于可读描述的稳定变更种类。
显式更新可刷新尚未接受的末端记录,无需删除其人工说明。它根据剩余历史重新计算转换,并拒绝基线或已有依赖方的记录。审阅接受状态不是目录内可得的事实,因此作者保留已接受记录并添加后继。
+2 -1
View File
@@ -56,6 +56,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in
- `package-bundle` → [templates/package-bundle.md](templates/package-bundle.md): a package declaring `dsh.bundle.patch` — the verified `dsh plugin` install path, layer semantics, patch document.
- `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas.
- `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement.
- `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-formats/`, with complete schemas; the current writer uses the existing catalog.
Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it.
@@ -66,7 +67,7 @@ These rules decide what a section may say. They apply to every authored human-fa
- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does.
- **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds.
- **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose.
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change` and `persistence-release` records retain type-transition evidence; release comparisons describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope.
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope.
- **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall.
## Quality criteria
@@ -29,7 +29,7 @@ kind: "package-reference"
## The kind system
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
1. The README is `packages/README.md` or `packages/<group>/README.md` → `package-group`.
2. The package manifest declares `dsh.bundle.patch` → `package-bundle`.
@@ -44,6 +44,7 @@ kind: "package-reference"
| `package-bundle` | `packages/<group>/<package>/README.md` declaring `dsh.bundle.patch` | [package-bundle.md](../templates/package-bundle.md) | Installable layer: the verified `dsh plugin` install path, layer semantics, and patch document. |
| `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. |
| `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. |
| `persistence-format` | `docs/persistence-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. |
Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile <name> add <package>` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it.
@@ -0,0 +1,25 @@
# Template: persistence-format
Use this kind for `docs/persistence-formats/vN.md` and its Chinese sibling. Each record describes one explicitly selected historical Session format checkpoint with complete declared types. It is a format reference, not a release record or compatibility acknowledgement. The checkout writer uses the existing current catalog.
## Frontmatter
```yaml
---
description: "The historical Session format, source checkpoint, and persistence types documented here."
kind: persistence-format
---
```
## Sections
1. **Summary:** identify the format and its distinguishing persisted fields.
2. **Table of Contents:** link subsequent sections with shared bilingual anchors.
3. **Source evidence:** name the tag or PR and enough context to select the source tree. A PR's intermediate tree must not be presented as its final merge or as a product release. Historical source locations remain text unless linked to the exact source.
4. **Format characteristics:** explain header, event, and physical encoding distinctions; link codec owners for behavior beyond the declared-type inventory.
5. **Declaration:** include one identical `yaml persistence-format` block per language with `schemaVersion: 1`, numeric `sessionFormatVersion`, `source` containing exactly one `tag` or numeric `pullRequest`, and a `roots` mapping from every root key to its captured SHA-256 digest. Link the sibling `vN.schema.json`.
6. **Complete schemas:** keep one `persistence-format-schema:start` / `persistence-format-schema:end` comment region. The format verifier renders root digests and all reachable definitions here.
7. **Verification and limitations:** state actual extraction evidence, coverage limits, and the offline verifier. Preserve historical optional fields and opaque declarations without claiming codec admission or migration safety.
8. **Dev Note:** reserve non-authoritative working context, or write `None.`.
The [format index](../../../../docs/persistence-formats/README.md) owns coverage and maintenance. `pnpm run verify-persistence-formats --write` validates schemas before refreshing generated regions and pairing records. It cannot create historical evidence or repair machine data. Keep every older integer documented when the current writer advances; do not duplicate the current catalog under a historical filename.
+1
View File
@@ -16,3 +16,4 @@
docs/persistence-schema.json linguist-generated=true
docs/persistence-changes/*.schema.json linguist-generated=true
docs/persistence-changes/releases/*.schema.json linguist-generated=true
docs/persistence-formats/*.schema.json linguist-generated=true
+1 -1
View File
@@ -24,7 +24,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there.
| [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) |
| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
| [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |
| [persistence-changes/](persistence-changes/README.md) | Type-change acknowledgements and retrospective release comparisons, with schema companions | Behavior-only changes; current runtime contracts |
| [Persistence history](persistence-changes/README.md) and [format references](persistence-formats/README.md) | Type-change acknowledgements, release comparisons, and complete historical format schemas | Behavior-only changes; current runtime contracts |
| [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) |
| [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history |
| Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns |
@@ -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/adding-a-session-format-version.md
adding-a-session-format-version.md: 54af2806bf7193ce9d1f5cd6c75f92b809c4ed95
adding-a-session-format-version.zh.md: 4f7135b00aba37758343b8c17f2b4aff3146eeab
adding-a-session-format-version.md: bf5521a1f42a846ae1ff8e8e6896864397efc4a6
adding-a-session-format-version.zh.md: af575db0e0db620ab8f6bda008bb3a4871063d3c
@@ -27,6 +27,8 @@ Released codecs and migration semantics remain frozen. Do not amend a released e
Use disposable, isolated Harness homes for unreleased N+1 integration testing. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
Before changing the writer, archive its complete persistence schema and bilingual format reference under `docs/persistence-formats/vN.*`, preserving all earlier records. The [format coverage check](../persistence-formats/README.md#maintenance) requires every integer below the new writer to have its own document; the current generated catalog covers only the new writer.
<a id="add-an-identity-edge"></a>
## 2. Add an identity edge
@@ -27,6 +27,8 @@
未发布 N+1 的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
变更写入器之前,将其完整持久化 schema 与双语格式参考归档到 `docs/persistence-formats/vN.*`,并保留所有更早的记录。[格式覆盖检查](../persistence-formats/README.zh.md#maintenance)要求低于新写入器的每个整数版本都有独立文档;当前生成的目录只覆盖新写入器。
<a id="add-an-identity-edge"></a>
## 2. 添加恒等迁移边
+2 -2
View File
@@ -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: 7501b63b8e5fbf1cf7c97f1ac810244e534413fe
persistence-catalog.zh.md: 744ad917f280a58b13217cae79160f1c129a4c29
persistence-catalog.md: 5668543d48d613ee7d6a485c35e28fd626a1be4d
persistence-catalog.zh.md: 2fcfd89e9579cfdaa2755db5bf8b7795ad5c6502
+1 -1
View File
@@ -13,7 +13,7 @@ The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and co
The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.
The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.
The [format references](persistence-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.
| Root | Kind | SHA-256 | Resolved type |
|---|---|---|---|
+1 -1
View File
@@ -15,7 +15,7 @@
[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。
[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。
[格式参考](persistence-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。
| 根类型 | 类别 | SHA-256 | 已解析类型 |
|---|---|---|---|
+2 -2
View File
@@ -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: de067440e9e1e0df2c980f00f485b71948e09647
README.zh.md: 45a88c131216fb174a5f96a6b74e1b97183221e9
README.md: 3c05a4ce40e8b59c099f6054ab6049242b5e7fff
README.zh.md: 0f90b74fd3196f39c8d3f8928abae053f3b29fc7
+2
View File
@@ -12,6 +12,8 @@ Use this reference to inspect an acknowledged Session persistence-type change an
For older tagged versions, use the [prerelease archive](releases/README.md). It reconstructs alpha/RC type differences for historical reading and format validation; its observations do not serve as current compatibility acknowledgements.
For complete schemas grouped by Session format, use the [format references](../persistence-formats/README.md). Their coverage follows the writer constant, including intermediate formats without a release tag.
## Table of Contents
- [Files and ownership](#files-and-ownership)
+2
View File
@@ -12,6 +12,8 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
较早的 tag 参见[预发行版本归档](releases/README.zh.md)。它重建 alpha/RC 类型差异,供历史阅读和格式校验使用;这些观察记录不作为当前兼容性确认。
按 Session 格式查看完整 schema 时,使用[格式参考](../persistence-formats/README.zh.md)。其覆盖范围由写入器常量决定,包括没有发布 tag 的中间格式。
## 目录
- [文件与职责](#files-and-ownership)
@@ -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 docs/persistence-formats/README.md
README.md: b63936dd29baf23d2fea0006d360ee048ba59635
README.zh.md: a2f99ab07121561b908062e0fa2719be6e810e30
+67
View File
@@ -0,0 +1,67 @@
---
description: "Find the complete declared persistence types for every Session format from V0 through the checkout writer."
---
# Session persistence formats
English | [中文](README.zh.md)
## Summary
Use this reference to inspect a stored Session generation’s headers, event envelopes, and payload types. Every format below the checkout writer has a bilingual document and a complete schema snapshot. The current format uses the generated persistence catalog. The [version authority](../session-format-status.md) owns the writer constant and release status.
## Table of Contents
- [Format references](#formats)
- [Scope and evidence](#scope)
- [Maintain coverage](#maintenance)
- [Dev Note](#dev-note)
-----
<a id="formats"></a>
## Format references
The index is generated from validated snapshots and the current writer constant. Each historical reference identifies its source checkpoint; a Session format number does not identify every event payload revision within that format.
<!-- persistence-format-index:start -->
| Format | Source | Reference | Machine schema | Roots / types |
|---|---|---|---|---|
| 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 |
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | Current checkout | [Current catalog](../persistence-catalog.md) | [JSON](../persistence-schema.json) | 59 / 463 |
<!-- persistence-format-index:end -->
<a id="scope"></a>
## Scope and evidence
Each `vN.md` / `vN.zh.md` pair has `kind: persistence-format`, an identical `yaml persistence-format` declaration binding every root key to its captured digest, a pairing sidecar, and a complete `vN.schema.json`. The [template](../../.agents/skills/dsh-doc/templates/persistence-format.md) defines these records. Roots cover the logical Session header, physical JSONL header, event envelope, and all first-party events at the selected checkpoint; each root includes every reachable declared type. Packed physical body records have separate codec owners linked from each page.
V0 and V2 use the latest matching tags in the [captured prerelease archive](../persistence-changes/releases/README.md). V1 uses an intermediate source tree identified in its reference because the captured tags contain no V1 writer. Source locations belong to those historical trees. Snapshots preserve historical optional fields and opaque values; they do not substitute current types into older formats. Historical identifiers remain intact only in schema JSON and the verified generated schema regions; authored prose follows current terminology rules.
These references describe selected schemas, not historical application replay or migration safety. Same-version event additions and optional payload changes can produce other valid inventories. The prerelease archive retains tag-by-tag differences; [change records](../persistence-changes/README.md) retain current compatibility acknowledgements. Neither history is replaced by these format snapshots.
<a id="maintenance"></a>
## Maintain coverage
`verify-persistence-formats` derives the required integer range from `SESSION_FORMAT_VERSION`. Every older integer needs its own complete record. The current catalog and schema must exist and match the writer version. The check rejects missing, extra, misnumbered, incomplete, or inconsistent records and stale generated regions without fetching Git history or querying a service.
Before advancing the writer, preserve the outgoing current format as `vN.schema.json` with its own bilingual record and source evidence. Keep exactly the type definitions reachable from its captured roots; discard any unused definitions retained by the current extractor after normalization. Retain all earlier records. The successor continues to use the current catalog; copying a new current catalog cannot satisfy the archived predecessor requirement. Follow the [format-version cookbook](../cookbook/adding-a-session-format-version.md) for runtime changes.
The schema definitions and index inside comment markers are generated. After completing the machine data and authored evidence, refresh their tables and pairing records, then verify:
```sh
pnpm run verify-persistence-formats --write
pnpm run verify-persistence-formats
pnpm run doc-sync
```
The write command validates the machine data before updating generated Markdown and pairing records. It preserves authored explanations, machine declarations, and schemas. Default verification also rejects stale pairing records; the standard documentation checks validate translations and local links. Current catalog freshness remains owned by `verify-persistence-catalog`.
<a id="dev-note"></a>
## Dev Note
None.
+67
View File
@@ -0,0 +1,67 @@
---
description: "查找从 V0 到工作区写入器版本的每个 Session 格式的完整持久化类型声明。"
---
# Session 持久化格式
[English](README.md) | 中文
## 概述
本参考用于查阅已存储 Session 代际的 header、事件信封与载荷类型。低于工作区写入器版本的每个格式都有双语文档与完整 schema 快照。当前格式使用生成的持久化目录。[版本真源](../session-format-status.zh.md)负责写入器常量与发布状态。
## 目录
- [格式参考](#formats)
- [范围与证据](#scope)
- [维护覆盖](#maintenance)
- [开发备注](#dev-note)
-----
<a id="formats"></a>
## 格式参考
索引由已验证的快照与当前写入器常量生成。每个历史参考标明其来源检查点;Session 格式编号不能标识该格式内的每次事件载荷修订。
<!-- persistence-format-index:start -->
| 格式 | 来源 | 参考文档 | 机器 schema | 根类型 / 类型 |
|---|---|---|---|---|
| 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 |
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | 当前工作树 | [当前目录](../persistence-catalog.zh.md) | [JSON](../persistence-schema.json) | 59 / 463 |
<!-- persistence-format-index:end -->
<a id="scope"></a>
## 范围与证据
每组 `vN.md` / `vN.zh.md` 文档包含 `kind: persistence-format`、将每个根类型键绑定到已记录摘要的相同 `yaml persistence-format` 声明、配对记录及完整的 `vN.schema.json`。[模板](../../.agents/skills/dsh-doc/templates/persistence-format.md)定义这些记录。根类型覆盖所选检查点的逻辑 Session header、物理 JSONL header、事件信封与所有第一方事件;每个根包含所有可达的声明类型。打包的物理 body 记录由每页链接的独立 codec 负责。
V0 与 V2 使用[已收录预发布归档](../persistence-changes/releases/README.zh.md)中最后一个对应 tag。由于收录的 tag 没有 V1 写入器,V1 使用其参考文档中标识的中间源码树。源码位置属于那些历史源码树。快照保留历史可选字段与不透明值,不会将当前类型替换到旧格式中。历史标识符仅在 schema JSON 与已验证的生成 schema 区间中原样保留;人工说明遵循当前术语规则。
这些参考描述选定的 schema,不表示历史应用回放结果或迁移安全性。同版本内新增事件与可选载荷变更可以产生其他有效目录。预发布归档保留逐 tag 差异;[变更记录](../persistence-changes/README.zh.md)保留当前兼容性确认。这些格式快照不替代任何一种历史。
<a id="maintenance"></a>
## 维护覆盖
`verify-persistence-formats` 从 `SESSION_FORMAT_VERSION` 推导所需的整数范围。每个更早的整数都需要独立的完整记录。当前目录与 schema 必须存在且匹配写入器版本。检查会拒绝缺失、多余、编号错误、不完整或不一致的记录及过期生成区间,无需获取 Git 历史或查询服务。
推进写入器之前,将原当前格式保留为 `vN.schema.json`,并添加其独立双语记录与来源证据。只保留从已记录根类型可达的类型定义;丢弃当前提取器在规范化后仍保留的未使用定义。保留所有更早的记录。后继格式继续使用当前目录;复制新的当前目录不能满足归档前驱的要求。运行时变更遵循[格式版本实操手册](../cookbook/adding-a-session-format-version.zh.md)。
注释标记内的 schema 定义与索引由工具生成。完成机器数据与人工证据后,刷新其表格和配对记录,再执行验证:
```sh
pnpm run verify-persistence-formats --write
pnpm run verify-persistence-formats
pnpm run doc-sync
```
写入命令先验证机器数据,再更新生成的 Markdown 与配对记录。它保留人工说明、机器声明和 schema。默认验证也会拒绝过期的配对记录;标准文档检查验证翻译与本地链接。当前目录的新鲜度仍由 `verify-persistence-catalog` 负责。
<a id="dev-note"></a>
## 开发备注
无。
+6
View File
@@ -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 docs/persistence-formats/v0.md
v0.md: 6b082b30ce95fa79a707af9fdcd4759d2b28858f
v0.zh.md: 7f8db23cd2af1691f03eff018dd759b5e475fbb2
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6
View File
@@ -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 docs/persistence-formats/v1.md
v1.md: be01e32ae9c3af21305c57c0a4affd7a5272f5d8
v1.zh.md: 095feb9f8b22af92e67b80c7cf5878ef3ce2d950
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6
View File
@@ -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 docs/persistence-formats/v2.md
v2.md: d228ecc5ba12e5b4a0f410093734bcd0d57602ce
v2.zh.md: 133ba1cbcd9185a87430182b1717fb9d14277652
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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/session-format-status.md
session-format-status.md: 4408aebf1304159596d01064c358e4b8954d4fab
session-format-status.zh.md: f7969fd382d4d9be0b2401e957dc55ceca156935
session-format-status.md: b13c82de24028f9d681d23207a0c01852c01021a
session-format-status.zh.md: e7726d3e71e45da3c3dd83942f3733e220328baa
+2
View File
@@ -22,6 +22,8 @@ Use this reference to distinguish the checkout’s Session writer version from t
An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning and authority decision](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion.
The [format references](persistence-formats/README.md) document every integer from zero through the checkout writer, with historical schemas and the existing current catalog.
<a id="release-record"></a>
## Release record
+2
View File
@@ -22,6 +22,8 @@
产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本与真源决策](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。
[格式参考](persistence-formats/README.zh.md)记录从零到工作区写入器的每个整数版本,提供历史 schema 并引用现有的当前目录。
<a id="release-record"></a>
## 发布记录
+1
View File
@@ -165,6 +165,7 @@
"persistence-changes": "tsx scripts/persistence-changes.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",
"gen-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts",
"verify-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts --check",
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
+11
View File
@@ -44,6 +44,7 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
'package-bundle': '.agents/skills/dsh-doc/templates/package-bundle.md',
'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md',
'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md',
'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md',
}
/**
@@ -368,6 +369,16 @@ describe('dsh-doc skill consolidation', () => {
}
})
it('maps historical Session format references to their dedicated document kind', () => {
const files = globSync('docs/persistence-formats/v*.md', { cwd: root })
expect(files.length).toBe(readCurrentSessionFormatVersion(root) * 2)
for (const file of files) {
const metadata = readFrontmatter(file)
expect(metadata.kind, file).toBe('persistence-format')
expect(typeof metadata.description === 'string' && metadata.description.trim().length > 0, file).toBe(true)
}
})
it('keeps the audited library registry accurate: every entry has a plain module entry and no bundle declaration', () => {
for (const [dir, reason] of Object.entries(PACKAGE_LIBRARIES)) {
expect(reason.trim().length, `${dir}: library justification`).toBeGreaterThan(0)
+2 -2
View File
@@ -11,7 +11,7 @@ const english = {
envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
fingerprints: 'Persistence type fingerprints',
fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.',
historyIntro: 'The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
historyIntro: 'The [format references](persistence-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
rootColumns: '| Root | Kind | SHA-256 | Resolved type |',
definitions: 'Resolved persistence types',
definitionsIntro: '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.',
@@ -29,7 +29,7 @@ const chinese: Record<keyof typeof english, string> = {
envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
fingerprints: '持久化类型指纹',
fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。',
historyIntro: '[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
historyIntro: '[格式参考](persistence-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
rootColumns: '| 根类型 | 类别 | SHA-256 | 已解析类型 |',
definitions: '已解析的持久化类型',
definitionsIntro: '每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。',
+74
View File
@@ -0,0 +1,74 @@
/** Render historical format references from complete, validated in-tree schemas. */
import { readFileSync } from 'node:fs'
import { basename, join, posix } from 'node:path'
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
import type { PersistenceFormatEntry, PersistenceFormats } from './persistence-formats.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
type Language = 'en' | 'zh'
function replaceRegion(source: string, name: string, content: string, path: string): string {
const start = `<!-- persistence-format-${name}:start -->`
const end = `<!-- persistence-format-${name}:end -->`
const opening = source.indexOf(start)
const closing = source.indexOf(end)
if (opening < 0 || closing < opening || source.indexOf(start, opening + start.length) >= 0
|| source.indexOf(end, closing + end.length) >= 0) throw new Error(`${path}: expected one ${name} factual block`)
return source.slice(0, opening + start.length) + '\n\n' + content.trim() + '\n\n' + source.slice(closing)
}
function historicalSchema(entry: PersistenceFormatEntry, language: Language): string {
const schema = basename(entry.schemaPath)
const introduction = language === 'en'
? `The [complete machine inventory](${schema}) contains ${entry.inventory.roots.length} roots and ${entry.inventory.types.length} reachable types. Digests include all referenced fields; source names and locations describe the selected historical tree.`
: `[完整机器目录](${schema})包含 ${entry.inventory.roots.length} 个根类型和 ${entry.inventory.types.length} 种可达类型。摘要包含所有引用字段;源码名称和位置描述所选的历史源码树。`
return [
'<a id="schema"></a>',
renderPersistenceSchemaIndex(entry.inventory, language, [introduction]),
'<details>',
language === 'en' ? '<summary>Complete resolved types</summary>' : '<summary>完整解析类型</summary>',
'',
renderPersistenceSchemaDefinitions(entry.inventory, language, () => undefined),
'</details>',
].join('\n')
}
function formatIndex(formats: PersistenceFormats, language: Language): string {
const path = (target: string): string => posix.relative('docs/persistence-formats',
language === 'zh' && target.endsWith('.md') ? target.replace(/\.md$/u, '.zh.md') : target)
return [
language === 'en' ? '| Format | Source | Reference | Machine schema | Roots / types |' : '| 格式 | 来源 | 参考文档 | 机器 schema | 根类型 / 类型 |',
'|---|---|---|---|---|',
...formats.entries.map((entry) => {
const source = entry.source === undefined ? language === 'en' ? 'Current checkout' : '当前工作树'
: 'tag' in entry.source ? `\`${entry.source.tag}\`` : `PR #${entry.source.pullRequest}`
const label = entry.version === formats.currentVersion ? language === 'en' ? 'Current catalog' : '当前目录' : `V${entry.version}`
return `| ${entry.version} | ${source} | [${label}](${path(entry.document)}) | [JSON](${path(entry.schemaPath)}) | ${entry.inventory.roots.length} / ${entry.inventory.types.length} |`
}),
].join('\n')
}
/**
* Refresh bounded schema regions and the format index, preserving authored evidence.
* @param root - repository with both document languages and factual markers.
* @param formats - complete validated versions through the current writer.
* @returns paired references and consistency records without modifying files.
*/
export function persistenceFormatFactArtifacts(root: string, formats: PersistenceFormats): PersistenceArtifact[] {
const artifacts: PersistenceArtifact[] = []
for (const entry of formats.entries.filter(entry => entry.version < formats.currentVersion)) {
const render = (language: Language): string => {
const path = language === 'en' ? entry.document : entry.document.replace(/\.md$/u, '.zh.md')
return replaceRegion(readFileSync(join(root, path), 'utf8'), 'schema', historicalSchema(entry, language), path)
}
artifacts.push(...renderPersistencePair(root, entry.document, render('en'), render('zh')))
}
const index = 'docs/persistence-formats/README.md'
const renderIndex = (language: Language): string => {
const path = language === 'en' ? index : index.replace(/\.md$/u, '.zh.md')
return replaceRegion(readFileSync(join(root, path), 'utf8'), 'index', formatIndex(formats, language), path)
}
artifacts.push(...renderPersistencePair(root, index, renderIndex('en'), renderIndex('zh')))
return artifacts
}
+369
View File
@@ -0,0 +1,369 @@
/** Format reference coverage follows the writer version and retains complete historical schemas. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dump } from 'js-yaml'
import { afterEach, describe, expect, it } from 'vitest'
import { loadPersistenceFormats, runPersistenceFormats } from './persistence-formats.ts'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
const temporary: string[] = []
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root, { recursive: true, force: true }) })
function rootSchema(metadata: Omit<PersistenceRoot, 'schema' | 'digest'>, nodes: readonly SchemaNode[]): PersistenceRoot {
const schema = canonicalizeSchema(nodes, 0)
return { ...metadata, schema, digest: schemaDigest(schema) }
}
function completeInventory(roots: readonly PersistenceRoot[]): PersistenceSchemaInventory {
const types = new Map<string, PersistenceType>()
for (const root of roots) {
root.schema.nodes.forEach((_, index) => {
const schema = canonicalizeSchema(root.schema.nodes, index)
const digest = schemaDigest(schema)
types.set(digest, { digest, schema, names: [], sources: [] })
})
}
return { formatVersion: 1, roots, types: [...types.values()] }
}
function header(key: string, version: number | 'number', optional = false): PersistenceRoot {
return rootSchema({ key, kind: 'header' }, [
{ kind: 'object', indices: [], properties: [{ name: 'version', type: 1, optional }] },
version === 'number' ? { kind: 'primitive', type: 'number' } : { kind: 'literal', value: version },
])
}
function inventory(version: number): PersistenceSchemaInventory {
return completeInventory([
header('SessionHeader', version), header('JsonlHeaderLine', 'number'),
rootSchema({ key: 'SessionEventEnvelope', kind: 'envelope' }, [{ kind: 'object', indices: [], properties: [] }]),
rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: false }, [
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }] },
{ kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
]),
])
}
function write(root: string, path: string, content: string): void {
writeFileSync(join(root, path), content)
}
function schemaPath(version: number, currentVersion: number): string {
return version === currentVersion ? 'docs/persistence-schema.json' : `docs/persistence-formats/v${version}.schema.json`
}
function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
}
function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
const current = version === currentVersion
const filename = current ? 'persistence-catalog' : `persistence-formats/v${version}`
const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
const record = {
schemaVersion: 1, sessionFormatVersion: version,
source: version === 1 ? { pullRequest: 3349 } : { tag: 'dsh-v0.1.0-rc.5' },
roots: Object.fromEntries(snapshot.roots.map(root => [root.key, root.digest])),
}
const lines = [
...current ? [] : ['---', 'kind: persistence-format', '---', '', '```yaml persistence-format', dump(record).trimEnd(), '```', ''],
`# Session format v${version}`, '', `[Inventory](${schemaName})`, '', '| Root | Kind | SHA-256 |', '|---|---|---|',
...snapshot.roots.map(root => `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`), '',
]
for (const suffix of ['.md', '.zh.md']) write(root, `docs/${filename}${suffix}`, lines.join('\n'))
replaceSnapshot(root, version, currentVersion, snapshot)
}
function fixture(currentVersion = 3): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-formats-'))
temporary.push(root)
mkdirSync(join(root, 'packages/core/session/src'), { recursive: true })
mkdirSync(join(root, 'docs/persistence-formats'), { recursive: true })
write(root, 'packages/core/session/src/types.ts', `export const SESSION_FORMAT_VERSION = ${currentVersion} as const\n`)
for (let version = 0; version <= currentVersion; version += 1) saveReference(root, version, currentVersion)
return root
}
function edit(root: string, path: string, from: string, to: string): void {
const source = readFileSync(join(root, path), 'utf8')
expect(source).toContain(from)
write(root, path, source.replace(from, to))
}
function editPair(root: string, version: number, from: string, to: string): void {
for (const suffix of ['.md', '.zh.md']) edit(root, `docs/persistence-formats/v${version}${suffix}`, from, to)
}
function prepareFacts(root: string, currentVersion = 3): void {
for (const suffix of ['.md', '.zh.md']) {
for (let version = 0; version < currentVersion; version += 1) {
const path = `docs/persistence-formats/v${version}${suffix}`
const switcher = suffix === '.md' ? `English | [中文](v${version}.zh.md)` : `[English](v${version}.md) | 中文`
write(root, path, readFileSync(join(root, path), 'utf8') + `\n${switcher}\n\n<!-- persistence-format-schema:start -->\n\nPending schemas.\n\n<!-- persistence-format-schema:end -->\n`)
}
const switcher = suffix === '.md' ? 'English | [中文](README.zh.md)' : '[English](README.md) | 中文'
write(root, `docs/persistence-formats/README${suffix}`, `# Session formats\n\n${switcher}\n\nAuthored context.\n\n<!-- persistence-format-index:start -->\n\nPending formats.\n\n<!-- persistence-format-index:end -->\n`)
}
}
describe('complete persistence format references', () => {
it('loads a contiguous archive and the current catalog without Git or historical package sources', () => {
const formats = loadPersistenceFormats(fixture())
expect(formats.currentVersion).toBe(3)
expect(formats.entries.map(entry => entry.version)).toEqual([0, 1, 2, 3])
expect(formats.entries[0]?.source).toEqual({ tag: 'dsh-v0.1.0-rc.5' })
expect(formats.entries[1]?.source).toEqual({ pullRequest: 3349 })
expect(formats.entries[3]).toMatchObject({ document: 'docs/persistence-catalog.md', schemaPath: 'docs/persistence-schema.json' })
expect(formats.entries[3]?.source).toBeUndefined()
})
it('accepts a version-zero writer without a historical directory', () => {
const root = fixture(0)
rmSync(join(root, 'docs/persistence-formats'), { recursive: true })
expect(loadPersistenceFormats(root).entries).toHaveLength(1)
})
it.each(['.md', '.zh.md', '.schema.json'])('requires every historical companion %s', (suffix) => {
const root = fixture()
rmSync(join(root, `docs/persistence-formats/v1${suffix}`))
expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact v1${suffix}`)
})
it('rejects a gap even when all remaining versions have complete companions', () => {
const root = fixture()
for (const suffix of ['.md', '.zh.md', '.schema.json']) rmSync(join(root, `docs/persistence-formats/v1${suffix}`))
expect(() => loadPersistenceFormats(root)).toThrow('v1: missing persistence format artifact')
})
it.each(['persistence-catalog.md', 'persistence-catalog.zh.md', 'persistence-schema.json'])('requires the current reference artifact %s', (filename) => {
const root = fixture()
rmSync(join(root, `docs/${filename}`))
expect(() => loadPersistenceFormats(root)).toThrow(`missing persistence format artifact docs/${filename}`)
})
it('requires a formerly current archive when the writer advances', () => {
const root = fixture()
write(root, 'packages/core/session/src/types.ts', 'export const SESSION_FORMAT_VERSION = 4 as const\n')
saveReference(root, 4, 4)
expect(() => loadPersistenceFormats(root)).toThrow('v3: missing persistence format artifact')
saveReference(root, 3, 4)
expect(loadPersistenceFormats(root).entries.map(entry => entry.version)).toEqual([0, 1, 2, 3, 4])
})
it.each(['v3.md', 'v9.schema.json', 'v01.md', 'v-1.md', 'v2.schema.yaml', 'v3.i18n.yaml'])('rejects unexpected or duplicate format filename %s', (filename) => {
const root = fixture()
write(root, `docs/persistence-formats/${filename}`, '{}')
expect(() => loadPersistenceFormats(root)).toThrow(`unexpected persistence format artifact ${filename}`)
})
it('rejects bilingual declarations that differ', () => {
const root = fixture()
edit(root, 'docs/persistence-formats/v1.zh.md', 'pullRequest: 3349', 'pullRequest: 3397')
expect(() => loadPersistenceFormats(root)).toThrow('bilingual machine records differ')
})
it.each([
['kind: persistence-format', 'kind: persistence-release', 'frontmatter kind'],
['sessionFormatVersion: 1', 'sessionFormatVersion: 0', 'must match its filename'],
['schemaVersion: 1', 'schemaVersion: 2', 'unsupported persistence format record schema version'],
['pullRequest: 3349', 'pullRequest: 0', 'positive integer'],
['pullRequest: 3349', 'pullRequest: 1.5', 'positive integer'],
['pullRequest: 3349', 'pullRequest: 3349\n tag: dsh-v0.1.0', 'expected fields'],
['pullRequest: 3349', 'other: 3349', 'expected fields'],
['source:\n pullRequest: 3349', 'source: null', 'expected an object'],
['schemaVersion: 1', 'schemaVersion: 1\nunknown: true', 'expected fields'],
])('rejects inconsistent machine data: %s', (from, to, error) => {
const root = fixture()
editPair(root, 1, from, to)
expect(() => loadPersistenceFormats(root)).toThrow(error)
})
it('rejects a source tag outside the DSH release namespace', () => {
const root = fixture()
editPair(root, 0, 'tag: dsh-v0.1.0-rc.5', 'tag: latest')
expect(() => loadPersistenceFormats(root)).toThrow('invalid source tag')
})
it.each(['missing', 'extra', 'wrong-digest'])('rejects a %s recorded root', (variant) => {
const root = fixture()
const digest = inventory(1).roots[0]!.digest
const from = variant === 'extra' ? 'roots:\n' : ` SessionHeader: ${digest}\n`
const to = variant === 'missing' ? '' : variant === 'extra' ? `roots:\n event:undeclared: ${'f'.repeat(64)}\n`
: ` SessionHeader: ${'f'.repeat(64)}\n`
editPair(root, 1, from, to)
expect(() => loadPersistenceFormats(root)).toThrow('recorded roots do not match the complete schema inventory')
})
it.each(['unknown-key', 'invalid-digest', 'duplicate-key', 'not-a-mapping'])('rejects invalid recorded roots: %s', (variant) => {
const root = fixture()
const digest = inventory(1).roots[0]!.digest
const from = variant === 'not-a-mapping' ? dump({ roots: Object.fromEntries(inventory(1).roots.map(root => [root.key, root.digest])) })
: ` SessionHeader: ${digest}\n`
const to = variant === 'unknown-key' ? ` unknown: ${digest}\n` : variant === 'invalid-digest' ? ' SessionHeader: invalid\n'
: variant === 'duplicate-key' ? from + from : 'roots: []\n'
editPair(root, 1, from, to)
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-key' ? 'duplicated mapping key'
: variant === 'not-a-mapping' ? 'roots must be a mapping' : 'invalid recorded root')
})
it.each(['missing', 'duplicate'])('rejects a %s machine block', (variant) => {
const root = fixture()
for (const suffix of ['.md', '.zh.md']) {
const path = `docs/persistence-formats/v1${suffix}`
const text = readFileSync(join(root, path), 'utf8')
write(root, path, variant === 'missing' ? text.replace('```yaml persistence-format', '```yaml') : text + '\n```yaml persistence-format\n{}\n```\n')
}
expect(() => loadPersistenceFormats(root)).toThrow('expected exactly one persistence-format machine record')
})
it.each(['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope', 'event:example/value'])('rejects an inventory missing %s', (key) => {
const root = fixture()
replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.filter(root => root.key !== key)))
expect(() => loadPersistenceFormats(root)).toThrow(key.startsWith('event:') ? 'must include an event root' : `missing schema root ${key}`)
})
it.each([0, 1, 2, 3])('rejects a mismatched header version at v%s', (version) => {
const root = fixture()
replaceSnapshot(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, version + 1) : root)))
expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match')
})
it('retains historical number declarations but requires the current logical header literal', () => {
const root = fixture()
for (const version of [0, 1, 2]) {
saveReference(root, version, 3, completeInventory(inventory(version).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
}
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.key === 'SessionHeader' ? header(root.key, 'number') : root)))
expect(() => loadPersistenceFormats(root)).toThrow('SessionHeader.version must match the current writer version')
})
it.each(['SessionHeader', 'JsonlHeaderLine'])('requires a present %s version property', (key) => {
const root = fixture()
replaceSnapshot(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.key === key ? header(key, 1, true) : root)))
expect(() => loadPersistenceFormats(root)).toThrow(`${key}.version must match`)
})
it('uses historical surface rules only for historical references', () => {
const root = fixture()
const event = rootSchema({ key: 'event:example/value', kind: 'event', event: 'example/value', surface: true }, [
{ kind: 'object', indices: [], properties: [
{ name: 'type', type: 1, optional: false }, { name: 'surfaceOp', type: 2, optional: true },
] },
{ kind: 'literal', value: 'example/value' }, { kind: 'primitive', type: 'string' },
])
saveReference(root, 1, 3, completeInventory(inventory(1).roots.map(root => root.kind === 'event' ? event : root)))
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
replaceSnapshot(root, 3, 3, completeInventory(inventory(3).roots.map(root => root.kind === 'event' ? event : root)))
expect(() => loadPersistenceFormats(root)).toThrow('surface metadata does not match its schema')
})
it.each(['missing', 'duplicate', 'extra'])('rejects %s reachable type coverage', (variant) => {
const root = fixture()
const original = inventory(1)
const extraSchema = canonicalizeSchema([{ kind: 'literal', value: 'unreferenced' }], 0)
const types = variant === 'missing' ? original.types.slice(1)
: variant === 'duplicate' ? [...original.types, original.types[0]!]
: [...original.types, { schema: extraSchema, digest: schemaDigest(extraSchema), names: [], sources: [] }]
replaceSnapshot(root, 1, 3, { ...original, types })
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'missing' ? 'cover every reachable type' : variant === 'duplicate' ? 'duplicate schema type' : 'unreferenced schema type')
})
it('permits extractor-retained unreachable types only in the current inventory', () => {
const root = fixture()
const original = inventory(3)
const schema = canonicalizeSchema([{ kind: 'literal', value: false }], 0)
replaceSnapshot(root, 3, 3, {
...original, types: [...original.types, { schema, digest: schemaDigest(schema), names: [], sources: [] }],
})
expect(loadPersistenceFormats(root).entries).toHaveLength(4)
})
it.each(['duplicate-root', 'invalid-digest', 'invalid-graph', 'unexpected-field'])('rejects malformed schema inventory: %s', (variant) => {
const root = fixture()
const original = inventory(1)
const first = original.roots[0]!
const changed = variant === 'duplicate-root' ? { ...original, roots: [...original.roots, first] }
: variant === 'invalid-digest' ? { ...original, roots: [{ ...first, digest: '0'.repeat(64) }, ...original.roots.slice(1)] }
: variant === 'invalid-graph' ? { ...original, roots: [{ ...first, schema: { root: 0, nodes: [{ kind: 'array', element: 99 }] } }, ...original.roots.slice(1)] }
: { ...original, extra: true }
write(root, schemaPath(1, 3), JSON.stringify(changed))
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-root' ? 'duplicate schema root' : variant === 'invalid-digest' ? 'schema digest mismatch' : variant === 'invalid-graph' ? 'unknown schema node' : 'unknown field extra')
})
it.each(['persistence-formats/v1.md', 'persistence-formats/v1.zh.md', 'persistence-catalog.md', 'persistence-catalog.zh.md'])('requires the matching schema link in %s', (document) => {
const root = fixture()
const schema = document.startsWith('persistence-formats/') ? 'v1.schema.json' : 'persistence-schema.json'
edit(root, `docs/${document}`, `](${schema})`, '](wrong.schema.json)')
expect(() => loadPersistenceFormats(root)).toThrow(`missing link to ${schema}`)
})
it.each(['persistence-catalog.md', 'persistence-catalog.zh.md'])('rejects a placeholder current catalog in %s', (document) => {
const root = fixture()
write(root, `docs/${document}`, '# Placeholder\n\n[Inventory](persistence-schema.json)\n')
expect(() => loadPersistenceFormats(root)).toThrow('missing schema index entry for SessionHeader')
})
it('checks generated schema regions and refreshes them without changing the recorded schemas or authored context', () => {
const root = fixture()
prepareFacts(root)
const paths = [0, 1, 2, 3].map(version => schemaPath(version, 3))
const before = paths.map(path => readFileSync(join(root, path), 'utf8'))
expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
expect(runPersistenceFormats(['--write'], root)).toContain('Refreshed')
expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v3 verified (4 complete references).')
expect(paths.map(path => readFileSync(join(root, path), 'utf8'))).toEqual(before)
expect(readFileSync(join(root, 'docs/persistence-formats/README.md'), 'utf8')).toContain('Authored context.')
expect(readFileSync(join(root, 'docs/persistence-formats/v1.md'), 'utf8')).toContain('pullRequest: 3349')
edit(root, 'docs/persistence-formats/v1.md', '<summary>Complete resolved types</summary>', '<summary>Missing definitions</summary>')
expect(() => runPersistenceFormats([], root)).toThrow('Stale persistence format facts')
})
it('renders a format index for a current version-zero writer', () => {
const root = fixture(0)
prepareFacts(root, 0)
runPersistenceFormats(['--write'], root)
expect(runPersistenceFormats([], root)).toBe('Persistence formats: v0 through v0 verified (1 complete references).')
})
it.each(['missing', 'stale'])('rejects a %s format pairing record and refreshes it with --write', (variant) => {
const root = fixture()
prepareFacts(root)
runPersistenceFormats(['--write'], root)
const sidecar = 'docs/persistence-formats/v1.i18n.yaml'
if (variant === 'missing') rmSync(join(root, sidecar))
else write(root, sidecar, 'schemaVersion: 1\n')
expect(() => runPersistenceFormats([], root)).toThrow(`Stale persistence format facts: ${sidecar}`)
runPersistenceFormats(['--write'], root)
expect(() => runPersistenceFormats([], root)).not.toThrow()
})
it('refuses to refresh facts until every format reference passes validation', () => {
const root = fixture()
prepareFacts(root)
const index = readFileSync(join(root, 'docs/persistence-formats/README.md'), 'utf8')
rmSync(join(root, 'docs/persistence-formats/v1.schema.json'))
expect(() => runPersistenceFormats(['--write'], root)).toThrow('missing persistence format artifact')
expect(readFileSync(join(root, 'docs/persistence-formats/README.md'), 'utf8')).toBe(index)
})
it('refuses to refresh an archive after an event and all its types are removed from its schema', () => {
const root = fixture()
const retained = rootSchema({ key: 'event:example/retained', kind: 'event', event: 'example/retained', surface: false }, [
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 1, optional: false }] },
{ kind: 'literal', value: 'example/retained' },
])
const complete = completeInventory([...inventory(1).roots, retained])
saveReference(root, 1, 3, complete)
prepareFacts(root)
runPersistenceFormats(['--write'], root)
const document = 'docs/persistence-formats/v1.md'
const before = readFileSync(join(root, document), 'utf8')
replaceSnapshot(root, 1, 3, completeInventory(complete.roots.filter(root => root.key !== 'event:example/value')))
expect(() => runPersistenceFormats(['--write'], root)).toThrow('recorded roots do not match the complete schema inventory')
expect(readFileSync(join(root, document), 'utf8')).toBe(before)
})
})
+204
View File
@@ -0,0 +1,204 @@
/** Verify complete Session format references from version zero through the current writer. */
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { JSON_SCHEMA, load } from 'js-yaml'
import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts'
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
import { persistenceFormatFactArtifacts } from './persistence-format-facts.ts'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
const DIRECTORY = 'docs/persistence-formats'
const CURRENT_DOCUMENT = 'docs/persistence-catalog.md'
const CURRENT_SCHEMA = 'docs/persistence-schema.json'
/** Historical checkout that supplies a format's complete declared persistence inventory. */
export type PersistenceFormatSource = { readonly tag: string } | { readonly pullRequest: number }
/** One complete format reference; the current catalog follows the historical entries. */
export interface PersistenceFormatEntry {
readonly version: number
readonly document: string
readonly schemaPath: string
readonly inventory: PersistenceSchemaInventory
readonly source?: PersistenceFormatSource
}
/** Contiguous format references ending at the source-declared writer version. */
export interface PersistenceFormats {
readonly currentVersion: number
readonly entries: readonly PersistenceFormatEntry[]
}
interface PersistenceFormatRecord {
readonly source: PersistenceFormatSource
readonly roots: ReadonlyMap<string, string>
}
function fields(value: unknown, expected: readonly string[], label: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: expected an object`)
const record = value as Record<string, unknown>
if (Object.keys(record).length !== expected.length || expected.some(key => !Object.hasOwn(record, key))) {
throw new Error(`${label}: expected fields ${expected.join(', ')}`)
}
return record
}
function machineBlock(document: string, label: string): string {
const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)/u.exec(document)?.[1]
const metadata: unknown = frontmatter === undefined ? undefined : load(frontmatter, { schema: JSON_SCHEMA })
if (metadata === null || typeof metadata !== 'object' || !('kind' in metadata) || metadata.kind !== 'persistence-format') {
throw new Error(`${label}: expected persistence-format frontmatter kind`)
}
const blocks = [...document.matchAll(/^```yaml persistence-format[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
if (blocks.length !== 1) throw new Error(`${label}: expected exactly one persistence-format machine record`)
return (blocks[0] as RegExpMatchArray)[1] as string
}
function parseSource(source: unknown, label: string): PersistenceFormatSource {
if (source !== null && typeof source === 'object' && 'tag' in source) {
const tag = fields(source, ['tag'], `${label} source`).tag
if (typeof tag !== 'string' || !/^dsh-[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(tag)) throw new Error(`${label}: invalid source tag`)
return { tag }
}
const pullRequest = fields(source, ['pullRequest'], `${label} source`).pullRequest
if (!Number.isSafeInteger(pullRequest) || (pullRequest as number) <= 0) throw new Error(`${label}: source pullRequest must be a positive integer`)
return { pullRequest: pullRequest as number }
}
function parseRecord(block: string, version: number): PersistenceFormatRecord {
const label = `v${version}`
const record = fields(load(block, { schema: JSON_SCHEMA }), ['schemaVersion', 'sessionFormatVersion', 'source', 'roots'], label)
if (record.schemaVersion !== 1) throw new Error(`${label}: unsupported persistence format record schema version`)
if (record.sessionFormatVersion !== version) throw new Error(`${label}: record sessionFormatVersion must match its filename`)
if (record.roots === null || typeof record.roots !== 'object' || Array.isArray(record.roots)) throw new Error(`${label}: roots must be a mapping`)
const roots = new Map<string, string>()
for (const [key, digest] of Object.entries(record.roots)) {
if (!/^(?:SessionHeader|JsonlHeaderLine|SessionEventEnvelope|event:.+)$/u.test(key)
|| typeof digest !== 'string' || !/^[a-f0-9]{64}$/u.test(digest)) throw new Error(`${label}: invalid recorded root ${key}`)
roots.set(key, digest)
}
return { source: parseSource(record.source, label), roots }
}
function validateInventory(inventory: PersistenceSchemaInventory, version: number, current: boolean): void {
const label = `v${version}`
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
const root = inventory.roots.find(root => root.key === key)
if (root === undefined) throw new Error(`${label}: missing schema root ${key}`)
if (root.kind !== 'header') continue
const node = root.schema.nodes[0]
const field = node?.kind === 'object' ? node.properties.find(property => property.name === 'version') : undefined
const type = field === undefined ? undefined : root.schema.nodes[field.type]
const legacyNumber = !(current && key === 'SessionHeader') && type?.kind === 'primitive' && type.type === 'number'
if (field?.optional !== false || !(legacyNumber || type?.kind === 'literal' && type.value === version)) {
throw new Error(`${label}: ${key}.version must match the ${current ? 'current writer' : 'recorded format'} version`)
}
}
if (!inventory.roots.some(root => root.kind === 'event')) throw new Error(`${label}: complete inventory must include an event root`)
const reachable = new Set(inventory.roots.flatMap(root => root.schema.nodes
.map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))))
const remaining = new Set(reachable)
const listed = new Set<string>()
for (const type of inventory.types) {
if (listed.has(type.digest)) throw new Error(`${label}: duplicate schema type ${type.digest}`)
listed.add(type.digest)
// The current extractor can retain types erased by normalization; its generator gate checks that inventory.
if (!current && !reachable.has(type.digest)) throw new Error(`${label}: unreferenced schema type ${type.digest}`)
remaining.delete(type.digest)
}
if (remaining.size > 0) throw new Error(`${label}: schema types must cover every reachable type`)
}
function validateDocument(
document: string, schemaName: string, inventory: PersistenceSchemaInventory, label: string, current: boolean,
): void {
if (!document.includes(`](${schemaName})`)) throw new Error(`${label}: missing link to ${schemaName}`)
if (!current) return
for (const root of inventory.roots) {
const row = `| \`${root.key}\` | ${root.kind} | \`${root.digest}\` |`
if (!document.includes(row)) throw new Error(`${label}: missing schema index entry for ${root.key}`)
}
}
/**
* Read every required format pair and complete inventory without Git or historical source extraction.
* @param root - checkout root containing the writer declaration and format references.
* @returns ordered historical references followed by the current generated catalog.
*/
export function loadPersistenceFormats(root: string): PersistenceFormats {
const currentVersion = readCurrentSessionFormatVersion(root)
const directory = join(root, DIRECTORY)
const files = new Set(existsSync(directory) ? readdirSync(directory) : [])
const expected = new Set<string>()
for (let version = 0; version < currentVersion; version += 1) {
for (const suffix of ['.md', '.zh.md', '.schema.json']) {
const name = `v${version}${suffix}`
expected.add(name)
if (!files.has(name)) throw new Error(`v${version}: missing persistence format artifact ${name}`)
}
expected.add(`v${version}.i18n.yaml`)
}
for (const file of files) {
if (file.startsWith('v') && !expected.has(file)) throw new Error(`unexpected persistence format artifact ${file}`)
}
const read = (path: string): string => {
if (!existsSync(join(root, path))) throw new Error(`missing persistence format artifact ${path}`)
return readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')
}
const entries: PersistenceFormatEntry[] = []
for (let version = 0; version <= currentVersion; version += 1) {
const current = version === currentVersion
const document = current ? CURRENT_DOCUMENT : `${DIRECTORY}/v${version}.md`
const schemaPath = current ? CURRENT_SCHEMA : `${DIRECTORY}/v${version}.schema.json`
const english = read(document)
const chinese = read(document.replace(/\.md$/u, '.zh.md'))
let record: PersistenceFormatRecord | undefined
if (!current) {
const block = machineBlock(english, document)
if (block !== machineBlock(chinese, document.replace(/\.md$/u, '.zh.md'))) throw new Error(`v${version}: bilingual machine records differ`)
record = parseRecord(block, version)
}
const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(JSON.parse(read(schemaPath)))
validateInventory(inventory, version, current)
const recordedRoots = record?.roots
if (recordedRoots !== undefined && (recordedRoots.size !== inventory.roots.length
|| inventory.roots.some(root => recordedRoots.get(root.key) !== root.digest))) {
throw new Error(`v${version}: recorded roots do not match the complete schema inventory`)
}
const schemaName = current ? 'persistence-schema.json' : `v${version}.schema.json`
validateDocument(english, schemaName, inventory, document, current)
validateDocument(chinese, schemaName, inventory, document.replace(/\.md$/u, '.zh.md'), current)
entries.push({ version, document, schemaPath, inventory, ...(record === undefined ? {} : { source: record.source }) })
}
return { currentVersion, entries }
}
/**
* Validate format coverage and check or refresh the historical schema and index regions.
* @param args - CLI arguments; --write refreshes generated facts after complete validation.
* @param root - default checkout directory, overridden by --root when provided.
* @returns the verified format count and optional number of refreshed artifacts.
*/
export function runPersistenceFormats(args: readonly string[], root = resolve(import.meta.dirname, '..')): string {
const { values } = parseArgs({ args: [...args], options: { root: { type: 'string' }, write: { type: 'boolean' } } })
root = resolve(values.root ?? root)
const formats = loadPersistenceFormats(root)
const changed = persistenceFormatFactArtifacts(root, formats).filter(artifact => !existsSync(join(root, artifact.path))
|| readFileSync(join(root, artifact.path), 'utf8') !== artifact.content)
if (!values.write && changed.length > 0) throw new Error(`Stale persistence format facts: ${changed.map(artifact => artifact.path).join(', ')}. Run pnpm run verify-persistence-formats --write.`)
if (values.write) for (const artifact of changed) writeFileSync(join(root, artifact.path), artifact.content)
return `Persistence formats: v0 through v${formats.currentVersion} verified (${formats.entries.length} complete references).`
+ (values.write ? ` Refreshed ${changed.length} file${changed.length === 1 ? '' : 's'}.` : '')
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try {
console.log(runPersistenceFormats(process.argv.slice(2)))
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
+11
View File
@@ -48,6 +48,17 @@ describe('persistence schema catalog', () => {
expect(before).not.toBe(after)
})
it('renders historical source locations without linking them to current files', () => {
const inventory = fixture()
const definitions = renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined)
expect(definitions).toContain('Sources: `packages/core/example/src/types.ts:8`')
expect(definitions).not.toContain('(../packages/')
const index = renderPersistenceSchemaIndex(inventory, 'en', ['[Historical schema](v0.schema.json)'])
expect(index).toContain('[Historical schema](v0.schema.json)')
expect(index).not.toContain('(persistence-schema.json)')
expect(renderPersistenceSchemaDefinitions(inventory)).toContain('(../packages/core/example/src/types.ts)')
})
it('refuses a current inventory that omits a referenced definition', () => {
const complete = fixture()
const missing: PersistenceSchemaInventory = { ...complete, types: [] }
+24 -6
View File
@@ -101,13 +101,21 @@ function typeExpression(
return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
}
function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay>, locale: PersistenceCatalogLocale): string[] {
function definition(
entry: TypeDisplay,
entries: ReadonlyMap<string, TypeDisplay>,
locale: PersistenceCatalogLocale,
sourceLink: (source: string) => string | undefined,
): string[] {
const text = persistenceCatalogText[locale]
const schema = entry.type.schema
const node = nodeAt(schema, 0)
const lines = [`<a id="${entry.anchor}"></a>`, '', `### ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
if (entry.type.sources.length > 0) {
lines.push(`${text.sources}${entry.type.sources.map(source => `[${code(source)}](../${sourcePath(source)})`).join(' · ')}`, '')
lines.push(`${text.sources}${entry.type.sources.map((source) => {
const href = sourceLink(sourcePath(source))
return href === undefined ? code(source) : `[${code(source)}](${href})`
}).join(' · ')}`, '')
}
const expression = (index: number): string => typeExpression(schema, index, entries, locale)
switch (node.kind) {
@@ -146,13 +154,18 @@ function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay
* Render every tracked root with its exact digest and resolved type reference.
* @param inventory - complete current-source schemas and declaration metadata.
* @param locale - generated document language.
* @param introduction - paragraphs before the root table; defaults to current-source links.
* @returns Markdown index including the history and contributor workflow links.
*/
export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
export function renderPersistenceSchemaIndex(
inventory: PersistenceSchemaInventory,
locale: PersistenceCatalogLocale = 'en',
introduction: readonly string[] = [persistenceCatalogText[locale].fingerprintsIntro, persistenceCatalogText[locale].historyIntro],
): string {
const entries = displays(inventory)
const text = persistenceCatalogText[locale]
return [
`## ${text.fingerprints}`, '', text.fingerprintsIntro, '', text.historyIntro, '',
`## ${text.fingerprints}`, '', ...introduction.flatMap(paragraph => [paragraph, '']),
text.rootColumns, '|---|---|---|---|',
...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
].join('\n')
@@ -162,15 +175,20 @@ export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInvento
* Render every reachable type once, with links for shared and recursive definitions.
* @param inventory - complete current-source schemas and declaration metadata.
* @param locale - generated document language.
* @param sourceLink - source path to URL; undefined keeps historical locations as text.
* @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
*/
export function renderPersistenceSchemaDefinitions(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
export function renderPersistenceSchemaDefinitions(
inventory: PersistenceSchemaInventory,
locale: PersistenceCatalogLocale = 'en',
sourceLink: (source: string) => string | undefined = source => `../${source}`,
): string {
const entries = displays(inventory)
const text = persistenceCatalogText[locale]
const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
return [
`## ${text.definitions}`, '', text.definitionsIntro, '',
...sorted.flatMap(entry => definition(entry, entries, locale)),
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink)),
].join('\n')
}
+13
View File
@@ -215,6 +215,19 @@ describe('gate graph validation', () => {
}
})
it('requires complete Session format references locally and in CI', () => {
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
scripts: Record<string, string>
}
for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
expect(withPnpmEntrypoint(() => gatesForMode(mode))).toContainEqual(expect.objectContaining({
id: 'persistence-formats',
displayCommand: 'pnpm run verify-persistence-formats',
}))
}
expect(scripts['verify-persistence-formats']).toBe('tsx scripts/persistence-formats.ts')
})
it('keeps package-group subsystem ownership in the documentation gate', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
+1
View File
@@ -746,6 +746,7 @@ function docSyncLeafGates(options: {
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('persistence-changes', 'verify-persistence-changes', { label: 'persistence type history' }),
pnpmScript('persistence-releases', 'verify-persistence-releases', { label: 'released persistence history' }),
pnpmScript('persistence-formats', 'verify-persistence-formats', { label: 'Session format references', quick: true }),
pnpmScript('session-format-catalog', 'verify-session-format-catalog', { label: 'Session format catalog' }),
pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links', quick: true }),
pnpmScript('repository-references', 'verify-repository-references', { label: 'repository references', quick: true }),
+87
View File
@@ -84,4 +84,91 @@ describe('concrete terminology policy', () => {
expect(findConcreteTermViolations(file, blockedTerm)).toEqual([{ file, line: 1 }])
}
})
it.each(['0', '1', '2', '10'])('preserves historical identifiers in the canonical v%s machine schema', (version) => {
expect(findConcreteTermViolations(`docs/persistence-formats/v${version}.schema.json`, blockedTerm)).toEqual([])
})
it.each([
'docs/persistence-formats/v00.schema.json',
'docs/persistence-formats/v01.schema.json',
'docs/persistence-formats/v-1.schema.json',
'docs/persistence-formats/v1.zh.schema.json',
'docs/persistence-formats/v1.schema.json.backup',
'docs/persistence-formats/V1.schema.json',
'docs/persistence-formats/versions/v1.schema.json',
'docs/persistence-formats-extra/v1.schema.json',
'docs/other/persistence-formats/v1.schema.json',
'docs/persistence-schema.json',
])('keeps machine content strict outside canonical historical schema path %s', (file) => {
expect(findConcreteTermViolations(file, blockedTerm)).toEqual([{ file, line: 1 }])
})
it.each(['md', 'zh.md'])('exempts only generated historical schema lines in %s references', (suffix) => {
const file = `docs/persistence-formats/v2.${suffix}`
for (const newline of ['\n', '\r\n']) {
expect(findConcreteTermViolations(file, [
`Authored ${blockedTerm}.`,
'<!-- persistence-format-schema:start -->',
`Historical${blockedTerm}`,
`Nested historical ${blockedTerm}.`,
'<!-- persistence-format-schema:end -->',
`Authored ${blockedTerm}.`,
].join(newline))).toEqual([{ file, line: 1 }, { file, line: 6 }])
}
})
it.each([
'docs/persistence-formats/v00.md',
'docs/persistence-formats/v01.zh.md',
'docs/persistence-formats/v-1.md',
'docs/persistence-formats/v1.zh.zh.md',
'docs/persistence-formats/v1.md.backup',
'docs/persistence-formats/V1.md',
'docs/persistence-formats/README.md',
'docs/persistence-formats-extra/v1.md',
'docs/other/persistence-formats/v1.zh.md',
'docs/persistence-catalog.md',
'docs/persistence-catalog.zh.md',
'docs/persistence-schema.json',
])('keeps generated-looking content strict outside canonical historical reference path %s', (file) => {
expect(findConcreteTermViolations(file, [
'<!-- persistence-format-schema:start -->', blockedTerm, '<!-- persistence-format-schema:end -->',
].join('\n'))).toEqual([{ file, line: 2 }])
})
it.each([
['no markers', []],
['missing end', ['<!-- persistence-format-schema:start -->']],
['missing start', ['<!-- persistence-format-schema:end -->']],
['reversed', ['<!-- persistence-format-schema:end -->', '<!-- persistence-format-schema:start -->']],
['duplicate start', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->']],
['duplicate end', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->', '<!-- persistence-format-schema:end -->']],
['nested pair', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->', '<!-- persistence-format-schema:end -->']],
['second pair', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->', '<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->']],
['inline pair', ['<!-- persistence-format-schema:start --><!-- persistence-format-schema:end -->']],
['indented start', [' <!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end -->']],
['malformed start', ['<!-- persistence-format-schema:start', '<!-- persistence-format-schema:end -->']],
['malformed end', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:end']],
['malformed nested marker', ['<!-- persistence-format-schema:start -->', '<!--persistence-format-schema:start', '<!-- persistence-format-schema:end -->']],
['uppercase nested marker', ['<!-- persistence-format-schema:start -->', '<!-- PERSISTENCE-FORMAT-SCHEMA:start -->', '<!-- persistence-format-schema:end -->']],
['unknown nested marker', ['<!-- persistence-format-schema:start -->', '<!-- persistence-format-schema:other -->', '<!-- persistence-format-schema:end -->']],
['split nested marker', ['<!-- persistence-format-schema:start -->', '<!--\npersistence-format-schema:start -->', '<!-- persistence-format-schema:end -->']],
['other marker prefix', ['<!-- persistence-release-schema:start -->', '<!-- persistence-release-schema:end -->']],
['index marker', ['<!-- persistence-format-index:start -->', '<!-- persistence-format-index:end -->']],
])('exempts no lines with %s', (_label, markers) => {
const file = 'docs/persistence-formats/v1.md'
const lines = [blockedTerm, ...markers.flatMap(marker => [marker, blockedTerm])].join('\n').split('\n')
const expected = lines.flatMap((line, index) => line === blockedTerm ? [{ file, line: index + 1 }] : [])
expect(findConcreteTermViolations(file, lines.join('\n'))).toEqual(expected)
})
it('keeps marker lines strict when they contain authored text', () => {
const file = 'docs/persistence-formats/v1.md'
expect(findConcreteTermViolations(file, [
`<!-- persistence-format-schema:start --> ${blockedTerm}`,
blockedTerm,
`<!-- persistence-format-schema:end --> ${blockedTerm}`,
].join('\n'))).toEqual([{ file, line: 1 }, { file, line: 2 }, { file, line: 3 }])
})
})
+15 -2
View File
@@ -21,23 +21,36 @@ function isExcluded(file: string): boolean {
return excludedPrefixes.some(prefix => file.startsWith(prefix))
// Release snapshots retain the identifiers present in their pinned source.
|| /^docs\/persistence-changes\/releases\/dsh-v\d+\.\d+\.\d+-(?:alpha|rc)\.\d+\.schema\.json$/u.test(file)
|| /^docs\/persistence-formats\/v(?:0|[1-9]\d*)\.schema\.json$/u.test(file)
}
function containsBlockedTerm(value: string): boolean {
return value.normalize('NFKC').toLowerCase().includes(blockedTerm)
}
function historicalSchemaRegion(file: string, source: string, lines: readonly string[]): readonly [number, number] | undefined {
if (!/^docs\/persistence-formats\/v(?:0|[1-9]\d*)(?:\.zh)?\.md$/u.test(file)) return undefined
if (source.match(/<!--\s*persistence-format-schema\b/giu)?.length !== 2) return undefined
const start = lines.indexOf('<!-- persistence-format-schema:start -->')
const end = lines.indexOf('<!-- persistence-format-schema:end -->')
// The format gate checks these generated lines against their historical schema inventory.
return start >= 0 && end > start ? [start, end] : undefined
}
/**
* Find the blocked term in one maintained tracked file.
* @param file - repository-relative tracked path.
* @param source - text contents or symlink target.
* @returns violations outside vendored sources, frozen Agent Notes and release schema snapshots.
* @returns violations outside vendored sources, frozen Agent Notes, historical schemas and their checked generated regions.
*/
export function findConcreteTermViolations(file: string, source: string): ConcreteTermViolation[] {
if (isExcluded(file)) return []
const violations: ConcreteTermViolation[] = []
if (containsBlockedTerm(file)) violations.push({ file, line: null })
for (const [index, line] of source.split('\n').entries()) {
const lines = source.split(/\r?\n/u)
const schemaRegion = historicalSchemaRegion(file, source, lines)
for (const [index, line] of lines.entries()) {
if (schemaRegion !== undefined && index > schemaRegion[0] && index < schemaRegion[1]) continue
if (containsBlockedTerm(line)) violations.push({ file, line: index + 1 })
}
return violations