docs: require bilingual non-README documentation

This commit is contained in:
Tianyi Cui
2026-07-26 02:33:53 +08:00
parent 30db52fa4b
commit f32d3f207f
12 changed files with 111 additions and 43 deletions
File diff suppressed because one or more lines are too long
@@ -1,4 +1,7 @@
{
"requiredClasses": [
"non-readme"
],
"requiredSince": "2026-07-14",
"required": [
".agents/notes/README.md",
@@ -215,6 +218,7 @@
"excluded": [
".agents/notes/AGENTS.md",
".agents/notes/implemented/AGENTS.md",
".agents/notes/implemented/CLAUDE.md",
"docs/AGENTS.md",
"docs/agent-lifecycle.md",
"docs/capability-seams.md",
+38
View File
@@ -7,6 +7,8 @@ import {
parseTranslationMarkdown,
parseTranslationPairingManifest,
requiresPairByDate,
requiresTranslationPair,
translationDocumentClass,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -20,10 +22,12 @@ describe('translation pairing manifest', () => {
expect(parseTranslationPairingManifest(JSON.stringify({
requiredSince: '2026-07-14',
required: ['README.md'],
requiredClasses: ['non-readme'],
excluded: ['docs/generated/'],
}))).toEqual({
requiredSince: '2026-07-14',
required: ['README.md'],
requiredClasses: ['non-readme'],
excluded: ['docs/generated/'],
})
})
@@ -33,6 +37,7 @@ describe('translation pairing manifest', () => {
expect(() => parseTranslationPairingManifest(JSON.stringify({
requiredSince: cutoff,
required: [],
requiredClasses: [],
excluded: [],
}))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
})
@@ -41,9 +46,42 @@ describe('translation pairing manifest', () => {
expect(() => parseTranslationPairingManifest(JSON.stringify({
requiredSince: '2026-07-14',
required: [42],
requiredClasses: [],
excluded: [],
}))).toThrow('required must be an array of strings')
})
it('rejects unknown and duplicate document classes', () => {
const manifest = (requiredClasses: string[]) => JSON.stringify({
requiredSince: '2026-07-14',
required: [],
requiredClasses,
excluded: [],
})
expect(() => parseTranslationPairingManifest(manifest(['guide']))).toThrow('requiredClasses must contain only')
expect(() => parseTranslationPairingManifest(manifest(['readme', 'readme']))).toThrow('requiredClasses must not contain duplicates')
})
})
describe('document-class pairing frontier', () => {
const manifest = parseTranslationPairingManifest(JSON.stringify({
requiredSince: '2026-07-14',
required: ['docs/legacy/README.md'],
requiredClasses: ['non-readme'],
excluded: [],
}))
it('classifies README basenames case-insensitively', () => {
expect(translationDocumentClass('packages/core/README.md')).toBe('readme')
expect(translationDocumentClass('missions/readme.md')).toBe('readme')
expect(translationDocumentClass('docs/readme-guide.md')).toBe('non-readme')
})
it('requires every non-README while retaining explicit README entries', () => {
expect(requiresTranslationPair('docs/guide.md', manifest)).toBe(true)
expect(requiresTranslationPair('docs/legacy/README.md', manifest)).toBe(true)
expect(requiresTranslationPair('docs/new/README.md', manifest)).toBe(false)
})
})
describe('date-based pairing frontier', () => {
+33
View File
@@ -12,11 +12,18 @@ import type { Nodes } from 'mdast'
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
export interface TranslationPairingManifest {
required: string[]
/** Document classes whose complete in-scope population must be paired. */
requiredClasses: TranslationDocumentClass[]
excluded: string[]
/** Date-named documents on or after this day must merge bilingual. */
requiredSince: string
}
/** Stable classes used to close one translation rollout without enumerating files. */
export type TranslationDocumentClass = 'readme' | 'non-readme'
const TRANSLATION_DOCUMENT_CLASSES: TranslationDocumentClass[] = ['readme', 'non-readme']
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
@@ -40,6 +47,19 @@ function stringArrayField(record: Record<string, unknown>, field: 'required' | '
return entries
}
/** Read and validate the manifest's closed document-class set. */
function requiredClassesField(record: Record<string, unknown>): TranslationDocumentClass[] {
const value = record.requiredClasses
if (!Array.isArray(value) || !value.every((entry): entry is TranslationDocumentClass =>
typeof entry === 'string' && TRANSLATION_DOCUMENT_CLASSES.includes(entry as TranslationDocumentClass))) {
throw new Error('translation-pairing.manifest.json: requiredClasses must contain only "readme" and "non-readme"')
}
if (new Set(value).size !== value.length) {
throw new Error('translation-pairing.manifest.json: requiredClasses must not contain duplicates')
}
return value
}
/** Parse and validate the checked-in bilingual manifest. */
export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
const value: unknown = JSON.parse(content)
@@ -53,11 +73,24 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
}
return {
required: stringArrayField(record, 'required'),
requiredClasses: requiredClassesField(record),
excluded: stringArrayField(record, 'excluded'),
requiredSince,
}
}
/** Classify a Markdown source by whether its basename is README, case-insensitively. */
export function translationDocumentClass(file: string): TranslationDocumentClass {
return /(?:^|\/)readme\.md$/i.test(file) ? 'readme' : 'non-readme'
}
/** Whether the manifest requires this in-scope source to have a complete pair. */
export function requiresTranslationPair(file: string, manifest: TranslationPairingManifest): boolean {
return manifest.required.includes(file)
|| manifest.requiredClasses.includes(translationDocumentClass(file))
|| requiresPairByDate(file, manifest.requiredSince)
}
/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
export function datedDocumentDate(file: string): string | undefined {
return DATED_DOCUMENT.exec(file)?.[1]
+14 -21
View File
@@ -1,9 +1,10 @@
/**
* Enforce complete English/Chinese pairs, matching structure, and recorded git
* blob hashes under the bilingual manifest. Required files and date-named docs
* at or after `requiredSince` must be paired; excluded docs may have neither a
* counterpart nor sidecar. `--list` reports state and `--write` records both
* sides after human review. Translation quality remains a review responsibility.
* at or after `requiredSince`, plus every source in a required document class,
* must be paired; excluded docs may have neither a counterpart nor sidecar.
* `--list` reports state and `--write` records both sides after human review.
* Translation quality remains a review responsibility.
* See `docs/i18n/README.md` for the owning contract.
*/
@@ -11,11 +12,11 @@ import { createHash } from 'node:crypto'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve, sep } from 'node:path'
import {
datedDocumentDate,
linksTo,
parseTranslationMarkdown,
parseTranslationPairingManifest,
requiresPairByDate,
requiresTranslationPair,
translationDocumentClass,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -118,29 +119,21 @@ if (writeMode) {
const errors: string[] = []
const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
// 1. Required pairs exist.
// 1. Explicit manifest entries name existing source documents.
for (const req of manifest.required) {
if (!existsSync(join(root, req))) {
errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
continue
}
const { zh } = pairPaths(req)
if (!existsSync(join(root, zh))) {
errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
state.set(req, 'missing')
}
}
// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
// the filename alone — no git history, so it holds on shallow CI checkouts.
// 2. Every source selected explicitly, by document class, or by the dated-document
// cutoff merges bilingual. Class enforcement closes a rollout for future files too.
for (const source of sources) {
if (isExcluded(source)) continue
const date = datedDocumentDate(source)
if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
if (!requiresTranslationPair(source, manifest)) continue
const { zh } = pairPaths(source)
if (!existsSync(join(root, zh))) {
errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
errors.push(`${source}: required to merge bilingual as a ${translationDocumentClass(source)} document (docs/i18n/README.md); add the counterpart and record the pair`)
state.set(source, 'missing')
}
}
@@ -214,8 +207,8 @@ if (listMode) {
const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
for (const [file, status] of rows) {
const required = manifest.required.includes(file)
const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
const required = requiresTranslationPair(file, manifest)
const tag = required ? ` (required ${translationDocumentClass(file)})` : ' (backlog)'
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
}
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
@@ -225,7 +218,7 @@ if (listMode) {
}
if (errors.length === 0) {
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} explicit requirements and required classes [${manifest.requiredClasses.join(', ')}], all consistent.`)
process.exit(0)
}