Skip to content

feat(#72): SPI v1 slice 1c-v.a — React Router substrate#113

Merged
mohanagy merged 1 commit into
mainfrom
feat/spi-v1-slice-1c-v.a-react-router
May 11, 2026
Merged

feat(#72): SPI v1 slice 1c-v.a — React Router substrate#113
mohanagy merged 1 commit into
mainfrom
feat/spi-v1-slice-1c-v.a-react-router

Conversation

@mohanagy
Copy link
Copy Markdown
Owner

@mohanagy mohanagy commented May 11, 2026

First React Router slice — substrate-level detection for the v6.4+ data-router patterns.

Patterns detected

Two structural patterns:

  1. Router factories — `createBrowserRouter` / `createHashRouter` / `createMemoryRouter` / `createStaticRouter`. The factory call's receiving variable is tagged with `framework_role: 'react_router_router'`.

  2. Route-module convention — when a file imports from `'react-router'` or `'react-router-dom'` AND exports a named function or const called exactly `loader` or `action`, those exports are tagged with `framework_role: 'react_router_loader'` / `'react_router_action'`.

Substrate

Three new `SpiFrameworkRole` variants:

  • `react_router_router`
  • `react_router_loader`
  • `react_router_action`

Projector wiring

Role Framework Node kind
`react_router_router` `react-router` `router`
`react_router_loader` `react-router` `function`
`react_router_action` `react-router` `function`

Out of scope (deferred)

  • JSX route definitions `<Route path="/x" element={} />` — slice 1c-v.b. Requires JSX walking and is structurally different from the import-call pattern.
  • Hook detection `useNavigate`, `useLoaderData`, etc. — structurally non-essential; consumers can filter on `framework_role` plus the file having a react-router import.

Test plan

  • `npm run typecheck` — clean
  • `npm run build` — clean
  • `npm run test:run` — 96 files / 1671 tests pass (12 new for React Router + 1659 pre-existing)
  • Tests cover: all four router factories, aliased factory imports, `react-router` (not -dom) module support, function-form vs variable-form loader/action exports, negative case (loader/action in non-react-router file not tagged), exact-name discipline (`loaderHelper` / `myAction` not matched), end-to-end projection through to the ExtractionNode shape.
  • CI must pass on Ubuntu/macOS/Windows matrix before merge.

Refs #72.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added React Router framework detection and analysis, enabling the system to identify and understand router configurations and route handlers (loaders and actions) in React Router applications.

Review Change Stack

Adds React Router (v6.4+ data-router idiom) substrate detection. Two structural patterns:

1. Router factories — createBrowserRouter / createHashRouter / createMemoryRouter / createStaticRouter. The factory call's receiving variable is tagged with framework_role: 'react_router_router'.

2. Route-module convention — when a file imports from 'react-router' or 'react-router-dom' AND exports a named function or const called exactly 'loader' or 'action', those exports are tagged with framework_role: 'react_router_loader' / 'react_router_action'.

Substrate: three new SpiFrameworkRole variants (react_router_router, react_router_loader, react_router_action).

Detector (src/pipeline/spi/framework-react-router.ts): Collects imports from react-router(-dom) — tracks aliased factory names and a hasReactRouterImport flag. Walks top-level VariableStatements for factory-call initializers; walks top-level FunctionDeclarations and VariableStatements for loader/action named exports. Skips loader/action exports in files that do not import react-router(-dom).

Projector wiring: frameworkForRole maps react_router_* prefix → 'react-router'. nodeKindForRole: react_router_router → 'router', loader/action → 'function'.

Tests: 12 new in spi-framework-react-router.test.ts. Cover: all four router factories, aliased factory imports, react-router (not -dom) module, function-form vs variable-form loader/action exports, negative case (loader/action in non-react-router file not tagged), exact-name match (loaderHelper / myAction skipped), end-to-end projection through to ExtractionNode.

Out of scope (deferred):

* JSX route definitions: <Routes><Route path='/x' element={<X />} /></Routes>. Slice 1c-v.b.

* Hook detection (useNavigate, useLoaderData, useActionData) — structurally non-essential; consumers can filter on framework_role.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

This PR adds React Router framework detection to the SPI v1 type-checker pass. It defines new framework roles, detects router factories and route-module exports in source files, integrates detection into the per-file build loop, maps detected roles to projection output, and provides comprehensive test validation of both detection and projector propagation.

Changes

React Router Framework Detection

Layer / File(s) Summary
Type System
src/pipeline/spi/types.ts
SpiFrameworkRole extended with react_router_router, react_router_loader, and react_router_action union members.
Detection Implementation
src/pipeline/spi/framework-react-router.ts
New module implementing detectReactRouterFramework that walks AST to identify router factory variable assignments and route-module exports (loader/action) from react-router(-dom) imports, tagging matching symbols with framework roles via helper functions for binding collection, factory call recognition, export modifiers, and symbol role assignment.
Type-Checker Pass Integration
src/pipeline/spi/build.ts
Imports detectReactRouterFramework and invokes it during the per-file loop in addTypeCheckerEdges, passing sourceFile, fileId, and symbolsByFile to emit React Router framework edges.
Projector Role Mapping
src/pipeline/spi/projector.ts
projectSpiToExtraction maps React Router framework roles to framework identifier and node kinds: react_router_* roles → react-router framework; react_router_routerrouter node kind; react_router_loader/react_router_actionfunction node kind.
Public API
src/pipeline/spi/index.ts
Re-exports detectReactRouterFramework and DetectReactRouterFrameworkContext type from framework-react-router module.
Tests
tests/unit/spi-framework-react-router.test.ts
Comprehensive test suite covering router factory detection variants (including aliased imports), route-module export detection (function and variable forms), negative cases (unrelated modules, missing imports, incorrect export names), and projector propagation assertions.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • mohanagy/graphify-ts#112: Both PRs add a new SPI framework detector module with parallel wiring into build.ts, index.ts re-exports, types.ts role extensions, projector mappings, and unit test coverage.
  • mohanagy/graphify-ts#107: Both PRs add new SPI framework detectors (React Router vs. Express) following the same architectural pattern of detection, integration, projection, and test validation.
  • mohanagy/graphify-ts#91: Extends the same addTypeCheckerEdges/type-checker pass in src/pipeline/spi/build.ts that this PR invokes the React Router detector within.

🐰 Hopping through the AST, we found routers so true,
Loaders and actions in shades of React blue,
Factories detected, roles clearly defined,
From react-router's imports, semantic insights aligned! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding SPI v1 slice 1c-v.a for React Router substrate detection, which directly corresponds to the changeset's primary objective.
Description check ✅ Passed The description is comprehensive and detailed, covering patterns detected, substrate changes, projector wiring, test results, and out-of-scope items. However, it does not include explicit commands or confirmation checkboxes for the required testing steps (typecheck, build, test:run, npm pack).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spi-v1-slice-1c-v.a-react-router

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/pipeline/spi/framework-react-router.ts`:
- Around line 101-108: The current import scan only sets
bindings.hasReactRouterImport when stmt.importClause exists, so side-effect
imports like import "react-router-dom" are ignored; change the logic in the
ts.isImportDeclaration branch that checks REACT_ROUTER_MODULE_SPECIFIERS so that
if stmt is an import declaration and stmt.moduleSpecifier matches
REACT_ROUTER_MODULE_SPECIFIERS you immediately set bindings.hasReactRouterImport
= true (even if stmt.importClause is null), and only after that check
stmt.importClause and named bindings (ts.isNamedImports) to iterate
named.elements for loader/action detection; keep the existing checks
(ts.isImportDeclaration, ts.isStringLiteral, REACT_ROUTER_MODULE_SPECIFIERS,
named/ts.isNamedImports, named.elements) but reorder so side-effect imports are
counted.
- Around line 78-81: The code currently tags any exported function declaration
by checking ts.isFunctionDeclaration(stmt) && stmt.name &&
hasExportModifier(stmt), but this also catches default exports like `export
default function loader()` which should be excluded; update the condition to
ensure the function is not a default export (e.g., require
!hasDefaultModifier(stmt) or equivalent) before consulting
ROUTE_MODULE_EXPORT_NAMES and calling tagSymbolByName(ctx, stmt.name.text, role)
so only named exports (export function ...) are tagged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c2f5234f-46cf-4dbc-bd47-9e0df5ebcef7

📥 Commits

Reviewing files that changed from the base of the PR and between 9621f4f and 66f7ed9.

📒 Files selected for processing (6)
  • src/pipeline/spi/build.ts
  • src/pipeline/spi/framework-react-router.ts
  • src/pipeline/spi/index.ts
  • src/pipeline/spi/projector.ts
  • src/pipeline/spi/types.ts
  • tests/unit/spi-framework-react-router.test.ts

Comment on lines +78 to +81
if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
const role = ROUTE_MODULE_EXPORT_NAMES.get(stmt.name.text)
if (role) tagSymbolByName(ctx, stmt.name.text, role)
continue
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the specific file
find . -name "framework-react-router.ts" -type f

Repository: mohanagy/graphify-ts

Length of output: 109


🏁 Script executed:

# Check the file size and read the relevant section
wc -l src/pipeline/spi/framework-react-router.ts

Repository: mohanagy/graphify-ts

Length of output: 111


🏁 Script executed:

# Read lines around 78-81 with context
sed -n '60,100p' src/pipeline/spi/framework-react-router.ts

Repository: mohanagy/graphify-ts

Length of output: 1693


🏁 Script executed:

# Look for hasExportModifier and related functions
rg "hasExportModifier|hasDefaultModifier" src/pipeline/spi/framework-react-router.ts

Repository: mohanagy/graphify-ts

Length of output: 266


🏁 Script executed:

# Check what ROUTE_MODULE_EXPORT_NAMES contains
rg "ROUTE_MODULE_EXPORT_NAMES" src/pipeline/spi/framework-react-router.ts -B 5 -A 5

Repository: mohanagy/graphify-ts

Length of output: 1127


🏁 Script executed:

# Let's read the full hasExportModifier function implementation
sed -n '120,147p' src/pipeline/spi/framework-react-router.ts

Repository: mohanagy/graphify-ts

Length of output: 832


🏁 Script executed:

# Check if there are any tests or examples with default exports
rg "export default" src/pipeline/spi/framework-react-router.ts -B 3 -A 3

Repository: mohanagy/graphify-ts

Length of output: 46


Exclude default exports from loader/action named-export tagging.

The current code tags all function declarations with an export modifier, but export default function loader() is a default export, not a named loader export. Only named exports (e.g., export function loader()) should be tagged per React Router conventions.

Add a check to exclude default exports:

Suggested fix
-    if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
+    if (
+      ts.isFunctionDeclaration(stmt) &&
+      stmt.name &&
+      hasExportModifier(stmt) &&
+      !hasDefaultModifier(stmt)
+    ) {
       const role = ROUTE_MODULE_EXPORT_NAMES.get(stmt.name.text)
       if (role) tagSymbolByName(ctx, stmt.name.text, role)
       continue
     }
 function hasExportModifier(node: ts.Node): boolean {
   const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
   if (!modifiers) return false
   for (const mod of modifiers) {
     if (mod.kind === ts.SyntaxKind.ExportKeyword) return true
   }
   return false
 }
+
+function hasDefaultModifier(node: ts.Node): boolean {
+  const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
+  if (!modifiers) return false
+  for (const mod of modifiers) {
+    if (mod.kind === ts.SyntaxKind.DefaultKeyword) return true
+  }
+  return false
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
const role = ROUTE_MODULE_EXPORT_NAMES.get(stmt.name.text)
if (role) tagSymbolByName(ctx, stmt.name.text, role)
continue
if (
ts.isFunctionDeclaration(stmt) &&
stmt.name &&
hasExportModifier(stmt) &&
!hasDefaultModifier(stmt)
) {
const role = ROUTE_MODULE_EXPORT_NAMES.get(stmt.name.text)
if (role) tagSymbolByName(ctx, stmt.name.text, role)
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pipeline/spi/framework-react-router.ts` around lines 78 - 81, The code
currently tags any exported function declaration by checking
ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt), but this
also catches default exports like `export default function loader()` which
should be excluded; update the condition to ensure the function is not a default
export (e.g., require !hasDefaultModifier(stmt) or equivalent) before consulting
ROUTE_MODULE_EXPORT_NAMES and calling tagSymbolByName(ctx, stmt.name.text, role)
so only named exports (export function ...) are tagged.

Comment on lines +101 to +108
if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
if (!REACT_ROUTER_MODULE_SPECIFIERS.has(stmt.moduleSpecifier.text)) continue

bindings.hasReactRouterImport = true
const named = stmt.importClause.namedBindings
if (!named || !ts.isNamedImports(named)) continue
for (const element of named.elements) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "framework-react-router.ts" | head -5

Repository: mohanagy/graphify-ts

Length of output: 109


🏁 Script executed:

cd . && cat -n src/pipeline/spi/framework-react-router.ts | head -130

Repository: mohanagy/graphify-ts

Length of output: 5923


🏁 Script executed:

python3 << 'EOF'
# Quick verification: TypeScript side-effect imports
# In TypeScript, a side-effect import like: import "react-router-dom"
# Has: { importClause: undefined }
# A normal import like: import { Router } from "react-router-dom"
# Has: { importClause: { namedBindings: { elements: [...] } } }

print("TypeScript import statement structure:")
print("1. import 'module' => importClause is undefined")
print("2. import {} from 'module' => importClause.namedBindings exists")
print("")
print("Therefore, checking '!stmt.importClause' would skip side-effect imports.")
EOF

Repository: mohanagy/graphify-ts

Length of output: 289


Count side-effect imports as React Router imports.

At Line 101, the guard !stmt.importClause prevents side-effect imports (import "react-router-dom") from setting hasReactRouterImport, causing files with side-effect imports and named loader/action exports to not be tagged as route modules.

Suggested fix
 function collectBindings(sourceFile: ts.SourceFile): ReactRouterBindings {
   const bindings: ReactRouterBindings = {
     routerFactories: new Set<string>(),
     hasReactRouterImport: false,
   }
   for (const stmt of sourceFile.statements) {
-    if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue
+    if (!ts.isImportDeclaration(stmt)) continue
     if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
     if (!REACT_ROUTER_MODULE_SPECIFIERS.has(stmt.moduleSpecifier.text)) continue

     bindings.hasReactRouterImport = true
-    const named = stmt.importClause.namedBindings
+    const named = stmt.importClause?.namedBindings
     if (!named || !ts.isNamedImports(named)) continue
     for (const element of named.elements) {
       const importedName = element.propertyName?.text ?? element.name.text
       if (ROUTER_FACTORY_NAMES.has(importedName)) {
         bindings.routerFactories.add(element.name.text)
       }
     }
   }
   return bindings
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
if (!REACT_ROUTER_MODULE_SPECIFIERS.has(stmt.moduleSpecifier.text)) continue
bindings.hasReactRouterImport = true
const named = stmt.importClause.namedBindings
if (!named || !ts.isNamedImports(named)) continue
for (const element of named.elements) {
function collectBindings(sourceFile: ts.SourceFile): ReactRouterBindings {
const bindings: ReactRouterBindings = {
routerFactories: new Set<string>(),
hasReactRouterImport: false,
}
for (const stmt of sourceFile.statements) {
if (!ts.isImportDeclaration(stmt)) continue
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue
if (!REACT_ROUTER_MODULE_SPECIFIERS.has(stmt.moduleSpecifier.text)) continue
bindings.hasReactRouterImport = true
const named = stmt.importClause?.namedBindings
if (!named || !ts.isNamedImports(named)) continue
for (const element of named.elements) {
const importedName = element.propertyName?.text ?? element.name.text
if (ROUTER_FACTORY_NAMES.has(importedName)) {
bindings.routerFactories.add(element.name.text)
}
}
}
return bindings
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pipeline/spi/framework-react-router.ts` around lines 101 - 108, The
current import scan only sets bindings.hasReactRouterImport when
stmt.importClause exists, so side-effect imports like import "react-router-dom"
are ignored; change the logic in the ts.isImportDeclaration branch that checks
REACT_ROUTER_MODULE_SPECIFIERS so that if stmt is an import declaration and
stmt.moduleSpecifier matches REACT_ROUTER_MODULE_SPECIFIERS you immediately set
bindings.hasReactRouterImport = true (even if stmt.importClause is null), and
only after that check stmt.importClause and named bindings (ts.isNamedImports)
to iterate named.elements for loader/action detection; keep the existing checks
(ts.isImportDeclaration, ts.isStringLiteral, REACT_ROUTER_MODULE_SPECIFIERS,
named/ts.isNamedImports, named.elements) but reorder so side-effect imports are
counted.

@mohanagy mohanagy merged commit 6c718fc into main May 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant