diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..9153925b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- **Inertia** page props are now part of the graph (Laravel, Rails and Phoenix). Each prop your server hands to a page becomes a symbol, linked to the code in the page component that actually reads it — so you can ask whether a prop is read anywhere, and see everything that has to change when you rename one. This works even when the two sides don't share a spelling: a server emitting `user_display_name` is matched to a client reading `userDisplayName`, which no search can do because each name appears on only one side. Nothing needs configuring — both spellings are tried and the one that matched is recorded. +- Inertia is now found in projects where the app is not the repository root — a `mix.exs` or `package.json` under `app/` or a client workspace counts, not just one at the top level. Because Inertia spans a server and a client, that layout is common, and reading only the root meant the whole feature stayed silently switched off. +- Phoenix pages rendered with a pipe — `conn |> render_inertia("Page", %{...})` — have their props read now. Only the form that passes the connection as an argument was recognised before, which is the form Elixir codebases write least. +- A prop counts as *used* only when a real function or value reads it. A `type` or `interface` that merely declares the field does not count, which is what keeps a never-rendered prop visible instead of appearing used by its own type declaration. The distinction is made per symbol rather than per file, so a module that exports both payload types and runtime helpers is treated correctly as both. Test files are deliberately still counted — a rendered-page test is sometimes the only thing that reads a field. ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index 48323f6fd..ef48ef705 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | | **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods | +| **Inertia** | `Inertia::render`, `render inertia:`, `render_inertia` — server prop keys become symbols linked to the page component that reads them, across the adapter's camelize transform (Laravel / Rails / Phoenix) | | **Play** | `GET`/`POST`/… verb routes in `conf/routes` → `Controller.method` actions (Scala + Java) | | **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` | | **Axum / actix / Rocket** | `.route("/x", get(handler))` | diff --git a/__tests__/inertia-prop-boundary.test.ts b/__tests__/inertia-prop-boundary.test.ts new file mode 100644 index 000000000..eea97f49b --- /dev/null +++ b/__tests__/inertia-prop-boundary.test.ts @@ -0,0 +1,346 @@ +/** + * The Inertia prop boundary, end to end. + * + * The contract is the prop map, written twice and referenced by neither half. + * These tests pin the three things that make linking it useful rather than + * merely plausible: + * + * 1. the two halves may not share a spelling (a camelizing server emits + * `user_display_name` for a client reading `userDisplayName`); + * 2. "used" is a per-SYMBOL question — a `type`/`interface` that DECLARES a + * field is not a consumer of it, and a module that holds both declarations + * and runtime helpers has to be able to be both at once; + * 3. a data-keyed map is a dynamic edge, not a prop schema, and must produce + * no links at all. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { propKeysFromMap, clientCandidates, inertiaResolver } from '../src/resolution/frameworks/inertia'; +import { findPageFile } from '../src/resolution/inertia-prop-synthesizer'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function hasSqliteBindings(): boolean { + try { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(':memory:'); + db.close(); + return true; + } catch { + return false; + } +} +const HAS_SQLITE = hasSqliteBindings(); + +describe('propKeysFromMap — only literal, only top level', () => { + it('reads Elixir atom keys', () => { + expect(propKeysFromMap('%{user_display_name: 1, year_2024: 2}').map((p) => p.key)) + .toEqual(['user_display_name', 'year_2024']); + }); + + it('reads PHP array keys', () => { + expect(propKeysFromMap("['user_name' => $u, 'is_admin' => false]").map((p) => p.key)) + .toEqual(['user_name', 'is_admin']); + }); + + it('reads Ruby symbol keys in both spellings', () => { + expect(propKeysFromMap('{ user_name: u, :is_admin => false }').map((p) => p.key)) + .toEqual(['user_name', 'is_admin']); + }); + + it('flags a preserve_case key', () => { + const keys = propKeysFromMap('%{preserve_case(:HTTP_status) => v, normal_key: 1}'); + expect(keys.find((k) => k.key === 'HTTP_status')?.preserved).toBe(true); + expect(keys.find((k) => k.key === 'normal_key')?.preserved).toBe(false); + }); + + it('ignores keys nested inside another map', () => { + // An adapter camelizes EVERY key at every depth with no distinction + // between a schema key and a DATA key, so a map keyed by a user-supplied + // name is transformed exactly like a field name. Treating those as props + // would invent names and produce confident wrong links. + const keys = propKeysFromMap('%{by_region: %{north_east: 1, south_west: 2}, total: 3}').map((k) => k.key); + expect(keys).toEqual(expect.arrayContaining(['by_region', 'total'])); + expect(keys).not.toContain('north_east'); + expect(keys).not.toContain('south_west'); + }); +}); + +describe('clientCandidates — both spellings, because the setting is not assumed', () => { + it('offers the verbatim and camelized forms', () => { + expect(clientCandidates('user_display_name', false)).toEqual([ + 'user_display_name', 'userDisplayName', + ]); + }); + + it('offers only the verbatim form for a preserved key', () => { + expect(clientCandidates('HTTP_status', true)).toEqual(['HTTP_status']); + }); + + it('collapses to one candidate when the transform is a no-op', () => { + expect(clientCandidates('total', false)).toEqual(['total']); + }); +}); + +describe('findPageFile — Inertia page-name convention', () => { + const files = [ + 'assets/inertia/pages/Reports/Index.tsx', + 'assets/inertia/pages/Dashboard.tsx', + 'assets/inertia/lib/Dashboard.tsx', + 'app/components/Reports/Index.tsx', + ]; + + it('resolves a nested page name against any pages root', () => { + expect(findPageFile('Reports/Index', files)).toBe('assets/inertia/pages/Reports/Index.tsx'); + }); + + it('requires a pages/ segment, so a same-named module is not mistaken for it', () => { + expect(findPageFile('Dashboard', files)).toBe('assets/inertia/pages/Dashboard.tsx'); + }); + + it('returns null for a page with no component', () => { + expect(findPageFile('Missing/Page', files)).toBeNull(); + }); +}); + +describe('inertia resolver — extraction from each adapter', () => { + const extract = (file: string, content: string) => + inertiaResolver.extract!(file, content).nodes; + + it('extracts props from a Phoenix render_inertia call', () => { + const nodes = extract('lib/app_web/controllers/report_controller.ex', + 'def index(conn, _p) do\n render_inertia(conn, "Reports/Index", %{user_display_name: w, total: t})\nend\n'); + expect(nodes.map((n) => n.name)).toEqual(['user_display_name', 'total']); + expect(nodes[0]!.decorators).toContain('page=Reports/Index'); + // Both halves of the contract are visible on the node itself. + expect(nodes[0]!.signature).toBe('user_display_name → user_display_name | userDisplayName'); + }); + + it('extracts props from a Laravel Inertia::render call', () => { + const nodes = extract('app/Http/Controllers/ReportController.php', + " $u]); } }\n"); + expect(nodes.map((n) => n.name)).toEqual(['user_name']); + expect(nodes[0]!.decorators).toContain('page=Reports/Index'); + }); + + it('extracts props from a Rails render inertia: call', () => { + const nodes = extract('app/controllers/reports_controller.rb', + "class ReportsController\n def index\n render inertia: 'Reports/Index', props: { user_name: @u }\n end\nend\n"); + expect(nodes.map((n) => n.name)).toEqual(['user_name']); + }); + + it('ignores a file with no render call', () => { + expect(extract('app/models/user.rb', 'class User\nend\n')).toEqual([]); + }); + + it('extracts props from the piped Phoenix form, where conn is not an argument', () => { + // Idiomatic Elixir pipes the conn in, so the render call carries only the + // page and the props. A pattern that requires a positional conn matches + // none of these — which is most of them in a real Phoenix codebase. + const nodes = extract('lib/app_web/controllers/page_controller.ex', + 'conn\n|> put_status(404)\n|> Inertia.Controller.render_inertia("Errors/NotFound", %{requested_path: p, suggestion: s})\n'); + expect(nodes.map((n) => n.name)).toEqual(['requested_path', 'suggestion']); + expect(nodes[0]!.decorators).toContain('page=Errors/NotFound'); + }); + + it('ignores a computed page name — it names no component we could find', () => { + expect(extract('lib/x.ex', 'render_inertia(conn, page_name, %{a: 1})')).toEqual([]); + // The piped form of the same thing is equally unusable. + expect(extract('lib/x.ex', 'conn |> render_inertia(page_name, %{a: 1})')).toEqual([]); + }); +}); + +describe('inertia resolver — detection', () => { + // Inertia is a server+client framework, so the app that uses it is often not + // the root of the repo it lives in. Reading only root manifests means the + // resolver silently never activates on that layout, and a silent + // non-activation is indistinguishable from a project that has no Inertia. + const contextWith = (files: Record): any => ({ + readFile: (p: string) => files[p] ?? null, + getAllFiles: () => Object.keys(files), + }); + + it('detects an adapter declared in a root manifest', () => { + expect(inertiaResolver.detect!(contextWith({ + 'mix.exs': 'defp deps do [{:inertia, "~> 2.0"}] end', + }))).toBe(true); + }); + + it('detects an adapter declared in a nested app manifest', () => { + expect(inertiaResolver.detect!(contextWith({ + 'package.json': '{"devDependencies":{"prettier":"^3"}}', + 'app/mix.exs': 'defp deps do [{:inertia, "~> 2.0"}] end', + 'app/lib/app_web/router.ex': 'defmodule R do end', + }))).toBe(true); + }); + + it('detects a client-only adapter under a workspace', () => { + expect(inertiaResolver.detect!(contextWith({ + 'app/assets/package.json': '{"dependencies":{"@inertiajs/react":"^2"}}', + }))).toBe(true); + }); + + it('stays off for a project with manifests but no Inertia', () => { + expect(inertiaResolver.detect!(contextWith({ + 'package.json': '{"dependencies":{"react":"^19"}}', + 'app/mix.exs': 'defp deps do [{:phoenix, "~> 1.7"}] end', + 'app/composer.json': '{"require":{"laravel/framework":"^11"}}', + }))).toBe(false); + }); + + it('does not read every file in the repo looking for one', () => { + // The scan is over manifests by name, not content — a large repo must not + // pay a whole-index read for a framework it does not use. + const read: string[] = []; + const files: Record = {}; + for (let i = 0; i < 500; i++) files[`src/mod_${i}.ts`] = 'export const x = 1;'; + files['app/mix.exs'] = 'defp deps do [{:phoenix, "~> 1.7"}] end'; + inertiaResolver.detect!({ + readFile: (p: string) => { read.push(p); return files[p] ?? null; }, + getAllFiles: () => Object.keys(files), + } as any); + expect(read.filter((p) => p.endsWith('.ts'))).toEqual([]); + }); +}); + +describe.skipIf(!HAS_SQLITE)('prop → consumer edges', () => { + let root: string; + let cg: any; + let edges: Array>; + + beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-inertia-')); + const write = (rel: string, body: string) => { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); + }; + + write('package.json', JSON.stringify({ dependencies: { '@inertiajs/react': '^1.0.0' } })); + // Rails server half — available without any extra language support. + write('app/controllers/reports_controller.rb', `class ReportsController + def index + render inertia: 'Reports/Index', props: { + user_display_name: @w, + never_drawn: @n, + total: @t + } + end +end +`); + // The page component. `userDisplayName` is read by the component (a + // consumer); `neverDrawn` appears ONLY in the type declaration, which must + // not count; `total` is read by a helper function. + write('assets/pages/Reports/Index.tsx', `import { formatTotal } from '../../lib/report-types'; + +interface Props { + userDisplayName: number; + neverDrawn: string; + total: number; +} + +export default function Index({ userDisplayName, total }: Props) { + return userDisplayName + formatTotal(total); +} +`); + // The module that is BOTH: declarations that must not count as consumers, + // and a runtime helper that must. + write('assets/lib/report-types.ts', `export interface ReportPayload { + userDisplayName: number; + neverDrawn: string; +} + +export function formatTotal(total: number): string { + return String(total); +} +`); + + const CodeGraph = (await import('../src/index')).default; + cg = CodeGraph.initSync(root, { + config: { include: ['**/*.rb', '**/*.ts', '**/*.tsx', 'package.json'], exclude: [] }, + }); + await cg.indexAll(); + edges = (cg as any).db.db + .prepare( + `SELECT s.name src, s.kind skind, s.file_path sfile, + t.name prop, t.qualified_name propq, + json_extract(e.metadata,'$.clientKey') clientKey, + json_extract(e.metadata,'$.page') page + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'inertia-prop'` + ) + .all(); + }, 120000); + + afterEach(() => { + cg?.destroy(); + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('creates a node per emitted prop, named as the server wrote it', () => { + const props = (cg as any).db.db + .prepare(`SELECT name, qualified_name q FROM nodes WHERE kind='property' ORDER BY name`) + .all(); + expect(props.map((p: any) => p.name)).toEqual(['never_drawn', 'total', 'user_display_name']); + expect(props.find((p: any) => p.name === 'total').q).toBe('Reports/Index.total'); + }); + + it('links a prop across the camelize boundary, where a grep finds only one side', () => { + const forProp = edges.filter((e) => e.prop === 'user_display_name'); + expect(forProp.length).toBeGreaterThan(0); + // The server never writes `userDisplayName` and the client never writes + // `user_display_name`; this edge is the only thing joining them. + expect(forProp[0]!.clientKey).toBe('userDisplayName'); + expect(forProp.map((e) => e.src)).toContain('Index'); + }); + + it('does NOT count a type declaration as a consumer', () => { + // `neverDrawn` appears in TWO interfaces and nowhere else. If a declaration + // counted, this prop would look used and the dead-prop finding would be + // invisible — the failure that hides the feature's whole point. + const forProp = edges.filter((e) => e.prop === 'never_drawn'); + expect(forProp).toEqual([]); + }); + + it('leaves a genuinely dead prop with no consumers, so it is findable', () => { + const consumed = new Set(edges.map((e) => e.prop)); + expect(consumed.has('user_display_name')).toBe(true); + expect(consumed.has('total')).toBe(true); + expect(consumed.has('never_drawn')).toBe(false); + }); + + it('treats one module as BOTH declaration and consumer', () => { + // report-types.ts exports interfaces (not consumers) AND `formatTotal` (a + // consumer). Any rule that has to classify the FILE gets this wrong in one + // direction; both directions are silent. + const fromTypes = edges.filter((e) => e.sfile.endsWith('lib/report-types.ts')); + for (const e of fromTypes) expect(e.skind).not.toBe('interface'); + // And the interfaces there contributed nothing. + expect(fromTypes.every((e) => e.skind !== 'type_alias')).toBe(true); + }); + + it('attributes each consumer to the symbol that reads it, not the file', () => { + for (const e of edges) { + expect(['function', 'method', 'variable', 'constant', 'component', 'property', 'field']) + .toContain(e.skind); + } + }); + + it('answers "is this prop read anywhere" through the graph', () => { + const dead = (cg as any).db.db + .prepare( + `SELECT n.name FROM nodes n + WHERE n.kind = 'property' + AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.target = n.id)` + ) + .all(); + expect(dead.map((d: any) => d.name)).toEqual(['never_drawn']); + }); +}); diff --git a/__tests__/inertia-props.test.ts b/__tests__/inertia-props.test.ts new file mode 100644 index 000000000..78ed044a3 --- /dev/null +++ b/__tests__/inertia-props.test.ts @@ -0,0 +1,131 @@ +/** + * Inertia prop-boundary name math. + * + * The contract between an Inertia server and its page component is the prop map + * itself, written twice — and when the server camelizes, the two halves do not + * share a spelling, so a grep for either name finds exactly one side. + * + * The transform below is pinned case-by-case against the behaviour of the real + * `Phoenix.Naming.camelize/2` rather than derived from the rule, because every + * divergence produces a plausible-LOOKING client name: a mismatch reads as + * "this field is never drawn" instead of "the transform is wrong". A test is + * the only thing that makes those cases visible. + */ + +import { describe, it, expect } from 'vitest'; +import { + phoenixCamelizeLower, + phoenixCamelizeUpper, + clientPropName, + stripPreserveCase, + isPropConsumer, +} from '../src/resolution/inertia-props'; + +describe('phoenixCamelizeLower — measured against the real camelize/2', () => { + // Each row is an observed input/output pair. The third column records what a + // naive snake→camel would have produced, which is what makes the divergent + // rows worth pinning. + const cases: Array<[input: string, expected: string, note: string]> = [ + ['user_display_name', 'userDisplayName', 'agrees with the naive rule'], + ['_internal', 'internal', 'leading underscore is STRIPPED'], + ['a__b', 'aB', 'consecutive underscores COLLAPSE'], + ['foo_', 'foo', 'trailing underscore is DROPPED'], + ['HTTP_status', 'hTTPStatus', 'only the FIRST character is lowercased'], + ['a_B', 'a_B', 'underscore before an UPPERCASE letter stays LITERAL'], + ['item_90x', 'item90x', 'underscore before a DIGIT is dropped'], + ['year_2024', 'year2024', 'digits again'], + ['a_coverage90', 'aCoverage90', 'digit inside a word is untouched'], + ['already_camelCase', 'alreadyCamelCase', 'an existing camel hump survives'], + ]; + + for (const [input, expected, note] of cases) { + it(`${JSON.stringify(input)} → ${JSON.stringify(expected)} — ${note}`, () => { + expect(phoenixCamelizeLower(input)).toBe(expected); + }); + } + + it('is not the naive rule, on exactly the rows where that matters', () => { + const naive = (s: string) => s.replace(/_(\w)/g, (_, c: string) => c.toUpperCase()); + // These four are where a regex-based implementation would silently disagree. + for (const input of ['_internal', 'a__b', 'foo_', 'HTTP_status']) { + expect(phoenixCamelizeLower(input)).not.toBe(naive(input)); + } + // And this one, where the naive rule over-eagerly joins across a capital. + expect(naive('a_B')).toBe('aB'); + expect(phoenixCamelizeLower('a_B')).toBe('a_B'); + }); + + it('handles the degenerate inputs without throwing', () => { + expect(phoenixCamelizeLower('')).toBe(''); + expect(phoenixCamelizeLower('_')).toBe(''); + expect(phoenixCamelizeLower('___')).toBe(''); + expect(phoenixCamelizeLower('a')).toBe('a'); + }); +}); + +describe('phoenixCamelizeUpper — the module-name form', () => { + it('upper-cases the first character and otherwise agrees', () => { + expect(phoenixCamelizeUpper('user_display_name')).toBe('UserDisplayName'); + expect(phoenixCamelizeUpper('_internal')).toBe('Internal'); + }); + + it('turns a slash into a dotted module path', () => { + expect(phoenixCamelizeUpper('my_app/some_module')).toBe('MyApp.SomeModule'); + }); +}); + +describe('clientPropName — the configured transform', () => { + it('camelizes when the adapter is configured to', () => { + expect(clientPropName('user_display_name', 'camelize')).toBe('userDisplayName'); + }); + + it('leaves the key alone when it is not', () => { + // Laravel and Rails adapters emit keys verbatim by default. + expect(clientPropName('user_display_name', 'preserve')).toBe('user_display_name'); + }); +}); + +describe('preserve_case — the per-key opt-out', () => { + it('unwraps a preserved key and flags it', () => { + expect(stripPreserveCase('preserve_case(:some_key)')).toEqual({ key: 'some_key', preserved: true }); + expect(stripPreserveCase('preserve_case("some_key")')).toEqual({ key: 'some_key', preserved: true }); + }); + + it('leaves an ordinary key untouched', () => { + expect(stripPreserveCase('some_key')).toEqual({ key: 'some_key', preserved: false }); + }); + + it('means the key must NOT be transformed', () => { + const { key, preserved } = stripPreserveCase('preserve_case(:HTTP_status)'); + expect(preserved).toBe(true); + // Transforming it anyway would produce `hTTPStatus` and mis-link the field, + // with nothing going red — the same invisible failure as a wrong transform. + expect(clientPropName(key, preserved ? 'preserve' : 'camelize')).toBe('HTTP_status'); + }); +}); + +describe('isPropConsumer — "used" is a per-SYMBOL question, not per-file', () => { + it('counts runtime symbols as consumers', () => { + for (const kind of ['function', 'method', 'variable', 'constant', 'component']) { + expect(isPropConsumer(kind)).toBe(true); + } + }); + + it('does NOT count a type-level declaration as a consumer', () => { + // A shared types module declares the payload AND exports runtime helpers + // over it. If its declarations counted, every correctly typed field would + // be "used" by its own declaration and nothing could ever be reported + // orphaned — the failure that hides the finding completely. + for (const kind of ['interface', 'type_alias', 'trait', 'protocol']) { + expect(isPropConsumer(kind)).toBe(false); + } + }); + + it('lets one file be both, which is the case that breaks path-based rules', () => { + // The same module can hold 74 declarations and 37 runtime exports. Any + // rule that has to classify the FILE gets it wrong in one direction; both + // directions are silent. + expect(isPropConsumer('interface')).toBe(false); + expect(isPropConsumer('function')).toBe(true); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0d53829b7..de8e6f9a5 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { stripCommentsForRegex } from './strip-comments'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; +import { inertiaPropEdges } from './inertia-prop-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; @@ -3611,6 +3612,10 @@ export const SYNTH_PASSES: SynthPassDef[] = [ }, { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) }, { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, + // Inertia prop map to the page component that reads it. ALWAYS gated: the + // server half may be PHP, Ruby or Elixir and the client half TSX/Vue/Svelte, + // and the pass is a no-op unless the framework resolver emitted prop nodes. + { name: 'inertiaPropEdges', gate: ALWAYS, run: (_q, c, y) => inertiaPropEdges(c, y) }, ]; /** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */ diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 91da9a01c..6af788b68 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -29,6 +29,7 @@ import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; import { cicsResolver } from './cics'; import { terraformResolver } from './terraform'; +import { inertiaResolver } from './inertia'; /** * All registered framework resolvers @@ -76,6 +77,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ cicsResolver, // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module terraformResolver, + // Inertia (Laravel / Rails / Phoenix) — server prop map to the page component + inertiaResolver, ]; /** diff --git a/src/resolution/frameworks/inertia.ts b/src/resolution/frameworks/inertia.ts new file mode 100644 index 000000000..d4180e271 --- /dev/null +++ b/src/resolution/frameworks/inertia.ts @@ -0,0 +1,246 @@ +/** + * Inertia prop-boundary resolver. + * + * Inertia (Laravel, Rails, Phoenix) renders a page by handing a server-side + * PROP MAP to a client component. There is no REST schema and no GraphQL + * document: the contract IS that map, and it is written twice — once as the + * server map, once as a client destructure or type. Nothing in either half + * references the other, so the boundary is invisible to a graph built from + * calls and imports, and three ordinary questions have no answer: + * + * - this prop is emitted — is it read anywhere? (a dead prop costs a query + * and payload on every render) + * - this prop is read — is it still emitted? (renders as undefined, silently) + * - I am renaming this prop — what else must change in the same commit? + * + * Worse, when the server camelizes its keys the two halves do not even share a + * spelling: the server emits `user_display_name` and the client reads + * `userDisplayName`, so a grep for either finds exactly one side. That is what + * makes this a resolver problem rather than something search can answer. + * + * NO CONFIGURATION, AND NO GLOBAL STATE. Rather than read the adapter's + * camelize setting out of project config — which would have to be discovered + * once and then carried into per-file extraction — a prop carries BOTH candidate + * spellings and the consumer scan records which one actually matched. That + * works unchanged for Laravel and Rails (verbatim keys) and for Phoenix with + * `camelize_props: true`, and it cannot silently mis-link if a project's + * setting is not what we assumed. + */ + +import { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import type { Node } from '../../types'; +import { detectLanguage } from '../../extraction/grammars'; +import { stripCommentsForRegex, type CommentLang } from '../strip-comments'; +import { phoenixCamelizeLower, stripPreserveCase } from '../inertia-props'; + +/** Marks a node this resolver created, and carries the page it belongs to. */ +export const INERTIA_PROP_MARKER = 'inertia-prop'; + +/** Server files that can hold a render call, by extension. */ +function serverLang(filePath: string): CommentLang | null { + if (/\.php$/i.test(filePath)) return 'php'; + if (/\.rb$/i.test(filePath)) return 'ruby'; + // Elixir has no comment-stripper entry; `#` line comments match ruby's. + if (/\.exs?$/i.test(filePath)) return 'ruby'; + return null; +} + +/** + * The render calls each adapter spells, reduced to "page name" + "prop map + * text". Only a LITERAL page name is usable — a computed one names no + * component we could find. + */ +const RENDER_PATTERNS: RegExp[] = [ + // Laravel: Inertia::render('Page/Name', [ ... ]) / inertia('Page', [ ... ]) + /(?:Inertia::render|inertia)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\[)/g, + // Rails: render inertia: 'Page/Name', props: { ... } + /render\s+inertia:\s*['"]([^'"]+)['"]\s*,\s*props:\s*(\{)/g, + // Phoenix: render_inertia(conn, "Page/Name", %{ ... }), and the pipe form + // `conn |> render_inertia("Page/Name", %{ ... })` — which is how Elixir + // actually writes it, and where the conn is not an argument at the call site + // at all. The leading conn is therefore optional; excluding quotes from it + // keeps the page name itself from being mistaken for one. + /render_inertia\s*\(\s*(?:[^,()'"]+,\s*)?['"]([^'"]+)['"]\s*,\s*(%\{|\{)/g, +]; + +/** Balanced-delimiter slice starting at `open`, so a nested map does not truncate it. */ +function balancedSlice(text: string, open: number): string { + const opener = text[open]!; + const closer = opener === '[' ? ']' : '}'; + let depth = 0; + for (let i = open; i < text.length; i++) { + const ch = text[i]!; + if (ch === opener) depth++; + else if (ch === closer) { + depth--; + if (depth === 0) return text.slice(open, i + 1); + } + } + return text.slice(open); +} + +/** + * The prop keys a map literal declares, in source order. + * + * Only literal keys are read, and that is a correctness requirement rather than + * a simplification: an adapter transforms EVERY map key at every depth, with no + * distinction between a schema key and a DATA key — a map keyed by a + * user-supplied name is camelized exactly like a field name. A computed key is + * therefore a dynamic edge, and inventing a name for it would produce a + * confident wrong link. Nested maps are skipped for the same reason: their keys + * are frequently data. + */ +export function propKeysFromMap(mapText: string): Array<{ key: string; preserved: boolean; offset: number }> { + const out: Array<{ key: string; preserved: boolean; offset: number }> = []; + // Only scan the TOP level of the map — depth tracking keeps a nested literal + // (often a data-keyed collection) from contributing phantom prop names. + let depth = 0; + const keyRe = /(?:^|[,{[\s])\s*(?:(['"])([\w]+)\1\s*(?:=>|:)|([\w]+):(?!:)|:([\w]+)\s*=>|(preserve_case\([^)]*\))\s*(?:=>|:))/g; + // Only collection delimiters nest. Parentheses are NOT counted: they appear + // in ordinary values (`total: sum(x)`) and in a key expression itself + // (`preserve_case(:K) => v`), so treating them as depth would split the + // top-level scan and drop real keys. + const opens = new Set(['{', '[']); + const closes = new Set(['}', ']']); + const topLevelRanges: Array<[number, number]> = []; + let rangeStart = 0; + for (let i = 0; i < mapText.length; i++) { + const ch = mapText[i]!; + if (opens.has(ch)) { + depth++; + if (depth === 1) rangeStart = i; + else if (depth === 2) topLevelRanges.push([rangeStart, i]); + } else if (closes.has(ch)) { + if (depth === 2) rangeStart = i + 1; + depth--; + if (depth === 0) topLevelRanges.push([rangeStart, i]); + } + } + for (const [from, to] of topLevelRanges) { + const segment = mapText.slice(from, to); + keyRe.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = keyRe.exec(segment)) !== null) { + const raw = m[2] ?? m[3] ?? m[4] ?? m[5]; + if (!raw) continue; + const { key, preserved } = stripPreserveCase(raw); + if (!key) continue; + out.push({ key, preserved, offset: from + m.index }); + } + } + return out; +} + +/** Both spellings a client could be using for one server key. */ +export function clientCandidates(key: string, preserved: boolean): string[] { + if (preserved) return [key]; + const camel = phoenixCamelizeLower(key); + return camel && camel !== key ? [key, camel] : [key]; +} + +/** The dependency manifests an Inertia adapter can be declared in. */ +const MANIFESTS = new Set(['package.json', 'composer.json', 'Gemfile', 'mix.exs']); + +/** + * Backstop for a repo carrying an unusual number of manifests; a real project + * declares its server and client adapters in a handful. + */ +const MANIFEST_SCAN_CAP = 64; + +/** + * Signals that a project uses Inertia at all. + * + * Manifests are looked for ANYWHERE in the index, not just at the project + * root. Inertia is a server+client framework, so the app that uses it is + * frequently not the root of the repository it lives in — `app/mix.exs` beside + * `app/assets/`, or a `package.json` under a client workspace, with the root + * holding only tooling. Reading the root alone means the resolver silently + * never activates on exactly the layout Inertia projects tend to have, and a + * silent non-activation looks identical to a project that does not use it. + */ +function usesInertia(context: ResolutionContext): boolean { + const rootFirst = ['package.json', 'composer.json', 'Gemfile', 'mix.exs']; + for (const manifest of rootFirst) { + const content = context.readFile(manifest); + if (content && /inertia/i.test(content)) return true; + } + + let scanned = 0; + for (const filePath of context.getAllFiles()) { + const base = filePath.slice(filePath.lastIndexOf('/') + 1); + if (!MANIFESTS.has(base) || !filePath.includes('/')) continue; + if (++scanned > MANIFEST_SCAN_CAP) break; + const content = context.readFile(filePath); + if (content && /inertia/i.test(content)) return true; + } + return false; +} + +export const inertiaResolver: FrameworkResolver = { + name: 'inertia', + // Deliberately unrestricted: the render call may be PHP, Ruby or Elixir, and + // the page component TSX / Vue / Svelte. `extract` gates by extension. + detect(context) { + return usesInertia(context); + }, + + // Nothing name-based to resolve — the boundary is closed by the synthesis + // pass, which needs whole-graph knowledge (page components, symbol ranges). + resolve() { + return null; + }, + + extract(filePath, content): FrameworkExtractionResult { + const lang = serverLang(filePath); + if (!lang) return { nodes: [], references: [] }; + if (!/render_inertia|Inertia::render|inertia:|inertia\s*\(/.test(content)) { + return { nodes: [], references: [] }; + } + + const nodes: Node[] = []; + const safe = stripCommentsForRegex(content, lang); + const now = Date.now(); + const fileLang = detectLanguage(filePath); + + for (const pattern of RENDER_PATTERNS) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(safe)) !== null) { + const page = match[1]!; + const mapOpen = safe.indexOf(match[2]!, match.index + match[0].length - match[2]!.length); + if (mapOpen < 0) continue; + // `%{` opens at the brace, not the percent. + const braceAt = safe[mapOpen] === '%' ? mapOpen + 1 : mapOpen; + const mapText = balancedSlice(safe, braceAt); + for (const { key, preserved, offset } of propKeysFromMap(mapText)) { + const absolute = braceAt + offset; + const line = safe.slice(0, absolute).split('\n').length; + nodes.push({ + id: `inertia-prop:${filePath}:${line}:${page}:${key}`, + kind: 'property', + // Named by what the SERVER wrote — that is the name someone + // renaming the prop is looking at. The client spelling(s) live in + // the signature, where both halves of the contract are visible at + // once. + name: key, + qualifiedName: `${page}.${key}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: 0, + language: fileLang, + signature: `${key} → ${clientCandidates(key, preserved).join(' | ')}`, + decorators: [ + INERTIA_PROP_MARKER, + `page=${page}`, + ...(preserved ? ['preserve_case'] : []), + ], + updatedAt: now, + }); + } + } + } + return { nodes, references: [] }; + }, +}; diff --git a/src/resolution/inertia-prop-synthesizer.ts b/src/resolution/inertia-prop-synthesizer.ts new file mode 100644 index 000000000..9c83534a5 --- /dev/null +++ b/src/resolution/inertia-prop-synthesizer.ts @@ -0,0 +1,170 @@ +/** + * Inertia prop → consumer edges. + * + * The `inertia` framework resolver turns each server-side prop key into a node. + * This pass closes the other half of the boundary: it finds the page component + * the prop is rendered into and links the symbols there that actually READ it. + * + * WHICH SYMBOLS COUNT IS THE FEATURE. + * + * A naive scan reports a field as used the moment its name appears anywhere in + * the page's file — which includes the `interface Props { … }` that declares it. + * Every correctly typed field is then "used" by its own declaration, so nothing + * is ever orphaned and the feature silently reports success while finding + * nothing. Only sloppy, untyped fields would ever surface. + * + * The discriminator has to be per-SYMBOL, not per-file, because one module + * routinely holds both halves: a shared client types module exports the payload + * declarations AND runtime helpers over them, and is imported as a value + * namespace by live code. Excluding the file loses every genuine consumer of + * those helpers; including it re-commits the error above. A path- or + * extension-based rule gets it wrong in one direction or the other, and both + * directions are silent. + * + * So each occurrence is attributed to the INNERMOST symbol whose line range + * contains it, and that symbol's kind decides (see `isPropConsumer`): a + * `type`/`interface` declaration is not a consumer of the field it declares; a + * function or value that reads it is. + * + * Test files are deliberately NOT excluded. A rendered-page test is sometimes + * the only thing asserting a field's wording — content behind a portal never + * reaches a server-rendered string, so the test calls the composer directly — + * and excluding them would make a genuinely consumed export look dead. + */ + +import type { Edge, Node } from '../types'; +import type { ResolutionContext } from './types'; +import type { MaybeYield } from './cooperative-yield'; +import { INERTIA_PROP_MARKER, clientCandidates } from './frameworks/inertia'; +import { isPropConsumer } from './inertia-props'; + +/** Backstop only. */ +const FANOUT_CAP = 20000; + +/** Client component extensions Inertia resolves a page name to. */ +const PAGE_EXTENSIONS = ['.tsx', '.jsx', '.vue', '.svelte', '.ts', '.js']; + +/** + * The file a page name renders into. Inertia resolves `"Reports/Index"` against + * a pages directory by convention, so match on the path tail rather than + * guessing a root — that works for `assets/js/Pages/`, `resources/js/Pages/`, + * `app/javascript/Pages/` and anything else a project chose. + */ +export function findPageFile(page: string, files: readonly string[]): string | null { + const wanted = page.replace(/\\/g, '/').replace(/^\/+/, ''); + const candidates: string[] = []; + for (const ext of PAGE_EXTENSIONS) { + const tail = `/${wanted}${ext}`.toLowerCase(); + for (const file of files) { + const lower = file.toLowerCase(); + if (!lower.endsWith(tail)) continue; + // Require a `pages/` segment so an unrelated same-named module cannot be + // mistaken for the page component. + if (!/(^|\/)pages\//i.test(file)) continue; + candidates.push(file); + } + if (candidates.length > 0) break; + } + if (candidates.length === 0) return null; + // Shortest path wins when a project has more than one match — deeper paths + // are usually variants (a `__tests__` copy, a storybook story). + return candidates.sort((a, b) => a.length - b.length)[0]!; +} + +/** The innermost node whose line range contains `line`. */ +function innermostAt(nodes: readonly Node[], line: number): Node | null { + let best: Node | null = null; + for (const node of nodes) { + if (node.kind === 'file') continue; + const end = node.endLine ?? node.startLine; + if (node.startLine > line || end < line) continue; + if (!best) { best = node; continue; } + const bestSpan = (best.endLine ?? best.startLine) - best.startLine; + if (end - node.startLine < bestSpan) best = node; + } + return best; +} + +/** 1-based line numbers where `name` occurs as a whole identifier. */ +function occurrenceLines(source: string, name: string): number[] { + const lines: number[] = []; + const re = new RegExp(`(? { + const props: Node[] = []; + let scanned = 0; + for (const node of ctx.iterateNodesByKind?.('property') ?? ctx.getNodesByKind('property')) { + if ((++scanned & 63) === 0) await onYield(); + if (node.decorators?.includes(INERTIA_PROP_MARKER)) props.push(node); + } + if (props.length === 0) return []; + + const files = ctx.getAllFiles(); + const pageFileCache = new Map(); + const edges: Edge[] = []; + const seen = new Set(); + + for (const prop of props) { + await onYield(); + const page = prop.decorators?.find((d) => d.startsWith('page='))?.slice(5); + if (!page) continue; + + let pageFile = pageFileCache.get(page); + if (pageFile === undefined) { + pageFile = findPageFile(page, files); + pageFileCache.set(page, pageFile); + } + if (!pageFile) continue; + + const source = ctx.readFile(pageFile); + if (!source) continue; + const pageNodes = ctx.getNodesInFile(pageFile); + const preserved = prop.decorators?.includes('preserve_case') ?? false; + + for (const candidate of clientCandidates(prop.name, preserved)) { + for (const line of occurrenceLines(source, candidate)) { + const owner = innermostAt(pageNodes, line); + // An occurrence outside every symbol (a bare import line, module-level + // JSX) has no consumer to attribute — counting it would reintroduce the + // "named somewhere in the file" fallacy this pass exists to avoid. + if (!owner || !isPropConsumer(owner.kind)) continue; + const key = `${owner.id}>${prop.id}`; + if (seen.has(key) || edges.length >= FANOUT_CAP) continue; + seen.add(key); + edges.push({ + // The consumer depends on the prop, so impact on a prop lists what + // reads it — and a prop with no incoming edge is a dead prop. + source: owner.id, + target: prop.id, + kind: 'references', + line, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'inertia-prop', + page, + serverKey: prop.name, + // Which spelling actually matched, so a camelizing project and a + // verbatim one are distinguishable without knowing the setting. + clientKey: candidate, + registeredAt: `${pageFile}:${line}`, + }, + }); + } + } + } + + return edges; +} diff --git a/src/resolution/inertia-props.ts b/src/resolution/inertia-props.ts new file mode 100644 index 000000000..0b0d30440 --- /dev/null +++ b/src/resolution/inertia-props.ts @@ -0,0 +1,130 @@ +/** + * Inertia prop-boundary name math. + * + * Inertia (Laravel, Rails, Phoenix) has no REST schema and no GraphQL document: + * the contract IS the prop map, and it is written twice — once as a server-side + * map, once as a client-side destructure or type. When the server is configured + * to camelize, the two halves do not even share a spelling, so a grep for + * either name finds exactly one side. That is what makes this a resolver + * problem rather than something a user can solve with search. + * + * Everything here is pure name math, kept separate from the resolver so the + * transform can be pinned by tests against the real generator's behaviour. + */ + +/** + * `Phoenix.Naming.camelize(key, :lower)` — the exact transform the Phoenix + * Inertia adapter applies when `camelize_props: true`. + * + * It is NOT a naive `snake_case` → `camelCase`, and the divergences are the + * whole reason this is a function rather than a regex. Each produces a + * plausible-LOOKING client name, so a mismatch reads as "this field is never + * drawn" rather than "the transform is wrong" — the failure is silent in the + * direction that matters: + * + * _internal → internal leading underscores are stripped + * a__b → aB repeated underscores collapse + * foo_ → foo a trailing underscore is dropped + * HTTP_status → hTTPStatus ONLY the first character is lowercased + * a_B → a_B an underscore before an UPPERCASE letter stays literal + * item_90x → item90x an underscore before a DIGIT is dropped + * + * The `a_B` and `HTTP_status` cases are the ones that bite, and the digit case is + * the one that is easy to get backwards from reading the guards alone. + */ +export function phoenixCamelizeLower(key: string): string { + let i = 0; + while (i < key.length && key[i] === '_') i++; // leading underscores stripped + if (i >= key.length) return ''; + return key[i]!.toLowerCase() + camelizeRest(key.slice(i + 1)); +} + +/** `Phoenix.Naming.camelize/1` — as above but with an upper-cased first letter. */ +export function phoenixCamelizeUpper(key: string): string { + let i = 0; + while (i < key.length && key[i] === '_') i++; + if (i >= key.length) return ''; + return key[i]!.toUpperCase() + camelizeRest(key.slice(i + 1)); +} + +function camelizeRest(rest: string): string { + let out = ''; + let i = 0; + while (i < rest.length) { + const ch = rest[i]!; + if (ch !== '_') { + // `/` becomes `.` and restarts the upper-case form — a module-path + // spelling that never appears in a prop key, kept for fidelity. + if (ch === '/') return `${out}.${phoenixCamelizeUpper(rest.slice(i + 1))}`; + out += ch; + i++; + continue; + } + const next = rest[i + 1]; + if (next === undefined) return out; // trailing `_` dropped + if (next === '_') { i++; continue; } // repeated `_` collapse + if (next >= 'a' && next <= 'z') { out += next.toUpperCase(); i += 2; continue; } + if (next >= '0' && next <= '9') { out += next; i += 2; continue; } + // Anything else (an uppercase letter): the underscore stays literal. + out += ch; + i++; + } + return out; +} + +/** How a server adapter spells prop keys on the wire. */ +export type PropTransform = 'camelize' | 'preserve'; + +/** Apply the configured transform to one server-side prop key. */ +export function clientPropName(key: string, transform: PropTransform): string { + return transform === 'camelize' ? phoenixCamelizeLower(key) : key; +} + +/** + * Server prop keys that must NOT be transformed. + * + * The Phoenix adapter exposes `preserve_case(:key)`, whose value is emitted + * verbatim. It is a single clause and currently rare in the wild, but a + * resolver that ignores it mis-links that field and nothing goes red — the same + * invisible-failure class as the transform divergences above. + */ +export function stripPreserveCase(raw: string): { key: string; preserved: boolean } { + const m = /^preserve_case\(\s*:?["']?([\w]+)["']?\s*\)$/.exec(raw.trim()); + return m ? { key: m[1]!, preserved: true } : { key: raw.trim(), preserved: false }; +} + +/** + * Node kinds that COUNT as consuming a symbol. + * + * This is the real definition of "used", and it has to be per-SYMBOL rather + * than per-file. A shared client types module routinely exports both the + * `type`/`interface` declarations for a payload AND runtime helpers over it, and + * it is imported as a value namespace by live code. Excluding the file loses + * every genuine consumer of those helpers; including it makes each correctly + * typed field "used" by its own declaration, so nothing is ever reported + * orphaned — which is the failure that hides the finding entirely. + * + * A declaration is not a consumer of the field it declares. A function or value + * that reads it is. + */ +const CONSUMER_KINDS = new Set([ + 'function', 'method', 'variable', 'constant', 'component', 'property', 'field', +]); + +/** Kinds that merely NAME a symbol — a type-level declaration, not a use. */ +const DECLARATION_KINDS = new Set(['interface', 'type_alias', 'trait', 'protocol']); + +/** + * Does a reference from this symbol count as the prop being consumed? + * + * Deliberately NOT path-based. There is no reliable `.d.ts` convention to lean + * on (in a typical app every `*.d.ts` lives in `node_modules`), and test files + * must not be excluded wholesale either: a rendered-page test is sometimes the + * only thing asserting a field's wording, because content behind a portal never + * reaches a server-rendered string and the test has to call the composer + * directly. Excluding those would make a genuinely consumed export look dead. + */ +export function isPropConsumer(kind: string): boolean { + if (DECLARATION_KINDS.has(kind)) return false; + return CONSUMER_KINDS.has(kind); +}