mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 01:07:18 +08:00
fix(nuxt, astro): a file-routed page is bound to its component (#2267)
A Nuxt pages/x.vue or Astro src/pages/x.astro route linked to nothing, so the viewer's Steps drew a page alone and the routes list never named what serves it. Each page route now references its file's own component (never a same-named one — every index.vue is a component named index), an Astro endpoint its exported verb handlers, and the routing manifest accepts a component as what serves a route, which also lists Vue Router screens. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- In Nuxt and Astro projects, a file-routed page is now linked to the component its file defines. An Astro endpoint is linked to the HTTP-verb handlers it exports, like `export const GET`. Before, a page route like Nuxt's `pages/admin.vue` or Astro's `src/pages/about.astro` linked to nothing, so callers, impact and flows stopped at the route.
|
||||
- In Rails apps, a `resources` line's `only:` and `except:` are now read in every form Rails accepts: `%i[new create index]`, `%w(index show)`, a quoted name, and the older `:only => [...]`. Before, only a bracketed list was read, so maybe's `resources :family_exports, only: %i[new create index]` became all seven routes, four of them to actions the controller doesn't have.
|
||||
- In Vue, Svelte and Astro files, a component now owns its script, and what a `<script setup>`, a Svelte instance script or Astro frontmatter does at its top level is the component's doing. That covers a Nuxt page's `useAsyncData(…)`, a Svelte page's `onMount(…)` and Astro's `await getCollection(…)`. Imports and a module-level script stay with the file. Before, those calls belonged to the file, so callers and flows from a component found nothing. The component was also recorded as containing its own file, and a file with no script had no file node at all.
|
||||
- In JavaScript and TypeScript, a CommonJS `require('./application')` now counts as an import of that file, the way an ESM `import` does. A module path also resolves the way Node and bundlers find it: extensions, `index` files and path aliases. Before, a CommonJS file depended on nothing at file level, so Express's own `lib/express.js` showed no imports and impact analysis missed the files it loads. An extensionless side-effect import like `import './polyfills'` also stopped at its own import statement instead of reaching the file.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* A file-routed page is served by the component its file is: Nuxt's
|
||||
* `pages/admin.vue`, Astro's `src/pages/about.astro`. The route stood alone,
|
||||
* so the viewer's Steps tab drew nothing for a page and the routes list never
|
||||
* named what serves it — it did not list a component-served route at all,
|
||||
* Vue Router's included. The link goes to the page file's OWN component,
|
||||
* never a same-named one (every `index.vue` is a component named `index`),
|
||||
* and an Astro endpoint is served by the verbs it exports.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { buildRoutes } from '../src/ui-server/api/routes';
|
||||
|
||||
const projects: Record<string, Record<string, string>> = {
|
||||
nuxt: {
|
||||
'package.json': JSON.stringify({ name: 'shop', private: true, dependencies: { nuxt: '^3.0.0' } }),
|
||||
'pages/index.vue': `<template><button @click="load">Load</button></template>
|
||||
<script setup lang="ts">
|
||||
function load() { return 1; }
|
||||
</script>
|
||||
`,
|
||||
'pages/admin.vue': `<template><div>admin</div></template>
|
||||
`,
|
||||
'pages/orders/index.vue': `<template><div>orders</div></template>
|
||||
`,
|
||||
'components/index.vue': `<template><div>not a page</div></template>
|
||||
`,
|
||||
},
|
||||
astro: {
|
||||
'package.json': JSON.stringify({ name: 'site', private: true, dependencies: { astro: '^4.0.0' } }),
|
||||
'src/pages/index.astro': `---
|
||||
const title = 'Home';
|
||||
---
|
||||
<h1>{title}</h1>
|
||||
`,
|
||||
'src/pages/about.astro': `<p>about</p>
|
||||
`,
|
||||
'src/pages/blog/index.astro': `<p>blog</p>
|
||||
`,
|
||||
'src/pages/api/hello.ts': `export const GET = async () => new Response('hi');
|
||||
export async function POST() { return new Response('ok'); }
|
||||
`,
|
||||
},
|
||||
};
|
||||
|
||||
const graphs: Record<string, { root: string; cg: CodeGraph }> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const [name, files] of Object.entries(projects)) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `cg-page-${name}-`));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
graphs[name] = { root, cg: await CodeGraph.init(root, { index: true }) };
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const { root, cg } of Object.values(graphs)) {
|
||||
cg.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** route name → the files of what it calls or references. */
|
||||
function served(cg: CodeGraph): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const route of cg.getNodesByKind('route')) {
|
||||
out[route.name] = cg.getOutgoingEdgesFrom([route.id], ['calls', 'references'])
|
||||
.map((e) => cg.getNode(e.target)!)
|
||||
.map((n) => `${n.kind} ${n.filePath}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('a Nuxt page', () => {
|
||||
it('is served by its own file’s component', () => {
|
||||
const map = served(graphs.nuxt!.cg);
|
||||
expect(map['/']).toEqual(['component pages/index.vue']);
|
||||
expect(map['/admin']).toEqual(['component pages/admin.vue']);
|
||||
expect(map['/orders']).toEqual(['component pages/orders/index.vue']);
|
||||
});
|
||||
|
||||
it('is listed with the component that serves it', () => {
|
||||
const { entries } = buildRoutes(graphs.nuxt!.cg, new URLSearchParams());
|
||||
expect(entries.map((e) => `${e.url} ${e.handler} ${e.file}`).sort()).toEqual([
|
||||
'/ index pages/index.vue',
|
||||
'/admin admin pages/admin.vue',
|
||||
'/orders index pages/orders/index.vue',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an Astro page', () => {
|
||||
it('is served by its own file’s component, and an endpoint by the verbs it exports', () => {
|
||||
const map = served(graphs.astro!.cg);
|
||||
expect(map['/']).toEqual(['component src/pages/index.astro']);
|
||||
expect(map['/about']).toEqual(['component src/pages/about.astro']);
|
||||
expect(map['/blog']).toEqual(['component src/pages/blog/index.astro']);
|
||||
expect(map['/api/hello']).toEqual(['function src/pages/api/hello.ts', 'function src/pages/api/hello.ts']);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
|
||||
|
||||
## Fixes — Screens, links and navigation
|
||||
|
||||
- **Routes served by a component are listed.** Entry points and the Steps picker listed a route only when a function, method or class served it. A Vue Router, Nuxt, Svelte or Astro screen is served by a component, so those apps' routes weren't listed at all. They are now, each with the component that serves it.
|
||||
|
||||
- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
|
||||
|
||||
- **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller.
|
||||
|
||||
+3
-2
@@ -1204,7 +1204,8 @@ export class QueryBuilder {
|
||||
if (!this.stmts.getRoutingManifest) {
|
||||
// Edge kind varies across framework resolvers: Spring/Rails/
|
||||
// Laravel/Drupal emit `references`, Express emits `calls`. Accept
|
||||
// both — the semantic is the same (route → its handler).
|
||||
// both — the semantic is the same (route → its handler). A screen in
|
||||
// a Vue / Svelte / Astro app is served by a `component`.
|
||||
this.stmts.getRoutingManifest = this.db.prepare(`
|
||||
SELECT
|
||||
r.name AS url,
|
||||
@@ -1220,7 +1221,7 @@ export class QueryBuilder {
|
||||
JOIN nodes h ON e.target = h.id
|
||||
WHERE r.kind = 'route'
|
||||
AND e.kind IN ('references', 'calls')
|
||||
AND h.kind IN ('function', 'method', 'class', 'constant', 'variable')
|
||||
AND h.kind IN ('function', 'method', 'class', 'constant', 'variable', 'component')
|
||||
ORDER BY r.file_path, r.start_line
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { pageComponentRef, resolvePageComponent } from './page-component';
|
||||
|
||||
/**
|
||||
* Astro virtual module prefixes — framework-provided, not user code
|
||||
@@ -47,6 +48,10 @@ export const astroResolver: FrameworkResolver = {
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
// A page route names the component its file is.
|
||||
const page = resolvePageComponent(ref, context);
|
||||
if (page) return page;
|
||||
|
||||
// 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.
|
||||
@@ -95,8 +100,9 @@ export const astroResolver: FrameworkResolver = {
|
||||
return null;
|
||||
},
|
||||
|
||||
extract(filePath: string, _content: string) {
|
||||
extract(filePath: string, content: string) {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Normalize to forward slashes
|
||||
@@ -118,8 +124,9 @@ export const astroResolver: FrameworkResolver = {
|
||||
!/\.config\.[a-z]+$/.test(base)
|
||||
) {
|
||||
const routePath = filePathToAstroRoute(afterPages);
|
||||
|
||||
nodes.push({
|
||||
const isPage = normalized.endsWith('.astro');
|
||||
const language = isPage ? 'astro' : 'typescript';
|
||||
const route: Node = {
|
||||
id: `route:${filePath}:${routePath}:1`,
|
||||
kind: 'route',
|
||||
name: routePath,
|
||||
@@ -129,16 +136,31 @@ export const astroResolver: FrameworkResolver = {
|
||||
endLine: 1,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language: normalized.endsWith('.astro') ? 'astro' : 'typescript',
|
||||
language,
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
nodes.push(route);
|
||||
if (isPage) {
|
||||
references.push(pageComponentRef(route, '.astro', 'astro'));
|
||||
} else {
|
||||
// An endpoint is served by the verbs it exports: `export const GET:
|
||||
// APIRoute = …`, `export async function POST(…)`.
|
||||
for (const m of content.matchAll(ENDPOINT_EXPORT)) {
|
||||
const verb = m[1] ?? m[2]!;
|
||||
const line = content.slice(0, m.index).split('\n').length;
|
||||
references.push({ fromNodeId: route.id, referenceName: verb, referenceKind: 'references', line, column: 0, filePath, language, candidates: [verb] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, references: [] };
|
||||
return { nodes, references };
|
||||
},
|
||||
};
|
||||
|
||||
/** An Astro endpoint's exported handler: `export const GET`, `export async function POST`. */
|
||||
const ENDPOINT_EXPORT = /^\s*export\s+(?:(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|ALL)\b|const\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|ALL)\b)/gm;
|
||||
|
||||
/**
|
||||
* Check if string is PascalCase
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* A file-routed page — Nuxt's `pages/admin.vue`, Astro's `src/pages/about.astro`
|
||||
* — is served by the component its file IS: the single-file-component
|
||||
* extractor's one component per file, named after the file. Without the link a
|
||||
* page route stood alone, so the viewer's Steps tab drew nothing for a page and
|
||||
* the routes list never named what serves it.
|
||||
*
|
||||
* The route says so with a `calls` reference by that name, the way a Next.js
|
||||
* page names its default export, and {@link resolvePageComponent} lands it on
|
||||
* the SAME file's component — never on a same-named one elsewhere, since every
|
||||
* `index.vue` is a component named `index`.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||
|
||||
/** The component name a single-file-component extractor gives a file: its basename, extension off. */
|
||||
export function pageComponentName(filePath: string, extension: string): string {
|
||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||
return base.endsWith(extension) ? base.slice(0, -extension.length) : base;
|
||||
}
|
||||
|
||||
export function pageComponentRef(route: Node, extension: string, language: Language): UnresolvedRef {
|
||||
const name = pageComponentName(route.filePath, extension);
|
||||
return {
|
||||
fromNodeId: route.id,
|
||||
referenceName: name,
|
||||
referenceKind: 'calls',
|
||||
line: 1,
|
||||
column: 0,
|
||||
filePath: route.filePath,
|
||||
language,
|
||||
candidates: [name],
|
||||
};
|
||||
}
|
||||
|
||||
/** A page route's reference to its file's own component, or null when this is not one. */
|
||||
export function resolvePageComponent(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls' || !ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) return null;
|
||||
const component = context
|
||||
.getNodesInFile(ref.filePath)
|
||||
.find((n) => n.kind === 'component' && n.name === ref.referenceName);
|
||||
return component ? { original: ref, targetNodeId: component.id, confidence: 0.95, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import { pageComponentRef, resolvePageComponent } from './page-component';
|
||||
|
||||
/** The languages a Vue app's scripts are written in. */
|
||||
const VUE_SCRIPT_LANGUAGES: ReadonlySet<string> = new Set(['vue', 'javascript', 'typescript', 'tsx', 'jsx']);
|
||||
@@ -212,12 +213,14 @@ export const nuxtResolver: FrameworkResolver = {
|
||||
return context.getAllFiles().some((f) => /(?:^|\/)nuxt\.config\.(?:[cm]?[jt]s)$/.test(f));
|
||||
},
|
||||
|
||||
resolve(): ResolvedRef | null {
|
||||
return null;
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
// A page route names the component its file is.
|
||||
return resolvePageComponent(ref, context);
|
||||
},
|
||||
|
||||
extract(filePath: string, _content: string) {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Forward slashes, and a leading `/` so an app at the repository root
|
||||
@@ -229,7 +232,7 @@ export const nuxtResolver: FrameworkResolver = {
|
||||
if (pagesIndex !== -1 && normalized.endsWith('.vue')) {
|
||||
const routePath = filePathToNuxtRoute(normalized, pagesIndex + '/pages/'.length);
|
||||
if (routePath !== null) {
|
||||
nodes.push({
|
||||
const route: Node = {
|
||||
id: `route:${filePath}:${routePath}:1`,
|
||||
kind: 'route',
|
||||
name: routePath,
|
||||
@@ -241,7 +244,9 @@ export const nuxtResolver: FrameworkResolver = {
|
||||
endColumn: 0,
|
||||
language: 'vue',
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
nodes.push(route);
|
||||
references.push(pageComponentRef(route, '.vue', 'vue'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +297,7 @@ export const nuxtResolver: FrameworkResolver = {
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, references: [] };
|
||||
return { nodes, references };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user