docs: require bilingual non-README documentation
This commit is contained in:
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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user