Merge pull request #4032 from deepseek-harness/worktree/persistence-type-history

feat(session): enforce persistence type compatibility
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-09-12 13:15:52 +08:00
commit 0e77868739
40 files changed
+89214 -350

No files matched your search

@@ -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-11-persistence-type-history.md
2026-09-11-persistence-type-history.md: ef2cd0d8c7013883657d6dad9eb1c4894d9172e7
2026-09-11-persistence-type-history.zh.md: 6045176ecbc41ef758e2dd98ddb3de6eb3c049f8
@@ -0,0 +1,41 @@
# Agent Note: In-tree persistence-type history
Status: implemented
English | [中文](2026-09-11-persistence-type-history.zh.md)
## Problem
A persisted event can retain the same payload type expression while a referenced type changes. Reviewing declaration text alone does not expose every nested structural change. A digest detects a difference but cannot explain whether it adds optional data or changes an existing property. Regenerating a catalog also does not establish that the author reviewed the persistence consequences.
## 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. 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.
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.
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.
## Alternatives considered
**Hash the displayed declarations.** Referenced definitions can change without altering the displayed expression. Normalized transitive types make those changes visible while omitting source-file and alias churn.
**Bind every acknowledgement to one overall digest.** An unrelated event change would invalidate a reviewed acknowledgement. Per-root history limits invalidation to the affected header or event while the complete inventory retains coverage.
**Compare with the PR merge-base or a release checkout.** Those inputs require history outside the current tree. Retained after schemas provide the comparison input to local checks and CI alike. A hash without its schema cannot support automatic structural classification.
**Treat every changed digest as a version bump.** Optional body additions, required-to-optional changes, and ordinary event additions do not always require a new persistence version. Conservative classification distinguishes these cases and leaves the compatibility explanation to review.
**Infer behavior from types or permit arbitrary compatibility overrides.** A type graph cannot prove replay semantics. The checker confines itself to detectable structure and rejects decisions below the mechanical classification; it does not claim to detect behavior-only changes.
## Consequences
Authors retain one snapshot per affected root per accepted transition and resolve history forks when integrating competing changes to that root. Independent roots can advance without rewriting unrelated acknowledgements. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) provides the local authoring and verification procedure.
The checks prove current-tree consistency, not that accepted history was never rewritten or that an explanation is semantically correct. Hidden structures inside `unknown` and similar opaque values remain undetectable. This mechanism adds no runtime digest or Session-format field. Existing [versioning](../architecture/2026-08-10-session-log-version-mechanism.md) and [released-generation migration](../architecture/2026-08-31-released-session-format-migrations.md) decisions retain their independent runtime guarantees.
@@ -0,0 +1,41 @@
# Agent Note: 目录内的持久化类型历史
Status: implemented
[English](2026-09-11-persistence-type-history.md) | 中文
## 问题
持久化事件可以保留相同的载荷类型表达式,但其引用的类型已经改变。只审阅声明文本无法暴露所有嵌套结构变更。摘要能检测差异,却不能说明它是添加可选数据还是更改已有属性。重新生成目录也不能证明作者已审阅持久化方面的影响。
## 决策
一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、注释和属性顺序属于摘要之外的展示细节。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。
专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。
新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
记录命令从仓库内模板生成双语目录和一致性记录。作者可以将两种语言的概述、兼容性说明和实际验证证据作为结构化输入。生成过程提供标识符、摘要和快照,不编造兼容性说明或测试结果。结构化检查输出保留非零失败退出码,并报告独立于可读描述的稳定变更种类。
显式更新可刷新尚未接受的末端记录,无需删除其人工说明。它根据剩余历史重新计算转换,并拒绝基线或已有依赖方的记录。审阅接受状态不是目录内可得的事实,因此作者保留已接受记录并添加后继。
## 考虑过的替代方案
**对展示的声明计算哈希。** 被引用的定义可能改变,而展示的表达式保持不变。规范化传递类型使这些变更可见,同时排除源文件和别名调整。
**将每次确认绑定到一个整体摘要。** 无关事件的变更会使已审阅的确认失效。逐根历史将失效范围限制在受影响的头部或事件,完整清单仍然保留覆盖范围。
**与 PR 的 merge-base 或发布版本检出目录比较。** 这些输入需要当前目录之外的历史。保留的变更后 schema 为本地检查和 CI 提供相同的比较输入。没有 schema 的哈希无法支持自动结构分类。
**把每次摘要变化都视为升版本。** 可选事件体新增、必选改可选,以及普通事件新增不一定需要新的持久化版本。保守分类区分这些情况,并将兼容性说明交给审阅。
**从类型推断行为,或允许任意兼容性豁免。** 类型图无法证明回放语义。检查器仅覆盖可检测结构,并拒绝低于机械分类要求的决策;它不声称能够检测纯行为变更。
## 影响
作者为每次已接受转换中的每个受影响根保留一份快照,并在集成该根的竞争变更时解决历史分叉。独立根可以演进而不改写无关确认。[实操手册](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md)提供本地编写与验证流程。
检查证明当前目录的一致性,不证明已接受历史从未被改写,也不证明说明在语义上正确。`unknown` 等不透明值内部的隐藏结构仍然无法检测。本机制不添加运行时摘要或会话格式字段。现有[版本机制](../architecture/2026-08-10-session-log-version-mechanism.zh.md)和[已发布代际迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)决策保留其独立的运行时保证。
+3 -2
View File
@@ -54,6 +54,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in
- `package-reference` → [templates/package-reference.md](templates/package-reference.md): a Cordis plugin or service package — mount configuration, the config table, folded implementation, Model Experience and Known Limitations in the gate-owned forms.
- `package-library` → [templates/package-library.md](templates/package-library.md): a package with no plugin surface — consumer entry points, no profile-install path, no mount configuration.
- `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.
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.
@@ -64,7 +65,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.** No compatibility shims, migration talk, or history ("previously", "now", "no longer", renamed) outside the Dev Note; the codebase as it is today is the only subject.
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change` records retain the before/after facts and compatibility evidence for their detected type transition; 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
@@ -106,7 +107,7 @@ Load only the reference needed for the task. Each reference links directly from
- [Review criteria](references/review.md): newcomer test, evidence checks, package README review, the reference example, and verification commands.
- [Website publication](references/website-sync.md): manifest fields, projector link rules, preview and validation, and deployment separation.
The four README templates in [`templates/`](templates/) are the working skeletons for the four `kind` labels; open the one your document's kind names before writing.
The templates in [`templates/`](templates/) provide one working skeleton per `kind`; open the one your document's kind names before writing.
Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for sentence-level contract coverage and editorial judgment. The `session-persistence-jsonl` README pair ([English](../../../packages/session/session-persistence-jsonl/README.md), [Chinese](../../../packages/session/session-persistence-jsonl/README.zh.md)) is the reference example: searchable YAML, Summary and Table of Contents, user-to-developer progression with a folded developer section, Further Exploration, canonical Model Experience and Known Limitations sections, and a final Dev Note.
@@ -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. Derive the kind 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`; their Chinese siblings use the same kind. The folder README is an index, not a change record. 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`.
@@ -42,6 +42,7 @@ kind: "package-reference"
| `package-reference` | `packages/<group>/<package>/README.md` with a plugin entry | [package-reference.md](../templates/package-reference.md) | Package contract: follow the [package README review standard](review.md#package-readme-review) and the canonical [package documentation requirements](../../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). |
| `package-library` | `packages/<group>/<package>/README.md` with a plain module entry | [package-library.md](../templates/package-library.md) | Library contract: consumer entry points and boundaries; no profile-install path and no mount configuration. |
| `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. |
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,73 @@
# Template: persistence-change
Use this template for `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling. The folder README is an index, not a record. A record acknowledges only mechanically detected persistence-type changes. Its generated JSON sibling retains complete after schemas so verification needs no Git baseline.
## Frontmatter
```yaml
---
description: "The persisted types changed and the compatibility decision this record explains."
kind: persistence-change
---
```
## Skeleton
The generator fills the machine declaration, schema companion, and consistency records, inferring the minimum version decision from the fixed rules. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the complete operation. The following values illustrate the document fields; use the actual generated root names, record ids, and digests.
````markdown
# Persistence change: <specific persisted change>
English | [中文](YYYY-MM-DD-slug.zh.md)
## Summary
Describe the mechanically detected change and its version decision.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: YYYY-MM-DD-slug
baseline: false
changes:
- root: event:example/message
previous: YYYY-MM-DD-predecessor
after: <generated-64-character-sha256-hex>
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
Explain the declared decision for the detected paths. For an optional addition, explain why existing records can omit it and older readers can ignore it without changing replay. For required-to-optional changes, explain how readers handle absence. For a version bump, link the adjacent migration and its verification owner.
<a id="verification"></a>
## Verification
Record the commands actually run and their outcomes. Link focused tests or preserved evidence that support the compatibility explanation.
<a id="dev-note"></a>
## Dev Note
None.
````
## Rules
- Keep exactly one `yaml persistence-change` block. The English and Chinese blocks are byte-identical; the verifier parses the English record once and checks its counterpart.
- The filename stem and `id` agree. A root's `previous` identifies its predecessor record; `null` introduces a root. Its `after` matches the generated companion schema digest; `null` records deletion.
- One initial `baseline: true` record covers the complete inventory. Routine changes use `baseline: false` and preserve accepted predecessors. A version-bump record carries its own increasing `SessionHeader.version` transition.
- Omit `--decision` in the ordinary authoring flow. An explicit value asserts the decision and cannot bypass classification or the required header increase. The command supplies no source changes or validation claims.
- Explain only the detected persistence-type changes. This kind permits the before/after facts and historical evidence needed for that transition; behavior-only changes remain out of scope.
- Record creation and terminal-record update generate the catalog pair and consistency sidecars. Review both languages and retain the generated companions. Direct prose edits still require re-recording pairing; do not hand-maintain schema nodes or digests.
- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. Accepted records receive successors.
- The [folder reference](../../../../docs/persistence-changes/README.md) owns compatibility rules; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) owns commands and recovery.
+4
View File
@@ -11,3 +11,7 @@
# Pairing records are generated from the three confirmed owner-blob pairs.
# The worktree-local installer registers the fail-closed driver command.
*.i18n.yaml merge=dsh-translation-pairing
# The persistence catalog is the readable projection of these generated schemas.
docs/persistence-schema.json linguist-generated=true
docs/persistence-changes/*.schema.json linguist-generated=true
+4 -3
View File
@@ -24,6 +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) | Historical acknowledgements of mechanically detected persistence-type changes, with generated schema companions | 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 |
@@ -35,7 +36,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb
## Writing rules
- **Document current state, not change history.** Name live mechanisms, not PRs, commits, stack positions, or "previously/now/no longer". Keep history in commits, PRs, Agent Notes, or postmortems. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence.
- **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped `persistence-change` records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence.
- **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)).
- **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
@@ -60,8 +61,8 @@ Ceilings are guardrails, not reduction targets. At or below target, retain at le
Hunt these in any doc; [dsh-doc](../.agents/skills/dsh-doc/SKILL.md) runs this list as an audit:
- The same rule stated in more than one home. Grep a distinctive phrase; keep one home and link the rest.
- Narrated history or war stories: "previously", "now", "no longer", "used to", "renamed", "was moved", PRs, or commits. State the current fact; link an Agent Note or postmortem when needed.
- Duplicated rules: search a distinctive phrase; keep one home and link the rest.
- History outside its permitted tier: state current facts and link the historical owner.
- Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it.
- Hand-restated catalogs, JSDoc, or inventories of tests, packages, and status when source or a generator is authoritative.
- Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it.
@@ -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/cookbook/reviewing-persistence-type-changes.md
reviewing-persistence-type-changes.md: 881bc689af656f0c4976e909a49848e38d53c453
reviewing-persistence-type-changes.zh.md: f1961013886cfab1abdbd7e2cd7e09bcfce2101d
@@ -0,0 +1,108 @@
---
description: "Generate, acknowledge, and verify Session persistence-type changes locally before opening a pull request."
---
# Cookbook: reviewing persistence-type changes
English | [中文](reviewing-persistence-type-changes.zh.md)
## Summary
Use this tutorial after changing a declared Session persistence type in a contributor checkout with dependencies installed. Supply a bilingual compatibility explanation, then let one command classify the change and generate its records. The [record reference](../persistence-changes/README.md) explains the files and automatic rules. All comparison inputs live in the checkout; no base branch or network access is required.
## Table of Contents
- [Optional: inspect the change](#generate)
- [1. Record the change](#acknowledge)
- [2. Check, commit, and push](#verify)
- [Update an unaccepted record](#competing-records)
- [Dev Note](#dev-note)
-----
<a id="generate"></a>
## Optional: inspect the change
For a preview before recording, run from the repository root:
```sh
pnpm --silent run verify-persistence-changes --json
```
Use `--silent` when consuming JSON: pnpm otherwise appends lifecycle failure text to stdout. Failed commands still exit with code 1.
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.
<a id="acknowledge"></a>
## 1. Record the change
Write a local JSON file containing `en` and `zh`, each with `summary`, `compatibility`, and `verification` strings. The following input describes an exercised required-to-optional hook audit field change. Replace the explanation and test evidence with facts about your change; the CLI does not establish these claims.
Save the input as `.artifacts/persistence-change.prose.json`, creating the ignored directory if needed:
```json
{
"en": {
"summary": "Makes the persisted hook audit decision optional.",
"compatibility": "Existing records remain valid. Hook execution consumes HookOutput instead of replaying this audit field. Producers still write decisions, and absence does not imply pass.",
"verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts: 10 tests passed."
},
"zh": {
"summary": "将持久化的钩子审计决策改为可选。",
"compatibility": "已有记录仍然有效。钩子执行消费 HookOutput,不回放此审计字段。写入方仍然记录决策,缺失不代表 pass。",
"verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts:10 个测试通过。"
}
}
```
Use a date and descriptive slug in place of this example id:
```sh
pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
The command validates the history and paired prose, infers the minimum version decision, and checks any required header increase before writing. It generates the record pair, complete after schemas, both catalogs, the machine inventory, and pairing records. Review the explanations and returned `changes`, `roots`, and `files` before committing. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed.
Inference follows the [fixed compatibility rules](../persistence-changes/README.md#compatibility-rules); it never changes source or relaxes them. If a bump is required, first follow [adding a Session format version](adding-a-session-format-version.md). The record must include its own increasing `SessionHeader.version` transition; an unrelated historical bump cannot authorize it. Routine changes never create another baseline.
<a id="verify"></a>
## 2. Check, commit, and push
Select the changed owner's behavior checks through the [testing policy](../testing.md), then run the documentation checks:
```sh
pnpm run doc-sync
```
`doc-sync` checks persistence inventory and catalog freshness, the complete history, and bilingual pairing. A recording command's `ok: true` does not replace these checks or the owner's behavior and migration tests. JSON failures retain `ok: false`, a diagnostic `code`, and exit code 1. Structured changes include stable kinds and per-root before/after digests, so automation need not parse descriptions.
Record generation owns its catalog and record pairs; edits to a package README or other bilingual page still follow their normal pairing workflow. Review and stage the intended diff, then commit and push normally. The staged lint, pairing, and whitespace hooks and the pre-push Host/Client typecheck still apply.
<a id="competing-records"></a>
## Update an unaccepted record
If source changes after recording, review the compatibility explanation and refresh the same unaccepted terminal record:
```sh
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Update refuses the initial baseline and records that another record depends on. The tree cannot identify which records were accepted in review: preserve accepted history and create a successor instead.
When integration creates competing terminal records, update the unaccepted record against the remaining history, then reassess the resulting diff. An unrelated root's acknowledgement does not need refreshing. The [mechanism decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) explains why complete snapshots and per-root predecessors are retained.
An explicit `--decision` remains a checked assertion. For an existing property's value-type change, the following deliberately wrong assertion fails before writing:
```sh
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json
```
<a id="dev-note"></a>
## Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -0,0 +1,108 @@
---
description: "在创建 PR 前,本地生成、确认并验证会话持久化类型变更。"
---
# 实操手册:审阅持久化类型变更
[English](reviewing-persistence-type-changes.md) | 中文
## 概述
在已安装依赖的贡献者检出目录中修改会话持久化类型声明后,使用本教程。提供双语兼容性说明,再用一条命令分类变更并生成记录。[记录参考](../persistence-changes/README.zh.md)解释文件和自动规则。所有比较输入都在检出目录中;不需要基线分支或网络访问。
## 目录
- [可选:检查变更](#generate)
- [1. 记录变更](#acknowledge)
- [2. 检查、提交并推送](#verify)
- [更新尚未接受的记录](#competing-records)
- [开发备注](#dev-note)
-----
<a id="generate"></a>
## 可选:检查变更
若需在记录前预览,在仓库根目录运行:
```sh
pnpm --silent run verify-persistence-changes --json
```
消费 JSON 时使用 `--silent`:否则 pnpm 会把生命周期失败文本追加到标准输出。失败命令仍以退出码 1 结束。
阅读报告中的根、路径、变更种类和版本要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。
<a id="acknowledge"></a>
## 1. 记录变更
编写包含 `en` 和 `zh` 的本地 JSON 文件,两者分别包含 `summary`、`compatibility` 和 `verification` 字符串。以下输入描述一个经过验证的钩子审计字段从必选改为可选的变更。用你所做变更的事实替换说明和测试证据;CLI(命令行界面)不会证明这些声明。
将输入保存为 `.artifacts/persistence-change.prose.json`,必要时创建该被忽略的目录:
```json
{
"en": {
"summary": "Makes the persisted hook audit decision optional.",
"compatibility": "Existing records remain valid. Hook execution consumes HookOutput instead of replaying this audit field. Producers still write decisions, and absence does not imply pass.",
"verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts: 10 tests passed."
},
"zh": {
"summary": "将持久化的钩子审计决策改为可选。",
"compatibility": "已有记录仍然有效。钩子执行消费 HookOutput,不回放此审计字段。写入方仍然记录决策,缺失不代表 pass。",
"verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts:10 个测试通过。"
}
}
```
用日期和描述性短名替换示例 id:
```sh
pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
命令在写入前验证历史和双语说明、推断最低版本决策,并检查所需的头部版本递增。它生成记录对、完整的变更后 schema、两份目录、机器清单和配对记录。提交前审阅说明及返回的 `changes`、`roots` 和 `files`。省略 `--prose` 会创建未完成草稿,验证将拒绝它们,直到说明补齐。
推断遵循[固定兼容性规则](../persistence-changes/README.zh.md#compatibility-rules),不会更改源码或放宽规则。需要升版本时,先遵循[添加会话格式版本](adding-a-session-format-version.zh.md)。记录必须包含其自身的 `SessionHeader.version` 递增转换;无关的历史升版本不能授权它。日常变更不创建另一条基线。
<a id="verify"></a>
## 2. 检查、提交并推送
根据[测试政策](../testing.zh.md)选择变更所属模块的行为检查,再运行文档检查:
```sh
pnpm run doc-sync
```
`doc-sync` 检查持久化清单和目录新鲜度、完整历史及双语配对。记录命令的 `ok: true` 不能替代这些检查,也不能替代所属模块的行为与迁移测试。JSON 失败响应保留 `ok: false`、诊断 `code` 和退出码 1。结构化变更包含稳定种类和逐根的变更前后摘要,自动化无需解析描述文本。
记录生成负责其目录和记录的双语对;包 README 或其他双语页面的编辑仍遵循常规配对流程。审阅并暂存预期差异,然后正常提交和推送。暂存 lint、配对、空白 hooks,以及 pre-push Host/Client 类型检查仍须执行。
<a id="competing-records"></a>
## 更新尚未接受的记录
记录后源码再次变化时,审阅兼容性说明,并刷新同一条尚未接受的末端记录:
```sh
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。更新会拒绝初始基线和被其他记录依赖的记录。目录本身无法识别哪些记录已获审阅接受:保留已接受历史,并创建后继。
集成产生竞争末端记录时,根据剩余历史更新尚未接受的记录,再重新评估最终差异。无关根的确认无需刷新。[机制决策](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)解释为何保留完整快照和逐根前驱。
显式 `--decision` 仍是受检查的断言。若已有属性的值类型发生变化,下面这个故意错误的断言会在写入前失败:
```sh
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json
```
<a id="dev-note"></a>
## 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
+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/i18n/README.md
README.md: 2ff8fc62f21d58a4d31b8aadd80c7a0c14556e6d
README.zh.md: 2e0c183f6f9374c08d698fbd7109eb4e59f87c10
README.md: f9b6b97d51541a12315c8b4503bd9b4399220888
README.zh.md: 1f0ab74d3fbb8301dedb3c396c1b65334ffc8503
+5 -1
View File
@@ -43,7 +43,11 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
**Scope**: the root `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. A generator that owns both sides, such as the Cordis subsystem-region generator, projects paired document paths to each output locale while keeping every other generated byte equal. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.
Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. When a generator owns only English, it remains that source of truth; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Freshness and pairing gates enforce their respective invariants independently.
Shared generated regions, such as the Cordis subsystem regions, keep every generated byte identical across languages except localized paired-document paths. A full-page generator can instead render source-owned paired prose, such as [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts), from the same structural data with matching Markdown structure and identical code blocks. It regenerates both pages and their consistency record together; review still verifies the paired prose's meaning and terminology.
Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A manually maintained Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.
**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):
+5 -1
View File
@@ -45,7 +45,11 @@
**范围**:根目录 `CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。Cordis subsystem 区块生成器等同时拥有两侧输出的生成器,会把配对文档路径投影到各自 locale,同时保持其余生成字节一致。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。
有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器只负责英文时,它仍是该语言的真源;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。新鲜度门禁与配对门禁各自独立强制其约束。
共享生成区域(例如 Cordis 子系统区域)在两种语言间保持每个生成字节相同,仅配对文档路径按语言本地化。整页生成器则可以从相同的结构数据渲染源码维护的双语说明,例如 [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts),并保持 Markdown 结构对应、代码块完全相同。它同时重新生成两页及其一致性记录;评审仍须验证双语说明的含义与术语。
生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。人工维护的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。
**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
+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: 7d6c9649628596b04dbe1d75c564a7f5965be940
persistence-catalog.zh.md: c1736a1f5654f82ea9995b6389630648fc8185eb
persistence-catalog.md: 4f884b2f990207fb2e2ba763191d20e2b4a173f3
persistence-catalog.zh.md: bfc8aabb97d303109ffb26d8f4a9abc18693f789
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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-changes/2026-09-11-initial.md
2026-09-11-initial.md: a919aba314a7e94242ab831a588e2112997edd8f
2026-09-11-initial.zh.md: e37d0c48219e8ac4d5d30d84cca441c150210a0b
@@ -0,0 +1,280 @@
---
description: "Records a persistence type transition and its compatibility acknowledgement."
kind: persistence-change
---
# Initial persistence type baseline
English | [中文](2026-09-11-initial.zh.md)
## Summary
This baseline records the complete persistence type inventory declared by the current source and gives later transitions a starting point stored in this tree.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: 2026-09-11-initial
baseline: true
changes:
- root: "JsonlHeaderLine"
previous: null
after: "18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"
decision: same-version
- root: "SessionEventEnvelope"
previous: null
after: "5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"
decision: same-version
- root: "SessionHeader"
previous: null
after: "2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03"
decision: same-version
- root: "event:agent-preset/selected"
previous: null
after: "a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"
decision: same-version
- root: "event:agent/inbox/spliced"
previous: null
after: "f757322e25914d63799c6235af4ed4b4f36d7d083fef40d6873a45e94fe6ad65"
decision: same-version
- root: "event:approval/asked"
previous: null
after: "3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"
decision: same-version
- root: "event:approval/decided"
previous: null
after: "bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"
decision: same-version
- root: "event:approval/policy"
previous: null
after: "26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"
decision: same-version
- root: "event:assistant/attempt"
previous: null
after: "3516f54b427775f483febe8e938e4e10f9afd3583610861547bbe2999ed4d8d9"
decision: same-version
- root: "event:assistant/message"
previous: null
after: "a58ca1bed502d425a8b65cac02f59717498cd296de9513c6f81b83b791c20e9b"
decision: same-version
- root: "event:command/done"
previous: null
after: "15196447222782e773eb943c92b18316ce96b9af0f0cfddb6e57ba8274ecc5ff"
decision: same-version
- root: "event:command/run"
previous: null
after: "37184378c6439257d105c4e2022d80fc9c3a3f7c7f6ac661b00bc9f18d871006"
decision: same-version
- root: "event:compaction/end"
previous: null
after: "b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d"
decision: same-version
- root: "event:compaction/prune"
previous: null
after: "7f7fd5a6b0064f597534b29ff62ef26e786dffccf5e14f654a7d4fcea2c35f04"
decision: same-version
- root: "event:compaction/start"
previous: null
after: "db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"
decision: same-version
- root: "event:compaction/summary"
previous: null
after: "eb1d32dcd3d76833392f351e039dcbd40686a1ab24b252a7a5003426f9cf4c89"
decision: same-version
- root: "event:deliverables/presented"
previous: null
after: "13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"
decision: same-version
- root: "event:feedback/message-delete"
previous: null
after: "3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"
decision: same-version
- root: "event:feedback/message-put"
previous: null
after: "b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"
decision: same-version
- root: "event:feedback/record"
previous: null
after: "b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"
decision: same-version
- root: "event:goal/change"
previous: null
after: "763c8a20af487263a0080548274f7437ef4a86e0ba8769127d1ced10a72c664d"
decision: same-version
- root: "event:hook/invoked"
previous: null
after: "8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0"
decision: same-version
- root: "event:hook/result"
previous: null
after: "e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4"
decision: same-version
- root: "event:llm/retry"
previous: null
after: "91c397f8f870e812e1dc5ac69c2745f9f105be9dc97dadc980d19ef415a65145"
decision: same-version
- root: "event:llm/retry-started"
previous: null
after: "48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3"
decision: same-version
- root: "event:model/selection"
previous: null
after: "35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc"
decision: same-version
- root: "event:permission/preset"
previous: null
after: "5c45bf4c544a7211dcd8ba6ba7e5f1bc39b49e7a9df9d5cbdc8e87c22771b37b"
decision: same-version
- root: "event:plan/mode"
previous: null
after: "a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f"
decision: same-version
- root: "event:request/context"
previous: null
after: "37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf"
decision: same-version
- root: "event:request/header"
previous: null
after: "8ea13bcd983eab596dbfd41c5c2b62a0b318b71cd2a987a6fd752c6ced7e4489"
decision: same-version
- root: "event:sandbox/mode"
previous: null
after: "516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415"
decision: same-version
- root: "event:schedule/change"
previous: null
after: "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42"
decision: same-version
- root: "event:session-log-deepseek/delivery-accepted"
previous: null
after: "d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"
decision: same-version
- root: "event:session/end-seed"
previous: null
after: "5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"
decision: same-version
- root: "event:session/title"
previous: null
after: "1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"
decision: same-version
- root: "event:session/title-llm-request"
previous: null
after: "6091f64d426ada2adc646094a6fe662a1b81d9ee5770929fe76800524e2c89a8"
decision: same-version
- root: "event:step/end"
previous: null
after: "e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"
decision: same-version
- root: "event:step/start"
previous: null
after: "4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"
decision: same-version
- root: "event:subagent/catalog"
previous: null
after: "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871"
decision: same-version
- root: "event:subagent/descriptor"
previous: null
after: "b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5"
decision: same-version
- root: "event:subagent/model-selection-policy"
previous: null
after: "a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc"
decision: same-version
- root: "event:system/message"
previous: null
after: "0ae7ccdc1cc5d9d43772ebd5c7357ff509011c3a6ba188d9c274d33df2542c42"
decision: same-version
- root: "event:team/member"
previous: null
after: "4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc"
decision: same-version
- root: "event:team/message/delivered"
previous: null
after: "48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb"
decision: same-version
- root: "event:team/message/queued"
previous: null
after: "2ffcad547cb840f462eb1849fa5f6cb0e17eb93f5d07cc88b61d749b1b78d632"
decision: same-version
- root: "event:team/task"
previous: null
after: "d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a"
decision: same-version
- root: "event:todo/write"
previous: null
after: "b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26"
decision: same-version
- root: "event:tool-workflow/agent-end"
previous: null
after: "babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7"
decision: same-version
- root: "event:tool-workflow/agent-start"
previous: null
after: "5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627"
decision: same-version
- root: "event:tool-workflow/run-end"
previous: null
after: "42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f"
decision: same-version
- root: "event:tool-workflow/run-start"
previous: null
after: "c1f9e0405de6d18cabb9ee70782a027f9bbdc57e5abec9dcccdd56119e2e9058"
decision: same-version
- root: "event:tool/call"
previous: null
after: "3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48"
decision: same-version
- root: "event:tool/ptc-dispatch"
previous: null
after: "da807583a19f4ebe2bc9115c9686016e1521e405bdfaced296ae652b91e8e5f6"
decision: same-version
- root: "event:tool/ptc-dispatch-start"
previous: null
after: "ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93"
decision: same-version
- root: "event:tool/result"
previous: null
after: "cc3e270ab982cbafff602b753647b6f056decb7fe4fd523afd2f157a65c3d622"
decision: same-version
- root: "event:turn/end"
previous: null
after: "84c24f1209fc3e0153de6ac85d58fd6968b851f955e0e761b92321053956609e"
decision: same-version
- root: "event:turn/start"
previous: null
after: "aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"
decision: same-version
- root: "event:user/message"
previous: null
after: "2e49ede7fb3a592cce8919ebe982f6ca1c1f67abe4def359ffc028c85e6d1ad8"
decision: same-version
- root: "event:web/deepseek-search-llm-request"
previous: null
after: "cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331"
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
This record initializes type history without asserting relationships to earlier formats or changing the Session writer version or runtime behavior. Each root starts without a predecessor and retains its complete normalized schema; successor records reference its after schema.
<a id="verification"></a>
## Verification
Ran `pnpm exec tsx scripts/gen-persistence-catalog.ts` to generate the current inventory and `pnpm run persistence-changes --baseline 2026-09-11-initial` to create this baseline and its schema snapshot.
<a id="dev-note"></a>
## Dev Note
None.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,280 @@
---
description: "记录持久化类型更改及其兼容性确认。"
kind: persistence-change
---
# 初始持久化类型基线
[English](2026-09-11-initial.md) | 中文
## 概述
此基线记录当前源码声明的完整持久化类型目录,为后续类型转换提供保存在本源码树内的起点。
## 目录
- [声明](#declaration)
- [兼容性](#compatibility)
- [验证](#verification)
- [开发备注](#dev-note)
<a id="declaration"></a>
## 声明
```yaml persistence-change
schemaVersion: 1
id: 2026-09-11-initial
baseline: true
changes:
- root: "JsonlHeaderLine"
previous: null
after: "18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"
decision: same-version
- root: "SessionEventEnvelope"
previous: null
after: "5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"
decision: same-version
- root: "SessionHeader"
previous: null
after: "2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03"
decision: same-version
- root: "event:agent-preset/selected"
previous: null
after: "a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"
decision: same-version
- root: "event:agent/inbox/spliced"
previous: null
after: "f757322e25914d63799c6235af4ed4b4f36d7d083fef40d6873a45e94fe6ad65"
decision: same-version
- root: "event:approval/asked"
previous: null
after: "3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"
decision: same-version
- root: "event:approval/decided"
previous: null
after: "bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"
decision: same-version
- root: "event:approval/policy"
previous: null
after: "26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"
decision: same-version
- root: "event:assistant/attempt"
previous: null
after: "3516f54b427775f483febe8e938e4e10f9afd3583610861547bbe2999ed4d8d9"
decision: same-version
- root: "event:assistant/message"
previous: null
after: "a58ca1bed502d425a8b65cac02f59717498cd296de9513c6f81b83b791c20e9b"
decision: same-version
- root: "event:command/done"
previous: null
after: "15196447222782e773eb943c92b18316ce96b9af0f0cfddb6e57ba8274ecc5ff"
decision: same-version
- root: "event:command/run"
previous: null
after: "37184378c6439257d105c4e2022d80fc9c3a3f7c7f6ac661b00bc9f18d871006"
decision: same-version
- root: "event:compaction/end"
previous: null
after: "b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d"
decision: same-version
- root: "event:compaction/prune"
previous: null
after: "7f7fd5a6b0064f597534b29ff62ef26e786dffccf5e14f654a7d4fcea2c35f04"
decision: same-version
- root: "event:compaction/start"
previous: null
after: "db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"
decision: same-version
- root: "event:compaction/summary"
previous: null
after: "eb1d32dcd3d76833392f351e039dcbd40686a1ab24b252a7a5003426f9cf4c89"
decision: same-version
- root: "event:deliverables/presented"
previous: null
after: "13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"
decision: same-version
- root: "event:feedback/message-delete"
previous: null
after: "3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"
decision: same-version
- root: "event:feedback/message-put"
previous: null
after: "b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"
decision: same-version
- root: "event:feedback/record"
previous: null
after: "b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"
decision: same-version
- root: "event:goal/change"
previous: null
after: "763c8a20af487263a0080548274f7437ef4a86e0ba8769127d1ced10a72c664d"
decision: same-version
- root: "event:hook/invoked"
previous: null
after: "8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0"
decision: same-version
- root: "event:hook/result"
previous: null
after: "e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4"
decision: same-version
- root: "event:llm/retry"
previous: null
after: "91c397f8f870e812e1dc5ac69c2745f9f105be9dc97dadc980d19ef415a65145"
decision: same-version
- root: "event:llm/retry-started"
previous: null
after: "48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3"
decision: same-version
- root: "event:model/selection"
previous: null
after: "35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc"
decision: same-version
- root: "event:permission/preset"
previous: null
after: "5c45bf4c544a7211dcd8ba6ba7e5f1bc39b49e7a9df9d5cbdc8e87c22771b37b"
decision: same-version
- root: "event:plan/mode"
previous: null
after: "a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f"
decision: same-version
- root: "event:request/context"
previous: null
after: "37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf"
decision: same-version
- root: "event:request/header"
previous: null
after: "8ea13bcd983eab596dbfd41c5c2b62a0b318b71cd2a987a6fd752c6ced7e4489"
decision: same-version
- root: "event:sandbox/mode"
previous: null
after: "516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415"
decision: same-version
- root: "event:schedule/change"
previous: null
after: "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42"
decision: same-version
- root: "event:session-log-deepseek/delivery-accepted"
previous: null
after: "d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"
decision: same-version
- root: "event:session/end-seed"
previous: null
after: "5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"
decision: same-version
- root: "event:session/title"
previous: null
after: "1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"
decision: same-version
- root: "event:session/title-llm-request"
previous: null
after: "6091f64d426ada2adc646094a6fe662a1b81d9ee5770929fe76800524e2c89a8"
decision: same-version
- root: "event:step/end"
previous: null
after: "e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"
decision: same-version
- root: "event:step/start"
previous: null
after: "4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"
decision: same-version
- root: "event:subagent/catalog"
previous: null
after: "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871"
decision: same-version
- root: "event:subagent/descriptor"
previous: null
after: "b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5"
decision: same-version
- root: "event:subagent/model-selection-policy"
previous: null
after: "a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc"
decision: same-version
- root: "event:system/message"
previous: null
after: "0ae7ccdc1cc5d9d43772ebd5c7357ff509011c3a6ba188d9c274d33df2542c42"
decision: same-version
- root: "event:team/member"
previous: null
after: "4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc"
decision: same-version
- root: "event:team/message/delivered"
previous: null
after: "48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb"
decision: same-version
- root: "event:team/message/queued"
previous: null
after: "2ffcad547cb840f462eb1849fa5f6cb0e17eb93f5d07cc88b61d749b1b78d632"
decision: same-version
- root: "event:team/task"
previous: null
after: "d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a"
decision: same-version
- root: "event:todo/write"
previous: null
after: "b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26"
decision: same-version
- root: "event:tool-workflow/agent-end"
previous: null
after: "babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7"
decision: same-version
- root: "event:tool-workflow/agent-start"
previous: null
after: "5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627"
decision: same-version
- root: "event:tool-workflow/run-end"
previous: null
after: "42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f"
decision: same-version
- root: "event:tool-workflow/run-start"
previous: null
after: "c1f9e0405de6d18cabb9ee70782a027f9bbdc57e5abec9dcccdd56119e2e9058"
decision: same-version
- root: "event:tool/call"
previous: null
after: "3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48"
decision: same-version
- root: "event:tool/ptc-dispatch"
previous: null
after: "da807583a19f4ebe2bc9115c9686016e1521e405bdfaced296ae652b91e8e5f6"
decision: same-version
- root: "event:tool/ptc-dispatch-start"
previous: null
after: "ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93"
decision: same-version
- root: "event:tool/result"
previous: null
after: "cc3e270ab982cbafff602b753647b6f056decb7fe4fd523afd2f157a65c3d622"
decision: same-version
- root: "event:turn/end"
previous: null
after: "84c24f1209fc3e0153de6ac85d58fd6968b851f955e0e761b92321053956609e"
decision: same-version
- root: "event:turn/start"
previous: null
after: "aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"
decision: same-version
- root: "event:user/message"
previous: null
after: "2e49ede7fb3a592cce8919ebe982f6ca1c1f67abe4def359ffc028c85e6d1ad8"
decision: same-version
- root: "event:web/deepseek-search-llm-request"
previous: null
after: "cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331"
decision: same-version
```
<a id="compatibility"></a>
## 兼容性
该记录初始化类型历史,不声明更早版本的类型关系,也不改变 Session writer 版本或运行时行为。每个根类型从空前驱开始,并保留完整的规范化 schema;后续记录引用本记录的 after schema。
<a id="verification"></a>
## 验证
已运行 `pnpm exec tsx scripts/gen-persistence-catalog.ts` 生成当前目录,并运行 `pnpm run persistence-changes --baseline 2026-09-11-initial` 创建此基线及其 schema 快照。
<a id="dev-note"></a>
## 开发备注
无。
@@ -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-changes/README.md
README.md: 99cb69ca17f7368c1f1467fd22ee7ac1594532e8
README.zh.md: ef3198426336d98ebce1f9264b378f7dbc9e2336
+70
View File
@@ -0,0 +1,70 @@
---
description: "Review and maintain recorded Session persistence-type changes, their schema snapshots, and compatibility decisions."
---
# Persistence-type change records
English | [中文](README.zh.md)
## Summary
Use this reference to inspect an acknowledged Session persistence-type change and its predecessor. Each record binds a compatibility decision to exact generated schemas. Local checks compare the current source with the recorded history using only files in the checkout. Start with the [review cookbook](../cookbook/reviewing-persistence-type-changes.md) when changing a persisted type.
## Table of Contents
- [Files and ownership](#files-and-ownership)
- [Compatibility rules](#compatibility-rules)
- [History and limitations](#history-and-limitations)
- [Dev Note](#dev-note)
-----
<a id="files-and-ownership"></a>
## Files and ownership
The generated [catalog](../persistence-catalog.md) provides readable declarations and digests; the [schema inventory](../persistence-schema.json) contains the normalized types. Roots cover the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Referenced types contribute transitively to each affected root's digest.
Each dated record has four sibling files:
| File | Owner |
|---|---|
| `YYYY-MM-DD-slug.md` | English acknowledgement with `kind: persistence-change`, one machine declaration, compatibility reasoning, and verification evidence |
| `YYYY-MM-DD-slug.zh.md` | Chinese counterpart with the identical machine declaration |
| `YYYY-MM-DD-slug.i18n.yaml` | Generated bilingual consistency record |
| `YYYY-MM-DD-slug.schema.json` | Generated complete after schemas for the affected roots that remain present |
The [record template](../../.agents/skills/dsh-doc/templates/persistence-change.md) defines the authored format. Record creation accepts a bilingual prose input and generates the machine declaration, snapshots, catalog pair, and consistency records. The verifier reads the machine declaration once from the English file and checks the Chinese declaration for equality. A declaration names each affected root, its predecessor record, its after digest, and its compatibility decision. A new root has no predecessor; a deletion has no after schema and retains an explicit tombstone.
<a id="compatibility-rules"></a>
## Compatibility rules
Every detected structural change requires an acknowledgement. Record creation and update infer the minimum decision from these fixed rules; an explicit `--decision` is a checked assertion. The rules apply to the complete change, so an allowed change cannot hide a simultaneous breaking change.
| Detected change | Minimum decision |
|---|---|
| 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` |
| 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` |
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>
## History and limitations
One baseline records the complete initial inventory. Later records use their predecessor's after schema as the before schema. The verifier rejects missing predecessors, cycles, duplicate successors for one root, digest mismatches, and current roots that disagree with their latest records. Independent roots can advance independently. Two changes to the same predecessor require a single ordered history after integration.
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.
<a id="dev-note"></a>
## Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
+70
View File
@@ -0,0 +1,70 @@
---
description: "审阅和维护已记录的会话持久化类型变更、对应 schema 快照以及兼容性决策。"
---
# 持久化类型变更记录
[English](README.md) | 中文
## 概述
本参考文档用于检查已确认的会话持久化类型变更及其前驱。每条记录把兼容性决策绑定到精确的生成 schema。本地检查仅使用当前检出目录中的文件,将当前源码与记录历史比较。修改持久化类型时,从[审阅实操手册](../cookbook/reviewing-persistence-type-changes.zh.md)开始。
## 目录
- [文件与职责](#files-and-ownership)
- [兼容性规则](#compatibility-rules)
- [历史与限制](#history-and-limitations)
- [开发备注](#dev-note)
-----
<a id="files-and-ownership"></a>
## 文件与职责
生成的[目录](../persistence-catalog.zh.md)提供可读声明和摘要;[schema 清单](../persistence-schema.json)包含规范化类型。根覆盖逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。被引用类型的变更会传递到每个受影响根的摘要。
每条带日期的记录有四个同目录文件:
| 文件 | 职责 |
|---|---|
| `YYYY-MM-DD-slug.md` | 英文确认文档,包含 `kind: persistence-change`、一份机器声明、兼容性说明和验证证据 |
| `YYYY-MM-DD-slug.zh.md` | 中文对侧文件,包含相同的机器声明 |
| `YYYY-MM-DD-slug.i18n.yaml` | 生成的双语一致性记录 |
| `YYYY-MM-DD-slug.schema.json` | 生成的完整变更后 schema,覆盖受影响且仍然存在的根 |
[记录模板](../../.agents/skills/dsh-doc/templates/persistence-change.md)定义人工编写的格式。创建记录时可以提供双语说明输入,由命令生成机器声明、快照、目录对和一致性记录。验证器从英文文件读取一次机器声明,并检查中文声明是否相同。声明列出每个受影响的根、其前驱记录、变更后摘要和兼容性决策。新根没有前驱;删除操作没有变更后 schema,并保留显式删除标记。
<a id="compatibility-rules"></a>
## 兼容性规则
每个检测到的结构变更都需要确认。创建和更新记录依据这些固定规则推断最低决策;显式 `--decision` 是受检查的断言。规则应用于整个变更,因此允许的变更不能掩盖同时发生的破坏性变更。
| 检测到的变更 | 最低决策要求 |
|---|---|
| 添加可选事件体属性,包括其完整子树 | `same-version` |
| 将必选事件体属性改为可选 | `same-version` |
| 添加普通事件类型 | `same-version` |
| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` |
| 更改会话头或事件封装 | `version-bump` |
同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。
<a id="history-and-limitations"></a>
## 历史与限制
一条基线记录完整的初始清单。后续记录使用前驱的变更后 schema 作为变更前 schema。验证器拒绝缺失前驱、环、同一根的重复后继、摘要不匹配,以及当前根与最新记录不一致的情况。独立的根可以独立演进。同一前驱上的两个变更在集成后需要形成一条有序历史。
已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。尚未接受的末端记录可以显式刷新;命令拒绝基线及被依赖的记录。目录本身无法证明审阅接受状态。验证证明目录内的一致性,不证明历史从未被改写。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。
摘要描述声明的持久化类型,不描述运行时验证或行为。注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
<a id="dev-note"></a>
## 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
+60454
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -158,6 +158,8 @@
"verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check",
"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",
"verify-persistence-changes": "tsx scripts/persistence-changes.ts --check",
"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",
+12
View File
@@ -42,6 +42,7 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
'package-reference': '.agents/skills/dsh-doc/templates/package-reference.md',
'package-library': '.agents/skills/dsh-doc/templates/package-library.md',
'package-bundle': '.agents/skills/dsh-doc/templates/package-bundle.md',
'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md',
}
/**
@@ -350,6 +351,17 @@ describe('dsh-doc skill consolidation', () => {
}
})
it('maps persistence transition records to their dedicated document kind', () => {
const files = globSync('docs/persistence-changes/*.md', { cwd: root })
.filter(file => !/\/README(?:\.zh)?\.md$/u.test(file.replaceAll('\\', '/')))
expect(files.length).toBeGreaterThan(0)
for (const file of files) {
const metadata = readFrontmatter(file)
expect(metadata.kind, file).toBe('persistence-change')
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)
+63 -324
View File
@@ -6,36 +6,31 @@
* surface-union member must resolve to one. `--check` verifies the artifact.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { githubSlug } from './verify-md-links.ts'
import {
annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes,
type AnnotatedLogEventEntry, type EventEnvelopeTypeEntry,
} from './persistence-catalog-source.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
const OUT_SCHEMA = 'docs/persistence-schema.json'
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
const FENCE = 'ts persistence-catalog'
/** The package that owns the durable event vocabulary. */
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
/** The type-only module that plugin declaration merges augment. */
const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types'
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
/** Documentation target, relative to `docs/`, for linked payload types. */
const LINK_MAP: Record<string, string> = {
ToolCallId: 'subsystems/core.md',
@@ -59,335 +54,72 @@ const LINK_MAP: Record<string, string> = {
TeamTaskSnapshot: 'subsystems/agent-team.md',
}
/** One log event, extracted from a `SessionEventMap` declaration. */
export interface LogEventEntry {
/** Scoped name, e.g. `turn/start`. */
name: string
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
export interface AnnotatedLogEventEntry extends LogEventEntry {
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
surface: boolean
}
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name: EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
const printer = ts.createPrinter({ removeComments: true })
/**
* Render a member type on one line through the TypeScript printer, which adds
* semicolon separators. Drop its trailing semicolon before `}` to match the
* repository's inline-literal style.
*/
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
.replace(/\s+/g, ' ')
.replace(/;\s*\}/g, ' }')
.trim()
}
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
const nodeStart = node.getStart(sf)
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return text.slice(lineStart, node.end)
.split('\n')
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
.trimEnd()
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
* merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms
* declare members of the SAME merged interface, so both are catalogued
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
* it actually lives in the owning package — an unrelated local interface that
* happens to share the name must not be catalogued as the on-disk vocabulary.
*/
function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] {
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
for (const stmt of sf.statements) {
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE
&& stmt.body && ts.isModuleBlock(stmt.body)) {
for (const inner of stmt.body.statements) {
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
}
}
}
return decls
}
/**
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
* from that package's manifest — or null when the manifest is missing or
* unparseable (the caller treats null as "ownership unverifiable").
*/
function packageNameFor(rel: string, scanRoot: string): string | null {
const dir = rel.split('/').slice(0, 3).join('/')
try {
const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string }
return typeof manifest.name === 'string' ? manifest.name : null
} catch {
// Missing or malformed package.json — every real workspace package has one,
// so this only arises in stripped-down fixture trees; either way ownership
// cannot be verified and the caller reports the declaration.
return null
}
}
/**
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
*/
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const entries: LogEventEntry[] = []
const violations: string[] = []
const seen = new Map<string, string>()
let owningDecl: string | null = null
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SessionEventMap')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
const declSrc = pointer(rel, sf, decl)
if (topLevel) {
// The top-level form has one home: the single exported declaration in
// the owning package. Same-named interfaces elsewhere are different
// types and must not enter the on-disk catalog.
const pkg = packageNameFor(rel, scanRoot)
if (pkg !== SESSION_PACKAGE) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`)
continue
}
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
if (!exported) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`)
continue
}
if (owningDecl) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`)
continue
}
owningDecl = declSrc
}
if (decl.heritageClauses?.length) {
violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`)
}
for (const member of decl.members) {
const src = pointer(rel, sf, member)
if (!ts.isPropertySignature(member) || !member.type) {
// A method-form or type-less member still joins `keyof SessionEventMap`,
// so skipping it silently would be exactly the undocumented-event hole
// this catalog exists to close.
const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ')
violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`)
continue
}
if (!ts.isStringLiteral(member.name)) {
violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`)
continue
}
const name = member.name.text
const where = `log event '${name}' (${src})`
const prior = seen.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
continue
}
seen.set(name, src)
const payload = payloadText(member.type, sf)
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
if (hasMode) {
violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`)
}
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
const declaration = declarationText(text, sf, member)
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
}
}
}
reportViolations('gen-persistence-catalog', violations)
return entries
}
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
const violations: string[] = []
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
const name = stmt.name.text as EventEnvelopeTypeName
const src = pointer(rel, sf, stmt)
const where = `event-envelope type '${name}' (${src})`
const prior = found.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
continue
}
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
violations.push(`${where} is not exported.`)
}
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
if (missing.length > 0) {
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
}
reportViolations('gen-persistence-catalog', violations)
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
const entry = found.get(name)
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
return entry
})
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
* than once, or contains a non-string-literal member: the badge derivation
* relies on the union being a closed set of literal event names.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
const found: { names: string[]; source: string }[] = []
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SurfaceEventType')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue
const src = pointer(rel, sf, stmt)
const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type]
const names: string[] = []
for (const m of members) {
if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text)
else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`)
}
found.push({ names, source: src })
}
}
const only = found[0]
if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.')
if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`)
return only.names
}
/**
* Attach the surface/log-only badge to each event. Hard-errors when a
* `SurfaceEventType` union member names no collected event — a stale union
* member would otherwise silently badge nothing.
*/
export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] {
const names = new Set(events.map(e => e.name))
const stale = surfaceTypes.filter(t => !names.has(t))
if (stale.length > 0) {
throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`)
}
const surface = new Set(surfaceTypes)
return events.map(e => ({ ...e, surface: surface.has(e.name) }))
}
/** Render the cross-link "Types:" line for a payload, or '' if none apply. */
function typeLinks(payload: string): string {
function typeLinks(payload: string, locale: PersistenceCatalogLocale): string {
const seen = new Set<string>()
for (const name of Object.keys(LINK_MAP)) {
if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
}
if (seen.size === 0) return ''
const links = [...seen].sort().map(n => `[${n}](${LINK_MAP[n]})`)
return `Types: ${links.join(' · ')}`
const links = Object.entries(LINK_MAP).filter(([name]) => seen.has(name)).sort(([left], [right]) => left.localeCompare(right))
.map(([name, path]) => {
return `[${name}](${locale === 'zh' ? path.replace(/\.md$/u, '.zh.md') : path})`
})
return `${persistenceCatalogText[locale].types}${links.join(' · ')}`
}
/** Render one log event entry. */
function renderEvent(e: AnnotatedLogEventEntry): string[] {
function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale): string[] {
const heading = `${e.name} — ${e.surface ? 'surface' : 'log-only'}`
const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
out.push('```' + FENCE, e.declaration, '```', '')
const links = typeLinks(e.payload)
const links = typeLinks(e.payload, locale)
if (links) out.push(links, '')
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
out.push(`${persistenceCatalogText[locale].source}[\`${e.source}\`](../${e.source.split(':')[0]})`, '')
return out
}
/** Render the full catalog (pure, deterministic given the collected inputs). */
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
export function render(
events: AnnotatedLogEventEntry[],
envelopeTypes: EventEnvelopeTypeEntry[],
schema?: PersistenceSchemaInventory,
locale: PersistenceCatalogLocale = 'en',
): string {
const text = persistenceCatalogText[locale]
const lines: string[] = [
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
'',
'# Session Persistence Event Catalog',
`# ${text.title}`,
'',
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
...(locale === 'zh' ? ['[English](persistence-catalog.md) | 中文', ''] : []),
text.intro,
'',
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. The archived [persistence-log-catalog record](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md) documents the original catalog decision.',
text.generation,
'',
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`). Current writers stamp `SESSION_FORMAT_VERSION`; supported historical artifacts reach this current vocabulary through the build-static adjacent migration catalog ([the version lifecycle](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further current-version event types, which are outside this catalog by construction and require an explicit disposition at a later format edge.',
text.envelopeIntro,
'',
'## Event envelope',
...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
`## ${text.envelope}`,
'',
'```' + FENCE,
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
'```',
'',
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
`${text.sources}${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
'',
'## Events',
`## ${text.events}`,
'',
]
const scopes = [...new Set(events.map(e => e.scope))].sort()
for (const scope of scopes) {
lines.push(`### \`${scope}/*\``, '')
for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
lines.push(...renderEvent(e))
lines.push(...renderEvent(e, locale))
}
}
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
return lines.join('\n')
}
@@ -427,26 +159,33 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
].join('\n')
}
/** One generated artifact: repo-relative target and its freshly-rendered content. */
interface GeneratedArtifact {
readonly out: string
readonly content: string
/**
* Render the complete generated persistence reference from one extracted inventory.
* @param scanRoot - checkout whose source event declarations supply JSDoc.
* @param schema - already extracted source schemas; shared with record validation.
* @returns both catalog languages, pairing metadata, runtime names and machine schemas.
*/
export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
const envelope = collectEventEnvelopeTypes(scanRoot)
return [
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
{ path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
]
}
/** CLI entry: default writes the artifacts, `--check` fails if a committed copy
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed files nor calls process.exit. */
function main(): void {
const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
const artifacts: GeneratedArtifact[] = [
{ out: OUT, content: render(events, collectEventEnvelopeTypes()) },
{ out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
]
const schema = extractPersistenceSchema(root)
const artifacts = persistenceCatalogArtifacts(root, schema)
if (process.argv.includes('--check')) {
const stale = artifacts.filter((artifact) => {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, artifact.out), 'utf8')
committed = readFileSync(resolve(root, artifact.path), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
@@ -456,16 +195,16 @@ function main(): void {
return committed !== artifact.content
})
if (stale.length === 0) {
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.path).join(', ')} are up to date.`)
process.exit(0)
}
console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
console.error(`gen-persistence-catalog: ${stale.map(a => a.path).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
process.exit(1)
}
for (const artifact of artifacts) {
writeFileSync(resolve(root, artifact.out), artifact.content)
console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
writeFileSync(resolve(root, artifact.path), artifact.content)
console.log(`gen-persistence-catalog: wrote ${artifact.path}.`)
}
}
+53
View File
@@ -0,0 +1,53 @@
/** Render complete persistence documentation pairs without Git or file mutation. */
import { readFileSync } from 'node:fs'
import { hasLanguageSwitcher } from './translation-links.ts'
import { translationPairPaths } from './translation-pairing-record.ts'
import {
blobHash, languageSwitcherTargets, parseTranslationMarkdown, parseTranslationPairingManifest,
renderPairMeta, requiresSourceLanguageSwitcher, translationPairSourcePredicate,
translationStructureDiff, translationStructureSignature,
} from './translation-pairing.ts'
const isTranslationPairSource = translationPairSourcePredicate(parseTranslationPairingManifest(
readFileSync(new URL('./translation-pairing.manifest.json', import.meta.url), 'utf8'),
))
/** One repository-relative generated file and its complete UTF-8 content. */
export interface PersistenceArtifact {
readonly path: string
readonly content: string
}
/**
* Check a pair's code, structure and localized links, then render its three files.
* Link existence remains the Markdown gate's responsibility. Pair hashes are
* computed in-process; staging the documents stores the corresponding Git blobs.
* @param root - checkout root used to resolve relative link identities.
* @param source - repository-relative English document path.
* @param en - complete authored or generated English Markdown.
* @param zh - complete authored or generated Chinese Markdown.
* @returns the documents and their matching consistency sidecar, without writing files.
*/
export function renderPersistencePair(root: string, source: string, en: string, zh: string): PersistenceArtifact[] {
const paths = translationPairPaths(source)
const sourceTree = parseTranslationMarkdown(en)
const zhTree = parseTranslationMarkdown(zh)
const sourceTargets = languageSwitcherTargets(paths.source)
const zhTargets = languageSwitcherTargets(paths.zh)
if (!hasLanguageSwitcher(zhTree, zh, sourceTargets)
|| requiresSourceLanguageSwitcher(source) && !hasLanguageSwitcher(sourceTree, en, zhTargets)) {
throw new Error(`${source}: both authored languages need their counterpart switcher`)
}
const context = { repoRoot: root, isTranslationPairSource, repositoryFileExists: () => true }
const errors = translationStructureDiff(
translationStructureSignature(sourceTree, zhTargets, { ...context, sourcePath: paths.source, markdown: en }),
translationStructureSignature(zhTree, sourceTargets, { ...context, sourcePath: paths.zh, markdown: zh }),
)
if (errors.length > 0) throw new Error(`${source}: bilingual structure mismatch: ${errors.join('; ')}`)
return [
{ path: paths.source, content: en },
{ path: paths.zh, content: zh },
{ path: paths.meta, content: renderPairMeta(paths.source, blobHash(Buffer.from(en)), paths.zh, blobHash(Buffer.from(zh))) },
]
}
+299
View File
@@ -0,0 +1,299 @@
/** Source discovery and declaration validation shared by persistence documentation and schemas. */
import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
/** The package that owns the durable event vocabulary. */
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
/** The type-only module that plugin declaration merges augment. */
const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types'
/** Event-envelope declarations rendered before the per-event vocabulary. */
const EVENT_ENVELOPE_TYPE_NAMES = [
'SessionEventType',
'SurfaceEventType',
'SurfaceOp',
'SessionEvent',
] as const
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
/** One log event, extracted from a `SessionEventMap` declaration. */
export interface LogEventEntry {
/** Scoped name, e.g. `turn/start`. */
name: string
/** The scope prefix, e.g. `turn` (everything before the first `/`). */
scope: string
/** Payload type text (the member's type annotation, whitespace-collapsed). */
payload: string
/** Source member declaration and complete JSDoc, dedented from its container. */
declaration: string
/** Description prose (the member's JSDoc), one line per paragraph. */
doc: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
/** A {@link LogEventEntry} plus its surface-eligibility badge. */
export interface AnnotatedLogEventEntry extends LogEventEntry {
/** Whether the type is a `SurfaceEventType` member (may carry `surfaceOp`). */
surface: boolean
}
/** One owning event-envelope declaration pasted into the generated catalog. */
export interface EventEnvelopeTypeEntry {
/** Exported declaration name. */
name: EventEnvelopeTypeName
/** Verbatim type declaration, including its complete leading JSDoc. */
declaration: string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source: string
}
const printer = ts.createPrinter({ removeComments: true })
/**
* Render a member type on one line through the TypeScript printer, which adds
* semicolon separators. Drop its trailing semicolon before `}` to match the
* repository's inline-literal style.
*/
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
.replace(/\s+/g, ' ')
.replace(/;\s*\}/g, ' }')
.trim()
}
/**
* Copy a declaration from its leading JSDoc through its closing token while
* removing only the indentation imposed by its containing interface/module.
*/
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
const raw = rawJsDoc(text, node)
const nodeStart = node.getStart(sf)
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
const { line } = sf.getLineAndCharacterOfPosition(start)
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
const indent = text.slice(lineStart, start)
return text.slice(lineStart, node.end)
.split('\n')
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
.join('\n')
.trimEnd()
}
/**
* Every `interface SessionEventMap` declaration in a source file: the owning
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
* merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms
* declare members of the SAME merged interface, so both are catalogued
* uniformly. `topLevel` distinguishes the owning form so the caller can verify
* it actually lives in the owning package — an unrelated local interface that
* happens to share the name must not be catalogued as the on-disk vocabulary.
*/
function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaration; topLevel: boolean }[] {
const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = []
for (const stmt of sf.statements) {
if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true })
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE
&& stmt.body && ts.isModuleBlock(stmt.body)) {
for (const inner of stmt.body.statements) {
if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false })
}
}
}
return decls
}
/**
* The npm package name owning a `packages/<group>/<pkg>/…` source file, read
* from that package's manifest — or null when the manifest is missing or
* unparseable (the caller treats null as "ownership unverifiable").
*/
function packageNameFor(rel: string, scanRoot: string): string | null {
const dir = rel.split('/').slice(0, 3).join('/')
try {
const manifest = JSON.parse(readFileSync(resolve(scanRoot, dir, 'package.json'), 'utf8')) as { name?: string }
return typeof manifest.name === 'string' ? manifest.name : null
} catch {
// Missing or malformed package.json — every real workspace package has one,
// so this only arises in stripped-down fixture trees; either way ownership
// cannot be verified and the caller reports the declaration.
return null
}
}
/**
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
*/
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const entries: LogEventEntry[] = []
const violations: string[] = []
const seen = new Map<string, string>()
let owningDecl: string | null = null
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SessionEventMap')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
const declSrc = pointer(rel, sf, decl)
if (topLevel) {
// The top-level form has one home: the single exported declaration in
// the owning package. Same-named interfaces elsewhere are different
// types and must not enter the on-disk catalog.
const pkg = packageNameFor(rel, scanRoot)
if (pkg !== SESSION_PACKAGE) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`)
continue
}
const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false
if (!exported) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is not exported; the owning vocabulary is the single exported declaration — rename a local helper interface.`)
continue
}
if (owningDecl) {
violations.push(`top-level interface SessionEventMap (${declSrc}) is already declared at ${owningDecl}; the owning vocabulary has exactly one home.`)
continue
}
owningDecl = declSrc
}
if (decl.heritageClauses?.length) {
violations.push(`SessionEventMap declaration (${declSrc}) uses extends; inherited keys would join keyof SessionEventMap without a catalog row — declare event members directly.`)
}
for (const member of decl.members) {
const src = pointer(rel, sf, member)
if (!ts.isPropertySignature(member) || !member.type) {
// A method-form or type-less member still joins `keyof SessionEventMap`,
// so skipping it silently would be exactly the undocumented-event hole
// this catalog exists to close.
const label = (member as { name?: ts.Node }).name?.getText(sf) ?? member.getText(sf).replace(/\s+/g, ' ')
violations.push(`SessionEventMap member ${label} (${src}) is not a property signature with an explicit payload type; declare every log event as 'scope/name': <payload>.`)
continue
}
if (!ts.isStringLiteral(member.name)) {
violations.push(`log event at ${src} has a non-literal name; the catalog needs string-literal event names.`)
continue
}
const name = member.name.text
const where = `log event '${name}' (${src})`
const prior = seen.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior}; an event type has exactly one declaration.`)
continue
}
seen.set(name, src)
const payload = payloadText(member.type, sf)
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
if (hasMode) {
violations.push(`${where} carries an @mode tag, but a log event has no dispatch mode (it is not a cordis bus event — it rides the 'session/event' emit). Remove the tag.`)
}
if (!doc) {
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
}
const declaration = declarationText(text, sf, member)
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
}
}
}
reportViolations('gen-persistence-catalog', violations)
return entries
}
/**
* Collect the exported declarations that compose the persisted event envelope,
* preserving their source JSDoc and declaration text.
*/
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
const violations: string[] = []
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
const name = stmt.name.text as EventEnvelopeTypeName
const src = pointer(rel, sf, stmt)
const where = `event-envelope type '${name}' (${src})`
const prior = found.get(name)
if (prior) {
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
continue
}
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
violations.push(`${where} is not exported.`)
}
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
}
}
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
if (missing.length > 0) {
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
}
reportViolations('gen-persistence-catalog', violations)
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
const entry = found.get(name)
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
return entry
})
}
/**
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
* types — from source. Hard-errors when the alias is missing, declared more
* than once, or contains a non-string-literal member: the badge derivation
* relies on the union being a closed set of literal event names.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
const found: { names: string[]; source: string }[] = []
for (const rel of globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('SurfaceEventType')) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
for (const stmt of sf.statements) {
if (!ts.isTypeAliasDeclaration(stmt) || stmt.name.text !== 'SurfaceEventType') continue
const src = pointer(rel, sf, stmt)
const members = ts.isUnionTypeNode(stmt.type) ? [...stmt.type.types] : [stmt.type]
const names: string[] = []
for (const m of members) {
if (ts.isLiteralTypeNode(m) && ts.isStringLiteral(m.literal)) names.push(m.literal.text)
else throw new Error(`gen-persistence-catalog: SurfaceEventType (${src}) has a non-string-literal member; the badge derivation needs a closed literal union.`)
}
found.push({ names, source: src })
}
}
const only = found[0]
if (!only) throw new Error('gen-persistence-catalog: no SurfaceEventType union found under packages/*/*/src.')
if (found.length > 1) throw new Error(`gen-persistence-catalog: SurfaceEventType is declared more than once (${found.map(f => f.source).join(', ')}); the surface subset has exactly one owner.`)
return only.names
}
/**
* Attach the surface/log-only badge to each event. Hard-errors when a
* `SurfaceEventType` union member names no collected event — a stale union
* member would otherwise silently badge nothing.
*/
export function annotateSurface(events: LogEventEntry[], surfaceTypes: string[]): AnnotatedLogEventEntry[] {
const names = new Set(events.map(e => e.name))
const stale = surfaceTypes.filter(t => !names.has(t))
if (stale.length > 0) {
throw new Error(`gen-persistence-catalog: SurfaceEventType member(s) ${stale.map(t => `'${t}'`).join(', ')} name no declared log event (stale union member?).`)
}
const surface = new Set(surfaceTypes)
return events.map(e => ({ ...e, surface: surface.has(e.name) }))
}
+43
View File
@@ -0,0 +1,43 @@
/** Authored language pairs for generated persistence reference prose. */
/** Language of a generated persistence reference. */
export type PersistenceCatalogLocale = 'en' | 'zh'
const english = {
title: 'Session Persistence Event Catalog',
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
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.',
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.',
propertyColumns: '| Property | Presence | Type |', positionColumns: '| Position | Presence | Type |',
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.",
}
const chinese: Record<keyof typeof english, string> = {
title: '会话持久化事件目录',
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
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)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
rootColumns: '| 根类型 | 类别 | SHA-256 | 已解析类型 |',
definitions: '已解析的持久化类型',
definitionsIntro: '每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。',
propertyColumns: '| 属性 | 存在性 | 类型 |', positionColumns: '| 位置 | 存在性 | 类型 |',
optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
}
/** Complete translated prose; adding an English key requires its Chinese counterpart. */
export const persistenceCatalogText = { en: english, zh: chinese }
+544
View File
@@ -0,0 +1,544 @@
/** Current-tree persistence history rejects uncovered and incorrectly acknowledged type changes. */
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
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 { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
import {
classifyPersistenceChange,
loadPersistenceHistory,
parsePersistenceSnapshot,
runPersistenceChanges as executePersistenceChanges,
validatePersistenceHistory,
verifyPersistenceChanges,
} from './persistence-changes.ts'
import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
function runPersistenceChanges(
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
): string {
return executePersistenceChanges(args, root, extract, (_root, current) => [{
path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
}])
}
function jsonResult(source: string): { ok: boolean; files: readonly string[] } {
return JSON.parse(source) as { ok: boolean; files: readonly string[] }
}
const AUTHORED_PROSE = {
en: { summary: 'Adds optional metadata.', compatibility: 'Readers may omit the metadata.', verification: 'The focused tests passed.' },
zh: { summary: '添加可选元数据。', compatibility: '读取方可省略元数据。', verification: '定向测试通过。' },
}
function proseFile(root: string): string {
const path = join(root, 'prose.json')
writeFileSync(path, JSON.stringify(AUTHORED_PROSE))
return path
}
const roots: string[] = []
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-changes-'))
roots.push(root)
mkdirSync(join(root, 'docs/persistence-changes'), { recursive: true })
return root
}
type Shape = 'string' | 'number' | 'boolean' | { readonly [property: string]: Shape }
function typeRoot(key: string, shape: Shape, version = 3): PersistenceRoot {
const nodes: SchemaNode[] = []
function add(shape: Shape): number {
const index = nodes.length
nodes.push({ kind: 'primitive', type: 'never' })
nodes[index] = typeof shape === 'string' ? { kind: 'primitive', type: shape } : {
kind: 'object', indices: [], properties: Object.entries(shape).map(([name, child]) => ({
name: name.replace(/\?$/u, ''), optional: name.endsWith('?'), type: add(child),
})),
}
return index
}
if (key === 'SessionHeader') {
const index = add(shape)
const node = nodes[index]!
if (node.kind !== 'object') throw new Error('header fixture requires an object')
nodes[index] = { ...node, properties: [...node.properties, { name: 'version', optional: false, type: nodes.length }] }
nodes.push({ kind: 'literal', value: version })
} else if (key.startsWith('event:')) {
add({ data: shape, type: 'string' })
const node = nodes[0]!
if (node.kind !== 'object') throw new Error('event fixture requires an object')
const type = node.properties.find(item => item.name === 'type')!.type
nodes[type] = { kind: 'literal', value: key.slice(6) }
} else add(shape)
const schema = canonicalizeSchema(nodes, 0)
return { key, kind: ['SessionHeader', 'JsonlHeaderLine'].includes(key) ? 'header' : key === 'SessionEventEnvelope' ? 'envelope' : 'event',
...(key.startsWith('event:') ? { event: key.slice(6), surface: false } : {}), schema, digest: schemaDigest(schema) }
}
function inventory(eventShape: Shape = { value: 'string' }, version = 3): PersistenceSchemaInventory {
return { formatVersion: 1, types: [], roots: [typeRoot('SessionHeader', { id: 'string' }, version), typeRoot('SessionEventEnvelope', { type: 'string' }), typeRoot('event:example/value', eventShape), typeRoot('JsonlHeaderLine', { version: 'number' })] }
}
function entry(
id: string, schema: PersistenceSchemaInventory, previous: string | null,
baseline = false, decision: 'same-version' | 'version-bump' = 'same-version',
): PersistenceHistoryEntry {
return {
record: { schemaVersion: 1, id, baseline, changes: schema.roots.map(root => ({
root: root.key, previous, after: root.digest, decision,
})) },
snapshot: schema,
}
}
const BASE_ID = '2026-09-11-baseline'
const NEXT_ID = '2026-09-11-change'
function onlyEvent(schema: PersistenceSchemaInventory): PersistenceSchemaInventory {
return { ...schema, roots: schema.roots.filter(root => root.kind === 'event') }
}
function finishDocuments(root: string, id: string): void {
for (const suffix of ['.md', '.zh.md']) {
const path = join(root, 'docs/persistence-changes', id + suffix)
writeFileSync(path, readFileSync(path, 'utf8').replaceAll('TODO: explain this change.', 'Optional payload metadata preserves the recorded value.')
.replaceAll('TODO: record validation evidence.', 'The focused persistence-history tests passed.'))
}
}
function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
}
function baseline(root: string, schema = inventory()): void {
runPersistenceChanges(['--baseline', BASE_ID], root, () => schema)
finishDocuments(root, BASE_ID)
commitCurrent(root, schema)
}
function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoot {
const nodes: SchemaNode[] = [
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
{ kind: 'union', types: arms.map((_, index) => 5 + index) },
{ kind: 'primitive', type: 'string' },
{ kind: 'primitive', type: 'number' },
{ kind: 'primitive', type: 'boolean' },
...arms.map(properties => ({ kind: 'object' as const, indices: [], properties })),
]
const schema = canonicalizeSchema(nodes, 0)
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
}
describe('persistence change classification', () => {
it('treats a new optional payload subtree as one additive change even with required descendants', () => {
const before = typeRoot('event:example/value', { value: 'string' })
const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
})
it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
const required = typeRoot('event:example/value', { value: 'string' })
const optional = typeRoot('event:example/value', { 'value?': 'string' })
expect(classifyPersistenceChange(required, optional).every(change => !change.requiresVersionBump)).toBe(true)
for (const after of [required, typeRoot('event:example/value', { 'value?': 'number' }), typeRoot('event:example/value', {})]) {
expect(classifyPersistenceChange(optional, after).some(change => change.requiresVersionBump)).toBe(true)
}
})
it('pairs multiple discriminated union arms when a shared payload gains an optional property', () => {
function unionRoot(optional: boolean): PersistenceRoot {
const nodes: SchemaNode[] = [
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
{ kind: 'union', types: [2, 3] },
{ kind: 'object', indices: [], properties: [{ name: 'kind', type: 4, optional: false }, { name: 'payload', type: 6, optional: false }] },
{ kind: 'object', indices: [], properties: [{ name: 'kind', type: 5, optional: false }, { name: 'payload', type: 6, optional: false }] },
{ kind: 'literal', value: 'left' }, { kind: 'literal', value: 'right' },
{ kind: 'object', indices: [], properties: [{ name: 'value', type: 7, optional: false }, ...(optional ? [{ name: 'label', type: 7, optional: true }] : [])] },
{ kind: 'primitive', type: 'string' },
]
const schema = canonicalizeSchema(nodes, 0)
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
}
const differences = classifyPersistenceChange(unionRoot(false), unionRoot(true))
expect(differences.length).toBeGreaterThan(0)
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
})
it('allows optional additions across undiscriminated union arms', () => {
const first = [{ name: 'a', type: 2, optional: false }]
const second = [{ name: 'b', type: 3, optional: false }]
const extra = { name: 'x', type: 4, optional: true }
const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
})
it('allows required-to-optional fields across undiscriminated union arms', () => {
const before = unionBody([[{ name: 'a', type: 2, optional: false }], [{ name: 'b', type: 3, optional: false }]])
const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
const differences = classifyPersistenceChange(before, after)
expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
})
it('finds a complete matching when one union arm has multiple additive successors', () => {
const required = [{ name: 'a', type: 2, optional: false }]
const optional = [{ name: 'a', type: 2, optional: true }]
const flexible = [{ name: '0', type: 4, optional: true }, ...optional]
const constrained = [...required, { name: 'z', type: 4, optional: true }]
const before = unionBody([required, optional])
for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
const differences = classifyPersistenceChange(before, after)
expect(differences.length).toBeGreaterThan(0)
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
}
const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
})
it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
const first = [{ name: 'a', type: 2, optional: false }]
const second = [{ name: 'b', type: 3, optional: false }]
const third = [{ name: 'c', type: 4, optional: false }]
const before = unionBody([first, second])
const variants = [
unionBody([first, second, third]),
unionBody([first]),
unionBody([first, third]),
unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
]
for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
})
it('checks all recursive union arms without retaining provisional successes between candidates', () => {
const next = { name: 'next', type: 1, optional: true }
const first = [{ name: 'a', type: 2, optional: false }, next]
const second = [{ name: 'b', type: 3, optional: false }, next]
const extra = { name: 'x', type: 4, optional: true }
const before = unionBody([first, second])
const additive = unionBody([[...first, extra], [...second, extra]])
expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
})
it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
function wrapped(optional: boolean, primitive: 'string' | 'number' = 'string'): PersistenceRoot {
const nodes: SchemaNode[] = [
{ kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
{ kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }] },
{ kind: 'array', element: 3 },
{ kind: 'object', indices: [], properties: [{ name: 'value', type: 4, optional }] },
{ kind: 'primitive', type: primitive },
]
const schema = canonicalizeSchema(nodes, 0)
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
}
expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresVersionBump)).toBe(true)
expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresVersionBump)).toBe(true)
})
it('keeps optional header/envelope properties and surface event additions strict', () => {
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresVersionBump).toBe(true)
}
const added = typeRoot('event:example/added', { value: 'string' })
expect(classifyPersistenceChange(null, added)[0]?.requiresVersionBump).toBe(false)
expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresVersionBump).toBe(true)
expect(classifyPersistenceChange(added, null)[0]?.requiresVersionBump).toBe(true)
})
})
describe('persistence history verification', () => {
it('accepts a baseline and a successive optional addition without comparing the historical schema to current', () => {
const before = inventory()
const after = inventory({ value: 'string', 'label?': 'string' })
const history = validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, onlyEvent(after), BASE_ID)])
expect(history.tips.get('event:example/value')?.root?.digest).toBe(after.roots[2]?.digest)
expect(history.tips.get('SessionHeader')?.id).toBe(BASE_ID)
})
it('rejects missing predecessors, forks, cycles, duplicates, and missing baseline', () => {
const base = entry(BASE_ID, inventory(), null, true)
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
expect(() => validatePersistenceHistory([next])).toThrow('exactly one baseline')
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, next.snapshot, '2026-09-11-missing')])).toThrow('missing predecessor')
expect(() => validatePersistenceHistory([base, next, entry('2026-09-11-fork', next.snapshot, BASE_ID)])).toThrow('forked')
expect(() => validatePersistenceHistory([base, base])).toThrow('exactly one baseline')
const cycle = entry('2026-09-11-cycle', { ...next.snapshot, roots: [typeRoot('event:cycle/event', {})] }, '2026-09-11-cycle')
expect(() => validatePersistenceHistory([base, cycle])).toThrow('cycle')
})
it('binds breaking decisions to the same record header version transition', () => {
const base = entry(BASE_ID, inventory(), null, true)
const breaking = inventory({ value: 'number' }, 4)
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)])).toThrow('requires a format version bump')
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID, false, 'version-bump')])).toThrow("this record's own")
const touched = { ...breaking, roots: breaking.roots.filter(root => root.key === 'SessionHeader' || root.kind === 'event') }
expect(validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump')]).tips.get('SessionHeader')?.id).toBe(NEXT_ID)
const later = entry('2026-09-11-later', onlyEvent(inventory({ value: 'boolean' }, 4)), NEXT_ID, false, 'version-bump')
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump'), later])).toThrow("this record's own")
})
it('retains deletion tombstones and requires re-added roots to continue them', () => {
const base = entry(BASE_ID, inventory(), null, true)
const after = inventory({}, 4)
const header = after.roots[0]!
const record: PersistenceChangeRecord = { schemaVersion: 1, id: NEXT_ID, baseline: false, changes: [
{ root: 'SessionHeader', previous: BASE_ID, after: header.digest, decision: 'version-bump' },
{ root: 'event:example/value', previous: BASE_ID, after: null, decision: 'version-bump' },
] }
const deletion = { record, snapshot: { ...after, roots: [header] } }
expect(validatePersistenceHistory([base, deletion]).tips.get('event:example/value')?.root).toBeNull()
const restored = entry('2026-09-11-restore', onlyEvent(inventory()), NEXT_ID)
expect(validatePersistenceHistory([base, deletion, restored]).tips.get('event:example/value')?.id).toBe(restored.record.id)
expect(() => validatePersistenceHistory([base, deletion, entry('2026-09-11-restore', restored.snapshot, null)])).toThrow('forked')
})
it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
const schema = inventory()
const malformed = structuredClone(schema) as unknown as { roots: Array<{ schema: { nodes: unknown[] }; digest: string }> }
malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node kind')
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')
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')
})
})
describe('persistence changes current-tree commands', () => {
it('requires completed record prose, rejects stale generated output, and reports unacknowledged paths', () => {
const root = fixture()
const before = inventory()
runPersistenceChanges(['--baseline', BASE_ID], root, () => before)
expect(() => loadPersistenceHistory(root)).toThrow('complete compatibility')
finishDocuments(root, BASE_ID)
rmSync(join(root, 'docs/persistence-schema.json'))
expect(() => verifyPersistenceChanges(root, before)).toThrow('is missing')
commitCurrent(root, before)
const after = inventory({ value: 'string', 'label?': 'string' })
expect(() => verifyPersistenceChanges(root, after)).toThrow('is stale')
commitCurrent(root, after)
expect(() => verifyPersistenceChanges(root, after)).toThrow('event:example/value.data.label: optional property added (same-version allowed)')
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
finishDocuments(root, NEXT_ID)
expect(runPersistenceChanges([], root, () => after)).toContain('4 roots match 2 history records')
expect(() => runPersistenceChanges(['--baseline', '2026-09-11-reset'], root, () => after)).toThrow('cannot reset history')
expect(() => runPersistenceChanges(['--record', '2026-09-11-empty', '--decision', 'same-version'], root, () => after)).toThrow('no persistence type changes')
})
it('rejects changed bilingual machine declarations and unreferenced snapshots', () => {
const root = fixture()
baseline(root)
const chinese = join(root, 'docs/persistence-changes', `${BASE_ID}.zh.md`)
const original = readFileSync(chinese, 'utf8')
writeFileSync(chinese, original.replace('baseline: true', 'baseline: false'))
expect(() => loadPersistenceHistory(root)).toThrow('bilingual machine records differ')
writeFileSync(chinese, original)
writeFileSync(join(root, 'docs/persistence-changes/2026-09-11-orphan.schema.json'), '{}\n')
expect(() => loadPersistenceHistory(root)).toThrow('unreferenced')
})
it.each([
['optional field', inventory({ value: 'string', 'label?': 'string' })],
['required-to-optional field', inventory({ 'value?': 'string' })],
['ordinary event addition', { ...inventory(), roots: [...inventory().roots, typeRoot('event:example/added', { count: 'number' })] }],
] as const)('infers same-version for %s', (_name, after) => {
const root = fixture()
baseline(root)
runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
expect(changes?.length).toBeGreaterThan(0)
expect(changes?.every(change => change.decision === 'same-version')).toBe(true)
verifyPersistenceChanges(root, after)
})
it('infers a version bump when the source includes its own increasing header version', () => {
const root = fixture()
baseline(root)
const after = inventory({ value: 'number' }, 4)
runPersistenceChanges(['--record', NEXT_ID, '--prose', proseFile(root)], root, () => after)
const changes = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes
expect(changes?.map(change => change.root).sort()).toEqual(['SessionHeader', 'event:example/value'])
expect(changes?.every(change => change.decision === 'version-bump')).toBe(true)
verifyPersistenceChanges(root, after)
})
it('rejects inferred bumps without a header transition and explicit incorrect assertions before writing', () => {
const root = fixture()
baseline(root)
const prose = proseFile(root)
const directory = join(root, 'docs/persistence-changes')
const files = readdirSync(directory).sort()
const inventoryPath = join(root, 'docs/persistence-schema.json')
const before = readFileSync(inventoryPath, 'utf8')
for (const [args, code] of [
[[], 'version-transition-required'],
[['--decision', 'same-version'], 'version-bump-required'],
] as const) {
const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json', ...args], root,
() => inventory({ value: 'number' })))
expect(result).toMatchObject({ ok: false, code, files: [], changes: [expect.objectContaining({ requiresVersionBump: true })] })
expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
expect(readdirSync(directory).sort()).toEqual(files)
}
})
it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
const root = fixture()
baseline(root)
const after = inventory({ value: 'string', 'label?': 'string' })
const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
{ root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
] })
const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
expect(written.files).toEqual([
'docs/persistence-schema.json', `docs/persistence-changes/${NEXT_ID}.md`, `docs/persistence-changes/${NEXT_ID}.zh.md`,
`docs/persistence-changes/${NEXT_ID}.i18n.yaml`, `docs/persistence-changes/${NEXT_ID}.schema.json`,
])
expect(written).toMatchObject({ roots: [{ root: 'event:example/value', kind: 'event',
before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }] })
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).toContain(AUTHORED_PROSE.en.compatibility)
expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after)).ok).toBe(true)
})
it('refreshes only a terminal acknowledgement while preserving its authored prose and predecessor', () => {
const root = fixture()
baseline(root)
const after = inventory({ value: 'string', 'label?': 'string' })
const prose = proseFile(root)
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
const beforeText = readFileSync(path, 'utf8')
const refreshed = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number' })
runPersistenceChanges(['--update', NEXT_ID], root, () => refreshed)
const stripRecord = (value: string): string => value.replace(/```yaml persistence-change[\s\S]*?```/u, '')
expect(stripRecord(readFileSync(path, 'utf8'))).toBe(stripRecord(beforeText))
expect(loadPersistenceHistory(root).tips.get('event:example/value')?.id).toBe(NEXT_ID)
expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes[0]?.previous).toBe(BASE_ID)
expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => refreshed)).ok).toBe(true)
const final = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number', 'last?': 'boolean' })
runPersistenceChanges(['--record', '2026-09-11-successor', '--decision', 'same-version', '--prose', prose], root, () => final)
expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => final)).toThrow('with successors')
expect(() => runPersistenceChanges(['--update', BASE_ID, '--decision', 'same-version'], root, () => final)).toThrow('cannot update the persistence baseline')
})
it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
const root = fixture()
baseline(root)
const after = inventory({ value: 'string', 'label?': 'string' })
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
const prose = proseFile(root)
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).not.toContain('TODO:')
const pairedPaths = ['.md', '.zh.md', '.i18n.yaml'].map(suffix => join(root, `docs/persistence-changes/${NEXT_ID}${suffix}`))
const completed = pairedPaths.map(path => readFileSync(path, 'utf8'))
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
const before = readFileSync(path, 'utf8')
const invalid = inventory({ value: 'number' })
const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
expect(readFileSync(path, 'utf8')).toBe(before)
for (const value of [
{ ...AUTHORED_PROSE, extra: 'unsupported' },
{ ...AUTHORED_PROSE, en: { ...AUTHORED_PROSE.en, compatibility: ' ' } },
{ ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: 'TODO: record validation evidence.' } },
{ ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: '```text\nUnpaired code.\n```' } },
]) {
writeFileSync(prose, JSON.stringify(value))
expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)).toThrow()
expect(readFileSync(path, 'utf8')).toBe(before)
}
})
it('executes the real CLI against a source-only temporary checkout without Git history', () => {
const root = fixture()
const physical = join(root, 'packages/session/session-persistence-jsonl/src')
mkdirSync(physical, { recursive: true })
writeFileSync(join(physical, 'format.ts'), "interface HeaderLine { type: 'session'; version: number; id: string; delegationDepth: number }; export {}\n")
const session = join(root, 'packages/core/session/src')
mkdirSync(session, { recursive: true })
writeFileSync(join(session, '../package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-session' }))
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: {
target: 'ESNext', module: 'ESNext', moduleResolution: 'Bundler', types: [], skipLibCheck: true,
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
} }))
const source = [
'/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
'/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
"'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
'/** Persisted event names. */', 'export type SessionEventType = keyof SessionEventMap',
'/** Surface placement. */', "export type SurfaceOp = 'append'", '/** Persisted event. */',
'export type SessionEvent<K extends keyof SessionEventMap = keyof SessionEventMap> =',
' { [P in K]: { type: P; seq: number; data: SessionEventMap[P]; surfaceOp: SurfaceOp } }[K]', '',
].join('\n')
writeFileSync(join(session, 'types.ts'), source)
const script = resolve(import.meta.dirname, 'persistence-changes.ts')
const cli = (...args: string[]): ReturnType<typeof spawnSync> => spawnSync(process.execPath, ['--import', import.meta.resolve('tsx/esm'), script, '--root', root, ...args], {
cwd: root, encoding: 'utf8', timeout: 120_000,
})
const prose = proseFile(root)
const initialized = cli('--baseline', BASE_ID, '--prose', prose)
expect(initialized.error).toBeUndefined()
expect(initialized.signal).toBeNull()
expect(initialized.status, String(initialized.stderr)).toBe(0)
const accepted = cli('--check')
expect(accepted.error).toBeUndefined()
expect(accepted.signal).toBeNull()
expect(accepted.status, String(accepted.stderr)).toBe(0)
const optional = source.replace('value: string', 'value: string; label?: string')
writeFileSync(join(session, 'types.ts'), optional)
const authored = cli('--record', NEXT_ID, '--prose', prose, '--json')
expect(authored.error).toBeUndefined()
expect(authored.signal).toBeNull()
expect(authored.status, String(authored.stderr)).toBe(0)
expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
const updated = cli('--update', NEXT_ID, '--json')
expect(updated.error).toBeUndefined()
expect(updated.signal).toBeNull()
expect(updated.status, String(updated.stderr)).toBe(0)
expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
.toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
const generated = extractPersistenceSchema(root)
for (const file of persistenceCatalogArtifacts(root, generated)) expect(readFileSync(join(root, file.path), 'utf8')).toBe(file.content)
verifyPersistenceChanges(root, generated)
writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number').replace('value: string', 'value: number'))
commitCurrent(root, extractPersistenceSchema(root))
const rejected = cli('--check')
expect(rejected.error).toBeUndefined()
expect(rejected.signal).toBeNull()
expect(rejected.status).toBe(1)
expect(String(rejected.stderr)).toContain('version-bump required')
const structured = cli('--check', '--json')
expect(structured.error).toBeUndefined()
expect(structured.signal).toBeNull()
expect(structured.status).toBe(1)
expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
})
})
})
+795
View File
@@ -0,0 +1,795 @@
/** Verify and acknowledge persistence type changes from current-tree schema history. */
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
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 type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
import { renderPersistencePair } from './persistence-artifacts.ts'
import type { PersistenceArtifact } from './persistence-artifacts.ts'
const HISTORY_DIRECTORY = 'docs/persistence-changes'
const CURRENT_SCHEMA = 'docs/persistence-schema.json'
const ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u
const EXPLANATION_PLACEHOLDER = 'TODO: explain this change.'
const EVIDENCE_PLACEHOLDER = 'TODO: record validation evidence.'
/** The author's acknowledgement of one mechanically classified transition. */
export type PersistenceDecision = 'same-version' | 'version-bump'
/** One root's successor; null after values preserve a deletion in its history. */
export interface PersistenceChange {
readonly root: string
readonly previous: string | null
readonly after: string | null
readonly decision: PersistenceDecision
}
/** A document's machine record, independent of its translated prose. */
export interface PersistenceChangeRecord {
readonly schemaVersion: 1
readonly id: string
readonly baseline: boolean
readonly changes: readonly PersistenceChange[]
}
/** A parsed acknowledgement and its self-contained after schemas. */
export interface PersistenceHistoryEntry {
readonly record: PersistenceChangeRecord
readonly snapshot: PersistenceSchemaInventory
}
/** One detected type change, with a path that reviewers can locate. */
export interface PersistenceTypeChange {
readonly kind: PersistenceTypeChangeKind
readonly path: string
readonly description: string
readonly requiresVersionBump: boolean
}
const CHANGE_DESCRIPTIONS = {
'root-added': 'root added',
'root-removed': 'root removed',
'root-classification-changed': 'root classification changed',
'type-changed': 'type changed',
'property-removed': 'property removed',
'property-made-optional': 'property made optional',
'property-made-required': 'property made required',
'optional-property-added': 'optional property added',
'required-property-added': 'required property added',
'index-signature-changed': 'index signature changed',
'tuple-length-changed': 'tuple length changed',
'tuple-element-cardinality-changed': 'tuple element cardinality changed',
'union-variants-changed': 'union variants changed',
} as const
/** Stable structural classification independent of diagnostic prose. */
export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS
/** Author-supplied paragraphs used to complete one language of a change record. */
export interface PersistenceChangeProse {
readonly summary: string
readonly compatibility: string
readonly verification: string
}
/** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */
export interface PersistenceChangeProsePair {
readonly en: PersistenceChangeProse
readonly zh: PersistenceChangeProse
}
interface ReportedChange extends PersistenceTypeChange {
readonly root: string
}
interface RootTransition {
readonly root: string
readonly kind: PersistenceRoot['kind']
readonly before: string | null
readonly after: string | null
}
class PersistenceChangeFailure extends Error {
constructor(
message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [],
readonly roots: readonly RootTransition[] = [],
) {
super(message)
}
}
interface CommandResult {
readonly schemaVersion: 1
readonly ok: boolean
readonly operation: 'check' | 'baseline' | 'record' | 'update'
readonly message: string
readonly changes: readonly ReportedChange[]
readonly roots: readonly RootTransition[]
readonly files: readonly string[]
readonly recordId?: string
readonly code?: string
}
interface Tip {
readonly id: string
readonly root: PersistenceRoot | null
}
/** Verified per-root history tips; historical schemas need not match the current tree. */
export interface PersistenceHistory {
readonly entries: readonly PersistenceHistoryEntry[]
readonly tips: ReadonlyMap<string, Tip>
}
function record(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
return value as Record<string, unknown>
}
function keys(value: Record<string, unknown>, expected: readonly string[], label: string, optional: readonly string[] = []): void {
const missing = expected.find(key => !Object.hasOwn(value, key))
const unexpected = Object.keys(value).find(key => !expected.includes(key) && !optional.includes(key))
if (missing !== undefined || unexpected !== undefined) throw new Error(`${label}: ${missing === undefined ? `unknown field ${unexpected}` : `missing field ${missing}`}`)
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${label} must be an array`)
return value
}
function textValue(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be a non-empty string`)
return value
}
function bool(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
return value
}
function identifier(value: unknown, label: string): string {
const id = textValue(value, label)
if (!ID_PATTERN.test(id)) throw new Error(`${label} must be YYYY-MM-DD-slug`)
return id
}
function digest(value: unknown, label: string): string {
const result = textValue(value, label)
if (!DIGEST_PATTERN.test(result)) throw new Error(`${label} must be a SHA-256 digest`)
return result
}
function reference(value: unknown, count: number, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`)
return value as number
}
function parseSchema(value: unknown, label: string): CanonicalSchema {
const input = record(value, label)
keys(input, ['root', 'nodes'], label)
if (input.root !== 0) throw new Error(`${label}.root must be zero`)
const nodes = array(input.nodes, `${label}.nodes`)
if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`)
const ref = (value: unknown): number => reference(value, nodes.length, label)
for (const [index, raw] of nodes.entries()) {
const node = record(raw, `${label}.nodes[${index}]`)
switch (node.kind) {
case 'primitive':
keys(node, ['kind', 'type'], label)
if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
break
case 'literal':
keys(node, ['kind', 'value'], label)
if (!['string', 'boolean', 'number'].includes(typeof node.value)
|| typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
break
case 'opaque':
keys(node, ['kind', 'reason'], label)
if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
break
case 'array':
keys(node, ['kind', 'element'], label)
ref(node.element)
break
case 'tuple':
keys(node, ['kind', 'elements'], label)
for (const rawElement of array(node.elements, label)) {
const element = record(rawElement, label)
keys(element, ['type', 'optional', 'rest'], label)
ref(element.type)
bool(element.optional, label)
bool(element.rest, label)
}
break
case 'object': {
keys(node, ['kind', 'properties', 'indices'], label)
const names = new Set<string>()
for (const rawProperty of array(node.properties, label)) {
const property = record(rawProperty, label)
keys(property, ['name', 'type', 'optional'], 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}`)
names.add(name)
ref(property.type)
bool(property.optional, label)
}
for (const rawIndex of array(node.indices, label)) {
const entry = record(rawIndex, label)
keys(entry, ['key', 'value'], label)
ref(entry.key)
ref(entry.value)
}
break
}
case 'union':
keys(node, ['kind', 'types'], label)
if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
for (const item of node.types as unknown[]) ref(item)
break
default:
throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
}
}
const schema = input as unknown as CanonicalSchema
const canonical = canonicalizeSchema(schema.nodes, schema.root)
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
return schema
}
/** Parse a persisted schema inventory, rejecting malformed graphs and digest drift.
* @param value - JSON read from a current or historical schema file.
* @returns the validated inventory.
*/
export function parsePersistenceSnapshot(value: unknown): 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')
const names = new Set<string>()
for (const rawRoot of array(input.roots, 'schema roots')) {
const root = record(rawRoot, 'schema root')
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
const key = textValue(root.key, 'root key')
if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
names.add(key)
if (root.kind === 'event') {
if (key !== `event:${textValue(root.event, 'event name')}`) throw new Error(`invalid event root key ${key}`)
bool(root.surface, 'event surface membership')
} 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)
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true)
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')
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')) textValue(source, 'type source')
}
return input as unknown as PersistenceSchemaInventory
}
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean): void {
const pending = [0]
const visited = new Set<number>()
while (pending.length > 0) {
const index = pending.pop() as number
if (visited.has(index)) continue
visited.add(index)
const node = schema.nodes[index] as SchemaNode
if (node.kind === 'union') { pending.push(...node.types); continue }
if (node.kind !== 'object') throw new Error(`${event}: event schema must be an object`)
const tag = node.properties.find(property => property.name === 'type')
const value = tag === undefined ? undefined : schema.nodes[tag.type]
if (tag?.optional !== false || value?.kind !== 'literal' || value.value !== event) throw new Error(`${event}: event schema type does not match its root`)
const operation = node.properties.find(property => property.name === 'surfaceOp')
if (surface ? operation?.optional !== false : operation !== undefined) throw new Error(`${event}: surface metadata does not match its schema`)
}
}
function subDigest(schema: CanonicalSchema, node: number): string {
return schemaDigest(canonicalizeSchema(schema.nodes, node))
}
function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
const owners = new Map<number, number>()
function assign(previous: number, visited: Set<number>): boolean {
for (const next of candidates[previous] ?? []) {
if (visited.has(next)) continue
visited.add(next)
const owner = owners.get(next)
if (owner === undefined || assign(owner, visited)) {
owners.set(next, previous)
return true
}
}
return false
}
for (let previous = 0; previous < candidates.length; previous += 1) {
if (!assign(previous, new Set())) return undefined
}
const matches: number[] = []
for (const [next, previous] of owners) matches[previous] = next
return matches
}
/** Classify structural differences; only optional payload properties and ordinary event additions are additive.
* @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.
*/
export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
if (before === null) {
return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
}
if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
const key = after.key
const oldRoot = before
const newRoot = after
const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
const changes: PersistenceTypeChange[] = []
if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
if (before.digest === after.digest) return changes
const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
let result = fingerprints[side].get(index)
if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
return result
}
type Scope = 'event' | 'body' | 'strict'
function compare(
oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
): PersistenceTypeChange[] {
if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
const pair = `${oldIndex}:${newIndex}:${scope}`
if (ancestors.has(pair)) return []
// Recursive pairs are assumptions for this candidate only. A failed sibling
// or unmatched union arm cannot leave a cached success for another candidate.
const active = new Set(ancestors).add(pair)
const differences: PersistenceTypeChange[] = []
const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
differences.push(describe(path, kind, requiresVersionBump))
}
const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
differences.push(...compare(oldType, newType, child, childScope, active))
}
const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
if (oldNode.kind === 'object' && newNode.kind === 'object') {
const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
const newProps = new Map(newNode.properties.map(property => [property.name, property]))
for (const [name, property] of oldProps) {
const next = newProps.get(name)
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')
}
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)
}
const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
for (const [index, entry] of oldIndices) {
const next = newIndices.get(index)
if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
}
return differences
}
if (oldNode.kind === 'array' && newNode.kind === 'array') {
return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
}
if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
for (const [index, element] of oldNode.elements.entries()) {
const next = newNode.elements[index] as SchemaTupleElement
if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
}
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 [describe(path, 'type-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()]
}
function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
if (frontmatter === null || record(load(frontmatter[1] as string, { schema: JSON_SCHEMA }), filename).kind !== 'persistence-change') throw new Error(`${filename}: kind must be persistence-change`)
const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
const block = /^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu.exec(source)
if (openings.length !== 1 || block === null) throw new Error(`${filename}: expected exactly one persistence-change block`)
const input = record(load(block[1] as string, { schema: JSON_SCHEMA }), filename)
keys(input, ['schemaVersion', 'id', 'baseline', 'changes'], filename)
if (input.schemaVersion !== 1) throw new Error(`${filename}: unsupported acknowledgement schema version`)
const id = identifier(input.id, filename)
if (basename(filename) !== `${id}.md`) throw new Error(`${filename}: record id does not match filename`)
bool(input.baseline, filename)
const roots = new Set<string>()
for (const value of array(input.changes, `${filename} changes`)) {
const change = record(value, filename)
keys(change, ['root', 'previous', 'after', 'decision'], filename)
const root = textValue(change.root, 'changed root')
if (roots.has(root)) throw new Error(`${filename}: duplicate change for ${root}`)
roots.add(root)
if (change.previous !== null) identifier(change.previous, 'previous record')
if (change.after !== null) digest(change.after, 'after digest')
if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
}
if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
return input as unknown as PersistenceChangeRecord
}
function headerVersion(root: PersistenceRoot | null): number | undefined {
if (root === null) return undefined
const node = root.schema.nodes[0]
if (node?.kind !== 'object') return undefined
const property = node.properties.find(item => item.name === 'version')
if (property === undefined) return undefined
const version = root.schema.nodes[property.type]
return version?.kind === 'literal' && typeof version.value === 'number' && Number.isSafeInteger(version.value) ? version.value : undefined
}
/** Check every historical transition and return each root's unique current tip.
* @param entries - parsed documents and their self-contained schema snapshots.
* @returns validated history and tips, without consulting Git or current source.
*/
export function validatePersistenceHistory(entries: readonly PersistenceHistoryEntry[]): PersistenceHistory {
if (entries.filter(entry => entry.record.baseline).length !== 1) throw new Error('persistence history requires exactly one baseline')
const records = new Map<string, PersistenceHistoryEntry>()
for (const entry of entries) {
if (records.has(entry.record.id)) throw new Error(`duplicate persistence record ${entry.record.id}`)
records.set(entry.record.id, entry)
const expected = entry.record.changes.filter(change => change.after !== null).map(change => change.root).sort()
if (JSON.stringify(expected) !== JSON.stringify(entry.snapshot.roots.map(root => root.key).sort())) throw new Error(`${entry.record.id}: snapshot roots do not match acknowledged after schemas`)
for (const change of entry.record.changes) {
const root = entry.snapshot.roots.find(root => root.key === change.root)
if ((root?.digest ?? null) !== change.after) throw new Error(`${entry.record.id}: after digest mismatch for ${change.root}`)
if (entry.record.baseline && (change.previous !== null || change.after === null || change.decision !== 'same-version')) throw new Error(`${entry.record.id}: invalid baseline transition`)
}
}
const states = new Map<string, 'visiting' | 'visited'>()
const successors = new Map<string, string>()
const nodes = new Map<string, { entry: PersistenceHistoryEntry; change: PersistenceChange }>()
const tips = new Map<string, Tip>()
const nodeKey = (id: string | null, root: string): string => JSON.stringify([id, root])
for (const entry of entries) for (const change of entry.record.changes) {
const parentKey = nodeKey(change.previous, change.root)
if (successors.has(parentKey)) throw new Error(`forked persistence history for ${change.root}: ${successors.get(parentKey)} and ${entry.record.id}`)
successors.set(parentKey, entry.record.id)
nodes.set(nodeKey(entry.record.id, change.root), { entry, change })
}
function visit(id: string, root: string): PersistenceRoot | null {
const key = nodeKey(id, root)
const found = nodes.get(key)
if (found === undefined) throw new Error(`missing predecessor ${id} for ${root}`)
if (states.get(key) === 'visiting') throw new Error(`cycle in persistence history for ${root}`)
const after = found.entry.snapshot.roots.find(item => item.key === root) ?? null
if (states.get(key) === 'visited') return after
states.set(key, 'visiting')
const before = found.change.previous === null ? null : visit(found.change.previous, root)
if (!found.entry.record.baseline) {
const differences = classifyPersistenceChange(before, after)
if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
throw new PersistenceChangeFailure(
`${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
)
}
if (found.change.decision === 'version-bump') {
const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
const from = headerVersion(oldHeader)
const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
if (from === undefined || to === undefined || to <= from) {
throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
}
}
}
states.set(key, 'visited')
if (!successors.has(key)) tips.set(root, { id, root: after })
return after
}
for (const entry of entries) for (const change of entry.record.changes) visit(entry.record.id, change.root)
const baseline = entries.find(entry => entry.record.baseline) as PersistenceHistoryEntry
if (!baseline.snapshot.roots.some(root => root.key === 'SessionHeader')
|| !baseline.snapshot.roots.some(root => root.key === 'SessionEventEnvelope')
|| !baseline.snapshot.roots.some(root => root.key === 'JsonlHeaderLine')) {
throw new Error('baseline requires SessionHeader, JsonlHeaderLine, and SessionEventEnvelope roots')
}
return { entries, tips }
}
/** Read and validate all current-tree persistence acknowledgement files.
* @param root - checkout or isolated fixture root.
* @returns checked history without comparing its tips to current source.
*/
export function loadPersistenceHistory(root: string): PersistenceHistory {
return validatePersistenceHistory(readPersistenceEntries(root))
}
function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
const directory = join(root, HISTORY_DIRECTORY)
if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
const files = readdirSync(directory).sort()
const documents = files.filter(file => file.endsWith('.md') && !file.endsWith('.zh.md') && file !== 'README.md' && file !== 'AGENTS.md')
const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
const entries = documents.map((filename) => {
const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
const allowIncomplete = filename === `${allowIncompleteId}.md`
const change = parseDocument(source, filename, allowIncomplete)
const snapshotName = `${change.id}.schema.json`
if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
const translatedName = `${change.id}.zh.md`
if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
return { record: change, snapshot }
})
if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
return entries
}
function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
const roots = new Map(current.roots.map(root => [root.key, root]))
const differences: string[] = []
for (const key of new Set([...history.tips.keys(), ...roots.keys()])) {
if (classifyPersistenceChange(history.tips.get(key)?.root ?? null, roots.get(key) ?? null).length !== 0) differences.push(key)
}
return differences.sort()
}
/** Verify current generated output and acknowledgement tips together.
* @param root - checkout or isolated fixture root.
* @param current - freshly extracted current-source inventory.
* @returns verified history.
*/
export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
const history = loadPersistenceHistory(root)
const differences = reportedDifferences(history, current)
const transitions = rootTransitions(history, current)
const committedPath = join(root, CURRENT_SCHEMA)
if (!existsSync(committedPath)) {
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
}
const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
if (JSON.stringify(committed) !== JSON.stringify(current)) {
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
}
if (differences.length !== 0) {
const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
}
return history
}
function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
const root = (after ?? before) as PersistenceRoot
return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
}
function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
}
function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
).map(change => ({ root, ...change })))
}
function machineBlock(change: PersistenceChangeRecord): string {
return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
...change.changes.flatMap(item => [` - root: ${JSON.stringify(item.root)}`, ` previous: ${item.previous === null ? 'null' : JSON.stringify(item.previous)}`, ` after: ${item.after === null ? 'null' : JSON.stringify(item.after)}`, ` decision: ${item.decision}`]), '```'].join('\n')
}
function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
const summary = chinese ? '概述' : 'Summary'
const compatibility = chinese ? '兼容性' : 'Compatibility'
const verification = chinese ? '验证' : 'Verification'
return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
`# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
`## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
`- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
'<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
'<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
'<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
}
/** Parse explicit authored prose without supplying compatibility or validation claims.
* @param value - decoded JSON supplied through --prose.
* @returns complete English and Chinese section text.
*/
export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
const pair = record(value, 'persistence prose')
keys(pair, ['en', 'zh'], 'persistence prose')
for (const locale of ['en', 'zh']) {
const sections = record(pair[locale], `persistence prose ${locale}`)
keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
for (const [name, value] of Object.entries(sections)) {
const text = textValue(value, `${locale}.${name}`)
if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
}
}
}
return pair as unknown as PersistenceChangeProsePair
}
function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
if (prose === undefined) return source
const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
const lines = source.split('\n')
const heading = `## ${headings[index]}`
const start = lines.indexOf(heading)
if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
if (end < 0) end = lines.length
let anchor = end
while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
lines.splice(start + 1, end - start - 1, '', text.trim(), '')
source = lines.join('\n')
}
return source
}
function commandOperation(args: readonly string[]): CommandResult['operation'] {
if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
return 'check'
}
function executeCommand(
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
): CommandResult {
const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
} })
if (values.root !== undefined) root = resolve(values.root)
const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
const operation = commandOperation(args)
if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
const current = parsePersistenceSnapshot(extract(root))
if (operation === 'check') {
const history = verifyPersistenceChanges(root, current)
return { schemaVersion: 1, ok: true, operation,
message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
}
const baseline = operation === 'baseline'
const update = operation === 'update'
const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
const directory = join(root, HISTORY_DIRECTORY)
if (baseline && existsSync(directory) && readdirSync(directory).some(file => file.endsWith('.schema.json') || ID_PATTERN.test(file.replace(/\.md$/u, '')))) throw new Error('persistence baseline already exists; baseline creation cannot reset history')
if (values.decision !== undefined && values.decision !== 'same-version' && values.decision !== 'version-bump') {
throw new Error('--decision must be same-version or version-bump')
}
const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
const existing = update ? entries.find(entry => entry.record.id === id) : undefined
if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
throw new Error(`${id}: cannot update an acknowledgement with successors`)
}
const prior = entries.filter(entry => entry !== existing)
const history = baseline ? undefined : validatePersistenceHistory(prior)
const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
const differences = history === undefined ? [] : reportedDifferences(history, current)
const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version')
const roots = current.roots.filter(root => changed.includes(root.key))
const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
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: [] }
validatePersistenceHistory([...prior, { record: change, snapshot }])
const document = (chinese: boolean): string => {
const supplied = chinese ? prose?.zh : prose?.en
return existing === undefined ? scaffold(change, chinese, supplied)
: updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
}
const english = document(false)
const chinese = document(true)
parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
const recordFiles = [
...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
{ path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
]
if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
const outputs = [...artifacts(root, current), ...recordFiles]
for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
const completion = baseline
? 'Complete both record documents and refresh their translation pairing.'
: `Complete the compatibility and verification prose with --update ${id} --prose FILE.`
const message = existing === undefined && prose === undefined
? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
: `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
return { schemaVersion: 1, ok: true, operation, recordId: id, message,
changes: differences,
roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
}
/** Execute tree-only verification or author an explicit persistence acknowledgement.
* @param args - check, baseline, record, or update arguments; --json selects structured output.
* @param root - checkout root; defaults to this script's repository.
* @param extract - current-source extraction function; fixtures supply their own source reader.
* @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
* @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
*/
export function runPersistenceChanges(
args: readonly string[],
root: string = resolve(import.meta.dirname, '..'),
extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
): string {
try {
const result = executeCommand(args, root, extract, artifacts)
return args.includes('--json') ? JSON.stringify(result) : result.message
} catch (error: unknown) {
if (!args.includes('--json')) throw error
const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
message: error instanceof Error ? error.message : String(error),
code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
changes: error instanceof PersistenceChangeFailure ? error.changes : [],
roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
return JSON.stringify(result)
}
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try {
const output = runPersistenceChanges(process.argv.slice(2))
console.log(output)
if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode } from './persistence-schema-model.ts'
const string: SchemaNode = { kind: 'primitive', type: 'string' }
const number: SchemaNode = { kind: 'primitive', type: 'number' }
describe('canonical persisted type graphs', () => {
it('ignores source ordering and shared versus duplicated subtypes', () => {
const left: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'b', type: 1, optional: false }, { name: 'a', type: 1, optional: true }], indices: [] }, string,
]
const right: SchemaNode[] = [
string, string,
{ kind: 'object', properties: [{ name: 'a', type: 0, optional: true }, { name: 'b', type: 1, optional: false }], indices: [] },
]
expect(canonicalizeSchema(left, 0)).toEqual(canonicalizeSchema(right, 2))
})
it('minimizes differently factored recursive aliases to the same graph', () => {
const self: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 1, optional: false }], indices: [] }, string,
]
const mutual: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'value', type: 2, optional: false }, { name: 'next', type: 1, optional: true }], indices: [] },
{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'value', type: 3, optional: false }], indices: [] }, string, string,
]
const normalized = canonicalizeSchema(self, 0)
expect(canonicalizeSchema(mutual, 0)).toEqual(normalized)
expect(canonicalizeSchema(normalized.nodes, 0)).toEqual(normalized)
mutual[3] = number
expect(schemaDigest(canonicalizeSchema(mutual, 0))).not.toBe(schemaDigest(normalized))
})
it('flattens reordered unions and removes structurally equal alternatives', () => {
const flat: SchemaNode[] = [{ kind: 'union', types: [1, 2] }, string, number]
const factored: SchemaNode[] = [string, number, { kind: 'union', types: [0, 3] }, { kind: 'union', types: [1, 0] }]
expect(canonicalizeSchema(flat, 0)).toEqual(canonicalizeSchema(factored, 2))
expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, string, string], 0)).toEqual(canonicalizeSchema([string], 0))
expect(canonicalizeSchema([{ kind: 'union', types: [1, 2] }, { kind: 'literal', value: true }, { kind: 'literal', value: false }], 0))
.toEqual(canonicalizeSchema([{ kind: 'primitive', type: 'boolean' }], 0))
})
it('ignores recursive union, field, index signature and graph numbering order together', () => {
const original: SchemaNode[] = [
{ kind: 'union', types: [1, 2, 3] },
{ kind: 'object', properties: [{ name: 'label', type: 5, optional: false }, { name: 'next', type: 0, optional: true }], indices: [] },
{ kind: 'array', element: 0 },
{ kind: 'object', properties: [], indices: [{ key: 5, value: 0 }, { key: 4, value: 1 }] },
number,
string,
]
const reordered: SchemaNode[] = [
string,
{ kind: 'object', properties: [], indices: [{ key: 3, value: 5 }, { key: 0, value: 4 }] },
{ kind: 'array', element: 4 },
number,
{ kind: 'union', types: [1, 2, 5] },
{ kind: 'object', properties: [{ name: 'next', type: 4, optional: true }, { name: 'label', type: 0, optional: false }], indices: [] },
]
expect(canonicalizeSchema(reordered, 4)).toEqual(canonicalizeSchema(original, 0))
})
it('preserves tuple positions, element absence, and rest elements', () => {
const baseline: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: true, rest: false }] }, string, number]
const reversed: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 2, optional: false, rest: false }, { type: 1, optional: true, rest: false }] }, string, number]
const rest: SchemaNode[] = [{ kind: 'tuple', elements: [{ type: 1, optional: false, rest: false }, { type: 2, optional: false, rest: true }] }, string, number]
const digest = schemaDigest(canonicalizeSchema(baseline, 0))
expect(schemaDigest(canonicalizeSchema(reversed, 0))).not.toBe(digest)
expect(schemaDigest(canonicalizeSchema(rest, 0))).not.toBe(digest)
})
it('recognizes arbitrary recursive JSON structurally and retains its definition', () => {
const json: SchemaNode[] = [
{ kind: 'union', types: [1, 2, 3, 4, 5, 6] },
{ kind: 'primitive', type: 'null' }, { kind: 'primitive', type: 'boolean' }, number, string,
{ kind: 'array', element: 0 }, { kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
]
const schema = canonicalizeSchema(json, 0)
expect(isArbitraryJsonSchema(schema)).toBe(true)
expect(schema.nodes.some(node => node.kind === 'array' && node.element === 0)).toBe(true)
json[0] = { kind: 'union', types: [1, 2, 3, 4, 5] }
expect(isArbitraryJsonSchema(canonicalizeSchema(json, 0))).toBe(false)
})
it('rejects missing references and unproductive union cycles', () => {
expect(() => canonicalizeSchema([{ kind: 'array', element: 5 }], 0)).toThrow('missing node 5')
expect(() => canonicalizeSchema([{ kind: 'union', types: [0] }], 0)).toThrow('union cycle')
})
})
+246
View File
@@ -0,0 +1,246 @@
/**
* Source-independent JSON type graphs and canonical persistence fingerprints.
* Numeric references are local to one graph; names and source locations are metadata.
*/
import { createHash } from 'node:crypto'
/** One JSON property, with absence represented independently of its value type. */
export interface SchemaProperty {
readonly name: string
readonly type: number
readonly optional: boolean
}
/** One positional tuple element. */
export interface SchemaTupleElement {
readonly type: number
readonly optional: boolean
readonly rest: boolean
}
/** A resolved persisted type; references address nodes in the enclosing graph. */
export type SchemaNode =
| { readonly kind: 'primitive'; readonly type: 'null' | 'boolean' | 'number' | 'string' | 'never' }
| { readonly kind: 'literal'; readonly value: string | number | boolean }
| { readonly kind: 'opaque'; readonly reason: 'any' | 'unknown' }
| { readonly kind: 'array'; readonly element: number }
| { readonly kind: 'tuple'; readonly elements: readonly SchemaTupleElement[] }
| { readonly kind: 'object'; readonly properties: readonly SchemaProperty[]; readonly indices: readonly { readonly key: number; readonly value: number }[] }
| { readonly kind: 'union'; readonly types: readonly number[] }
/** A self-contained minimized graph with deterministic root-first node numbering. */
export interface CanonicalSchema {
readonly root: 0
readonly nodes: readonly SchemaNode[]
}
/** One independently tracked persistent record vocabulary. */
export interface PersistenceRoot {
readonly key: string
readonly kind: 'header' | 'envelope' | 'event'
readonly event?: string
readonly surface?: boolean
readonly digest: string
readonly schema: CanonicalSchema
}
/** One reachable structural type and its non-fingerprinted source declaration metadata. */
export interface PersistenceType {
readonly digest: string
readonly schema: CanonicalSchema
readonly names: readonly string[]
readonly sources: readonly string[]
}
/** Complete current-source persistence inventory; version pins normalization too. */
export interface PersistenceSchemaInventory {
readonly formatVersion: 1
readonly roots: readonly PersistenceRoot[]
readonly types: readonly PersistenceType[]
}
/**
* Visit direct graph edges in their normalized semantic order.
* @param node - resolved graph node.
* @returns referenced node indices, including repeated ordered edges.
*/
export function schemaChildren(node: SchemaNode): number[] {
switch (node.kind) {
case 'array': return [node.element]
case 'tuple': return node.elements.map(element => element.type)
case 'object': return [...node.properties.map(property => property.type), ...node.indices.flatMap(index => [index.key, index.value])]
case 'union': return [...node.types]
case 'primitive':
case 'literal':
case 'opaque': return []
default: return assertNever(node)
}
}
function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
switch (node.kind) {
case 'array': return { kind: 'array', element: ref(node.element) }
case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: ref(element.type), optional: element.optional, rest: element.rest })) }
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 })),
indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
.sort((left, right) => left.key - right.key || left.value - right.value),
}
case 'union': return { kind: 'union', types: [...new Set(node.types.map(ref))].sort((left, right) => left - right) }
case 'primitive': return { kind: 'primitive', type: node.type }
case 'literal': return { kind: 'literal', value: node.value }
case 'opaque': return { kind: 'opaque', reason: node.reason }
default: return assertNever(node)
}
}
function partition(nodes: readonly SchemaNode[]): number[] {
let colors = nodes.map(() => 0)
for (;;) {
const signatures = nodes.map((node, index) => JSON.stringify([colors[index], mapNode(node, id => colors[id] as number)]))
const ordered = [...new Set(signatures)].sort(compare)
const ids = new Map(ordered.map((value, index) => [value, index]))
const next = signatures.map(value => ids.get(value) as number)
const unchanged = new Set(next).size === new Set(colors).size
colors = next
if (unchanged) return colors
}
}
/**
* Minimize bisimilar recursive nodes and number the reachable graph deterministically.
* @param input - resolved nodes; property and union order may be arbitrary.
* @param root - index of the requested root.
* @returns canonical graph excluding unreachable nodes and duplicate structures.
*/
export function canonicalizeSchema(input: readonly SchemaNode[], root: number): CanonicalSchema {
input = normalizeUnions(input)
const selected: number[] = []
const positions = new Map<number, number>()
const select = (id: number): void => {
if (!Number.isInteger(id) || id < 0 || id >= input.length) throw new Error(`persistence schema: missing node ${String(id)}`)
if (positions.has(id)) return
positions.set(id, selected.length)
selected.push(id)
for (const child of schemaChildren(input[id] as SchemaNode)) select(child)
}
select(root)
let nodes = selected.map(id => mapNode(input[id] as SchemaNode, child => positions.get(child) as number))
let rootIndex = 0
for (;;) {
const colors = partition(nodes)
const representatives = new Map<number, number>()
colors.forEach((color, index) => { if (!representatives.has(color)) representatives.set(color, index) })
const aliases = new Map<number, number>()
nodes.forEach((node, index) => {
if (node.kind !== 'union') return
const members = [...new Set(node.types.map(child => colors[child] as number))]
if (members.length === 1) aliases.set(index, representatives.get(members[0] as number) as number)
})
if (aliases.size > 0) {
const resolve = (id: number): number => {
const seen = new Set<number>()
while (aliases.has(id)) {
if (seen.has(id)) throw new Error('persistence schema: union cycle has no material type')
seen.add(id)
id = aliases.get(id) as number
}
return id
}
rootIndex = resolve(rootIndex)
nodes = nodes.map((node, index) => aliases.has(index) ? nodes[resolve(index)] as SchemaNode : mapNode(node, resolve))
continue
}
const emitted = new Map<number, number>()
const result: SchemaNode[] = []
const visit = (id: number): number => {
const color = colors[id] as number
const existing = emitted.get(color)
if (existing !== undefined) return existing
const position = result.length
emitted.set(color, position)
result.push({ kind: 'primitive', type: 'never' })
const representative = representatives.get(color) as number
const colored = mapNode(nodes[representative] as SchemaNode, child => colors[child] as number)
result[position] = mapNode(colored, child => visit(representatives.get(child) as number))
return position
}
visit(rootIndex)
return { root: 0, nodes: result }
}
}
function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
const result = [...input]
const flattened = (id: number, visiting: Set<number>): number[] => {
const node = input[id]
if (node === undefined) throw new Error(`persistence schema: missing node ${String(id)}`)
if (node.kind !== 'union') return [id]
if (visiting.has(id)) throw new Error('persistence schema: union cycle has no material type')
const next = new Set(visiting).add(id)
return node.types.flatMap(child => flattened(child, next))
}
for (const [id, node] of input.entries()) {
if (node.kind !== 'union') continue
let members = [...new Set(flattened(id, new Set()))]
const primitives = new Set(members.flatMap((child) => {
const value = input[child] as SchemaNode
return value.kind === 'primitive' ? [value.type] : []
}))
const literals = members.flatMap((child) => {
const value = input[child] as SchemaNode
return value.kind === 'literal' ? [value.value] : []
})
if (!primitives.has('boolean') && literals.includes(true) && literals.includes(false)) {
primitives.add('boolean')
members.push(result.length)
result.push({ kind: 'primitive', type: 'boolean' })
}
members = members.filter((child) => {
const value = result[child] as SchemaNode
if (value.kind === 'primitive') return value.type !== 'never'
return value.kind !== 'literal' || !primitives.has(typeof value.value as 'string' | 'number' | 'boolean')
})
result[id] = members.length === 0 ? { kind: 'primitive', type: 'never' } : { kind: 'union', types: members }
}
return result
}
/**
* Compute the versioned SHA-256 fingerprint of a canonical graph.
* @param schema - canonical resolved persisted type.
* @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')
}
/**
* Recognize the complete recursive JSON value language without relying on type names.
* @param schema - canonical resolved type.
* @returns whether the type permits arbitrary JSON values.
*/
export function isArbitraryJsonSchema(schema: CanonicalSchema): boolean {
return schemaDigest(schema) === JSON_VALUE_DIGEST
}
const JSON_VALUE_DIGEST = schemaDigest(canonicalizeSchema([
{ kind: 'union', types: [1, 2, 3, 4, 5, 6] },
{ kind: 'primitive', type: 'null' },
{ kind: 'primitive', type: 'boolean' },
{ kind: 'primitive', type: 'number' },
{ kind: 'primitive', type: 'string' },
{ kind: 'array', element: 0 },
{ kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
], 0))
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function assertNever(value: never): never {
throw new Error(`persistence schema: unsupported node ${JSON.stringify(value)}`)
}
+323
View File
@@ -0,0 +1,323 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
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 { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
})
function put(root: string, file: string, source: string): void {
const path = join(root, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, source)
}
function fixture(payload: string, options: { surface?: string; event?: string } = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-persistence-schema-'))
roots.push(root)
put(root, 'tsconfig.host.json', JSON.stringify({ compilerOptions: {
target: 'es2024', module: 'esnext', moduleResolution: 'bundler', strict: true, skipLibCheck: true,
types: [], paths: {
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'],
'@fixture/payload': ['./packages/domain/payload/src/types.ts'],
},
}, include: ['packages/**/src/**/*.ts'] }))
put(root, 'packages/core/session/package.json', '{"name":"@deepseek-ai/dsh-session"}')
put(root, 'packages/domain/payload/package.json', '{"name":"@fixture/payload"}')
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number}\nexport {}\n")
put(root, 'packages/domain/payload/src/types.ts', payload)
put(root, 'packages/core/session/src/types.ts', `
import type { Payload } from '@fixture/payload'
export interface SessionHeader { version: 3; id: string; createdAt: number }
export interface SessionEventMap {
/** One recorded payload. */
'test/record': Payload
${options.event ?? ''}
}
export type SurfaceEventType = ${options.surface ?? 'never'}
export type SessionEvent<T extends keyof SessionEventMap = keyof SessionEventMap> = {
[K in keyof SessionEventMap]: { type: K; seq: number; time: number; data: SessionEventMap[K] }
& (K extends SurfaceEventType ? { surfaceOp: 'append' | { replace: number } } : { surfaceOp?: never })
}[T]
`)
return root
}
function event(inventory: PersistenceSchemaInventory, name = 'test/record'): string {
const root = inventory.roots.find(root => root.event === name)
if (root === undefined) throw new Error(`missing test event ${name}`)
return root.digest
}
describe('persistent source type extraction', () => {
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}')
expect(event(extractPersistenceSchema(left))).toBe(event(extractPersistenceSchema(right)))
})
it.each([
['nested field order',
'export interface Payload {\n id: string\n detail: {\n label: string\n count?: number\n }\n}',
'export interface Payload {\n detail: {\n count?: number\n label: string\n }\n id: string\n}'],
['anonymous union order',
'export type Payload =\n { a: string }\n | { b: number }\n | { c?: boolean }',
'export type Payload =\n { c?: boolean }\n | { b: number }\n | { a: string }'],
['recursive union order',
'export type Payload =\n { a: string; next?: Payload }\n | { b: number; next?: Payload }\n | null',
'export type Payload =\n null\n | { next?: Payload; b: number }\n | { next?: Payload; a: string }'],
['intersection and distributed union order',
'type A = { a: string } | { b: number }; type B = { c: boolean }; export type Payload = A & B',
'type A = { b: number } | { a: string }; type B = { c: boolean }; export type Payload = B & A'],
['index signature order',
'export interface Payload { [key: string]: string | number; [key: number]: number }',
'export interface Payload { [key: number]: number; [key: string]: number | string }'],
['type declaration order',
'interface A { a: string }\ninterface B { b: number }\nexport type Payload = A | B',
'interface B { b: number }\ninterface A { a: string }\nexport type Payload = B | A'],
['merged declaration order',
'export interface Payload { a: string }\nexport interface Payload { b: number }',
'export interface Payload { b: number }\nexport interface Payload { a: string }'],
['mapped key order',
'export type Payload = { [K in "a" | "b" | "c"]: string }',
'export type Payload = { [K in "c" | "b" | "a"]: string }'],
['explicit enum member order',
'enum Value {\n A = "a",\n B = "b",\n}\nexport interface Payload { value: Value }',
'enum Value {\n B = "b",\n A = "a",\n}\nexport interface Payload { value: Value }'],
])('keeps every root and type digest unchanged by %s', (_name, before, after) => {
const root = fixture(before)
const inventory = extractPersistenceSchema(root)
put(root, 'packages/domain/payload/src/types.ts', after)
const reordered = extractPersistenceSchema(root)
expect(reordered.roots).toEqual(inventory.roots)
expect(reordered.types.map(({ digest, schema }) => ({ digest, schema })))
.toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
})
it.each([
['tuple positions', 'export type Payload = [string, number]', 'export type Payload = [number, string]'],
['implicit enum values', 'enum Value { A, B }; export interface Payload { value: Value.A }', 'enum Value { B, A }; export interface Payload { value: Value.A }'],
])('changes the digest when reordering changes %s', (_name, before, after) => {
const root = fixture(before)
const digest = event(extractPersistenceSchema(root))
put(root, 'packages/domain/payload/src/types.ts', after)
expect(event(extractPersistenceSchema(root))).not.toBe(digest)
})
it('keeps header, envelope and event digests unchanged when root declarations are reordered', () => {
const record = " /** One recorded payload. */\n 'test/record': Payload"
const other = "/** Another recorded payload. */\n 'test/other': { value: number }"
const root = fixture('export interface Payload { id: string }', {
surface: "'test/record' | 'test/other'",
event: other,
})
const inventory = extractPersistenceSchema(root)
const source = readFileSync(join(root, 'packages/core/session/src/types.ts'), 'utf8')
const reordered = source
.replace(`${record}\n ${other}`, ` ${other}\n${record}`)
.replace('version: 3; id: string; createdAt: number', 'createdAt: number; id: string; version: 3')
.replace("'test/record' | 'test/other'", "'test/other' | 'test/record'")
.replace('type: K; seq: number; time: number; data: SessionEventMap[K]', 'data: SessionEventMap[K]; time: number; seq: number; type: K')
expect(reordered.indexOf("'test/other':")).toBeLessThan(reordered.indexOf("'test/record':"))
put(root, 'packages/core/session/src/types.ts', reordered)
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {delegationDepth: number; id: string; version: number; type: 'session'}\nexport {}\n")
const result = extractPersistenceSchema(root)
expect(result.roots).toEqual(inventory.roots)
expect(result.types.map(({ digest, schema }) => ({ digest, schema })))
.toEqual(inventory.types.map(({ digest, schema }) => ({ digest, schema })))
})
it('materializes generic, conditional and mapped aliases into their concrete properties', () => {
const generic = fixture('type Box<T> = { [K in keyof T]: T[K] extends string ? string[] : T[K] }; export type Payload = Box<{label: string; count?: number}>')
const concrete = fixture('export interface Payload {count?: number; label: string[]}')
expect(event(extractPersistenceSchema(generic))).toBe(event(extractPersistenceSchema(concrete)))
})
it('retains transitive recursion without depending on alias factoring', () => {
const self = fixture('export interface Payload { value: string; next?: Payload }')
const mutual = fixture('interface Other {next?: Payload; value: string} export interface Payload {value: string; next?: Other}')
const changed = fixture('interface Other {next?: Payload; value: number} export interface Payload {value: string; next?: Other}')
const digest = event(extractPersistenceSchema(self))
expect(event(extractPersistenceSchema(mutual))).toBe(digest)
expect(event(extractPersistenceSchema(changed))).not.toBe(digest)
})
it('includes independent Host modules that augment nested payload maps without event declarations', () => {
const root = fixture('export interface NestedMap {first: {value: string}}; export type Payload = NestedMap[keyof NestedMap]')
const before = extractPersistenceSchema(root)
put(root, 'packages/domain/extension/src/index.ts', "import '@fixture/payload'; declare module '@fixture/payload' {interface NestedMap {second: {value: number}}}")
const after = extractPersistenceSchema(root)
expect(collectLogEvents(root)).toHaveLength(1)
expect(event(after)).not.toBe(event(before))
const data = after.types.find(item => item.schema.nodes[0]?.kind === 'union' && item.schema.nodes.some(node => node.kind === 'primitive' && node.type === 'number'))
expect(data).toBeDefined()
})
it('discovers plugin event merges and keeps ordinary event additions out of the envelope digest', () => {
const root = fixture('export interface Payload {id: string}')
const before = extractPersistenceSchema(root)
put(root, 'packages/domain/extension/src/index.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/** A plugin event. */
'plugin/new': { optional?: boolean }
}}
`)
const after = extractPersistenceSchema(root)
expect(after.roots.some(root => root.event === 'plugin/new')).toBe(true)
expect(after.roots.find(root => root.kind === 'envelope')?.digest).toBe(before.roots.find(root => root.kind === 'envelope')?.digest)
expect(event(after)).toBe(event(before))
})
it('includes JSX-module events and rejects unsupported inherited declarations there', () => {
const root = fixture('export interface Payload {id: string}')
put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/** A JSX-module event. */
'plugin/tsx': { value: number }
}}
`)
const config = join(root, 'tsconfig.host.json')
const settings = JSON.parse(readFileSync(config, 'utf8')) as { include: string[] }
settings.include.push('packages/**/src/**/*.tsx')
writeFileSync(config, JSON.stringify(settings))
expect(extractPersistenceSchema(root).roots.some(root => root.event === 'plugin/tsx')).toBe(true)
put(root, 'packages/domain/extension/src/index.tsx', `import '@deepseek-ai/dsh-session/types';
interface Extra { 'plugin/inherited': {value: number} }
declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap extends Extra {} }
`)
expect(() => extractPersistenceSchema(root)).toThrow('uses extends')
})
it('rejects an event map whose compiler vocabulary exceeds the discovered source corpus', () => {
const root = fixture("import '../../../../vendor/events.ts'; export interface Payload {id: string}")
put(root, 'vendor/events.ts', `import '@deepseek-ai/dsh-session/types'; declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/** A contribution outside the package source corpus. */
'outside/event': { value: number }
}}
`)
expect(() => extractPersistenceSchema(root)).toThrow('omitted: outside/event')
})
it('keeps surface metadata strict and optional field additions visible', () => {
const old = extractPersistenceSchema(fixture('export interface Payload {id: string}'))
const optional = extractPersistenceSchema(fixture('export interface Payload {id: string; detail?: string}'))
const surface = extractPersistenceSchema(fixture('export interface Payload {id: string}', { surface: "'test/record'" }))
expect(event(old)).not.toBe(event(optional))
expect(event(old)).not.toBe(event(surface))
expect(surface.roots.find(root => root.event === 'test/record')?.surface).toBe(true)
expect(surface.roots.find(root => root.kind === 'envelope')?.digest).not.toBe(old.roots.find(root => root.kind === 'envelope')?.digest)
})
it('tracks the physical JSONL header independently of the logical Session header', () => {
const root = fixture('export interface Payload {id: string}')
const before = extractPersistenceSchema(root)
put(root, 'packages/session/session-persistence-jsonl/src/format.ts', "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number; marker?: string}\nexport {}\n")
const after = extractPersistenceSchema(root)
expect(after.roots.find(root => root.key === 'JsonlHeaderLine')?.digest).not.toBe(before.roots.find(root => root.key === 'JsonlHeaderLine')?.digest)
expect(after.roots.find(root => root.key === 'SessionHeader')?.digest).toBe(before.roots.find(root => root.key === 'SessionHeader')?.digest)
})
it('keeps arbitrary JSON structural while explicitly identifying unknown and any', () => {
const root = fixture('type Arbitrary = null | boolean | number | string | Arbitrary[] | {[key: string]: Arbitrary}; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
const model = extractPersistenceSchema(root)
expect(model.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(true)
expect(model.types.filter(item => item.schema.nodes[0]?.kind === 'opaque').map(item => item.schema.nodes[0]))
.toEqual(expect.arrayContaining([{ kind: 'opaque', reason: 'unknown' }, { kind: 'opaque', reason: 'any' }]))
put(root, 'packages/domain/payload/src/types.ts', 'type Arbitrary = null | boolean | number | string | Arbitrary[]; export interface Payload {json: Arbitrary; opaque: unknown; unsafe: any}')
const changed = extractPersistenceSchema(root)
expect(event(changed)).not.toBe(event(model))
expect(changed.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(false)
})
it('rejects invalid reachable declaration files despite inherited skipLibCheck', () => {
const root = fixture("export type { Payload } from './declared.js'")
put(root, 'packages/domain/payload/src/declared.d.ts', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}')
expect(() => extractPersistenceSchema(root)).toThrow('TS2344')
})
it('preserves authored declaration-file any without checking unrelated declarations', () => {
const root = fixture("export type { Payload } from './declared.js'")
put(root, 'packages/domain/payload/src/declared.d.ts', 'type Declared = any; export interface Payload {value: Declared}; type Unrelated = ReturnType<42>')
const model = extractPersistenceSchema(root)
expect(model.types.some(item => item.schema.nodes[0]?.kind === 'opaque' && item.schema.nodes[0].reason === 'any')).toBe(true)
})
it('locates named transitive definitions at their declarations instead of their references', () => {
const root = fixture("import type { Detail as ImportedDetail } from './detail.js'\nexport interface Payload { first: ImportedDetail; second: ImportedDetail }")
put(root, 'packages/domain/payload/src/detail.ts', '/** Detailed payload. */\nexport interface Detail { code: string; done: true }\n')
const model = extractPersistenceSchema(root)
const detail = model.types.find(item => item.names.includes('packages/domain/payload/src/detail.ts#Detail'))
expect(detail?.sources).toEqual(['packages/domain/payload/src/detail.ts:2'])
})
it('keeps shared scalar declaration metadata at real aliases and omits plain property references', () => {
const root = fixture([
'type Label = string',
"type Done = 'done'",
'export interface Payload {',
' label: Label',
' other: string',
' state: Done',
" fallback: 'done'",
' count: number',
'}',
].join('\n'))
const model = extractPersistenceSchema(root)
const string = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'string')
const literal = model.types.find(item => item.schema.nodes[0]?.kind === 'literal' && item.schema.nodes[0].value === 'done')
const number = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'number')
expect(string?.names).toEqual(['packages/domain/payload/src/types.ts#Label'])
expect(string?.sources).toEqual(['packages/domain/payload/src/types.ts:1'])
expect(literal?.names).toEqual(['packages/domain/payload/src/types.ts#Done'])
expect(literal?.sources).toEqual(['packages/domain/payload/src/types.ts:2'])
expect(number?.names).toEqual([])
expect(number?.sources).toEqual([])
})
it('uses the declaration of an anonymous object literal and never its containing property', () => {
const root = fixture('export interface Payload {\n detail:\n { code: string }\n}')
const model = extractPersistenceSchema(root)
const detail = model.types.find((item) => {
const node = item.schema.nodes[0]
return node?.kind === 'object' && node.properties.length === 1 && node.properties[0]?.name === 'code'
})
expect(detail?.sources).toEqual(['packages/domain/payload/src/types.ts:3'])
})
it.each([
['missing type', 'export interface Payload {value: Missing}', 'TS2304'],
['callable value', 'export interface Payload {value: () => void}', 'callable data'],
['class instance', 'export class Instance {value: string = ""}; export interface Payload {value: Instance}', 'class instances'],
['required undefined', 'export interface Payload {value: string | undefined}', 'no supported JSON'],
['unresolved template', 'export interface Payload {value: `prefix-${string}`}', 'no supported JSON'],
['empty object type', 'export type Payload = {}', 'unconstrained empty object'],
['symbol property', 'declare const key: unique symbol; export interface Payload {[key]: string; value: number}', 'symbol-keyed data'],
['conflicting merged property', 'export interface Payload {value: string}; export interface Payload {value: number}', 'TS2717'],
['class intersection', 'class Instance {value: string = ""}; export type Payload = Instance & {extra: string}', 'class instances'],
['invalid generic alias', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}', 'TS2344'],
])('rejects %s rather than weakening the schema', (_name, source, error) => {
expect(() => extractPersistenceSchema(fixture(source))).toThrow(error)
})
it('includes every real repository event and fingerprints every reachable node', () => {
const root = resolve(import.meta.dirname, '..')
const model = extractPersistenceSchema(root)
expect(model.roots.filter(root => root.kind === 'event').map(root => root.event).sort())
.toEqual(collectLogEvents(root).map(event => event.name).sort())
expect(model.roots.some(root => root.kind === 'header')).toBe(true)
expect(model.roots.some(root => root.kind === 'envelope')).toBe(true)
const digests = new Set(model.types.map(type => type.digest))
for (const root of model.roots) {
expect(schemaDigest(root.schema)).toBe(root.digest)
for (let node = 0; node < root.schema.nodes.length; node++) {
expect(digests.has(schemaDigest(canonicalizeSchema(root.schema.nodes, node)))).toBe(true)
}
}
})
})
+462
View File
@@ -0,0 +1,462 @@
/** Extract complete persistent Session record types from one source-only compiler program. */
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 {
canonicalizeSchema,
schemaChildren,
schemaDigest,
type PersistenceRoot,
type PersistenceSchemaInventory,
type PersistenceType,
type SchemaNode,
type SchemaProperty,
} from './persistence-schema-model.ts'
interface DeclarationMetadata {
readonly names: Set<string>
readonly sources: Set<string>
}
interface RootInput {
readonly key: string
readonly kind: PersistenceRoot['kind']
readonly event?: string
readonly surface?: boolean
readonly node: number
}
/** A reachable TypeScript type that cannot be represented as a persisted JSON type. */
export class PersistenceSchemaError extends Error {
override name = 'PersistenceSchemaError'
}
/**
* Extract every repository event, its complete envelope, and the Session header.
* @param root - repository root, with source paths in tsconfig.host.json.
* @returns canonical root fingerprints and all reachable type definitions.
*/
export function extractPersistenceSchema(root: string): PersistenceSchemaInventory {
root = resolve(root)
const events = collectLogEvents(root).sort((left, right) => compare(left.name, right.name))
if (events.length === 0) throw new PersistenceSchemaError('persistence schema: no Session events were discovered')
const filename = resolve(root, 'scripts/__persistence_schema_roots__.ts')
const source = [
"import type { SessionHeader, SessionEvent, SessionEventMap, SurfaceEventType } from '@deepseek-ai/dsh-session/types'",
'export type HeaderRoot = SessionHeader',
'export type SurfaceRoot = SurfaceEventType',
'export type EventNamesRoot = keyof SessionEventMap',
...events.map((event, index) => `export type EventRoot${String(index)} = SessionEvent<${JSON.stringify(event.name)}>`),
'',
].join('\n')
const configPath = resolve(root, 'tsconfig.host.json')
const config = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
if (config.error !== undefined) throw new PersistenceSchemaError(diagnosticText(config.error))
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
const configErrors = parsed.errors.filter(error => error.code !== 18003)
if (configErrors.length > 0) throw new PersistenceSchemaError(configErrors.map(diagnosticText).join('\n'))
const options: ts.CompilerOptions = {
...parsed.options,
composite: false,
incremental: false,
noEmit: true,
rootDir: root,
noUnusedLocals: false,
noUnusedParameters: false,
// Reachable declaration-file errors must not become opaque any values.
skipLibCheck: false,
strict: true,
exactOptionalPropertyTypes: true,
}
const host = ts.createCompilerHost(options)
const originalGetSourceFile = host.getSourceFile.bind(host)
host.getSourceFile = (path, languageVersion, onError, shouldCreateNewSourceFile) =>
resolve(path) === filename
? ts.createSourceFile(filename, source, languageVersion, true)
: originalGetSourceFile(path, languageVersion, onError, shouldCreateNewSourceFile)
const files = [...new Set([
...hostSourceFiles(root, configPath),
...events.map(event => resolve(root, event.source.slice(0, event.source.lastIndexOf(':')))),
])]
const program = ts.createProgram({ rootNames: [filename, ...files], options, host })
const rootSource = program.getSourceFile(filename)
if (rootSource === undefined) throw new PersistenceSchemaError('persistence schema: compiler omitted the requested roots')
const diagnostics = [
...program.getOptionsDiagnostics(), ...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics(rootSource),
]
if (diagnostics.length > 0) throw new PersistenceSchemaError(diagnostics.map(diagnosticText).join('\n'))
const checker = program.getTypeChecker()
const declarations = new Map(rootSource.statements.filter(ts.isTypeAliasDeclaration)
.map(declaration => [declaration.name.text, declaration]))
const declaration = (name: string): ts.TypeAliasDeclaration => {
const found = declarations.get(name)
if (found === undefined) throw new PersistenceSchemaError(`persistence schema: missing compiler root ${name}`)
return found
}
const eventNamesDeclaration = declaration('EventNamesRoot')
const compiledEvents = stringLiterals(checker.getTypeFromTypeNode(eventNamesDeclaration.type), 'keyof SessionEventMap')
const discoveredEvents = new Set(events.map(event => event.name))
const omitted = [...compiledEvents].filter(name => !discoveredEvents.has(name))
const uncompiled = [...discoveredEvents].filter(name => !compiledEvents.has(name))
if (omitted.length > 0 || uncompiled.length > 0) {
throw new PersistenceSchemaError(`persistence schema: compiler and source event discovery disagree; omitted: ${omitted.join(', ')}; uncompiled: ${uncompiled.join(', ')}`)
}
const surfaceDeclaration = declaration('SurfaceRoot')
const surface = stringLiterals(checker.getTypeFromTypeNode(surfaceDeclaration.type), 'SurfaceEventType')
for (const name of surface) {
if (!events.some(event => event.name === name)) throw new PersistenceSchemaError(`persistence schema: surface event ${name} has no declaration`)
}
const header = declaration('HeaderRoot')
const physicalFile = resolve(root, 'packages/session/session-persistence-jsonl/src/format.ts')
const physical = program.getSourceFile(physicalFile)?.statements
.filter((node): node is ts.InterfaceDeclaration | ts.TypeAliasDeclaration =>
ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node))
.filter(declaration => declaration.name.text === 'HeaderLine')
if (physical?.length !== 1) throw new PersistenceSchemaError('persistence schema: expected one current JSONL HeaderLine declaration')
const physicalHeader = physical[0] as ts.InterfaceDeclaration | ts.TypeAliasDeclaration
const declarationSources = validateReachableDeclarations(program, root, [...declarations.values(), physicalHeader])
const extraction = new SchemaExtractor(root, program, declarationSources)
const roots: RootInput[] = [{
key: 'SessionHeader',
kind: 'header',
node: extraction.convert(checker.getTypeFromTypeNode(header.type), header),
}]
roots.push({ key: 'JsonlHeaderLine', kind: 'header', node: extraction.convert(checker.getTypeAtLocation(physicalHeader), physicalHeader) })
const envelopes: number[] = []
for (const [index, event] of events.entries()) {
const item = declaration(`EventRoot${String(index)}`)
const node = extraction.convert(checker.getTypeFromTypeNode(item.type), item)
roots.push({ key: `event:${event.name}`, kind: 'event', event: event.name, surface: surface.has(event.name), node })
envelopes.push(extraction.envelope(node, event.name))
}
roots.splice(2, 0, { key: 'SessionEventEnvelope', kind: 'envelope', node: extraction.union(envelopes) })
return extraction.inventory(roots)
}
function hostSourceFiles(root: string, configPath: string, seen = new Set<string>()): string[] {
configPath = resolve(configPath)
if (seen.has(configPath)) return []
seen.add(configPath)
const read = ts.readConfigFile(configPath, file => readFileSync(file, 'utf8'))
if (read.error !== undefined) throw new PersistenceSchemaError(diagnosticText(read.error))
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath)
const errors = parsed.errors.filter(error => error.code !== 18003)
if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
return [
...parsed.fileNames.filter(file => /^packages\/[^/]+\/[^/]+\/src\//.test(slash(relative(root, file)))),
...(parsed.projectReferences ?? []).flatMap(reference =>
hostSourceFiles(root, extname(reference.path) === '.json' ? reference.path : resolve(reference.path, 'tsconfig.json'), seen)),
]
}
function validateReachableDeclarations(
program: ts.Program,
root: string,
roots: readonly ts.Node[],
): ReadonlyMap<ts.Type, readonly ts.Node[]> {
const checker = program.getTypeChecker()
const visited = new Set<ts.Node>()
const declarations = new Map<ts.SourceFile, ts.Node[]>()
const definitions = new Map<ts.Type, ts.Node[]>()
const visit = (node: ts.Node): void => {
if (visited.has(node)) return
visited.add(node)
const file = node.getSourceFile()
if (trackedSource(slash(relative(root, file.fileName)))) {
const scopes = declarations.get(file) ?? []
scopes.push(node)
declarations.set(file, scopes)
if (isNamedTypeDeclaration(node) || ts.isTypeLiteralNode(node)) {
// Primitive aliases share checker types with unaliased properties.
const type = checker.getTypeAtLocation(node)
const entries = definitions.get(type) ?? []
entries.push(node)
definitions.set(type, entries)
}
}
const target = ts.isTypeReferenceNode(node) ? node.typeName
: ts.isExpressionWithTypeArguments(node) ? node.expression
: ts.isTypeQueryNode(node) ? node.exprName
: ts.isImportTypeNode(node) ? node.qualifier
: undefined
if (target !== undefined) {
let symbol = checker.getSymbolAtLocation(target)
if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol)
for (const declaration of symbol?.declarations ?? []) {
if (trackedSource(slash(relative(root, declaration.getSourceFile().fileName)))) visit(declaration)
}
}
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return
ts.forEachChild(node, visit)
}
for (const root of roots) visit(root)
const errors = [...declarations].flatMap(([file, scopes]) => program.getSemanticDiagnostics(file)
.filter((error) => {
const start = error.start
return start !== undefined && scopes.some(scope => start >= scope.getStart() && start < scope.end)
}))
if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
return definitions
}
class SchemaExtractor {
readonly nodes: SchemaNode[] = []
private readonly cache = new Map<ts.Type, number>()
private readonly declarationMetadata = new Map<number, DeclarationMetadata>()
private readonly checker: ts.TypeChecker
constructor(
private readonly root: string,
program: ts.Program,
private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
) {
this.checker = program.getTypeChecker()
}
convert(type: ts.Type, site: ts.Node): number {
const cached = this.cache.get(type)
if (cached !== undefined) {
this.record(cached, type)
return cached
}
const id = this.nodes.length
this.nodes.push({ kind: 'primitive', type: 'never' })
this.cache.set(type, id)
this.record(id, type)
const add = (node: SchemaNode): number => { this.nodes[id] = node; return id }
const flags = type.flags
if (flags & ts.TypeFlags.Any) {
if ((type as ts.Type & { intrinsicName?: string }).intrinsicName === 'error') this.fail(type, site, 'unresolved compiler type')
return add({ kind: 'opaque', reason: 'any' })
}
if (flags & ts.TypeFlags.Unknown) return add({ kind: 'opaque', reason: 'unknown' })
if (flags & ts.TypeFlags.Never) return add({ kind: 'primitive', type: 'never' })
if (flags & ts.TypeFlags.Null) return add({ kind: 'primitive', type: 'null' })
if (flags & ts.TypeFlags.String) return add({ kind: 'primitive', type: 'string' })
if (flags & ts.TypeFlags.Number) return add({ kind: 'primitive', type: 'number' })
if (flags & ts.TypeFlags.Boolean) return add({ kind: 'primitive', type: 'boolean' })
if (flags & ts.TypeFlags.StringLiteral) return add({ kind: 'literal', value: (type as ts.StringLiteralType).value })
if (flags & ts.TypeFlags.NumberLiteral) return add({ kind: 'literal', value: (type as ts.NumberLiteralType).value })
if (flags & ts.TypeFlags.BooleanLiteral) return add({ kind: 'literal', value: this.checker.typeToString(type) === 'true' })
if (type.isUnion()) return add(this.unionNode(type.types.map(member => this.convert(member, site))))
if (type.isIntersection()) {
const material = type.types.filter(member => !this.phantom(member))
for (const member of material) this.rejectClass(member, site)
if (material.length === 1) {
const target = this.convert(material[0] as ts.Type, site)
return add({ kind: 'union', types: [target] })
}
if (material.length === 0 || material.some(member => (member.flags & ts.TypeFlags.Object) === 0)) {
this.fail(type, site, 'unsupported material intersection')
}
return add(this.object(type, site))
}
if (this.checker.isTupleType(type)) {
const reference = type as ts.TypeReference
const target = reference.target as ts.TupleType
return add({
kind: 'tuple',
elements: this.checker.getTypeArguments(reference).map((element, index) => {
const flags = target.elementFlags[index] ?? ts.ElementFlags.Required
const optional = (flags & ts.ElementFlags.Optional) !== 0
const rest = (flags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0
return { type: this.valueType(element, site, optional), optional, rest }
}),
})
}
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
if (element === undefined) this.fail(type, site, 'array element is unavailable')
return add({ kind: 'array', element: this.convert(element, site) })
}
if (flags & ts.TypeFlags.Object) return add(this.object(type, site))
this.fail(type, site, 'type has no supported JSON representation')
}
private valueType(type: ts.Type, site: ts.Node, optional: boolean): number {
if (!optional) return this.convert(type, site)
const members = (type.isUnion() ? type.types : [type]).filter(member => !(member.flags & ts.TypeFlags.Undefined))
return this.union(members.map(member => this.convert(member, site)))
}
private object(type: ts.Type, site: ts.Node): SchemaNode {
this.rejectClass(type, site)
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) this.fail(type, site, 'callable data is not JSON')
const properties: SchemaProperty[] = []
for (const property of this.checker.getPropertiesOfType(type).sort((left, right) => compare(left.name, right.name))) {
if (property.getName().startsWith('__@')) this.fail(type, site, 'symbol-keyed data is not a JSON field')
const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? site
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration)
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
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 indices = this.checker.getIndexInfosOfType(type).map(info => ({
key: this.convert(info.keyType, site),
value: this.convert(info.type, site),
}))
if (properties.length === 0 && indices.length === 0) this.fail(type, site, 'unconstrained empty object type is not an explicit JSON record')
return { kind: 'object', properties, indices }
}
private rejectClass(type: ts.Type, site: ts.Node): void {
const symbol = type.getSymbol()
if (symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
this.fail(type, site, 'class instances require an explicit persisted representation')
}
}
private phantom(type: ts.Type): boolean {
if (!(type.flags & ts.TypeFlags.Object)) return false
const properties = this.checker.getPropertiesOfType(type)
return properties.length > 0 && properties.every(property => property.getName().startsWith('__@'))
&& type.getCallSignatures().length === 0 && type.getConstructSignatures().length === 0
&& this.checker.getIndexInfosOfType(type).length === 0
}
union(types: readonly number[]): number {
const node = this.unionNode(types)
if (node.kind === 'union' && node.types.length === 1) return node.types[0] as number
this.nodes.push(node)
return this.nodes.length - 1
}
private unionNode(types: readonly number[]): SchemaNode {
if (types.length === 0) return { kind: 'primitive', type: 'never' }
return { kind: 'union', types: [...new Set(types)] }
}
envelope(id: number, event: string): number {
const node = this.nodes[id] as SchemaNode
if (node.kind === 'union') return this.union(node.types.map(type => this.envelope(type, event)))
if (node.kind !== 'object') throw new PersistenceSchemaError('persistence schema: SessionEvent must resolve to object alternatives')
const tag = node.properties.find(property => property.name === 'type')
const data = node.properties.find(property => property.name === 'data')
const literal = tag === undefined ? undefined : canonicalizeSchema(this.nodes, tag.type).nodes[0]
if (tag?.optional !== false || data?.optional !== false || literal?.kind !== 'literal' || literal.value !== event) {
throw new PersistenceSchemaError(`persistence schema: SessionEvent<${JSON.stringify(event)}> must retain its required type and data fields`)
}
const string = this.nodes.length
this.nodes.push({ kind: 'primitive', type: 'string' })
const envelope: SchemaNode = {
kind: 'object',
properties: node.properties.filter(property => property.name !== 'data')
.map(property => property.name === 'type' ? { ...property, type: string } : property),
indices: node.indices,
}
this.nodes.push(envelope)
return this.nodes.length - 1
}
inventory(inputs: readonly RootInput[]): PersistenceSchemaInventory {
const roots = inputs.map((input) => {
const schema = canonicalizeSchema(this.nodes, input.node)
return {
key: input.key,
kind: input.kind,
...(input.event === undefined ? {} : { event: input.event }),
...(input.surface === undefined ? {} : { surface: input.surface }),
digest: schemaDigest(schema),
schema,
}
})
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)
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 input of inputs) visit(input.node, input.key)
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)
const digest = schemaDigest(schema)
const item = types.get(digest) ?? { schema, names: new Set<string>(), sources: new Set<string>() }
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,
roots,
types: [...types].sort(([left], [right]) => compare(left, right)).map(([digest, item]) => ({
digest,
schema: item.schema,
names: [...item.names].sort(compare),
sources: [...item.sources].sort(compare),
})),
}
}
private record(id: number, type: ts.Type): void {
const item = this.declarationMetadata.get(id) ?? { names: new Set<string>(), sources: new Set<string>() }
const declarations = new Set([
...this.declarationSources.get(type) ?? [],
...type.aliasSymbol?.declarations ?? [],
...type.getSymbol()?.declarations ?? [],
])
for (const declaration of declarations) {
if (!isNamedTypeDeclaration(declaration) && !ts.isTypeLiteralNode(declaration) && !ts.isEnumMember(declaration)) continue
const source = declaration.getSourceFile()
const file = slash(relative(this.root, source.fileName))
if (!trackedSource(file)) continue
if (isNamedTypeDeclaration(declaration)) item.names.add(`${file}#${declaration.name.text}`)
const position = source.getLineAndCharacterOfPosition(declaration.getStart())
item.sources.add(`${file}:${String(position.line + 1)}`)
}
this.declarationMetadata.set(id, item)
}
private fail(type: ts.Type, site: ts.Node, reason: string): never {
const source = slash(relative(this.root, site.getSourceFile().fileName))
const position = site.getSourceFile().getLineAndCharacterOfPosition(site.getStart())
throw new PersistenceSchemaError(`persistence schema: ${source}:${String(position.line + 1)}: ${reason}: ${this.checker.typeToString(type)}`)
}
}
function isNamedTypeDeclaration(node: ts.Node): node is ts.TypeAliasDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration {
return ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)
}
function stringLiterals(type: ts.Type, name: string): Set<string> {
if (type.flags & ts.TypeFlags.Never) return new Set()
const members = type.isUnion() ? type.types : [type]
if (members.some(member => !(member.flags & ts.TypeFlags.StringLiteral))) throw new PersistenceSchemaError(`persistence schema: ${name} is not a closed string-literal union`)
return new Set(members.map(member => (member as ts.StringLiteralType).value))
}
function diagnosticText(diagnostic: ts.Diagnostic): string {
return `persistence schema: TypeScript TS${String(diagnostic.code)}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
}
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function slash(path: string): string {
return path.split(sep).join('/')
}
function trackedSource(file: string): boolean {
return (file.startsWith('packages/') || file.startsWith('vendor/')) && !file.includes('/node_modules/')
}
+80
View File
@@ -0,0 +1,80 @@
/** 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 { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
function fixture(optional = true): PersistenceSchemaInventory {
const types = new Map<string, PersistenceType>()
const roots = ['first/event', 'second/event'].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: 'error', type: 3, optional: true }], indices: [] },
{ 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 { formatVersion: 1, roots, types: [...types.values()] }
}
describe('persistence schema catalog', () => {
it('shows a shared nested definition once and updates both affected root fingerprints', () => {
const before = fixture()
const after = fixture(false)
const definitions = renderPersistenceSchemaDefinitions(after)
expect(definitions.match(/### `ErrorInfo`/gu)).toHaveLength(1)
expect(definitions).toContain('| `reason` | required | `string` |')
expect(definitions).toContain('[`ErrorInfo`](#persistence-type-errorinfo)')
const index = renderPersistenceSchemaIndex(after)
for (const [position, root] of after.roots.entries()) {
expect(root.digest).not.toBe(before.roots[position]?.digest)
expect(index).toContain(root.digest)
}
})
it('keeps named definition anchors when their digest changes', () => {
const before = renderPersistenceSchemaDefinitions(fixture())
const after = renderPersistenceSchemaDefinitions(fixture(false))
expect(before).toContain('<a id="persistence-type-errorinfo"></a>')
expect(after).toContain('<a id="persistence-type-errorinfo"></a>')
expect(before).not.toBe(after)
})
it('refuses a current inventory that omits a referenced definition', () => {
const complete = fixture()
const missing: PersistenceSchemaInventory = { ...complete, types: [] }
expect(() => renderPersistenceSchemaIndex(missing)).toThrow(/absent from the inventory/u)
})
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: [] }],
}
expect(renderPersistenceSchemaDefinitions(inventory)).toContain('| `next` | optional | [`Recursive`](#persistence-type-recursive) |')
})
it('uses intrinsic scalar labels even when an alias or reference supplied a name', () => {
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'string' }], 0)
const digest = schemaDigest(schema)
const inventory: PersistenceSchemaInventory = {
formatVersion: 1,
roots: [],
types: [{ digest, schema, names: ['SessionHeader.agentPreset'], sources: [] }],
}
const rendered = renderPersistenceSchemaDefinitions(inventory)
expect(rendered).toContain('### `string`')
expect(rendered).not.toContain('### `SessionHeader.agentPreset`')
})
})
+179
View File
@@ -0,0 +1,179 @@
/** Readable, linked persistence schemas rendered from the fingerprint inventory. */
import { githubSlug } from './verify-md-links.ts'
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
import {
canonicalizeSchema,
schemaChildren,
schemaDigest,
type CanonicalSchema,
type PersistenceSchemaInventory,
type PersistenceType,
type SchemaNode,
} from './persistence-schema-model.ts'
interface TypeDisplay {
readonly type: PersistenceType
readonly label: string
readonly anchor: string
}
function code(text: string): string {
return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
}
function sourcePath(source: string): string {
return source.replace(/:\d+(?::\d+)?$/u, '')
}
function nodeAt(schema: CanonicalSchema, index: number): SchemaNode {
const node = schema.nodes[index]
if (!node) throw new Error(`persistence catalog: missing schema node ${String(index)}`)
return node
}
function childPath(node: SchemaNode, position: number): string {
switch (node.kind) {
case 'object': return node.properties[position]?.name ?? `index-${String(position - node.properties.length)}`
case 'array': return 'item'
case 'tuple': return `item-${String(position)}`
case 'union': return `variant-${String(position)}`
case 'primitive':
case 'literal':
case 'opaque': return String(position)
default: return assertNever(node)
}
}
function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
const paths = new Map<string, string>()
for (const root of inventory.roots) {
const seen = new Set<number>()
const visit = (index: number, path: string): void => {
if (seen.has(index)) return
seen.add(index)
const digest = schemaDigest(canonicalizeSchema(root.schema.nodes, index))
if (!paths.has(digest)) paths.set(digest, path)
const node = nodeAt(root.schema, index)
schemaChildren(node).forEach((child, position) => { visit(child, `${path}.${childPath(node, position)}`) })
}
visit(0, root.key)
}
const labels = inventory.types.map((type) => {
const node = nodeAt(type.schema, 0)
const label = node.kind === 'primitive' ? node.type
: node.kind === 'literal' ? JSON.stringify(node.value)
: node.kind === 'opaque' ? node.reason
: type.names[0] ?? paths.get(type.digest) ?? node.kind
return { type, label }
})
const counts = new Map<string, number>()
for (const { label } of labels) counts.set(label, (counts.get(label) ?? 0) + 1)
const used = new Set<string>()
return new Map(labels.map(({ type, label: original }) => {
const label = (counts.get(original) ?? 0) > 1
? `${original} (${type.sources[0] ? sourcePath(type.sources[0]) : paths.get(type.digest) ?? 'anonymous'})`
: original
const base = `persistence-type-${githubSlug(label)}`
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 }]
}))
}
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`)
return `[${code(entry.label)}](#${entry.anchor})`
}
function typeExpression(
schema: CanonicalSchema,
index: number,
entries: ReadonlyMap<string, TypeDisplay>,
locale: PersistenceCatalogLocale,
): string {
const node = nodeAt(schema, index)
if (node.kind === 'primitive') return code(node.type)
if (node.kind === 'literal') return code(JSON.stringify(node.value))
if (node.kind === 'opaque') return `${code(node.reason)}${persistenceCatalogText[locale].opaque}`
return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
}
function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay>, locale: PersistenceCatalogLocale): 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(' · ')}`, '')
}
const expression = (index: number): string => typeExpression(schema, index, entries, locale)
switch (node.kind) {
case 'object':
if (node.properties.length === 0 && node.indices.length === 0) lines.push(text.emptyObject, '')
else {
lines.push(text.propertyColumns, '|---|---|---|')
for (const property of node.properties) {
lines.push(`| ${code(property.name)} | ${property.optional ? text.optional : text.required} | ${expression(property.type)} |`)
}
for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
lines.push('')
}
break
case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
case 'tuple':
lines.push(text.positionColumns, '|---|---|---|')
node.elements.forEach((element, index) => {
const presence = element.rest ? text.rest : element.optional ? text.optional : text.required
lines.push(`| ${String(index)} | ${presence} | ${expression(element.type)} |`)
})
lines.push('')
break
case 'union':
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
default: assertNever(node)
}
return lines
}
/**
* 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.
* @returns Markdown index including the history and contributor workflow links.
*/
export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
const entries = displays(inventory)
const text = persistenceCatalogText[locale]
return [
`## ${text.fingerprints}`, '', text.fingerprintsIntro, '', text.historyIntro, '',
text.rootColumns, '|---|---|---|---|',
...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
].join('\n')
}
/**
* 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.
* @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
*/
export function renderPersistenceSchemaDefinitions(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): 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)),
].join('\n')
}
function assertNever(value: never): never {
throw new Error(`persistence catalog: unsupported type ${JSON.stringify(value)}`)
}
+1
View File
@@ -741,6 +741,7 @@ function docSyncLeafGates(options: {
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('persistence-changes', 'verify-persistence-changes', { label: 'persistence type history' }),
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('concrete-terms', 'verify-concrete-terms', { label: 'concrete terms', quick: true }),