From cf6276e78d4409fe35de1f936955e1e0104df3bf Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 25 Sep 2026 01:09:44 +0800 Subject: [PATCH] fix: preserve independent persistence type graphs --- ...viewing-persistence-type-changes.i18n.yaml | 4 ++-- .../reviewing-persistence-type-changes.md | 2 +- .../reviewing-persistence-type-changes.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 7 +++++-- docs/persistence-catalog.md | 8 +++++++ docs/persistence-catalog.zh.md | 8 +++++++ .../historical-formats/README.i18n.yaml | 4 ++-- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 14 +++++++++++++ scripts/persistence-formats.ts | 2 +- scripts/persistence-schema-model.ts | 21 +++++++++++++------ scripts/persistence-schema-snapshot.spec.ts | 19 +++++++++++++++++ scripts/persistence-schema.spec.ts | 11 ---------- scripts/persistence-schema.ts | 10 +++------ 15 files changed, 81 insertions(+), 35 deletions(-) diff --git a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml index d15cbd4f6f..efb3b93c60 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml +++ b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml @@ -15,8 +15,8 @@ en: 5fca19ac2bb1ddac zh: 00d78fe7e80ba4f7 /cookbook-reviewing-persistence-type-changes/optional-inspect-the-change: - en: 39443a54ee68b24a - zh: cdece79db4977116 + en: 3f85edb803afe870 + zh: 545c651b9e5ccb56 /cookbook-reviewing-persistence-type-changes/1-record-the-change: en: 342bd49412db070e zh: a8d14958e9a637d9 diff --git a/docs/cookbook/reviewing-persistence-type-changes.md b/docs/cookbook/reviewing-persistence-type-changes.md index 45af2fd175..bf3810453c 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.md +++ b/docs/cookbook/reviewing-persistence-type-changes.md @@ -41,7 +41,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types. -The current machine inventory stores complete graphs in `roots`; each `types` entry contains only `digest`, `names`, and `sources`. Readers reconstruct type graphs from root subgraphs by digest and also accept historical entries with an explicit `schema`. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement. +The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement. ## 1. Record the change diff --git a/docs/cookbook/reviewing-persistence-type-changes.zh.md b/docs/cookbook/reviewing-persistence-type-changes.zh.md index 4a176570e0..4e31658b6b 100644 --- a/docs/cookbook/reviewing-persistence-type-changes.zh.md +++ b/docs/cookbook/reviewing-persistence-type-changes.zh.md @@ -41,7 +41,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte 在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。 -当前机器清单在 `roots` 中保存完整图;每个 `types` 条目只包含 `digest`、`names` 和 `sources`。读取器按摘要从根的子图重建类型图,同时接受带有显式 `schema` 的历史条目。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。 +当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。 ## 1. 记录变更 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 2f05118220..6e6a1802d1 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1260,8 +1260,11 @@ en: a0d0018c68abc564 zh: 299d86dbba56b528 /session-persistence-event-catalog/resolved-persistence-types/boolean: - en: b121d42640099ec1 - zh: b121d42640099ec1 + en: 40616917f0b8f9cc + zh: 40616917f0b8f9cc +/session-persistence-event-catalog/resolved-persistence-types/false: + en: 0e3a8e3c2c62bdd7 + zh: 0e3a8e3c2c62bdd7 /session-persistence-event-catalog/resolved-persistence-types/never~2: en: a35333b49c104e92 zh: a35333b49c104e92 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e2c37ca1f3..b9a4241eef 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5176,6 +5176,14 @@ SHA-256: `aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877` `boolean` + + +### `false` + +SHA-256: `0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3` + +`false` + ### `never` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 5d7cc912be..139a1fb20b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5178,6 +5178,14 @@ SHA-256: `aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877` `boolean` + + +### `false` + +SHA-256: `0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3` + +`false` + ### `never` diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index b11919a866..4579af471a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 528c6482cbafbc3e - zh: 6215d623ca04df65 + en: 45dba01dd6d69c80 + zh: c366d1a3991118c9 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index bb425d5137..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 586 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index d7a3c9f67c..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 586 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index fa209989a6..80e6ca754e 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -21808,6 +21808,20 @@ "names": [], "sources": [] }, + { + "digest": "0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": false + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "0d47a8f3d847243c4488e755ad4cab7a04ac5d4621b9c81865524cf3f4d8536f", "names": [], diff --git a/scripts/persistence-formats.ts b/scripts/persistence-formats.ts index fe908e0b4a..c215e3223d 100644 --- a/scripts/persistence-formats.ts +++ b/scripts/persistence-formats.ts @@ -109,7 +109,7 @@ function validateInventory(inventory: PersistenceSchemaInventory, version: numbe for (const type of inventory.types) { if (listed.has(type.digest)) throw new Error(`${label}: duplicate schema type ${type.digest}`) listed.add(type.digest) - // Legacy current inventories can retain types erased by normalization; the generator gate checks current output. + // The current extractor can retain types erased by normalization; its generator gate checks that inventory. if (!current && !reachable.has(type.digest)) throw new Error(`${label}: unreferenced schema type ${type.digest}`) remaining.delete(type.digest) } diff --git a/scripts/persistence-schema-model.ts b/scripts/persistence-schema-model.ts index 99acee2d61..f2beeb8eac 100644 --- a/scripts/persistence-schema-model.ts +++ b/scripts/persistence-schema-model.ts @@ -4,6 +4,7 @@ */ import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' /** Recorded reader promise for explicitly attributed source additions. */ export interface SourceCompatibility { @@ -71,8 +72,10 @@ export interface PersistenceSchemaInventory { readonly types: readonly PersistenceType[] } -/** On-disk type metadata; complete graphs are stored only in roots. */ -export type PersistenceTypeIndex = Omit +/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */ +export interface PersistenceTypeIndex extends Omit { + readonly schema?: CanonicalSchema +} /** Compact inventory representation; formatVersion still pins normalization. */ export interface PersistenceSchemaSnapshot { @@ -98,15 +101,21 @@ export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map root.schema)) return { formatVersion: inventory.formatVersion, roots: inventory.roots, - types: inventory.types.map(({ digest, names, sources }) => ({ digest, names, sources })), + types: inventory.types.map(({ digest, schema, names, sources }) => ({ + digest, + ...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }), + names, + sources, + })), } } diff --git a/scripts/persistence-schema-snapshot.spec.ts b/scripts/persistence-schema-snapshot.spec.ts index 50b8fb2386..1f081b0bf9 100644 --- a/scripts/persistence-schema-snapshot.spec.ts +++ b/scripts/persistence-schema-snapshot.spec.ts @@ -40,6 +40,25 @@ describe('compact persistence snapshots', () => { expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original) }) + it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => { + const base = inventory() + const schema = canonicalizeSchema([ + { kind: 'object', properties: [{ name: 'next', type: 0, optional: true }, { name: 'flag', type: 1, optional: false }], indices: [] }, + { kind: 'literal', value: false }, + ], 0) + const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] } + const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] } + const snapshot = persistenceSchemaSnapshot(original) + expect(snapshot.types[0]).toEqual(independent) + if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true) + else expect(snapshot.types).toEqual(original.types) + const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot))) + expect(restored).toEqual(original) + expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original) + expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot) + expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original)) + }) + it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => { const original = inventory() expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original) diff --git a/scripts/persistence-schema.spec.ts b/scripts/persistence-schema.spec.ts index 5cf50d625a..63a5464c94 100644 --- a/scripts/persistence-schema.spec.ts +++ b/scripts/persistence-schema.spec.ts @@ -56,17 +56,6 @@ function event(inventory: PersistenceSchemaInventory, name = 'test/record'): str } describe('persistent source type extraction', () => { - it('indexes normalized reachable types without erased boolean literals', () => { - const root = fixture('export interface Payload { enabled: boolean; next?: Payload }') - const inventory = extractPersistenceSchema(root) - const reachable = new Set(inventory.roots.flatMap(root => root.schema.nodes - .map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index))))) - expect(new Set(inventory.types.map(type => type.digest))).toEqual(reachable) - expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'primitive' && type.schema.nodes[0].type === 'boolean')).toBe(true) - expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'literal' && type.schema.nodes[0].value === false)).toBe(false) - expect(inventory.types.some(type => type.names.some(name => name.endsWith('#Payload')))).toBe(true) - }) - it('keeps authored names and anonymous declaration locations without synthesized traversal names', () => { const root = fixture('interface Section { text: string }\nexport interface Payload { inserted: { source: { kind: "hooks-codex"; sections: Section[] } }[] }') const inventory = extractPersistenceSchema(root) diff --git a/scripts/persistence-schema.ts b/scripts/persistence-schema.ts index 545792403e..ecdf22e1c6 100644 --- a/scripts/persistence-schema.ts +++ b/scripts/persistence-schema.ts @@ -9,7 +9,6 @@ import { validSourceCompatibility } from './persistence-source-policy.ts' import { canonicalizeSchema, schemaChildren, - reachableSchemaTypes, schemaDigest, schemaHasCompatibility, type PersistenceRoot, @@ -437,18 +436,15 @@ class SchemaExtractor { for (const child of schemaChildren(node)) visit(child) } for (const input of inputs) visit(input.node) - const types = new Map; sources: Set }>( - [...reachableSchemaTypes(roots.map(root => root.schema))].map(([digest, schema]) => - [digest, { schema, names: new Set(), sources: new Set() }]), - ) + const types = new Map; sources: Set }>() for (const id of found) { const schema = canonicalizeSchema(this.nodes, id) const digest = schemaDigest(schema) - const item = types.get(digest) - if (item === undefined) continue + const item = types.get(digest) ?? { schema, names: new Set(), sources: new Set() } 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) + types.set(digest, item) } return { formatVersion: roots.some(root => schemaHasCompatibility(root.schema)) ? 2 : 1,