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:
Colby Mchenry
2026-06-11 18:50:11 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 763ee9c825
commit 823ffd1c3d
15 changed files with 934 additions and 16 deletions
+195
View File
@@ -0,0 +1,195 @@
/**
* Astro Framework Resolver
*
* Handles Astro component references, the `Astro` global, `astro:*` virtual
* module imports, and Astro's `src/pages/` file-based routing.
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
/**
* Astro virtual module prefixes — framework-provided, not user code
*/
const ASTRO_VIRTUAL_MODULES = [
'astro:content',
'astro:assets',
'astro:actions',
'astro:env',
'astro:i18n',
'astro:middleware',
'astro:transitions',
'astro:components',
'astro:schema',
];
export const astroResolver: FrameworkResolver = {
name: 'astro',
detect(context: ResolutionContext): boolean {
// Check for astro in package.json
const packageJson = context.readFile('package.json');
if (packageJson) {
try {
const pkg = JSON.parse(packageJson);
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps.astro) {
return true;
}
} catch {
// Invalid JSON
}
}
// Check for .astro files in project
const allFiles = context.getAllFiles();
return allFiles.some((f) => f.endsWith('.astro'));
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: the `Astro` global (Astro.props, Astro.url, Astro.params, …)
// — runtime-provided in every component's frontmatter. Resolving it as
// framework-provided keeps it from name-matching a user symbol named Astro.
if (ref.referenceName === 'Astro' || ref.referenceName.startsWith('Astro.')) {
return {
original: ref,
targetNodeId: ref.fromNodeId,
confidence: 1.0,
resolvedBy: 'framework',
};
}
// Pattern 2: astro:* virtual module imports (astro:content, astro:assets, …)
if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('astro:')) {
if (ASTRO_VIRTUAL_MODULES.some((prefix) => ref.referenceName.startsWith(prefix))) {
return {
original: ref,
targetNodeId: ref.fromNodeId,
confidence: 1.0,
resolvedBy: 'framework',
};
}
}
// Pattern 3: Component references (PascalCase) — resolve to component
// nodes. Template tags arrive as `references`, frontmatter expression
// usages as `calls`.
if (
isPascalCase(ref.referenceName) &&
(ref.referenceKind === 'references' || ref.referenceKind === 'calls')
) {
const result = resolveComponent(ref.referenceName, ref.filePath, context);
if (result) {
return {
original: ref,
targetNodeId: result,
confidence: 0.8,
resolvedBy: 'framework',
};
}
}
return null;
},
extract(filePath: string, _content: string) {
const nodes: Node[] = [];
const now = Date.now();
// Normalize to forward slashes
const normalized = filePath.replace(/\\/g, '/');
// Astro file-based routing lives under src/pages/ — .astro files are
// pages, .ts/.js files are API endpoints. (.md/.mdx pages exist too but
// aren't indexed as source.) Underscore-prefixed segments are excluded
// from routing by Astro.
const pagesMatch = /(?:^|\/)src\/pages\//.exec(normalized);
if (pagesMatch && /\.(astro|ts|js|mjs)$/.test(normalized)) {
const afterPages = normalized.substring(pagesMatch.index + pagesMatch[0].length);
const base = afterPages.split('/').pop() || '';
// Underscore-prefixed segments are excluded from routing by Astro;
// a stray `*.config.*` in a pages dir is never a route.
if (
!afterPages.split('/').some((segment) => segment.startsWith('_')) &&
!/\.config\.[a-z]+$/.test(base)
) {
const routePath = filePathToAstroRoute(afterPages);
nodes.push({
id: `route:${filePath}:${routePath}:1`,
kind: 'route',
name: routePath,
qualifiedName: `${filePath}::route:${routePath}`,
filePath,
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
language: normalized.endsWith('.astro') ? 'astro' : 'typescript',
updatedAt: now,
});
}
}
return { nodes, references: [] };
},
};
/**
* Check if string is PascalCase
*/
function isPascalCase(str: string): boolean {
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
}
/**
* Resolve an Astro component reference using name-based lookup
*/
function resolveComponent(
name: string,
fromFile: string,
context: ResolutionContext
): string | null {
// Look for component nodes by name
const candidates = context.getNodesByName(name);
const components = candidates.filter((n) => n.kind === 'component');
if (components.length === 0) return null;
// Prefer same directory
const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
const sameDir = components.filter((n) => n.filePath.startsWith(fromDir));
if (sameDir.length > 0) return sameDir[0]!.id;
// No positional signal: only an UNAMBIGUOUS name may resolve — picking
// components[0] would choose an arbitrary same-named component in a
// multi-app monorepo (#764). Ambiguity falls through to the name-matcher,
// whose proximity scoring decides.
return components.length === 1 ? components[0]!.id : null;
}
/**
* Convert a path under src/pages/ to an Astro route path.
*
* blog/[slug].astro -> /blog/:slug
* blog/[...path].astro -> /blog/*path
* api/posts.ts -> /api/posts
* index.astro -> /
*/
function filePathToAstroRoute(afterPages: string): string {
// Remove the extension
const withoutExt = afterPages.replace(/\.(astro|ts|js|mjs)$/, '');
// index files map to their parent path (index -> /, blog/index -> /blog)
const withoutIndex = withoutExt.replace(/(^|\/)index$/, '$1').replace(/\/$/, '');
// Convert Astro param syntax
const route = '/' + withoutIndex
.replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest (catch-all)
.replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param
if (route === '/') return '/';
// Remove trailing slash
return route.replace(/\/$/, '');
}
+3
View File
@@ -13,6 +13,7 @@ import { nestjsResolver } from './nestjs';
import { reactResolver } from './react';
import { svelteResolver } from './svelte';
import { vueResolver } from './vue';
import { astroResolver } from './astro';
import { djangoResolver, flaskResolver, fastapiResolver } from './python';
import { railsResolver } from './ruby';
import { springResolver } from './java';
@@ -39,6 +40,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
reactResolver,
svelteResolver,
vueResolver,
astroResolver,
// Python
djangoResolver,
flaskResolver,
@@ -128,6 +130,7 @@ export { nestjsResolver } from './nestjs';
export { reactResolver } from './react';
export { svelteResolver } from './svelte';
export { vueResolver } from './vue';
export { astroResolver } from './astro';
export { djangoResolver, flaskResolver, fastapiResolver } from './python';
export { railsResolver } from './ruby';
export { springResolver } from './java';
+4 -2
View File
@@ -24,6 +24,7 @@ const EXTENSION_RESOLUTION: Record<string, string[]> = {
// `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629).
svelte: ['.ts', '.js', '.svelte', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.svelte'],
vue: ['.ts', '.js', '.vue', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.vue'],
astro: ['.ts', '.js', '.astro', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.astro'],
python: ['.py', '/__init__.py'],
go: ['.go'],
rust: ['.rs', '/mod.rs'],
@@ -582,9 +583,10 @@ export function extractImportMappings(
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
mappings.push(...extractJSImports(content));
} else if (language === 'svelte' || language === 'vue') {
} else if (language === 'svelte' || language === 'vue' || language === 'astro') {
// Svelte/Vue single-file components import via plain ES6 inside their
// `<script>` block. Without this, a `.svelte`/`.vue` consumer produces
// `<script>` block (Astro: the `---` frontmatter). Without this, a
// `.svelte`/`.vue`/`.astro` consumer produces
// zero import mappings, so `resolveViaImport` can't run and a barrel
// import (`import { Foo } from './lib'`) falls back to name-matching —
// which silently fails whenever the re-export alias differs from the