226 lines
12 KiB
JavaScript
226 lines
12 KiB
JavaScript
import { existsSync, readFileSync } from 'node:fs'
|
|
|
|
const requiredFiles = [
|
|
'src/app/App.tsx',
|
|
'src/pages/login.tsx',
|
|
'src/pages/workspace-body.tsx',
|
|
'src/pages/workspace-home.tsx',
|
|
'src/pages/workspace-explore.tsx',
|
|
'src/pages/projects/project-overview.tsx',
|
|
'src/pages/projects/project-inbox.tsx',
|
|
'src/pages/projects/project-channel-page.tsx',
|
|
'src/pages/projects/project-tasks.tsx',
|
|
'src/pages/projects/project-ai.tsx',
|
|
'src/pages/projects/project-notes.tsx',
|
|
'src/pages/projects/project-cron.tsx',
|
|
'src/pages/projects/project-new-channel.tsx',
|
|
'src/pages/projects/project-rail.tsx',
|
|
'src/pages/projects/project-sidebar.tsx',
|
|
'src/pages/projects/project-topbar.tsx',
|
|
'src/pages/projects/project-statusbar.tsx',
|
|
'src/pages/projects/workbench-item-preview.tsx',
|
|
'src/pages/projects/project-types.ts',
|
|
'src/api/client.ts',
|
|
'src/api/projects.ts',
|
|
'src/api/mappers.tsx',
|
|
'src/api/search.ts',
|
|
'src/api/inbox.ts',
|
|
'src/api/ai.ts',
|
|
'scripts/api-client.test.mjs',
|
|
'scripts/workspace-refresh-gate.test.mjs',
|
|
]
|
|
|
|
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
|
|
|
const tokenSource = existsSync('src/styles/tokens.css') ? readFileSync('src/styles/tokens.css', 'utf8') : ''
|
|
for (const token of ['--color-primary: #165dff', '--font-sans', '--space-4: 16px']) {
|
|
if (!tokenSource.toLowerCase().includes(token)) failures.push(`missing token ${token}`)
|
|
}
|
|
for (const forbidden of ['森林Agent', 'AI智能体', '实时接口', '进度 60%']) {
|
|
for (const file of requiredFiles.filter((name) => name.endsWith('.tsx') && existsSync(name))) {
|
|
if (readFileSync(file, 'utf8').includes(forbidden)) failures.push(`${file} contains forbidden copy ${forbidden}`)
|
|
}
|
|
}
|
|
|
|
if (existsSync('src/App.tsx')) {
|
|
failures.push('legacy src/App.tsx should be removed after page split')
|
|
}
|
|
|
|
for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx']) {
|
|
if (existsSync(removed)) {
|
|
failures.push(`${removed} should be removed; frontend data must come from backend APIs`)
|
|
}
|
|
}
|
|
|
|
if (existsSync('src/app/App.tsx')) {
|
|
const appSource = readFileSync('src/app/App.tsx', 'utf8')
|
|
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {
|
|
if (appSource.includes(forbidden)) {
|
|
failures.push(`src/app/App.tsx still contains ${forbidden}`)
|
|
}
|
|
}
|
|
for (const required of ['createWorkspaceRefreshGate', 'captureProjectInbox', 'refreshProjectWorkspace']) {
|
|
if (!appSource.includes(required)) failures.push(`src/app/App.tsx must include ${required}`)
|
|
}
|
|
}
|
|
|
|
const apiFiles = existsSync('src/api')
|
|
? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts', 'ai.ts']
|
|
.map((name) => `src/api/${name}`)
|
|
.filter((file) => existsSync(file))
|
|
: []
|
|
const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
|
|
for (const forbidden of [
|
|
{ pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' },
|
|
{ pattern: /\bName\s*\?\s*:/, label: 'optional PascalCase Name compatibility field' },
|
|
{ pattern: /['"`]\/api\/projects/, label: 'legacy /api/projects path' },
|
|
{
|
|
pattern: /\b(?:project|task|inbox)Id\s*\??\s*:\s*(?:string\s*\|\s*)?number\b/i,
|
|
label: 'numeric project/task/inbox identity',
|
|
},
|
|
]) {
|
|
if (forbidden.pattern.test(apiSource)) failures.push(`src/api contains ${forbidden.label}`)
|
|
}
|
|
|
|
const clientSource = existsSync('src/api/client.ts') ? readFileSync('src/api/client.ts', 'utf8') : ''
|
|
if (!clientSource.includes("'/api/v1/auth/login'")) failures.push('login must use /api/v1/auth/login')
|
|
if (!clientSource.includes('export class ApiError extends Error')) failures.push('client must export ApiError')
|
|
|
|
const projectsSource = existsSync('src/api/projects.ts') ? readFileSync('src/api/projects.ts', 'utf8') : ''
|
|
if (!projectsSource.includes("'/api/v1/projects'")) failures.push('project API must use /api/v1/projects')
|
|
if (/['"`]\/api\/(?!v1\/)/.test(apiSource)) failures.push('src/api paths must use the /api/v1 prefix')
|
|
|
|
for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\n\}/g)) {
|
|
const [, name, body] = match
|
|
if (/^\s*\w+\s*\?\s*:/m.test(body)) failures.push(`${name} fields must be required`)
|
|
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
|
|
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
|
|
}
|
|
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
|
|
if (/\bfilePath\s*:/.test(projectsSource)) failures.push('public source DTO must not expose filePath')
|
|
if (!/\bstorageKey\s*:\s*string/.test(projectsSource)) failures.push('public source DTO must expose an opaque storageKey')
|
|
|
|
const searchSource = existsSync('src/api/search.ts') ? readFileSync('src/api/search.ts', 'utf8') : ''
|
|
if (searchSource && !searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
|
|
for (const unsupportedType of ["'source'", "'inbox'", "'ai_session'"]) {
|
|
if (searchSource.includes(unsupportedType)) failures.push(`search DTO contains unsupported result type ${unsupportedType}`)
|
|
}
|
|
|
|
const loginSource = existsSync('src/pages/login.tsx') ? readFileSync('src/pages/login.tsx', 'utf8') : ''
|
|
if (!loginSource.includes("import { normalizeBaseUrl } from '../api/client'")) failures.push('login must share the API base URL normalizer')
|
|
if (loginSource.includes('function normalizeBaseUrl')) failures.push('login must not define a second base URL normalizer')
|
|
|
|
const inboxSource = existsSync('src/api/inbox.ts') ? readFileSync('src/api/inbox.ts', 'utf8') : ''
|
|
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
|
|
if (inboxSource && !inboxSource.includes(path)) failures.push(`inbox API must use ${path}`)
|
|
}
|
|
if (inboxSource.includes('body: { suggestions }')) failures.push('inbox confirm must not send client-authored suggestion content')
|
|
if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confirm must send only saved suggestion identities')
|
|
|
|
const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : ''
|
|
if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
|
|
for (const required of ['收集到 Inbox', 'captureDraft', 'onCapture']) {
|
|
if (!inboxPageSource.includes(required)) failures.push(`project inbox capture flow must include ${required}`)
|
|
}
|
|
|
|
const channelPageSource = existsSync('src/pages/projects/project-channel-page.tsx') ? readFileSync('src/pages/projects/project-channel-page.tsx', 'utf8') : ''
|
|
if (!channelPageSource.includes("activeChannel.startsWith('custom:')")) failures.push('custom_link channels must render an explicit unavailable state')
|
|
|
|
const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : ''
|
|
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
|
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
|
|
}
|
|
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
|
|
if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass the AbortSignal to apiRequest')
|
|
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
|
|
|
|
const aiPageSource = existsSync('src/pages/projects/project-ai.tsx') ? readFileSync('src/pages/projects/project-ai.tsx', 'utf8') : ''
|
|
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
|
|
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
|
|
}
|
|
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
|
|
if (!aiPageSource.includes(required)) failures.push(`project AI page must gate stale requests with ${required}`)
|
|
}
|
|
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
|
|
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
|
|
}
|
|
|
|
const unsupportedControls = [
|
|
{
|
|
file: 'src/pages/workspace-explore.tsx',
|
|
required: '暂未开放',
|
|
forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-new-channel.tsx',
|
|
required: '暂未开放',
|
|
forbidden: ['保存频道', '<Input', '<Select', '<TextArea'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-statusbar.tsx',
|
|
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲', '服务已连接', 'IconCheckCircle'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-topbar.tsx',
|
|
forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-cron.tsx',
|
|
forbidden: ['<Switch', '最近一次自动任务', '资料索引刷新成功'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-overview.tsx',
|
|
forbidden: ['/share/projects/', 'projectShareURL', '查看全部'],
|
|
},
|
|
{
|
|
file: 'src/pages/projects/project-notes.tsx',
|
|
forbidden: ['搜索资料', '按更新时间排序'],
|
|
},
|
|
{
|
|
file: 'src/pages/login.tsx',
|
|
required: 'localStorage',
|
|
forbidden: ['无法连接?', '隐私政策', '服务协议', 'check-dot'],
|
|
},
|
|
]
|
|
for (const check of unsupportedControls) {
|
|
const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : ''
|
|
if (check.required && !source.includes(check.required)) failures.push(`${check.file} must show ${check.required}`)
|
|
for (const forbidden of check.forbidden) {
|
|
if (source.includes(forbidden)) failures.push(`${check.file} contains unsupported control ${forbidden}`)
|
|
}
|
|
}
|
|
|
|
const html = readFileSync('index.html', 'utf8')
|
|
if (!html.includes('<html lang="zh-CN">')) failures.push('index language must be zh-CN')
|
|
if (!html.includes('<title>森林AI</title>')) failures.push('document title must be 森林AI')
|
|
if (!existsSync('public/senlinai-icon.svg')) failures.push('missing public/senlinai-icon.svg')
|
|
if (!html.includes('href="/senlinai-icon.svg"')) failures.push('index must use senlinai-icon.svg as the canonical favicon')
|
|
if (existsSync('public/favicon.svg')) failures.push('legacy public/favicon.svg must be removed; senlinai-icon.svg is the only brand asset')
|
|
if (existsSync('public/senlinai-icon.svg')) {
|
|
const icon = readFileSync('public/senlinai-icon.svg', 'utf8')
|
|
if (icon.includes('<text') || icon.includes('<title')) failures.push('brand SVG must not include text or title metadata')
|
|
if (icon.includes('role=') || icon.includes('aria-labelledby=')) failures.push('brand SVG must not expose presentation ARIA')
|
|
}
|
|
const appStyles = readFileSync('src/App.css', 'utf8')
|
|
if (/\.theme-dark\s*\{[^}]*--color-primary\s*:/s.test(appStyles)) {
|
|
failures.push('theme-dark must not override the #165DFF primary token')
|
|
}
|
|
if (!appStyles.includes('.brand-icon {')) failures.push('brand-icon must have an explicit base size')
|
|
if (!appStyles.includes('.brand-lockup.large .brand-icon')) failures.push('login brand icon must have an explicit size')
|
|
if (!appStyles.includes('.rail-brand {')) failures.push('rail brand button must have explicit layout')
|
|
if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styles must be removed')
|
|
if (!appStyles.includes('@media (max-width: 1179px)')) failures.push('channel sidebar must collapse below 1180px')
|
|
if (!appStyles.includes('.project-stack-scroll')) failures.push('long project rails need a dedicated scroll region')
|
|
if (!appStyles.includes('.mobile-search-button')) failures.push('mobile workbench must retain a global search entry')
|
|
for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-rail.tsx']) {
|
|
const source = readFileSync(file, 'utf8')
|
|
if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`)
|
|
}
|
|
|
|
if (failures.length) {
|
|
console.error(failures.join('\n'))
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('structure ok')
|