Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,10 @@
{ "name": "react-native-segmented-control", "repo": "https://github.com/react-native-segmented-control/segmented-control", "size": "Small", "files": "~25", "question": "How does JSX `<SegmentedControl onChange={cb}/>` reach the native onChange handler on iOS/Android?" },
{ "name": "react-native-screens", "repo": "https://github.com/software-mansion/react-native-screens", "size": "Medium", "files": "~1200", "question": "How does JSX `<ScreenStack>` reach the native RNSScreenStackView component?" },
{ "name": "react-native-skia", "repo": "https://github.com/Shopify/react-native-skia", "size": "Large", "files": "~1000", "question": "How does a `<SkiaPictureView/>` JSX usage reach the iOS / Android native renderer?" }
],
"Astro": [
{ "name": "astro-paper", "repo": "https://github.com/satnaing/astro-paper", "size": "Small", "files": "~175", "question": "How does a blog post's frontmatter data flow from the content collection to the SEO and layout components?" },
{ "name": "astrowind", "repo": "https://github.com/onwidget/astrowind", "size": "Medium", "files": "~176", "question": "How does the Hero component receive and pass its props down to child components?" },
{ "name": "astro-docs", "repo": "https://github.com/withastro/docs", "size": "Large", "files": "~3191", "question": "How does the navigation sidebar get populated from the content collections and page metadata?" }
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- CodeGraph now indexes **Astro** (`.astro`) — components, functions, interfaces, and imports from the frontmatter block, plus component reference edges from the template. Works with `astro-paper`, `astrowind`, and the official Astro docs.
- `codegraph init` now builds the initial index by default — you no longer need the `-i`/`--index` flag (it's still accepted, so existing commands and scripts keep working). (#483)
- Go: Gin middleware chains now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request reaches the middleware and route handlers registered via `.Use()` / `.GET()` instead of dead-ending where the framework dispatches the chain dynamically.
- `codegraph_explore` now sizes its response to the *answer* instead of the file count: it shows the mechanism and the exact methods you asked about in full — even when they're buried deep in a large file — while collapsing the redundant interchangeable implementations of an interface (an HTTP interceptor chain, a query-compiler family) down to signatures. Fewer tokens for a more complete answer, so on the flows that used to occasionally cost more than plain grep/read it's now clearly cheaper — and the win holds across small, medium, and large codebases. Distinct, non-interchangeable code is shown in full as before. Disable with `CODEGRAPH_ADAPTIVE_EXPLORE=0`.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ CodeGraph cuts **cost, tokens, tool calls, and time on every repo** — across s
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
| **21+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Astro, Svelte, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
Expand Down Expand Up @@ -592,6 +592,7 @@ is written):
| Kotlin | `.kt`, `.kts` | Full support |
| Scala | `.scala`, `.sc` | Full support (classes, traits, methods, type aliases, Scala 3 enums) |
| Dart | `.dart` | Full support |
| Astro | `.astro` | Full support (frontmatter TypeScript extraction, component nodes, template call/component edges) |
| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
| Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) |
| Liquid | `.liquid` | Full support |
Expand Down
117 changes: 117 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4387,3 +4387,120 @@ void helperFunction(int count) {
expect(getSupportedLanguages()).toContain('objc');
});
});

describe('Astro Extraction', () => {
it('should detect Astro files', () => {
expect(detectLanguage('src/pages/index.astro')).toBe('astro');
expect(detectLanguage('components/Header.astro')).toBe('astro');
expect(isLanguageSupported('astro')).toBe(true);
expect(getSupportedLanguages()).toContain('astro');
});

it('should extract a component node for every .astro file', () => {
const code = `---
const { title } = Astro.props;
---
<h1>{title}</h1>
`;
const result = extractFromSource('Heading.astro', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Heading');
expect(componentNode?.language).toBe('astro');
expect(componentNode?.isExported).toBe(true);
});

it('should extract functions from the frontmatter block', () => {
const code = `---
import Layout from './Layout.astro';

interface Props {
title: string;
}

function formatDate(date: Date): string {
return date.toLocaleDateString();
}

const { title } = Astro.props;
---
<Layout title={title}>
<p>{formatDate(new Date())}</p>
</Layout>
`;
const result = extractFromSource('Page.astro', code);

const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Page');

const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'formatDate');
expect(funcNode).toBeDefined();
expect(funcNode?.language).toBe('astro');
});

it('should extract imports from the frontmatter block', () => {
const code = `---
import Header from '../components/Header.astro';
import { getCollection } from 'astro:content';
---
<Header />
`;
const result = extractFromSource('Blog.astro', code);

const importNodes = result.nodes.filter((n) => n.kind === 'import');
expect(importNodes.length).toBeGreaterThanOrEqual(1);

const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
expect(refs.length).toBeGreaterThanOrEqual(1);
});

it('should capture child component references from the template', () => {
const code = `---
import Header from './Header.astro';
import Footer from './Footer.astro';
---
<html>
<body>
<Header />
<main><slot /></main>
<Footer />
</body>
</html>
`;
const result = extractFromSource('Layout.astro', code);

const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
const refNames = refs.map((r) => r.referenceName);
expect(refNames).toContain('Header');
expect(refNames).toContain('Footer');
});

it('should mark all extracted symbols as astro language', () => {
const code = `---
function helper() { return 42; }
const x = 1;
---
<p>{helper()}</p>
`;
const result = extractFromSource('Component.astro', code);
const nonFileNodes = result.nodes.filter((n) => n.kind !== 'file');
for (const node of nonFileNodes) {
expect(node.language).toBe('astro');
}
});

it('should handle .astro files with no frontmatter', () => {
const code = `<html>
<body>
<h1>Hello</h1>
</body>
</html>
`;
const result = extractFromSource('Static.astro', code);
const componentNode = result.nodes.find((n) => n.kind === 'component');
expect(componentNode).toBeDefined();
expect(componentNode?.name).toBe('Static');
expect(result.errors).toHaveLength(0);
});
});
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

221 changes: 221 additions & 0 deletions src/extraction/astro-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';

/**
* AstroExtractor - Extracts code relationships from Astro component files (.astro)
*
* Astro files are multi-language: a TypeScript frontmatter block (delimited by
* `---`) followed by an HTML template. Rather than relying on the Astro
* tree-sitter grammar (which treats the frontmatter as a raw text blob), we
* extract the frontmatter content and delegate it to the TypeScript
* TreeSitterExtractor, then scan the template for component usages and
* expression call references.
*
* Every .astro file produces a `component` node (Astro components are always
* importable as named exports).
*/
export class AstroExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];

constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
}

extract(): ExtractionResult {
const startTime = Date.now();
try {
const componentNode = this.createComponentNode();
const frontmatter = this.extractFrontmatter();
if (frontmatter) {
this.processFrontmatter(frontmatter, componentNode.id);
}
this.extractTemplateCalls(componentNode.id, frontmatter);
this.extractTemplateComponents(componentNode.id, frontmatter);
} catch (error) {
this.errors.push({
message: `Astro extraction error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
code: 'parse_error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}

private createComponentNode(): Node {
const lines = this.source.split('\n');
const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
const componentName = fileName.replace(/\.astro$/, '');
const id = generateNodeId(this.filePath, 'component', componentName, 1);
const node: Node = {
id,
kind: 'component',
name: componentName,
qualifiedName: `${this.filePath}::${componentName}`,
filePath: this.filePath,
language: 'astro',
startLine: 1,
endLine: lines.length,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length || 0,
isExported: true,
updatedAt: Date.now(),
};
this.nodes.push(node);
return node;
}

/**
* Extract the frontmatter block content and its line offset within the file.
* Frontmatter is the TypeScript/JavaScript code between the opening and
* closing `---` fence lines at the top of the file.
*/
private extractFrontmatter(): { content: string; startLine: number } | null {
// Must start with `---` (optional trailing whitespace, then newline)
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(this.source);
if (!match) return null;
const content = match[1] ?? '';
// `---` is line 1 (1-indexed); content starts on line 2
const startLine = 1;
return { content, startLine };
}

/**
* Re-parse frontmatter as TypeScript and incorporate the extracted symbols,
* offsetting all line numbers by the frontmatter's position in the file.
*/
private processFrontmatter(
frontmatter: { content: string; startLine: number },
componentNodeId: string
): void {
const lang: Language = 'typescript';
if (!isLanguageSupported(lang)) {
this.errors.push({
message: 'TypeScript parser not available; cannot parse Astro frontmatter',
severity: 'warning',
});
return;
}

const extractor = new TreeSitterExtractor(this.filePath, frontmatter.content, lang);
const result = extractor.extract();

for (const node of result.nodes) {
node.startLine += frontmatter.startLine;
node.endLine += frontmatter.startLine;
node.language = 'astro';
this.nodes.push(node);
this.edges.push({ source: componentNodeId, target: node.id, kind: 'contains' });
}
for (const edge of result.edges) {
if (edge.line) edge.line += frontmatter.startLine;
this.edges.push(edge);
}
for (const ref of result.unresolvedReferences) {
ref.line += frontmatter.startLine;
ref.filePath = this.filePath;
ref.language = 'astro';
this.unresolvedReferences.push(ref);
}
for (const error of result.errors) {
if (error.line) error.line += frontmatter.startLine;
this.errors.push(error);
}
}

/**
* Extract function calls from Astro template expressions (`{expr(...)}`)
* outside the frontmatter block.
*/
private extractTemplateCalls(
componentNodeId: string,
frontmatter: { content: string; startLine: number } | null
): void {
const frontmatterEndLine = frontmatter
? frontmatter.startLine + frontmatter.content.split('\n').length + 1
: 0;

const lines = this.source.split('\n');
const exprRegex = /\{([^}]+)\}/g;

for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
if (lineIdx < frontmatterEndLine) continue;
const line = lines[lineIdx]!;
let exprMatch;
exprRegex.lastIndex = 0;
while ((exprMatch = exprRegex.exec(line)) !== null) {
const expr = exprMatch[1]!;
const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g;
let callMatch;
while ((callMatch = callRegex.exec(expr)) !== null) {
const calleeName = callMatch[1]!;
// Skip common non-function identifiers
if (
calleeName === 'if' || calleeName === 'else' || calleeName === 'for' ||
calleeName === 'while' || calleeName === 'switch' || calleeName === 'return'
) continue;
this.unresolvedReferences.push({
fromNodeId: componentNodeId,
referenceName: calleeName,
referenceKind: 'calls',
line: lineIdx + 1,
column: exprMatch.index + callMatch.index,
filePath: this.filePath,
language: 'astro',
});
}
}
}
}

/**
* Extract component usages from the Astro template.
*
* PascalCase tags (<Header />, <BlogPost>, etc.) and client directive
* components represent component instantiations — creates `references`
* edges from this component to the imported child component.
*/
private extractTemplateComponents(
componentNodeId: string,
frontmatter: { content: string; startLine: number } | null
): void {
const frontmatterEndLine = frontmatter
? frontmatter.startLine + frontmatter.content.split('\n').length + 1
: 0;

const lines = this.source.split('\n');
const componentTagRegex = /<([A-Z][a-zA-Z0-9_$]*)\b/g;

for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
if (lineIdx < frontmatterEndLine) continue;
const line = lines[lineIdx]!;
let match;
componentTagRegex.lastIndex = 0;
while ((match = componentTagRegex.exec(line)) !== null) {
const componentName = match[1]!;
this.unresolvedReferences.push({
fromNodeId: componentNodeId,
referenceName: componentName,
referenceKind: 'references',
line: lineIdx + 1,
column: match.index + 1,
filePath: this.filePath,
language: 'astro',
});
}
}
}
}
Loading