release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const ROOT = process.cwd()
const RULES = [
{
label: 'component',
dir: 'src',
include: (relPath) =>
relPath.includes('/components/')
&& /\.(ts|tsx)$/.test(relPath),
limit: 500,
},
{
label: 'hook',
dir: 'src',
include: (relPath) =>
(relPath.includes('/hooks/') || /\/use[A-Z].+\.(ts|tsx)$/.test(relPath))
&& /\.(ts|tsx)$/.test(relPath),
limit: 400,
},
{
label: 'worker-handler',
dir: 'src/lib/workers/handlers',
include: (relPath) => /\.(ts|tsx)$/.test(relPath),
limit: 300,
},
{
label: 'mutation',
dir: 'src/lib/query/mutations',
include: (relPath) => /\.(ts|tsx)$/.test(relPath) && !relPath.endsWith('/index.ts'),
limit: 300,
},
]
const walkFiles = (absDir, relBase = '') => {
if (!fs.existsSync(absDir)) return []
const entries = fs.readdirSync(absDir, { withFileTypes: true })
const out = []
for (const entry of entries) {
const abs = path.join(absDir, entry.name)
const rel = path.join(relBase, entry.name).replace(/\\/g, '/')
if (entry.isDirectory()) {
out.push(...walkFiles(abs, rel))
continue
}
out.push({ absPath: abs, relPath: rel })
}
return out
}
const countLines = (absPath) => {
const raw = fs.readFileSync(absPath, 'utf8')
if (raw.length === 0) return 0
return raw.split('\n').length
}
const violations = []
for (const rule of RULES) {
const absDir = path.join(ROOT, rule.dir)
const files = walkFiles(absDir, rule.dir).filter((f) => rule.include(f.relPath))
for (const file of files) {
const lineCount = countLines(file.absPath)
if (lineCount > rule.limit) {
violations.push({
label: rule.label,
relPath: file.relPath,
lineCount,
limit: rule.limit,
})
}
}
}
if (violations.length === 0) {
process.stdout.write('[file-line-count-guard] PASS\n')
process.exit(0)
}
process.stderr.write('[file-line-count-guard] FAIL: file size budget exceeded\n')
for (const violation of violations) {
process.stderr.write(
`- [${violation.label}] ${violation.relPath}: ${violation.lineCount} > ${violation.limit}\n`,
)
}
process.exit(1)

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const scanRoots = ['src/app/api', 'src/pages/api']
const allowedPrefixes = []
const sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
function fail(title, details = []) {
console.error(`\n[no-api-direct-llm-call] ${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
}
const ext = path.extname(entry.name)
if (sourceExtensions.has(ext)) {
out.push(fullPath)
}
}
return out
}
function isAllowedFile(relPath) {
return allowedPrefixes.some((prefix) => relPath.startsWith(prefix))
}
function collectViolations(fullPath) {
const relPath = toRel(fullPath)
if (isAllowedFile(relPath)) return []
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
const violations = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (/from\s+['"]@\/lib\/llm-client['"]/.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden import from '@/lib/llm-client'`)
}
if (/\bchatCompletion[A-Za-z0-9_]*\s*\(/.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden direct chatCompletion* call`)
}
if (/\bisInternalTaskExecution\b/.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden dual-track fallback marker isInternalTaskExecution`)
}
}
return violations
}
const allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))
if (violations.length > 0) {
fail('Found forbidden direct LLM execution in production API routes', violations)
}
console.log('[no-api-direct-llm-call] OK')

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const ROOT = process.cwd()
const API_ROOT = path.join(ROOT, 'src', 'app', 'api')
const KNOWN_DUPLICATE_GROUPS = [
{
key: 'user-llm-test-connection',
candidates: [
'src/app/api/user/api-config/test-connection/route.ts',
'src/app/api/user/test-llm-provider/route.ts',
],
},
]
const exists = (relPath) => fs.existsSync(path.join(ROOT, relPath))
const failures = []
for (const group of KNOWN_DUPLICATE_GROUPS) {
const present = group.candidates.filter(exists)
if (present.length > 1) {
failures.push({ key: group.key, present })
}
}
if (!fs.existsSync(API_ROOT)) {
process.stdout.write('[no-duplicate-endpoint-entry] PASS (api dir missing)\n')
process.exit(0)
}
if (failures.length === 0) {
process.stdout.write('[no-duplicate-endpoint-entry] PASS\n')
process.exit(0)
}
process.stderr.write('[no-duplicate-endpoint-entry] FAIL: duplicated endpoint entry detected\n')
for (const failure of failures) {
process.stderr.write(`- ${failure.key}\n`)
for (const relPath of failure.present) {
process.stderr.write(` - ${relPath}\n`)
}
}
process.exit(1)

View File

@@ -0,0 +1,73 @@
#!/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']
const allowConstantDefinitionsIn = new Set([
'src/lib/constants.ts',
])
const forbiddenCapabilityConstants = [
'VIDEO_MODELS',
'FIRST_LAST_FRAME_MODELS',
'AUDIO_SUPPORTED_MODELS',
'BANANA_MODELS',
'BANANA_RESOLUTION_OPTIONS',
]
function fail(title, details = []) {
console.error(`\n[no-hardcoded-model-capabilities] ${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
}
if (sourceExtensions.has(path.extname(entry.name))) {
out.push(fullPath)
}
}
return out
}
const files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = []
for (const fullPath of files) {
const relPath = toRel(fullPath)
if (allowConstantDefinitionsIn.has(relPath)) continue
const lines = fs.readFileSync(fullPath, 'utf8').split('\n')
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
for (const token of forbiddenCapabilityConstants) {
const tokenPattern = new RegExp(`\\b${token}\\b`)
if (tokenPattern.test(line)) {
violations.push(`${relPath}:${index + 1} forbidden hardcoded model capability token ${token}`)
}
}
}
}
if (violations.length > 0) {
fail('Found hardcoded model capability usage', violations)
}
console.log('[no-hardcoded-model-capabilities] OK')

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const scanRoots = ['src/app/api', 'src/pages/api']
const allowedPrefixes = ['src/app/api/ui-review/', 'src/pages/api/ui-review/']
const sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
function fail(title, details = []) {
console.error(`\n[no-internal-task-sync-fallback] ${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
}
if (sourceExtensions.has(path.extname(entry.name))) {
out.push(fullPath)
}
}
return out
}
function isAllowedFile(relPath) {
return allowedPrefixes.some((prefix) => relPath.startsWith(prefix))
}
function collectViolations(fullPath) {
const relPath = toRel(fullPath)
if (isAllowedFile(relPath)) return []
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
const violations = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (/\bisInternalTaskExecution\b/.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden dual-track fallback marker isInternalTaskExecution`)
}
if (/\bshouldRunSyncTask\s*\(/.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden sync-mode branch helper shouldRunSyncTask`)
}
}
if (/\bmaybeSubmitLLMTask\s*\(/.test(content) && !/sync mode is disabled for this route/.test(content)) {
violations.push(`${relPath} missing explicit sync-disabled guard after maybeSubmitLLMTask`)
}
return violations
}
const allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))
if (violations.length > 0) {
fail('Found potential sync fallback or dual-track task branch in production API routes', violations)
}
console.log('[no-internal-task-sync-fallback] OK')

View File

@@ -0,0 +1,88 @@
#!/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 allowFactoryImportIn = new Set([
'src/lib/generator-api.ts',
'src/lib/generators/factory.ts',
])
function fail(title, details = []) {
console.error(`\n[no-media-provider-bypass] ${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
}
if (sourceExtensions.has(path.extname(entry.name))) {
out.push(fullPath)
}
}
return out
}
const generatorApiPath = path.join(root, 'src/lib/generator-api.ts')
if (!fs.existsSync(generatorApiPath)) {
fail('Missing src/lib/generator-api.ts')
}
const generatorApiContent = fs.readFileSync(generatorApiPath, 'utf8')
const resolveModelSelectionHits = (generatorApiContent.match(/resolveModelSelection\s*\(/g) || []).length
if (resolveModelSelectionHits < 2) {
fail('generator-api must route both image and video generation through resolveModelSelection', [
'expected >= 2 resolveModelSelection(...) calls in src/lib/generator-api.ts',
])
}
const allFiles = walk(path.join(root, 'src'))
const violations = []
for (const fullPath of allFiles) {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (
relPath !== 'src/lib/generators/factory.ts' &&
(/\bcreateImageGeneratorByModel\s*\(/.test(line) || /\bcreateVideoGeneratorByModel\s*\(/.test(line))
) {
violations.push(`${relPath}:${i + 1} forbidden provider-bypass factory call create*GeneratorByModel(...)`)
}
if ((/\bgetImageApiKey\s*\(/.test(line) || /\bgetVideoApiKey\s*\(/.test(line)) && relPath !== 'src/lib/api-config.ts') {
violations.push(`${relPath}:${i + 1} forbidden direct getImageApiKey/getVideoApiKey usage outside api-config`)
}
if (/from\s+['"]@\/lib\/generators\/factory['"]/.test(line) && !allowFactoryImportIn.has(relPath)) {
violations.push(`${relPath}:${i + 1} forbidden direct import from '@/lib/generators/factory' (must go through generator-api)`)
}
}
}
if (violations.length > 0) {
fail('Found media provider routing bypass', violations)
}
console.log('[no-media-provider-bypass] OK')

View File

@@ -0,0 +1,93 @@
#!/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/app', 'src/lib']
const modelFields = [
'analysisModel',
'characterModel',
'locationModel',
'storyboardModel',
'editModel',
'videoModel',
]
function fail(title, details = []) {
console.error(`\n[no-model-key-downgrade] ${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
}
if (sourceExtensions.has(path.extname(entry.name))) {
out.push(fullPath)
}
}
return out
}
function collectViolations(filePath) {
const relPath = toRel(filePath)
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
const violations = []
const modelFieldPattern = new RegExp(`\\b(${modelFields.join('|')})\\s*:\\s*[^,\\n]*\\bmodelId\\b`)
const optionModelIdPattern = /value=\{model\.modelId\}/
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
if (modelFieldPattern.test(line)) {
violations.push(`${relPath}:${index + 1} default model field must persist model_key, not modelId`)
}
if (optionModelIdPattern.test(line)) {
violations.push(`${relPath}:${index + 1} UI option value must use modelKey, not model.modelId`)
}
}
return violations
}
function assertFileContains(relativePath, requiredSnippets) {
const fullPath = path.join(root, relativePath)
if (!fs.existsSync(fullPath)) {
fail('Missing required contract file', [relativePath])
}
const content = fs.readFileSync(fullPath, 'utf8')
const missing = requiredSnippets.filter((snippet) => !content.includes(snippet))
if (missing.length > 0) {
fail('Model key contract anchor missing', missing.map((snippet) => `${relativePath} missing: ${snippet}`))
}
}
const files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = files.flatMap((filePath) => collectViolations(filePath))
assertFileContains('src/lib/model-config-contract.ts', ['parseModelKeyStrict', 'markerIndex === -1) return null'])
assertFileContains('src/lib/config-service.ts', ['parseModelKeyStrict'])
assertFileContains('src/app/api/user/api-config/route.ts', ['validateDefaultModelKey', 'must be provider::modelId'])
assertFileContains('src/app/api/novel-promotion/[projectId]/route.ts', ['must be provider::modelId'])
if (violations.length > 0) {
fail('Found model key downgrade pattern', violations)
}
console.log('[no-model-key-downgrade] OK')

View File

@@ -0,0 +1,109 @@
#!/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 lineScanRoots = [
'src/app/[locale]/workspace/[projectId]/modes/novel-promotion',
'src/lib/query/hooks',
]
const fileScanRoots = [
'src/app/api/novel-promotion',
'src/lib/workers/handlers',
]
const lineRules = [
{
name: 'shadow state localStoryboards',
test: (line) => /const\s*\[\s*localStoryboards\s*,\s*setLocalStoryboards\s*\]\s*=\s*useState/.test(line),
},
{
name: 'shadow state localVoiceLines',
test: (line) => /const\s*\[\s*localVoiceLines\s*,\s*setLocalVoiceLines\s*\]\s*=\s*useState/.test(line),
},
{
name: 'hardcoded queryKey array',
test: (line) => /queryKey\s*:\s*\[/.test(line),
},
]
function fail(title, details = []) {
console.error(`\n[no-multiple-sources-of-truth] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
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
}
if (sourceExtensions.has(path.extname(entry.name))) out.push(fullPath)
}
return out
}
function collectLineViolations(fullPath) {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
const violations = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
for (const rule of lineRules) {
if (rule.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden: ${rule.name}`)
}
}
}
return violations
}
function collectFileViolations(fullPath) {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const violations = []
const updateCallRegex = /novelPromotionProject\.update\(\{[\s\S]*?\n\s*\}\)/g
for (const match of content.matchAll(updateCallRegex)) {
const block = match[0]
const hasStageWrite = /\bdata\s*:\s*\{[\s\S]*?\bstage\s*:/.test(block)
if (!hasStageWrite) continue
const before = content.slice(0, match.index ?? 0)
const lineNumber = before.split('\n').length
violations.push(`${relPath}:${lineNumber} forbidden: DB stage write in novelPromotionProject.update`)
}
return violations
}
const lineFiles = lineScanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const fileFiles = fileScanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const lineViolations = lineFiles.flatMap((fullPath) => collectLineViolations(fullPath))
const fileViolations = fileFiles.flatMap((fullPath) => collectFileViolations(fullPath))
const allViolations = [...lineViolations, ...fileViolations]
if (allViolations.length > 0) {
fail('Found multiple-sources-of-truth regressions', allViolations)
}
console.log('[no-multiple-sources-of-truth] OK')

View File

@@ -0,0 +1,95 @@
#!/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/lib', 'src/app/api']
const allowModelRegistryUsage = new Set()
function fail(title, details = []) {
console.error(`\n[no-provider-guessing] ${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
}
if (sourceExtensions.has(path.extname(entry.name))) {
out.push(fullPath)
}
}
return out
}
const apiConfigPath = path.join(root, 'src/lib/api-config.ts')
if (!fs.existsSync(apiConfigPath)) {
fail('Missing src/lib/api-config.ts')
}
const legacyRegistryPath = path.join(root, 'src/lib/model-registry.ts')
if (fs.existsSync(legacyRegistryPath)) {
fail('Legacy runtime registry must be removed', ['src/lib/model-registry.ts'])
}
const apiConfigText = fs.readFileSync(apiConfigPath, 'utf8')
const forbiddenApiConfigTokens = [
'includeAnyType',
'crossTypeCandidates',
'matches multiple providers across media types',
]
const apiViolations = forbiddenApiConfigTokens
.filter((token) => apiConfigText.includes(token))
.map((token) => `src/lib/api-config.ts contains forbidden provider-guessing token: ${token}`)
// 验证 api-config.ts 使用严格 provider.id 精确匹配(不按 type 过滤,不做 providerKey 模糊匹配)
if (!apiConfigText.includes('pickProviderStrict(')) {
apiViolations.push('src/lib/api-config.ts missing strict provider resolution function (pickProviderStrict)')
}
const files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = [...apiViolations]
for (const fullPath of files) {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]
if (
/from\s+['"]@\/lib\/model-registry['"]/.test(line)
&& !allowModelRegistryUsage.has(relPath)
) {
violations.push(`${relPath}:${index + 1} forbidden model-registry import outside allowed boundary`)
}
if (
(/\bgetModelRegistryEntry\s*\(/.test(line) || /\blistRegisteredModels\s*\(/.test(line))
&& !allowModelRegistryUsage.has(relPath)
) {
violations.push(`${relPath}:${index + 1} forbidden model-registry runtime mapping usage`)
}
}
}
if (violations.length > 0) {
fail('Found provider guessing / registry mapping violation', violations)
}
console.log('[no-provider-guessing] OK')

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const scanRoots = [
'src/app/[locale]/workspace/[projectId]/modes/novel-promotion',
]
const sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
const forbiddenRules = [
{
name: 'localProject/localEpisode mirror state',
test: (line) => /\blocalProject\b|\blocalEpisode\b/.test(line),
},
{
name: 'server mirror useState(projectData.*)',
test: (line) => /useState\s*\(\s*projectData\./.test(line),
},
{
name: 'server mirror useState(episode?.*)',
test: (line) => /useState\s*\(\s*episode\?\./.test(line),
},
]
function fail(title, details = []) {
console.error(`\n[no-server-mirror-state] ${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
}
const ext = path.extname(entry.name)
if (sourceExtensions.has(ext)) out.push(fullPath)
}
return out
}
function collectViolations(fullPath) {
const relPath = toRel(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
const violations = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
for (const rule of forbiddenRules) {
if (rule.test(line)) {
violations.push(`${relPath}:${i + 1} forbidden: ${rule.name}`)
}
}
}
return violations
}
const allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))
const violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))
if (violations.length > 0) {
fail('Found forbidden server mirror state patterns', violations)
}
console.log('[no-server-mirror-state] OK')

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')
const singlePlaceholderPattern = /\{([A-Za-z0-9_]+)\}/g
const doublePlaceholderPattern = /\{\{([A-Za-z0-9_]+)\}\}/g
const unresolvedPlaceholderPattern = /\{\{?[A-Za-z0-9_]+\}?\}/g
function fail(title, details = []) {
console.error(`\n[prompt-ab-regression] ${title}`)
for (const line of details) {
console.error(` - ${line}`)
}
process.exit(1)
}
function parseCatalog(text) {
const entries = []
const entryPattern = /pathStem:\s*'([^']+)'\s*,[\s\S]*?variableKeys:\s*\[([\s\S]*?)\]\s*,/g
for (const match of text.matchAll(entryPattern)) {
const pathStem = match[1]
const rawKeys = match[2] || ''
const keys = Array.from(rawKeys.matchAll(/'([^']+)'/g)).map((item) => item[1])
entries.push({ pathStem, variableKeys: keys })
}
return entries
}
function extractPlaceholders(template) {
const keys = new Set()
for (const match of template.matchAll(singlePlaceholderPattern)) {
if (match[1]) keys.add(match[1])
}
for (const match of template.matchAll(doublePlaceholderPattern)) {
if (match[1]) keys.add(match[1])
}
return Array.from(keys)
}
function replaceAll(template, variables) {
let rendered = template
for (const [key, value] of Object.entries(variables)) {
const pattern = new RegExp(`\\{\\{${key}\\}\\}|\\{${key}\\}`, 'g')
rendered = rendered.replace(pattern, value)
}
return rendered
}
function setDiff(left, right) {
const rightSet = new Set(right)
return left.filter((item) => !rightSet.has(item))
}
if (!fs.existsSync(catalogPath)) {
fail('catalog.ts not found', ['src/lib/prompt-i18n/catalog.ts'])
}
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const entries = parseCatalog(catalogText)
if (entries.length === 0) {
fail('failed to parse prompt catalog entries')
}
const violations = []
for (const entry of entries) {
const zhPath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.zh.txt`)
const enPath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.en.txt`)
if (!fs.existsSync(zhPath)) {
violations.push(`missing zh template: lib/prompts/${entry.pathStem}.zh.txt`)
continue
}
if (!fs.existsSync(enPath)) {
violations.push(`missing en template: lib/prompts/${entry.pathStem}.en.txt`)
continue
}
const zhTemplate = fs.readFileSync(zhPath, 'utf8')
const enTemplate = fs.readFileSync(enPath, 'utf8')
const declared = entry.variableKeys
const zhPlaceholders = extractPlaceholders(zhTemplate)
const enPlaceholders = extractPlaceholders(enTemplate)
const missingInZh = setDiff(declared, zhPlaceholders)
const missingInEn = setDiff(declared, enPlaceholders)
const extraInZh = setDiff(zhPlaceholders, declared)
const extraInEn = setDiff(enPlaceholders, declared)
const zhOnly = setDiff(zhPlaceholders, enPlaceholders)
const enOnly = setDiff(enPlaceholders, zhPlaceholders)
for (const key of missingInZh) {
violations.push(`missing {${key}} in zh template: lib/prompts/${entry.pathStem}.zh.txt`)
}
for (const key of missingInEn) {
violations.push(`missing {${key}} in en template: lib/prompts/${entry.pathStem}.en.txt`)
}
for (const key of extraInZh) {
violations.push(`unexpected {${key}} in zh template: lib/prompts/${entry.pathStem}.zh.txt`)
}
for (const key of extraInEn) {
violations.push(`unexpected {${key}} in en template: lib/prompts/${entry.pathStem}.en.txt`)
}
for (const key of zhOnly) {
violations.push(`placeholder {${key}} exists only in zh template: ${entry.pathStem}`)
}
for (const key of enOnly) {
violations.push(`placeholder {${key}} exists only in en template: ${entry.pathStem}`)
}
const variables = Object.fromEntries(
declared.map((key) => [key, `__AB_SAMPLE_${key.toUpperCase()}__`]),
)
const renderedZh = replaceAll(zhTemplate, variables)
const renderedEn = replaceAll(enTemplate, variables)
const unresolvedZh = renderedZh.match(unresolvedPlaceholderPattern) || []
const unresolvedEn = renderedEn.match(unresolvedPlaceholderPattern) || []
if (unresolvedZh.length > 0) {
violations.push(`unresolved placeholders in zh template: ${entry.pathStem} -> ${unresolvedZh.join(', ')}`)
}
if (unresolvedEn.length > 0) {
violations.push(`unresolved placeholders in en template: ${entry.pathStem} -> ${unresolvedEn.join(', ')}`)
}
for (const [key, sample] of Object.entries(variables)) {
if (!renderedZh.includes(sample)) {
violations.push(`zh template variable not used after render: ${entry.pathStem}.{${key}}`)
}
if (!renderedEn.includes(sample)) {
violations.push(`en template variable not used after render: ${entry.pathStem}.{${key}}`)
}
}
}
if (violations.length > 0) {
fail('A/B regression check failed', violations)
}
console.log(`[prompt-ab-regression] OK (${entries.length} templates checked)`)

View 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')

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const CANARY_FILES = {
clips: 'standards/prompt-canary/story_to_script_clips.canary.json',
screenplay: 'standards/prompt-canary/screenplay_conversion.canary.json',
storyboardPanels: 'standards/prompt-canary/storyboard_panels.canary.json',
voiceAnalysis: 'standards/prompt-canary/voice_analysis.canary.json',
}
const TEMPLATE_TOKEN_REQUIREMENTS = {
'novel-promotion/agent_clip': ['start', 'end', 'summary', 'location', 'characters'],
'novel-promotion/screenplay_conversion': [
'clip_id',
'original_text',
'scenes',
'heading',
'content',
'type',
'action',
'dialogue',
'voiceover',
],
'novel-promotion/agent_storyboard_plan': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
],
'novel-promotion/agent_storyboard_detail': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
'shot_type',
'camera_move',
'video_prompt',
],
'novel-promotion/agent_storyboard_insert': [
'panel_number',
'description',
'characters',
'location',
'scene_type',
'source_text',
'shot_type',
'camera_move',
'video_prompt',
],
'novel-promotion/voice_analysis': [
'lineIndex',
'speaker',
'content',
'emotionStrength',
'matchedPanel',
'storyboardId',
'panelIndex',
],
}
function fail(title, details = []) {
console.error(`\n[prompt-json-canary-guard] ${title}`)
for (const line of details) {
console.error(` - ${line}`)
}
process.exit(1)
}
function isRecord(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isString(value) {
return typeof value === 'string'
}
function isNumber(value) {
return typeof value === 'number' && Number.isFinite(value)
}
function readJson(relativePath) {
const fullPath = path.join(root, relativePath)
if (!fs.existsSync(fullPath)) {
fail('Missing canary fixture', [relativePath])
}
try {
return JSON.parse(fs.readFileSync(fullPath, 'utf8'))
} catch (error) {
fail('Invalid canary fixture JSON', [`${relativePath}: ${error instanceof Error ? error.message : String(error)}`])
}
}
function validateClipCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'clips fixture must be a non-empty array'
for (let i = 0; i < value.length; i += 1) {
const row = value[i]
if (!isRecord(row)) return `clips[${i}] must be an object`
if (!isString(row.start) || row.start.length < 5) return `clips[${i}].start must be string length >= 5`
if (!isString(row.end) || row.end.length < 5) return `clips[${i}].end must be string length >= 5`
if (!isString(row.summary) || row.summary.length === 0) return `clips[${i}].summary must be non-empty string`
if (!(row.location === null || isString(row.location))) return `clips[${i}].location must be string or null`
if (!Array.isArray(row.characters) || !row.characters.every((item) => isString(item))) {
return `clips[${i}].characters must be string array`
}
}
return null
}
function validateScreenplayCanary(value) {
if (!isRecord(value)) return 'screenplay fixture must be an object'
if (!isString(value.clip_id) || !value.clip_id) return 'screenplay.clip_id must be non-empty string'
if (!isString(value.original_text)) return 'screenplay.original_text must be string'
if (!Array.isArray(value.scenes) || value.scenes.length === 0) return 'screenplay.scenes must be non-empty array'
for (let i = 0; i < value.scenes.length; i += 1) {
const scene = value.scenes[i]
if (!isRecord(scene)) return `screenplay.scenes[${i}] must be object`
if (!isNumber(scene.scene_number)) return `screenplay.scenes[${i}].scene_number must be number`
if (!isRecord(scene.heading)) return `screenplay.scenes[${i}].heading must be object`
if (!isString(scene.heading.int_ext)) return `screenplay.scenes[${i}].heading.int_ext must be string`
if (!isString(scene.heading.location)) return `screenplay.scenes[${i}].heading.location must be string`
if (!isString(scene.heading.time)) return `screenplay.scenes[${i}].heading.time must be string`
if (!isString(scene.description)) return `screenplay.scenes[${i}].description must be string`
if (!Array.isArray(scene.characters) || !scene.characters.every((item) => isString(item))) {
return `screenplay.scenes[${i}].characters must be string array`
}
if (!Array.isArray(scene.content) || scene.content.length === 0) return `screenplay.scenes[${i}].content must be non-empty array`
for (let j = 0; j < scene.content.length; j += 1) {
const segment = scene.content[j]
if (!isRecord(segment)) return `screenplay.scenes[${i}].content[${j}] must be object`
if (!isString(segment.type)) return `screenplay.scenes[${i}].content[${j}].type must be string`
if (segment.type === 'action') {
if (!isString(segment.text)) return `screenplay action[${i}:${j}].text must be string`
} else if (segment.type === 'dialogue') {
if (!isString(segment.character)) return `screenplay dialogue[${i}:${j}].character must be string`
if (!isString(segment.lines)) return `screenplay dialogue[${i}:${j}].lines must be string`
if (segment.parenthetical !== undefined && !isString(segment.parenthetical)) {
return `screenplay dialogue[${i}:${j}].parenthetical must be string when present`
}
} else if (segment.type === 'voiceover') {
if (!isString(segment.text)) return `screenplay voiceover[${i}:${j}].text must be string`
if (segment.character !== undefined && !isString(segment.character)) {
return `screenplay voiceover[${i}:${j}].character must be string when present`
}
} else {
return `screenplay.scenes[${i}].content[${j}].type must be action/dialogue/voiceover`
}
}
}
return null
}
function validateStoryboardPanelsCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'storyboard panels fixture must be non-empty array'
for (let i = 0; i < value.length; i += 1) {
const panel = value[i]
if (!isRecord(panel)) return `storyboardPanels[${i}] must be object`
if (!isNumber(panel.panel_number)) return `storyboardPanels[${i}].panel_number must be number`
if (!isString(panel.description)) return `storyboardPanels[${i}].description must be string`
if (!isString(panel.location)) return `storyboardPanels[${i}].location must be string`
if (!isString(panel.scene_type)) return `storyboardPanels[${i}].scene_type must be string`
if (!isString(panel.source_text)) return `storyboardPanels[${i}].source_text must be string`
if (!isString(panel.shot_type)) return `storyboardPanels[${i}].shot_type must be string`
if (!isString(panel.camera_move)) return `storyboardPanels[${i}].camera_move must be string`
if (!isString(panel.video_prompt)) return `storyboardPanels[${i}].video_prompt must be string`
if (panel.duration !== undefined && !isNumber(panel.duration)) return `storyboardPanels[${i}].duration must be number when present`
if (!Array.isArray(panel.characters)) return `storyboardPanels[${i}].characters must be array`
for (let j = 0; j < panel.characters.length; j += 1) {
const character = panel.characters[j]
if (!isRecord(character)) return `storyboardPanels[${i}].characters[${j}] must be object`
if (!isString(character.name)) return `storyboardPanels[${i}].characters[${j}].name must be string`
if (character.appearance !== undefined && !isString(character.appearance)) {
return `storyboardPanels[${i}].characters[${j}].appearance must be string when present`
}
}
}
return null
}
function validateVoiceAnalysisCanary(value) {
if (!Array.isArray(value) || value.length === 0) return 'voice analysis fixture must be non-empty array'
for (let i = 0; i < value.length; i += 1) {
const row = value[i]
if (!isRecord(row)) return `voiceAnalysis[${i}] must be object`
if (!isNumber(row.lineIndex)) return `voiceAnalysis[${i}].lineIndex must be number`
if (!isString(row.speaker)) return `voiceAnalysis[${i}].speaker must be string`
if (!isString(row.content)) return `voiceAnalysis[${i}].content must be string`
if (!isNumber(row.emotionStrength)) return `voiceAnalysis[${i}].emotionStrength must be number`
if (row.matchedPanel !== null) {
if (!isRecord(row.matchedPanel)) return `voiceAnalysis[${i}].matchedPanel must be object or null`
if (!isString(row.matchedPanel.storyboardId)) return `voiceAnalysis[${i}].matchedPanel.storyboardId must be string`
if (!isNumber(row.matchedPanel.panelIndex)) return `voiceAnalysis[${i}].matchedPanel.panelIndex must be number`
}
}
return null
}
function checkTemplateTokens(pathStem, requiredTokens) {
const violations = []
for (const locale of ['zh', 'en']) {
const relPath = `lib/prompts/${pathStem}.${locale}.txt`
const fullPath = path.join(root, relPath)
if (!fs.existsSync(fullPath)) {
violations.push(`missing template: ${relPath}`)
continue
}
const content = fs.readFileSync(fullPath, 'utf8')
for (const token of requiredTokens) {
if (!content.includes(token)) {
violations.push(`missing token ${token} in ${relPath}`)
}
}
}
return violations
}
const violations = []
const clipsErr = validateClipCanary(readJson(CANARY_FILES.clips))
if (clipsErr) violations.push(clipsErr)
const screenplayErr = validateScreenplayCanary(readJson(CANARY_FILES.screenplay))
if (screenplayErr) violations.push(screenplayErr)
const panelsErr = validateStoryboardPanelsCanary(readJson(CANARY_FILES.storyboardPanels))
if (panelsErr) violations.push(panelsErr)
const voiceErr = validateVoiceAnalysisCanary(readJson(CANARY_FILES.voiceAnalysis))
if (voiceErr) violations.push(voiceErr)
for (const [pathStem, requiredTokens] of Object.entries(TEMPLATE_TOKEN_REQUIREMENTS)) {
violations.push(...checkTemplateTokens(pathStem, requiredTokens))
}
if (violations.length > 0) {
fail('JSON schema canary check failed', violations)
}
console.log('[prompt-json-canary-guard] OK')

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
const catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')
const chineseCharPattern = /[\p{Script=Han}]/u
const singlePlaceholderPattern = /\{([A-Za-z0-9_]+)\}/g
const doublePlaceholderPattern = /\{\{([A-Za-z0-9_]+)\}\}/g
const criticalTemplateTokens = new Map([
['novel-promotion/voice_analysis', ['"lineIndex"', '"speaker"', '"content"', '"emotionStrength"', '"matchedPanel"']],
['novel-promotion/agent_storyboard_plan', ['"panel_number"', '"description"', '"characters"', '"location"', '"scene_type"', '"source_text"', '"shot_type"', '"camera_move"', '"video_prompt"']],
['novel-promotion/agent_storyboard_detail', ['"panel_number"', '"description"', '"characters"', '"location"', '"scene_type"', '"source_text"', '"shot_type"', '"camera_move"', '"video_prompt"']],
['novel-promotion/agent_storyboard_insert', ['"panel_number"', '"description"', '"characters"', '"location"', '"scene_type"', '"source_text"', '"shot_type"', '"camera_move"', '"video_prompt"']],
['novel-promotion/screenplay_conversion', ['"clip_id"', '"scenes"', '"heading"', '"content"', '"dialogue"', '"voiceover"']],
['novel-promotion/select_location', ['"locations"', '"name"', '"summary"', '"descriptions"']],
['novel-promotion/episode_split', ['"analysis"', '"episodes"', '"startMarker"', '"endMarker"', '"validation"']],
['novel-promotion/image_prompt_modify', ['"image_prompt"', '"video_prompt"']],
['novel-promotion/character_create', ['"prompt"']],
['novel-promotion/location_create', ['"prompt"']],
])
function fail(title, details = []) {
console.error(`\n[prompt-semantic-regression] ${title}`)
for (const line of details) {
console.error(` - ${line}`)
}
process.exit(1)
}
function parseCatalog(text) {
const entries = []
const entryPattern = /pathStem:\s*'([^']+)'\s*,[\s\S]*?variableKeys:\s*\[([\s\S]*?)\]\s*,/g
for (const match of text.matchAll(entryPattern)) {
const pathStem = match[1]
const rawKeys = match[2] || ''
const keys = Array.from(rawKeys.matchAll(/'([^']+)'/g)).map((item) => item[1])
entries.push({ pathStem, variableKeys: keys })
}
return entries
}
function extractPlaceholders(template) {
const keys = new Set()
for (const match of template.matchAll(singlePlaceholderPattern)) {
if (match[1]) keys.add(match[1])
}
for (const match of template.matchAll(doublePlaceholderPattern)) {
if (match[1]) keys.add(match[1])
}
return Array.from(keys)
}
if (!fs.existsSync(catalogPath)) {
fail('catalog.ts not found', ['src/lib/prompt-i18n/catalog.ts'])
}
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const entries = parseCatalog(catalogText)
if (entries.length === 0) {
fail('failed to parse prompt catalog entries')
}
const violations = []
for (const entry of entries) {
const templatePath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.en.txt`)
if (!fs.existsSync(templatePath)) {
violations.push(`missing template: lib/prompts/${entry.pathStem}.en.txt`)
continue
}
const template = fs.readFileSync(templatePath, 'utf8')
if (chineseCharPattern.test(template)) {
violations.push(`unexpected Chinese content in English template: lib/prompts/${entry.pathStem}.en.txt`)
}
const placeholders = extractPlaceholders(template)
const placeholderSet = new Set(placeholders)
const variableKeySet = new Set(entry.variableKeys)
for (const key of entry.variableKeys) {
if (!placeholderSet.has(key)) {
violations.push(`missing placeholder {${key}} in lib/prompts/${entry.pathStem}.en.txt`)
}
}
for (const key of placeholders) {
if (!variableKeySet.has(key)) {
violations.push(`unexpected placeholder {${key}} in lib/prompts/${entry.pathStem}.en.txt`)
}
}
const requiredTokens = criticalTemplateTokens.get(entry.pathStem) || []
for (const token of requiredTokens) {
if (!template.includes(token)) {
violations.push(`missing semantic token ${token} in lib/prompts/${entry.pathStem}.en.txt`)
}
}
}
if (violations.length > 0) {
fail('semantic regression check failed', violations)
}
console.log(`[prompt-semantic-regression] OK (${entries.length} templates checked)`)

View File

@@ -0,0 +1,9 @@
{
"allowedDirectTaskStateUsageFiles": [
"src/lib/query/hooks/useTaskTargetStates.ts",
"src/lib/query/hooks/useTaskPresentation.ts",
"src/lib/query/hooks/useProjectAssets.ts",
"src/lib/query/hooks/useGlobalAssets.ts"
],
"allowedLegacyGeneratingUsageFiles": []
}

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const workspaceRoot = process.cwd()
const baselinePath = path.join(workspaceRoot, 'scripts/guards/task-loading-baseline.json')
function walkFiles(dir, out = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.next') continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walkFiles(fullPath, out)
} else {
out.push(fullPath)
}
}
return out
}
function toPosixRelative(filePath) {
return path.relative(workspaceRoot, filePath).split(path.sep).join('/')
}
function collectMatches(files, pattern) {
const matches = []
for (const fullPath of files) {
if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) continue
const relPath = toPosixRelative(fullPath)
const content = fs.readFileSync(fullPath, 'utf8')
const lines = content.split('\n')
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].includes(pattern)) {
matches.push(`${relPath}:${i + 1}`)
}
}
}
return matches
}
function fail(title, lines) {
console.error(`\n[task-loading-guard] ${title}`)
for (const line of lines) {
console.error(` - ${line}`)
}
process.exit(1)
}
if (!fs.existsSync(baselinePath)) {
fail('Missing baseline file', [toPosixRelative(baselinePath)])
}
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
const allowedFiles = new Set(baseline.allowedDirectTaskStateUsageFiles || [])
const allowedLegacyGeneratingFiles = new Set(baseline.allowedLegacyGeneratingUsageFiles || [])
const allFiles = walkFiles(path.join(workspaceRoot, 'src'))
const directTaskStateUsage = collectMatches(allFiles, 'useTaskTargetStates(')
const directUsageOutOfAllowlist = directTaskStateUsage
.map((entry) => entry.split(':')[0])
.filter((file) => !allowedFiles.has(file))
if (directUsageOutOfAllowlist.length > 0) {
fail(
'Found component-level direct useTaskTargetStates outside baseline allowlist',
Array.from(new Set(directUsageOutOfAllowlist)),
)
}
const crossDomainLabels = collectMatches(allFiles, 'video.panelCard.generating')
if (crossDomainLabels.length > 0) {
fail('Found cross-domain loading label reuse (video.panelCard.generating)', crossDomainLabels)
}
const uiFiles = allFiles.filter((file) => {
const relPath = toPosixRelative(file)
return relPath.startsWith('src/app/') || relPath.startsWith('src/components/')
})
const legacyGeneratingPatterns = [
'appearance.generating',
'panel.generatingImage',
'shot.generatingImage',
'line.generating',
]
const legacyGeneratingMatches = legacyGeneratingPatterns.flatMap((pattern) =>
collectMatches(uiFiles, pattern),
)
const legacyGeneratingOutOfAllowlist = legacyGeneratingMatches
.map((entry) => entry.split(':')[0])
.filter((file) => !allowedLegacyGeneratingFiles.has(file))
if (legacyGeneratingOutOfAllowlist.length > 0) {
fail(
'Found legacy generating truth usage in UI components',
Array.from(new Set(legacyGeneratingOutOfAllowlist)),
)
}
const hooksIndexPath = path.join(workspaceRoot, 'src/lib/query/hooks/index.ts')
if (fs.existsSync(hooksIndexPath)) {
const hooksIndex = fs.readFileSync(hooksIndexPath, 'utf8')
const bannedReexports = [
{
pattern: /export\s*\{[^}]*useGenerateCharacterImage[^}]*\}\s*from\s*['"]\.\/useGlobalAssets['"]/m,
message: 'hooks/index.ts must not export useGenerateCharacterImage from useGlobalAssets',
},
{
pattern: /export\s*\{[^}]*useGenerateLocationImage[^}]*\}\s*from\s*['"]\.\/useGlobalAssets['"]/m,
message: 'hooks/index.ts must not export useGenerateLocationImage from useGlobalAssets',
},
{
pattern: /export\s*\{[^}]*useGenerateProjectCharacterImage[^}]*\}\s*from\s*['"]\.\/useProjectAssets['"]/m,
message: 'hooks/index.ts must not export useGenerateProjectCharacterImage from useProjectAssets',
},
{
pattern: /export\s*\{[^}]*useGenerateProjectLocationImage[^}]*\}\s*from\s*['"]\.\/useProjectAssets['"]/m,
message: 'hooks/index.ts must not export useGenerateProjectLocationImage from useProjectAssets',
},
]
const violations = bannedReexports
.filter((item) => item.pattern.test(hooksIndex))
.map((item) => item.message)
if (violations.length > 0) {
fail('Found non-canonical mutation re-exports', violations)
}
}
console.log('[task-loading-guard] OK')

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
failed=0
check_absent() {
local label="$1"
local pattern="$2"
shift 2
local output
output="$(git grep --untracked -nE "$pattern" -- "$@" || true)"
if [[ -n "$output" ]]; then
echo "$output"
echo "::error title=${label}::${label}"
failed=1
fi
}
check_absent \
"Do not branch UI status on cancelled" \
"status[[:space:]]*===[[:space:]]*['\\\"]cancelled['\\\"]|status[[:space:]]*==[[:space:]]*['\\\"]cancelled['\\\"]" \
src/app \
src/components \
src/features \
src/lib/query
check_absent \
"useTaskHandoff is forbidden" \
"useTaskHandoff" \
src
check_absent \
"Do not use legacy task hooks in app layer" \
"useActiveTasks\\(|useTaskStatus\\(" \
src/app \
src/features
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
echo "task-state-unification guard passed"

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
FAILED=0
print_header() {
echo
echo "============================================================"
echo "$1"
echo "============================================================"
}
print_ok() {
echo "[PASS] $1"
}
print_fail() {
echo "[FAIL] $1"
}
run_zero_match_check() {
local title="$1"
local pattern="$2"
shift 2
local paths=("$@")
local output
output="$(git grep -n -E "$pattern" -- "${paths[@]}" || true)"
if [[ -z "$output" ]]; then
print_ok "$title"
else
print_fail "$title"
echo "$output"
FAILED=1
fi
}
run_usetasktargetstates_check() {
local title="useTaskTargetStates 仅允许在 useProjectAssets/useGlobalAssets 中使用"
local output
output="$(git grep -n "useTaskTargetStates" -- src || true)"
if [[ -z "$output" ]]; then
print_ok "$title (当前 0 命中)"
return
fi
local filtered
filtered="$(echo "$output" | grep -v "src/lib/query/hooks/useProjectAssets.ts" | grep -v "src/lib/query/hooks/useGlobalAssets.ts" || true)"
if [[ -z "$filtered" ]]; then
print_ok "$title"
else
print_fail "$title"
echo "$filtered"
FAILED=1
fi
}
print_header "Task Status Cutover Audit"
run_zero_match_check \
"禁止 useTaskHandoff" \
"useTaskHandoff" \
src
run_zero_match_check \
"禁止 manualRegeneratingItems/setRegeneratingItems/clearRegeneratingItem" \
"manualRegeneratingItems|setRegeneratingItems|clearRegeneratingItem" \
src
run_zero_match_check \
"禁止业务层直接判断 status ===/!== cancelled" \
"status\\s*===\\s*['\\\"]cancelled['\\\"]|status\\s*!==\\s*['\\\"]cancelled['\\\"]" \
src
run_zero_match_check \
"禁止 generatingImage/generatingVideo/generatingLipSync 字段" \
"\\bgeneratingImage\\b|\\bgeneratingVideo\\b|\\bgeneratingLipSync\\b" \
src
run_usetasktargetstates_check
run_zero_match_check \
"禁止 novel-promotion/asset-hub/shared-assets 中 useState(false) 作为生成态命名" \
"const \\[[^\\]]*(Generating|Regenerating|WaitingForGeneration|AnalyzingAssets|GeneratingAll|CopyingFromGlobal)[^\\]]*\\]\\s*=\\s*useState\\(false\\)" \
"src/app/[locale]/workspace/[projectId]/modes/novel-promotion" \
"src/app/[locale]/workspace/asset-hub" \
"src/components/shared/assets"
print_header "Audit Result"
if [[ "$FAILED" -eq 0 ]]; then
echo "All checks passed."
exit 0
fi
echo "Audit failed. Please fix findings above."
exit 1

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import process from 'process'
const root = process.cwd()
function fail(title, details = []) {
console.error(`\n[task-target-states-no-polling-guard] ${title}`)
for (const line of details) {
console.error(` - ${line}`)
}
process.exit(1)
}
function readFile(relativePath) {
const fullPath = path.join(root, relativePath)
if (!fs.existsSync(fullPath)) {
fail('Missing required file', [relativePath])
}
return fs.readFileSync(fullPath, 'utf8')
}
function walk(dir, 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 full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full, out)
} else {
out.push(full)
}
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
function collectPattern(pattern) {
const files = walk(path.join(root, 'src'))
const hits = []
for (const fullPath of files) {
if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) continue
const text = fs.readFileSync(fullPath, 'utf8')
const lines = text.split('\n')
for (let i = 0; i < lines.length; i += 1) {
if (pattern.test(lines[i])) {
hits.push(`${toRel(fullPath)}:${i + 1}`)
}
}
}
return hits
}
const refetchIntervalMsHits = collectPattern(/\brefetchIntervalMs\b/)
if (refetchIntervalMsHits.length > 0) {
fail('Found forbidden refetchIntervalMs usage', refetchIntervalMsHits)
}
const voiceStagePath =
'src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VoiceStage.tsx'
const voiceStageText = readFile(voiceStagePath)
if (voiceStageText.includes('setInterval(')) {
fail('VoiceStage must not use timer polling', [voiceStagePath])
}
const targetStateMapPath = 'src/lib/query/hooks/useTaskTargetStateMap.ts'
const targetStateMapText = readFile(targetStateMapPath)
if (!/refetchInterval:\s*false/.test(targetStateMapText)) {
fail('useTaskTargetStateMap must keep refetchInterval disabled', [targetStateMapPath])
}
const ssePath = 'src/lib/query/hooks/useSSE.ts'
const sseText = readFile(ssePath)
const targetStatesInvalidateExprMatch = sseText.match(
/const shouldInvalidateTargetStates\s*=\s*([\s\S]*?)\n\s*\n/,
)
if (!targetStatesInvalidateExprMatch) {
fail('Unable to locate shouldInvalidateTargetStates expression', [ssePath])
}
const targetStatesInvalidateExpr = targetStatesInvalidateExprMatch[1]
if (!/TASK_EVENT_TYPE\.COMPLETED/.test(targetStatesInvalidateExpr) || !/TASK_EVENT_TYPE\.FAILED/.test(targetStatesInvalidateExpr)) {
fail('useSSE must invalidate target states only for terminal events', [ssePath])
}
if (/TASK_EVENT_TYPE\.CREATED/.test(targetStatesInvalidateExpr)) {
fail('useSSE target-state invalidation must not include CREATED', [ssePath])
}
if (/TASK_EVENT_TYPE\.PROCESSING/.test(targetStatesInvalidateExpr)) {
fail('useSSE target-state invalidation must not include PROCESSING', [ssePath])
}
console.log('[task-target-states-no-polling-guard] OK')

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const targetDirs = [
path.join(root, 'tests', 'integration', 'api', 'contract'),
path.join(root, 'tests', 'integration', 'chain'),
]
function fail(title, details = []) {
console.error(`\n[test-behavior-quality-guard] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
process.exit(1)
}
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 === 'node_modules') continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full, out)
continue
}
if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(full)
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
const files = targetDirs.flatMap((dir) => walk(dir))
if (files.length === 0) {
fail('No target test files found', targetDirs.map((dir) => toRel(dir)))
}
const violations = []
for (const file of files) {
const rel = toRel(file)
const text = fs.readFileSync(file, 'utf8')
const hasSourceRead = /(readFileSync|fs\.readFileSync)\s*\([\s\S]{0,240}src\/(app|lib)\//m.test(text)
if (hasSourceRead) {
violations.push(`${rel}: reading source code text is forbidden in behavior contract/chain tests`)
}
const forbiddenStringContracts = [
/toContain\(\s*['"]apiHandler['"]\s*\)/,
/toContain\(\s*['"]submitTask['"]\s*\)/,
/toContain\(\s*['"]maybeSubmitLLMTask['"]\s*\)/,
/includes\(\s*['"]apiHandler['"]\s*\)/,
/includes\(\s*['"]submitTask['"]\s*\)/,
/includes\(\s*['"]maybeSubmitLLMTask['"]\s*\)/,
]
for (const pattern of forbiddenStringContracts) {
if (pattern.test(text)) {
violations.push(`${rel}: forbidden structural string assertion matched ${pattern}`)
break
}
}
const hasWeakCallAssertion = /toHaveBeenCalled\(\s*\)/.test(text)
const hasStrongCallAssertion = /toHaveBeenCalledWith\(/.test(text)
if (hasWeakCallAssertion && !hasStrongCallAssertion) {
violations.push(`${rel}: has toHaveBeenCalled() without any toHaveBeenCalledWith() result assertions`)
}
}
if (violations.length > 0) {
fail('Behavior quality violations found', violations)
}
console.log(`[test-behavior-quality-guard] OK files=${files.length}`)

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const catalogPath = path.join(root, 'tests', 'contracts', 'route-catalog.ts')
const matrixPath = path.join(root, 'tests', 'contracts', 'route-behavior-matrix.ts')
function fail(title, details = []) {
console.error(`\n[test-behavior-route-coverage-guard] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
process.exit(1)
}
if (!fs.existsSync(catalogPath)) {
fail('route catalog is missing', ['tests/contracts/route-catalog.ts'])
}
if (!fs.existsSync(matrixPath)) {
fail('route behavior matrix is missing', ['tests/contracts/route-behavior-matrix.ts'])
}
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const matrixText = fs.readFileSync(matrixPath, 'utf8')
if (!matrixText.includes('ROUTE_CATALOG.map')) {
fail('route behavior matrix must derive entries from ROUTE_CATALOG.map')
}
const routeFilesBlockMatch = catalogText.match(/const ROUTE_FILES = \[([\s\S]*?)\] as const/)
if (!routeFilesBlockMatch) {
fail('unable to parse ROUTE_FILES block from route catalog')
}
const routeFilesBlock = routeFilesBlockMatch ? routeFilesBlockMatch[1] : ''
const routeCount = Array.from(routeFilesBlock.matchAll(/'src\/app\/api\/[^']+\/route\.ts'/g)).length
if (routeCount === 0) {
fail('no routes detected in route catalog')
}
const testFiles = Array.from(matrixText.matchAll(/'tests\/[a-zA-Z0-9_\-/.]+\.test\.ts'/g))
.map((match) => match[0].slice(1, -1))
if (testFiles.length === 0) {
fail('route behavior matrix does not declare any behavior test files')
}
const missingTests = Array.from(new Set(testFiles)).filter((file) => !fs.existsSync(path.join(root, file)))
if (missingTests.length > 0) {
fail('route behavior matrix references missing test files', missingTests)
}
console.log(`[test-behavior-route-coverage-guard] OK routes=${routeCount} tests=${new Set(testFiles).size}`)

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const catalogPath = path.join(root, 'tests', 'contracts', 'task-type-catalog.ts')
const matrixPath = path.join(root, 'tests', 'contracts', 'tasktype-behavior-matrix.ts')
function fail(title, details = []) {
console.error(`\n[test-behavior-tasktype-coverage-guard] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
process.exit(1)
}
if (!fs.existsSync(catalogPath)) {
fail('task type catalog is missing', ['tests/contracts/task-type-catalog.ts'])
}
if (!fs.existsSync(matrixPath)) {
fail('tasktype behavior matrix is missing', ['tests/contracts/tasktype-behavior-matrix.ts'])
}
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const matrixText = fs.readFileSync(matrixPath, 'utf8')
if (!matrixText.includes('TASK_TYPE_CATALOG.map')) {
fail('tasktype behavior matrix must derive entries from TASK_TYPE_CATALOG.map')
}
const taskTypeCount = Array.from(catalogText.matchAll(/\[TASK_TYPE\.([A-Z_]+)\]/g)).length
if (taskTypeCount === 0) {
fail('no task types detected in task type catalog')
}
const testFiles = Array.from(matrixText.matchAll(/'tests\/[a-zA-Z0-9_\-/.]+\.test\.ts'/g))
.map((match) => match[0].slice(1, -1))
if (testFiles.length === 0) {
fail('tasktype behavior matrix does not declare any behavior test files')
}
const missingTests = Array.from(new Set(testFiles)).filter((file) => !fs.existsSync(path.join(root, file)))
if (missingTests.length > 0) {
fail('tasktype behavior matrix references missing test files', missingTests)
}
console.log(`[test-behavior-tasktype-coverage-guard] OK taskTypes=${taskTypeCount} tests=${new Set(testFiles).size}`)

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const apiDir = path.join(root, 'src', 'app', 'api')
const catalogPath = path.join(root, 'tests', 'contracts', 'route-catalog.ts')
function fail(title, details = []) {
console.error(`\n[test-route-coverage-guard] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
process.exit(1)
}
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
}
if (entry.name === 'route.ts') out.push(fullPath)
}
return out
}
function toRel(fullPath) {
return path.relative(root, fullPath).split(path.sep).join('/')
}
if (!fs.existsSync(catalogPath)) {
fail('route-catalog.ts is missing', ['tests/contracts/route-catalog.ts'])
}
const actualRoutes = walk(apiDir).map(toRel).sort()
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const catalogRoutes = Array.from(catalogText.matchAll(/'src\/app\/api\/[^']+\/route\.ts'/g))
.map((match) => match[0].slice(1, -1))
.sort()
const missingInCatalog = actualRoutes.filter((routeFile) => !catalogRoutes.includes(routeFile))
const staleInCatalog = catalogRoutes.filter((routeFile) => !actualRoutes.includes(routeFile))
if (missingInCatalog.length > 0) {
fail('Missing routes in tests/contracts/route-catalog.ts', missingInCatalog)
}
if (staleInCatalog.length > 0) {
fail('Stale route entries found in tests/contracts/route-catalog.ts', staleInCatalog)
}
console.log(`[test-route-coverage-guard] OK routes=${actualRoutes.length}`)

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const taskTypesPath = path.join(root, 'src', 'lib', 'task', 'types.ts')
const catalogPath = path.join(root, 'tests', 'contracts', 'task-type-catalog.ts')
function fail(title, details = []) {
console.error(`\n[test-tasktype-coverage-guard] ${title}`)
for (const detail of details) {
console.error(` - ${detail}`)
}
process.exit(1)
}
if (!fs.existsSync(taskTypesPath)) {
fail('Task type source file is missing', ['src/lib/task/types.ts'])
}
if (!fs.existsSync(catalogPath)) {
fail('Task type catalog file is missing', ['tests/contracts/task-type-catalog.ts'])
}
const taskTypesText = fs.readFileSync(taskTypesPath, 'utf8')
const catalogText = fs.readFileSync(catalogPath, 'utf8')
const taskTypeBlockMatch = taskTypesText.match(/export const TASK_TYPE = \{([\s\S]*?)\n\} as const/)
if (!taskTypeBlockMatch) {
fail('Unable to parse TASK_TYPE block from src/lib/task/types.ts')
}
const taskTypeBlock = taskTypeBlockMatch ? taskTypeBlockMatch[1] : ''
const taskTypeKeys = Array.from(taskTypeBlock.matchAll(/^\s+([A-Z_]+):\s'[^']+',?$/gm)).map((match) => match[1])
const catalogKeys = Array.from(catalogText.matchAll(/\[TASK_TYPE\.([A-Z_]+)\]/g)).map((match) => match[1])
const missingKeys = taskTypeKeys.filter((key) => !catalogKeys.includes(key))
const staleKeys = catalogKeys.filter((key) => !taskTypeKeys.includes(key))
if (missingKeys.length > 0) {
fail('Missing TASK_TYPE owners in tests/contracts/task-type-catalog.ts', missingKeys)
}
if (staleKeys.length > 0) {
fail('Stale TASK_TYPE keys in tests/contracts/task-type-catalog.ts', staleKeys)
}
console.log(`[test-tasktype-coverage-guard] OK taskTypes=${taskTypeKeys.length}`)