diff --git a/CHANGELOG.md b/CHANGELOG.md
index 904d3cb0..7a4442be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,37 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- **CodeIgniter 3 framework support**. CodeGraph now indexes CI3 projects
+ with the same depth it already had for Laravel:
+ - **Explicit routes** from `application/config/routes.php` —
+ `$route['pattern'] = 'controller/method'`, including HTTP-verb-scoped
+ variants (`$route['x']['POST'] = '...'`) and CI3 wildcards (`(:any)`,
+ `(:num)`). Reserved keys (`default_controller`, `404_override`,
+ `translate_uri_dashes`) are skipped.
+ - **Convention routes** synthesized from every public method on each
+ controller in `application/controllers/**`. Without this the graph
+ would be near-empty since most CI3 projects barely touch routes.php.
+ URLs are lowercased per CI3 routing rules, methods prefixed with `_`
+ and `__construct`/`__destruct` are excluded. Recognized controller
+ bases: `CI_Controller`, `MX_Controller` (HMVC), `MY_Controller`,
+ `REST_Controller`, plus common project-specific patterns
+ (`Admin_Controller`, `Public_Controller`, `Frontend_Controller`,
+ `Backend_Controller`).
+ - **Magic-loaded models and libraries** — both `$this->load->model('X')`
+ / `$this->load->library('X')` calls and the runtime property accesses
+ that follow (`$this->ModelName->method()`). Critical for CI3
+ codebases: in a real-world project, only ~0.2% of model usages are
+ explicit `load->model()` calls; the rest are property accesses on
+ pre-loaded models that pure static analysis can't see. The resolver
+ filters by PascalCase to avoid false positives on built-in CI3
+ properties (`$this->load`, `$this->db`, `$this->input`, …).
+- Reference resolution: `controller/method` strings in routes.php resolve
+ to method nodes in `application/controllers/`, trying both PascalCase
+ (CI3 file-naming convention) and the literal segment as fallback.
+
## [0.7.8] - 2026-05-17
### Fixed
diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts
index 8eb33e2e..392fbf40 100644
--- a/__tests__/frameworks.test.ts
+++ b/__tests__/frameworks.test.ts
@@ -199,6 +199,194 @@ describe('laravelResolver.extract', () => {
});
});
+import { codeigniterResolver } from '../src/resolution/frameworks/codeigniter';
+
+describe('codeigniterResolver.extract', () => {
+ it('extracts explicit route from routes.php', () => {
+ const src = `$route['products/(:any)'] = 'catalog/product_lookup';\n`;
+ const { nodes, references } = codeigniterResolver.extract!(
+ 'application/config/routes.php',
+ src
+ );
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0].name).toBe('ANY /products/(:any)');
+ expect(nodes[0].kind).toBe('route');
+ expect(references[0].referenceName).toBe('catalog/product_lookup');
+ });
+
+ it('extracts HTTP-verb-scoped route from routes.php', () => {
+ const src = `$route['api/users']['POST'] = 'api/users/create';\n`;
+ const { nodes } = codeigniterResolver.extract!('application/config/routes.php', src);
+ expect(nodes[0].name).toBe('POST /api/users');
+ });
+
+ it('skips reserved keys like default_controller and 404_override', () => {
+ const src =
+ `$route['default_controller'] = 'welcome';\n` +
+ `$route['404_override'] = '';\n` +
+ `$route['translate_uri_dashes'] = FALSE;\n`;
+ const { nodes } = codeigniterResolver.extract!('application/config/routes.php', src);
+ expect(nodes).toHaveLength(0);
+ });
+
+ it('synthesizes convention routes for each public controller method', () => {
+ const src =
+ ` n.name).sort();
+ expect(paths).toEqual(['ANY /welcome', 'ANY /welcome/about']);
+ const refNames = references.map((r) => r.referenceName).sort();
+ expect(refNames).toEqual(['about', 'index']);
+ });
+
+ it('builds URL from controller subdirectory', () => {
+ const src =
+ `class Users extends CI_Controller {\n` +
+ ` public function list() {}\n` +
+ `}\n`;
+ const { nodes } = codeigniterResolver.extract!(
+ 'application/controllers/admin/Users.php',
+ src
+ );
+ expect(nodes.map((n) => n.name).sort()).toEqual(['ANY /admin/users/list']);
+ });
+
+ it('does not emit convention routes for files that are not CI controllers', () => {
+ const src = `class Foo {\n public function bar() {}\n}\n`;
+ const { nodes } = codeigniterResolver.extract!(
+ 'application/controllers/Foo.php',
+ src
+ );
+ expect(nodes).toHaveLength(0);
+ });
+
+ it('ignores non-controller PHP files', () => {
+ const src = `class Foo extends CI_Controller {\n public function bar() {}\n}\n`;
+ const { nodes } = codeigniterResolver.extract!('app/Http/Foo.php', src);
+ expect(nodes).toHaveLength(0);
+ });
+
+ it('emits file -> Model imports reference for $this->load->model() calls', () => {
+ const src =
+ `load->model('Restomodel');\n` +
+ ` $this->load->model('drivers/Drivers_model');\n` +
+ ` $this->load->library('Form_validation');\n` +
+ ` }\n` +
+ `}\n`;
+ const { references } = codeigniterResolver.extract!(
+ 'application/controllers/Welcome.php',
+ src
+ );
+
+ const imports = references.filter((r) => r.referenceKind === 'imports');
+ const names = imports.map((r) => r.referenceName).sort();
+ expect(names).toEqual(['Drivers_model', 'Form_validation', 'Restomodel']);
+
+ const ref = imports.find((r) => r.referenceName === 'Restomodel')!;
+ expect(ref.fromNodeId).toBe('file:application/controllers/Welcome.php');
+ });
+
+ it('deduplicates repeated $this->load->model() calls in the same file', () => {
+ const src =
+ `load->model('Restomodel'); }\n` +
+ ` public function b() { $this->load->model('Restomodel'); }\n` +
+ ` public function c() { $this->load->model('Restomodel'); }\n` +
+ `}\n`;
+ const { references } = codeigniterResolver.extract!(
+ 'application/controllers/Foo.php',
+ src
+ );
+ const imports = references.filter(
+ (r) => r.referenceKind === 'imports' && r.referenceName === 'Restomodel'
+ );
+ expect(imports).toHaveLength(1);
+ });
+
+ it('capitalizes lowercase model names per CI3 file naming convention', () => {
+ const src = `load->model('restomodel');\n`;
+ const { references } = codeigniterResolver.extract!(
+ 'application/libraries/MyLib.php',
+ src
+ );
+ const ref = references.find((r) => r.referenceName === 'Restomodel');
+ expect(ref).toBeDefined();
+ });
+
+ it('emits file -> ModelName imports for magic property access $this->Foo->bar()', () => {
+ const src =
+ `Restomodel->get_real_hour(now());\n` +
+ ` $this->Drivers_model->insert($data);\n` +
+ ` $this->load->view('welcome');\n` +
+ ` $row = $this->db->where('id', 1)->get('users')->row();\n` +
+ ` $this->session->set_userdata('x', 'y');\n` +
+ ` }\n` +
+ `}\n`;
+ const { references } = codeigniterResolver.extract!(
+ 'application/controllers/Welcome.php',
+ src
+ );
+ const names = references
+ .filter((r) => r.referenceKind === 'imports')
+ .map((r) => r.referenceName)
+ .sort();
+ // CI3 built-ins (load, db, session) are lowercase and must be excluded.
+ expect(names).toEqual(['Drivers_model', 'Restomodel']);
+ });
+
+ it('does not duplicate when both load->model and $this->X-> appear', () => {
+ const src =
+ `load->model('Restomodel');\n` +
+ ` return $this->Restomodel->something();\n` +
+ ` }\n` +
+ `}\n`;
+ const { references } = codeigniterResolver.extract!(
+ 'application/controllers/Foo.php',
+ src
+ );
+ const restoRefs = references.filter(
+ (r) => r.referenceKind === 'imports' && r.referenceName === 'Restomodel'
+ );
+ expect(restoRefs).toHaveLength(1);
+ });
+
+ it('recognizes MX_Controller (HMVC) and MY_Controller base classes', () => {
+ const mx = `class Foo extends MX_Controller {\n public function index() {}\n}\n`;
+ const { nodes: mxNodes } = codeigniterResolver.extract!(
+ 'application/controllers/Foo.php',
+ mx
+ );
+ expect(mxNodes).toHaveLength(1);
+
+ const my = `class Bar extends MY_Controller {\n public function index() {}\n}\n`;
+ const { nodes: myNodes } = codeigniterResolver.extract!(
+ 'application/controllers/Bar.php',
+ my
+ );
+ expect(myNodes).toHaveLength(1);
+ });
+});
+
import { railsResolver } from '../src/resolution/frameworks/ruby';
describe('railsResolver.extract', () => {
diff --git a/src/resolution/frameworks/codeigniter.ts b/src/resolution/frameworks/codeigniter.ts
new file mode 100644
index 00000000..97670d4d
--- /dev/null
+++ b/src/resolution/frameworks/codeigniter.ts
@@ -0,0 +1,314 @@
+/**
+ * CodeIgniter 3 Framework Resolver
+ *
+ * CI3 routes come from two places:
+ * 1. `application/config/routes.php` — explicit `$route['pattern'] = 'controller/method';`
+ * 2. Convention — every public method on a controller in `application/controllers/**`
+ * is reachable at `/
//` (lowercased). Without this,
+ * a CI3 project's graph would be almost empty since most projects barely
+ * touch routes.php.
+ */
+
+import { Node } from '../../types';
+import {
+ FrameworkResolver,
+ UnresolvedRef,
+ ResolvedRef,
+ ResolutionContext,
+ FrameworkExtractionResult,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+
+const CONTROLLER_BASE_CLASSES = [
+ 'CI_Controller',
+ 'MX_Controller',
+ 'MY_Controller',
+ 'REST_Controller',
+ 'Admin_Controller',
+ 'Public_Controller',
+ 'Frontend_Controller',
+ 'Backend_Controller',
+];
+
+const CONTROLLER_BASE_RE = new RegExp(
+ `class\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+extends\\s+(?:${CONTROLLER_BASE_CLASSES.join('|')})\\b`
+);
+
+const SPECIAL_ROUTE_KEYS = new Set([
+ 'default_controller',
+ '404_override',
+ 'translate_uri_dashes',
+]);
+
+export const codeigniterResolver: FrameworkResolver = {
+ name: 'codeigniter',
+ languages: ['php'],
+
+ detect(context: ResolutionContext): boolean {
+ return (
+ context.fileExists('application/config/config.php') ||
+ context.fileExists('application/config/routes.php') ||
+ context.fileExists('system/core/CodeIgniter.php')
+ );
+ },
+
+ resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+ // controller/method → method on Controller class in application/controllers/
+ const slashMatch = ref.referenceName.match(/^([a-zA-Z0-9_\/]+)\/([a-zA-Z_][a-zA-Z0-9_]*)$/);
+ if (slashMatch) {
+ const [, controllerPath, methodName] = slashMatch;
+ const target = resolveControllerMethod(controllerPath!, methodName!, context);
+ if (target) {
+ return {
+ original: ref,
+ targetNodeId: target,
+ confidence: 0.85,
+ resolvedBy: 'framework',
+ };
+ }
+ }
+ return null;
+ },
+
+ extract(filePath: string, content: string): FrameworkExtractionResult {
+ if (!filePath.endsWith('.php')) return { nodes: [], references: [] };
+
+ const normalized = filePath.replace(/\\/g, '/');
+ const nodes: Node[] = [];
+ const references: UnresolvedRef[] = [];
+ const safe = stripCommentsForRegex(content, 'php');
+ const now = Date.now();
+
+ if (/(^|\/)application\/config\/(?:[^\/]+\/)?routes\.php$/.test(normalized)) {
+ extractExplicitRoutes(normalized, safe, nodes, references, now);
+ }
+
+ const controllerMatch = normalized.match(/(^|\/)application\/controllers\/(.+)\.php$/);
+ if (controllerMatch && CONTROLLER_BASE_RE.test(safe)) {
+ extractConventionRoutes(normalized, controllerMatch[2]!, safe, nodes, references, now);
+ }
+
+ const seenImports = new Set();
+ extractLoaderUsages(normalized, safe, references, seenImports);
+ extractMagicPropertyAccess(normalized, safe, references, seenImports);
+
+ return { nodes, references };
+ },
+};
+
+/**
+ * CI3 magic-loads models/libraries at runtime via:
+ * $this->load->model('Foo_model');
+ * $this->load->model('subdir/Foo_model');
+ * $this->load->model('Foo_model', 'alias');
+ * $this->load->library('Some_lib');
+ *
+ * Static analyzers can't follow this without help. We emit a file → ClassName
+ * `imports` reference per load() call; the standard name-matcher resolves it to
+ * the real class node in application/models/ or application/libraries/. The
+ * graph then shows "which files use Foo_model" without needing PHP AST context.
+ *
+ * We do NOT (yet) try to resolve subsequent `$this->Foo_model->method()` calls
+ * to specific method nodes — that requires correlating with the alias scope per
+ * file, which is left for a follow-up.
+ */
+function extractLoaderUsages(
+ filePath: string,
+ safe: string,
+ references: UnresolvedRef[],
+ seen: Set
+): void {
+ const loaderRegex =
+ /\$this\s*->\s*load\s*->\s*(?:model|library)\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_\/]*)['"]/g;
+
+ let match: RegExpExecArray | null;
+ while ((match = loaderRegex.exec(safe)) !== null) {
+ const arg = match[1]!;
+ const lastSegment = arg.split('/').pop()!;
+ const className = lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
+ if (seen.has(className)) continue;
+ seen.add(className);
+
+ const line = safe.slice(0, match.index).split('\n').length;
+ references.push({
+ fromNodeId: `file:${filePath}`,
+ referenceName: className,
+ referenceKind: 'imports',
+ line,
+ column: 0,
+ filePath,
+ language: 'php',
+ });
+ }
+}
+
+/**
+ * Capture `$this->ModelName->...` access — the CI3 magic-loaded property
+ * pattern. We can't know statically whether `ModelName` actually maps to a
+ * model, but two filters keep the noise down:
+ * 1. Only PascalCase identifiers (`$this->Restomodel`, not `$this->db`).
+ * CI3 built-in properties (load, db, input, session, config, lang,
+ * router, uri, security, parser, log, output, cache) are all lowercase.
+ * 2. The reference goes into the normal resolution pipeline; if the name
+ * doesn't match any class in the graph, it just stays unresolved (no
+ * edge created). False positives only happen when an unrelated
+ * Uppercase property name happens to collide with a real class name.
+ */
+function extractMagicPropertyAccess(
+ filePath: string,
+ safe: string,
+ references: UnresolvedRef[],
+ seen: Set
+): void {
+ const propRegex = /\$this\s*->\s*([A-Z][A-Za-z0-9_]*)\s*->/g;
+
+ let match: RegExpExecArray | null;
+ while ((match = propRegex.exec(safe)) !== null) {
+ const className = match[1]!;
+ if (seen.has(className)) continue;
+ seen.add(className);
+
+ const line = safe.slice(0, match.index).split('\n').length;
+ references.push({
+ fromNodeId: `file:${filePath}`,
+ referenceName: className,
+ referenceKind: 'imports',
+ line,
+ column: 0,
+ filePath,
+ language: 'php',
+ });
+ }
+}
+
+function extractExplicitRoutes(
+ filePath: string,
+ safe: string,
+ nodes: Node[],
+ references: UnresolvedRef[],
+ now: number
+): void {
+ // $route['pattern'] = 'controller/method';
+ // $route['pattern']['GET'] = 'controller/method';
+ const routeRegex =
+ /\$route\s*\[\s*['"]([^'"]+)['"]\s*\](?:\s*\[\s*['"]([A-Za-z]+)['"]\s*\])?\s*=\s*['"]([^'"]+)['"]/g;
+
+ let match: RegExpExecArray | null;
+ while ((match = routeRegex.exec(safe)) !== null) {
+ const [, pattern, verb, handler] = match;
+ if (SPECIAL_ROUTE_KEYS.has(pattern!)) continue;
+
+ const line = safe.slice(0, match.index).split('\n').length;
+ const method = (verb || 'ANY').toUpperCase();
+ const routePath = '/' + pattern!.replace(/^\/+/, '');
+
+ const routeNode: Node = {
+ id: `route:${filePath}:${line}:${method}:${routePath}`,
+ kind: 'route',
+ name: `${method} ${routePath}`,
+ qualifiedName: `${filePath}::route:${routePath}`,
+ filePath,
+ startLine: line,
+ endLine: line,
+ startColumn: 0,
+ endColumn: match[0].length,
+ language: 'php',
+ updatedAt: now,
+ };
+ nodes.push(routeNode);
+
+ references.push({
+ fromNodeId: routeNode.id,
+ referenceName: handler!,
+ referenceKind: 'references',
+ line,
+ column: 0,
+ filePath,
+ language: 'php',
+ });
+ }
+}
+
+function extractConventionRoutes(
+ filePath: string,
+ controllerRelPath: string,
+ safe: string,
+ nodes: Node[],
+ references: UnresolvedRef[],
+ now: number
+): void {
+ const urlBase = '/' + controllerRelPath.toLowerCase();
+
+ const methodRegex =
+ /^\s*public\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/gm;
+
+ let match: RegExpExecArray | null;
+ while ((match = methodRegex.exec(safe)) !== null) {
+ const methodName = match[1]!;
+ if (methodName === '__construct' || methodName === '__destruct') continue;
+ if (methodName.startsWith('_')) continue; // CI3 convention: underscore = not web-callable
+
+ const line = safe.slice(0, match.index).split('\n').length;
+ const routePath = methodName === 'index' ? urlBase : `${urlBase}/${methodName.toLowerCase()}`;
+
+ const routeNode: Node = {
+ id: `route:${filePath}:${line}:ANY:${routePath}`,
+ kind: 'route',
+ name: `ANY ${routePath}`,
+ qualifiedName: `${filePath}::route:${routePath}`,
+ filePath,
+ startLine: line,
+ endLine: line,
+ startColumn: 0,
+ endColumn: match[0].length,
+ language: 'php',
+ updatedAt: now,
+ };
+ nodes.push(routeNode);
+
+ references.push({
+ fromNodeId: routeNode.id,
+ referenceName: methodName,
+ referenceKind: 'references',
+ line,
+ column: 0,
+ filePath,
+ language: 'php',
+ });
+ }
+}
+
+/**
+ * Resolve `controller/method` (CI3 explicit route handler) to a method node.
+ * CI3 URL segments are lowercased; the file on disk is PascalCase.
+ */
+function resolveControllerMethod(
+ controllerPath: string,
+ methodName: string,
+ context: ResolutionContext
+): string | null {
+ const segments = controllerPath.split('/').filter(Boolean);
+ if (segments.length === 0) return null;
+
+ const controllerName = segments.pop()!;
+ const subdir = segments.join('/');
+ const capitalized = controllerName.charAt(0).toUpperCase() + controllerName.slice(1);
+
+ const candidatePaths = [
+ `application/controllers/${subdir ? subdir + '/' : ''}${capitalized}.php`,
+ `application/controllers/${subdir ? subdir + '/' : ''}${controllerName}.php`,
+ ];
+
+ for (const candidate of candidatePaths) {
+ if (!context.fileExists(candidate)) continue;
+ const fileNodes = context.getNodesInFile(candidate);
+ const methodNode = fileNodes.find(
+ (n) => n.kind === 'method' && n.name === methodName
+ );
+ if (methodNode) return methodNode.id;
+ const classNode = fileNodes.find((n) => n.kind === 'class');
+ if (classNode) return classNode.id;
+ }
+
+ return null;
+}
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index f50ea84a..6d326a01 100644
--- a/src/resolution/frameworks/index.ts
+++ b/src/resolution/frameworks/index.ts
@@ -7,6 +7,7 @@
import { FrameworkResolver, ResolutionContext } from '../types';
import type { Language } from '../../types';
import { laravelResolver } from './laravel';
+import { codeigniterResolver } from './codeigniter';
import { expressResolver } from './express';
import { reactResolver } from './react';
import { svelteResolver } from './svelte';
@@ -25,6 +26,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
// PHP
laravelResolver,
+ codeigniterResolver,
// JavaScript/TypeScript
expressResolver,
reactResolver,
@@ -104,6 +106,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
// Re-export framework resolvers
export { laravelResolver, FACADE_MAPPINGS } from './laravel';
+export { codeigniterResolver } from './codeigniter';
export { expressResolver } from './express';
export { reactResolver } from './react';
export { svelteResolver } from './svelte';