feat(frameworks): CodeIgniter 3 routes, and calls through what $this->load loads (#3112)

A CodeIgniter 3 project had no routes in the graph and its flows stopped
at the controller: `$this->users_model->find($id)` goes through a property
nothing declares, so most such calls failed and the rest were guesses by
method name.

The resolver is detected by system/core/CodeIgniter.php, or by a routes
file naming a default controller, at any depth. It makes:
- a route per public method of a controller, implicit-public ones too,
  at /<folder>/<class>/<method>, and HMVC modules with the module first;
- a route per $route entry in routes.php, verb-scoped or not, and the
  default controller at /. `controller/method/$1` walks to the controller
  as CodeIgniter does, and a variable in the method's place binds the
  class;
- a `calls` edge for `$this->x->m()`, `$this->CI->x->m()` and
  `$CI->x->m()` to the method on the class x holds, its own or inherited.
  A class is read by name whatever the case, as PHP and the loader read
  it, and an alias comes from the load that gives it;
- a `calls` edge from `$this->load->view('a/b')` to the view's file, where
  the calls a template makes are recorded, so a request can be followed
  to the end.

Differences from PR #167, each from the real apps: its controller test
is a list of base classes, which misses EA_Controller, Secure_Controller
and every other project's own; its property match is PascalCase only,
which misses `$this->providers_model` and `$this->mdl_invoices`, the form
two of the four apps use throughout; and it stopped at a file-level
`imports` edge to the class, so the flow still ended at the controller.
A reference it made to a method by name could land on a same-named method
in another controller, so a route's reference here names its own file.

Validation:
- codeigniter.test.ts, 21 tests, on the kernel and the wasm path; the 10
  on a real index fail with the resolver unregistered.
- A/B on main cd04b21a, real CI3 apps: alextselegidis/easyappointments
  f6c8a4c2 +217 routes, +1,229 edges; InvoicePlane e5e2529d (HMVC) +213,
  +1,088; opensourcepos 3.3.9 c00ff28d +315, +1,428; ci-bonfire/Bonfire
  3eaf6f88 +67, +515. Classes: `+references framework` route to handler,
  `+calls framework` property call, and 2 `+calls test-request`. Nothing
  removed. Those 2 come from an existing pass: a vendored SimpleTest file
  calls `$this->get('http://www.lastcraft.com/')`, read as `/`, which now
  exists.
- Every route binds: 217/217, 213/213, 315/315, 67/67.
- Site by site, every framework edge read against its source line and the
  class the property holds: 1,012, 875, 1,113 and 446 edges, none wrong.
- A text scan of `->x->m(` where x names a project class: 967 of 967
  linked, 855 of 961, 886 of 891, 379 of 420. What's left is methods the
  class doesn't declare: InvoicePlane's are `where`, `update` and the like,
  which MY_Model forwards to the database through __call.
- Flow: Booking::register -> Appointments_model::save, and
  Appointments::store -> save -> validate.
- Controls flask 49943b5a and gin 0f09c3a9 are byte-identical.
- Index CPU, median of 3 interleaved, with a suite running: ospos +6.8%,
  easyappointments -8.2%, flask -9.1%, gin -8.8%. The swing is noise; a
  project that doesn't use CodeIgniter pays one path scan at detection.

Left out: routes.php entries a helper function adds, which only that
function's code says; CodeIgniter 4, a different layout.

Reimplements PR #167 by @darkspock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 17:52:18 -05:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bea5e205f4
commit 002dd4d517
7 changed files with 632 additions and 4 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
- `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`.
- `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend).
- `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread.
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `<Link href>` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `<Link href>` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`).
- `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws),
`dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here), `file-cycles.ts` (circular imports, in `codegraph cycles` and the viewer's map). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree.
- `src/context/` — `ContextBuilder` + formatter for markdown/JSON output.
+1
View File
@@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- For contributors, the test suite now runs on Vitest 4 and working in this repository needs Node 22.12 or newer; installing and using CodeGraph doesn't change. Thanks @danusha2345.
- New `codegraph cycles` command lists the groups of files that import each other in a circle, as text or `--json`, and exits 1 when it finds any, so it works as a pre-commit or CI check (#888). Thanks @jiangyq9 and @goutham80808.
- `codegraph init --local-only` keeps `.codegraph/` out of git entirely, so nothing of CodeGraph's shows in `git status`, and it works on a project that's already set up, leaving a `.codegraph/.gitignore` you've edited or committed alone (#910). Thanks @jadchene, @jiangyq9, @Wood-Q and @walterf3.
- CodeIgniter 3 projects are now understood: every controller method and `routes.php` entry becomes a route, HMVC modules included, and calls through what `$this->load` loads, like `$this->user_model->find($id)`, link to the method they reach, so `codegraph_explore` can follow a request from its route through the model and into the view. Thanks @darkspock.
### Fixes
+1
View File
@@ -326,6 +326,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
| **CodeIgniter 3** | Every public controller method by convention, `$route[...]` in `routes.php`, HMVC modules; `$this->load` models, libraries and views |
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
| **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
| **Play** | `GET`/`POST`/… verb routes in `conf/routes` → `Controller.method` actions (Scala + Java) |
+294
View File
@@ -0,0 +1,294 @@
/**
* CodeIgniter 3: routes by convention and from routes.php, and the calls that
* go through the properties `$this->load` fills in.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { codeigniterResolver } from '../src/resolution/frameworks/codeigniter';
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
import type { ResolutionContext } from '../src/resolution/types';
const extract = (file: string, source: string) => codeigniterResolver.extract!(file, source);
const routeNames = (file: string, source: string): string[] => extract(file, source).nodes.map((n) => n.name);
describe('routes from a controller', () => {
const controller = `<?php
class Blog extends MY_Controller {
public function __construct() { parent::__construct(); }
public function index() {}
public function view($id) {}
function comments() {}
public static function feed() {}
protected function guard() {}
private function load_post() {}
public function _remap() {}
public function _hidden() {}
}
`;
it('takes every public method, an implicit one too, and keeps underscored ones off the web', () => {
expect(routeNames('application/controllers/Blog.php', controller)).toEqual([
'ANY /blog', 'ANY /blog/view', 'ANY /blog/comments', 'ANY /blog/feed',
]);
});
it('builds the URL from the folder and lower-cases it', () => {
expect(routeNames('application/controllers/admin/Blog.php', controller)).toContain('ANY /admin/blog/view');
expect(routeNames('sub/dir/application/controllers/Blog.php', controller)).toContain('ANY /blog/view');
});
it('reads HMVC modules, with the controller named after its module at the module\'s URL', () => {
expect(routeNames('application/modules/blog/controllers/Blog.php', controller)).toContain('ANY /blog/view');
expect(routeNames('application/modules/shop/controllers/Blog.php', controller)).toContain('ANY /shop/blog/view');
expect(routeNames('bonfire/modules/shop/controllers/Blog.php', controller)).toContain('ANY /shop/blog/view');
});
it('skips a file whose class is not the one it is named after, and an abstract one', () => {
expect(routeNames('application/controllers/Other.php', controller)).toEqual([]);
expect(routeNames('application/controllers/Base.php', '<?php\nabstract class Base extends CI_Controller {\n public function index() {}\n}\n')).toEqual([]);
});
it('ignores a method in a comment, and every other file', () => {
expect(routeNames('application/controllers/Blog.php', '<?php\nclass Blog {\n // public function gone() {}\n /* public function older() {} */\n public function kept() {}\n}\n')).toEqual(['ANY /blog/kept']);
expect(routeNames('application/models/Blog.php', controller)).toEqual([]);
});
});
describe('routes from routes.php', () => {
const file = 'application/config/routes.php';
it('reads a route, a verb-scoped one, and the default controller at /', () => {
expect(routeNames(file, `<?php
$route['default_controller'] = 'welcome';
$route['404_override'] = 'errors/missing';
$route['translate_uri_dashes'] = FALSE;
$route['product/(:num)'] = 'catalog/lookup/$1';
$route['api/users']['post'] = 'api/users/create';
`)).toEqual(['ANY /', 'ANY /product/(:num)', 'POST /api/users']);
});
it('skips a route in a comment, and one a closure computes', () => {
expect(routeNames(file, `<?php
// $route['old'] = 'old/index';
$route['drinks/(:any)'] = function ($type) { return 'catalog/drinks/' . strtolower($type); };
`)).toEqual([]);
});
it('reads a module\'s own routes file', () => {
expect(routeNames('application/modules/shop/config/routes.php', "<?php\n$route['shop/sale'] = 'shop/items/sale';\n")).toEqual(['ANY /shop/sale']);
});
});
describe('detecting a CodeIgniter project', () => {
const context = (files: Record<string, string>): ResolutionContext =>
({
fileExists: (f: string) => f in files,
getAllFiles: () => Object.keys(files),
readFile: (f: string) => files[f] ?? null,
}) as unknown as ResolutionContext;
it('goes by the framework\'s own file, or by a routes file that names a default controller', () => {
expect(codeigniterResolver.detect(context({ 'system/core/CodeIgniter.php': '' }))).toBe(true);
expect(codeigniterResolver.detect(context({ 'application/config/routes.php': "<?php\n$route['default_controller'] = 'welcome';\n" }))).toBe(true);
expect(codeigniterResolver.detect(context({ 'htdocs/application/config/routes.php': '<?php\n$route["default_controller"] = "welcome";\n' }))).toBe(true);
});
it('takes no other project\'s routes file for one', () => {
expect(codeigniterResolver.detect(context({ 'application/config/routes.php': "<?php\nreturn ['home' => 'index'];\n" }))).toBe(false);
expect(codeigniterResolver.detect(context({ 'app/Config/Routes.php': "<?php\n$routes->get('/', 'Home::index');\n" }))).toBe(false);
expect(codeigniterResolver.detect(context({ 'routes/web.php': '<?php\n' }))).toBe(false);
});
});
describe('a CodeIgniter project, indexed', () => {
let dir: string;
let cg: CodeGraph;
const write = (rel: string, content: string): void => {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
};
const calls = (from: string, inFile: string): string[] => {
const node = cg.getNodesInFile(inFile).find((n) => n.name === from && n.kind === 'method');
if (!node) throw new Error(`no ${from} in ${inFile}`);
return cg.getCallees(node.id).map((c) => `${c.node.qualifiedName} @${c.node.filePath}`).sort();
};
const routeTarget = (name: string): string | undefined => {
const route = cg.getNodesByKind('route').find((n) => n.name === name);
if (!route) throw new Error(`no route ${name}`);
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)?.qualifiedName)[0];
};
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-codeigniter-'));
write('application/config/routes.php', `<?php
$route['default_controller'] = 'welcome';
$route['profile/(:num)'] = 'users/show/$1';
$route['reports/(summary_:any)'] = 'admin/reports/$1';
$route['sale'] = 'shop/items/sale';
$route['board'] = 'dashboard';
$route['gone'] = 'nowhere/index';
`);
write('application/controllers/Welcome.php', '<?php\nclass Welcome extends CI_Controller {\n public function index() {}\n}\n');
write('application/controllers/Users.php', `<?php
class Users extends MY_Controller {
public function __construct() {
parent::__construct();
$this->load->model('user_model');
$this->load->model('Order_model');
$this->load->model('shop/mdl_items', 'items');
$this->load->library('mailer');
}
public function show($id) {
$user = $this->user_model->find($id);
$this->user_model->save($user);
$this->Order_model->find($id);
$this->items->price($id);
$this->mailer->welcome($user);
$this->load->view('users/show', ['user' => $user]);
}
public function missing() {
$this->user_model->no_such_method();
$this->nothing_loaded->find();
$this->db->get('users');
}
}
`);
write('application/controllers/admin/Reports.php', '<?php\nclass Reports extends MY_Controller {\n public function index() {}\n}\n');
write('application/core/MY_Model.php', '<?php\nclass MY_Model extends CI_Model {\n public function save($row) { return $row; }\n}\n');
write('application/models/User_model.php', '<?php\nclass User_model extends MY_Model {\n public function find($id) { return $id; }\n}\n');
write('application/models/Order_model.php', '<?php\nclass Order_model extends MY_Model {\n public function find($id) { return $id; }\n}\n');
write('application/libraries/Mailer.php', `<?php
class Mailer {
protected $CI;
public function __construct() { $this->CI =& get_instance(); }
public function welcome($user) {
$this->CI->user_model->find($user);
$CI =& get_instance();
$CI->user_model->save($user);
}
}
`);
write('application/views/users/show.php', "<?php echo $this->mailer->welcome('x'); ?>\n");
write('application/modules/shop/controllers/Items.php', `<?php
class Items extends MX_Controller {
public function sale() { return $this->mdl_items->price(1); }
}
`);
write('application/modules/shop/models/Mdl_items.php', '<?php\nclass Mdl_Items extends MY_Model {\n public function price($id) { return $id; }\n}\n');
write('application/modules/dashboard/controllers/Dashboard.php', '<?php\nclass Dashboard extends MX_Controller {\n public function index() {\n $this->load->view(\'overview\');\n }\n}\n');
write('application/modules/dashboard/views/overview.php', '<p>overview</p>\n');
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
afterAll(() => {
cg.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it('binds a convention route to its method, and a routes.php one to what it names', () => {
expect(routeTarget('ANY /users/show')).toBe('Users::show');
expect(routeTarget('ANY /shop/items/sale')).toBe('Items::sale');
expect(routeTarget('ANY /dashboard')).toBe('Dashboard::index');
expect(routeTarget('ANY /')).toBe('Welcome::index');
expect(routeTarget('ANY /profile/(:num)')).toBe('Users::show');
expect(routeTarget('ANY /sale')).toBe('Items::sale');
expect(routeTarget('ANY /board')).toBe('Dashboard::index');
});
it('binds a route whose method is a variable to the controller, and leaves one with no controller alone', () => {
expect(routeTarget('ANY /reports/(summary_:any)')).toBe('Reports');
expect(routeTarget('ANY /gone')).toBeUndefined();
});
it('links a call through a property to that class\'s method, not to any method of that name', () => {
expect(calls('show', 'application/controllers/Users.php')).toEqual(expect.arrayContaining([
'User_model::find @application/models/User_model.php',
'Order_model::find @application/models/Order_model.php',
]));
});
it('follows a method up to the class it is inherited from', () => {
expect(calls('show', 'application/controllers/Users.php')).toContain('MY_Model::save @application/core/MY_Model.php');
});
it('reads a library\'s property, and a class whose case differs from its file', () => {
expect(calls('show', 'application/controllers/Users.php')).toContain('Mailer::welcome @application/libraries/Mailer.php');
expect(calls('sale', 'application/modules/shop/controllers/Items.php')).toEqual(['Mdl_Items::price @application/modules/shop/models/Mdl_items.php']);
});
it('reads an alias from the load that gives it', () => {
expect(calls('show', 'application/controllers/Users.php')).toContain('Mdl_Items::price @application/modules/shop/models/Mdl_items.php');
});
it('reads a library\'s way to the controller, through get_instance()', () => {
expect(calls('welcome', 'application/libraries/Mailer.php')).toEqual([
'MY_Model::save @application/core/MY_Model.php',
'User_model::find @application/models/User_model.php',
]);
});
it('links nothing for a method the class does not have, or a property nothing fills', () => {
expect(calls('missing', 'application/controllers/Users.php')).toEqual([]);
});
it('links a loaded view to its file, in the app or the caller\'s module, and on to what the view calls', () => {
const show = cg.getNodesInFile('application/controllers/Users.php').find((n) => n.name === 'show')!;
const view = cg.getCallees(show.id).find((c) => c.node.kind === 'file');
expect(view?.node.filePath).toBe('application/views/users/show.php');
expect(cg.getCallees(view!.node.id).map((c) => c.node.name)).toContain('welcome');
const index = cg.getNodesInFile('application/modules/dashboard/controllers/Dashboard.php').find((n) => n.name === 'index')!;
expect(cg.getCallees(index.id).map((c) => c.node.filePath)).toEqual(['application/modules/dashboard/views/overview.php']);
});
it('connects a request end to end for a question in the app\'s own names', () => {
const flow = resolveNamedSymbolFlow(cg, 'Users.show Mailer.welcome User_model.find');
expect(flow.chains[0]?.steps.map((step) => step.node.qualifiedName)).toEqual(['Users::show', 'Mailer::welcome', 'User_model::find']);
});
});
describe('a sync of a CodeIgniter project', () => {
it('binds a route when its controller arrives, and a call when its model does', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-codeigniter-sync-'));
const write = (rel: string, content: string): void => {
const full = path.join(dir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
};
write('application/config/routes.php', "<?php\n$route['default_controller'] = 'welcome';\n$route['read/(:num)'] = 'blog/view/$1';\n");
write('application/controllers/Welcome.php', `<?php
class Welcome extends CI_Controller {
public function index() { $this->post_model->latest(); }
}
`);
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
const route = (): string | undefined => {
const node = cg.getNodesByKind('route').find((n) => n.name === 'ANY /read/(:num)')!;
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)?.qualifiedName)[0];
};
const index = (): string[] => {
const node = cg.getNodesInFile('application/controllers/Welcome.php').find((n) => n.name === 'index')!;
return cg.getCallees(node.id).map((c) => c.node.qualifiedName);
};
expect(route()).toBeUndefined();
expect(index()).toEqual([]);
write('application/controllers/Blog.php', '<?php\nclass Blog extends CI_Controller {\n public function view($id) {}\n}\n');
write('application/models/Post_model.php', '<?php\nclass Post_model extends CI_Model {\n public function latest() {}\n}\n');
await cg.sync();
expect(route()).toBe('Blog::view');
expect(index()).toEqual(['Post_model::latest']);
cg.close();
fs.rmSync(dir, { recursive: true, force: true });
});
});
+4 -3
View File
@@ -110,9 +110,9 @@ table; the resolver half is one `Astro.redirect` reader.
### 2. Server-rendered frameworks — a redirect is a transition, not just a response
**Fourteen frameworks** have route nodes and no navigation: Django, Flask,
FastAPI, Express, NestJS, Laravel, Drupal, Rails, Spring, Play, Gin/chi/gorilla,
Axum/actix/Rocket, ASP.NET, Vapor.
**Fifteen frameworks** have route nodes and no navigation: Django, Flask,
FastAPI, Express, NestJS, Laravel, CodeIgniter 3, Drupal, Rails, Spring, Play,
Gin/chi/gorilla, Axum/actix/Rocket, ASP.NET, Vapor.
Be precise about what is missing. `redirect_to`, `HttpResponseRedirect`,
`res.redirect`, PHP's `redirect()` are **already recognised as `response`
@@ -129,6 +129,7 @@ gets no Screens picture at all today:
| Rails | `redirect_to :dashboard`, `redirect_to users_path` | destinations are named helpers (`*_path`/`*_url`) generated from `routes.rb`, not literals |
| Django | `redirect('profile')`, `reverse('profile')` | same — a route *name*, like Vue's `{ name }`, which `vue-router.ts` already shows how to index |
| Laravel | `redirect()->route('home')`, `->view()` | route names again |
| CodeIgniter 3 | `redirect('users/show')` | a controller path, which `routes.php` or the convention maps to a route |
| Spring | `"redirect:/x"`, `RedirectView` | a literal inside a string return value |
| ASP.NET | `RedirectToAction("Index", "Home")` | controller + action pair, not a path — needs the route table's reverse mapping |
| Flask | `redirect(url_for('profile'))` | nested call; the name is `url_for`'s argument |
+328
View File
@@ -0,0 +1,328 @@
/**
* CodeIgniter 3 Framework Resolver
*
* A request reaches a controller by convention more often than through
* `application/config/routes.php`: every public method of a class in
* `application/controllers/` answers at `/<folder>/<class>/<method>`, so a
* project's routes are mostly its controllers' methods, and both kinds become
* `route` nodes bound to the method that serves them. HMVC keeps the same layout
* per module (`modules/<module>/controllers/`), with the module as the first
* segment, and `/<module>/<method>` for the controller named after it.
*
* From there the flow goes through properties nothing declares:
* `$this->load->model('users_model')` puts a `Users_model` on the controller,
* and `$this->users_model->find($id)`, or `$this->CI->users_model->find($id)` in
* a library, calls it. A model is read by its class name whatever the case, as
* PHP and the loader read it, so the call links to that class's own method or
* one it inherits, and not to whichever `find` a lookup by name meets first.
* `$this->load->view('users/list')` links to the view's file, where the calls a
* template makes are recorded, so a request can be followed to the end.
*/
import { Node } from '../../types';
import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
const ROUTES_FILE = /(?:^|\/)application\/(?:modules\/[^/]+\/)?config\/(?:[^/]+\/)?routes\.php$/;
const CONTROLLER_FILE = /^(?:.*?\/)?(?:application\/controllers|modules\/([^/]+)\/controllers)\/(.+)\.php$/;
/** Where the loader finds what `$this->load` puts on a property. */
const LOADABLE_FILE = /(?:^|\/)(?:models|libraries)\//;
/** The module a file of an HMVC project belongs to. */
const MODULE_OF = /(?:^|\/)modules\/([^/]+)\//;
/**
* A handler reference: `ci:method:<file>|<method>` from a controller, and
* `ci:route:<app root>|<uri>:<word>` from `routes.php`. The word is what a sync
* looks a failed reference up by: a name the controller it waits for defines.
*/
const METHOD_REF = 'ci:method:';
const ROUTE_REF = 'ci:route:';
/** `$this->x_model->find()`, as extraction names it; through `get_instance()` in a library: `$this->CI->x_model`, `$CI->x_model`. */
const PROPERTY_CALL = /^(?:this->(?:_?ci->)?|_?ci->)([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/i;
/** Levels of `extends` a method is looked for through. */
const MAX_ANCESTORS = 6;
const ROUTE_RE = /\$route\s*\[\s*['"]([^'"]+)['"]\s*\](?:\s*\[\s*['"]([A-Za-z]+)['"]\s*\])?\s*=\s*['"]([^'"]*)['"]/g;
const CLASS_RE = /^[ \t]*((?:abstract\s+|final\s+)*)class\s+([A-Za-z_]\w*)/gm;
const METHOD_RE = /^[ \t]*((?:(?:public|protected|private|static|final|abstract)\s+)*)function\s+&?\s*([A-Za-z_]\w*)\s*\(/gm;
const MODEL_ALIAS_RE = /->\s*load\s*->\s*model\s*\(\s*['"]([\w/]+)['"]\s*,\s*['"](\w+)['"]/g;
const LIBRARY_ALIAS_RE = /->\s*load\s*->\s*library\s*\(\s*['"]([\w/]+)['"]\s*,[^,()]*,\s*['"](\w+)['"]/g;
const VIEW_RE = /->\s*load\s*->\s*view\s*\(\s*['"]([\w./-]+)['"]/;
interface ClassTable {
/** What the table was built from: the context hands out one array until the graph changes. */
source: Node[];
/** Lower-cased class name → the models and libraries of that name. */
loadable: Map<string, Node[]>;
/** The lower-cased URI a controller answers at, with no leading slash → its classes, one per app. */
controllers: Map<string, Node[]>;
/** File → property → the class a load in that file gave that name to. */
aliases: Map<string, Map<string, string>>;
}
const tables = new WeakMap<ResolutionContext, ClassTable>();
function add(map: Map<string, Node[]>, key: string, node: Node): void {
const list = map.get(key);
if (list) list.push(node);
else map.set(key, [node]);
}
function classTable(context: ResolutionContext): ClassTable {
const all = context.getNodesByKind('class');
const cached = tables.get(context);
if (cached && cached.source === all) return cached;
const table: ClassTable = { source: all, loadable: new Map(), controllers: new Map(), aliases: new Map() };
for (const node of all) {
if (node.language !== 'php') continue;
const url = controllerUrl(node.filePath);
if (url !== null) add(table.controllers, url.slice(1), node);
else if (LOADABLE_FILE.test(node.filePath)) add(table.loadable, node.name.toLowerCase(), node);
}
tables.set(context, table);
return table;
}
/** The URL a controller's `index` answers at, or null for a file that isn't one. */
function controllerUrl(filePath: string): string | null {
const m = CONTROLLER_FILE.exec(filePath);
if (!m) return null;
const segments = m[2]!.toLowerCase().split('/');
const module = m[1]?.toLowerCase();
if (!module) return `/${segments.join('/')}`;
return segments.length === 1 && segments[0] === module ? `/${module}` : `/${[module, ...segments].join('/')}`;
}
function routeNode(filePath: string, line: number, verb: string, routePath: string, now: number): Node {
return {
id: `route:${filePath}:${line}:${verb}:${routePath}`,
kind: 'route',
name: `${verb} ${routePath}`,
qualifiedName: `${filePath}::route:${verb}:${routePath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: 0,
language: 'php',
updatedAt: now,
};
}
/** Each public method of the class a controller file is named after, as a route. */
function conventionRoutes(filePath: string, baseUrl: string, safe: string): { nodes: Node[]; references: UnresolvedRef[] } {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const stem = filePath.slice(filePath.lastIndexOf('/') + 1, -'.php'.length).toLowerCase();
CLASS_RE.lastIndex = 0;
let cls: RegExpExecArray | null;
while ((cls = CLASS_RE.exec(safe)) !== null) {
if (cls[2]!.toLowerCase() === stem) break;
}
if (!cls || /abstract/.test(cls[1]!)) return { nodes, references };
const now = Date.now();
const seen = new Set<string>();
METHOD_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = METHOD_RE.exec(safe)) !== null) {
const name = m[2]!;
// A leading underscore is CodeIgniter's own way to keep a method off the web.
if (/private|protected/.test(m[1]!) || name.startsWith('_')) continue;
const routePath = name.toLowerCase() === 'index' ? baseUrl : `${baseUrl}/${name.toLowerCase()}`;
if (seen.has(routePath)) continue;
seen.add(routePath);
const line = safe.slice(0, m.index).split('\n').length;
const route = routeNode(filePath, line, 'ANY', routePath, now);
nodes.push(route);
references.push({
fromNodeId: route.id,
referenceName: `${METHOD_REF}${filePath}|${name}`,
referenceKind: 'references',
line,
column: 0,
filePath,
language: 'php',
});
}
return { nodes, references };
}
/** The name the handler's method goes by, or its class's when only that is written. */
function awaitedName(handler: string): string {
const literal = handler.split('/').filter((segment) => segment && !segment.includes('$'));
const last = literal[literal.length - 1] ?? handler;
return literal.length > 1 ? last : last.charAt(0).toUpperCase() + last.slice(1);
}
/** `$route['pattern'] = 'controller/method'`, verb-scoped or not, and the default controller at `/`. */
function explicitRoutes(filePath: string, safe: string): { nodes: Node[]; references: UnresolvedRef[] } {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
const root = filePath.slice(0, filePath.indexOf('application/'));
ROUTE_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ROUTE_RE.exec(safe)) !== null) {
const [, pattern, verb, handler] = m;
if (pattern === '404_override' || pattern === 'translate_uri_dashes' || !handler) continue;
const line = safe.slice(0, m.index).split('\n').length;
const routePath = pattern === 'default_controller' ? '/' : `/${pattern!.replace(/^\/+/, '')}`;
const route = routeNode(filePath, line, (verb ?? 'ANY').toUpperCase(), routePath, now);
nodes.push(route);
references.push({
fromNodeId: route.id,
referenceName: `${ROUTE_REF}${root}|${handler}:${awaitedName(handler!)}`,
referenceKind: 'references',
line,
column: 0,
filePath,
language: 'php',
});
}
return { nodes, references };
}
/** A method by name on a class, its own or one it inherits. PHP doesn't mind the case. */
function methodOf(cls: Node, name: string, context: ResolutionContext): Node | null {
const lower = name.toLowerCase();
const seen = new Set<string>();
let level: Node[] = [cls];
for (let depth = 0; depth < MAX_ANCESTORS && level.length > 0; depth++) {
const next: Node[] = [];
for (const type of level) {
if (seen.has(type.id)) continue;
seen.add(type.id);
const prefix = `${type.qualifiedName}::`.toLowerCase();
const own = context.getNodesInFile(type.filePath).find(
(n) => n.kind === 'method' && n.name.toLowerCase() === lower && n.qualifiedName.toLowerCase().startsWith(prefix)
);
if (own) return own;
for (const parent of context.getSupertypeNodes?.(type.id) ?? []) {
if (parent.kind === 'class') next.push(parent);
}
}
level = next;
}
return null;
}
/** The method the segment after a controller names, `index` when none does, and the class when a variable does. */
function handlerIn(cls: Node, segments: readonly string[], at: number, context: ResolutionContext): Node {
const segment = segments[at] ?? 'index';
return (segment.includes('$') ? null : methodOf(cls, segment, context)) ?? cls;
}
/** What `controller/method/$1` serves: the first run of segments that names a controller, then the method after it. */
function routeHandler(root: string, uri: string, context: ResolutionContext): Node | null {
const { controllers } = classTable(context);
const segments = uri.split('/').filter(Boolean);
const lower = segments.map((segment) => segment.toLowerCase());
for (let i = lower.length; i > 0; i--) {
const classes = controllers.get(lower.slice(0, i).join('/'));
if (!classes) continue;
const cls = classes.find((c) => c.filePath.startsWith(root)) ?? classes[0]!;
return handlerIn(cls, segments, i, context);
}
return null;
}
/** The file the `$this->load->view('users/list')` on this line loads: the caller's module's, another module's, or the app's. */
function viewFile(ref: UnresolvedRef, context: ResolutionContext): Node | null {
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1];
const view = line ? VIEW_RE.exec(line)?.[1] : undefined;
if (!view) return null;
const file = /\.\w+$/.test(view) ? view : `${view}.php`;
const root = ref.filePath.slice(0, Math.max(ref.filePath.indexOf('application/'), 0));
const module = MODULE_OF.exec(ref.filePath);
const modules = module ? ref.filePath.slice(0, module.index + module[0].length - module[1]!.length - 1) : `${root}application/modules/`;
const slash = file.indexOf('/');
const candidates = [
...(module ? [`${modules}${module[1]}/views/${file}`] : []),
...(slash > 0 ? [`${modules}${file.slice(0, slash)}/views/${file.slice(slash + 1)}`] : []),
`${root}application/views/${file}`,
];
for (const candidate of candidates) {
const node = context.getNodesInFile(candidate).find((n) => n.kind === 'file');
if (node) return node;
}
return null;
}
function aliasesIn(file: string, table: ClassTable, context: ResolutionContext): Map<string, string> {
const known = table.aliases.get(file);
if (known) return known;
const aliases = new Map<string, string>();
const source = context.readFile(file) ?? '';
for (const re of [MODEL_ALIAS_RE, LIBRARY_ALIAS_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(source)) !== null) aliases.set(m[2]!, m[1]!.split('/').pop()!.toLowerCase());
}
table.aliases.set(file, aliases);
return aliases;
}
/** The class a property holds in `file`: by its own name, or by the alias a load there gave it. */
function propertyClass(property: string, file: string, context: ResolutionContext): Node | null {
const table = classTable(context);
const alias = table.loadable.has(property.toLowerCase()) ? undefined : aliasesIn(file, table, context).get(property);
const classes = table.loadable.get(alias ?? property.toLowerCase());
if (!classes) return null;
if (classes.length === 1) return classes[0]!;
// One name in two HMVC modules: the caller's own, and no guess past that.
const module = MODULE_OF.exec(file)?.[1];
const own = module ? classes.filter((c) => MODULE_OF.exec(c.filePath)?.[1] === module) : [];
return own.length === 1 ? own[0]! : null;
}
export const codeigniterResolver: FrameworkResolver = {
name: 'codeigniter',
languages: ['php'],
// The handler references are this file's own, and a property call names no
// declared symbol, so neither would get past the name pre-filter.
claimsReference(name: string): boolean {
return name.startsWith('ci:') || PROPERTY_CALL.test(name);
},
detect(context: ResolutionContext): boolean {
if (context.fileExists('system/core/CodeIgniter.php')) return true;
// `system/` is often a Composer package, and `application/` can sit below the
// repository root. A CodeIgniter routes file names its default controller.
const routes = context.getAllFiles().find((file) => file.endsWith('application/config/routes.php'));
return routes !== undefined && /\$route\s*\[\s*['"]default_controller['"]\s*\]/.test(context.readFile(routes) ?? '');
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const name = ref.referenceName;
const resolved = (target: Node | null | undefined, confidence: number): ResolvedRef | null =>
target ? { original: ref, targetNodeId: target.id, confidence, resolvedBy: 'framework' } : null;
if (name.startsWith(METHOD_REF)) {
const [file, method] = name.slice(METHOD_REF.length).split('|');
return resolved(context.getNodesInFile(file!).find((n) => n.kind === 'method' && n.name === method), 0.95);
}
if (name.startsWith(ROUTE_REF)) {
const [root, handler] = name.slice(ROUTE_REF.length).split('|');
return resolved(routeHandler(root!, handler!.slice(0, handler!.lastIndexOf(':')), context), 0.9);
}
const call = PROPERTY_CALL.exec(name);
if (call) {
if (call[1] === 'load' && call[2] === 'view') return resolved(viewFile(ref, context), 0.9);
const cls = propertyClass(call[1]!, ref.filePath, context);
return resolved(cls && methodOf(cls, call[2]!, context), 0.9);
}
return null;
},
extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
const routes = ROUTES_FILE.test(filePath);
const baseUrl = routes ? null : controllerUrl(filePath);
if (!routes && baseUrl === null) return { nodes: [], references: [] };
const safe = stripCommentsForRegex(content, 'php');
return routes ? explicitRoutes(filePath, safe) : conventionRoutes(filePath, baseUrl!, safe);
},
};
+3
View File
@@ -6,6 +6,7 @@
import { FrameworkResolver, ResolutionContext } from '../types';
import type { Language } from '../../types';
import { codeigniterResolver } from './codeigniter';
import { drupalResolver } from './drupal';
import { laravelResolver } from './laravel';
import { expressResolver } from './express';
@@ -44,6 +45,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
// PHP
laravelResolver,
drupalResolver,
codeigniterResolver,
// JavaScript/TypeScript
expressResolver,
nestjsResolver,
@@ -176,6 +178,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
}
// Re-export framework resolvers
export { codeigniterResolver } from './codeigniter';
export { drupalResolver } from './drupal';
export { laravelResolver, FACADE_MAPPINGS } from './laravel';
export { expressResolver } from './express';