From c34f71ed2d66d681e93dbf0b1d6dcff81cca7d16 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 15:47:48 +0800 Subject: [PATCH] chore(workspace): enforce dependency declarations --- .github/workflows/test.yml | 3 + apps/desktop/package.json | 8 + package.json | 5 + packages/core/package.json | 5 +- packages/node-runtime/package.json | 2 + pnpm-lock.yaml | 48 +++++- scripts/check-workspace-dependencies.mjs | 180 +++++++++++++++++++++++ 7 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 scripts/check-workspace-dependencies.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a06346a..b499525a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check workspace dependency declarations + run: pnpm run check:workspace-deps + # 共享 node_modules 的 better-sqlite3 由 pnpm install 保持 Node ABI # (desktop 不再有 electron-rebuild postinstall),这里只需准备 CLI 专属副本。 - name: Prepare better-sqlite3 for Node tests diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9ef97702..06f4a00a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,6 +31,14 @@ "undici": "^6.25.0" }, "devDependencies": { + "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", + "@openchatlab/http-routes": "workspace:*", + "@openchatlab/node-runtime": "workspace:*", + "@openchatlab/parser": "workspace:*", + "@openchatlab/shared-types": "workspace:*", + "@openchatlab/sync": "workspace:*", + "@openchatlab/tools": "workspace:*", "@electron-toolkit/tsconfig": "^1.0.1", "@electron/rebuild": "^4.0.4", "@types/better-sqlite3": "^7.6.13", diff --git a/package.json b/package.json index b5bbdb3e..964f3821 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "type-check:node": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p apps/desktop/tsconfig.node.json", "type-check:all": "pnpm run type-check:web && pnpm run type-check:node", "type-check": "vue-tsc --noEmit -p tsconfig.web.json", + "check:workspace-deps": "node scripts/check-workspace-dependencies.mjs", "test": "node scripts/run-tests.mjs", "test:unit": "node scripts/run-tests.mjs", "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", @@ -63,6 +64,10 @@ "vue-i18n": "^11.2.8" }, "devDependencies": { + "@openchatlab/core": "workspace:*", + "@openchatlab/parser": "workspace:*", + "@openchatlab/shared-types": "workspace:*", + "@openchatlab/tools": "workspace:*", "@electron-toolkit/tsconfig": "^1.0.1", "@nuxt/ui": "^4.9.0", "@tailwindcss/vite": "^4.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index 05271a6a..7e294e0a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,5 +5,8 @@ "description": "ChatLab 平台无关的共享核心:抽象接口、查询逻辑、分析算法", "type": "module", "main": "./src/index.ts", - "types": "./src/index.ts" + "types": "./src/index.ts", + "dependencies": { + "@openchatlab/shared-types": "workspace:*" + } } diff --git a/packages/node-runtime/package.json b/packages/node-runtime/package.json index bcac2908..5c4300b7 100644 --- a/packages/node-runtime/package.json +++ b/packages/node-runtime/package.json @@ -19,6 +19,8 @@ "@huggingface/transformers": "^4.2.0", "@openchatlab/config": "workspace:*", "@openchatlab/core": "workspace:*", + "@openchatlab/parser": "workspace:*", + "@openchatlab/shared-types": "workspace:*", "better-sqlite3": "^12.4.6", "gray-matter": "^4.0.3", "js-tiktoken": "^1.0.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95fd52d7..81d976a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,18 @@ importers: '@nuxt/ui': specifier: ^4.9.0 version: 4.9.0(s6g7ddwkwqprfo6zxngcqjtvte) + '@openchatlab/core': + specifier: workspace:* + version: link:packages/core + '@openchatlab/parser': + specifier: workspace:* + version: link:packages/parser + '@openchatlab/shared-types': + specifier: workspace:* + version: link:packages/shared-types + '@openchatlab/tools': + specifier: workspace:* + version: link:packages/tools '@tailwindcss/vite': specifier: ^4.0.0 version: 4.1.18(vite@7.3.3(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -231,6 +243,30 @@ importers: '@electron/rebuild': specifier: ^4.0.4 version: 4.0.4 + '@openchatlab/config': + specifier: workspace:* + version: link:../../packages/config + '@openchatlab/core': + specifier: workspace:* + version: link:../../packages/core + '@openchatlab/http-routes': + specifier: workspace:* + version: link:../../packages/http-routes + '@openchatlab/node-runtime': + specifier: workspace:* + version: link:../../packages/node-runtime + '@openchatlab/parser': + specifier: workspace:* + version: link:../../packages/parser + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@openchatlab/sync': + specifier: workspace:* + version: link:../../packages/sync + '@openchatlab/tools': + specifier: workspace:* + version: link:../../packages/tools '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -262,7 +298,11 @@ importers: specifier: ^3.24.4 version: 3.25.76 - packages/core: {} + packages/core: + dependencies: + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types packages/http-routes: dependencies: @@ -337,6 +377,12 @@ importers: '@openchatlab/core': specifier: workspace:* version: link:../core + '@openchatlab/parser': + specifier: workspace:* + version: link:../parser + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types better-sqlite3: specifier: ^12.4.6 version: 12.6.2 diff --git a/scripts/check-workspace-dependencies.mjs b/scripts/check-workspace-dependencies.mjs new file mode 100644 index 00000000..821d5b51 --- /dev/null +++ b/scripts/check-workspace-dependencies.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' +import ts from 'typescript' + +const repoRoot = path.resolve(import.meta.dirname, '..') +const sourceExtensions = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx', '.vue']) +const ignoredDirectories = new Set(['.docs', '.git', 'coverage', 'dist', 'dist-web', 'node_modules', 'out', 'target']) +const ignoredPaths = new Set([ + path.join(repoRoot, 'apps', 'desktop', 'native'), + path.join(repoRoot, 'docs', '.vitepress', 'cache'), +]) + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function findWorkspaceManifests() { + const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + const result = spawnSync(pnpmCommand, ['-r', 'list', '--depth', '-1', '--json'], { + cwd: repoRoot, + encoding: 'utf8', + }) + if (result.status !== 0) { + throw new Error(`Failed to list pnpm workspace projects: ${result.stderr.trim()}`) + } + return JSON.parse(result.stdout).map((project) => path.join(project.path, 'package.json')) +} + +function parseModuleSpecifiers(sourceText, filePath) { + const scriptKind = filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, scriptKind) + const specifiers = new Set() + + function addStringLiteral(node) { + if (node && ts.isStringLiteralLike(node)) specifiers.add(node.text) + } + + function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + addStringLiteral(node.moduleSpecifier) + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + addStringLiteral(node.moduleReference.expression) + } else if (ts.isCallExpression(node) && node.arguments.length === 1) { + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword + const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require' + if (isDynamicImport || isRequire) addStringLiteral(node.arguments[0]) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return specifiers +} + +function extractModuleSpecifiers(filePath) { + const sourceText = fs.readFileSync(filePath, 'utf8') + if (!filePath.endsWith('.vue')) return parseModuleSpecifiers(sourceText, filePath) + + const specifiers = new Set() + const scriptBlockPattern = /]*>([\s\S]*?)<\/script>/gi + for (const match of sourceText.matchAll(scriptBlockPattern)) { + for (const specifier of parseModuleSpecifiers(match[1], filePath)) specifiers.add(specifier) + } + return specifiers +} + +function collectSourceFiles(projectRoot, nestedProjectRoots) { + const files = [] + + function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const child = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (ignoredDirectories.has(entry.name) || ignoredPaths.has(child) || nestedProjectRoots.has(child)) continue + walk(child) + } else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) { + files.push(child) + } + } + } + + walk(projectRoot) + return files +} + +function resolveWorkspacePackage(specifier, workspaceNames) { + return workspaceNames.find((name) => specifier === name || specifier.startsWith(`${name}/`)) +} + +function collectDeclaredDependencies(manifest) { + return new Map( + ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap((field) => + Object.entries(manifest[field] ?? {}).map(([name, version]) => [name, { field, version }]) + ) + ) +} + +function relativePath(filePath) { + return path.relative(repoRoot, filePath) || '.' +} + +function auditWorkspaceDependencies() { + const projects = findWorkspaceManifests().map((manifestPath) => ({ + manifestPath, + root: path.dirname(manifestPath), + manifest: readJson(manifestPath), + })) + const workspaceNames = projects + .map((project) => project.manifest.name) + .filter(Boolean) + .sort((a, b) => b.length - a.length) + const projectRoots = new Set(projects.map((project) => project.root)) + const missing = [] + const invalidProtocols = [] + + for (const project of projects) { + const declared = collectDeclaredDependencies(project.manifest) + const nestedProjectRoots = new Set( + [...projectRoots].filter((root) => root !== project.root && root.startsWith(`${project.root}${path.sep}`)) + ) + const references = new Map() + + for (const filePath of collectSourceFiles(project.root, nestedProjectRoots)) { + for (const specifier of extractModuleSpecifiers(filePath)) { + const dependency = resolveWorkspacePackage(specifier, workspaceNames) + if (!dependency || dependency === project.manifest.name) continue + const files = references.get(dependency) ?? new Set() + files.add(relativePath(filePath)) + references.set(dependency, files) + } + } + + for (const [dependency, files] of references) { + if (!declared.has(dependency)) { + missing.push({ project: project.manifest.name, dependency, files: [...files].sort() }) + } + } + + for (const [dependency, declaration] of declared) { + if (!workspaceNames.includes(dependency)) continue + if (declaration.version !== 'workspace:*') { + invalidProtocols.push({ + project: project.manifest.name, + dependency, + field: declaration.field, + version: declaration.version, + }) + } + } + } + + return { projects, missing, invalidProtocols } +} + +const result = auditWorkspaceDependencies() + +if (result.missing.length === 0 && result.invalidProtocols.length === 0) { + console.log( + `[workspace-deps] Checked ${result.projects.length} projects; all direct workspace dependencies are declared.` + ) + process.exit(0) +} + +for (const issue of result.missing) { + console.error(`[workspace-deps] ${issue.project} imports ${issue.dependency} but does not declare it:`) + for (const file of issue.files.slice(0, 8)) console.error(` - ${file}`) + if (issue.files.length > 8) console.error(` - ...and ${issue.files.length - 8} more`) +} + +for (const issue of result.invalidProtocols) { + console.error( + `[workspace-deps] ${issue.project} declares ${issue.dependency} in ${issue.field} as ${String(issue.version)}; use workspace:*.` + ) +} + +process.exitCode = 1