feat: initial release v0.3.0
This commit is contained in:
160
scripts/guards/prompt-i18n-guard.mjs
Normal file
160
scripts/guards/prompt-i18n-guard.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/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')
|
||||
Reference in New Issue
Block a user