chore(workspace): enforce dependency declarations

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent 756daf04bf
commit c34f71ed2d
7 files changed
+249 -2

No files matched your search

+3
View File
@@ -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
+8
View File
@@ -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",
+5
View File
@@ -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",
+4 -1
View File
@@ -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:*"
}
}
+2
View File
@@ -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",
+47 -1
View File
@@ -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
+180
View File
@@ -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 = /<script\b[^>]*>([\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