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