diff --git a/AGENTS.md b/AGENTS.md
index 1b13c4511..a9658fa53 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 `` — 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 `` — 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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad0c603b5..524b51f77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 0c10d51cb..47ef4a9aa 100644
--- a/README.md
+++ b/README.md
@@ -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) |
diff --git a/__tests__/codeigniter.test.ts b/__tests__/codeigniter.test.ts
new file mode 100644
index 000000000..bf6d2a933
--- /dev/null
+++ b/__tests__/codeigniter.test.ts
@@ -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 = ` {
+ 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', ' {
+ expect(routeNames('application/controllers/Blog.php', ' {
+ const file = 'application/config/routes.php';
+
+ it('reads a route, a verb-scoped one, and the default controller at /', () => {
+ expect(routeNames(file, ` {
+ expect(routeNames(file, ` {
+ expect(routeNames('application/modules/shop/config/routes.php', " {
+ const context = (files: Record): 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': " {
+ expect(codeigniterResolver.detect(context({ 'application/config/routes.php': " 'index'];\n" }))).toBe(false);
+ expect(codeigniterResolver.detect(context({ 'app/Config/Routes.php': "get('/', 'Home::index');\n" }))).toBe(false);
+ expect(codeigniterResolver.detect(context({ 'routes/web.php': ' {
+ 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', `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', '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', "mailer->welcome('x'); ?>\n");
+ write('application/modules/shop/controllers/Items.php', `mdl_items->price(1); }
+}
+`);
+ write('application/modules/shop/models/Mdl_items.php', 'load->view(\'overview\');\n }\n}\n');
+ write('application/modules/dashboard/views/overview.php', '
overview
\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', "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', '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 |
diff --git a/src/resolution/frameworks/codeigniter.ts b/src/resolution/frameworks/codeigniter.ts
new file mode 100644
index 000000000..0ee8587c8
--- /dev/null
+++ b/src/resolution/frameworks/codeigniter.ts
@@ -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 `///`, 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//controllers/`), with the module as the first
+ * segment, and `//` 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:|` from a controller, and
+ * `ci:route:|:` 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;
+ /** The lower-cased URI a controller answers at, with no leading slash → its classes, one per app. */
+ controllers: Map;
+ /** File → property → the class a load in that file gave that name to. */
+ aliases: Map>;
+}
+
+const tables = new WeakMap();
+
+function add(map: Map, 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();
+ 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();
+ 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 {
+ const known = table.aliases.get(file);
+ if (known) return known;
+ const aliases = new Map();
+ 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);
+ },
+};
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index da8f7b0ec..ae9df9e13 100644
--- a/src/resolution/frameworks/index.ts
+++ b/src/resolution/frameworks/index.ts
@@ -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';