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';