diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74e63e7..570b175 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
+- **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side `
+
+`;
+ const result = extractFromSource('Guard.astro', code);
+
+ const templateRefs = result.unresolvedReferences.filter(
+ (r) => r.referenceKind === 'references' && r.referenceName === 'FakeComponent'
+ );
+ expect(templateRefs).toHaveLength(0);
+
+ // maybeCall/scriptCall come from the delegated TS extraction (once),
+ // not double-counted by the template scanner
+ const maybeCalls = result.unresolvedReferences.filter(
+ (r) => r.referenceName === 'maybeCall' && r.referenceKind === 'calls'
+ );
+ expect(maybeCalls.length).toBeLessThanOrEqual(1);
+ });
+
+ it('should extract
+`;
+ const result = extractFromSource('Tracker.astro', code);
+
+ const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'trackView');
+ expect(fn).toBeDefined();
+ expect(fn?.startLine).toBe(6);
+ expect(fn?.language).toBe('astro');
+ });
+
+ it('should create component node for a frontmatter-less template-only file', () => {
+ const code = `
Static content
+`;
+ const result = extractFromSource('Static.astro', code);
+
+ const componentNode = result.nodes.find((n) => n.kind === 'component');
+ expect(componentNode).toBeDefined();
+ expect(componentNode?.name).toBe('Static');
+ expect(componentNode?.language).toBe('astro');
+ });
+
+ it('should treat an unclosed frontmatter fence as no frontmatter', () => {
+ const code = `---
+const broken = true;
+never closed
+`;
+ const result = extractFromSource('Broken.astro', code);
+
+ // No TS delegation happened (the fence never closes), but the component
+ // node still exists and nothing throws.
+ const componentNode = result.nodes.find((n) => n.kind === 'component');
+ expect(componentNode).toBeDefined();
+ expect(result.nodes.find((n) => n.name === 'broken')).toBeUndefined();
+ });
+
+ it('should create containment edges from component to frontmatter nodes', () => {
+ const code = `---
+const value = 42;
+---
+{value}
+`;
+ const result = extractFromSource('Contained.astro', code);
+
+ const componentNode = result.nodes.find((n) => n.kind === 'component');
+ expect(componentNode).toBeDefined();
+
+ const containEdges = result.edges.filter(
+ (e) => e.source === componentNode!.id && e.kind === 'contains'
+ );
+ expect(containEdges.length).toBeGreaterThan(0);
+ });
+});
+
describe('Instantiates + Decorates edge extraction', () => {
it('emits an instantiates ref for `new Foo()`', () => {
const code = `
diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts
index c0e8749..ff1abb5 100644
--- a/__tests__/frameworks.test.ts
+++ b/__tests__/frameworks.test.ts
@@ -1373,6 +1373,7 @@ func boot(routes: RoutesBuilder) throws {
import { reactResolver } from '../src/resolution/frameworks/react';
import { svelteResolver } from '../src/resolution/frameworks/svelte';
+import { astroResolver } from '../src/resolution/frameworks/astro';
describe('reactResolver.extract — React Router', () => {
it('extracts a v6 }>', () => {
@@ -1428,6 +1429,77 @@ describe('svelteResolver.extract (smoke)', () => {
});
});
+describe('astroResolver.extract — src/pages file-based routing', () => {
+ const routeNames = (filePath: string): string[] =>
+ astroResolver.extract!(filePath, '').nodes.filter((n) => n.kind === 'route').map((n) => n.name);
+
+ it('maps index.astro to /', () => {
+ expect(routeNames('src/pages/index.astro')).toEqual(['/']);
+ });
+
+ it('maps nested index and plain pages', () => {
+ expect(routeNames('src/pages/blog/index.astro')).toEqual(['/blog']);
+ expect(routeNames('src/pages/about.astro')).toEqual(['/about']);
+ });
+
+ it('converts [param] and [...rest] syntax', () => {
+ expect(routeNames('src/pages/blog/[slug].astro')).toEqual(['/blog/:slug']);
+ expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']);
+ });
+
+ it('maps .ts endpoints under src/pages to routes', () => {
+ expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']);
+ expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']);
+ });
+
+ it('excludes underscore-prefixed segments and config files', () => {
+ expect(routeNames('src/pages/_partial.astro')).toEqual([]);
+ expect(routeNames('src/pages/blog/_components/Card.astro')).toEqual([]);
+ expect(routeNames('src/pages/vite.config.ts')).toEqual([]);
+ });
+
+ it('ignores .astro files outside src/pages', () => {
+ expect(routeNames('src/components/Button.astro')).toEqual([]);
+ expect(routeNames('docs/pages/guide.astro')).toEqual([]);
+ });
+});
+
+describe('astroResolver.resolve — Astro global and virtual modules', () => {
+ const ctx = {} as never;
+ const baseRef = {
+ fromNodeId: 'component:a',
+ line: 1,
+ column: 0,
+ filePath: 'src/pages/index.astro',
+ language: 'astro',
+ };
+
+ it('claims Astro.* global references as framework-provided', () => {
+ const res = astroResolver.resolve(
+ { ...baseRef, referenceName: 'Astro.props', referenceKind: 'references' } as never,
+ ctx
+ );
+ expect(res?.resolvedBy).toBe('framework');
+ expect(res?.confidence).toBe(1.0);
+ });
+
+ it('claims astro:content virtual module imports', () => {
+ const res = astroResolver.resolve(
+ { ...baseRef, referenceName: 'astro:content', referenceKind: 'imports' } as never,
+ ctx
+ );
+ expect(res?.resolvedBy).toBe('framework');
+ });
+
+ it('leaves ordinary names alone', () => {
+ const res = astroResolver.resolve(
+ { ...baseRef, referenceName: 'astrolabe', referenceKind: 'calls' } as never,
+ { getNodesByName: () => [] } as never
+ );
+ expect(res).toBeNull();
+ });
+});
+
// Regression tests: commented-out and docstring route examples must NOT
// surface as phantom route nodes. These would have failed before the
// strip-comments wiring (the regex would happily scan comments/docstrings).
diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts
index 47c6b92..3059392 100644
--- a/__tests__/resolution.test.ts
+++ b/__tests__/resolution.test.ts
@@ -1438,6 +1438,47 @@ func main() {
expect(callers.some((c) => c.node.filePath === 'src/Bar.svelte')).toBe(true);
});
+ it('links an .astro page to the component and TS util it uses (#768)', async () => {
+ // The canonical Astro shape: a page imports a layout/component in
+ // frontmatter and uses it as a template tag; the component's template
+ // calls an imported .ts util. Both hops must produce graph edges or
+ // an Astro project is invisible to callers/impact.
+ fs.mkdirSync(path.join(tempDir, 'src/components'), { recursive: true });
+ fs.mkdirSync(path.join(tempDir, 'src/utils'), { recursive: true });
+ fs.mkdirSync(path.join(tempDir, 'src/pages'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tempDir, 'src/utils/format.ts'),
+ `export function formatDate(d: Date): string { return d.toISOString(); }\n`
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'src/components/PostCard.astro'),
+ `---\nimport { formatDate } from '../utils/format';\nconst { date } = Astro.props;\n---\n\n`
+ );
+ fs.writeFileSync(
+ path.join(tempDir, 'src/pages/index.astro'),
+ `---\nimport PostCard from '../components/PostCard.astro';\n---\n\n`
+ );
+
+ cg = await CodeGraph.init(tempDir, { index: true });
+ cg.resolveReferences();
+
+ // Hop 1: page → component (template tag through the frontmatter import)
+ const cardNode = cg
+ .getNodesByKind('component')
+ .find((n) => n.name === 'PostCard' && n.filePath === 'src/components/PostCard.astro');
+ expect(cardNode).toBeDefined();
+ const cardCallers = cg.getCallers(cardNode!.id);
+ expect(cardCallers.some((c) => c.node.filePath === 'src/pages/index.astro')).toBe(true);
+
+ // Hop 2: component template call → .ts util
+ const fmtNode = cg
+ .getNodesByKind('function')
+ .find((n) => n.name === 'formatDate' && n.filePath === 'src/utils/format.ts');
+ expect(fmtNode).toBeDefined();
+ const fmtCallers = cg.getCallers(fmtNode!.id);
+ expect(fmtCallers.some((c) => c.node.filePath === 'src/components/PostCard.astro')).toBe(true);
+ });
+
it('resolves a bare directory import (import { x } from "." / "./") to index.ts (#629)', async () => {
// `import { helper } from '.'` (or './') must map to the
// directory's index.ts before the re-export chase can run. The
diff --git a/src/extraction/astro-extractor.ts b/src/extraction/astro-extractor.ts
new file mode 100644
index 0000000..e389893
--- /dev/null
+++ b/src/extraction/astro-extractor.ts
@@ -0,0 +1,365 @@
+import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types';
+import { generateNodeId } from './tree-sitter-helpers';
+import { TreeSitterExtractor } from './tree-sitter';
+import { isLanguageSupported } from './grammars';
+
+/**
+ * Astro built-in components — compiler-provided (``) or shipped by
+ * `astro:components` (``, ``), not user code.
+ */
+const ASTRO_BUILTIN_COMPONENTS = new Set(['Fragment', 'Code', 'Debug']);
+
+/**
+ * AstroExtractor - Extracts code relationships from Astro component files
+ *
+ * Astro files are multi-language: a TypeScript frontmatter block fenced by
+ * `---` lines, a JSX-like HTML template, and optional