Răsfoiți Sursa

feat(impact): SvelteKit load→page synthesizer (+page.server.js → +page.svelte)

SvelteKit wires a +page.server.js / +page.js `load` (and form `actions`) to the
sibling +page.svelte's `data` BY FILE PATH — there is no static import — so
editing a loader showed no impact on the page it feeds and the page had no
server-side dependency. New svelteKitLoadEdges synthesizer links each page
component to the load/actions in its OWN route directory (same for +layout);
path-deterministic, so it never crosses routes (provenance: heuristic).

Direction page→load, so getImpactRadius(load) surfaces its page. Verified on
sveltejs/realworld: 19 sibling links, 0 cross-dir mislinks, false edges 0, node
count unchanged (edges only). Regression test (incl. a does-not-cross-routes
guard) fails without the synthesizer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 săptămâni în urmă
părinte
comite
3ea03e55ba
3 a modificat fișierele cu 99 adăugiri și 3 ștergeri
  1. 1 0
      CHANGELOG.md
  2. 43 0
      __tests__/extraction.test.ts
  3. 55 3
      src/resolution/callback-synthesizer.ts

+ 1 - 0
CHANGELOG.md

@@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
 - Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
 - Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
+- SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument* — `@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.

+ 43 - 0
__tests__/extraction.test.ts

@@ -4317,6 +4317,49 @@ describe('Rust module-path call resolution', () => {
   });
 });
 
+describe('SvelteKit load → page synthesizer', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links a +page.svelte to its OWN directory\'s +page.server.js load, not another route\'s', async () => {
+    // SvelteKit wires +page.server.js's `load` to +page.svelte's `data` BY FILE
+    // PATH — there is no static import — so editing a loader showed no impact on
+    // the page it feeds. The synthesizer links each page component to the `load`
+    // in its OWN directory (path-deterministic, so it never crosses routes).
+    const login = path.join(tempDir, 'src/routes/login');
+    const register = path.join(tempDir, 'src/routes/register');
+    fs.mkdirSync(login, { recursive: true });
+    fs.mkdirSync(register, { recursive: true });
+    fs.writeFileSync(path.join(login, '+page.svelte'), `<script>export let data;</script>\n<h1>Login {data.x}</h1>\n`);
+    fs.writeFileSync(path.join(login, '+page.server.js'), `export function load() { return { x: 1 }; }\n`);
+    fs.writeFileSync(path.join(register, '+page.svelte'), `<script>export let data;</script>\n<h1>Register</h1>\n`);
+    fs.writeFileSync(path.join(register, '+page.server.js'), `export function load() { return { y: 2 }; }\n`);
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const loginLoad = cg
+      .getNodesByKind('function')
+      .find((n) => n.name === 'load' && n.filePath.endsWith('login/+page.server.js'));
+    expect(loginLoad, 'login load fn').toBeDefined();
+    const impacted = [...cg.getImpactRadius(loginLoad!.id, 3).nodes.values()].map((n) => n.filePath ?? '');
+    // editing login's load surfaces login's page (the framework-wired data flow)…
+    expect(impacted.some((p) => p.endsWith('login/+page.svelte')), 'load links to its own page').toBe(true);
+    // …but never register's page (same-directory only).
+    expect(impacted.some((p) => p.endsWith('register/+page.svelte')), 'does not cross routes').toBe(false);
+  });
+});
+
 describe('Swift property-wrapper attribute type references', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 55 - 3
src/resolution/callback-synthesizer.ts

@@ -1450,11 +1450,61 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
   return edges;
 }
 
+/**
+ * SvelteKit file-convention data flow. A route directory's `+page.svelte` (a
+ * `component` node) receives its `data` from the sibling `+page.server.{ts,js}`
+ * / `+page.{ts,js}` `load` function and posts forms to its `actions` — wired by
+ * the framework BY FILE PATH, with no static import between them. So editing a
+ * `load` shows no impact on the page it feeds, and the page looks like it has no
+ * server-side dependency. Link the page component to its sibling loader's
+ * `load` / `actions` (same for `+layout`). The pairing is path-deterministic
+ * (same directory, matching `+page`/`+layout` prefix), so it's precise — but
+ * it's a framework-convention edge, so provenance stays `heuristic`.
+ *
+ * Direction: page → load, so `getImpactRadius(load)` surfaces the page (editing
+ * a loader's data shows the page it feeds) and the page's dependencies include
+ * its loader.
+ */
+function svelteKitLoadEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const allFiles = new Set(ctx.getAllFiles());
+  const HOOKS = new Set(['load', 'actions']);
+  const HOOK_KINDS = new Set(['function', 'method', 'constant', 'variable']);
+  for (const file of allFiles) {
+    const m = file.match(/(.*\/)(\+(?:page|layout))\.svelte$/);
+    if (!m) continue;
+    const dir = m[1]!;
+    const prefix = m[2]!;
+    const page = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
+    if (!page) continue;
+    for (const ext of ['.server.ts', '.server.js', '.ts', '.js']) {
+      const loaderFile = `${dir}${prefix}${ext}`;
+      if (!allFiles.has(loaderFile)) continue;
+      for (const hook of ctx.getNodesInFile(loaderFile)) {
+        if (!HOOK_KINDS.has(hook.kind) || !HOOKS.has(hook.name)) continue;
+        edges.push({
+          source: page.id,
+          target: hook.id,
+          kind: 'references',
+          line: page.startLine,
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'sveltekit-load',
+            via: hook.name,
+            registeredAt: `${loaderFile}:${hook.startLine ?? 0}`,
+          },
+        });
+      }
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
- * React re-render + JSX children + Vue templates + RN event channel +
- * Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the
- * count added. Never throws into indexing — callers wrap in try/catch.
+ * React re-render + JSX children + Vue templates + SvelteKit load + RN event
+ * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain).
+ * Returns the count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
   // Go implicit `implements` edges must be synthesized AND persisted first: the
@@ -1470,6 +1520,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const renderEdges = reactRenderEdges(queries, ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
   const vueEdges = vueTemplateEdges(ctx);
+  const svelteKitEdges = svelteKitLoadEdges(ctx);
   const flutterEdges = flutterBuildEdges(queries, ctx);
   const cppEdges = cppOverrideEdges(queries);
   const ifaceEdges = interfaceOverrideEdges(queries);
@@ -1491,6 +1542,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...renderEdges,
     ...jsxEdges,
     ...vueEdges,
+    ...svelteKitEdges,
     ...flutterEdges,
     ...cppEdges,
     ...ifaceEdges,