persistence-release-facts.ts 5.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. /** Render bounded release facts from validated offline records while preserving authored prose. */
  2. import { readFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { renderPersistencePair } from './persistence-artifacts.ts'
  5. import type { PersistenceArtifact } from './persistence-artifacts.ts'
  6. import type { PersistenceReleaseEntry, PersistenceReleases } from './persistence-releases.ts'
  7. import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
  8. type Language = 'en' | 'zh'
  9. function count(value: number, noun: string): string {
  10. return `${value} ${noun}${value === 1 ? '' : 's'}`
  11. }
  12. function replaceFacts(source: string, name: string, content: string, path: string): string {
  13. const start = `<!-- persistence-release-${name}:start -->`
  14. const end = `<!-- persistence-release-${name}:end -->`
  15. const opening = source.indexOf(start)
  16. const closing = source.indexOf(end)
  17. if (opening < 0 || closing < opening || source.indexOf(start, opening + start.length) >= 0
  18. || source.indexOf(end, closing + end.length) >= 0) throw new Error(`${path}: expected one ${name} factual block`)
  19. return source.slice(0, opening + start.length) + content + source.slice(closing)
  20. }
  21. function structuralChanges(entry: PersistenceReleaseEntry, language: Language): string {
  22. if (entry.record.previous === null) {
  23. return language === 'en'
  24. ? 'This entry establishes the historical comparison starting point. Its declaration lists every extracted root; it makes no compatibility judgment about earlier versions.'
  25. : '本条记录建立历史比较起点。机器声明列出所有提取的根类型,不对更早版本作兼容性判断。'
  26. }
  27. if (entry.differences.length === 0) {
  28. return language === 'en'
  29. ? 'Normalized root types and their transitive digests are unchanged from the preceding tag.'
  30. : '规范化后的根类型及其传递引用摘要与前一 tag 相同。'
  31. }
  32. const summary = language === 'en'
  33. ? `Detected ${count(entry.record.changes.length, 'changed root')} and ${count(entry.differences.length, 'structural difference')}. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.`
  34. : `检测到 ${entry.record.changes.length} 个根类型变化、${entry.differences.length} 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。`
  35. const heading = language === 'en' ? '| Path | Change | Current minimum |' : '| 路径 | 变化 | 当前最低要求 |'
  36. const rows = entry.differences.map((change) => {
  37. const path = change.path.replaceAll('`', '\\`').replaceAll('|', '\\|')
  38. return `| \`${path}\` | \`${change.kind}\` | \`${change.requiresVersionBump ? 'version-bump' : 'same-version'}\` |`
  39. })
  40. return [summary, '', heading, '|---|---|---|', ...rows].join('\n')
  41. }
  42. /** Compute paired factual updates without writing or changing machine declarations.
  43. * @param root - checkout containing existing documents with bounded factual markers.
  44. * @param archive - fully validated release records and reconstructed roots.
  45. * @returns complete bilingual documents and sidecars, validated together before any caller writes.
  46. */
  47. export function persistenceReleaseFactArtifacts(root: string, archive: PersistenceReleases): PersistenceArtifact[] {
  48. const directory = 'docs/persistence-changes/releases'
  49. const rootTypes = new Map<string, readonly string[]>()
  50. const typeCounts = archive.entries.map((entry) => {
  51. const types = new Set<string>()
  52. for (const root of entry.roots.values()) {
  53. let digests = rootTypes.get(root.digest)
  54. if (digests === undefined) {
  55. digests = root.schema.nodes.map((_, index) => schemaDigest(canonicalizeSchema(root.schema.nodes, index)))
  56. rootTypes.set(root.digest, digests)
  57. }
  58. for (const digest of digests) types.add(digest)
  59. }
  60. return types.size
  61. })
  62. const read = (name: string): string => readFileSync(join(root, directory, name), 'utf8')
  63. const index = (language: Language): string => {
  64. const heading = language === 'en'
  65. ? '| Tag | Source date (UTC) | Session version | Roots / types | Changed roots |'
  66. : '| Tag | 源码日期(UTC) | Session 版本 | 根 / 类型 | 变化根 |'
  67. const rows = archive.entries.map((entry, index) => {
  68. const tag = entry.release.tag
  69. const date = new Date(entry.release.sourceDate).toISOString().slice(0, 10)
  70. return `| [${tag}](${tag}${language === 'zh' ? '.zh' : ''}.md) | ${date} | ${entry.release.sessionFormatVersion} | ${entry.roots.size} / ${typeCounts[index]} | ${entry.record.changes.length} |`
  71. })
  72. return '\n\n' + [heading, '|---|---|---|---|---|', ...rows].join('\n') + '\n\n'
  73. }
  74. const artifacts = renderPersistencePair(root, `${directory}/README.md`,
  75. replaceFacts(read('README.md'), 'index', index('en'), 'README.md'),
  76. replaceFacts(read('README.zh.md'), 'index', index('zh'), 'README.zh.md'))
  77. for (const [index, entry] of archive.entries.entries()) {
  78. const document = (language: Language): string => {
  79. const path = `${entry.release.tag}${language === 'zh' ? '.zh' : ''}.md`
  80. const inventory = language === 'en'
  81. ? `${count(entry.roots.size, 'root')} / ${count(typeCounts[index] as number, 'type')}`
  82. : `${entry.roots.size} 个根类型 / ${typeCounts[index]} 种类型`
  83. return replaceFacts(replaceFacts(read(path), 'inventory', inventory, path),
  84. 'changes', '\n\n' + structuralChanges(entry, language) + '\n\n', path)
  85. }
  86. artifacts.push(...renderPersistencePair(root, `${directory}/${entry.release.tag}.md`, document('en'), document('zh')))
  87. }
  88. return artifacts
  89. }