Files
ChatLab/scripts/check-workspace-dependencies.mjs
T

181 lines
6.4 KiB
JavaScript

#!/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