From 8a215264fe0b096785c57dbba4831e2717b7d7a4 Mon Sep 17 00:00:00 2001 From: ferres Date: Mon, 31 Aug 2026 07:41:37 +0000 Subject: [PATCH 1/2] feat(inertia): link server page props to the client that reads them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 and once as a client destructure or type, with neither half referencing the other. The boundary is invisible to a graph built from calls and imports, so three ordinary questions have no answer: is this emitted prop read anywhere, is this read prop still emitted, and what else must change when I rename it. When the adapter camelizes, the two halves do not even share a spelling: the server emits `user_display_name` for a client reading `userDisplayName`, so a grep for either finds exactly one side. That is what makes it a resolver problem rather than something search can do. Server prop keys become nodes (named as the server wrote them, with the client spelling in the signature), and a synthesis pass links the symbols in the page component that read them. WHICH SYMBOLS COUNT IS THE FEATURE. A scan that reports a field used the moment its name appears in the page's file counts the `interface Props` that DECLARES it — so every correctly typed field is "used" by its own declaration, nothing is ever orphaned, and only sloppy untyped fields ever surface. The discriminator is therefore per-SYMBOL, not per-file: each occurrence is attributed to the innermost symbol whose range contains it, and a type-level declaration is not a consumer of the field it declares while a function or value that reads it is. One module can hold both halves — payload declarations plus runtime helpers imported as a value namespace — and any path- or extension-based rule gets that file wrong in one direction or the other, silently. Test files are deliberately NOT excluded: a rendered-page test is sometimes the only thing asserting a field, because content behind a portal never reaches a server-rendered string. No configuration and no global state. Rather than discover the adapter's camelize setting and carry it into per-file extraction, a prop offers BOTH candidate spellings and the pass records which matched — correct for Laravel and Rails (verbatim) and Phoenix (camelized) alike, and unable to mis-link if a project's setting is not what we assumed. The camelize transform is `Phoenix.Naming.camelize/2`, implemented faithfully rather than as a snake-to-camel regex, and pinned case by case. It diverges on a leading underscore, a repeated underscore, a trailing underscore, a leading acronym (only the FIRST character lowercases) and an underscore before an uppercase letter (which stays literal), while an underscore before a DIGIT is dropped. Every divergence yields a plausible-LOOKING client name, so a mismatch reads as "this field is never drawn" rather than "the transform is wrong". `preserve_case/1` is honoured. Only LITERAL, top-level map keys are read. An adapter transforms 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 camelized exactly like a field name; those are dynamic edges, and inventing a name for one produces a confident wrong link. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7 --- CHANGELOG.md | 4 + README.md | 1 + __tests__/inertia-prop-boundary.test.ts | 281 +++++++++++++++++++++ __tests__/inertia-props.test.ts | 131 ++++++++++ src/resolution/callback-synthesizer.ts | 5 + src/resolution/frameworks/index.ts | 3 + src/resolution/frameworks/inertia.ts | 213 ++++++++++++++++ src/resolution/inertia-prop-synthesizer.ts | 170 +++++++++++++ src/resolution/inertia-props.ts | 130 ++++++++++ 9 files changed, 938 insertions(+) create mode 100644 __tests__/inertia-prop-boundary.test.ts create mode 100644 __tests__/inertia-props.test.ts create mode 100644 src/resolution/frameworks/inertia.ts create mode 100644 src/resolution/inertia-prop-synthesizer.ts create mode 100644 src/resolution/inertia-props.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..963527203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ 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. +- 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..9bcf19259 --- /dev/null +++ b/__tests__/inertia-prop-boundary.test.ts @@ -0,0 +1,281 @@ +/** + * 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('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([]); + }); +}); + +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..dbcfbe9e2 --- /dev/null +++ b/src/resolution/frameworks/inertia.ts @@ -0,0 +1,213 @@ +/** + * 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", %{ ... }) + /render_inertia\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]; +} + +/** Signals that a project uses Inertia at all. */ +function usesInertia(context: ResolutionContext): boolean { + for (const manifest of ['package.json', 'composer.json', 'Gemfile', 'mix.exs']) { + const content = context.readFile(manifest); + 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); +} From c8443c4417298d84deb9e9eb1052b929f9901736 Mon Sep 17 00:00:00 2001 From: ferres Date: Mon, 31 Aug 2026 10:42:40 +0000 Subject: [PATCH 2/2] fix(inertia): detect a non-root app, and read the piped Phoenix render form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reasons the prop boundary stayed empty on real projects, both of which make the feature look absent rather than broken. Detection read dependency manifests at the project root only. Inertia spans a server and a client, so the app that uses it is frequently not the root of the repository it lives in — a `mix.exs` beside its own `assets/`, or a `package.json` under a client workspace, with the root holding only tooling. Manifests are now looked for anywhere in the index, by filename, capped so a repo that does not use Inertia does not pay for the check. The Phoenix pattern also required the connection as a positional argument, `render_inertia(conn, "Page", %{...})`. Idiomatic Elixir pipes it, so the call carries only the page and the props and matched nothing. The leading argument is now optional, with quotes excluded from it so the page name itself is not mistaken for one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7 --- CHANGELOG.md | 2 + __tests__/inertia-prop-boundary.test.ts | 65 +++++++++++++++++++++++++ src/resolution/frameworks/inertia.ts | 41 ++++++++++++++-- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 963527203..9153925b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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/__tests__/inertia-prop-boundary.test.ts b/__tests__/inertia-prop-boundary.test.ts index 9bcf19259..eea97f49b 100644 --- a/__tests__/inertia-prop-boundary.test.ts +++ b/__tests__/inertia-prop-boundary.test.ts @@ -140,8 +140,73 @@ describe('inertia resolver — extraction from each adapter', () => { 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([]); }); }); diff --git a/src/resolution/frameworks/inertia.ts b/src/resolution/frameworks/inertia.ts index dbcfbe9e2..d4180e271 100644 --- a/src/resolution/frameworks/inertia.ts +++ b/src/resolution/frameworks/inertia.ts @@ -55,8 +55,12 @@ const RENDER_PATTERNS: RegExp[] = [ /(?: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", %{ ... }) - /render_inertia\s*\([^,]+,\s*['"]([^'"]+)['"]\s*,\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. */ @@ -134,12 +138,41 @@ export function clientCandidates(key: string, preserved: boolean): string[] { return camel && camel !== key ? [key, camel] : [key]; } -/** Signals that a project uses Inertia at all. */ +/** 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 { - for (const manifest of ['package.json', 'composer.json', 'Gemfile', 'mix.exs']) { + 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; }