feat(extraction+resolution): Astro support — frontmatter/template extraction + src/pages routes (#768) (#815)
.astro files were not indexed at all, leaving a typical Astro site mostly
invisible to search/impact/explore. New AstroExtractor (Svelte/Vue SFC
pattern): component node per file, TS frontmatter + <script> blocks
delegated to the TypeScript extractor, template {fn(...)} calls (incl. the
multiline `{posts.map((post) => (` opening line), PascalCase component-tag
references. New astroResolver: Astro global + astro:* virtual modules as
framework-provided, component resolution with the #764 ambiguity rule,
src/pages/ file-based routes ([param]→:param, [...rest]→*rest, _-prefixed
and *.config.* excluded). SFC languages now preload the TS/JS grammars
their extractors delegate to (a pure-SFC file set previously had none
loaded). Also fixes a pre-existing Svelte/Vue script-block off-by-one that
reported every script symbol one line low.
Validated per the playbook: stalux (the issue's repro) 54/54 .astro files
indexed, getIconNode found at its exact line, 14/14 routes, 93.0% fair
cross-file coverage; AstroPaper 27/27 components, 13/13 routes (underscore
dirs correctly excluded), explore connects page→Card→Datetime through the
jsx-render synthesizer; node/edge counts stable across re-syncs.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
763ee9c825
commit
823ffd1c3d
@@ -5895,6 +5895,218 @@ const value = 42;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Astro Extraction', () => {
|
||||
it('should detect Astro files', () => {
|
||||
expect(detectLanguage('src/pages/index.astro')).toBe('astro');
|
||||
expect(detectLanguage('Layout.astro')).toBe('astro');
|
||||
expect(isLanguageSupported('astro')).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract component node from an .astro file', () => {
|
||||
const code = `---
|
||||
const title = 'Hello';
|
||||
---
|
||||
<h1>{title}</h1>
|
||||
`;
|
||||
const result = extractFromSource('Card.astro', code);
|
||||
|
||||
const componentNode = result.nodes.find((n) => n.kind === 'component');
|
||||
expect(componentNode).toBeDefined();
|
||||
expect(componentNode?.name).toBe('Card');
|
||||
expect(componentNode?.language).toBe('astro');
|
||||
expect(componentNode?.isExported).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract frontmatter symbols with correct line numbers (#768)', () => {
|
||||
const code = `---
|
||||
import { formatDate } from '../utils/format';
|
||||
|
||||
function getIconNode(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
<span>{title}</span>
|
||||
`;
|
||||
const result = extractFromSource('navs.astro', code);
|
||||
|
||||
// The #768 repro: a function defined in frontmatter must be found
|
||||
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getIconNode');
|
||||
expect(fn).toBeDefined();
|
||||
expect(fn?.language).toBe('astro');
|
||||
expect(fn?.startLine).toBe(4);
|
||||
|
||||
const imp = result.nodes.find((n) => n.kind === 'import');
|
||||
expect(imp).toBeDefined();
|
||||
expect(imp?.startLine).toBe(2);
|
||||
});
|
||||
|
||||
it('should extract exported getStaticPaths from frontmatter', () => {
|
||||
const code = `---
|
||||
export async function getStaticPaths() {
|
||||
return [];
|
||||
}
|
||||
const { slug } = Astro.params;
|
||||
---
|
||||
<p>{slug}</p>
|
||||
`;
|
||||
const result = extractFromSource('[slug].astro', code);
|
||||
|
||||
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'getStaticPaths');
|
||||
expect(fn).toBeDefined();
|
||||
expect(fn?.isExported).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract calls from template expressions', () => {
|
||||
const code = `---
|
||||
import { formatDate } from '../utils/format';
|
||||
const date = new Date();
|
||||
---
|
||||
<time>{formatDate(date)}</time>
|
||||
`;
|
||||
const result = extractFromSource('Stamp.astro', code);
|
||||
|
||||
const call = result.unresolvedReferences.find(
|
||||
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'formatDate' && ref.line === 5
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
it('should extract calls from a multiline expression opening line', () => {
|
||||
const code = `---
|
||||
const posts = [];
|
||||
---
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>{render(post)}</li>
|
||||
))}
|
||||
</ul>
|
||||
`;
|
||||
const result = extractFromSource('List.astro', code);
|
||||
|
||||
const mapCall = result.unresolvedReferences.find(
|
||||
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'posts.map'
|
||||
);
|
||||
expect(mapCall).toBeDefined();
|
||||
const innerCall = result.unresolvedReferences.find(
|
||||
(ref) => ref.referenceKind === 'calls' && ref.referenceName === 'render'
|
||||
);
|
||||
expect(innerCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should extract PascalCase component usages from the template', () => {
|
||||
const code = `---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import PostCard from '../components/PostCard.astro';
|
||||
---
|
||||
<Layout title="Home">
|
||||
<PostCard />
|
||||
<Fragment slot="head" />
|
||||
<div class="plain-html" />
|
||||
</Layout>
|
||||
`;
|
||||
const result = extractFromSource('index.astro', code);
|
||||
|
||||
const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references');
|
||||
const names = refs.map((r) => r.referenceName);
|
||||
expect(names).toContain('Layout');
|
||||
expect(names).toContain('PostCard');
|
||||
// Astro built-ins and lowercase HTML are not component references
|
||||
expect(names).not.toContain('Fragment');
|
||||
expect(names).not.toContain('div');
|
||||
});
|
||||
|
||||
it('should not extract template patterns from frontmatter, script, or style content', () => {
|
||||
const code = `---
|
||||
// <FakeComponent /> inside frontmatter comment
|
||||
const x = { y: maybeCall(1) };
|
||||
---
|
||||
<div>real</div>
|
||||
<script>
|
||||
const z = { w: scriptCall(2) };
|
||||
</script>
|
||||
<style>
|
||||
.a { color: red; }
|
||||
</style>
|
||||
`;
|
||||
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 <script> block symbols with correct line numbers', () => {
|
||||
const code = `---
|
||||
const a = 1;
|
||||
---
|
||||
<div>hi</div>
|
||||
<script>
|
||||
function trackView(page: string) {
|
||||
console.log(page);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
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 = `<div>Static content</div>
|
||||
`;
|
||||
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;
|
||||
<div>never closed</div>
|
||||
`;
|
||||
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;
|
||||
---
|
||||
<div>{value}</div>
|
||||
`;
|
||||
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 = `
|
||||
|
||||
@@ -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 <Route path element={<Comp/>}>', () => {
|
||||
@@ -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).
|
||||
|
||||
@@ -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<time>{formatDate(date)}</time>\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'src/pages/index.astro'),
|
||||
`---\nimport PostCard from '../components/PostCard.astro';\n---\n<PostCard date={new Date()} />\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
|
||||
|
||||
Reference in New Issue
Block a user