feat: add experimental support for AST parsing and static graph indexing

- Introduced CLI options for using AST grep parsers and enabling static graph relationships during indexing.
- Updated configuration management to load new settings for AST parsing and static graph types.
- Enhanced AST grep processor to handle imports with aliases and improve relationship tracking.
- Modified TreeSitter parsers to support synthetic module scopes for better static graph persistence.
- Implemented global relationship updates in the incremental indexer for static graph expansion.
- Added new ArtifactTag and FloatingFileBrowser components to the frontend for improved terminal dashboard functionality.
- Created utility functions for detecting CCW artifacts in terminal output with associated tests.
This commit is contained in:
catlog22
2026-02-15 23:12:06 +08:00
parent 48a6a1f2aa
commit 8938c47f88
39 changed files with 2956 additions and 297 deletions

View File

@@ -2507,9 +2507,18 @@ export interface McpServer {
scope: 'project' | 'global';
}
export interface McpServerConflict {
name: string;
projectServer: McpServer;
globalServer: McpServer;
/** Runtime effective scope */
effectiveScope: 'global' | 'project';
}
export interface McpServersResponse {
project: McpServer[];
global: McpServer[];
conflicts: McpServerConflict[];
}
/**
@@ -2618,7 +2627,6 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
const disabledSet = new Set(disabledServers);
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
const enterpriseServers = isUnknownRecord(config.enterpriseServers) ? (config.enterpriseServers as UnknownRecord) : {};
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
? (projectConfig.mcpServers as UnknownRecord)
@@ -2635,21 +2643,34 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
});
const project: McpServer[] = Object.entries(projectServersRecord)
// Avoid duplicates: if defined globally/enterprise, treat it as global
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
.map(([name, raw]) => {
const normalized = normalizeServerConfig(raw);
return {
name,
...normalized,
enabled: !disabledSet.has(name),
scope: 'project',
scope: 'project' as const,
};
});
// Detect conflicts: same name exists in both project and global
const conflicts: McpServerConflict[] = [];
for (const ps of project) {
const gs = global.find(g => g.name === ps.name);
if (gs) {
conflicts.push({
name: ps.name,
projectServer: ps,
globalServer: gs,
effectiveScope: 'global',
});
}
}
return {
project,
global,
conflicts,
};
}
@@ -3549,6 +3570,7 @@ export interface CcwMcpConfig {
projectRoot?: string;
allowedDirs?: string;
enableSandbox?: boolean;
installedScopes: ('global' | 'project')[];
}
/**
@@ -3605,22 +3627,24 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
try {
const config = await fetchMcpConfig();
// Check if ccw-tools server exists in any config
const installedScopes: ('global' | 'project')[] = [];
let ccwServer: any = null;
// Check global servers
// Check global/user servers
if (config.globalServers?.['ccw-tools']) {
installedScopes.push('global');
ccwServer = config.globalServers['ccw-tools'];
}
// Check user servers
if (!ccwServer && config.userServers?.['ccw-tools']) {
} else if (config.userServers?.['ccw-tools']) {
installedScopes.push('global');
ccwServer = config.userServers['ccw-tools'];
}
// Check project servers
if (!ccwServer && config.projects) {
if (config.projects) {
for (const proj of Object.values(config.projects)) {
if (proj.mcpServers?.['ccw-tools']) {
ccwServer = proj.mcpServers['ccw-tools'];
installedScopes.push('project');
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
break;
}
}
@@ -3630,6 +3654,7 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
return {
isInstalled: false,
enabledTools: [],
installedScopes: [],
};
}
@@ -3646,11 +3671,13 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
installedScopes,
};
} catch {
return {
isInstalled: false,
enabledTools: [],
installedScopes: [],
};
}
}
@@ -3742,6 +3769,27 @@ export async function uninstallCcwMcp(): Promise<void> {
}
}
/**
* Uninstall CCW Tools MCP server from a specific scope
*/
export async function uninstallCcwMcpFromScope(
scope: 'global' | 'project',
projectPath?: string
): Promise<void> {
if (scope === 'global') {
await fetchApi('/api/mcp-remove-global-server', {
method: 'POST',
body: JSON.stringify({ serverName: 'ccw-tools' }),
});
} else {
if (!projectPath) throw new Error('projectPath required for project scope uninstall');
await fetchApi('/api/mcp-remove-server', {
method: 'POST',
body: JSON.stringify({ projectPath, serverName: 'ccw-tools' }),
});
}
}
// ========== CCW Tools MCP - Codex API ==========
/**
@@ -3753,7 +3801,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
if (!ccwServer) {
return { isInstalled: false, enabledTools: [] };
return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
const env = ccwServer.env || {};
@@ -3768,9 +3816,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
installedScopes: ['global'],
};
} catch {
return { isInstalled: false, enabledTools: [] };
return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
}
@@ -3856,18 +3905,39 @@ export async function updateCcwConfigForCodex(config: {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchIndexStatus(projectPath?: string): Promise<IndexStatus> {
const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status';
return fetchApi<IndexStatus>(url);
const url = projectPath
? `/api/codexlens/workspace-status?path=${encodeURIComponent(projectPath)}`
: '/api/codexlens/workspace-status';
const resp = await fetchApi<{
success: boolean;
hasIndex: boolean;
fts?: { indexedFiles: number; totalFiles: number };
}>(url);
return {
totalFiles: resp.fts?.totalFiles ?? 0,
lastUpdated: new Date().toISOString(),
buildTime: 0,
status: resp.hasIndex ? 'completed' : 'idle',
};
}
/**
* Rebuild index
*/
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/rebuild', {
await fetchApi<{ success: boolean }>('/api/codexlens/init', {
method: 'POST',
body: JSON.stringify(request),
body: JSON.stringify({
path: request.paths?.[0],
indexType: 'vector',
}),
});
return {
totalFiles: 0,
lastUpdated: new Date().toISOString(),
buildTime: 0,
status: 'building',
};
}
// ========== Prompt History API ==========

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { detectCcArtifacts } from './ccw-artifacts';
describe('ccw-artifacts', () => {
it('returns empty array for empty input', () => {
expect(detectCcArtifacts('')).toEqual([]);
});
it('detects workflow session artifacts', () => {
const text = 'Created: (.workflow/active/WFS-demo/workflow-session.json)';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'workflow-session', path: '.workflow/active/WFS-demo/workflow-session.json' },
]);
});
it('detects lite session artifacts', () => {
const text = 'Plan: .workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'lite-session', path: '.workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json' },
]);
});
it('detects CLAUDE.md artifacts (case-insensitive)', () => {
const text = 'Updated: /repo/docs/claude.md and also CLAUDE.md';
const res = detectCcArtifacts(text);
expect(res).toEqual([
{ type: 'claude-md', path: '/repo/docs/claude.md' },
{ type: 'claude-md', path: 'CLAUDE.md' },
]);
});
it('detects CCW config artifacts', () => {
const text = 'Config: .ccw/config.toml and ccw.config.yaml';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'ccw-config', path: '.ccw/config.toml' },
{ type: 'ccw-config', path: 'ccw.config.yaml' },
]);
});
it('detects issue artifacts', () => {
const text = 'Queue: .workflow/issues/queues/index.json';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'issue', path: '.workflow/issues/queues/index.json' },
]);
});
it('deduplicates repeated artifacts', () => {
const text = '.workflow/issues/issues.jsonl ... .workflow/issues/issues.jsonl';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'issue', path: '.workflow/issues/issues.jsonl' },
]);
});
it('preserves discovery order across types', () => {
const text = [
'Issue: .workflow/issues/issues.jsonl',
'Then plan: .workflow/.lite-plan/abc/plan.json',
'Then session: .workflow/active/WFS-x/workflow-session.json',
'Then config: .ccw/config.toml',
'Then CLAUDE: CLAUDE.md',
].join(' | ');
const res = detectCcArtifacts(text);
expect(res.map((a) => a.type)).toEqual([
'issue',
'lite-session',
'workflow-session',
'ccw-config',
'claude-md',
]);
});
});

View File

@@ -0,0 +1,91 @@
// ========================================
// CCW Artifacts - Types & Detection
// ========================================
export type ArtifactType =
| 'workflow-session'
| 'lite-session'
| 'claude-md'
| 'ccw-config'
| 'issue';
export interface CcArtifact {
type: ArtifactType;
path: string;
}
const TRAILING_PUNCTUATION = /[)\]}>,.;:!?]+$/g;
const WRAP_QUOTES = /^['"`]+|['"`]+$/g;
function normalizePath(raw: string): string {
return raw.trim().replace(WRAP_QUOTES, '').replace(TRAILING_PUNCTUATION, '');
}
/**
* Patterns for detecting CCW-related artifacts in terminal output.
*
* Notes:
* - Prefer relative paths (e.g., `.workflow/...`) so callers can resolve against a project root.
* - Keep patterns conservative to reduce false positives in generic logs.
*/
export const ARTIFACT_PATTERNS: Record<ArtifactType, RegExp[]> = {
'workflow-session': [
/(?:^|[^\w.])(\.workflow[\\/](?:active|archives)[\\/][^\s"'`]+[\\/]workflow-session\.json)\b/g,
],
'lite-session': [
/(?:^|[^\w.])(\.workflow[\\/]\.lite-plan[\\/][^\s"'`]+)\b/g,
],
'claude-md': [
/([^\s"'`]*CLAUDE\.md)\b/gi,
],
'ccw-config': [
/(?:^|[^\w.])(\.ccw[\\/][^\s"'`]+)\b/g,
/(?:^|[^\w.])(ccw\.config\.(?:json|ya?ml|toml))\b/gi,
],
issue: [
/(?:^|[^\w.])(\.workflow[\\/]issues[\\/][^\s"'`]+)\b/g,
],
};
/**
* Detect CCW artifacts from an arbitrary text blob.
*
* Returns a de-duplicated list of `{ type, path }` in discovery order.
*/
export function detectCcArtifacts(text: string): CcArtifact[] {
if (!text) return [];
const candidates: Array<CcArtifact & { index: number }> = [];
for (const type of Object.keys(ARTIFACT_PATTERNS) as ArtifactType[]) {
for (const pattern of ARTIFACT_PATTERNS[type]) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const raw = match[1] ?? match[0];
const path = normalizePath(raw);
if (!path) continue;
const full = match[0] ?? '';
const group = match[1] ?? raw;
const rel = full.indexOf(group);
const index = (match.index ?? 0) + (rel >= 0 ? rel : 0);
candidates.push({ type, path, index });
}
}
}
candidates.sort((a, b) => a.index - b.index);
const results: CcArtifact[] = [];
const seen = new Set<string>();
for (const c of candidates) {
const key = `${c.type}:${c.path}`;
if (seen.has(key)) continue;
seen.add(key);
results.push({ type: c.type, path: c.path });
}
return results;
}