release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
89
scripts/guards/file-line-count-guard.mjs
Normal file
89
scripts/guards/file-line-count-guard.mjs
Normal 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)
|
||||
77
scripts/guards/no-api-direct-llm-call.mjs
Normal file
77
scripts/guards/no-api-direct-llm-call.mjs
Normal 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')
|
||||
45
scripts/guards/no-duplicate-endpoint-entry.mjs
Normal file
45
scripts/guards/no-duplicate-endpoint-entry.mjs
Normal 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)
|
||||
73
scripts/guards/no-hardcoded-model-capabilities.mjs
Normal file
73
scripts/guards/no-hardcoded-model-capabilities.mjs
Normal 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')
|
||||
77
scripts/guards/no-internal-task-sync-fallback.mjs
Normal file
77
scripts/guards/no-internal-task-sync-fallback.mjs
Normal 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')
|
||||
88
scripts/guards/no-media-provider-bypass.mjs
Normal file
88
scripts/guards/no-media-provider-bypass.mjs
Normal 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')
|
||||
93
scripts/guards/no-model-key-downgrade.mjs
Normal file
93
scripts/guards/no-model-key-downgrade.mjs
Normal 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')
|
||||
109
scripts/guards/no-multiple-sources-of-truth.mjs
Normal file
109
scripts/guards/no-multiple-sources-of-truth.mjs
Normal 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')
|
||||
95
scripts/guards/no-provider-guessing.mjs
Normal file
95
scripts/guards/no-provider-guessing.mjs
Normal 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')
|
||||
81
scripts/guards/no-server-mirror-state.mjs
Normal file
81
scripts/guards/no-server-mirror-state.mjs
Normal 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')
|
||||
143
scripts/guards/prompt-ab-regression.mjs
Normal file
143
scripts/guards/prompt-ab-regression.mjs
Normal 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)`)
|
||||
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')
|
||||
250
scripts/guards/prompt-json-canary-guard.mjs
Normal file
250
scripts/guards/prompt-json-canary-guard.mjs
Normal 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')
|
||||
108
scripts/guards/prompt-semantic-regression.mjs
Normal file
108
scripts/guards/prompt-semantic-regression.mjs
Normal 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)`)
|
||||
9
scripts/guards/task-loading-baseline.json
Normal file
9
scripts/guards/task-loading-baseline.json
Normal 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": []
|
||||
}
|
||||
132
scripts/guards/task-loading-guard.mjs
Normal file
132
scripts/guards/task-loading-guard.mjs
Normal 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')
|
||||
42
scripts/guards/task-state-unification-guard.sh
Normal file
42
scripts/guards/task-state-unification-guard.sh
Normal 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"
|
||||
100
scripts/guards/task-status-cutover-audit.sh
Normal file
100
scripts/guards/task-status-cutover-audit.sh
Normal 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
|
||||
96
scripts/guards/task-target-states-no-polling-guard.mjs
Normal file
96
scripts/guards/task-target-states-no-polling-guard.mjs
Normal 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')
|
||||
82
scripts/guards/test-behavior-quality-guard.mjs
Normal file
82
scripts/guards/test-behavior-quality-guard.mjs
Normal 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}`)
|
||||
54
scripts/guards/test-behavior-route-coverage-guard.mjs
Normal file
54
scripts/guards/test-behavior-route-coverage-guard.mjs
Normal 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}`)
|
||||
49
scripts/guards/test-behavior-tasktype-coverage-guard.mjs
Normal file
49
scripts/guards/test-behavior-tasktype-coverage-guard.mjs
Normal 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}`)
|
||||
57
scripts/guards/test-route-coverage-guard.mjs
Normal file
57
scripts/guards/test-route-coverage-guard.mjs
Normal 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}`)
|
||||
46
scripts/guards/test-tasktype-coverage-guard.mjs
Normal file
46
scripts/guards/test-tasktype-coverage-guard.mjs
Normal 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}`)
|
||||
Reference in New Issue
Block a user