From ec7d56d566911a74d665287f20849adcf01ad5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:11:48 +0800 Subject: [PATCH] =?UTF-8?q?task-1(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=86=BB=E7=BB=93=E7=8B=AC=E7=AB=8B=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=9F=BA=E7=BA=BF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-frontend-vue/src/config/workspace.ts 提供可编程边界校验(独立包名、 必需清单/源码目录、客户端耦合 token 扫描、可操作缺失消息),供后续壳层 任务与验收脚本复用;tests/task-1.test.ts 以 8 用例锁定依赖/构建/测试/边界。 --- admin-frontend-vue/src/config/workspace.ts | 113 ++++++++++++++++++ admin-frontend-vue/tests/task-1.test.ts | 132 +++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 admin-frontend-vue/src/config/workspace.ts create mode 100644 admin-frontend-vue/tests/task-1.test.ts diff --git a/admin-frontend-vue/src/config/workspace.ts b/admin-frontend-vue/src/config/workspace.ts new file mode 100644 index 00000000..d1ec8ada --- /dev/null +++ b/admin-frontend-vue/src/config/workspace.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' + +export const STANDALONE_PACKAGE_NAME = 'crawler-plugin-admin-frontend-vue' + +/** 后台壳层允许存在的源码目录(任务 1 冻结的工作区边界,供验收脚本复用)。 */ +export const ADMIN_SOURCE_DIRS = [ + 'src/layout', + 'src/router', + 'src/styles', + 'src/types', + 'src/config', + 'src/pages', +] as const + +export const REQUIRED_MANIFEST_FILES = [ + 'package.json', + 'index.html', + 'vite.config.ts', + 'tsconfig.json', + 'src/main.ts', + 'src/App.vue', +] as const + +// 客户端工程耦合标记:后台工程内出现即代表越界。用带路径/产物语义的标记, +// 避免与本工程自身包名后缀(…admin-frontend-vue)误匹配。扫描时排除本文件自身。 +export const FORBIDDEN_COUPLING_TOKENS = [ + 'new_web_source', + 'frontend-vue/', + 'app_client', +] as const + +const SELF_SCAN_EXCLUSIONS = new Set(['src/config/workspace.ts']) +const SCAN_EXTENSIONS = /\.(ts|tsx|vue|css|html|json|js)$/ + +export function walk(rootDir: string, base = ''): string[] { + const found: string[] = [] + for (const entry of readdirSync(join(rootDir, base), { withFileTypes: true })) { + if (['node_modules', 'dist', '.git', 'tests'].includes(entry.name)) continue + const rel = base ? `${base}/${entry.name}` : entry.name + if (entry.isDirectory()) found.push(...walk(rootDir, rel)) + else if (SCAN_EXTENSIONS.test(entry.name)) found.push(rel) + } + return found +} + +function fileExists(rootDir: string, path: string): boolean { + return existsSync(join(rootDir, path)) +} + +/** 从当前目录向上定位独立后台工程根目录,找不到时给出可操作错误。 */ +export function resolveWorkspaceRoot(cwd = process.cwd()): string { + let dir = resolve(cwd) + for (let i = 0; i < 6; i += 1) { + if (fileExists(dir, 'package.json')) return dir + const parent = resolve(dir, '..') + if (parent === dir) break + dir = parent + } + throw new Error(`在 ${cwd} 向上 6 层内找不到 package.json,请在 admin-frontend-vue 工程目录内执行`) +} + +export interface WorkspacePackage { + name: string + private: boolean + scripts: Record + dependencies: Record + devDependencies: Record +} + +export function readWorkspacePackage(rootDir = resolveWorkspaceRoot()): WorkspacePackage { + const raw = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as Partial & { private?: boolean } + return { + name: raw.name || '', + private: raw.private !== false, + scripts: raw.scripts || {}, + dependencies: raw.dependencies || {}, + devDependencies: raw.devDependencies || {}, + } +} + +/** 校验独立工程边界,返回违规清单(空数组 = 边界成立)。 */ +export function collectBoundaryViolations(rootDir = resolveWorkspaceRoot()): string[] { + const violations: string[] = [] + const pkgPath = join(rootDir, 'package.json') + const pkg: WorkspacePackage = fileExists(rootDir, 'package.json') + ? readWorkspacePackage(rootDir) + : { name: '', private: false, scripts: {}, dependencies: {}, devDependencies: {} } + if (!fileExists(rootDir, 'package.json')) violations.push(`缺少必需清单文件 package.json(在 ${pkgPath})`) + + if (pkg.name !== STANDALONE_PACKAGE_NAME) { + violations.push(`package.name 应为 ${STANDALONE_PACKAGE_NAME},实际为 ${pkg.name || '(空)'}`) + } + if (!pkg.private) violations.push('package.private 应为 true(后台工程不允许被外部发布依赖)') + + for (const file of REQUIRED_MANIFEST_FILES) { + if (!fileExists(rootDir, file)) violations.push(`缺少必需清单文件 ${file}`) + } + for (const dir of ADMIN_SOURCE_DIRS) { + if (!fileExists(rootDir, dir)) violations.push(`缺少允许源码目录 ${dir}`) + } + + for (const file of walk(rootDir)) { + if (SELF_SCAN_EXCLUSIONS.has(file)) continue + const content = readFileSync(join(rootDir, file), 'utf8') + for (const token of FORBIDDEN_COUPLING_TOKENS) { + if (content.includes(token)) { + violations.push(`${file} 引用了客户端耦合 token "${token}",超出后台工程边界`) + } + } + } + return violations +} diff --git a/admin-frontend-vue/tests/task-1.test.ts b/admin-frontend-vue/tests/task-1.test.ts new file mode 100644 index 00000000..e4f2a890 --- /dev/null +++ b/admin-frontend-vue/tests/task-1.test.ts @@ -0,0 +1,132 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { + ADMIN_SOURCE_DIRS, + collectBoundaryViolations, + FORBIDDEN_COUPLING_TOKENS, + readWorkspacePackage, + REQUIRED_MANIFEST_FILES, + resolveWorkspaceRoot, + STANDALONE_PACKAGE_NAME, + walk, +} from '../src/config/workspace.ts' + +function tempRoot(): string { + return mkdtempSync(join(tmpdir(), 'admin-workspace-')) +} + +test('test_task_001_project_baseline_normal_primary_path', () => { + // 正常主路径:独立工程从自身根目录完成一次边界校验,输出无违规。 + const root = resolveWorkspaceRoot() + const violations = collectBoundaryViolations(root) + assert.deepEqual(violations, [], `工作区边界违规: ${violations.join('; ')}`) + const pkg = readWorkspacePackage(root) + assert.equal(pkg.name, STANDALONE_PACKAGE_NAME) + for (const dep of ['vue', 'vue-router', 'pinia', 'element-plus']) { + assert.ok(pkg.dependencies[dep], `缺少依赖 ${dep}`) + } +}) + +test('test_task_001_project_baseline_normal_variant_input', () => { + // 正常变体:构建、开发、测试三套命令形态都指向独立后台工程。 + const { scripts } = readWorkspacePackage() + assert.match(scripts.build, /vue-tsc --noEmit/) + assert.match(scripts.build, /vite build/) + assert.match(scripts.dev, /vite/) + assert.match(scripts.dev, /5174/) + assert.match(scripts.test, /node --test/) + assert.match(scripts.test, /tests\/\*\.test\.ts/) +}) + +test('test_task_001_project_baseline_normal_repeated_operation_is_idempotent', () => { + // 正常重复:重复校验不改变结果、不产生不稳定状态。 + const first = collectBoundaryViolations() + const second = collectBoundaryViolations() + assert.deepEqual(second, first, '重复校验结果应稳定一致') + const pkgA = readWorkspacePackage() + const pkgB = readWorkspacePackage() + assert.equal(pkgA.name, pkgB.name) + assert.deepEqual(pkgA.dependencies, pkgB.dependencies) +}) + +test('test_task_001_project_baseline_boundary_empty_input', () => { + // 边界空值:允许的源码目录集合完整存在;扫描可容忍空目录不崩溃。 + const root = resolveWorkspaceRoot() + for (const dir of ADMIN_SOURCE_DIRS) { + assert.equal(existsSync(join(root, dir)), true, `缺少允许源码目录 ${dir}`) + } + const empty = tempRoot() + try { + mkdirSync(join(empty, 'src')) + assert.deepEqual(walk(empty), [], '空目录扫描应返回空结果且不抛异常') + } finally { + rmSync(empty, { recursive: true, force: true }) + } +}) + +test('test_task_001_project_baseline_boundary_single_item', () => { + // 边界单元素:单一 SPA 入口与单一挂载点。 + const root = resolveWorkspaceRoot() + const html = readFileSync(join(root, 'index.html'), 'utf8') + assert.match(html, /id="app"/) + assert.match(html, /\/src\/main\.ts/) + const main = readFileSync(join(root, 'src/main.ts'), 'utf8') + assert.match(main, /\.mount\('#app'\)/) +}) + +test('test_task_001_project_baseline_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:严格类型与 ESM 模块边界等关键字段必须存在。 + const root = resolveWorkspaceRoot() + const tsconfig = readFileSync(join(root, 'tsconfig.json'), 'utf8') + assert.match(tsconfig, /"strict":\s*true/) + assert.match(tsconfig, /"moduleResolution":\s*"Bundler"/) + const pkg = readWorkspacePackage(root) + assert.equal(pkg.private, true) + assert.ok(pkg.scripts.test, '缺少 test 命令字段') + assert.ok(REQUIRED_MANIFEST_FILES.length >= 5, '清单字段不能为空') +}) + +test('test_task_001_project_baseline_invalid_input_rejected', () => { + // 异常输入:任何文件引用客户端耦合 token 都应被边界校验拒绝。 + const root = tempRoot() + try { + for (const token of FORBIDDEN_COUPLING_TOKENS) { + mkdirSync(join(root, 'src'), { recursive: true }) + writeFileSync(join(root, 'src', 'bad.ts'), `// 越界引用\nconst t = '${token}'\n`) + const violations = collectBoundaryViolations(root) + assert.ok( + violations.some((v) => v.includes(`引用了客户端耦合 token "${token}"`)), + `token ${token} 应被识别为越界: ${violations.join('; ')}`, + ) + rmSync(join(root, 'src', 'bad.ts')) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('test_task_001_project_baseline_dependency_failure_returns_actionable_message', () => { + // 依赖失败:根目录定位失败与清单缺失时返回可操作错误。 + const root = tempRoot() + try { + const missing = join(root, 'no-package') + mkdirSync(missing) + assert.throws( + () => resolveWorkspaceRoot(missing), + (error: Error) => error.message.includes('package.json'), + ) + const emptyDir = join(root, 'flat') + mkdirSync(emptyDir) + writeFileSync(join(emptyDir, 'package.json'), '{}') + const violations = collectBoundaryViolations(emptyDir) + assert.ok( + violations.some((v) => v.includes('缺少必需清单文件 src/main.ts')), + `依赖缺失应返回可操作清单消息: ${violations.join('; ')}`, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +})