autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
440 lines (439 loc) • 16.1 kB
JavaScript
/**
* @module NodeDiscoverer
* @description TypeScript / JavaScript 项目结构发现器
*
* 检测信号: package.json, tsconfig.json, node_modules/
* 支持: 单包、Monorepo (npm/pnpm/yarn workspaces, lerna)
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { basename, extname, join, relative, resolve } from 'node:path';
import { inferLang } from '../../external/mcp/handlers/LanguageExtensions.js';
import { ProjectDiscoverer, } from './ProjectDiscoverer.js';
const SOURCE_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.vue',
'.svelte',
]);
const EXCLUDE_DIRS = new Set([
'node_modules',
'.git',
'.cursor',
'dist',
'build',
'out',
'.next',
'.nuxt',
'coverage',
'.turbo',
'.cache',
]);
export class NodeDiscoverer extends ProjectDiscoverer {
#projectRoot = null;
#packageJson = null;
#targets = [];
#depGraph = { nodes: [], edges: [] };
get id() {
return 'node';
}
get displayName() {
return 'Node.js (npm/pnpm/yarn)';
}
async detect(projectRoot) {
let confidence = 0;
const reasons = [];
if (existsSync(join(projectRoot, 'package.json'))) {
confidence = 0.9;
reasons.push('package.json exists');
}
if (existsSync(join(projectRoot, 'tsconfig.json'))) {
confidence = Math.max(confidence, 0.9);
confidence += 0.05;
reasons.push('tsconfig.json exists');
}
if (existsSync(join(projectRoot, 'node_modules'))) {
confidence += 0.05;
reasons.push('node_modules/ exists');
}
// ── 降低 confidence:当检测到其他生态的强标记时 ──────────
// 这些项目使用 package.json 仅作为前端/工具链辅助,主语言在别的生态
if (confidence > 0) {
// Ruby 生态:Gemfile/Rakefile 的存在几乎确定是 Rails/Sinatra 项目
// 即使有 tsconfig.json 也只是说明前端构建链使用 TS(Ember/React),
// 主语言仍然是 Ruby,因此始终重度降级
const rubyMarkers = ['Gemfile', 'Rakefile'];
const hasRubyMarker = rubyMarkers.some((f) => existsSync(join(projectRoot, f)));
if (hasRubyMarker) {
confidence *= 0.05;
reasons.push('Ruby marker found (Gemfile/Rakefile) — confidence heavily reduced');
}
else {
// 其他生态标记:tsconfig 存在则可能是全栈项目,保留较高 confidence
const otherMarkers = [
{ files: ['Cargo.toml'], lang: 'Rust' },
{ files: ['go.mod'], lang: 'Go' },
];
for (const marker of otherMarkers) {
if (marker.files.some((f) => existsSync(join(projectRoot, f)))) {
const hasTsConfig = existsSync(join(projectRoot, 'tsconfig.json'));
if (hasTsConfig) {
confidence *= 0.5;
reasons.push(`${marker.lang} marker found (but tsconfig present) — confidence moderately reduced`);
}
else {
confidence *= 0.05;
reasons.push(`${marker.lang} marker found — confidence heavily reduced`);
}
break;
}
}
}
}
return {
match: confidence > 0,
confidence: Math.min(confidence, 1.0),
reason: reasons.join(', ') || 'No Node.js markers found',
};
}
async load(projectRoot) {
this.#projectRoot = projectRoot;
this.#targets = [];
this.#depGraph = { nodes: [], edges: [] };
// 读取 package.json
const pkgPath = join(projectRoot, 'package.json');
if (existsSync(pkgPath)) {
try {
this.#packageJson = JSON.parse(readFileSync(pkgPath, 'utf8'));
}
catch {
this.#packageJson = {};
}
}
else {
this.#packageJson = {};
}
// 检测 monorepo workspaces
const workspacePaths = this.#resolveWorkspaces(projectRoot);
if (workspacePaths.length > 0) {
// Monorepo 模式: 每个 workspace 是一个 Target
for (const wsPath of workspacePaths) {
const wsAbsPath = resolve(projectRoot, wsPath);
if (!existsSync(wsAbsPath)) {
continue;
}
const wsPkgPath = join(wsAbsPath, 'package.json');
let wsPkg = {};
if (existsSync(wsPkgPath)) {
try {
wsPkg = JSON.parse(readFileSync(wsPkgPath, 'utf8'));
}
catch {
/* skip */
}
}
const framework = this.#detectFramework(wsPkg);
const name = wsPkg.name || basename(wsPath);
const type = this.#inferTargetType(wsPkg);
this.#targets.push({
name,
path: wsAbsPath,
type,
language: 'typescript',
framework,
metadata: { packageJson: wsPkg },
});
this.#depGraph.nodes.push(name);
}
// 构建 workspace 间依赖
this.#buildWorkspaceDeps(workspacePaths);
}
else {
// 单包模式
const framework = this.#detectFramework(this.#packageJson);
const name = this.#packageJson?.name || basename(projectRoot);
const type = this.#inferTargetType(this.#packageJson);
this.#targets.push({
name,
path: projectRoot,
type,
language: 'typescript',
framework,
metadata: { packageJson: this.#packageJson },
});
this.#depGraph.nodes.push(name);
}
// 添加外部依赖到依赖图
this.#addExternalDeps();
}
async listTargets() {
return this.#targets;
}
async getTargetFiles(target) {
const targetPath = typeof target === 'string'
? this.#targets.find((t) => t.name === target)?.path || this.#projectRoot
: target.path;
if (!targetPath || !existsSync(targetPath)) {
return [];
}
const files = [];
this.#collectFiles(targetPath, targetPath, files);
return files;
}
async getDependencyGraph() {
return this.#depGraph;
}
// ── 内部实现 ──
#resolveWorkspaces(projectRoot) {
const paths = [];
// npm/yarn workspaces (from package.json)
const workspaces = this.#packageJson?.workspaces;
if (workspaces) {
const patterns = Array.isArray(workspaces) ? workspaces : workspaces.packages || [];
for (const pattern of patterns) {
// 简单 glob 展开: "packages/*" → 列出子目录
if (pattern.endsWith('/*') || pattern.endsWith('/**')) {
const dir = pattern.replace(/\/\*\*?$/, '');
const absDir = resolve(projectRoot, dir);
if (existsSync(absDir)) {
try {
const entries = readdirSync(absDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
paths.push(join(dir, entry.name));
}
}
}
catch {
/* skip */
}
}
}
else {
paths.push(pattern);
}
}
}
// pnpm-workspace.yaml
const pnpmWsPath = join(projectRoot, 'pnpm-workspace.yaml');
if (paths.length === 0 && existsSync(pnpmWsPath)) {
try {
const content = readFileSync(pnpmWsPath, 'utf8');
const pkgMatches = content.matchAll(/^\s*-\s*['"]?([^'"#\n]+)['"]?/gm);
for (const m of pkgMatches) {
const pattern = m[1].trim();
if (pattern.endsWith('/*') || pattern.endsWith('/**')) {
const dir = pattern.replace(/\/\*\*?$/, '');
const absDir = resolve(projectRoot, dir);
if (existsSync(absDir)) {
try {
const entries = readdirSync(absDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
paths.push(join(dir, entry.name));
}
}
}
catch {
/* skip */
}
}
}
else {
paths.push(pattern);
}
}
}
catch {
/* skip */
}
}
// lerna.json
const lernaPath = join(projectRoot, 'lerna.json');
if (paths.length === 0 && existsSync(lernaPath)) {
try {
const lerna = JSON.parse(readFileSync(lernaPath, 'utf8'));
const patterns = lerna.packages || ['packages/*'];
for (const pattern of patterns) {
if (pattern.endsWith('/*') || pattern.endsWith('/**')) {
const dir = pattern.replace(/\/\*\*?$/, '');
const absDir = resolve(projectRoot, dir);
if (existsSync(absDir)) {
try {
const entries = readdirSync(absDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.')) {
paths.push(join(dir, entry.name));
}
}
}
catch {
/* skip */
}
}
}
else {
paths.push(pattern);
}
}
}
catch {
/* skip */
}
}
return paths;
}
#detectFramework(pkg) {
if (!pkg) {
return null;
}
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
if (deps.next) {
return 'nextjs';
}
if (deps.nuxt || deps.nuxt3) {
return 'nuxt';
}
if (deps['@angular/core']) {
return 'angular';
}
if (deps.svelte) {
return 'svelte';
}
if (deps['react-native']) {
return 'react-native';
}
if (deps.react || deps['react-dom']) {
return 'react';
}
if (deps.vue) {
return 'vue';
}
if (deps['@nestjs/core']) {
return 'nestjs';
}
if (deps.electron) {
return 'electron';
}
if (deps.express) {
return 'node-server';
}
if (deps.fastify) {
return 'node-server';
}
if (deps.koa) {
return 'node-server';
}
if (deps.hono) {
return 'node-server';
}
return null;
}
#inferTargetType(pkg) {
if (!pkg) {
return 'library';
}
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
if (pkg.bin) {
return 'executable';
}
if (deps.jest || deps.mocha || deps.vitest) {
// Has test runner but check if it's primary purpose
if (pkg.name?.includes('test')) {
return 'test';
}
}
if (deps.react || deps.vue || deps['@angular/core'] || deps.svelte) {
return 'app';
}
if (deps.express || deps.fastify || deps.koa || deps['@nestjs/core']) {
return 'app';
}
if (deps.electron) {
return 'app';
}
return 'library';
}
#collectFiles(dir, rootDir, files, depth = 0) {
if (depth > 15) {
return; // 防止过深递归
}
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) {
continue;
}
if (EXCLUDE_DIRS.has(entry.name)) {
continue;
}
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
this.#collectFiles(fullPath, rootDir, files, depth + 1);
}
else if (entry.isFile()) {
const ext = extname(entry.name);
if (SOURCE_EXTENSIONS.has(ext)) {
const lang = inferLang(entry.name) || 'javascript';
files.push({
name: entry.name,
path: fullPath,
relativePath: relative(rootDir, fullPath),
language: lang,
});
}
}
}
}
catch {
/* skip unreadable dirs */
}
}
#buildWorkspaceDeps(workspacePaths) {
// 收集所有 workspace 包名
const nameToPath = new Map();
for (const t of this.#targets) {
nameToPath.set(t.name, t.path);
}
for (const t of this.#targets) {
const pkg = t.metadata?.packageJson;
if (!pkg) {
continue;
}
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
for (const depName of Object.keys(allDeps)) {
if (nameToPath.has(depName)) {
this.#depGraph.edges.push({
from: t.name,
to: depName,
type: pkg.devDependencies?.[depName] ? 'dev_depends_on' : 'depends_on',
});
}
}
}
}
#addExternalDeps() {
if (!this.#packageJson) {
return;
}
const deps = this.#packageJson.dependencies || {};
const devDeps = this.#packageJson.devDependencies || {};
const rootName = this.#targets[0]?.name;
if (!rootName) {
return;
}
for (const dep of Object.keys(deps)) {
if (!this.#depGraph.nodes.includes(dep)) {
this.#depGraph.edges.push({ from: rootName, to: dep, type: 'depends_on' });
}
}
for (const dep of Object.keys(devDeps)) {
if (!this.#depGraph.nodes.includes(dep)) {
this.#depGraph.edges.push({ from: rootName, to: dep, type: 'dev_depends_on' });
}
}
}
}