Files
deepseek-harness/scripts/verify-upgrade-guides.ts
T
Turtle 772db811c8 ci(upgrade-guide): reject guides added under a released version directory
Policy / Pre-Merge Check now runs verify-upgrade-guides --base <merged base commit>, which rejects a guide added outside docs/upgrade-guide/v<root version>/ unless the release branch of its version line already has it. The check re-runs on every base-branch push, so a release rejects open pull requests whose guides went stale.

Refs #7919
2026-11-05 01:57:13 +08:00

144 lines
8.6 KiB
TypeScript

/**
* Enforce the `docs/upgrade-guide/v<version>/<item>/guide.md` layout, metadata,
* sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill.
* With `--base <ref>`, it also rejects guides added since that commit outside the root version's directory.
* `verify-translation-pairing` owns the Chinese sibling's pairing record.
* @module scripts/verify-upgrade-guides
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { JSON_SCHEMA, load } from 'js-yaml'
import { fromMarkdown } from 'mdast-util-from-markdown'
const ROOT = resolve(import.meta.dirname, '..')
const GUIDE_ROOT = 'docs/upgrade-guide'
const FRONTMATTER_KEYS = ['description', 'kind']
/** Required `##` sections for each guide language; the word ceiling applies to the English source. */
const LANGUAGES = {
'guide.md': { sections: ['Change', 'Migration'], maxWords: 500 },
'guide.zh.md': { sections: ['变更', '迁移'], maxWords: undefined },
} as const
const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`
const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/(guide\\.md|guide\\.zh\\.md|guide\\.i18n\\.yaml)$`, 'u')
/** Return one guide's frontmatter and structure violations. */
function guideViolations(source: string, language: typeof LANGUAGES[keyof typeof LANGUAGES]): string[] {
const violations: string[] = []
const words = source.split(/\s+/u).filter(Boolean).length
if (language.maxWords !== undefined && words > language.maxWords) violations.push(`${String(words)} words exceeds the ${String(language.maxWords)}-word ceiling`)
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source)
if (frontmatter === null) return [...violations, 'must start with YAML frontmatter']
const metadata = load(frontmatter[1] as string, { schema: JSON_SCHEMA })
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return [...violations, 'frontmatter must be a YAML object']
const fields = metadata as Record<string, unknown>
const keys = Object.keys(fields).sort()
if (keys.join() !== FRONTMATTER_KEYS.join()) violations.push(`frontmatter keys must be exactly ${FRONTMATTER_KEYS.join(', ')}; got ${keys.join(', ') || 'none'}`)
if (fields.kind !== 'upgrade-guide') violations.push('kind must be upgrade-guide')
if (typeof fields.description !== 'string' || fields.description.trim() === '') violations.push('description must be a non-empty string')
const body = source.slice(frontmatter[0].length)
const outline = fromMarkdown(body).children
.flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : [])
const expected = language.sections.map(section => `## ${section}`)
if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) {
violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`)
}
return violations
}
/**
* Report upgrade-guide tree violations.
* @param root - Repository root containing `docs/upgrade-guide`.
* @returns diagnostics prefixed by the repository-relative file path; empty when the tree is absent.
*/
export function collectUpgradeGuideViolations(root: string): string[] {
const directory = join(root, GUIDE_ROOT)
if (!existsSync(directory)) return []
const violations: string[] = []
const files = readdirSync(directory, { recursive: true, withFileTypes: true })
.filter(entry => !entry.isDirectory())
.map(entry => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/'))
.sort()
for (const file of files) {
const path = `${GUIDE_ROOT}/${file}`
const name = GUIDE_PATH.exec(file)?.[1]
if (name === undefined) {
violations.push(`${path}: only v<semver>/<kebab-case-item>/guide.{md,zh.md,i18n.yaml} files belong in ${GUIDE_ROOT}`)
continue
}
if (name === 'guide.md' || name === 'guide.zh.md') {
for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'), LANGUAGES[name])) violations.push(`${path}: ${violation}`)
}
}
return violations
}
/**
* Report tracked guides added since `base` outside `docs/upgrade-guide/v<version>/`, where `<version>` is the
* root `package.json` version. A version directory is frozen once that version is released, so a guide that a
* pull request wrote before a release must move to the new version's directory before it merges. A guide that
* the tip of `origin`'s `release/dsh-<x.y.z>` branch for its version line already contains passes, because
* merging a release branch forward carries guides that were current on it. The tip is fetched by commit id,
* without updating any ref, only when such a guide exists and the commit is absent locally; a shallow
* repository fetches only that commit.
* @param root - Repository root: a Git working tree with an `origin` remote.
* @param base - Commit the working tree is compared with: the base-branch commit a pull request merges.
* @returns diagnostics prefixed by the repository-relative guide path.
* @throws Error naming the Git command when Git fails, such as for an unknown `base` or an unreachable `origin`.
*/
export function collectStaleUpgradeGuideAdditions(root: string, base: string): string[] {
const git = (args: readonly string[]): string => {
try {
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
} catch (error: unknown) {
const stderr = (error as { stderr?: unknown }).stderr
throw new Error(`\`git ${args.join(' ')}\` failed: ${typeof stderr === 'string' && stderr.trim() !== '' ? stderr.trim() : String(error)}`, { cause: error })
}
}
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: unknown }
if (typeof version !== 'string') throw new Error('package.json: version must be a string')
const current = `${GUIDE_ROOT}/v${version}/`
const released = new Map<string, ReadonlySet<string>>()
const releasedGuides = (line: string): ReadonlySet<string> => {
const tip = git(['ls-remote', 'origin', `refs/heads/release/dsh-${line}`]).split('\t')[0]?.trim() ?? ''
if (tip === '') return new Set()
if (spawnSync('git', ['cat-file', '-e', `${tip}^{commit}`], { cwd: root, stdio: 'ignore' }).status !== 0) {
// Fetching a commit already in a shallow repository with `--depth` would cut the history behind it.
const shallow = git(['rev-parse', '--is-shallow-repository']).trim() === 'true'
// A background maintenance run after a shallow fetch rewrites .git/shallow under later Git commands.
git(['-c', 'maintenance.auto=false', '-c', 'gc.auto=0', 'fetch', '--quiet', '--no-tags', '--no-write-fetch-head', ...(shallow ? ['--depth=1', '--filter=blob:none'] : []), 'origin', tip])
}
return new Set(git(['ls-tree', '-r', '-z', '--name-only', tip, '--', GUIDE_ROOT]).split('\0').filter(Boolean))
}
return git(['diff', '-z', '--name-only', '--no-renames', '--diff-filter=A', base, '--', GUIDE_ROOT])
.split('\0')
.filter((path) => {
const line = /^v(\d+\.\d+\.\d+)/u.exec(path.split('/')[2] ?? '')?.[1]
// `collectUpgradeGuideViolations` reports a path outside any version directory.
if (path === '' || path.startsWith(current) || line === undefined) return false
if (!released.has(line)) released.set(line, releasedGuides(line))
return released.get(line)?.has(path) !== true
})
.map(path => `${path}: added outside the current version directory; the root version is ${version}, so move the guide to ${current}<item>/`)
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
const { values } = parseArgs({ options: { base: { type: 'string' } } })
const violations = collectUpgradeGuideViolations(ROOT)
try {
if (values.base !== undefined) violations.push(...collectStaleUpgradeGuideAdditions(ROOT, values.base))
} catch (error: unknown) {
violations.push(`cannot check guides added since ${values.base ?? ''}: ${error instanceof Error ? error.message : String(error)}`)
}
if (violations.length > 0) {
process.stderr.write('verify-upgrade-guides: violations found (see .agents/skills/dsh-create-upgrade-guide/SKILL.md):\n')
for (const violation of violations) process.stderr.write(` ${violation}\n`)
process.exit(1)
}
process.stdout.write('verify-upgrade-guides: upgrade guides conform.\n')
}