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