From 4731149469fb01cfc30eaf67a2d55a4111f0caf8 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:42:43 +0000 Subject: [PATCH 01/20] fix(react): a type argument or a name the component binds itself renders no JSX child (#2442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jsx-render pass reads tag names off a parent's source with a pattern, and two kinds of name it read that way linked a same-named class or component elsewhere: - A name written only in a type argument or type parameter list: outline's ` …>` and `useState()`, or bulletproof-react's generic `(…) =>`. A ` `. Such a tag renders only a component the parent declares inside itself, else nothing. The check runs only for names the parent also writes outside a tag. Destructured names still link by name, as for a destructured call. Fresh before/after indexes on 13 repos (main ed199e60): 66 jsx-render edges removed and none added; no other edge, node, ref or file changes. Every removal checks out against the TypeScript compiler (38 names with no JSX tag in the parent, 28 tags bound to a local); 64 of the 66 targets were wrong, 2 right by luck. mantis, proshop_mern, next-saas-starter and create-t3-turbo are byte-identical, and Screens is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/jsx-child-disambiguation.test.ts | 209 +++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 81 +++++++- src/resolution/name-matcher.ts | 34 ++-- 4 files changed, 301 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08226636c1..5b6119014f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. +- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 5c8465a900..2aedc99b56 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -129,3 +129,212 @@ export function Header() { return

; } expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']); }); }); + +/** + * The pass reads tag names off a parent's source with a pattern, and two + * kinds of name it reads that way are no other file's component. + * + * - A type argument: ` …>` or `useState()` + * writes a type's name right after another name. outline's document list + * rendered its `Document` model class, its settings tables the `User` and + * `Group` classes. + * - A name the parent binds itself, as a `const` or a parameter, is that local + * wherever its tags sit. outline's menus pick a Radix part into `const + * Content = variant === 'dropdown' ? DropdownMenu.SubContent : + * ContextMenu.SubContent` and rendered the command bar's styled `Content` + * instead. A local is no node, so it renders nothing, unless the parent + * declares a component of that name inside itself. + */ +describe('JSX child: a name the parent writes only as a type, or binds itself', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-local-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}'); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + async function index(): Promise { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + } + + /** `name file:line` of each node a jsx-render edge out of `parent` points at. */ + const renders = (parent: string): string[] => + cg.db.db + .prepare( + `SELECT t.name || ' ' || t.file_path || ':' || t.start_line AS r FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + ORDER BY r` + ) + .all(parent) + .map((row: any) => row.r); + + it('renders nothing for a name written only as a type argument or parameter', async () => { + write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n'); + write('app/models/User.ts', 'export default class User {\n name = "";\n}\n'); + write( + 'app/components/PaginatedList.tsx', + 'export function PaginatedList({ items }: { items: T[] }) {\n return
    {items.length}
;\n}\n' + ); + write( + 'app/components/Badge.tsx', + 'import * as React from "react";\nexport class Badge extends React.Component {\n render() {\n return ;\n }\n}\n' + ); + write( + 'app/components/DocumentList.tsx', + `import * as React from 'react'; +import Document from '../models/Document'; +import User from '../models/User'; +import { Badge } from './Badge'; +import { PaginatedList } from './PaginatedList'; + +export function DocumentList({ items }: { items: Document[] }) { + const [owner] = React.useState(null); + const badge = React.useRef(null); + return ( +
+ items={items} /> + +
+ ); +} +` + ); + // bulletproof-react's table: a generic arrow function's type parameter. + write('app/profile/entry.tsx', 'export function Entry() {\n return
;\n}\n'); + write( + 'app/components/Table.tsx', + `type BaseEntity = { id: string }; + +export const Table = ({ data }: { data: Entry[] }) => { + return {data.length}
; +}; +` + ); + await index(); + // `Badge` is written as a type argument too, but a tag of it is enough. + // A generic tag's own name is not read at all: no tag ends after + // ` { + write( + 'app/components/CommandBarItem.tsx', + 'export function Content() {\n return
;\n}\n\nexport function Icon() {\n return ;\n}\n' + ); + write('shared/editor/Widget.tsx', 'export function Widget() {\n return
;\n}\n'); + write('app/components/Portal.tsx', 'export function Portal({ children }) {\n return
{children}
;\n}\n'); + write( + 'app/components/Menu.tsx', + `import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import * as ContextMenu from '@radix-ui/react-context-menu'; + +const Portal = DropdownMenu.Portal; + +export function SubMenuContent({ variant, children }) { + const Portal = + variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal; + const Content = + variant === 'dropdown' + ? DropdownMenu.SubContent + : ContextMenu.SubContent; + return ( + + {children} + + ); +} + +export function SettingsLinks({ config }) { + return ( +
    + {config.map((item) => { + const Icon = item.icon; + return
  • ; + })} +
+ ); +} + +export function Widgets({ widgets }) { + return
{Object.values(widgets).map((Widget, index) => )}
; +} +` + ); + await index(); + // The file's own `Portal` is shadowed too: the tag means the local. + expect(renders('SubMenuContent')).toEqual([]); + expect(renders('SettingsLinks')).toEqual([]); + expect(renders('Widgets')).toEqual([]); + }); + + it('renders a component the parent declares inside itself, and the outer one for a tag above the local', async () => { + write('a-decoy/row.tsx', 'export function Row() {\n return ;\n}\n'); + write('app/icon.tsx', 'export function Icon() {\n return ;\n}\n'); + write( + 'app/table.tsx', + `import { Icon } from './icon'; + +function Row() { + return ; +} + +export function Table({ rows }) { + const Row = ({ row }) => {row.name}; + return {rows.map((row) => )}
; +} + +export function Toolbar({ actions }) { + return ( +
+ + {actions.map((action) => { + const Icon = action.icon; + return ; + })} +
+ ); +} +` + ); + await index(); + expect(renders('Table')).toEqual(['Row app/table.tsx:8']); + expect(renders('Toolbar')).toEqual(['Icon app/icon.tsx:1']); + }); + + it('keeps a destructured name on the same-named component, as a destructured call does', async () => { + // excalidraw's example app: `const { Sidebar, Footer, … } = excalidrawLib`. + // Destructuring is not the parent's own binding here, as it is not for a + // bare call (`const { t } = useI18n()`), so the name links by name. + write( + 'packages/excalidraw/components/WelcomeScreen.tsx', + 'export default function WelcomeScreen() {\n return
;\n}\n' + ); + write( + 'examples/app/ExampleApp.tsx', + `export default function ExampleApp({ excalidrawLib }) { + const { WelcomeScreen, exportToSvg } = excalidrawLib; + return
; +} +` + ); + await index(); + expect(renders('ExampleApp')).toEqual(['WelcomeScreen packages/excalidraw/components/WelcomeScreen.tsx:1']); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 87229132ae..046c636e21 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -39,7 +39,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield'; import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; -import { crossesCodeBoundary } from './name-matcher'; +import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -1584,14 +1584,62 @@ function jsxChild( return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0]; } +/** + * Whether the `()`, `Array`, the + * `` of `` — and a tag never does, short of + * a `return` written up against it. A generic arrow function's type + * parameters read like a tag but constrain theirs: `({ data }) =>`. + */ +function opensTag(src: string, at: number, name: string): boolean { + const end = at + 1 + name.length; + if (/^\s+extends\s/.test(src.slice(end, end + 40))) return false; + return !/[\w$]/.test(src[at - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, at - 7), at)); +} + +/** + * Whether `src` writes `name` other than in a tag (``), so the check for that runs only on these. + */ +function writtenBare(src: string, name: string): boolean { + for (let at = src.indexOf(name); at !== -1; at = src.indexOf(name, at + name.length)) { + if (/[\w$]/.test(src[at - 1] ?? '') || /[\w$]/.test(src[at + name.length] ?? '')) continue; + if (src[at - 1] === '<' || (src[at - 1] === '/' && src[at - 2] === '<')) continue; + return true; + } + return false; +} + +/** + * What a tag renders when its parent binds the name itself: a component the + * parent declares in its own lines (`const Row = ({ row }) => ` in a + * table's body), else nothing. Any other local is no node, and a same-named + * component elsewhere is only a guess at what it holds. + */ +function declaredInside(ctx: ResolutionContext, name: string, parent: Node, child: Node): Node | undefined { + const inside = (n: Node) => n.id !== parent.id && n.filePath === parent.filePath && + n.startLine >= parent.startLine && n.startLine <= parent.endLine; + if (inside(child)) return child; + return ctx.getNodesByName(name).find((n) => JSX_CHILD_KINDS.has(n.kind) && inside(n)); +} + /** * Phase 5: React JSX child rendering. A component that returns `` * mounts Child — React calls it — but JSX instantiation isn't a static call edge, * so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the * JSX hop. Link parent → each capitalized JSX child it renders. File-oriented - * (read each JSX file once). Precision gate: the child name must resolve to a - * component/function/class node — TS generics like `Array` resolve to a type - * (or nothing) and are dropped. + * (read each JSX file once). Precision gates: the parent must write the name as + * a tag, not only in a type argument or parameter list (`useState()`, + * `(…) =>`; outline's `` + * read its `Document` model class as a child), and the name must resolve to a + * component/function/class node. A name the parent binds itself by its first + * tag — a parameter, or a `const` like outline's `const Content = variant === + * "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that + * local, and renders only a component declared inside the parent + * (`declaredInside`). A destructured name still links by name, as a + * destructured function call does. */ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { let scannedFiles = 0; @@ -1620,26 +1668,41 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): // over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The // file is split once, not once per function. let lines: string[] | null = null; - const tagsBySpan = new Map>(); + // Each name a span holds, and the line of its first tag: 0 while only type + // argument or parameter lists name it (`opensTag`). `bare` holds the + // names it also writes outside a tag (`writtenBare`). + const tagsBySpan = new Map; bare: Set }>(); for (const parent of parents) { if (!parent.startLine || !parent.endLine) continue; const span = `${parent.startLine}:${parent.endLine}`; let names = tagsBySpan.get(span); if (!names) { - names = new Set(); + names = { tags: new Map(), bare: new Set() }; lines ??= content.split('\n'); const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n'); if (src.includes('')) { + let line = parent.startLine; + let counted = 0; JSX_TAG_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + while ((m = JSX_TAG_RE.exec(src))) { + for (let nl = src.indexOf('\n', counted); nl !== -1 && nl < m.index; nl = src.indexOf('\n', nl + 1)) line++; + counted = m.index; + if (!names.tags.get(m[1]!)) names.tags.set(m[1]!, opensTag(src, m.index, m[1]!) ? line : 0); + } + for (const [name, first] of names.tags) if (first && writtenBare(src, name)) names.bare.add(name); } tagsBySpan.set(span, names); } let added = 0; - for (const name of names) { + for (const [name, line] of names.tags) { if (added >= MAX_JSX_CHILDREN) break; - const child = jsxChild(ctx, name, file, importsOf); + if (!line) continue; + let child = jsxChild(ctx, name, file, importsOf); + // A name the parent binds itself by its first tag is that local. + if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) { + child = declaredInside(ctx, name, parent, child); + } if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue; const key = `${parent.id}>${child.id}`; if (seen.has(key)) continue; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 36365398d0..84b6fb457e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6857,7 +6857,7 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut let scope: { start: number; end: number } | null = null; const fn = context.getNodeById?.(ref.fromNodeId); if (fn && (fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line && - jsCodeBindsName(name, fn, ref, context)) { + jsCodeBindsName(name, fn, ref.filePath, ref.line, context)) { scope = { start: fn.startLine, end: fn.endLine }; } memo.set(key, scope); @@ -6872,14 +6872,18 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut */ const JS_CODE_BINDS = new WeakMap>(); -/** Whether the function's code through the reference's line declares `name`, or names it in a parameter list. */ -function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { +/** + * Whether the function's code in `filePath`, from its first line through + * `line` (a reference's, or a JSX tag's), declares `name`, or names it in a + * parameter list. + */ +export function jsCodeBindsName(name: string, fn: Node, filePath: string, line: number, context: ResolutionContext): boolean { let memo = JS_CODE_BINDS.get(context); if (!memo) JS_CODE_BINDS.set(context, (memo = new Map())); - const key = `${ref.filePath}\0${fn.startLine}\0${ref.line}\0${name}`; + const key = `${filePath}\0${fn.startLine}\0${line}\0${name}`; let binds = memo.get(key); if (binds === undefined) { - const text = jsFunctionCodeThrough(fn, ref, context); + const text = jsFunctionCodeThrough(fn, filePath, line, context); const { param } = localBindingPatterns(name, 'g'); const n = name.replace(/\$/g, '\\$'); // A plain declaration. Destructuring re-binds what a call returns under @@ -6904,38 +6908,38 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re } /** - * Per context: the comment-stripped lines of the functions jsFunctionLocalScope + * Per context: the comment-stripped lines of the functions jsCodeBindsName * read last, and where each line ends in that text, most recent last. */ const JS_FN_CODE = new WeakMap>(); const JS_FN_CODE_KEEP = 64; /** - * The function's lines from its first through the reference's, comments - * blanked. Stripping looks at most one character ahead — past a line's end, - * a newline, which completes no comment marker — so this is the whole + * The function's lines from its first through `line`, comments blanked. + * Stripping looks at most one character ahead — past a line's end, a + * newline, which completes no comment marker — so this is the whole * function's stripped text cut at that line's end. Stripping the lines again * for every reference took time in the square of a large function's length; * in a minified script, every function's text runs to the end of its one * line (#2334). */ -function jsFunctionCodeThrough(fn: Node, ref: UnresolvedRef, context: ResolutionContext): string { +function jsFunctionCodeThrough(fn: Node, filePath: string, line: number, context: ResolutionContext): string { let fns = JS_FN_CODE.get(context); if (!fns) JS_FN_CODE.set(context, (fns = new Map())); - const key = `${ref.filePath}\0${fn.startLine}\0${fn.endLine}`; + const key = `${filePath}\0${fn.startLine}\0${fn.endLine}`; let own = fns.get(key); if (own) { fns.delete(key); } else { - const lines = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []) + const lines = (context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []) .slice(fn.startLine - 1, fn.endLine); let end = -1; - own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((line) => (end += line.length + 1)) }; + own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((text) => (end += text.length + 1)) }; if (fns.size >= JS_FN_CODE_KEEP) fns.delete(fns.keys().next().value!); } fns.set(key, own); - // Through the reference's line, or the file's last when it ends sooner. - const last = Math.min(ref.line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine; + // Through `line`, or the file's last when it ends sooner. + const last = Math.min(line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine; return last < 0 ? '' : own.code.slice(0, own.lineEnds[last]); } From 883504d2015cdb91f8a8f4eb1e8ecbbfa7feba22 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:42:45 +0000 Subject: [PATCH 02/20] fix(go): a call on an embedded struct's method does not reach the embedder's method of that name (#2430) Go has no inheritance. A struct that embeds another (`type Engine struct { RouterGroup }`) has an `extends` edge to it since #2397, and the interface-dispatch bridge read every `extends` edge as an override, so it linked RouterGroup.Use to Engine.Use. A call on a *RouterGroup only ever runs RouterGroup's own Use, so a flow or impact walk through it went on into Engine.Use and the code only the engine runs. The bridge now skips a Go type's supertype edge unless it points at an interface: only a call through an interface dispatches. Structs that embed an interface keep their links, and so do the go-implements edges, so a call through IRoutes still reaches both RouterGroup.Use and Engine.Use. Other languages are unchanged. Removed on gin / prometheus / etcd: 1 / 112 / 60 interface-impl edges, all from a struct's method to a struct embedding it; nothing else in the graph changed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-embedding-no-override.test.ts | 250 +++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 6 + 3 files changed, 257 insertions(+) create mode 100644 __tests__/go-embedding-no-override.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6119014f..1d4a5efb13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. - In Go, a type defined over a slice, map, function or basic type now counts as implementing the interfaces its methods satisfy, just as a struct does: prometheus's `type staticDiscoverer []*targetgroup.Group` is a `Discoverer`, gin's `type formSource map[string][]string` a `setter`, etcd's `type Int64Comparable int64` a `Comparable`, and an adapter like `type HandlerFunc func(…)` a `Handler`. Before, only structs were considered, so these types were missing from an interface's implementations in `codegraph_explore`, impact and the type hierarchy, and a call through the interface, like `Discoverer.Run` or `setter.TrySet`, stopped there instead of reaching their methods. Re-index Go projects after upgrading. - In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. +- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) diff --git a/__tests__/go-embedding-no-override.test.ts b/__tests__/go-embedding-no-override.test.ts new file mode 100644 index 0000000000..d6d116634c --- /dev/null +++ b/__tests__/go-embedding-no-override.test.ts @@ -0,0 +1,250 @@ +/** + * Go has no inheritance. gin's Engine embeds RouterGroup and declares a Use + * of its own: + * + * type Engine struct { + * RouterGroup + * … + * } + * + * func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes { + * engine.RouterGroup.Use(middleware...) + * … + * } + * + * The embedding is an `extends` edge (#2397), and the interface-dispatch + * bridge read every `extends` edge as an override, so it linked + * RouterGroup.Use → Engine.Use. But a call on a *RouterGroup only ever runs + * RouterGroup's own Use; nothing dispatches it to the embedder's. A flow or + * impact walk through RouterGroup.Use then went on into Engine.Use. Only an + * interface dispatches: a call through IRoutes still reaches both, through + * the implements edges go-implements adds, and so does a call through an + * interface a struct embeds. Java and TypeScript subclasses keep their + * overrides. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import type { Edge, Node } from '../src/types'; + +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'gin/gin.go': `package gin + +type HandlerFunc func() + +type HandlersChain []HandlerFunc + +func (c HandlersChain) Last() HandlerFunc { return nil } + +type IRoutes interface { + Use(...HandlerFunc) IRoutes +} + +type RouterGroup struct{ Handlers HandlersChain } + +func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes { + group.Handlers = append(group.Handlers, middleware...) + return group +} + +type Engine struct { + RouterGroup + trees []string +} + +func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes { + engine.RouterGroup.Use(middleware...) + engine.rebuild404Handlers() + return engine +} + +func (engine *Engine) rebuild404Handlers() {} + +// A Last of its own over the defined type it embeds. +type routeInfo struct { + HandlersChain + path string +} + +func (r routeInfo) Last() HandlerFunc { return nil } +`, + // etcd's client/v3/concurrency: embedded through a pointer. + 'concurrency/mutex.go': `package concurrency + +type Locker interface { + Lock() + Unlock() +} + +type Mutex struct{ key string } + +func (m *Mutex) Lock() {} +func (m *Mutex) Unlock() {} + +type lockerMutex struct{ *Mutex } + +func (lm *lockerMutex) Lock() { lm.Mutex.Lock() } +func (lm *lockerMutex) Unlock() { lm.Mutex.Unlock() } +`, + // etcd's generated gRPC stub, and a test server that overrides one RPC. + 'api/auth.go': `package api + +type AuthServer interface { + Authenticate(name string) error + UserAdd(name string) error +} + +type UnimplementedAuthServer struct{} + +func (UnimplementedAuthServer) Authenticate(name string) error { return nil } +func (UnimplementedAuthServer) UserAdd(name string) error { return nil } +`, + 'client/mock.go': `package client + +import "example.com/app/api" + +type mockAuthServer struct { + api.UnimplementedAuthServer +} + +func (mockAuthServer) Authenticate(name string) error { return nil } +`, + // A struct that embeds the interface it wraps does dispatch: a call + // through Appender on a limitAppender runs limitAppender's Append. + 'storage/appender.go': `package storage + +type Appender interface { + Append(v float64) error + Commit() error +} + +type limitAppender struct { + Appender + limit int +} + +func (a *limitAppender) Append(v float64) error { return a.Appender.Append(v) } +`, + 'java/src/main/java/app/Base.java': 'package app;\n\npublic class Base {\n public void run() {}\n}\n', + 'java/src/main/java/app/Derived.java': + 'package app;\n\npublic class Derived extends Base {\n @Override\n public void run() {}\n}\n', + 'web/shapes.ts': + 'export class Shape {\n area(): number { return 0; }\n}\n\nexport class Square extends Shape {\n area(): number { return 1; }\n}\n', +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-embedding-override-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 120_000); + +afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const meta = (e: Edge): Record => (e.metadata ?? {}) as Record; + +/** The one non-import node with this name in this file. */ +function one(name: string, file: string): Node { + const found = cg.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** The method `name` that the type `owner` in `file` contains. */ +function method(owner: string, name: string, file: string): Node { + const found = cg + .getOutgoingEdgesFrom([one(owner, file).id], ['contains']) + .map((e) => cg.getNode(e.target)) + .filter((n): n is Node => n?.kind === 'method' && n.name === name); + expect(found, `${owner}.${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** Where the interface-dispatch bridge sends a call to this method. */ +function dispatchesTo(m: Node): string[] { + return cg + .getOutgoingEdgesFrom([m.id], ['calls']) + .filter((e) => meta(e).synthesizedBy === 'interface-impl') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +/** The type that contains a method. */ +function ownerOf(id: string): Node | undefined { + return cg + .getIncomingEdgesTo([id], ['contains']) + .map((e) => cg.getNode(e.source)) + .find((n): n is Node => !!n && n.kind !== 'file'); +} + +describe('a Go embedding is not an override', () => { + it('embeds structs and a defined type through declared supertype edges', () => { + // The shapes the bridge used to read as inheritance. + const types = ['Engine', 'routeInfo', 'lockerMutex', 'mockAuthServer'].map((name) => + cg.getNodesByName(name).find((n) => n.language === 'go' && n.kind === 'struct')! + ); + const embeds = cg + .getOutgoingEdgesFrom(types.map((n) => n.id), ['extends']) + .filter((e) => e.provenance !== 'heuristic') + .map((e) => `${cg.getNode(e.source)!.name} -> ${cg.getNode(e.target)!.kind} ${cg.getNode(e.target)!.name}`) + .sort(); + expect(embeds).toEqual([ + 'Engine -> struct RouterGroup', + 'lockerMutex -> struct Mutex', + 'mockAuthServer -> struct UnimplementedAuthServer', + 'routeInfo -> type_alias HandlersChain', + ]); + }); + + it('links no method of an embedded struct or defined type to the embedder\'s method of that name', () => { + expect(dispatchesTo(method('RouterGroup', 'Use', 'gin/gin.go'))).toEqual([]); + expect(dispatchesTo(method('HandlersChain', 'Last', 'gin/gin.go'))).toEqual([]); + expect(dispatchesTo(method('Mutex', 'Lock', 'concurrency/mutex.go'))).toEqual([]); + expect(dispatchesTo(method('UnimplementedAuthServer', 'Authenticate', 'api/auth.go'))).toEqual([]); + // Nor anywhere else: every Go dispatch edge starts at an interface method. + const goMethods = cg.getNodesByKind('method').filter((n) => n.language === 'go'); + const fromConcrete = cg + .getOutgoingEdgesFrom(goMethods.map((n) => n.id), ['calls']) + .filter((e) => meta(e).synthesizedBy === 'interface-impl' && ownerOf(e.source)?.kind !== 'interface') + .map((e) => `${cg.getNode(e.source)!.qualifiedName} -> ${cg.getNode(e.target)!.qualifiedName}`); + expect(fromConcrete).toEqual([]); + }); + + it('ends a walk through the embedded type\'s method at that method', () => { + const groupUse = method('RouterGroup', 'Use', 'gin/gin.go'); + const engineUse = method('Engine', 'Use', 'gin/gin.go'); + expect(cg.getCallees(groupUse.id).map((c) => c.node.qualifiedName)).not.toContain('Engine::Use'); + expect(cg.getCallers(engineUse.id).map((c) => c.node.qualifiedName)).not.toContain('RouterGroup::Use'); + expect([...cg.getImpactRadius(engineUse.id).nodes.values()].map((n) => n.qualifiedName)).not.toContain( + 'RouterGroup::Use' + ); + }); + + it('still dispatches a call through an interface to each type that implements it', () => { + expect(dispatchesTo(method('IRoutes', 'Use', 'gin/gin.go'))).toEqual(['Engine::Use', 'RouterGroup::Use']); + expect(dispatchesTo(method('Locker', 'Lock', 'concurrency/mutex.go'))).toEqual([ + 'Mutex::Lock', + 'lockerMutex::Lock', + ]); + // limitAppender embeds Appender: a declared edge, not a synthesized one. + expect(dispatchesTo(method('Appender', 'Append', 'storage/appender.go'))).toEqual(['limitAppender::Append']); + }); + + it('keeps the overrides of languages with inheritance', () => { + const run = method('Base', 'run', 'java/src/main/java/app/Base.java'); + expect(dispatchesTo(run).map((qn) => qn.replace(/^.*?(Derived)/, '$1'))).toEqual(['Derived::run']); + const area = method('Shape', 'area', 'web/shapes.ts'); + expect(dispatchesTo(area).map((qn) => qn.replace(/^.*?(Square)/, '$1'))).toEqual(['Square::area']); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 046c636e21..bc0fcc8bbe 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1316,6 +1316,12 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield for (const sup of sups) { const base = queries.getNodeById(sup.target); if (!base || !IFACE_OVERRIDE_LANGS.has(base.language) || base.id === cls.id) continue; + // Go has no inheritance: a Go type's supertype edge to a struct or a + // defined type is an embedding (`type Engine struct { RouterGroup }`), + // and a call on the embedded type runs its own method, never the + // embedder's of the same name. Only a call through an interface + // dispatches. + if (cls.language === 'go' && base.kind !== 'interface') continue; const promotes = goStruct && sup.provenance === 'heuristic' && base.kind === 'interface'; // Group impl methods by name to handle OVERLOADS: an interface `list()` and // `list(params)` are distinct nodes and a call may resolve to either, so From cdb994b41784960e06dadcb14612b3987c7caee3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:45:30 +0000 Subject: [PATCH 03/20] fix(cpp): a class keeps its members past attribute macros the parser can't read (#2449) tree-sitter-cpp has no preprocessor, so an attribute macro where it expects a name or a type misparses the declaration around it. The C++ preParse already blanked one export macro before a class name, an inline macro before a return type, and a lone macro line before a declaration. It missed: - several macros, or one with arguments, between `class` and the name (protobuf's generated `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public Message`, rocksdb's `struct ALIGN_AS(64U) HandleImpl`), and a macro before a partial specialization's name; - a macro between a pointer and the declared name (`const Descriptor* PROTOBUF_NONNULL descriptor()`), after a parameter list (`unknown_fields() const ABSL_ATTRIBUTE_LIFETIME_BOUND {`, leveldb's `LOCKS_EXCLUDED(mu_) {`), after a declared name (`int count_ GUARDED_BY(mu_);`), or opening a declaration (`PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const`); - a lone macro line with a comment under it ({fmt}'s `FMT_BEGIN_EXPORT` above `// A generic formatting context ...`). Every generated protobuf message class misparsed, so its members were indexed as namespace-level functions; leveldb's annotated methods became phantoms named after the annotation. The new passes match on the code alone (comments and directives as spaces, literals as placeholders), blank only the macro tokens, and keep every offset. They run in the hoisted preParse, so files that now parse clean go through the native kernel, at parity. The passes share one mask per file: a pass that blanks nothing hands the next the same string, and one that blanks hands it the mask blanked the same way. #2426's brace-scope fixtures misparsed only through PROTOBUF_FUTURE_ADD_EARLY_NODISCARD and ALIGN_AS(64U), which the preParse now blanks. They now use macros it doesn't know (NODISCARD, cacheline_aligned(64U)), with the same expected scopes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-attribute-macros.test.ts | 536 +++++++++++++++++++++++++ __tests__/cpp-brace-scopes.test.ts | 10 +- __tests__/kernel-ccpp-parity.test.ts | 46 +++ src/extraction/languages/c-cpp.ts | 446 +++++++++++++++++++- 5 files changed, 1015 insertions(+), 24 deletions(-) create mode 100644 __tests__/cpp-attribute-macros.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4a5efb13..2e504c5ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C and C++, a header or source file named like a test, such as protocolbuffers/protobuf's `conformance_test.h` and `test_runner.h`, now counts as part of the code that `#include`s it, directly or through other headers. Before, calls from that code into it were dropped, so protobuf's conformance suites' `suite_.ReportFailure(…)` and `RunValidInputTest(…)` calls, and its unit tests' calls to the shared `TestUtil` helpers, linked to nothing. A test file that only tests include is still kept out of reach of the rest of your code. Re-index C and C++ projects after upgrading. - In C++, a class, struct or method now keeps the namespaces and the class it is declared in when the parser misreads something earlier in the file, such as an unknown macro in front of a member or in a class header. Before, everything after the misread code could lose them, or land inside a class it isn't in: in protocolbuffers/protobuf, `FieldDescriptor` was indexed without `google::protobuf::`, and in RocksDB, the `Opts` struct declared inside a cache table class was indexed outside it, so finding a class's base classes and the methods called on its objects fell back to guessing by name. Methods that had come loose from their class are its members again, and classes the misread code had hidden, like leveldb's POSIX file and environment classes, are now indexed. Re-index C++ projects after upgrading. - In C++, a call on a variable, parameter or member declared as a standard-library or other outside type with a lowercase name, like `std::string`, `std::vector`, `std::set` or `absl::flat_hash_set`, no longer links to a project method that only shares the method's name. In google/leveldb, `saved_key_.clear()` on a `std::string saved_key_;` reached `Slice::clear`, and in protocolbuffers/protobuf, `proto.append(…)` on a `std::string` reached an unrelated `LeftoverBuffer::append`. The same goes for `->` through a raw pointer like `std::string* out`, while `->` through a smart pointer, iterator or optional still links to the type it holds. Comments also no longer pass for a variable's declaration, so more calls now link to the method of the type the variable is really declared with. Re-index C++ projects after upgrading. +- In C++, a class written with attribute macros the parser can't read now keeps its members. That covers several macros between `class` and the class name, like protocolbuffers/protobuf's generated `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final`, a macro with arguments there, like RocksDB's `struct ALIGN_AS(64U) HandleImpl`, or one before a template specialization, and macros on a member: in front of it (`PROTOBUF_FUTURE_ADD_EARLY_NODISCARD`), between a pointer and its name (`PROTOBUF_NONNULL`), after its parameters or its name (`ABSL_ATTRIBUTE_LIFETIME_BOUND`, google/leveldb's `LOCKS_EXCLUDED(mu_)` and `GUARDED_BY(mu_)`), or alone on a line above a comment ({fmt}'s `FMT_BEGIN_EXPORT`). Before, protobuf's generated message classes were missing and their members, like `GetDescriptor`, `GetReflection` and `unknown_fields`, were indexed as functions outside any class, and leveldb's annotated methods were indexed under the annotation's name, so calls to them linked to nothing. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. diff --git a/__tests__/cpp-attribute-macros.test.ts b/__tests__/cpp-attribute-macros.test.ts new file mode 100644 index 0000000000..af90c4a299 --- /dev/null +++ b/__tests__/cpp-attribute-macros.test.ts @@ -0,0 +1,536 @@ +/** + * Attribute macros tree-sitter-cpp can't read, in the places the C++ preParse + * did not yet blank them: + * + * - several macros, or one with arguments, between `class` and the name + * (protobuf's generated `class PROTOBUF_EXPORT + * PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public Message`, + * rocksdb's `struct ALIGN_AS(64U) HandleImpl`), and one before a partial + * specialization's name; + * - a macro between a pointer and the declared name (`const Descriptor* + * PROTOBUF_NONNULL descriptor()`), after a parameter list + * (`unknown_fields() const ABSL_ATTRIBUTE_LIFETIME_BOUND {`, leveldb's + * `LOCKS_EXCLUDED(mu_) {`), after a declared name (`int count_ + * GUARDED_BY(mu_);`), or opening a declaration + * (`PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const`); + * - a lone macro line with a comment under it ({fmt}'s `FMT_BEGIN_EXPORT`). + * + * Each misparsed the class around it: protobuf's generated messages had their + * members indexed as namespace-level functions, and leveldb's annotated + * methods became phantoms named after the annotation. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { + blankCppExportMacros, + blankCppPointerAnnotationMacros, + blankCppTrailingAttributeMacros, + blankCppDeclaratorAttributeMacros, + blankCppLeadingAttributeMacros, + blankLoneMacroLines, + cppExtractor, +} from '../src/extraction/languages/c-cpp'; + +/** Assert `blank` removes exactly `macros` from `input` and nothing else, offsets kept. */ +function expectBlanked(blank: (s: string) => string, input: string, macros: string[]): void { + const out = blank(input); + expect(out.length).toBe(input.length); + let expected = input; + for (const macro of macros) { + const at = expected.indexOf(macro); + expect(at, `${macro} in ${input}`).toBeGreaterThanOrEqual(0); + expected = expected.slice(0, at) + macro.replace(/[^\r\n]/g, ' ') + expected.slice(at + macro.length); + } + expect(out).toBe(expected); +} + +describe('C++ attribute-macro blanks', () => { + it('blanks every macro between class and the name, and before a specialization', () => { + expectBlanked( + blankCppExportMacros, + 'class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public ::google::protobuf::Message {};', + ['PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED'] + ); + expectBlanked( + blankCppExportMacros, + 'template \nclass ABSL_ATTRIBUTE_WARN_UNUSED PROTOBUF_DECLSPEC_EMPTY_BASES\n RepeatedField final\n : private internal::RepeatedFieldBase {};', + ['ABSL_ATTRIBUTE_WARN_UNUSED', 'PROTOBUF_DECLSPEC_EMPTY_BASES'] + ); + expectBlanked( + blankCppExportMacros, + 'template \nclass PROTOBUF_DECLSPEC_EMPTY_BASES RepeatedFieldProxyWithSet<\n T, kOrProxy,\n std::enable_if_t>> {};', + ['PROTOBUF_DECLSPEC_EMPTY_BASES'] + ); + expectBlanked(blankCppExportMacros, 'struct ALIGN_AS(64U) HandleImpl : public ClockHandle {};', ['ALIGN_AS(64U)']); + // An all-caps class name after a macro is the name, `final` included. + expectBlanked(blankCppExportMacros, 'class FOO_API BAR_IMPL final : public X {};', ['FOO_API']); + }); + + it('leaves class heads alone when nothing is defined there', () => { + for (const c of [ + 'class FOO_API Name;', + 'class FOO_API Name var;', + 'struct STAT_T st = {0};', + 'class GTEST_1_TUPLE_(T) {', + '// class PROTOBUF_EXPORT Any final : public Message {', + 'const char* s = "class FOO Bar {";', + ]) { + expect(blankCppExportMacros(c)).toBe(c); + } + }); + + it('blanks annotations between a pointer or reference and the declared name', () => { + expectBlanked( + blankCppPointerAnnotationMacros, + 'static const Descriptor* PROTOBUF_NONNULL descriptor() { return d; }', + ['PROTOBUF_NONNULL'] + ); + expectBlanked( + blankCppPointerAnnotationMacros, + 'void Any::InternalSwap(Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL other) {}', + ['PROTOBUF_RESTRICT PROTOBUF_NONNULL'] + ); + expectBlanked( + blankCppPointerAnnotationMacros, + 'void f(const FieldDescriptor * PROTOBUF_NULLABLE *\n PROTOBUF_NONNULL field, const T& ABSL_ATTRIBUTE_LIFETIME_BOUND v);', + ['PROTOBUF_NULLABLE', 'PROTOBUF_NONNULL', 'ABSL_ATTRIBUTE_LIFETIME_BOUND'] + ); + expectBlanked( + blankCppPointerAnnotationMacros, + 'static constexpr const EnumDescriptor* PROTOBUF_NONNULL* PROTOBUF_NULLABLE\n file_level_enum_descriptors = nullptr;', + ['PROTOBUF_NONNULL', 'PROTOBUF_NULLABLE'] + ); + }); + + it('leaves products, bit tests, unnamed parameters and comments alone', () => { + for (const c of [ + 'int n = count * MAX_LEN + 1;', + 'bool b = (flags & FOO_BIT) != 0;', + 'bool b = flags & FOO_BIT\n && enabled;', + 'bool b = flags & FOO_BIT and enabled;', + 'void f(Foo* PROTOBUF_NONNULL);', + '/**\n * NOTE_THIS applies\n * TODO_LATER fix it\n */', + 'const char* s = "a * PROTOBUF_NONNULL b";', + 'int digits10 = int(sizeof(int) * CHAR_BIT * 3 / 10);', + 'int n = 2 * PAGE_SIZE * pages;', + '#define PTR_T int *\nFOO_BAR name;', + ]) { + expect(blankCppPointerAnnotationMacros(c)).toBe(c); + } + }); + + it('blanks attribute macros after a parameter list', () => { + expectBlanked( + blankCppTrailingAttributeMacros, + 'const UnknownFieldSet& unknown_fields() const\n ABSL_ATTRIBUTE_LIFETIME_BOUND {\n return f;\n}', + ['ABSL_ATTRIBUTE_LIFETIME_BOUND'] + ); + expectBlanked(blankCppTrailingAttributeMacros, '~Any() PROTOBUF_FINAL;', ['PROTOBUF_FINAL']); + expectBlanked( + blankCppTrailingAttributeMacros, + 'void Lock() EXCLUSIVE_LOCK_FUNCTION() { mu_.lock(); }\nvoid Wait() LOCKS_EXCLUDED(mu_);', + ['EXCLUSIVE_LOCK_FUNCTION()', 'LOCKS_EXCLUDED(mu_)'] + ); + expectBlanked( + blankCppTrailingAttributeMacros, + 'explicit MutexLock(port::Mutex* mu) EXCLUSIVE_LOCK_FUNCTION(mu) : mu_(mu) {}', + ['EXCLUSIVE_LOCK_FUNCTION(mu)'] + ); + expectBlanked( + blankCppTrailingAttributeMacros, + 'bool operator==(const A& a) const ABSL_MUST_USE_RESULT;', + ['ABSL_MUST_USE_RESULT'] + ); + }); + + it('leaves statements, casts and macro calls followed by macros alone', () => { + for (const c of [ + 'if (x) RETURN_FALSE;', + 'while (busy) SPIN_PAUSE;', + 'int v = (int) MAX_VALUE;', + 'FMT_PRAGMA_CLANG(diagnostic ignored "-Wbit-int-extension")\n\nTEST(std_test, bitint) {\n}', + 'DECLARE_FOO(a) DECLARE_BAR(b);', + 'void f()\n\nBAR_BAZ;', + 'bool b = f(x) == FOO_BAR;', + // A directive's `)` closes no parameter list. + '# if FMT_USE_FCNTL && !defined(__MINGW32__)\nTEST(file_test, open_windows_file) {\n}', + 'if constexpr (POS + 1 == str.size())\n FMT_THROW(format_error("unmatched brace"));', + ]) { + expect(blankCppTrailingAttributeMacros(c)).toBe(c); + } + }); + + it('blanks attribute macros after a declared name or its brackets', () => { + expectBlanked(blankCppDeclaratorAttributeMacros, 'port::CondVar cv GUARDED_BY(mu);', ['GUARDED_BY(mu)']); + expectBlanked(blankCppDeclaratorAttributeMacros, 'int refs_ GUARDED_BY(refs_mutex_);', ['GUARDED_BY(refs_mutex_)']); + expectBlanked(blankCppDeclaratorAttributeMacros, 'Version* const version GUARDED_BY(mu);', ['GUARDED_BY(mu)']); + expectBlanked( + blankCppDeclaratorAttributeMacros, + 'std::map cache_ ABSL_GUARDED_BY(mutex_) = {};', + ['ABSL_GUARDED_BY(mutex_)'] + ); + expectBlanked( + blankCppDeclaratorAttributeMacros, + 'const AnyGlobalsTypeInternal Any_globals_\n PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro);', + ['PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro)'] + ); + expectBlanked( + blankCppDeclaratorAttributeMacros, + 'const ::uint32_t Table::offsets[] ABSL_ATTRIBUTE_SECTION_VARIABLE(\n protodesc_cold) = {1, 2};', + ['ABSL_ATTRIBUTE_SECTION_VARIABLE(\n protodesc_cold)'] + ); + expectBlanked( + blankCppDeclaratorAttributeMacros, + 'explicit CordInputStream(const absl::Cord* cord ABSL_ATTRIBUTE_LIFETIME_BOUND);', + ['ABSL_ATTRIBUTE_LIFETIME_BOUND'] + ); + }); + + it('leaves variables named in capitals and names without a type before them alone', () => { + for (const c of [ + 'Foo DEFAULT_OPTIONS;', + 'static Foo DEFAULT_OPTIONS;', + 'const Foo DEFAULT_OPTIONS = Make();', + 'ns::Foo DEFAULT_OPTIONS;', + 'typedef Foo BAR_T;', + 'template T MAX_VALUE;', + 'return x FOO_SUFFIX;', + 'f(a, b FOO_FLAG);', + 'x = rate FOO_UNITS;', + 'EXPECT_EQ(2 SECS_X, d);', + ]) { + expect(blankCppDeclaratorAttributeMacros(c)).toBe(c); + } + }); + + it('blanks attribute macros that open a declaration', () => { + expectBlanked( + blankCppLeadingAttributeMacros, + ' PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { return n; }', + ['PROTOBUF_FUTURE_ADD_EARLY_NODISCARD'] + ); + expectBlanked( + blankCppLeadingAttributeMacros, + ' PROTOBUF_FUTURE_ADD_EARLY_NODISCARD static constexpr size_t\n kMax = 3;', + ['PROTOBUF_FUTURE_ADD_EARLY_NODISCARD'] + ); + expectBlanked( + blankCppLeadingAttributeMacros, + ' ABSL_ATTRIBUTE_REINITIALIZES void Clear() final;', + ['ABSL_ATTRIBUTE_REINITIALIZES'] + ); + expectBlanked( + blankCppLeadingAttributeMacros, + ' [[nodiscard]] PROTOBUF_NDEBUG_INLINE Ptr Make(Args&&... args) {}', + ['PROTOBUF_NDEBUG_INLINE'] + ); + expectBlanked( + blankCppLeadingAttributeMacros, + 'GTEST_API_ std::string JoinAsTuple(const Strings& fields);', + ['GTEST_API_'] + ); + expectBlanked(blankCppLeadingAttributeMacros, 'FMT_EXPORT struct as_identifiers_t {};', ['FMT_EXPORT']); + expectBlanked( + blankCppLeadingAttributeMacros, + 'PROTOBUF_NDEBUG_INLINE Any::Impl_::Impl_(Arena* arena) : x_(arena) {}', + ['PROTOBUF_NDEBUG_INLINE'] + ); + expectBlanked( + blankCppLeadingAttributeMacros, + 'PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT\n PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 const AnyGlobals Any_globals_;', + ['PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT\n PROTOBUF_ATTRIBUTE_INIT_PRIORITY1'] + ); + }); + + it('leaves a type in capitals alone', () => { + for (const c of [ + 'DWORD_PTR value = 0;', + 'SIZE_T const n = 3;', + 'UINT_PTR const* p = q;', + 'HANDLE_T Open(int flags);', + 'UINT_PTR Foo::Bar() { return 0; }', + 'RESULT_T operator==(const A& a);', + 'RESULT_T\nGnuStyle(int x) {}', + 'int x = a |\n SOME_CONST\n | OTHER;', + '// PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const;', + // A macro inside a string concatenation, with a declaration after it. + 'static const char kHelp[] =\n "using " GTEST_NAME_\n ". You can use the\\n"\n " --" GTEST_FLAG_PREFIX_\n "list_tests\\n";\nstatic bool g_help = false;', + ]) { + expect(blankCppLeadingAttributeMacros(c)).toBe(c); + } + }); + + it('blanks only the macros of a run, not a comment between them', () => { + expectBlanked( + blankCppLeadingAttributeMacros, + 'PROTOBUF_ATTRIBUTE_NO_DESTROY /* keep */ PROTOBUF_CONSTINIT const Foo kFoo;', + ['PROTOBUF_ATTRIBUTE_NO_DESTROY', 'PROTOBUF_CONSTINIT'] + ); + }); + + it('blanks a lone macro line with comments under it, but not an operand', () => { + const fmt = 'FMT_BEGIN_EXPORT\n\n// A generic formatting context with custom output iterator and character\n/* (code unit) support. */\ntemplate class generic_context {};\n'; + expectBlanked(blankLoneMacroLines, fmt, ['FMT_BEGIN_EXPORT']); + // A string literal continues the expression, comment or not. + const operand = 'const char* s =\n "x"\n FOO_PREFIX\n // comment\n "y";\n'; + expect(blankLoneMacroLines(operand)).toBe(operand); + }); + + it('keeps every offset through the whole C++ preParse', () => { + const src = 'class PROTOBUF_EXPORT X final : public Y {\n const D* PROTOBUF_NONNULL d() const ABSL_ATTRIBUTE_LIFETIME_BOUND;\n int n_ GUARDED_BY(mu_);\n};\r\n'; + const out = cppExtractor.preParse!(src, 'x.h'); + expect(out.length).toBe(src.length); + expect(out.split(/\r?\n/).map((l) => l.length)).toEqual(src.split(/\r?\n/).map((l) => l.length)); + expect(out).not.toMatch(/PROTOBUF_|ABSL_|GUARDED_BY/); + }); +}); + +const PB_H = `// Generated by the protocol buffer compiler. DO NOT EDIT! +#ifndef GOOGLE_PROTOBUF_INCLUDED_any_2eproto +#define GOOGLE_PROTOBUF_INCLUDED_any_2eproto + +#include "google/protobuf/message.h" + +namespace google { +namespace protobuf { + +class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public ::google::protobuf::Message +/* @@protoc_insertion_point(class_definition:google.protobuf.Any) */ { + public: + Any() : Any(nullptr) {} + ~Any() PROTOBUF_FINAL; + + [[nodiscard]] const ::google::protobuf::UnknownFieldSet& unknown_fields() const + ABSL_ATTRIBUTE_LIFETIME_BOUND { + return _internal_metadata_.unknown_fields(); + } + [[nodiscard]] ::google::protobuf::UnknownFieldSet* PROTOBUF_NONNULL mutable_unknown_fields() + ABSL_ATTRIBUTE_LIFETIME_BOUND { + return _internal_metadata_.mutable_unknown_fields(); + } + + [[nodiscard]] static const ::google::protobuf::Descriptor* PROTOBUF_NONNULL descriptor() { + return GetDescriptor(); + } + [[nodiscard]] static const ::google::protobuf::Descriptor* PROTOBUF_NONNULL + GetDescriptor() { + return default_instance().GetMetadata().descriptor; + } + [[nodiscard]] static const ::google::protobuf::Reflection* PROTOBUF_NONNULL GetReflection() { + return default_instance().GetMetadata().reflection; + } + void Swap(Any* PROTOBUF_NONNULL other) { + InternalSwap(other); + } + [[nodiscard]] ::std::string* PROTOBUF_NONNULL mutable_type_url(); + + private: + void InternalSwap(Any* PROTOBUF_NONNULL other); +}; + +inline ::std::string* PROTOBUF_NONNULL Any::mutable_type_url() { + return _impl_.type_url_.Mutable(); +} + +} // namespace protobuf +} // namespace google + +#endif // GOOGLE_PROTOBUF_INCLUDED_any_2eproto +`; + +const MESSAGE_H = `namespace google { +namespace protobuf { + +class Message { + public: + virtual ~Message(); +}; + +} // namespace protobuf +} // namespace google +`; + +const PB_CC = `#include "google/protobuf/any.pb.h" + +namespace google { +namespace protobuf { + +PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT PROTOBUF_EXPORT + PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 const AnyGlobalsTypeInternal Any_globals_ + PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro); + +void Any::InternalSwap(Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL other) { + _impl_.type_url_.InternalSwap(&other->_impl_.type_url_); +} + +} // namespace protobuf +} // namespace google +`; + +const REPEATED_H = `#include + +namespace google { +namespace protobuf { + +template +class ABSL_ATTRIBUTE_WARN_UNUSED PROTOBUF_DECLSPEC_EMPTY_BASES + RepeatedField final + : private internal::RepeatedFieldBase { + public: + int size() const { return size_; } + + private: + int size_; +}; + +} // namespace protobuf +} // namespace google +`; + +const DESCRIPTOR_H = `#include + +namespace google { +namespace protobuf { + +class PROTOBUF_EXPORT Descriptor : private internal::SymbolBase { + public: + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { + return all_names_[0]; + } + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view full_name() const { + return all_names_[1]; + } + PROTOBUF_FUTURE_ADD_EARLY_NODISCARD const FileDescriptor* file() const; +}; + +} // namespace protobuf +} // namespace google +`; + +const FMT_H = `#include + +#define FMT_BEGIN_EXPORT +FMT_BEGIN_NAMESPACE + +FMT_BEGIN_EXPORT + +// A generic formatting context with custom output iterator and character +// (code unit) support. +template class generic_context { + private: + OutputIt out_; + + public: + constexpr auto out() -> OutputIt { return out_; } +}; + +FMT_END_EXPORT +FMT_END_NAMESPACE +`; + +const LEVELDB_CC = `#include "port/port.h" + +namespace leveldb { + +class Counter { + public: + void IncrementBy(int count) LOCKS_EXCLUDED(mu_) { + MutexLock l(&mu_); + count_ += count; + } + void Lock() EXCLUSIVE_LOCK_FUNCTION() { mu_.Lock(); } + + private: + port::Mutex mu_; + int count_ GUARDED_BY(mu_); +}; + +} // namespace leveldb +`; + +describe('C++ classes written around attribute macros', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-attr-macros-')); + const files: Record = { + 'src/google/protobuf/message.h': MESSAGE_H, + 'src/google/protobuf/any.pb.h': PB_H, + 'src/google/protobuf/any.pb.cc': PB_CC, + 'src/google/protobuf/repeated_field.h': REPEATED_H, + 'src/google/protobuf/descriptor.h': DESCRIPTOR_H, + 'include/fmt/format.h': FMT_H, + 'db/counter.cc': LEVELDB_CC, + }; + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + } + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + const nodes = (file: string) => cg.getNodesInFile(file); + const qualified = (file: string, kind: string) => + nodes(file).filter((n) => n.kind === kind).map((n) => n.qualifiedName).sort(); + + it('indexes a generated message class with its members as methods', () => { + const file = 'src/google/protobuf/any.pb.h'; + expect(qualified(file, 'class')).toEqual(['google::protobuf::Any']); + expect(qualified(file, 'method')).toEqual( + expect.arrayContaining([ + 'google::protobuf::Any::Any', + 'google::protobuf::Any::GetDescriptor', + 'google::protobuf::Any::GetReflection', + 'google::protobuf::Any::Swap', + 'google::protobuf::Any::descriptor', + 'google::protobuf::Any::mutable_type_url', + 'google::protobuf::Any::mutable_unknown_fields', + 'google::protobuf::Any::unknown_fields', + ]) + ); + // No member is left at namespace level, and no node is named after a macro. + expect(qualified(file, 'function')).toEqual([]); + expect(nodes(file).filter((n) => /PROTOBUF_|ABSL_/.test(n.qualifiedName))).toEqual([]); + const any = nodes(file).find((n) => n.kind === 'class')!; + const bases = cg.getOutgoingEdges(any.id).filter((e) => e.kind === 'extends'); + expect(bases.map((e) => cg.getNode(e.target)?.qualifiedName)).toEqual(['google::protobuf::Message']); + }); + + it('indexes an out-of-line definition whose parameter carries an annotation', () => { + expect(qualified('src/google/protobuf/any.pb.cc', 'method')).toEqual(['google::protobuf::Any::InternalSwap']); + }); + + it('indexes a class whose head stacks macros over several lines', () => { + const file = 'src/google/protobuf/repeated_field.h'; + expect(qualified(file, 'class')).toEqual(['google::protobuf::RepeatedField']); + expect(qualified(file, 'method')).toEqual(['google::protobuf::RepeatedField::size']); + }); + + it('indexes members that open with an attribute macro', () => { + const file = 'src/google/protobuf/descriptor.h'; + expect(qualified(file, 'method')).toEqual([ + 'google::protobuf::Descriptor::full_name', + 'google::protobuf::Descriptor::name', + ]); + }); + + it('indexes a class under a lone macro line and a comment', () => { + expect(qualified('include/fmt/format.h', 'method')).toEqual(['generic_context::out']); + expect(qualified('include/fmt/format.h', 'function')).toEqual([]); + }); + + it('keeps annotated methods instead of phantoms named after the annotation', () => { + const file = 'db/counter.cc'; + expect(qualified(file, 'method')).toEqual(['leveldb::Counter::IncrementBy', 'leveldb::Counter::Lock']); + expect(nodes(file).filter((n) => /LOCKS|GUARDED/.test(n.name))).toEqual([]); + }); +}); diff --git a/__tests__/cpp-brace-scopes.test.ts b/__tests__/cpp-brace-scopes.test.ts index 2740f6007c..f29c76b099 100644 --- a/__tests__/cpp-brace-scopes.test.ts +++ b/__tests__/cpp-brace-scopes.test.ts @@ -19,6 +19,10 @@ * Lookups that start from a declaration's scope (base classes, alias * receivers, constructors) then had only a unique-name guess left. * + * The C++ preParse now blanks both of those macros, so the fixtures below + * keep the shapes with macros it doesn't know: a one-word attribute macro + * (`NODISCARD`) and a lowercase one in a class head (`cacheline_aligned(64U)`). + * * For a file whose tree has errors, the walker now takes each declaration's * namespaces and enclosing classes from the source's braces, which the * parser rarely misreads; a class the tree glued into a declaration's type is @@ -54,7 +58,7 @@ namespace protobuf { class Names { public: - PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { + NODISCARD absl::string_view name() const { return absl::string_view(payload_); } @@ -77,7 +81,7 @@ namespace clock_cache { class FixedTable : public BaseTable { public: - struct ALIGN_AS(64U) HandleImpl : public ClockHandle { + struct cacheline_aligned(64U) HandleImpl : public ClockHandle { RelaxedAtomic displacements{}; bool standalone = false; }; @@ -224,7 +228,7 @@ describe('C++ scopes from the source braces of a file whose tree has errors', () }); it('a file that parses cleanly keeps the tree scopes', () => { - const clean = PROTOBUF_SHAPE.replace('PROTOBUF_FUTURE_ADD_EARLY_NODISCARD ', ''); + const clean = PROTOBUF_SHAPE.replace('NODISCARD ', ''); const tree = getParser('cpp')!.parse(cppExtractor.preParse!(clean, 'descriptor.h'))!; expect(tree.rootNode.hasError).toBe(false); tree.delete(); diff --git a/__tests__/kernel-ccpp-parity.test.ts b/__tests__/kernel-ccpp-parity.test.ts index 720ffb623a..33baf912ec 100644 --- a/__tests__/kernel-ccpp-parity.test.ts +++ b/__tests__/kernel-ccpp-parity.test.ts @@ -232,6 +232,52 @@ describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => { assertParity('fixtures/kern.cu', cuda, 'cpp'); }); + // protobuf's generated headers and leveldb's thread annotations put + // attribute macros where tree-sitter can't read them: stacked in a class + // head, between a pointer and its name, after a parameter list or a field, + // opening a member. The hoisted preParse blanks them all, so the file parses + // clean and goes through the kernel instead of deferring — at parity. + it.each(['\n', '\r\n'])('attribute-macro shapes parse clean through the hoisted preParse (%j)', (eol) => { + const source = [ + 'namespace google {', + 'namespace protobuf {', + 'class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final : public ::google::protobuf::Message', + '/* @@protoc_insertion_point(class_definition:google.protobuf.Any) */ {', + ' public:', + ' ~Any() PROTOBUF_FINAL;', + ' [[nodiscard]] const ::google::protobuf::UnknownFieldSet& unknown_fields() const', + ' ABSL_ATTRIBUTE_LIFETIME_BOUND {', + ' return _internal_metadata_.unknown_fields();', + ' }', + ' [[nodiscard]] static const ::google::protobuf::Descriptor* PROTOBUF_NONNULL', + ' GetDescriptor() {', + ' return default_instance().GetMetadata().descriptor;', + ' }', + ' PROTOBUF_FUTURE_ADD_EARLY_NODISCARD absl::string_view name() const { return name_; }', + ' void Swap(Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL other) { InternalSwap(other); }', + '', + ' private:', + ' void InternalSwap(Any* PROTOBUF_NONNULL other);', + ' int count_ GUARDED_BY(mu_);', + '};', + 'void Any::InternalSwap(Any* PROTOBUF_NONNULL other) { swap(*other); }', + '} // namespace protobuf', + '} // namespace google', + '', + ].join(eol); + assertParity('fixtures/any.pb.h', source, 'cpp', 6); + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract('fixtures/any.pb.h', source, 'cpp')!; + const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.qualifiedName); + expect(methods).toEqual(expect.arrayContaining([ + 'google::protobuf::Any::GetDescriptor', + 'google::protobuf::Any::InternalSwap', + 'google::protobuf::Any::Swap', + 'google::protobuf::Any::name', + 'google::protobuf::Any::unknown_fields', + ])); + }); + // Every torture fixture again with CRLF line endings — the shape every // Windows autocrlf checkout has. Derived in memory (not a checked-in CRLF // file) so no platform or editor can silently normalize it away. Pins the diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index ac238978d6..c2e1f59bea 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -378,13 +378,389 @@ function isMacroMisparsedTypeDecl(node: SyntaxNode): boolean { * declarations (`struct FOO var;`, `class FOO obj = …`) untouched, since those * end in `;` / `=` / `[`, never `:` / `{`. C++-only (wired into cppExtractor), * so C's heavier use of `struct TAG var;` never reaches it. + * + * The head may carry several macros, and a macro may take arguments: protobuf's + * generated `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any + * final : public Message`, `class ABSL_ATTRIBUTE_WARN_UNUSED + * PROTOBUF_DECLSPEC_EMPTY_BASES RepeatedField final`, rocksdb's `struct + * ALIGN_AS(64U) HandleImpl : public ClockHandle`. The whole run is blanked; the + * last name before the guard is the class. The name may be followed by template + * arguments, for a specialization (`class PROTOBUF_DECLSPEC_EMPTY_BASES + * RepeatedFieldProxyWithSet {`). Matched on the code alone, so + * a comment or string never takes part. */ +const CPP_CLASS_HEAD_MACROS_RE = + /\b(?:class|struct)\s+((?:[A-Z][A-Z0-9_]+(?:\s*\((?:[^()]|\([^()]*\))*\))?\s+)+)(?!final\b)[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*/g; export function blankCppExportMacros(source: string): string { if (source.indexOf('class') === -1 && source.indexOf('struct') === -1) return source; - return source.replace( - /\b(class|struct)(\s+)([A-Z][A-Z0-9_]+)(?=\s+[A-Za-z_]\w*(?:\s+final)?\s*[:{])/g, - (_m, kw, ws, macro) => kw + ws + ' '.repeat(macro.length) - ); + if (!/\b(?:class|struct)\s+[A-Z][A-Z0-9_]/.test(source)) return source; + const code = maskCppCode(source); + const spans: Array<[number, number]> = []; + const re = new RegExp(CPP_CLASS_HEAD_MACROS_RE.source, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + let i = skipCppSpace(code, m.index + m[0].length); + if (code[i] === '<') { + i = skipCppAngleArgs(code, i); + if (i < 0) continue; + i = skipCppSpace(code, i); + } + if (/^final\b/.test(code.slice(i, i + 6))) i = skipCppSpace(code, i + 5); + if (code[i] !== '{' && !(code[i] === ':' && code[i + 1] !== ':')) continue; + const run = m[1] as string; + const start = m.index + m[0].indexOf(run); + pushCppRunTokens(spans, run, start); + } + return blankCppSpans(source, spans, code); +} + +// Comments and string/char literals (raw strings are masked beforehand by +// maskCppRawStrings). The char-literal boundary leaves digit separators alone. +const CPP_COMMENT_OR_LITERAL_RE = + /\/\/(?:\\\r?\n|[^\r\n])*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*(?:"|$)|(? m.replace(/[^\r\n]/g, ' ')); +} + +/** + * The code the macro scans below match on, at the source's offsets: comments + * are spaces, a string or char literal keeps its place as `\x01`s (it is a + * token, not space: in `"… using " GTEST_NAME_` the macro does not start a + * line, and a run of macros never reaches across a literal), and + * preprocessor directives with their `\` continuation lines are spaces — the + * `)` ending `#if !defined(__MINGW32__)` closes no parameter list, so the + * `TEST(file_test, open_windows_file) {` under it is not a trailing macro. + */ +function maskCppCode(source: string): string { + if (cppMaskMemo !== null && cppMaskMemo.source === source) return cppMaskMemo.code; + const code = source + .replace(CPP_COMMENT_OR_LITERAL_RE, (m) => m.replace(/[^\r\n]/g, m.startsWith('/') ? ' ' : '\x01')) + .replace(/^[ \t]*#(?:\\\r?\n|[^\r\n])*/gm, (m) => m.replace(/[^\r\n]/g, ' ')); + cppMaskMemo = { source, code }; + return code; +} + +// The last source maskCppCode masked. The passes run one after another on +// the same text: one that blanks nothing hands the next the same string, and +// one that blanks hands it a mask blanked the same way (blankCppSpans), so a +// file is masked once rather than once per pass. +let cppMaskMemo: { source: string; code: string } | null = null; + +// One macro, with its arguments, inside a run matched on the masked code. +const CPP_RUN_TOKEN_RE = /[A-Z][A-Z0-9_]*(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?/g; + +/** Add a span for each macro of `run` (found at `start`), leaving what lies between them. */ +function pushCppRunTokens(spans: Array<[number, number]>, run: string, start: number): void { + for (const t of run.matchAll(CPP_RUN_TOKEN_RE)) spans.push([start + t.index, start + t.index + t[0].length]); +} + +/** + * Replace each [start, end) span of `source` with spaces, keeping line + * endings. Given the mask the spans were found on, blank it the same way and + * keep it for the next pass: a span holds whole macros (with their arguments), + * so the result is what masking the new source would give. + */ +function blankCppSpans(source: string, spans: ReadonlyArray, code?: string): string { + if (spans.length === 0) return source; + const blank = (text: string): string => { + const chars = text.split(''); + for (const [start, end] of spans) { + for (let k = start; k < end; k++) { + if (chars[k] !== '\n' && chars[k] !== '\r') chars[k] = ' '; + } + } + return chars.join(''); + }; + const out = blank(source); + if (code !== undefined) cppMaskMemo = { source: out, code: blank(code) }; + return out; +} + +function skipCppSpace(code: string, i: number): number { + while (i < code.length && /\s/.test(code[i] as string)) i++; + return i; +} + +/** + * The index just past the template argument list opening at `code[open]` + * (`<`), or -1 when it doesn't close before a `;`, `{` or `}` — then it was a + * comparison, not template arguments. Angle brackets inside parentheses don't + * count (`Foo`). + */ +function skipCppAngleArgs(code: string, open: number): number { + let angles = 0; + let parens = 0; + const limit = Math.min(code.length, open + 4000); + for (let i = open; i < limit; i++) { + const c = code[i]; + if (c === '(') parens++; + else if (c === ')') { + if (--parens < 0) return -1; + } else if (c === ';' || c === '{' || c === '}') return -1; + else if (parens === 0 && c === '<') angles++; + else if (parens === 0 && c === '>' && code[i - 1] !== '-') { + if (--angles === 0) return i + 1; + } + } + return -1; +} + +/** + * Blank attribute macros between a pointer or reference and the name it + * declares: protobuf's nullability annotations (`const Descriptor* + * PROTOBUF_NONNULL descriptor()`, `Any* PROTOBUF_RESTRICT PROTOBUF_NONNULL + * other`, `const FieldDescriptor* PROTOBUF_NULLABLE* PROTOBUF_NONNULL field`). + * tree-sitter takes the macro for the declared name and the real name for an + * error: a member keeps a broken declarator, an out-of-line definition gets the + * macro glued into its qualified name (`PROTOBUF_NONNULL Any::mutable_type_url`), + * and a definition whose parameter carries one (`void Any::Swap(Any* + * PROTOBUF_NONNULL other) { … }`) parses as a variable and drops out of the + * index. Generated `.pb.h`/`.pb.cc` files carry thousands of them. + * + * Matched on the code alone: the macro is ALL-CAPS with an underscore, right + * after `*` or `&`, and followed by the name (or, after `*`, by another `*`). + * C++ never puts two names in a row there, and a product (`a * MAX_LEN + 1`) + * or a bit test (`flags & FOO_BIT)`) is followed by an operator or a closer + * instead; a `*` after `)`, `]` or a number is a product too (`sizeof(int) * + * CHAR_BIT * 3`). A trailing macro with nothing after it (`Foo* + * PROTOBUF_NONNULL)`) parses as the parameter name and is left alone. + * C++-only. + */ +const CPP_POINTER_ANNOTATION_RE = + /([*&])\s*((?:[A-Z][A-Z0-9]*_[A-Z0-9_]*(?:\s+|(?=\*)))+)(?=\*|::|(?!(?:and|and_eq|bitand|bitor|compl|not|not_eq|or|or_eq|xor|xor_eq)\b)[A-Za-z_~])/g; +export function blankCppPointerAnnotationMacros(source: string): string { + if (!/[*&]\s*[A-Z][A-Z0-9]*_/.test(source)) return source; + const code = maskCppCode(source); + const spans: Array<[number, number]> = []; + const re = new RegExp(CPP_POINTER_ANNOTATION_RE.source, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + const run = m[2] as string; + const start = m.index + m[0].length - run.length; + // After `&`, only a name: `flags & FOO_BIT\n && ok` is an expression. + if (m[1] === '&' && code[start + run.length] === '*') continue; + // A `*` after a closing bracket or a number multiplies: `sizeof(int) * CHAR_BIT * 3`. + if (/(?:[)\]]|\b\d[\w.]*)\s*$/.test(code.slice(Math.max(0, m.index - 40), m.index))) continue; + pushCppRunTokens(spans, run, start); + } + return blankCppSpans(source, spans, code); +} + +// Keywords that take a parenthesized operand: a `)` closing one of these is +// not a parameter list, so a token after it is not a trailing attribute. +// `constexpr` / `consteval` stand before the condition of `if constexpr (…)`. +const CPP_PAREN_KEYWORDS = new Set([ + 'if', 'while', 'for', 'switch', 'catch', 'return', 'sizeof', 'alignof', 'alignas', 'decltype', + 'typeid', 'noexcept', 'throw', 'static_assert', 'new', 'delete', 'case', 'requires', 'co_await', + 'co_yield', 'co_return', 'asm', '__asm__', '__attribute__', '__attribute', '__declspec', '_Pragma', + 'else', 'do', 'constexpr', 'consteval', 'explicit', 'typeof', '__typeof__', '__typeof', '_Alignof', + '__alignof__', 'volatile', '__volatile__', +]); + +/** + * Blank attribute macros after a function's parameter list: protobuf's + * `unknown_fields() const\n ABSL_ATTRIBUTE_LIFETIME_BOUND {` and `~Any() + * PROTOBUF_FINAL;`, and the thread-safety annotations of leveldb, rocksdb and + * abseil (`void Wait() LOCKS_EXCLUDED(mu_) {`, `void Lock() + * EXCLUSIVE_LOCK_FUNCTION();`). tree-sitter reads the macro as the start of a + * new declaration, so the method loses its `;` or its body: an inline body + * becomes a stray block, and in a long run of members the class can end early. + * + * Matched on the code alone, so it can't touch an expression: + * - the `)` closes a parameter list: the `(` it matches follows a function + * name (`name(`, `~Name(`, `operator==(`, `operator()(`), not a keyword + * (`if (x) FOO;`), a cast (`= (int) MAX_VALUE;`) or a macro call + * (`FMT_PRAGMA_CLANG(…)` above `TEST(std_test, bitint) {`); + * - between `)` and the macro only `const`, `volatile`, `&`, `&&`, + * `noexcept`, `override` or `final` may stand, and no blank line; + * - the macro (ALL-CAPS, optionally with balanced arguments; several may + * stack) is followed by what may end a declarator: `{`, `;`, `=`, a + * constructor's `:`, `->`, or `override`/`final`/`const`/`noexcept`. + * C++-only. + */ +const CPP_TRAILING_ATTR_CANDIDATE_RE = + /\)((?:\s*(?:const|volatile|noexcept|override|final|&&|&)(?![\w]))*)\s*(?=[A-Z][A-Z0-9_]{2,}\b)/g; +const CPP_TRAILING_ATTR_RUN_RE = + /(?:[A-Z][A-Z0-9_]{2,}\b(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?\s*)+/y; +const CPP_TRAILING_ATTR_FOLLOW_RE = /(?:\{|;|=(?!=)|:(?!:)|->|(?:override|final|const|noexcept)\b)/y; +export function blankCppTrailingAttributeMacros(source: string): string { + if (!/\)[\s\w&]*?\b[A-Z][A-Z0-9_]{2,}\b/.test(source)) return source; + const code = maskCppCode(source); + const spans: Array<[number, number]> = []; + const re = new RegExp(CPP_TRAILING_ATTR_CANDIDATE_RE.source, 'g'); + const run = new RegExp(CPP_TRAILING_ATTR_RUN_RE.source, 'y'); + const follow = new RegExp(CPP_TRAILING_ATTR_FOLLOW_RE.source, 'y'); + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + const macroStart = m.index + m[0].length; + if (/\n[ \t]*\r?\n/.test(m[0])) continue; + run.lastIndex = macroStart; + const r = run.exec(code); + if (!r) continue; + follow.lastIndex = macroStart + r[0].length; + if (!follow.test(code)) continue; + if (!closesParameterList(code, m.index)) continue; + pushCppRunTokens(spans, r[0], macroStart); + re.lastIndex = macroStart + r[0].length; + } + return blankCppSpans(source, spans, code); +} + +// Type keywords that may stand right before a declared name (`int count_ …`). +const CPP_TYPE_KEYWORDS = new Set([ + 'void', 'bool', 'char', 'char8_t', 'char16_t', 'char32_t', 'wchar_t', 'short', 'int', 'long', 'float', + 'double', 'signed', 'unsigned', 'auto', +]); +const CPP_KEYWORDS = new Set([ + ...CPP_TYPE_KEYWORDS, ...CPP_PAREN_KEYWORDS, 'const', 'volatile', 'static', 'extern', 'inline', 'virtual', + 'explicit', 'constexpr', 'consteval', 'constinit', 'friend', 'mutable', 'thread_local', 'register', + 'typedef', 'using', 'template', 'typename', 'class', 'struct', 'union', 'enum', 'namespace', 'operator', + 'goto', 'break', 'continue', 'default', 'public', 'private', 'protected', 'this', 'true', 'false', + 'nullptr', 'and', 'or', 'not', 'xor', 'bitand', 'bitor', 'compl', 'and_eq', 'or_eq', 'xor_eq', 'not_eq', +]); + +/** + * Blank attribute macros after the name a declaration declares, or after its + * array brackets: thread-safety annotations on fields (leveldb's `port::CondVar + * cv GUARDED_BY(mu);`, abseil's `ABSL_GUARDED_BY`), annotated parameters + * (`const absl::Cord* cord ABSL_ATTRIBUTE_LIFETIME_BOUND)`), and the section + * attributes of protobuf's generated globals (`Any_globals_ + * PROTOBUF_MESSAGE_GLOBALS_SECTION(.data.rel.ro);`, `offsets[] + * ABSL_ATTRIBUTE_SECTION_VARIABLE(protodesc_cold) = {`). tree-sitter reads + * the macro as a second declarator and errors. + * + * Matched on the code alone, so it can't touch a variable named in capitals: + * - the macro is ALL-CAPS with an underscore, optionally with balanced + * arguments, and is followed by `;`, `=`, `,` or `)`; + * - before it stands `]`, or a name with a lowercase letter that itself + * follows a type: a type name, a type keyword, `*`, `&`, `>`, or `const` + * after a pointer. `Foo DEFAULT_OPTIONS;` and `const Foo kMAX_SIZE;` + * declare those capitals (nothing names a type before `Foo`), `typedef Foo + * BAR_T;` names a type, and `return x FOO;` or `f(a, b FOO)` have no type + * before the name. + * C++-only. + */ +const CPP_DECLARATOR_ATTR_CANDIDATE_RE = /(? = []; + const re = new RegExp(CPP_DECLARATOR_ATTR_CANDIDATE_RE.source, 'g'); + const run = new RegExp(CPP_DECLARATOR_ATTR_RUN_RE.source, 'y'); + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + const macroStart = m.index; + // Whitespace before the macro, and before that a name or `]`. + let last = macroStart - 1; + while (last >= 0 && /\s/.test(code[last] as string)) last--; + if (last === macroStart - 1 || last < 0 || !/[\w\]]/.test(code[last] as string)) continue; + run.lastIndex = macroStart; + const r = run.exec(code); + if (!r || !/^[;,)]|^=(?!=)/.test(code.slice(macroStart + r[0].length, macroStart + r[0].length + 2))) continue; + if (code[last] !== ']' && !followsDeclaredName(code, last)) continue; + pushCppRunTokens(spans, r[0], macroStart); + re.lastIndex = macroStart + r[0].length; + } + return blankCppSpans(source, spans, code); +} + +/** True when the identifier ending at `code[last]` is a name a declaration declares. */ +function followsDeclaredName(code: string, last: number): boolean { + let start = last; + while (start > 0 && /\w/.test(code[start - 1] as string)) start--; + const name = code.slice(start, last + 1); + if (!/^[A-Za-z_]\w*$/.test(name) || !/[a-z]/.test(name) || CPP_KEYWORDS.has(name)) return false; + let i = start - 1; + while (i >= 0 && /\s/.test(code[i] as string)) i--; + const c = code[i]; + if (c === '*' || c === '&') return true; + if (c === '>') return code[i - 1] !== '-'; + if (!c || !/\w/.test(c)) return false; + let j = i; + while (j > 0 && /\w/.test(code[j - 1] as string)) j--; + const before = code.slice(j, i + 1); + if (before === 'const' || before === 'volatile') { + let k = j - 1; + while (k >= 0 && /\s/.test(code[k] as string)) k--; + return code[k] === '*' || code[k] === '&'; + } + if (CPP_TYPE_KEYWORDS.has(before)) return true; + return /^[A-Za-z_]\w*$/.test(before) && !CPP_KEYWORDS.has(before); +} + +/** True when the `)` at `code[close]` closes a function declarator's parameter list. */ +function closesParameterList(code: string, close: number): boolean { + let depth = 0; + let open = -1; + for (let i = close; i >= 0 && close - i < 4000; i--) { + const c = code[i]; + if (c === ')') depth++; + else if (c === '(') { + if (--depth === 0) { open = i; break; } + } else if (c === ';' || c === '{' || c === '}') return false; + } + if (open < 0) return false; + const before = code.slice(Math.max(0, open - 200), open); + // `operator()(…)`, `operator==(…)`, `operator bool(…)` + if (/\boperator\s*(?:\(\s*\)|\[\s*\]|[^\s\w()[\]]{1,3}|\s+[A-Za-z_][\w\s:<>*&]*)\s*$/.test(before)) return true; + const name = /([A-Za-z_]\w*)\s*$/.exec(before)?.[1]; + return !!name && !CPP_PAREN_KEYWORDS.has(name) && !/^[A-Z][A-Z0-9_]*$/.test(name); +} + +/** + * Blank attribute macros that open a declaration, at the start of a line or + * after a `[[…]]` attribute: protobuf's `PROTOBUF_FUTURE_ADD_EARLY_NODISCARD + * absl::string_view name() const {` (250 members of descriptor.h alone), + * `ABSL_ATTRIBUTE_REINITIALIZES void Clear()`, `[[nodiscard]] + * PROTOBUF_NDEBUG_INLINE Ptr Make(…)`, googletest's `GTEST_API_ + * std::string JoinAsTuple(…)`, {fmt}'s `FMT_EXPORT struct …`. tree-sitter + * takes the macro for the type and everything after it for an error, which + * swallows the members that follow. `blankCLeadingAttrMacros` only knows a + * plain `MACRO Ret name(`; C++ adds qualified and template types, specifiers + * and keywords. + * + * The macro (ALL-CAPS with an underscore; several may stack) must be followed + * by what can't follow a type name: a declaration keyword (`static`, + * `inline`, `virtual`, `void`, `struct`, `template`, `return`, `[[`, …) or a + * whole declaration of its own, a type and then the declared name (`Type + * name(`, `ns::Type* name;`). A type is followed by the name alone + * (`DWORD_PTR value = 0;`, `HANDLE_T Open(…)`), so it never matches. `const` + * after the token could be either (`SIZE_T const n`), so it only counts with a + * type and name after it. Matched on the code alone. C++-only. + */ +const CPP_TYPE_ARGS = '<(?:[^<>;{}]|<(?:[^<>;{}]|<[^<>;{}]*>)*>)*>'; +const CPP_TYPE_NAME = `(?:::\\s*)?(?!(?:const|volatile|operator|return)\\b)[A-Za-z_]\\w*(?:\\s*${CPP_TYPE_ARGS})?(?:\\s*::\\s*[A-Za-z_]\\w*(?:\\s*${CPP_TYPE_ARGS})?)*`; +const CPP_DECLARED_NAME = '(?:(?:::\\s*)?~?[A-Za-z_]\\w*(?:\\s*::\\s*~?[A-Za-z_]\\w*)*\\s*[(;=[{]|operator\\b)'; +const CPP_LEADING_ATTR_RE = new RegExp( + '(^[ \\t]*|\\]\\][ \\t]*)((?:[A-Z][A-Z0-9]*_[A-Z0-9_]*\\s+)+)(?=' + + '(?:static|inline|virtual|explicit|constexpr|consteval|constinit|friend|extern|thread_local|mutable|' + + 'typedef|using|template|typename|class|struct|union|enum|void|bool|char|char8_t|char16_t|char32_t|' + + 'wchar_t|short|int|long|float|double|signed|unsigned|auto|decltype|return)\\b|\\[\\[|' + + // An out-of-line constructor or destructor: `Any::Impl_::Impl_(`, `Any::~Any(`. + '(?:[A-Za-z_]\\w*\\s*::\\s*)*([A-Za-z_]\\w*)\\s*::\\s*~?\\3\\s*\\(|' + + `(?:(?:const|volatile)\\s+)*${CPP_TYPE_NAME}(?:\\s*(?:const|volatile)\\b)?(?:\\s*[*&]+\\s*|\\s+)(?:(?:const|volatile)\\s+)?${CPP_DECLARED_NAME})`, + 'gm' +); +export function blankCppLeadingAttributeMacros(source: string): string { + if (!/(?:^|\]\])[ \t]*[A-Z][A-Z0-9]*_[A-Z0-9_]*\s/m.test(source)) return source; + const code = maskCppCode(source); + const spans: Array<[number, number]> = []; + const re = new RegExp(CPP_LEADING_ATTR_RE.source, 'gm'); + let m: RegExpExecArray | null; + while ((m = re.exec(code)) !== null) { + const start = m.index + (m[1] as string).length; + pushCppRunTokens(spans, m[2] as string, start); + } + return blankCppSpans(source, spans, code); } /** @@ -680,9 +1056,11 @@ export function blankCppAnnotationMacroCalls(source: string): string { * continuation) — so an ALL-CAPS operand split onto its own line inside a * multi-line expression (`int x =\n SOME_CONST\n | OTHER;`) is left * alone; and - * - the NEXT non-blank line starts like a declaration/scope token + * - the NEXT line of code starts like a declaration/scope token * (letter, `_`, `#`, `{`, `}`, or `~`) or the file ends — an operator, - * string literal, or `;` continuation rejects the match. + * string literal, or `;` continuation rejects the match. Comments in + * between are skipped: {fmt}'s `FMT_BEGIN_EXPORT` sits above `// A + * generic formatting context …` and then the class it opens. * Shared by C and C++ (the idiom is identical in both). */ const LONE_MACRO_LINE_RE = /^[ \t]*([A-Z][A-Z0-9_]{3,})[ \t]*(?:\/\/[^\n\r]*|\/\*[^\n\r]*\*\/[ \t]*)?\r?$/; @@ -709,12 +1087,8 @@ export function blankLoneMacroLines(source: string): string { const prevText = prev >= 0 ? content(lines[prev] as string) : ''; const prevCode = /^(?:\/\/|\/\*|\*)/.test(prevText) ? '' : prevText.replace(/\/\*.*?\*\/|\/\/.*$/g, '').trim(); if (prevCode !== '' && !/^template\s* m.replace(/[^\r\n]/g, ' ') - ); + let code = maskCppCommentsAndLiterals(source); const hasAlias = /^[ \t]*#[ \t]*define[ \t]+interface[ \t]+struct\b/m.test(code); code = code.replace(/^[ \t]*#(?:\\\r?\n|[^\r\n])*/gm, (m) => m.replace(/[^\r\n]/g, '\0')); const declaration = /(^|[;{}])\s*\binterface\s+[A-Za-z_]\w*\s*(:[^;{}]+)?\{/gm; @@ -1065,12 +1458,23 @@ function preParseCppSource(source: string, filePath?: string): string { source = flattenMidStatementConditionals(rewriteCppAccessMacros(rawStrings.source)); // blankCLeadingAttrMacros runs AFTER the api-prefix blank so a stacked // `FMT_NORETURN FMT_API void f(…)` reduces to the `MACRO Ret name(` shape - // it matches (the _API token is already spaces by then). + // it matches (the _API token is already spaces by then). The C++ leading + // blank runs after the pointer, trailing and declarator ones, so the + // declaration it looks for (`Type* name(`, `Type name;`) is already free of + // their macros. let blanked = blankLoneMacroLines( - blankCLeadingAttrMacros( - blankCppAnnotationMacroCalls( - blankCppInlineAnnotationMacros( - blankCppApiPrefixMacros(blankCppInlineMacros(blankCppExportMacros(normalizeCppComInterfaces(source)))) + blankCppLeadingAttributeMacros( + blankCLeadingAttrMacros( + blankCppAnnotationMacroCalls( + blankCppDeclaratorAttributeMacros( + blankCppTrailingAttributeMacros( + blankCppPointerAnnotationMacros( + blankCppInlineAnnotationMacros( + blankCppApiPrefixMacros(blankCppInlineMacros(blankCppExportMacros(normalizeCppComInterfaces(source)))) + ) + ) + ) + ) ) ) ) From 9a1d4bd91deee406d3f41d79d6e89686ca190a42 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:49:21 +0000 Subject: [PATCH 04/20] fix(js,ts): `require('./x').default` reaches an ES module's default export (#2433) `const X = require('./x').default` and `const { default: X } = require('./x')` map to a named import of `default`. That is right for a CommonJS module that sets the property itself (`exports.default = fn`, or the dual `module.exports = X; module.exports.default = X`), but a module written as an ES module and compiled to CommonJS sets it to its default export, which is no named export. References through the binding stayed unresolved (bitwarden's `new OsBiometricsServiceMac(...)`) or fell to name matching: the local variable holding the module, or a namesake method in another package. findExportedSymbolWalk now falls back to the module's ESM default export when the named lookup finds no `default`. Only for JS-family refs, only in the module the require names (`export * from` forwards no default), and only when that module has an ESM default export at all: a single-file component, or an `export default` statement. A CommonJS module's `exports.x = function` declarations are flagged exported, so without that gate the first-exported-function guess would invent a default for it. bitwarden +2 edges (the two failed `instantiates` refs). react-native +872 -617: all 617 re-resolved at the same site (541 from the local require binding to the real function or class, 47 from a namesake method in another package to the right module, 28 metadata only, 1 from a type signature to the implementation), 870 of 872 added edges on the declared default or what it holds. express, eslint, mocha, koa, body-parser, fastify, topcoder, proshop_mern, next-saas-starter and create-t3-turbo byte-identical. Dead-code claims unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/require-default.test.ts | 243 ++++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 43 +++++- 3 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 __tests__/require-default.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e504c5ade..f6a57a9d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. +- In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. diff --git a/__tests__/require-default.test.ts b/__tests__/require-default.test.ts new file mode 100644 index 0000000000..a908e35965 --- /dev/null +++ b/__tests__/require-default.test.ts @@ -0,0 +1,243 @@ +/** + * `require('./x').default` reads the `default` PROPERTY of the module's + * exports, and so does `const { default: X } = require('./x')`. The import + * mapping records it as a named import of `default`, which is right for a + * CommonJS module that sets the property by name (`exports.default = fn`, or + * the dual `module.exports = X; module.exports.default = X`). A module + * written as an ES module and compiled to CommonJS sets it to its default + * export, `export default class Foo`, which is no named export: bitwarden's + * desktop app loads its macOS biometrics service with + * `require("./os-biometrics-mac.service").default` and `new`s it, and that + * `instantiates` reference stayed unresolved. The named property is still + * looked up first; the ES module's default export answers only when there is + * no such property, and only in the module asked for. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Edge } from '../src/types'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-require-default-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'desktop', devDependencies: { svelte: '^4' } }), + // bitwarden's shape: an ES module with an unexported helper above its + // default-exported class, loaded with `require(...).default` on one + // platform only. + 'src/biometrics/os-biometrics.service.ts': `export interface OsBiometricService { + enrollPersistent(userId: string): Promise; +} +`, + 'src/biometrics/os-biometrics-mac.service.ts': `import { OsBiometricService } from './os-biometrics.service'; + +function getLookupKeyForUser(userId: string): string { + return \`\${userId}_user_biometric\`; +} + +export default class OsBiometricsServiceMac implements OsBiometricService { + async enrollPersistent(userId: string): Promise { + return getLookupKeyForUser(userId); + } +} +`, + 'src/biometrics/main-biometrics.service.ts': `import { OsBiometricService } from './os-biometrics.service'; + +export class MainBiometricsService { + private osBiometricsService: OsBiometricService | undefined; + + constructor(platform: string) { + if (platform === 'darwin') { + // eslint-disable-next-line + const OsBiometricsServiceMac = require('./os-biometrics-mac.service').default; + this.osBiometricsService = new OsBiometricsServiceMac(); + } + } +} +`, + 'src/biometrics/os-biometrics-mac.service.spec.ts': `describe('OsBiometricsServiceMac', () => { + it('enrolls in an isolated module registry', async () => { + const { default: ServiceCtor } = require('./os-biometrics-mac.service'); + const isolated = new ServiceCtor(); + await isolated.enrollPersistent('user'); + }); +}); +`, + // A single-file component is its module's default export. + 'src/App.svelte': ` + +

Hello {name}!

+`, + 'src/main.js': `const App = require('./App.svelte').default; + +const app = new App({ target: document.body }); + +module.exports = app; +`, + // CommonJS modules that set the property by name. + 'lib/format.js': `'use strict'; + +exports.pad = function (value) { + return String(value).padStart(2, '0'); +}; + +function formatDate(date) { + return exports.pad(date.getDate()); +} + +exports.default = formatDate; +`, + 'lib/server.js': `'use strict'; + +function createServer(options) { + return { options }; +} + +module.exports = createServer; +module.exports.default = createServer; +module.exports.createServer = createServer; +`, + // A CommonJS module without the property: its exports are no default. + 'lib/plain.js': `'use strict'; + +exports.first = function () { + return 1; +}; + +exports.second = () => 2; +`, + // `export * from` forwards every export but the default. + 'src/widgets/Dial.ts': `export const DIAL_SIZE = 3; + +export default class Dial { + size = DIAL_SIZE; +} +`, + 'src/widgets/index.ts': `export * from './Dial'; +`, + 'src/dashboard.ts': `export function mountDashboard(): unknown { + const Widget = require('./widgets').default; + return new Widget(); +} +`, + 'lib/app.js': `'use strict'; + +const format = require('./format').default; +const build = require('./server').default; +const { default: fastify } = require('./server'); +const plain = require('./plain').default; + +function start() { + const server = build({ port: 3000 }); + fastify({ logger: true }); + plain(); + return format(new Date()) + server.options.port; +} + +module.exports = start; +`, + // Python has no default export: `default` is an attribute's name. + 'py/__init__.py': '', + 'py/mod.py': `def first(): + return 1 +`, + 'py/use.py': `from .mod import default + + +def run(): + return default() +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const node = (file: string, name: string) => { + const found = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import'); + expect(found, `${file}: ${name}`).toBeDefined(); + return found!; +}; + +/** The edges into `target`, as `kind source-file resolvedBy`. */ +const into = (target: { id: string }, kinds: Edge['kind'][]) => + cg + .getIncomingEdges(target.id) + .filter((e) => kinds.includes(e.kind)) + .map((e) => `${e.kind} ${cg.getNode(e.source)!.filePath} ${(e.metadata as { resolvedBy?: string }).resolvedBy}`) + .sort(); + +/** The call and instantiation edges out of a file's nodes, as `kind target-file:target-name`. */ +const outOf = (file: string) => + cg + .getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id)) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => { + const target = cg.getNode(e.target)!; + return `${e.kind} ${target.filePath}:${target.name}`; + }) + .sort(); + +describe('`require(…).default` of an ES module', () => { + it('is the module’s default export, as `.default` and as `{ default: X }`', () => { + expect(into(node('src/biometrics/os-biometrics-mac.service.ts', 'OsBiometricsServiceMac'), ['instantiates'])).toEqual([ + 'instantiates src/biometrics/main-biometrics.service.ts import', + 'instantiates src/biometrics/os-biometrics-mac.service.spec.ts import', + ]); + }); + + it('is the component a single-file component file is', () => { + expect(into(node('src/App.svelte', 'App'), ['instantiates'])).toEqual(['instantiates src/main.js import']); + }); +}); + +describe('`require(…).default` of a CommonJS module', () => { + it('is what `exports.default` names, not the module’s first export', () => { + expect(outOf('lib/app.js')).toContain('calls lib/format.js:formatDate'); + expect(into(node('lib/format.js', 'pad'), ['calls'])).toEqual([]); + }); + + it('is X in the dual export `module.exports = X; module.exports.default = X`', () => { + expect(into(node('lib/server.js', 'createServer'), ['calls'])).toEqual([ + 'calls lib/app.js import', + 'calls lib/app.js import', + ]); + }); + + it('is nothing when the module sets no `default`: its exports are no default', () => { + expect(into(node('lib/plain.js', 'first'), ['calls'])).toEqual([]); + expect(into(node('lib/plain.js', 'second'), ['calls'])).toEqual([]); + }); +}); + +describe('what the fallback leaves alone', () => { + it('a default behind `export * from`, which forwards none', () => { + expect(into(node('src/widgets/Dial.ts', 'Dial'), ['instantiates', 'calls'])).toEqual([]); + expect(outOf('src/dashboard.ts')).toEqual([]); + }); + + it('every other require binding, which reaches only what it names', () => { + expect(outOf('lib/app.js').filter((e) => !e.includes(' lib/app.js:'))).toEqual([ + 'calls lib/format.js:formatDate', + 'calls lib/server.js:createServer', + 'calls lib/server.js:createServer', + ]); + }); + + it('Python’s `from .mod import default`, an attribute of that name', () => { + expect(into(node('py/mod.py', 'first'), ['calls'])).toEqual([]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index e8c5e6a4eb..8ac0695cac 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -111,6 +111,8 @@ interface FileExportIndex { const DEFAULT_BINDING_KINDS = new Set(['function', 'class', 'component', 'constant', 'variable']); const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m; +/** Any `export default …` statement: the module is an ES module with a default export. */ +const ESM_DEFAULT_EXPORT_RE = /^[ \t]*export\s+default\b/m; const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/; /** The identifier `export default NAME` names in a JS-family file, or null. */ @@ -179,6 +181,28 @@ function defaultExportBindingNode(filePath: string, idx: FileExportIndex, contex return idx.defaultBinding ?? undefined; } +/** + * The declaration a module default-exports: the component a single-file + * component file is, else what an `export default NAME` statement names, else + * the first exported function or class (`export default class Foo`). + */ +function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined { + return idx.defaultComponent ?? defaultExportBindingNode(filePath, idx, context) ?? idx.defaultFnClass; +} + +/** + * Whether the module has an ESM default export at all: it is a single-file + * component, or a JS-family file with an `export default` statement. A + * CommonJS module has none, though its `exports.x = function` declarations are + * exported and would feed the first-exported-function guess. + */ +function hasEsmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): boolean { + if (idx.defaultComponent) return true; + if (!JS_FAMILY_FILE.test(filePath)) return false; + const source = context.readFile(filePath); + return !!source && source.includes('default') && ESM_DEFAULT_EXPORT_RE.test(source); +} + /** What this file exports as `name`: an exported declaration, else a local export clause's binding. */ function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined { const direct = idx.byName.get(name); @@ -2683,8 +2707,7 @@ function findExportedSymbolWalk( // resolves and the component shows a false 0 callers (#629). // A component file IS its default export; otherwise the statement that // names the binding beats the first-exported-function guess. - const direct = - exportIndex.defaultComponent ?? defaultExportBindingNode(filePath, exportIndex, context) ?? exportIndex.defaultFnClass; + const direct = esmDefaultExport(filePath, exportIndex, context); if (direct) return direct; // CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`. const commonJs = commonJsDefaultExport(filePath, context); @@ -2703,6 +2726,22 @@ function findExportedSymbolWalk( } else { const direct = exportedByName(filePath, exportIndex, want.exportedName, context); if (direct) return direct; + // `require('./x').default` and `const { default: X } = require('./x')` read + // the `default` property of module.exports. A CommonJS module sets it by + // name (`exports.default = fn`, the dual `module.exports.default = X`), + // found just above. A module written as an ES module sets it to its + // default export, `export default class Foo`, which is no named export — + // the answer when the module has no such property. Only in the module + // asked for: `export * from` never forwards a default. + if ( + depth === 0 && + want.exportedName === 'default' && + ESM_IMPORT_LANGUAGES.has(language) && + hasEsmDefaultExport(filePath, exportIndex, context) + ) { + const esmDefault = esmDefaultExport(filePath, exportIndex, context); + if (esmDefault) return esmDefault; + } } // 2. Re-export hit: the file forwards the symbol to another module. From 3de5bef0d2f3c32bfaa22e83b69f82bc0d6d09f7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:53:32 +0000 Subject: [PATCH 05/20] fix(go): a call through a type assertion reaches the asserted type's method (#2444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both extractors record `srv.(KVServer).Range(ctx, in)` as a bare `Range` call at the column where its receiver expression starts, as they record any call through an expression. Resolution matched the name alone, so every handler protoc-gen-go-grpc emits went to the `UnimplementedKVServer` stub beside the `KVServer` interface (72 edges in etcd), and calls like `c.Reader.(*pipe).Close()`, `p.(Pausable).Pause()` or `v.(featuregate.MutableFeatureGate).Set(…)` linked to a namesake or to nothing. The call's chain is now read back from its column (operand, selectors, assertions, calls and indexes, past strings, runes and comments, and on to the next line after a trailing `.`). When the link of the call's name follows an assertion, the call is a method of the asserted type, found where Go finds it: - a bare `T` or `*T` in the call's own package, or one it dot-imports; - a `pkg.T` in the imported project package; - its own method, the one its interface declares, or one promoted from a type it embeds, through the package-scoped lookup #2361 added; an alias is followed to the type it names (#2417). A type from outside the project (`http.Flusher`), a predeclared one (`error`), an alias of an outside type (`type Ctx = context.Context`, for which the package-scoped lookup would fall back to matching by name) or a type literal (`interface{ Flush() }`) links nothing. The check runs ahead of the framework, import and name strategies, and ahead of the built-in filter, which dropped a method named `close` or `copy` called through an assertion. Two calls of one name in a chain share the column (`b.(*Builder).Add(1).Add(2)`); both are taken for the one made through the assertion, whose edge is there either way. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-type-assertion-calls.test.ts | 404 ++++++++++++++++++++++ src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 157 +++++++++ 4 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 __tests__/go-type-assertion-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a57a9d9b..517805ff63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338) - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) +- In Go, a call made through a type assertion, like `srv.(KVServer).Range(ctx, in)` in generated gRPC code, `c.Reader.(*pipe).Close()` or `v.(storage.Store).Fetch(key)`, now links to that method of the asserted type, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, it linked to whichever method shared the name, so every gRPC handler pointed at the `Unimplemented…Server` stub instead of the server interface. Re-index Go projects after upgrading. - In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322) - In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading. - In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. Re-index Go projects after upgrading. diff --git a/__tests__/go-type-assertion-calls.test.ts b/__tests__/go-type-assertion-calls.test.ts new file mode 100644 index 0000000000..efd1da4143 --- /dev/null +++ b/__tests__/go-type-assertion-calls.test.ts @@ -0,0 +1,404 @@ +/** + * A Go call through a type assertion is a call of the asserted type's method: + * + * func _KV_Range_Handler(srv interface{}, …) (interface{}, error) { + * return srv.(KVServer).Range(ctx, in) // etcd's generated gRPC code + * } + * + * Both extractors record such a call by its bare member name (`Range`), at + * the column where its receiver expression starts, as any call through an + * expression. Resolution matched the name alone, so every handler etcd + * generates went to `UnimplementedKVServer`'s stub instead of the `KVServer` + * interface method. The asserted type is now read back from the source: its + * own method, the method its interface declares, or one embedding promotes + * into it — written bare (its own package's, or a dot import's), as a + * pointer, or through a project package. A type from outside the project + * (`http.Flusher`) or a type literal links nothing. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** etcd's generated server handlers, a pointer assertion, and a chain continued on the next line. */ +const HANDLERS = `package pb + +import "context" + +type KVServer interface { + Range(context.Context, *RangeRequest) (*RangeResponse, error) + Put(context.Context, *RangeRequest) (*RangeResponse, error) +} + +type UnimplementedKVServer struct{} + +func (UnimplementedKVServer) Range(context.Context, *RangeRequest) (*RangeResponse, error) { + return nil, nil +} + +func (UnimplementedKVServer) Put(context.Context, *RangeRequest) (*RangeResponse, error) { + return nil, nil +} + +type RangeRequest struct{} + +type RangeResponse struct{} + +func _KV_Range_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor func(context.Context, interface{}, func(context.Context, interface{}) (interface{}, error)) (interface{}, error)) (interface{}, error) { + in := new(RangeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KVServer).Range(ctx, in) + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KVServer).Range(ctx, req.(*RangeRequest)) + } + return interceptor(ctx, in, handler) +} + +func _KV_Put_Handler(srv interface{}, ctx context.Context, in *RangeRequest) (interface{}, error) { + return srv.(KVServer). + Put(ctx, in) +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('Go calls through a type assertion are recorded at their receiver', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['go']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource('pb/rpc_grpc.pb.go', source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract('pb/rpc_grpc.pb.go', source, 'go'); + expect(result, 'kernel extraction').not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const source = crlf ? HANDLERS.replace(/\n/g, '\r\n') : HANDLERS; + it.each(backends)(`by the bare member name, at the receiver's column: %s${crlf ? ' (CRLF)' : ''}`, (backend) => { + const lines = source.split(/\r?\n/); + const refs = extract(backend, source).unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && (r.referenceName.endsWith('Range') || r.referenceName.endsWith('Put'))) + .map((r) => `${r.referenceName} ${r.line}:${r.column}`) + .sort(); + // `line:column` of the receiver `srv` on the line `pick` finds. + const srvOn = (pick: (line: string) => boolean): string => { + const line = lines.findIndex(pick); + return `${line + 1}:${lines[line]!.indexOf('srv')}`; + }; + expect(refs).toEqual([ + `Put ${srvOn((l) => l.endsWith('srv.(KVServer).'))}`, + `Range ${srvOn((l) => l.includes('Range(ctx, in)'))}`, + `Range ${srvOn((l) => l.includes('Range(ctx, req.('))}`, + ].sort()); + }); + } +}); + +/** + * The module the resolution tests index. Namesakes sit where name matching + * looked first: a stub beside each interface, a same-file type with each + * method name, and an `alpha` package that sorts ahead of `storage`. + */ +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'pb/rpc_grpc.pb.go': HANDLERS.replace(/\n/g, '\r\n'), + 'internal/bufconn/bufconn.go': `package bufconn + +import "io" + +type pipe struct{} + +func (p *pipe) Close() error { return nil } + +func (p *pipe) closeWrite() error { return nil } + +func (p *pipe) close() {} + +type conn struct { + io.Reader + io.Writer +} + +func (c *conn) Close() error { + err1 := c.Reader.(*pipe).Close() + err2 := c.Writer.(*pipe).closeWrite() + c.Reader.(*pipe).close() + if err1 != nil { + return err1 + } + return err2 +} +`, + 'alpha/alpha.go': `package alpha + +type Store interface { + Fetch(key string) string +} + +type sink struct{} + +func (s *sink) Fetch(key string) string { return "" } + +func (s *sink) Flush() error { return nil } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +type Ctx struct{} + +func (c *Ctx) Done() <-chan struct{} { return nil } +`, + 'storage/storage.go': `package storage + +type Store interface { + Fetch(key string) string +} + +type Base struct{} + +func (b *Base) Flush() error { return nil } +`, + 'store/store.go': `package store + +import ( + "context" + + "example.com/app/storage" +) + +type Reader interface { + Read(p []byte) (int, error) +} + +type ReadCloser interface { + Reader + Close() error +} + +type Wrapper struct { + *storage.Base + name string +} + +type Job struct{} + +func (j *Job) Start() {} + +type Factory interface { + New() *Job +} + +type Builder struct{} + +func (b *Builder) Add(n int) *Builder { return b } + +type Ctx = context.Context + +type Keeper = storage.Store +`, + 'store/use.go': `package store + +import ( + "net/http" + + "example.com/app/storage" +) + +type sink struct{} + +func (s *sink) Flush() error { return nil } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +func (s *sink) Fetch(key string) string { return "" } + +func Use(v any, w http.ResponseWriter, p []byte) string { + v.(*Wrapper).Flush() + v.(ReadCloser).Read(p) + w.(http.Flusher).Flush() + return v.(storage.Store).Fetch("k") +} + +func Anon(v any) string { + return v.(interface{ Fetch(key string) string }).Fetch("k") +} + +func Run(v any) { + v.(Factory).New().Start() +} + +func lookup(key string) any { return nil } + +func Quoted() string { + return lookup(")").(storage.Store).Fetch("k") +} + +func (s *sink) Add(n int) *sink { return s } + +func Build(v any) { + v.(*Builder).Add(1).Add(2) +} + +func Wait(v any) { + v.(Ctx).Done() +} + +func Keep(v any) string { + return v.(Keeper).Fetch("k") +} +`, + 'dot/dot.go': `package dot + +import . "example.com/app/storage" + +func Get(v any) string { + return v.(Store).Fetch("k") +} +`, +}; + +describe.each(['default', 'wasm'])('Go calls through a type assertion resolve on the asserted type (%s)', (backend) => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeAll(async () => { + kernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-assert-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + /** ` ::` for every call the function or method `qualifiedName` in `file` makes. */ + function calls(file: string, qualifiedName: string): string[] { + const fn = cg!.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName && (n.kind === 'function' || n.kind === 'method')); + expect(fn, `${qualifiedName} in ${file}`).toBeDefined(); + return cg! + .getOutgoingEdges(fn!.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${e.line} ${cg!.getNode(e.target)!.filePath}::${cg!.getNode(e.target)!.qualifiedName}`) + .sort(); + } + + it("links etcd's gRPC handlers to the server interface's method, not the stub", () => { + expect(calls('pb/rpc_grpc.pb.go', '_KV_Range_Handler')).toEqual([ + '30 pb/rpc_grpc.pb.go::KVServer::Range', + '33 pb/rpc_grpc.pb.go::KVServer::Range', + ]); + // A chain continued on the next line. + expect(calls('pb/rpc_grpc.pb.go', '_KV_Put_Handler')).toEqual(['39 pb/rpc_grpc.pb.go::KVServer::Put']); + }); + + it("links a pointer assertion to the type's own method, one named like a builtin too", () => { + expect(calls('internal/bufconn/bufconn.go', 'conn::Close')).toEqual([ + '19 internal/bufconn/bufconn.go::pipe::Close', + '20 internal/bufconn/bufconn.go::pipe::closeWrite', + '21 internal/bufconn/bufconn.go::pipe::close', + ]); + }); + + it('follows embedding, a package qualifier and a dot import to the method', () => { + // *Wrapper's Flush is promoted from *storage.Base; ReadCloser's Read from Reader. + expect(calls('store/use.go', 'Use')).toEqual([ + '18 storage/storage.go::Base::Flush', + '19 store/store.go::Reader::Read', + '21 storage/storage.go::Store::Fetch', + ]); + expect(calls('dot/dot.go', 'Get')).toEqual(['6 storage/storage.go::Store::Fetch']); + // An alias is the type it names. + expect(calls('store/use.go', 'Keep')).toEqual(['49 storage/storage.go::Store::Fetch']); + // A string argument holding a parenthesis is not the end of the call. + expect(calls('store/use.go', 'Quoted')).toEqual([ + '35 storage/storage.go::Store::Fetch', + '35 store/use.go::lookup', + ]); + }); + + it('links nothing for a type from outside the project or a type literal', () => { + // http.Flusher's Flush, on line 20 of Use, has no edge (see above). + expect(calls('store/use.go', 'Anon')).toEqual([]); + // Ctx is an alias of context.Context. + expect(calls('store/use.go', 'Wait')).toEqual([]); + const flush = cg!.getNodesInFile('store/use.go').find((n) => n.qualifiedName === 'sink::Flush')!; + expect(cg!.getIncomingEdges(flush.id).filter((e) => e.kind === 'calls')).toEqual([]); + }); + + it("leaves a later link of the chain to the type the call before it returns", () => { + expect(calls('store/use.go', 'Run')).toEqual([ + '29 store/store.go::Factory::New', + '29 store/store.go::Job::Start', + ]); + }); + + it('takes two calls of one name in a chain for the one made through the assertion', () => { + // Both `Add` calls start at `v`, so nothing tells them apart. + expect([...new Set(calls('store/use.go', 'Build'))]).toEqual(['41 store/store.go::Builder::Add']); + }); + + it('never links a namesake in another package or a same-file type', () => { + const decoys = [ + ...cg!.getNodesInFile('alpha/alpha.go'), + ...cg!.getNodesInFile('store/use.go').filter((n) => n.qualifiedName.startsWith('sink::')), + ...cg!.getNodesInFile('pb/rpc_grpc.pb.go').filter((n) => n.qualifiedName.startsWith('UnimplementedKVServer::')), + ].filter((n) => n.kind === 'method'); + expect(decoys.length).toBe(11); + for (const decoy of decoys) { + expect(cg!.getIncomingEdges(decoy.id).filter((e) => e.kind === 'calls' && e.provenance !== 'heuristic'), decoy.qualifiedName).toEqual([]); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 68665c1270..333c1086a6 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1233,6 +1233,11 @@ export class ReferenceResolver { // A Dart annotation (`@riverpod`, `@Riverpod(…)`) is a constant or a // constructor call, as written — never a method or function by its name. if (isDartAnnotation(ref)) return matchDartAnnotation(ref, this.context); + // A Go call through a type assertion (`srv.(KVServer).Range(ctx, in)`), + // which arrives by its bare name, is to a method of the asserted type, or + // to nothing the project declares. + const asserted = matchGoAssertedCall(ref, this.context); + if (asserted !== undefined) return asserted; // A section or snippet a Shopify theme's file names is that theme's own, // or nothing: Shopify never looks in another theme (see ./shopify-themes). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 84b6fb457e..6884777790 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9189,6 +9189,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { GO_QUALIFIERS.delete(context); GO_EMBEDS.delete(context); GO_ALIAS_TARGETS.delete(context); + GO_DOT_IMPORTS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -11014,6 +11015,162 @@ function goEmbeddedTypes(typeNode: Node, context: ResolutionContext): Array<{ na return embedded; } +/** + * A Go call made through a type assertion — `srv.(KVServer).Range(ctx, in)`, + * `c.Reader.(*pipe).Close()`, `v.(storage.Store).Fetch(k)` — calls a method of + * the asserted type: its own, the one its interface declares, or one embedding + * promotes into it. The call reaches the resolver by its bare name, at the + * column where its receiver expression starts, and name matching took any + * method of that name: every gRPC handler etcd generates went to the + * `UnimplementedKVServer` stub beside the `KVServer` interface. The type is + * the one Go finds: in the call's own package or a package it dot-imports for + * a bare name, in the imported package for a qualified one, and an alias is + * the type it names. A type found in none — one from outside the project + * (`http.Flusher`), a predeclared one (`error`) — an alias of such a type, or + * a type literal (`interface{ Flush() }`) links nothing. + * Undefined when the call is not made through an assertion. + */ +export function matchGoAssertedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'go' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return undefined; + const asserted = goAssertedType(ref, context); + if (asserted === undefined) return undefined; + // `KVServer`, `*pipe`, `storage.Store`, `*List[int]`: anything else is a type literal. + const type = /^\*?\s*(?:([A-Za-z_]\w*)\s*\.\s*)?([A-Za-z_]\w*)\s*(?:\[[\s\S]*\])?$/.exec(asserted); + if (!type) return null; + const [, qualifier, typeName] = type; + const declares = (dir: string | null | undefined): dir is string => !!dir && goPackageTypes(typeName!, dir, context).length > 0; + const dir = qualifier !== undefined + ? goImportPackageDir(qualifier, ref.filePath, context) + : [goPackageDir(ref.filePath), ...goDotImportDirs(ref.filePath, context)].find(declares); + if (!declares(dir)) return null; + // An alias of a type from outside the project (`type Ctx = context.Context`) + // has that type's methods, none the project declares. + if (goPackageTypes(typeName!, dir, context).every((t) => goAliasTarget(t, context) === null)) return null; + return resolveMethodOnType(typeName!, ref.referenceName, ref, context, 0.9, 'instance-method', dir); +} + +/** How many lines past a Go call's first its chain is read on. */ +const GO_CHAIN_LINES = 30; + +/** + * The type a Go call's receiver is asserted to, as written (`KVServer`, + * `*pipe`, `storage.Store`). Every call of a chain records the chain's start, + * so the call is a `.name(…)` link of the chain read from there, and the link + * before it is the assertion. Two links of one name in a chain + * (`b.(*Builder).Add(1).Add(2)`) are told apart by nothing, and both are taken + * for the one made through the assertion: that call is there either way. + * Undefined when no link of the name follows an assertion, or no chain + * starts at the column. + */ +function goAssertedType(ref: UnresolvedRef, context: ResolutionContext): string | undefined { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const line = lines?.[ref.line - 1]; + if (!lines || line === undefined || ref.column >= line.length) return undefined; + // Most calls through an expression assert nothing: no `.(` after the + // column, nor a chain that goes on past the line. + if (line.indexOf('.(', ref.column) < 0 && !/\.\s*$/.test(line)) return undefined; + const links = goChainLinks(lines.slice(ref.line - 1, ref.line + GO_CHAIN_LINES).join('\n'), ref.column); + for (let k = 1; k + 1 < links.length; k++) { + if (links[k - 1]!.kind === 'assert' && links[k]!.kind === 'select' && links[k]!.text === ref.referenceName && + links[k + 1]!.kind === 'call') return links[k - 1]!.text; + } + return undefined; +} + +interface GoChainLink { + kind: 'operand' | 'select' | 'assert' | 'call' | 'index'; + /** An operand's or selector's name, an assertion's type. */ + text: string; +} + +/** + * The links of the Go expression that starts at `start`: its operand (a name + * or a parenthesized expression), then each selector, type assertion, call + * and index. A line break ends it, except right after a `.`, where Go + * inserts no semicolon. + */ +function goChainLinks(text: string, start: number): GoChainLink[] { + const links: GoChainLink[] = []; + const NAME = /[A-Za-z_]\w*/y; + let i = start; + const name = (): string | null => { + NAME.lastIndex = i; + const m = NAME.exec(text); + if (m) i += m[0].length; + return m ? m[0] : null; + }; + const group = (): string | null => { + const close = goClosingBracket(text, i); + if (close < 0) return null; + const inner = text.slice(i + 1, close); + i = close + 1; + return inner; + }; + const operand = text[i] === '(' ? group() : name(); + if (operand === null) return links; + links.push({ kind: 'operand', text: operand }); + for (;;) { + while (text[i] === ' ' || text[i] === '\t') i++; + if (text[i] === '.') { + i++; + while (i < text.length && WHITESPACE.test(text[i]!)) i++; + const type = text[i] === '(' ? group() : null; + const member = type === null ? name() : null; + if (type !== null) links.push({ kind: 'assert', text: type.trim() }); + else if (member !== null) links.push({ kind: 'select', text: member }); + else break; + } else if (text[i] === '(' || text[i] === '[') { + // A call whose arguments run past what was read is still a call. + links.push({ kind: text[i] === '(' ? 'call' : 'index', text: '' }); + if (group() === null) break; + } else break; + } + return links; +} + +/** The bracket closing the one at `open`, past Go strings, runes and comments; -1 when it does not close. */ +function goClosingBracket(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if (c === '"' || c === '\'') { + for (i++; i < text.length && text[i] !== c && text[i] !== '\n'; i++) if (text[i] === '\\') i++; + } else if (c === '`') { + i = text.indexOf('`', i + 1); + if (i < 0) return -1; + } else if (c === '/' && text[i + 1] === '/') { + i = text.indexOf('\n', i); + if (i < 0) return -1; + } else if (c === '/' && text[i + 1] === '*') { + i = text.indexOf('*/', i + 2); + if (i < 0) return -1; + i++; + } else if (c === '(' || c === '[' || c === '{') depth++; + else if ((c === ')' || c === ']' || c === '}') && --depth === 0) return i; + } + return -1; +} + +const GO_DOT_IMPORTS = new WeakMap>(); + +/** The project packages a Go file dot-imports (`import . "example.com/app/storage"`), by directory: their names it reads bare. */ +function goDotImportDirs(filePath: string, context: ResolutionContext): string[] { + let memo = GO_DOT_IMPORTS.get(context); + if (!memo) GO_DOT_IMPORTS.set(context, (memo = new Map())); + let dirs = memo.get(filePath); + if (dirs) return dirs; + dirs = []; + for (const line of context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []) { + // The imports come before any other declaration. + if (/^(?:func|type|var|const)\b/.test(line)) break; + const dot = /^\s*(?:import\s+)?\.\s+"([^"]+)"/.exec(line); + const dir = dot ? context.getGoPackageDir?.(dot[1]!, filePath) : null; + if (dir) dirs.push(dir); + } + memo.set(filePath, dirs); + return dirs; +} + /** Go builtin/primitive field types that can never carry a project method. */ const GO_BUILTIN_FIELD_TYPES = new Set([ 'string', 'bool', 'byte', 'rune', 'error', 'any', From 1fb8691dfcb1551d59b88913aa41a1f41ff3fd27 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 14:57:38 +0000 Subject: [PATCH 06/20] fix(c,cpp): a file named like a Google unittest, foo_unittest.cc, is a test (#2434) Google names C++ tests `foo_unittest.cc` (protobuf, Breakpad, glog, Chromium), not `foo_test.cc`. The test-file check wanted a separator right before `test`, so each one read as production code: search and explore ranked it alongside the code it tests, `codegraph affected` never listed it, and the resolver's test rules ran backwards for it. Production calls landed on a unittest's own declarations (`stack_frame_entries_.size()` on a unittest's `StackHelper`, a template's `AddressType()` on a unittest's typedef), and a unittest's calls into test suites outside what it includes (a Python `*_unittest.py` using a `tests/` fake) were treated as production calls. #2421 already links a C/C++ unittest to the test helpers it includes. `unittest` and `unittests` join the separator-delimited test suffixes (`foo_unittest.cc`, `foo-unittest.cpp`, `foo.unittest.js`), and a file named so is a test suite to the resolver, as `foo_test.cc` is. A bare `unittest.go` stays production code: promtool's runs rule tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cli-affected-test-conventions.test.ts | 9 +- __tests__/is-test-file.test.ts | 26 ++- __tests__/unittest-named-files.test.ts | 150 ++++++++++++++++++ src/resolution/name-matcher.ts | 9 +- src/search/query-utils.ts | 6 +- 6 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 __tests__/unittest-named-files.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 517805ff63..fde26e979e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In a repository that holds more than one Shopify theme, a theme's `{% render %}`, `{% include %}` and `{% section %}` tags and the sections its JSON templates list now link only to that theme's own snippets and sections, which is where Shopify looks for them. Before, a section or snippet the theme doesn't have was linked to another theme's file of the same name, and a theme kept in a subfolder could link to the root theme's copy instead of its own. A reference the theme can't satisfy now links to nothing. Projects with a single theme are indexed as before. Re-index projects with several Shopify themes after upgrading. - `codegraph sync` and the file watcher now link a Shopify section or snippet to the files that name it when it is added after them, or comes back after being deleted, whether they name it in a `{% render %}`, `{% include %}` or `{% section %}` tag or as a section `type` in a JSON template. Before, the link appeared only once a file naming it changed or the project was re-indexed, even in a repository with a single theme, so the new section or snippet looked unused and `codegraph affected` missed the files that use it. Re-index Shopify themes after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now link a route to the page or layout it loads lazily when that file is added after the router was indexed, comes back after being deleted, or gets the component the route renders in a later edit, whether or not the import names the file's extension: React Router's `lazy: () => import('./pages/Team')`, Vue Router's `component: () => import('@/views/Login')` and Angular's `loadComponent: () => import('./home/home.component')`. Before, the route stayed unlinked until the router file changed or the project was re-indexed, so the page looked unused and `codegraph affected` missed the tests that reach it through the router. Re-index after upgrading to pick up links an earlier version missed. +- A file named the way Google names its tests, like `wire_format_unittest.cc` in protocolbuffers/protobuf, `logging_unittest.cc` in google/glog or a Python `foo_unittest.py`, now counts as a test, as `wire_format_test.cc` already did. Before, it was taken for production code: search and `codegraph_explore` ranked it alongside the code it tests, `codegraph affected` never listed it, and calls in production code could link to a class or type a unittest declares for itself, as google/breakpad's `stack_frame_entries_.size()` did to a unittest's `StackHelper`. Re-index projects that have such files after upgrading. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - In C++, a call on a variable, parameter or member declared through a `typedef` or `using` alias now links to the method of the type the alias names, with the alias looked up the way C++ does it: in the calling function, its class and the classes that class inherits from, then the namespaces around it. In google/leveldb, `table_.Insert(buf)` on a `Table table_;` declared next to `typedef SkipList Table;` now reaches `SkipList::Insert` instead of the unrelated `HandleTable::Insert`, and `Table::Iterator iter(&table_);` reaches `SkipList::Iterator`'s constructor. A class that only shares the alias's name is no longer taken for it, and when the aliased type has no such method, as with an alias of `std::vector` or of a template parameter, no method is guessed from the receiver's name. Re-index C++ projects after upgrading. - In C++, a base class like `Message` in `class DynamicMessage final : public Message` now links to the class C++ itself would pick: the one declared in the namespaces around the class, or brought in by the file's `using` declarations and namespace aliases, and the class a `typedef` or `using` alias names. Before, a base class was matched by its name alone, so it could land on a class of the same name in another namespace or another language: in protocolbuffers/protobuf, `DynamicMessage` derived from the JSON parser's internal `Message` class, and in google/leveldb and RocksDB, iterators derived from an unrelated nested `Iterator` class such as the skip list's. The wrong base showed up in `codegraph_explore`'s type hierarchy and in impact, and tied the base's methods to the wrong overrides. A template parameter used as a base no longer links to a class that shares its name, and a base written from the global scope, like `public ::testing::Test`, now links to its class. Re-index C++ projects after upgrading. diff --git a/__tests__/cli-affected-test-conventions.test.ts b/__tests__/cli-affected-test-conventions.test.ts index 1887e934f6..7418fde255 100644 --- a/__tests__/cli-affected-test-conventions.test.ts +++ b/__tests__/cli-affected-test-conventions.test.ts @@ -41,10 +41,13 @@ describe('codegraph affected — test-file conventions (#1507)', () => { w('pkg/test_calc.py', 'from pkg.calc import add\n\ndef test_add():\n assert add(1, 2) == 3\n'); w('src/main/kotlin/app/Calc.kt', 'package app\n\nclass Calc {\n fun add(a: Int, b: Int): Int = a + b\n}\n'); w('src/test/kotlin/app/CalcTest.kt', 'package app\n\nclass CalcTest {\n fun addsNumbers() { Calc().add(1, 2) }\n}\n'); + w('src/wire_format.h', '#pragma once\n\nint ByteSize(int value);\n'); + w('src/wire_format.cc', '#include "wire_format.h"\n\nint ByteSize(int value) { return value; }\n'); + w('src/wire_format_unittest.cc', '#include "wire_format.h"\n\nvoid ComputesByteSize() { ByteSize(1); }\n'); const cg = CodeGraph.initSync(dir); await cg.indexAll(); cg.close(); - }); + }, 60_000); afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); @@ -59,6 +62,10 @@ describe('codegraph affected — test-file conventions (#1507)', () => { expect(affected(dir, ['src/main/kotlin/app/Calc.kt'])).toEqual(['src/test/kotlin/app/CalcTest.kt']); }); + it("reports Google's C++ _unittest.cc beside the code it tests", () => { + expect(affected(dir, ['src/wire_format.h', 'src/wire_format.cc'])).toEqual(['src/wire_format_unittest.cc']); + }); + it('still honours an explicit --filter glob', () => { expect(affected(dir, ['math.go', '--filter', '*_test.go'])).toEqual(['math_test.go']); expect(affected(dir, ['math.go', '--filter', '*.spec.ts'])).toEqual([]); diff --git a/__tests__/is-test-file.test.ts b/__tests__/is-test-file.test.ts index a24e48962a..08b67e7d48 100644 --- a/__tests__/is-test-file.test.ts +++ b/__tests__/is-test-file.test.ts @@ -10,7 +10,7 @@ * `manifest.kt` / a `RealCall.kt` production file must NOT be flagged. */ import { describe, it, expect } from 'vitest'; -import { isTestFile } from '../src/search/query-utils'; +import { isTestFile, isTestPath } from '../src/search/query-utils'; describe('isTestFile', () => { it('flags test-support modules and doubles by directory name', () => { @@ -56,6 +56,30 @@ describe('isTestFile', () => { expect(isTestFile('project/tests/foo.rb')).toBe(true); }); + it("flags Google's unittest-named files as tests", () => { + // protobuf's 62 and Chromium's: `wire_format_unittest.cc` is a suite, not + // the code it tests, even with no `test/` directory above it. + for (const suite of [ + 'src/google/protobuf/wire_format_unittest.cc', + 'src/google/protobuf/wire_format_unittest.h', + 'src/compiler/register-allocator-unittest.cpp', + 'lib/parser.unittest.js', + 'tools/run_all_unittests.cc', + 'build/android/pylib/device_unittest.py', + ]) { + expect(isTestPath(suite), suite).toBe(true); + expect(isTestFile(suite), suite).toBe(true); + } + }); + + it('does NOT flag a file merely named unittest', () => { + // promtool's `unittest.go` is the code that runs rule tests, and + // CPython's `unittest` package is the framework itself. + expect(isTestPath('cmd/promtool/unittest.go')).toBe(false); + expect(isTestPath('Lib/unittest/case.py')).toBe(false); + expect(isTestFile('cmd/promtool/unittest.go')).toBe(false); + }); + it('does NOT flag production files that merely contain "test" lowercase', () => { // The fix is capital-led so camelCase boundaries distinguish these. expect(isTestFile('src/latest/loader.kt')).toBe(false); diff --git a/__tests__/unittest-named-files.test.ts b/__tests__/unittest-named-files.test.ts new file mode 100644 index 0000000000..69baff7d28 --- /dev/null +++ b/__tests__/unittest-named-files.test.ts @@ -0,0 +1,150 @@ +/** + * Google names its tests `foo_unittest.cc` (protobuf, Breakpad, glog, + * Chromium) and Chromium names its Python tests `foo_unittest.py`, where + * every other convention puts a separator right before `test`. The test-file + * check wanted that separator, so each of them read as production code and the + * resolver's test rules ran backwards for it. A unittest could not reach the + * test helpers it uses (`device.reboot()` on a fake from `tests/`), while + * production code resolved into a unittest's own declarations: Breakpad's + * `stack_frame_entries_.size()` went to a unittest's `StackHelper::size`, and + * a template's `AddressType()` to the `typedef` one unittest declares. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-unittest-named-')); + const files: Record = { + // A test helper in a test suite, and a unittest that uses it. + 'tests/__init__.py': '', + 'tests/fake_device.py': `class FakeDevice: + def reboot(self): + return True +`, + 'tools/device/device_unittest.py': `from tests.fake_device import FakeDevice + + +def test_reboot(): + device = FakeDevice() + return device.reboot() +`, + // The same in C++: protobuf's TestUtil, reached through the header. + 'src/message.h': `#ifndef MESSAGE_H_ +#define MESSAGE_H_ + +namespace protobuf { + +class Message { + public: + void Clear(); +}; + +} // namespace protobuf + +#endif +`, + 'src/message.cc': `#include "message.h" + +namespace protobuf { + +void Message::Clear() {} + +} // namespace protobuf +`, + 'src/test_util.h': `#ifndef TEST_UTIL_H_ +#define TEST_UTIL_H_ + +#include "message.h" + +namespace protobuf { +namespace TestUtil { + +inline void SetAllFields(Message* message) { message->Clear(); } + +} // namespace TestUtil +} // namespace protobuf + +#endif +`, + 'src/wire_format_unittest.cc': `#include "message.h" +#include "test_util.h" + +namespace protobuf { + +void ParsesAllFields() { + Message message; + TestUtil::SetAllFields(&message); +} + +} // namespace protobuf +`, + // Production code whose calls name nothing in the project. + 'src/module.cc': `#include + +using std::vector; + +class Module { + public: + int CountEntries() const { return static_cast(stack_frame_entries_.size()); } + + private: + vector stack_frame_entries_; +}; +`, + 'src/range_map-inl.h': `template +bool StoreRange(const AddressType& base) { + AddressType high = AddressType(); + return base < high; +} +`, + // A unittest's own declarations, named like what production code calls. + 'src/ptrace_dumper_unittest.cc': `class StackHelper { + public: + unsigned size() const { return 0; } +}; + +unsigned Depth() { + StackHelper helper; + return helper.size(); +} +`, + 'src/address_map_unittest.cc': `typedef int AddressType; + +AddressType Lookup() { return 0; } +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const edgesFrom = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!); +}; + +describe("Google's unittest files are tests", () => { + it('a unittest reaches the test helpers it uses', () => { + expect(edgesFrom('tools/device/device_unittest.py').map((n) => n.qualifiedName)).toContain('FakeDevice::reboot'); + expect(edgesFrom('src/wire_format_unittest.cc').map((n) => n.qualifiedName)).toContain('protobuf::TestUtil::SetAllFields'); + }); + + it("production code never resolves into a unittest's own declarations", () => { + for (const file of ['src/module.cc', 'src/range_map-inl.h']) { + expect(edgesFrom(file).map((n) => n.filePath).filter((f) => f.endsWith('_unittest.cc')), file).toEqual([]); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6884777790..46dd84752d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1549,9 +1549,10 @@ const JVM_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum /** * A test suite — a test source set, a `tests/` / `__tests__/` / `spec/` - * directory, a `FooTest.kt` / `test_foo.py` / `foo.test.ts` file — as opposed - * to test-support code a project ships (`testing/`, `fakes/`, a `*-test` - * module like kotlinx-coroutines-test), which its own code may use. + * directory, a `FooTest.kt` / `test_foo.py` / `foo.test.ts` / `foo_unittest.cc` + * file — as opposed to test-support code a project ships (`testing/`, + * `fakes/`, a `*-test` module like kotlinx-coroutines-test), which its own + * code may use. */ function isTestSuitePath(filePath: string): boolean { if (!isTestPath(filePath)) return false; @@ -1559,7 +1560,7 @@ function isTestSuitePath(filePath: string): boolean { const name = lower.slice(lower.lastIndexOf('/') + 1); const original = filePath.slice(filePath.lastIndexOf('/') + 1); // (`…Spec.java` alone is no test: halo's `IndexSpecs`, okhttp's `ConnectionSpec`.) - if (name.startsWith('test_') || /[._-](?:test|tests)\.[a-z0-9]+$|[._](?:spec|specs)\.[a-z0-9]+$/.test(name) || + if (name.startsWith('test_') || /[._-](?:test|tests|unittest|unittests)\.[a-z0-9]+$|[._](?:spec|specs)\.[a-z0-9]+$/.test(name) || // CamelCase suffixes where the language names tests so: not `useTests.ts`, a React hook. /(?:Test|Tests|TestCase)\.(?:java|kt|kts|swift|cs|scala|groovy|m|mm|vb|fs)$/.test(original) || name === 'conftest.py') return true; return /(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//.test(lower) || /(?:^|\/)[A-Za-z0-9]*(?:Test|Tests|Spec)\//.test(filePath); diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 03817b8c5f..e51cbe5d46 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -317,8 +317,10 @@ export function isTestPath(filePath: string): boolean { if ( lowerName.startsWith('test_') || // python: test_foo.py lowerName.startsWith('test.') || - // separator-delimited: foo_test.go, foo.test.ts, foo-spec.rb, bar_spec.py - /[._-](test|tests|spec|specs)\.[a-z0-9]+$/.test(lowerName) || + // separator-delimited: foo_test.go, foo.test.ts, foo-spec.rb, bar_spec.py, + // and Google's C++ foo_unittest.cc (protobuf, Chromium). A bare + // `unittest.go` is no test: promtool's is the code that runs rule tests. + /[._-](test|tests|unittest|unittests|spec|specs)\.[a-z0-9]+$/.test(lowerName) || // CamelCase suffix (Java/Kotlin/Swift/C#/Scala): FooTest.kt, BarTests.swift, // BazSpec.scala, QuxTestCase.java. Capital-led so "latest.kt"/"manifest.kt" // (lowercase "test") are NOT matched. From c051ea0a0e6e9660e4568b4613fd885e997a9840 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 15:03:52 +0000 Subject: [PATCH 07/20] fix(go): a dotted call is written through its own receiver, and a local named like an import is that variable (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goRefQualification read a dotted Go call's package qualifier from its line, as the line's only `X.Name` spelling. A second spelling of the name left it with none (`Digest:` beside `digest.Digest(dig)`, `"Log using Infof"`), and another selector before it was taken instead (`klog` for `err.Error` in `klog.Error(err.Error())`). It now takes the reference name's first segment, the only one that can be a package. A call through a parameter or local that takes an import's name (etcd's `jwt, err := newTokenProviderJWT(…)`, testify suites' `suite` receivers) is a call on that variable. A per-file scope reader finds the parameters, receivers, results, `:=`, `var` and `const` names in scope at the call, so such a call is no longer one into the package. The variable's declared type, when its declaration writes one, decides what the call reaches: - An outside package's type, or a value an outside package's function hands out (`clock := clocktesting.NewFakePassiveClock(…)`), has none of the project's methods. - A project package's type is resolved as the receiver's type. This also covers parameters declared through a package (`logger logger.Interface`). - A field read through the local (`metadata.Type.String()`) keeps its own type. A sync drops a file's scope reading with the other per-file memos. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-ref-qualifier.test.ts | 415 ++++++++++++++++++++++++ src/resolution/import-resolver.ts | 3 + src/resolution/name-matcher.ts | 490 ++++++++++++++++++++++++++++- 4 files changed, 892 insertions(+), 17 deletions(-) create mode 100644 __tests__/go-ref-qualifier.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fde26e979e..e893dd18b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) +- In Go, a call is now matched to the package or variable it is actually made on. Before, a call through a package, like `klog.Infof("Log using Infof")` or `digest.Digest(dig)` inside `&distribution.Descriptor{Digest: …}`, could link to a project method of the same name when the name appeared again on the line, and in `klog.Error(err.Error())` the `err.Error()` call was taken for a call into `klog`. A parameter, receiver or local variable named like an import, like etcd's `jwt, err := newTokenProviderJWT(…)` or the `suite` receiver in a testify test suite, now counts as that variable, so calls on it link to the methods of the type it is declared as, and to nothing when that type comes from outside your project. Re-index Go projects after upgrading. - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. diff --git a/__tests__/go-ref-qualifier.test.ts b/__tests__/go-ref-qualifier.test.ts new file mode 100644 index 0000000000..cad483034a --- /dev/null +++ b/__tests__/go-ref-qualifier.test.ts @@ -0,0 +1,415 @@ +/** + * A Go name written through an outside package is none of the project's + * symbols (#2322), so which package a call is written through decides what + * it may reach. Two misreadings of that qualifier, both seen on real trees: + * + * - A dotted call reference (`klog.Infof`) is recorded at its receiver, and + * the reader looked for the line's only `X.Infof` instead of taking the + * receiver the reference names. A second spelling of the name on the line + * left it with no qualifier — `Digest:` beside `digest.Digest(dig)` + * (harbor), `"Log using Infof"` (kubernetes) — so the call kept a project + * namesake's method, and another selector before it made `perr.Error` in + * `klog.Error(perr.Error())` a call through `klog`. Only the first segment + * can be a package: `s.cache.Get` is no call through a `cache` import. + * - A parameter or local can take an import's name: etcd's + * `jwt, err := newTokenProviderJWT(…)` beside `import ".../jwt/v5"`. A + * call through it is a method call on the variable, which the + * outside-package rule rejected along with the package's own calls. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + // Project methods named like the outside packages' functions. + 'logging/wrapper.go': `package logging + +// An adapter with klog's method names, like kubernetes' etcd3 logger. +type klogWrapper struct{} + +func (klogWrapper) Infof(format string, args ...interface{}) {} +func (klogWrapper) Errorf(format string, args ...interface{}) {} +`, + 'registry/suite.go': `package registry + +type Suite struct{} + +func (s *Suite) Digest(v string) string { return v } +`, + 'diff/differ.go': `package diff + +type Differ struct{} + +func (d *Differ) Diff(a, b string) string { return a + b } +`, + 'example/example.go': `package example + +import ( + "errors" + "testing" + + "github.com/distribution/distribution/v3" + "github.com/google/go-cmp/cmp" + "github.com/opencontainers/go-digest" + "github.com/patrickmn/go-cache" + "k8s.io/klog/v2" +) + +type parseError struct{} + +func (e *parseError) Error() string { return "parse" } + +type lru struct{} + +func (l *lru) Get(key string) string { return key } + +type Server struct { + cache *lru +} + +func (s *Server) Lookup(key string) string { + return s.cache.Get(key) +} + +func NewServer() *Server { + _ = cache.New(0, 0) + return &Server{cache: &lru{}} +} + +func Run(dig string) { + klog.Infof("Log using Infof, key: %s", "value") + err := errors.New("fail") + klog.Errorf("Log using Errorf, err: %v", err) + desc := &distribution.Descriptor{Digest: digest.Digest(dig)} + _ = desc + perr := &parseError{} + klog.Error(perr.Error()) +} + +func TestParser(t *testing.T) { + t.Errorf("Unexpected policy! Diff:\\n%s", cmp.Diff("a", "b")) +} +`, + 'auth/jwt.go': `package auth + +import "github.com/golang-jwt/jwt/v5" + +type tokenJWT struct{ key string } + +func newTokenProviderJWT(key string) (*tokenJWT, error) { return &tokenJWT{key: key}, nil } + +func (t *tokenJWT) assign(user string) (string, error) { return user, nil } + +func (t *tokenJWT) info(token string) (string, bool) { return token, true } + +// Named like the package's Parse. +type jwtOptions struct{} + +func (o *jwtOptions) Parse(token string) error { return nil } + +func parse(token string) { + _, _ = jwt.Parse(token, nil) +} +`, + 'auth/jwt_test.go': `package auth + +import ( + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func TestJWTInfo(t *testing.T) { + jwt, err := newTokenProviderJWT("key") + if err != nil { + t.Fatal(err) + } + token, _ := jwt.assign("abc") + _, ok := jwt.info(token) + _ = ok +} + +func TestJWTParse(t *testing.T) { + jwt, err := jwt.Parse("x", nil) + _, _ = jwt, err +} + +func TestJWTClosure(t *testing.T) { + check := func(jwt *tokenJWT) { + jwt.info("t") + } + check(nil) + _, _ = jwt.Parse("y", nil) +} +`, + 'compare/compare.go': `package compare + +import "github.com/google/go-cmp/cmp" + +type Comparer interface { + Compare(a, b int) int +} + +func comparers() []Comparer { return nil } + +func Check(cmp Comparer) int { + return cmp.Compare(1, 2) +} + +func Report(a, b string) string { + for _, cmp := range comparers() { + _ = cmp.Compare(3, 4) + } + return cmp.Diff(a, b) +} + +func Report2(a, b string) string { + cmp := cmp.Diff(a, b) + return cmp +} +`, + // A variable's declared type says whose methods it has: kubernetes' + // `clock clock.PassiveClock` parameter, `cache := &atomic.Bool{}`, and + // harbor's `logger logger.Interface`. + 'clock/clock.go': `package clock + +import "time" + +// Named like k8s.io/utils/clock's interface. +type Clock interface { + Now() time.Time +} +`, + 'cache/store.go': `package cache + +type Store interface { + Add(obj interface{}) error +} +`, + 'jobs/logger/logger.go': `package logger + +type Interface interface { + Error(v ...interface{}) + String() string +} + +func Error(v ...interface{}) {} +`, + 'jobs/transfer/transfer.go': `package transfer + +type Logger struct{} + +func (l *Logger) Error(v ...interface{}) {} +`, + 'jobs/job.go': `package jobs + +import ( + "sync/atomic" + "time" + + "example.com/app/cache" + "example.com/app/jobs/logger" + "k8s.io/utils/clock" + clocktesting "k8s.io/utils/clock/testing" +) + +func logError(logger logger.Interface, err error) { + logger.Error(err) +} + +func nextCheck(clock clock.PassiveClock) int64 { + return clock.Now().Unix() +} + +func cachedHasSynced(store cache.Store) func() bool { + cache := &atomic.Bool{} + cache.Store(false) + return cache.Load +} + +func describe(logger logger.Interface) string { + return logger.Kind.String() +} + +func elapsed() time.Time { + clock := clocktesting.NewFakePassiveClock(time.Unix(1, 0)) + now := clock.Now() + return now +} +`, + // harbor's controller/robot.Robot embeds model.Robot, whose method a + // local named like the import calls. + 'robot/model/model.go': `package model + +type Robot struct{ Name string } + +func (r *Robot) ToJSON() (string, error) { return r.Name, nil } +`, + 'robot/robot.go': `package robot + +import "example.com/app/robot/model" + +type Robot struct { + model.Robot + Level string +} +`, + 'scan/job_test.go': `package scan + +import ( + "testing" + + "example.com/app/robot" +) + +func TestJob(t *testing.T) { + robot := &robot.Robot{Level: "system"} + data, _ := robot.ToJSON() + _ = data +} +`, + 'store/store.go': `package store + +import "github.com/patrickmn/go-cache" + +type store struct{} + +func (cache *store) Refresh() { + cache.load() +} + +func (s *store) load() {} + +func New() *cache.Cache { + return cache.New(0, 0) +} +`, +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-ref-qualifier-')); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** The line of `file` that holds `snippet` (its nth occurrence, from 1). */ +const lineOf = (file: string, snippet: string, nth = 1): number => { + const lines = files[file]!.split('\n'); + let seen = 0; + const at = lines.findIndex((l) => l.includes(snippet) && ++seen === nth); + if (at < 0) throw new Error(`no ${snippet} in ${file}`); + return at + 1; +}; + +/** What the calls on the line of `file` holding `snippet` reach, as qualified names. */ +const reachedAt = (file: string, snippet: string, nth = 1): string[] => { + const line = lineOf(file, snippet, nth); + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains' && e.line === line) + .map((e) => cg.getNode(e.target)!.qualifiedName); +}; + +describe('a dotted Go call is written through its own receiver', () => { + it('whatever else on the line spells the name', () => { + // `"Log using Infof"` and the `Digest:` key spell the name again. + expect(reachedAt('example/example.go', 'klog.Infof(')).not.toContain('klogWrapper::Infof'); + expect(reachedAt('example/example.go', 'klog.Errorf(')).not.toContain('klogWrapper::Errorf'); + expect(reachedAt('example/example.go', 'digest.Digest(dig)')).not.toContain('Suite::Digest'); + expect(reachedAt('example/example.go', 'cmp.Diff("a", "b")')).not.toContain('Differ::Diff'); + }); + + it('not through another selector on its line', () => { + // `klog.Error(perr.Error())`: perr's own method, not a call through klog. + expect(reachedAt('example/example.go', 'klog.Error(perr.Error())')).toContain('parseError::Error'); + // `s.cache.Get(key)`: the field's type, not the go-cache package the + // middle segment is named like. + expect(reachedAt('example/example.go', 's.cache.Get(key)')).toContain('lru::Get'); + }); +}); + +describe('a Go parameter or local named like an import', () => { + it('is the variable where it is in scope', () => { + expect(reachedAt('auth/jwt_test.go', 'jwt.assign(')).toContain('tokenJWT::assign'); + expect(reachedAt('auth/jwt_test.go', 'jwt.info(token)')).toContain('tokenJWT::info'); + // A closure's parameter, a function's parameter, a range variable and a method receiver. + expect(reachedAt('auth/jwt_test.go', 'jwt.info("t")')).toContain('tokenJWT::info'); + expect(reachedAt('compare/compare.go', 'cmp.Compare(1, 2)')).toContain('Comparer::Compare'); + expect(reachedAt('compare/compare.go', 'cmp.Compare(3, 4)')).toContain('Comparer::Compare'); + expect(reachedAt('store/store.go', 'cache.load()')).toContain('store::load'); + }); + + it('leaves the package what the import names everywhere else', () => { + // Another function, the declaration's own right-hand side, past a + // closure or a loop that bound the name. + expect(reachedAt('auth/jwt.go', 'jwt.Parse(token, nil)')).not.toContain('jwtOptions::Parse'); + expect(reachedAt('auth/jwt_test.go', 'jwt.Parse("x", nil)')).not.toContain('jwtOptions::Parse'); + expect(reachedAt('auth/jwt_test.go', 'jwt.Parse("y", nil)')).not.toContain('jwtOptions::Parse'); + expect(reachedAt('compare/compare.go', 'return cmp.Diff(a, b)')).not.toContain('Differ::Diff'); + expect(reachedAt('compare/compare.go', 'cmp := cmp.Diff(a, b)')).not.toContain('Differ::Diff'); + }); + + it('has the methods of the type it is declared as', () => { + // An outside package's type holds none of the project's methods, nor + // does a value one of its functions hands out… + expect(reachedAt('jobs/job.go', 'clock.Now()')).not.toContain('Clock::Now'); + expect(reachedAt('jobs/job.go', 'cache.Store(false)')).toEqual([]); + expect(reachedAt('jobs/job.go', 'now := clock.Now()')).toEqual([]); + // …and a project package's type its own: not the function the import + // names, nor another package's `Logger::Error`. A method an embedded + // type brings in counts. + expect(reachedAt('jobs/job.go', 'logger.Error(err)')).toEqual(['Interface::Error']); + expect(reachedAt('scan/job_test.go', 'robot.ToJSON()')).toEqual(['Robot::ToJSON']); + // The type is the variable's, not a field's read through it. + expect(reachedAt('jobs/job.go', 'logger.Kind.String()')).not.toContain('Interface::String'); + }); + + it('is read from the file as a sync leaves it', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-ref-qualifier-sync-')); + const write = (rel: string, content: string): void => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + write('go.mod', 'module example.com/app\n\ngo 1.22\n'); + write('auth/jwt.go', files['auth/jwt.go']!); + // The same lines either way: a stale reading would still see the local. + const use = (decl: string) => `package auth + +import "github.com/golang-jwt/jwt/v5" + +func use() { + ${decl} + jwt.assign("user") +} +`; + write('auth/use.go', use('jwt, _ := newTokenProviderJWT("key")')); + const synced = await CodeGraph.init(dir, { index: true }); + try { + const assigns = (): string[] => { + const ids = synced.getNodesInFile('auth/use.go').map((n) => n.id); + return synced.getOutgoingEdgesFrom(ids).filter((e) => e.line === 7).map((e) => synced.getNode(e.target)!.qualifiedName); + }; + expect(assigns()).toEqual(['tokenJWT::assign']); + write('auth/use.go', use('_, _ = jwt.Parse("key", nil)')); + await synced.sync(); + expect(assigns()).toEqual([]); + } finally { + synced.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 8ac0695cac..2d1741065c 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -2591,6 +2591,9 @@ function resolveGoCrossPackageReference( const receiver = ref.referenceName.substring(0, dotIdx); const memberName = ref.referenceName.substring(dotIdx + 1); if (!memberName) return null; + // A parameter or local named like the import holds the call there: + // `store := newStore()`, then `store.Get(k)` is no call into package store. + if (!goRefQualifier(ref, context)) return null; for (const imp of imports) { if (imp.localName !== receiver) continue; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 46dd84752d..0934c2844d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1923,6 +1923,16 @@ interface GoQualification { written?: string; /** The file's import that name is. */ imported?: ImportMapping; + /** + * For a call through a parameter or local that takes an import's name, the + * type it is declared as, when its declaration writes one (`*tokenJWT`, + * `clock.PassiveClock`), and the import that type is written through — + * `clock` in a parameter `clock clock.PassiveClock`, `atomic` in + * `cache := &atomic.Bool{}` — or its value comes from: `reflect` in + * `value := reflect.New(t)`. + */ + declaredType?: string; + typePackage?: ImportMapping; } const GO_QUALIFIERS = new WeakMap>(); @@ -1936,24 +1946,58 @@ function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQ const key = `${ref.filePath}\0${ref.line}\0${ref.column}\0${ref.referenceName}`; const hit = memo.get(key); if (hit !== undefined) return hit; - const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; - const at = Math.max(0, ref.column); - // The qualifier right before the name at the reference's column, or the - // line's only spelling of the name. A variadic `...chunks.Meta` is written - // through `chunks` too: the ellipsis is no receiver. - const written = line.startsWith(name, at) ? /(?:^|[^\w.]|\.{3})([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] - : !new RegExp(`(? m.localName === written) : undefined; - const found = { written, imported }; + let written: string | undefined; + const dot = ref.referenceName.indexOf('.'); + if (dot >= 0) { + // A dotted reference spells its receiver, and only the first segment can + // be a package: `klog` in `klog.Infof`, `s` in `s.cache.Get`. Reading + // the line for it found nothing where the name is spelled again — + // harbor's `Digest:` key beside `digest.Digest(dig)` — and another + // selector's where one comes first: `klog` for `err.Error` in + // `klog.Error(err.Error())`. + const head = ref.referenceName.slice(0, dot); + if (/^[A-Za-z_]\w*$/.test(head)) written = head; + } else { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; + const at = Math.max(0, ref.column); + // The qualifier right before the name at the reference's column, or the + // line's only spelling of the name. A variadic `...chunks.Meta` is written + // through `chunks` too: the ellipsis is no receiver. + written = line.startsWith(name, at) ? /(?:^|[^\w.]|\.{3})([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] + : !new RegExp(`(? m.localName === written) : undefined; + let declaredType: string | undefined; + let typePackage: ImportMapping | undefined; + // A call through a parameter or local that takes the import's name is a + // call on that variable: etcd's `jwt, err := newTokenProviderJWT(…)`, then + // `jwt.assign(…)` beside `import "github.com/golang-jwt/jwt/v5"`; harbor's + // testify suites' `func (suite *DaoTestSuite) …` beside testify's `suite`. + // A type's qualifier is always a package, and a value read through one + // (`quota.Name`) is no call. + const local = imported && dot >= 0 && ref.referenceKind === 'calls' ? goLocalDecl(written!, ref, context) : undefined; + if (local) { + declaredType = local.type; + // A value a package outside the project hands out is of a type it + // declares: kubernetes' `clock := clocktesting.NewFakePassiveClock(…)` + // has no project method `Now`. + const qualifier = /^[*&\s]*([A-Za-z_]\w*)\.[A-Za-z_]\w*$/.exec(declaredType ?? '')?.[1] ?? local.from; + typePackage = qualifier ? imports.find((m) => m.localName === qualifier) : undefined; + written = undefined; + imported = undefined; + } + const found = { written, imported, declaredType, typePackage }; memo.set(key, found); return found; } /** * The import a Go reference is written through — `context` in - * `context.Context`, `store` in `store.Manager` — read from its line, since - * the index keeps only the name. Undefined for a name written bare, or - * through anything that isn't one of the file's imports. + * `context.Context`, `store` in `store.Manager` — taken from a dotted + * reference's receiver, else read from its line, since the index keeps only + * the name. Undefined for a name written bare, or through anything that isn't + * one of the file's imports there. */ export function goRefQualifier(ref: UnresolvedRef, context: ResolutionContext): ImportMapping | undefined { return goRefQualification(ref, context).imported; @@ -2016,13 +2060,15 @@ export function goTypePositionTarget(result: ResolvedRef, ref: UnresolvedRef, co /** * Whether a Go reference is written through an imported package from outside * the project's modules — `context.Context`, `fmt.Errorf`, a third-party - * `gin.H`. + * `gin.H` — or is a call on a variable declared as such a package's type: + * kubernetes' `clock.Now()` on a parameter `clock clock.PassiveClock`. */ function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { - const imported = goRefQualifier(ref, context); - if (!imported) return false; - return !(imported.source.startsWith('.') || imported.source.includes('/internal/') || - context.getGoPackageDir?.(imported.source, ref.filePath) != null); + const { imported, typePackage } = goRefQualification(ref, context); + const pkg = imported ?? typePackage; + if (!pkg) return false; + return !(pkg.source.startsWith('.') || pkg.source.includes('/internal/') || + context.getGoPackageDir?.(pkg.source, ref.filePath) != null); } /** @@ -2042,6 +2088,404 @@ function isInGoQualifierPackage(candidate: Node, ref: UnresolvedRef, context: Re return pkgDir == null || goPackageDir(candidate.filePath) === pkgDir; } +/** + * A Go file's blocks and the names its functions declare: the receivers, + * parameters and results of every `func` with a body, and each name a `:=`, + * `var` or `const` declares. + */ +interface GoScopeIndex { + /** The source, comments and string contents blanked, offsets kept. */ + code: string; + lineStarts: number[]; + /** Offsets of every `{` and `}`, with the innermost block open after each. */ + events: number[]; + open: number[]; + close: Map; + decls: Map; + /** Where each declared name is bound, read on demand: [start, end, declaration]. */ + scopes: Map>; +} + +interface GoDecl { + /** Offset of the declared name. */ + at: number; + /** + * A statement's local, an `if` / `for` / `switch` header's, a `select` + * case's, or a parameter of the function whose body opens at `body`. + */ + kind: 'local' | 'header' | 'case' | 'param'; + body?: number; + /** The declared type as written, when the declaration writes one. */ + type?: string; + /** The package a `:=` takes its value from: `clocktesting` in `clock := clocktesting.NewFakeClock(…)`. */ + from?: string; +} + +const GO_SCOPE_INDEXES = new WeakMap>(); +const GO_TYPE_KEYWORDS: ReadonlySet = new Set(['chan', 'func', 'interface', 'map', 'struct']); + +/** + * The parameter or local of the Go function around `ref` that `name` is + * there, if one is. Go gives an identifier the innermost block that declares + * it, from the end of its declaration on: a header's names live in its + * statement's blocks (`else` included), a `select` case's in its clause, a + * parameter in its function's body. + */ +function goLocalDecl(name: string, ref: UnresolvedRef, context: ResolutionContext): GoDecl | undefined { + const index = goScopeIndex(ref.filePath, context); + if (!index?.decls.has(name)) return undefined; + const at = (index.lineStarts[ref.line - 1] ?? index.code.length) + Math.max(0, ref.column); + let found: [number, number, GoDecl] | undefined; + for (const scope of goScopesOf(index, name)) { + if (at > scope[0] && at < scope[1] && (!found || scope[0] > found[0])) found = scope; + } + return found?.[2]; +} + +function goScopeIndex(filePath: string, context: ResolutionContext): GoScopeIndex | null { + let memo = GO_SCOPE_INDEXES.get(context); + if (!memo) GO_SCOPE_INDEXES.set(context, (memo = new Map())); + const hit = memo.get(filePath); + if (hit !== undefined) return hit; + const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/); + const index = lines ? readGoScopes(blankGoCode(lines.join('\n'))) : null; + // Refs arrive file by file; a few files' worth is all a pass revisits. + if (memo.size >= 64) memo.delete(memo.keys().next().value!); + memo.set(filePath, index); + return index; +} + +function isGoIdentChar(c: number): boolean { + return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95; +} + +function readGoScopes(code: string): GoScopeIndex { + const lineStarts = [0]; + for (let i = code.indexOf('\n'); i >= 0; i = code.indexOf('\n', i + 1)) lineStarts.push(i + 1); + const events: number[] = []; + const open: number[] = []; + const close = new Map(); + const stack: number[] = []; + const braces = /[{}]/g; + for (let m = braces.exec(code); m; m = braces.exec(code)) { + const i = m.index; + if (code.charCodeAt(i) === 123) stack.push(i); + else { + const at = stack.pop(); + if (at !== undefined) close.set(at, i); + } + events.push(i); + open.push(stack.length > 0 ? stack[stack.length - 1]! : -1); + } + const decls = new Map(); + const add = (name: string, decl: GoDecl): void => { + if (name === '_') return; + const list = decls.get(name); + if (list) list.push(decl); + else decls.set(name, [decl]); + }; + // `a, b := …`: the identifiers before each `:=`. + for (let i = code.indexOf(':='); i >= 0; i = code.indexOf(':=', i + 2)) { + const names: Array<[string, number]> = []; + let p = i; + for (;;) { + while (p > 0 && (code[p - 1] === ' ' || code[p - 1] === '\t')) p--; + let s = p; + while (s > 0 && isGoIdentChar(code.charCodeAt(s - 1))) s--; + if (s === p || /\d/.test(code[s]!) || code[s - 1] === '.') break; + names.push([code.slice(s, p), s]); + p = s; + while (p > 0 && (code[p - 1] === ' ' || code[p - 1] === '\t')) p--; + if (code[p - 1] !== ',') break; + p--; + } + if (names.length === 0) continue; + const first = names[names.length - 1]![1]; + const lead = code.slice(code.lastIndexOf('\n', first - 1) + 1, first); + const kind = /^\s*(?:\}\s*else\s+)?(?:if|for|switch)\b/.test(lead) ? 'header' : /^\s*case\b/.test(lead) ? 'case' : 'local'; + // `x := &pkg.T{…}` writes the type it declares; `x := pkg.New(…)` the + // package its value comes from. + const rhs = code.slice(i + 2, i + 160); + const type = names.length === 1 ? /^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{/.exec(rhs)?.[1] : undefined; + const from = /^[ \t]*&?[ \t]*([A-Za-z_]\w*)\.[A-Za-z_]\w*[ \t]*[({]/.exec(rhs)?.[1]; + for (const [name, at] of names) add(name, { at, kind, type, from }); + } + // `var x T`, `var a, b = …`, `const (…)`: names before a type, an `=` or + // the line's end — in a group, on lines outside the brackets of a value + // spanning several (`http.MethodHead,` in a `[]string{…}` is no name). + const spec = /[ \t]*([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)(?=[ \t]*(?:\n|$|=(?!=))|[ \t]+(?:[*[A-Za-z_(]|<-))/y; + const addSpec = (at: number): void => { + spec.lastIndex = at; + const s = spec.exec(code); + if (!s) return; + const type = /^[ \t]+([^\s=;][^=;\n]*?)[ \t]*(?:[=;\n]|$)/.exec(code.slice(at + s[0].length, at + s[0].length + 160))?.[1]; + let p = at + s[0].length - s[1]!.length; + for (const part of s[1]!.split(',')) { + const name = part.trim(); + add(name, { at: p + part.indexOf(name), kind: 'local', type }); + p += part.length + 1; + } + }; + for (const m of code.matchAll(/(?; body: number } | null { + const groups: Array<[number, number]> = []; + let p = at; + const skip = (): void => { while (code[p] === ' ' || code[p] === '\t') p++; }; + const group = (): boolean => { + if (code[p] !== '(') return false; + const end = goClosing(code, p); + groups.push([p + 1, end]); + p = end + 1; + skip(); + return true; + }; + // `func Name[T any](…)`, `func (r T) Name(…)`, or a literal / type `func(…)`. + const name = (): boolean => { + const m = /^[A-Za-z_]\w*/.exec(code.slice(p, p + 128)); + if (!m || GO_TYPE_KEYWORDS.has(m[0])) return false; + let q = p + m[0].length; + while (code[q] === ' ' || code[q] === '\t') q++; + if (code[q] === '[') q = goClosing(code, q) + 1; + while (code[q] === ' ' || code[q] === '\t') q++; + if (code[q] !== '(') return false; + p = q; + return true; + }; + skip(); + if (code[p] !== '(') { + if (!name() || !group()) return null; + } else { + group(); + if (name() && !group()) return null; + } + if (code[p] === '(') group(); + // A result type up to the body; a function type ends at a `,`, `)`, `}`, + // `=`, `;` or the line's end instead. + let depth = 0; + for (; p < code.length; p++) { + const c = code[p]; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') { if (--depth < 0) return null; } + else if (c === '{') { + if (depth > 0) continue; + // `struct{…}` / `interface{…}` is the result type's own brace. + if (/(?:struct|interface)\s*$/.test(code.slice(Math.max(0, p - 12), p))) { p = goClosing(code, p); continue; } + return { groups, body: p }; + } else if (depth === 0 && (c === '\n' || c === ',' || c === ';' || c === '=' || c === '}')) return null; + } + return null; +} + +/** + * The names a Go parameter list between `from` and `to` declares, with the + * type each is declared as: `(a, b int, c string)` declares a and b as `int` + * and c as `string`; `(int, pkg.T)` and `(*T, ...U)` declare none. + */ +function goParamNames(code: string, from: number, to: number): Array<[string, number, string]> { + const parts: Array<[number, number]> = []; + let depth = 0; + let start = from; + for (let i = from; i < to; i++) { + const c = code[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (c === ',' && depth === 0) { parts.push([start, i]); start = i + 1; } + } + parts.push([start, to]); + const names: Array<[string, number, string]> = []; + let grouped: Array<[string, number]> = []; + for (const [s, e] of parts) { + const text = code.slice(s, e); + const m = /^\s*([A-Za-z_]\w*)/.exec(text); + if (!m || GO_TYPE_KEYWORDS.has(m[1]!)) continue; + const rest = text.slice(m[0].length); + // `pkg.T` is a type; a variadic `args ...T` is a name. + if (/^\s*\.(?!\.\.)/.test(rest)) continue; + const at = s + m[0].length - m[1]!.length; + if (/^\s*$/.test(rest)) { grouped.push([m[1]!, at]); continue; } + // `a, b int`: the names before a typed one share its type. + const type = rest.trim(); + for (const [name, nameAt] of grouped) names.push([name, nameAt, type]); + grouped = []; + names.push([m[1]!, at, type]); + } + // A list with no typed name names no parameters: its lone identifiers are types. + return names; +} + +/** The innermost `{…}` holding an offset, as [open, close] — null outside every block. */ +function goBlockAt(index: GoScopeIndex, offset: number): [number, number] | null { + let lo = 0; + let hi = index.events.length - 1; + let at = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (index.events[mid]! < offset) { at = mid; lo = mid + 1; } else hi = mid - 1; + } + const open = at < 0 ? -1 : index.open[at]!; + return open < 0 ? null : [open, index.close.get(open) ?? index.code.length]; +} + +/** Where the statement declaring at `at` ends: its line's end, or a `;`, outside brackets. */ +function goStatementEnd(code: string, at: number): number { + let depth = 0; + for (let i = at; i < code.length; i++) { + const c = code[i]; + if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') { if (--depth < 0) return i; } + else if ((c === '\n' || c === ';') && depth === 0) return i; + } + return code.length; +} + +/** + * The blocks of the `if` / `for` / `switch` statement whose header declares + * at `at`: gofmt ends a header's line with its block's `{` (a composite + * literal's in the header has more on its line), and an `else` chain keeps + * the header's names. + */ +function goHeaderBlocks(index: GoScopeIndex, at: number): Array<[number, number]> { + const code = index.code; + const blocks: Array<[number, number]> = []; + let depth = 0; + for (let i = at; i < code.length; i++) { + const c = code[i]; + if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === '}') break; + else if (c === '{') { + const end = index.close.get(i) ?? code.length; + if (depth !== 0 || !/^[ \t]*(?:\n|$)/.test(code.slice(i + 1, i + 80))) { i = end; continue; } + blocks.push([i, end]); + const chained = /^[ \t]*else\b[ \t]*/.exec(code.slice(end + 1, end + 40)); + if (!chained) break; + i = end + chained[0].length; + depth = 0; + } + } + return blocks; +} + +function goScopesOf(index: GoScopeIndex, name: string): Array<[number, number, GoDecl]> { + const hit = index.scopes.get(name); + if (hit) return hit; + const scopes: Array<[number, number, GoDecl]> = []; + for (const d of index.decls.get(name) ?? []) { + if (d.kind === 'param') { + scopes.push([d.body!, index.close.get(d.body!) ?? index.code.length, d]); + continue; + } + // Outside every block it is a package-level name, which no import shares. + const block = goBlockAt(index, d.at); + if (!block) continue; + if (d.kind === 'header') { + for (const [start, end] of goHeaderBlocks(index, d.at)) scopes.push([start, end, d]); + continue; + } + const from = goStatementEnd(index.code, d.at); + let to = block[1]; + if (d.kind === 'case') { + const clause = /\n[ \t]*(?:case\b|default[ \t]*:)/g; + clause.lastIndex = from; + for (let m = clause.exec(index.code); m && m.index < to; m = clause.exec(index.code)) { + if (goBlockAt(index, m.index + 1)?.[0] === block[0]) { to = m.index; break; } + } + } + if (from < to) scopes.push([from, to, d]); + } + index.scopes.set(name, scopes); + return scopes; +} + +/** + * Go source with its comments and the contents of its strings and runes + * blanked: offsets and newlines stay, so a regex match still maps to its + * line and column. + */ +function blankGoCode(src: string): string { + const parts: string[] = []; + let last = 0; + const blank = (from: number, to: number): void => { + parts.push(src.slice(last, from)); + const newline = src.indexOf('\n', from); + parts.push(newline < 0 || newline >= to ? ' '.repeat(to - from) : src.slice(from, to).replace(/[^\n]/g, ' ')); + last = to; + }; + const special = /[/"'`]/g; + for (let m = special.exec(src); m; m = special.exec(src)) { + const i = m.index; + const c = src.charCodeAt(i); + if (c === 47) { + const d = src.charCodeAt(i + 1); + if (d === 47) { // `//` + const end = src.indexOf('\n', i); + blank(i, end < 0 ? src.length : end); + } else if (d === 42) { // `/*` + const end = src.indexOf('*/', i + 2); + blank(i, end < 0 ? src.length : end + 2); + } else continue; + special.lastIndex = last; + } else if (c === 96) { // a raw string + const end = src.indexOf('`', i + 1); + blank(i + 1, end < 0 ? src.length : end); + special.lastIndex = last + 1; + } else { // "…" or '…' + let j = i + 1; + while (j < src.length) { + const d = src.charCodeAt(j); + if (d === 92) { j += 2; continue; } + if (d === c || d === 10) break; + j++; + } + blank(i + 1, Math.min(j, src.length)); + special.lastIndex = last + 1; + } + } + parts.push(src.slice(last)); + return parts.join(''); +} + const PHP_CLASS_KINDS: ReadonlySet = new Set(['class', 'interface', 'trait', 'enum']); /** * Whether a bare PHP class name at `ref` can mean `candidate`. An unqualified @@ -9188,6 +9632,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { PY_PLUGGED_MODULES.delete(context); SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); + GO_SCOPE_INDEXES.delete(context); GO_EMBEDS.delete(context); GO_ALIAS_TARGETS.delete(context); GO_DOT_IMPORTS.delete(context); @@ -10311,6 +10756,17 @@ export function matchMethodCall( // A pytest test's parameter is what its fixture returns: flaskbb's // `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`. if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context); + // A Go parameter named like an import is declared through a package as + // often as not (`logger logger.Interface`), which the patterns above + // don't read; the scope reader kept its type. Not a field's: in + // `metadata.Type.String()` that is the type of `metadata`. + if (!inferredType && ref.language === 'go' && dotMatch && !objectOrClass!.includes('.')) { + const declared = goRefQualification(ref, context).declaredType; + if (declared) { + inferredType = normalizeInferredTypeName(declared); + decl.raw = declared; + } + } if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch) { inferredType = nmTimedT('mc-member', ref, () => inferMemberReceiverType(objectOrClass!, ref, context)); // A field of a built-in type (`string _name`, `int count`) has no project method. From 2c807f5c137a683d4eacb403cc4f7a7b4675681e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 15:04:13 +0000 Subject: [PATCH 08/20] fix(js,ts): a catch-all or node_modules path alias no longer makes package imports look local (#2437) The out-of-repo import guard (`isOutOfRepoImport`) never fired in two setups: - A catch-all `paths` key. The guard started with `isExternalImport`, which treats an import as local when it starts with a root alias's prefix, and a `"*"` key's prefix is empty. On cord-field (`"*": ["./typings/*"]`), 169 imports of `Typography` from `@mui/material` bound to the project's own `Typography`. - An alias that lands on disk but outside the index. `fileExists` falls back to `fs.existsSync`, which also answers for a directory or a file under `node_modules`. home-assistant's `"lit/decorators": ["./node_modules/lit/decorators.js"]` bound 4,916 `@property()` decorators to an unrelated `property` field, and topcoder's `config` package landed on its `config/` folder. The guard now skips the alias-prefix test (it already asks `resolveImportPath` whether an alias maps the import to a file) and counts that resolution only when it lands on an indexed file. Other callers of `isExternalImport` are unchanged: resolving an import still needs the prefix test so `"*": ["src/*"]` aliases resolve at all. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/catch-all-path-alias.test.ts | 184 +++++++++++++++++++++++++ src/resolution/import-resolver.ts | 10 +- src/resolution/index.ts | 23 +++- 4 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 __tests__/catch-all-path-alias.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e893dd18b0..a9d37c0303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. +- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/catch-all-path-alias.test.ts b/__tests__/catch-all-path-alias.test.ts new file mode 100644 index 0000000000..7f41631519 --- /dev/null +++ b/__tests__/catch-all-path-alias.test.ts @@ -0,0 +1,184 @@ +/** + * A tsconfig `paths` entry keyed `"*"` matches every specifier, so treating + * "an alias pattern matches" as "the import is the project's" made every + * package import in such a project look local. The out-of-repo guard never + * fired there: on cord-field (`"baseUrl": "src"`, `"*": ["./typings/*"]`), + * 169 imports of `Typography` from `@mui/material` landed on a project + * `Typography`, and `Form` from `react-final-form` on the project's form. + * + * An alias counts only when it maps the specifier to a project file. A + * package the importing file's package.json declares, which no alias maps to + * a file, is outside the repository, catch-all or not. A catch-all that does + * find a file (a local `.d.ts` for an untyped package, or `"*": ["src/*"]` + * for the project's own folders) still makes that import the project's. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-catch-all-alias-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +/** + * ` :` for every edge leaving + * `file`'s nodes. JSX tags are left out: a separate pass links `; } +`, + 'src/components/Card.tsx': `export function Card(props: { children?: unknown }) { return
{String(props.children)}
; } +`, + 'src/components/form/Form.tsx': `export function Form() { return
; } +export function useForm() { return { valid: true }; } +`, + 'src/common/DateTime.ts': `export class DateTime { + static now(): DateTime { return new DateTime(); } +} +`, + // The catch-all does answer this package: its types live in the project. + 'src/typings/legacy-widgets.d.ts': `export interface WidgetOptions { name: string } +export declare class Widget { + constructor(options: WidgetOptions); + render(): void; +} +`, + 'src/scenes/Page.tsx': `import { Button, type ButtonProps } from '@mui/material'; +import { Form, useForm } from 'react-final-form'; +import { DateTime } from 'luxon'; +import { Widget, type WidgetOptions } from 'legacy-widgets'; +import { Card } from '~/components/Card'; + +const options: WidgetOptions = { name: 'page' }; + +export function Page(props: ButtonProps) { + const form = useForm(); + const when = DateTime.now(); + new Widget(options).render(); + return ; +} +`, + }); + try { + const fromPage = edgesFrom(cg, 'src/scenes/Page.tsx'); + // No name the page imports from a package lands on a project namesake. + expect(fromPage.filter((e) => /src\/components\/(?:Button|form\/Form)\.tsx|src\/common\/DateTime\.ts/.test(e))).toEqual([]); + // `~/…` still reaches the project, and so does a package whose types the + // catch-all finds in the project. + expect(fromPage).toContain('imports src/components/Card.tsx:Card'); + expect(fromPage).toContain('imports src/typings/legacy-widgets.d.ts:Widget'); + expect(fromPage).toContain('imports src/typings/legacy-widgets.d.ts:WidgetOptions'); + } finally { + cg.close(); + } + }); + + it('still makes an import the project’s when it maps the import to a project file', async () => { + const cg = await indexProject({ + 'package.json': JSON.stringify({ name: 'shop', dependencies: { '@mui/material': '^5.15.0', react: '^18.2.0' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', jsx: 'react-jsx', paths: { '*': ['src/*'] } } }), + 'src/components/Button.tsx': `export function Button() { return