161 lines
5.1 KiB
JavaScript
161 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import process from 'process'
|
|
|
|
const root = process.cwd()
|
|
const sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
|
const scanRoots = ['src', 'scripts']
|
|
const allowedPromptTemplateReaders = new Set([
|
|
'src/lib/prompt-i18n/template-store.ts',
|
|
'scripts/guards/prompt-i18n-guard.mjs',
|
|
'scripts/guards/prompt-semantic-regression.mjs',
|
|
'scripts/guards/prompt-ab-regression.mjs',
|
|
'scripts/guards/prompt-json-canary-guard.mjs',
|
|
])
|
|
const languageDirectiveAllowList = new Set([
|
|
'scripts/guards/prompt-i18n-guard.mjs',
|
|
])
|
|
const languageDirectivePattern = /请用中文|中文输出|use Chinese|output in Chinese/i
|
|
|
|
function fail(title, details = []) {
|
|
console.error(`\n[prompt-i18n-guard] ${title}`)
|
|
for (const line of details) {
|
|
console.error(` - ${line}`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
function toRel(fullPath) {
|
|
return path.relative(root, fullPath).split(path.sep).join('/')
|
|
}
|
|
|
|
function walk(dir, out = []) {
|
|
if (!fs.existsSync(dir)) return out
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue
|
|
const fullPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
walk(fullPath, out)
|
|
continue
|
|
}
|
|
out.push(fullPath)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function listSourceFiles() {
|
|
return scanRoots
|
|
.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
|
|
.filter((fullPath) => sourceExtensions.has(path.extname(fullPath)))
|
|
}
|
|
|
|
function collectDirectPromptReadViolations() {
|
|
const violations = []
|
|
const files = listSourceFiles()
|
|
for (const filePath of files) {
|
|
const relPath = toRel(filePath)
|
|
if (allowedPromptTemplateReaders.has(relPath)) continue
|
|
const content = fs.readFileSync(filePath, 'utf8')
|
|
const hasReadFileSync = /\breadFileSync\s*\(/.test(content)
|
|
if (!hasReadFileSync) continue
|
|
const hasPromptPathToken =
|
|
content.includes('lib/prompts')
|
|
|| (
|
|
/['"]lib['"]/.test(content)
|
|
&& /['"]prompts['"]/.test(content)
|
|
)
|
|
if (hasPromptPathToken) {
|
|
violations.push(`${relPath} direct prompt file read is forbidden; use buildPrompt/getPromptTemplate`)
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
function collectLanguageDirectiveViolations() {
|
|
const violations = []
|
|
|
|
for (const filePath of listSourceFiles()) {
|
|
const relPath = toRel(filePath)
|
|
if (languageDirectiveAllowList.has(relPath)) continue
|
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index]
|
|
if (languageDirectivePattern.test(line)) {
|
|
violations.push(`${relPath}:${index + 1} hardcoded language directive is forbidden`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const promptFiles = walk(path.join(root, 'lib', 'prompts'))
|
|
.filter((fullPath) => fullPath.endsWith('.en.txt'))
|
|
for (const filePath of promptFiles) {
|
|
const relPath = toRel(filePath)
|
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index]
|
|
if (languageDirectivePattern.test(line)) {
|
|
violations.push(`${relPath}:${index + 1} English template cannot require Chinese output`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
function collectLegacyPromptFiles() {
|
|
return walk(path.join(root, 'lib', 'prompts'))
|
|
.map((fullPath) => toRel(fullPath))
|
|
.filter((relPath) => relPath.endsWith('.txt') && !relPath.endsWith('.zh.txt') && !relPath.endsWith('.en.txt'))
|
|
}
|
|
|
|
function verifyPromptCatalogCoverage() {
|
|
const catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')
|
|
if (!fs.existsSync(catalogPath)) {
|
|
fail('Missing prompt catalog file', ['src/lib/prompt-i18n/catalog.ts'])
|
|
}
|
|
|
|
const catalogText = fs.readFileSync(catalogPath, 'utf8')
|
|
const stems = Array.from(catalogText.matchAll(/pathStem:\s*'([^']+)'/g)).map((match) => match[1])
|
|
if (stems.length === 0) {
|
|
fail('No prompt pathStem found in catalog.ts')
|
|
}
|
|
|
|
const missing = []
|
|
for (const stem of stems) {
|
|
const zhPath = path.join(root, 'lib', 'prompts', `${stem}.zh.txt`)
|
|
const enPath = path.join(root, 'lib', 'prompts', `${stem}.en.txt`)
|
|
if (!fs.existsSync(zhPath)) {
|
|
missing.push(`missing zh template: lib/prompts/${stem}.zh.txt`)
|
|
}
|
|
if (!fs.existsSync(enPath)) {
|
|
missing.push(`missing en template: lib/prompts/${stem}.en.txt`)
|
|
}
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
fail('Prompt template coverage check failed', missing)
|
|
}
|
|
}
|
|
|
|
const legacyPromptFiles = collectLegacyPromptFiles()
|
|
if (legacyPromptFiles.length > 0) {
|
|
fail('Legacy prompt files found (.txt without locale suffix)', legacyPromptFiles)
|
|
}
|
|
|
|
verifyPromptCatalogCoverage()
|
|
|
|
const promptReadViolations = collectDirectPromptReadViolations()
|
|
if (promptReadViolations.length > 0) {
|
|
fail('Found direct prompt template reads', promptReadViolations)
|
|
}
|
|
|
|
const languageViolations = collectLanguageDirectiveViolations()
|
|
if (languageViolations.length > 0) {
|
|
fail('Found hardcoded language directives', languageViolations)
|
|
}
|
|
|
|
console.log('[prompt-i18n-guard] OK')
|