archived-agent-notes.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /** Pure archive-format, triplet, and immutable-manifest helpers. */
  2. import { createHash } from 'node:crypto'
  3. import { basename } from 'node:path'
  4. import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts'
  5. /** Versioned fields in the frozen-content manifest. */
  6. export interface ArchiveManifest {
  7. version: 1
  8. files: Readonly<Record<string, string>>
  9. }
  10. /** Hash one archived artifact independently of the repository's Git object format. */
  11. function archiveContentHash(content: Buffer): string {
  12. return `sha256:${createHash('sha256').update(content).digest('hex')}`
  13. }
  14. /** Compute the SHA-1 Git blob id used by bilingual consistency sidecars. */
  15. export function gitBlobHash(content: Buffer): string {
  16. const hash = createHash('sha1')
  17. hash.update(`blob ${content.byteLength}\0`)
  18. hash.update(content)
  19. return hash.digest('hex')
  20. }
  21. function isRecord(value: unknown): value is Record<string, unknown> {
  22. return typeof value === 'object' && value !== null && !Array.isArray(value)
  23. }
  24. /** Parse the archive manifest and reject fields or hashes outside its closed schema. */
  25. export function parseArchiveManifest(content: string): ArchiveManifest {
  26. const value: unknown = JSON.parse(content)
  27. if (!isRecord(value)) throw new Error('expected a JSON object')
  28. const fields = Object.keys(value).sort()
  29. if (fields.join(',') !== 'files,version') throw new Error('expected exactly the fields `version` and `files`')
  30. if (value.version !== 1) throw new Error('unsupported manifest version (expected 1)')
  31. if (!isRecord(value.files)) throw new Error('`files` must be an object')
  32. const files: Record<string, string> = {}
  33. for (const [path, hash] of Object.entries(value.files)) {
  34. if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) {
  35. throw new Error(`invalid content hash for ${path}`)
  36. }
  37. files[path] = hash
  38. }
  39. return { version: 1, files }
  40. }
  41. /** Render the archive manifest with deterministic path ordering. */
  42. export function renderArchiveManifest(files: Readonly<Record<string, string>>): string {
  43. return `${JSON.stringify({
  44. version: 1,
  45. files: Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right))),
  46. }, null, 2)}\n`
  47. }
  48. /** Reject changes or removals of entries sealed by a prior manifest. */
  49. export function validateArchiveManifestExtension(
  50. baseline: ArchiveManifest,
  51. current: ArchiveManifest,
  52. ): string[] {
  53. const errors: string[] = []
  54. for (const [path, expected] of Object.entries(baseline.files)) {
  55. const actual = current.files[path]
  56. if (actual === undefined) errors.push(`${path}: sealed manifest entry is missing`)
  57. else if (actual !== expected) errors.push(`${path}: sealed manifest hash changed`)
  58. }
  59. return errors
  60. }
  61. function validDate(value: string): boolean {
  62. const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
  63. if (match === null) return false
  64. const year = Number(match[1])
  65. const month = Number(match[2])
  66. const day = Number(match[3])
  67. const date = new Date(Date.UTC(year, month - 1, day))
  68. return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
  69. }
  70. interface Triplet {
  71. source?: Buffer
  72. zh?: Buffer
  73. meta?: Buffer
  74. }
  75. function pairMeta(content: string): Map<string, string> | undefined {
  76. const entries = new Map<string, string>()
  77. for (const line of content.split('\n')) {
  78. if (line === '' || line.startsWith('#')) continue
  79. const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
  80. if (match?.[1] === undefined || match[2] === undefined) return undefined
  81. entries.set(match[1], match[2])
  82. }
  83. return entries
  84. }
  85. function validateHeader(path: string, content: Buffer, sourceBase: string, chinese: boolean): string[] {
  86. const errors: string[] = []
  87. const lines = content.toString('utf8').split('\n')
  88. if (!/^# Agent Note: \S/.test(lines[0] ?? '')) errors.push(`${path}: line 1 must be \`# Agent Note: <title>\``)
  89. if (lines[1] !== '') errors.push(`${path}: line 2 must be blank`)
  90. if (lines[2] !== 'Status: implemented') errors.push(`${path}: line 3 must be \`Status: implemented\``)
  91. const archived = /^Archived: (\d{4}-\d{2}-\d{2})$/.exec(lines[3] ?? '')?.[1]
  92. if (archived === undefined || !validDate(archived)) {
  93. errors.push(`${path}: line 4 must be \`Archived: YYYY-MM-DD\` with a valid date`)
  94. } else if (archived < sourceBase.slice(0, 10)) {
  95. errors.push(`${path}: archive date ${archived} predates the note filename`)
  96. }
  97. if (lines[4] !== '') errors.push(`${path}: line 5 must be blank`)
  98. const switcher = chinese
  99. ? `[English](${sourceBase}.md) | 中文`
  100. : `English | [中文](${sourceBase}.zh.md)`
  101. if (lines[5] !== switcher) errors.push(`${path}: line 6 must be ${JSON.stringify(switcher)}`)
  102. return errors
  103. }
  104. /** Validate the closed kind tree, implemented/archive headers, and complete bilingual triplets. */
  105. export function validateArchiveArtifacts(artifacts: ReadonlyMap<string, Buffer>): string[] {
  106. const errors: string[] = []
  107. const triplets = new Map<string, Triplet>()
  108. for (const [path, content] of artifacts) {
  109. const match = /^([^/]+)\/(\d{4}-\d{2}-\d{2}-.+?)(\.zh\.md|\.i18n\.yaml|\.md)$/.exec(path)
  110. if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
  111. errors.push(`${path}: expected {kind}/yyyy-mm-dd-topic.{md,zh.md,i18n.yaml}`)
  112. continue
  113. }
  114. if (!(AGENT_NOTE_CLASSES as readonly string[]).includes(match[1])) {
  115. errors.push(`${path}: unknown Agent Note kind ${JSON.stringify(match[1])}`)
  116. continue
  117. }
  118. const key = `${match[1]}/${match[2]}`
  119. const triplet = triplets.get(key) ?? {}
  120. if (match[3] === '.md') triplet.source = content
  121. else if (match[3] === '.zh.md') triplet.zh = content
  122. else triplet.meta = content
  123. triplets.set(key, triplet)
  124. }
  125. for (const [key, triplet] of [...triplets].sort(([left], [right]) => left.localeCompare(right))) {
  126. const sourcePath = `${key}.md`
  127. const zhPath = `${key}.zh.md`
  128. const metaPath = `${key}.i18n.yaml`
  129. const { source, zh, meta } = triplet
  130. const missing = [
  131. source === undefined ? sourcePath : undefined,
  132. zh === undefined ? zhPath : undefined,
  133. meta === undefined ? metaPath : undefined,
  134. ].filter((path): path is string => path !== undefined)
  135. if (source === undefined || zh === undefined || meta === undefined) {
  136. errors.push(`${key}: incomplete archived triplet; missing ${missing.join(', ')}`)
  137. continue
  138. }
  139. const sourceBase = basename(key)
  140. errors.push(...validateHeader(sourcePath, source, sourceBase, false))
  141. errors.push(...validateHeader(zhPath, zh, sourceBase, true))
  142. const sourceDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(source.toString('utf8'))?.[1]
  143. const zhDate = /^Archived: (\d{4}-\d{2}-\d{2})$/m.exec(zh.toString('utf8'))?.[1]
  144. if (sourceDate !== undefined && zhDate !== undefined && sourceDate !== zhDate) {
  145. errors.push(`${key}: English and Chinese archive dates differ (${sourceDate} vs ${zhDate})`)
  146. }
  147. const pair = pairMeta(meta.toString('utf8'))
  148. if (pair === undefined || pair.size !== 2
  149. || pair.get(`${sourceBase}.md`) !== gitBlobHash(source)
  150. || pair.get(`${sourceBase}.zh.md`) !== gitBlobHash(zh)) {
  151. errors.push(`${metaPath}: consistency record must contain the current Git blob hashes of both archived sides`)
  152. }
  153. }
  154. return errors
  155. }
  156. /** Preserve every sealed path/hash and append hashes for newly archived artifacts. */
  157. export function extendArchiveManifest(
  158. existing: ArchiveManifest,
  159. artifacts: ReadonlyMap<string, Buffer>,
  160. ): { files: Record<string, string>; added: string[]; errors: string[] } {
  161. const errors: string[] = []
  162. const files: Record<string, string> = { ...existing.files }
  163. for (const [path, expected] of Object.entries(existing.files)) {
  164. const content = artifacts.get(path)
  165. if (content === undefined) errors.push(`${path}: sealed artifact is missing`)
  166. else if (archiveContentHash(content) !== expected) errors.push(`${path}: sealed content hash changed`)
  167. }
  168. const added: string[] = []
  169. for (const [path, content] of [...artifacts].sort(([left], [right]) => left.localeCompare(right))) {
  170. if (files[path] !== undefined) continue
  171. files[path] = archiveContentHash(content)
  172. added.push(path)
  173. }
  174. return { files, added, errors }
  175. }