mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
chore(workspace): enforce dependency declarations
This commit is contained in:
1 parent
756daf04bf
commit
c34f71ed2d
7 files changed
+249
-2
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user