diff --git a/CHANGELOG.md b/CHANGELOG.md index 738ca4cd1..88a1a6518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -573,6 +573,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. +- In JavaScript and TypeScript, a file that re-exports a module, like an `index.ts` with `export * from './button'` or `export { Button } from './button'`, now counts as importing that module, the way an `import` statement does. `codegraph affected` and a module's dependents now include the barrels that re-export it; before, a barrel made only of `export *` lines showed no dependency at all. Re-index JavaScript and TypeScript projects after upgrading. +- `codegraph sync` and the file watcher now also update the files that import through an `index.ts` re-exporting another barrel, like `export * from './dropdown-menu'`, when that inner barrel is deleted or added. Before, their imports stayed linked through the deleted barrel, or stayed linked the way they were while it was missing, until each importing file changed or the project was re-indexed. - In Kotlin, a call on a typed receiver now links to an extension function declared in a package, like `value.shout()` or the infix `a join b` with `value: Foo` and `fun Foo.shout()` under `package demo`, including one declared in another file of the package. Before, it linked only when the file had no `package` line, so the usual Kotlin project got no edge. On JetBrains Exposed, 1,020 such calls now link (`Table.selectAll()` to `FieldSet.selectAll`, `transaction.throwUnsupportedException(…)`), and none that linked before is lost. A member of the receiver's class still wins over an extension of the same name. diff --git a/README.md b/README.md index 0d2d4b3b2..616662ff5 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. ## About this fork -This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`7ba8eee4`](https://github.com/colbymchenry/codegraph/commit/7ba8eee4) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. +This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`041db101`](https://github.com/colbymchenry/codegraph/commit/041db101) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below). diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 28f4ff150..984854a88 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11094,6 +11094,34 @@ export const registry = [widget]; expect(names).toContain('beta'); }); + it('a re-export imports its source module, as an import statement does', () => { + const result = extractFromSource( + 'barrel.ts', + `export * from './star';\nexport { alpha } from './named';\nexport * as ns from './space';\n` + ); + const modules = ['./star', './named', './space']; + expect(result.nodes.filter((n) => n.kind === 'import').map((n) => n.name)).toEqual(modules); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName); + for (const module of modules) expect(refs).toContain(module); + expect(refs).toContain('alpha'); + }); + + it('a barrel that only re-exports everything is a dependent of the module', async () => { + const dir = createTempDir(); + try { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export function helper(): void {}\n`); + fs.writeFileSync(path.join(dir, 'src', 'index.ts'), `export * from './foo';\n`); + const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.resolveReferences(); + expect(cg.getFileDependents('src/foo.ts')).toContain('src/index.ts'); + cg.destroy(); + } finally { + cleanupTempDir(dir); + } + }); + it('a value imported/re-exported but never called still makes the importer a dependent', async () => { const dir = createTempDir(); try { diff --git a/__tests__/fixtures/golden/torture-multilang.dump b/__tests__/fixtures/golden/torture-multilang.dump index 95acc2c88..97524d883 100644 --- a/__tests__/fixtures/golden/torture-multilang.dump +++ b/__tests__/fixtures/golden/torture-multilang.dump @@ -1,4 +1,4 @@ -== nodes (1836) +== nodes (1838) {"id":"class:0083dc18baa2b629e8572db20ba2b464","kind":"class","name":"Widget","qualified_name":"Widget","file_path":"torture.cpp","language":"cpp","start_line":98,"end_line":104,"start_column":0,"end_column":1,"docstring":"UE-macro shapes — every one below is recovered by the hoisted preParse\n(export macro, reflection markup, inline specifier, API member prefix).","signature":null,"visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"class:00e4c67aff13a714886744f6829e0cea","kind":"class","name":"Cfg","qualified_name":"Cfg","file_path":"TortureDoubleWalk.dart","language":"dart","start_line":60,"end_line":62,"start_column":0,"end_column":1,"docstring":null,"signature":null,"visibility":"public","is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"class:01a3945fa12793f1af9ac531aedcc8cd","kind":"class","name":"Widget","qualified_name":"Widget","file_path":"TortureMini.dart","language":"dart","start_line":34,"end_line":52,"start_column":0,"end_column":1,"docstring":null,"signature":null,"visibility":"public","is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -1063,6 +1063,7 @@ {"id":"import:92cf06e18d12ccca19cc4fcd24ce1065","kind":"import","name":"./helpers","qualified_name":"./helpers","file_path":"torture.tsx","language":"tsx","start_line":8,"end_line":8,"start_column":0,"end_column":51,"docstring":null,"signature":"import { helperFn, CONFIG_TABLE } from './helpers';","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:93029aaf6aa7a84a081bd57e109d4488","kind":"import","name":"pct/lib","qualified_name":"pct/lib","file_path":"torture.rb","language":"ruby","start_line":5,"end_line":5,"start_column":0,"end_column":19,"docstring":null,"signature":"require %q(pct/lib)","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:95e073d27b0a05dee53bd4f2dd337996","kind":"import","name":"sys","qualified_name":"sys","file_path":"torture.py","language":"python","start_line":2,"end_line":2,"start_column":0,"end_column":14,"docstring":null,"signature":"import os, sys","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} +{"id":"import:98bb06445772756486c947f90b53b571","kind":"import","name":"./barrel-source","qualified_name":"./barrel-source","file_path":"torture.tsx","language":"tsx","start_line":10,"end_line":10,"start_column":0,"end_column":62,"docstring":null,"signature":"export { reExported, orig as aliased } from './barrel-source';","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:9a07e6aafcafeb20a10495d518e54993","kind":"import","name":"Short","qualified_name":"Torture.Scoped::Short","file_path":"TortureFileScoped.cs","language":"csharp","start_line":2,"end_line":2,"start_column":0,"end_column":23,"docstring":null,"signature":"using Short = SomeType;","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:9d128e56be07bc941d645549ed7165dc","kind":"import","name":"json","qualified_name":"json","file_path":"torture.rb","language":"ruby","start_line":2,"end_line":2,"start_column":0,"end_column":14,"docstring":null,"signature":"require \"json\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:9f7cdf48927d4c1e2948a7228a4a2391","kind":"import","name":"Child","qualified_name":"Child","file_path":"torture.lua","language":"lua","start_line":8,"end_line":8,"start_column":17,"end_column":54,"docstring":null,"signature":"require(script:WaitForChild(\"Child\"))","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -1080,6 +1081,7 @@ {"id":"import:b9ff610e03df9a920aee3ea37414fa5a","kind":"import","name":"App\\Contracts\\Cache","qualified_name":"App\\Services::App\\Contracts\\Cache","file_path":"torture.php","language":"php","start_line":8,"end_line":8,"start_column":0,"end_column":38,"docstring":null,"signature":"use App\\Contracts\\Cache as CacheAlias;","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:c1f5e9c54d0621a52704c17df0c9f464","kind":"import","name":"package:torture/other.dart","qualified_name":"package:torture/other.dart","file_path":"torture.dart","language":"dart","start_line":3,"end_line":3,"start_column":0,"end_column":61,"docstring":null,"signature":"import 'package:torture/other.dart' as other show OtherClass;","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:c3d526b9bc42c3eefafcdd6dad4898e6","kind":"import","name":"lib/plain.php","qualified_name":"App\\Services::lib/plain.php","file_path":"torture.php","language":"php","start_line":14,"end_line":14,"start_column":0,"end_column":23,"docstring":null,"signature":"require 'lib/plain.php'","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} +{"id":"import:c4aa3756e11681aadead3ec870085b66","kind":"import","name":"./star-source","qualified_name":"./star-source","file_path":"torture.tsx","language":"tsx","start_line":11,"end_line":11,"start_column":0,"end_column":30,"docstring":null,"signature":"export * from './star-source';","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:c83935b9151e970acd12b8999a18efde","kind":"import","name":"com.example.other.OtherClass","qualified_name":"com.example.torture::com.example.other.OtherClass","file_path":"torture.kt","language":"kotlin","start_line":4,"end_line":4,"start_column":0,"end_column":35,"docstring":null,"signature":"import com.example.other.OtherClass","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:cc46f446c585ccfc466f2e2651ed18a2","kind":"import","name":"lombok.Data","qualified_name":"com.example.torture::lombok.Data","file_path":"Torture.java","language":"java","start_line":13,"end_line":13,"start_column":0,"end_column":19,"docstring":null,"signature":"import lombok.Data;","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:d01aa110302e3842ac3db1f01e44fd49","kind":"import","name":"java.util.List","qualified_name":"com.example.torture::java.util.List","file_path":"Torture.java","language":"java","start_line":9,"end_line":9,"start_column":0,"end_column":22,"docstring":null,"signature":"import java.util.List;","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -1835,7 +1837,7 @@ {"id":"variable:fb450996c12f335b36201ead67a34f64","kind":"variable","name":"INIT_MAP","qualified_name":"INIT_MAP","file_path":"torture.py","language":"python","start_line":54,"end_line":54,"start_column":0,"end_column":26,"docstring":null,"signature":"= {\"a\": helper()}","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:feb330d554c5d0caaa8978323a006e52","kind":"variable","name":"MD_BARE","qualified_name":"MD_BARE","file_path":"torture.rb","language":"ruby","start_line":238,"end_line":238,"start_column":0,"end_column":21,"docstring":null,"signature":"= 'README.md'","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:fedacda34e94b85718ac655a74368326","kind":"variable","name":"okG","qualified_name":"okG","file_path":"torture.lua","language":"lua","start_line":12,"end_line":12,"start_column":6,"end_column":9,"docstring":null,"signature":"= pcall(require, \"app.guarded\")","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -== edges (2452) +== edges (2454) {"source":"class:0083dc18baa2b629e8572db20ba2b464","target":"class:25244cbdbe3e9bc720df9fc4d54fac0a","kind":"extends","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"app::Config\"}","line":98,"col":35,"provenance":null} {"source":"class:0083dc18baa2b629e8572db20ba2b464","target":"method:0795ac46f5bef3826c20039a05014ebc","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"class:0083dc18baa2b629e8572db20ba2b464","target":"method:35a476809e2727e80d7f2c8c404180b2","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} @@ -3543,6 +3545,8 @@ {"source":"file:torture.tsx","target":"import:4935bc32d5f33e8f95c3cb009bf0935d","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:torture.tsx","target":"import:7bff5cb286a60587ad04b8eed5de0e67","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:torture.tsx","target":"import:92cf06e18d12ccca19cc4fcd24ce1065","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} +{"source":"file:torture.tsx","target":"import:98bb06445772756486c947f90b53b571","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} +{"source":"file:torture.tsx","target":"import:c4aa3756e11681aadead3ec870085b66","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:torture.tsx","target":"import:d3f11476b3c70d68f9f9724f3e6ab699","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:torture.tsx","target":"interface:a05baa699fc9324830859524f09604c9","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:torture.tsx","target":"type_alias:600473b4a3158649443b2f214febf906","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} @@ -4288,7 +4292,7 @@ {"source":"variable:36e608fb368763b7f8a74a69f477cec7","target":"function:692077921d04b315880bc35a0bc872a9","kind":"references","metadata":"{\"confidence\":0.95,\"resolvedBy\":\"function-ref\",\"refName\":\"view\",\"refKind\":\"function_ref\",\"fnRef\":true}","line":49,"col":24,"provenance":null} {"source":"variable:6b7262d2883839c6f601126f4dd59e9b","target":"function:0e1ad124e719c2d18615cda6816af46c","kind":"references","metadata":"{\"confidence\":0.95,\"resolvedBy\":\"function-ref\",\"refName\":\"TargetCb\",\"refKind\":\"function_ref\",\"fnRef\":true}","line":14,"col":9,"provenance":null} {"source":"variable:788d75a828c79f9847e0be0e2b73a196","target":"variable:8fef25ea8abe486ee94190ed47c3644b","kind":"references","metadata":"{\"valueRef\":true}","line":null,"col":null,"provenance":null} -== refs (1713) +== refs (1715) {"from_node_id":"class:01a3945fa12793f1af9ac531aedcc8cd","reference_name":"Mix2","reference_kind":"implements","line":34,"col":37,"candidates":null,"file_path":"TortureMini.dart","language":"dart","status":"failed","name_tail":"Mix2"} {"from_node_id":"class:01a3945fa12793f1af9ac531aedcc8cd","reference_name":"Paint","reference_kind":"implements","line":34,"col":59,"candidates":null,"file_path":"TortureMini.dart","language":"dart","status":"failed","name_tail":"Paint"} {"from_node_id":"class:027779b3581d9f4aee2aef4dbd5c8873","reference_name":"immutable","reference_kind":"decorates","line":144,"col":0,"candidates":null,"file_path":"TortureAnnotatedDocs.dart","language":"dart","status":"failed","name_tail":"immutable"} @@ -4789,11 +4793,13 @@ {"from_node_id":"file:torture.swift","reference_name":"Foundation","reference_kind":"imports","line":1,"col":0,"candidates":null,"file_path":"torture.swift","language":"swift","status":"failed","name_tail":"Foundation"} {"from_node_id":"file:torture.swift","reference_name":"TortureKit","reference_kind":"imports","line":213,"col":0,"candidates":null,"file_path":"torture.swift","language":"swift","status":"failed","name_tail":"TortureKit"} {"from_node_id":"file:torture.swift","reference_name":"UIKit.UIView","reference_kind":"imports","line":2,"col":0,"candidates":null,"file_path":"torture.swift","language":"swift","status":"failed","name_tail":"UIView"} +{"from_node_id":"file:torture.tsx","reference_name":"./barrel-source","reference_kind":"imports","line":10,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"barrel-source"} {"from_node_id":"file:torture.tsx","reference_name":"./default-module","reference_kind":"imports","line":6,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"default-module"} {"from_node_id":"file:torture.tsx","reference_name":"./dynamic-module","reference_kind":"imports","line":211,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"dynamic-module"} {"from_node_id":"file:torture.tsx","reference_name":"./helpers","reference_kind":"imports","line":8,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"helpers"} {"from_node_id":"file:torture.tsx","reference_name":"./namespace-module","reference_kind":"imports","line":5,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"namespace-module"} {"from_node_id":"file:torture.tsx","reference_name":"./side-effect","reference_kind":"imports","line":7,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"side-effect"} +{"from_node_id":"file:torture.tsx","reference_name":"./star-source","reference_kind":"imports","line":11,"col":0,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"star-source"} {"from_node_id":"file:torture.tsx","reference_name":"CONFIG_TABLE","reference_kind":"imports","line":8,"col":19,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"CONFIG_TABLE"} {"from_node_id":"file:torture.tsx","reference_name":"DefaultThing","reference_kind":"function_ref","line":201,"col":31,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"module:default-module"} {"from_node_id":"file:torture.tsx","reference_name":"DefaultThing","reference_kind":"imports","line":6,"col":7,"candidates":null,"file_path":"torture.tsx","language":"tsx","status":"failed","name_tail":"module:default-module"} @@ -6904,4 +6910,4 @@ {"path":"torture.rs","language":"rust","node_count":83} {"path":"torture.scala","language":"scala","node_count":94} {"path":"torture.swift","language":"swift","node_count":86} -{"path":"torture.tsx","language":"tsx","node_count":92} +{"path":"torture.tsx","language":"tsx","node_count":94} diff --git a/__tests__/sync-module-importers.test.ts b/__tests__/sync-module-importers.test.ts index 2a8c27236..b03f6f37d 100644 --- a/__tests__/sync-module-importers.test.ts +++ b/__tests__/sync-module-importers.test.ts @@ -53,16 +53,39 @@ const PYTHON: Files = { }; const PYTHON_BARRELS = ['app/shop/__init__.py', 'lib/api.py', 'app/orm/__init__.py']; -/** ES modules: an `index.ts` re-exporting under the same name and under another. */ +/** + * ES modules: an `index.ts` re-exporting under the same name and under + * another, and modules re-exporting it in turn — an outer `index.ts`, all of + * it and one name renamed, and a module passing on one name, read through a + * namespace import — imported in its stead. + */ const TS: Files = { 'package.json': '{"name":"app"}', 'src/components/Button.ts': 'export function Button() { return 1; }\n', 'src/components/Icon.ts': 'export function BaseIcon() { return 2; }\n', 'src/components/index.ts': "export { Button } from './Button';\nexport { BaseIcon as Icon } from './Icon';\n", 'src/app.ts': "import { Button, Icon } from './components';\nexport function render() { return [Button(), Icon()]; }\n", + 'src/ui/index.ts': "export * from '../components';\nexport { Icon as UiIcon } from '../components';\n", + 'src/page.ts': "import { Button, UiIcon } from './ui';\nexport function page() { return [Button(), UiIcon()]; }\n", + 'src/kit.ts': "export { Button as KitButton } from './components';\n", + 'src/screen.ts': "import * as kit from './kit';\nexport function screen() { return kit.KitButton(); }\n", }; const TS_BARRELS = ['src/components/index.ts']; +/** + * ES modules: one name through two routes. `c.ts` re-exports `Button` from + * the barrel itself and `Btn`, the same function renamed, through `a.ts`, + * which re-exports the barrel; `d.ts` imports `Btn` from `c.ts`. + */ +const TS_TWO_ROUTES: Files = { + 'package.json': '{"name":"app"}', + 'src/core/Button.ts': 'export function Button() { return 1; }\n', + 'src/core/index.ts': "export { Button } from './Button';\n", + 'src/a.ts': "export { Button as Btn } from './core';\n", + 'src/c.ts': "export { Button } from './core';\nexport { Btn } from './a';\n", + 'src/d.ts': "import { Btn } from './c';\nexport function d() { return Btn(); }\n", +}; + let roots: string[] = []; let graphs: CodeGraph[] = []; @@ -119,6 +142,7 @@ const only = (files: Files, kept: string[]): Files => describe.each([ ['Python package and module', PYTHON, PYTHON_BARRELS], ['ES module barrel', TS, TS_BARRELS], + ['ES module reached by two routes', TS_TWO_ROUTES, ['src/core/index.ts']], ])('sync re-resolves the imports through a re-exporting file: %s', (_label, files, barrels) => { it('when it is deleted', async () => { const root = tempRoot(); diff --git a/codegraph-kernel/src/buffers.rs b/codegraph-kernel/src/buffers.rs index fb1e6e238..8e7a403fb 100644 --- a/codegraph-kernel/src/buffers.rs +++ b/codegraph-kernel/src/buffers.rs @@ -365,6 +365,9 @@ pub struct Tables { pub edge_count: u32, pub ref_count: u32, pub binding_count: u32, + /// Import nodes made for `export … from` statements: no symbols of the + /// file's own, so they do not count against a collapsed parse. + pub reexport_imports: u32, } impl Default for Tables { @@ -378,6 +381,7 @@ impl Default for Tables { edge_count: 0, ref_count: 0, binding_count: 0, + reexport_imports: 0, } } } @@ -521,6 +525,7 @@ impl EmitOut { edge_count: 0, ref_count: 0, binding_count, + reexport_imports: 0, }; EmitOut { meta: build_meta(&tables, self.arena.len() as u32, NONE_STR, duration_ms), @@ -552,7 +557,9 @@ pub fn parse_collapse_warning( .chunks(NODE_ROW_SIZE) .filter(|row| row.first().copied() != Some(file_kind)) .count(); - if symbols > 0 { + // (A re-export's import of its module is none: a barrel whose other lines + // failed to parse has lost what it passes on.) + if symbols > tables.reexport_imports as usize { return NONE_STR; } let mut path_json = String::with_capacity(file_path.len()); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 5dca64269..0b2a56328 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -475,7 +475,12 @@ impl<'t> Walker<'t> { } else if kind == "import_statement" { self.extract_import(node); } else if kind == "export_statement" && node.child_by_field_name("source").is_some() { - // Re-export: `export { X } from './y'`. + // Re-export: `export { X } from './y'`, `export * from './y'`. It + // imports the module as `import … from` does (an import node and a + // reference to the module), then links each re-exported name. + let nodes_before = self.tables.node_count; + self.extract_import(node); + self.tables.reexport_imports += self.tables.node_count - nodes_before; self.emit_re_export_refs(node); } else if kind == "export_statement" && self.looks_like_vue_store_file() { // Vuex MODULE default export (`export default { actions: {…} }`). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index dc7b717a4..818f26a07 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -593,6 +593,8 @@ export class TreeSitterExtractor { private fileScopeValueCounts = new Map(); // file-scope nodes per name (conditional-def detection) private valueRefScopes: Array<{ id: string; node: SyntaxNode; name: string }> = []; private errors: ExtractionError[] = []; + // Import nodes made for `export … from` statements: no symbols of the file's own. + private reExportImports = 0; private extractor: LanguageExtractor | null = null; private nodeStack: string[] = []; // Stack of parent node IDs private referenceKeys: Set = new Set(); @@ -756,7 +758,9 @@ export class TreeSitterExtractor { this.unresolvedReferences.push(...commonJsRequireRefs(this.filePath, this.source, this.language)); // hasError is routine for several grammars; warn only when no symbols survived. - const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length; + // (A re-export's import of its module is none: a barrel whose other lines + // failed to parse has lost what it passes on.) + const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length - this.reExportImports; if (this.tree?.rootNode.hasError && symbolCount === 0) { this.errors.push({ message: @@ -1599,13 +1603,16 @@ export class TreeSitterExtractor { else if (this.extractor.importTypes.includes(nodeType)) { this.extractImport(node); } - // Re-export from another module — `export { X } from './y'` (TS/JS). A - // re-export is a dependency on the source module just like an import, but - // the export_statement is otherwise only descended into (no declaration to - // extract), so a barrel that ONLY re-exports produced zero edges and showed - // 0 dependents. Link each re-exported name to its definition. Children are - // still visited (a non-re-export `export const X = …` has no `source` and - // falls through to its normal declaration extraction). + // Re-export from another module — `export { X } from './y'`, `export * from + // './y'` (TS/JS). A re-export is a dependency on the source module just like + // an import, but the export_statement is otherwise only descended into (no + // declaration to extract), so a barrel that ONLY re-exports produced zero + // edges and showed 0 dependents. It imports the module as `import … from` + // does — an import node and a reference to the module, which links the + // barrel to the file it names (so a barrel of barrels can be followed) — + // and links each re-exported name to its definition. Children are still + // visited (a non-re-export `export const X = …` has no `source` and falls + // through to its normal declaration extraction). else if ( nodeType === 'export_statement' && (this.language === 'typescript' || this.language === 'tsx' || @@ -1613,6 +1620,9 @@ export class TreeSitterExtractor { this.language === 'arkts') && getChildByField(node, 'source') ) { + const before = this.nodes.length; + this.extractImport(node); + this.reExportImports += this.nodes.length - before; const parentId = this.nodeStack[this.nodeStack.length - 1]; if (parentId) this.emitReExportRefs(node, parentId); } diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index b2d9aa07a..f87f98452 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -11,19 +11,17 @@ * binds them through the module when it is there, and another way (by name) * or not at all when it is not. * - * A Python module that re-exports another one (`from .shop import *`, or a - * package's `__init__.py` importing names from it) is the way in for its own - * importers, so theirs are followed too, as deep as the resolver chases a - * name through such modules. An ES module's `export … from` leaves no import - * edge or node to find it by, so an `index.ts` re-exporting another is not - * followed. + * A module that re-exports another one (`export * from './components'` in an + * `index.ts`, `from .shop import *` or a package's `__init__.py` importing + * names from it) is the way in for its own importers, so theirs are followed + * too, as deep as the resolver chases a name through such modules. */ import * as fs from 'fs'; import * as path from 'path'; import type { QueryBuilder } from '../db/queries'; -import type { Language, UnresolvedReference } from '../types'; -import type { ImportMapping } from './types'; +import type { Binding, Language, UnresolvedReference } from '../types'; +import type { ImportMapping, ReExport } from './types'; import { importMappingsFromBindings, pythonModulePath } from './import-resolver'; import { resurrectRefFromDroppedEdge } from './resurrect-ref'; @@ -37,11 +35,15 @@ const BINDING_LANGUAGES = new Set([ ]); /** - * How many re-exporting Python modules deep the importers of a file are - * followed: as deep as the resolver chases a name through star imports and - * re-exports (`pythonModuleSymbol`). + * How many re-exporting modules deep the importers of a file are followed: as + * deep as the resolver chases a name through them, three for Python's star + * imports and re-exports (`pythonModuleSymbol`), `REEXPORT_MAX_DEPTH` for + * ES-module `export … from`. */ -const PYTHON_REEXPORT_DEPTH = 3; +const reExportDepth = (language: Language): number => (language === 'python' ? 3 : REEXPORT_MAX_DEPTH); + +/** The depth the kernel's resolver follows `export … from` chains to (`REEXPORT_MAX_DEPTH` in resolve/query.rs). */ +const REEXPORT_MAX_DEPTH = 8; /** A Python star import, read the way the resolver reads one. */ const PYTHON_STAR_IMPORT = /^\s*from\s+([\w.]+)\s+import\s+\*/gm; @@ -72,12 +74,24 @@ type AddImporter = (importer: string, language: Language, source: string, module export interface ImportReader { /** Its import mappings. */ mappings(file: string, language: Language): ImportMapping[]; + /** Its ES-module `export … from` statements, which have no import mapping. */ + reExports(file: string, language: Language): ReExport[]; /** The modules a Python file star-imports, which have no import mapping. */ starImports(file: string, language: Language): string[]; } export function createImportReader(rootDir: string, queries: QueryBuilder): ImportReader { + const bindingsMemo = new Map(); + const bindingsOf = (file: string): Binding[] => { + let rows = bindingsMemo.get(file); + if (!rows) { + rows = queries.getBindingsByFile(file); + bindingsMemo.set(file, rows); + } + return rows; + }; const mappingsMemo = new Map(); + const reExportsMemo = new Map(); const starsMemo = new Map(); return { // The file's `import` binding rows, which the resolver reads its imports @@ -86,11 +100,27 @@ export function createImportReader(rootDir: string, queries: QueryBuilder): Impo mappings: (file) => { let mappings = mappingsMemo.get(file); if (!mappings) { - mappings = (importMappingsFromBindings(queries.getBindingsByFile(file)) ?? []).filter((m) => m.localName !== '*'); + mappings = (importMappingsFromBindings(bindingsOf(file)) ?? []).filter((m) => m.localName !== '*'); mappingsMemo.set(file, mappings); } return mappings; }, + // The file's `reexport` binding rows: `export … from` leaves one per name + // (`*` for `export * from`, renamed through `exportedAs`). + reExports: (file) => { + let reExports = reExportsMemo.get(file); + if (!reExports) { + reExports = []; + for (const r of bindingsOf(file)) { + if (r.kind !== 'reexport' || !r.targetSpec) continue; + if (r.name !== '*') reExports.push({ kind: 'named', exportedName: r.exportedAs ?? r.name, originalName: r.name, source: r.targetSpec }); + else if (r.exportedAs && r.exportedAs !== '*') reExports.push({ kind: 'namespace', exportedName: r.exportedAs, source: r.targetSpec }); + else reExports.push({ kind: 'wildcard', source: r.targetSpec }); + } + reExportsMemo.set(file, reExports); + } + return reExports; + }, starImports: (file, language) => { if (language !== 'python') return []; let stars = starsMemo.get(file); @@ -160,7 +190,11 @@ function importersOf( ): Importers { const importers: Importers = new Map(); const changed = new Set(files.map((file) => file.path)); - const seen = new Set(changed); + // The names each re-exporting importer has been followed with so far (null: + // all of them). A module reached again by another path is followed again + // for the names that path adds, so one passing a name on under two routes + // loses neither. + const followed = new Map | null>(); let level: Module[] = files.filter((file) => BINDING_LANGUAGES.has(file.language)).map((file) => ({ ...file, passes: null })); for (let depth = 1; level.length > 0; depth++) { // Per importer found here, its statements naming each module of the level. @@ -185,15 +219,18 @@ function importersOf( } const python = level.filter((module) => module.language === 'python'); if (python.length > 0) addPythonImporters(queries, python, reader, add); - // The next level: the Python importers that pass on what they import from these. - if (depth > PYTHON_REEXPORT_DEPTH) break; + // The next level: the importers that pass on what they import from these. const next: Module[] = []; for (const [importer, { language, statements }] of naming) { - if (language !== 'python' || seen.has(importer)) continue; - const passes = pythonPassedOn(importer, statements, reader); + if (depth > reExportDepth(language)) continue; + const passes = passedOn(importer, language, statements, reader); if (passes === undefined) continue; - seen.add(importer); - next.push({ path: importer, language, passes }); + const before = followed.get(importer); + if (before === null) continue; + const added = before === undefined || passes === null ? passes : new Set([...passes].filter((name) => !before.has(name))); + if (added !== null && added.size === 0) continue; + followed.set(importer, before === undefined || passes === null ? passes : new Set([...before, ...passes])); + next.push({ path: importer, language, passes: added }); } level = next; } @@ -201,12 +238,14 @@ function importersOf( } /** - * What the Python module `importer` passes on of what its `statements` - * import, or undefined when the resolver reads no name through it: what it - * star-imports, and for a package's `__init__.py`, the names it imports too. + * What `importer` passes on of what its `statements` import, or undefined + * when the resolver reads no name through it: an ES module what it `export … + * from`s; a Python module what it star-imports, and a package's `__init__.py` + * the names it imports too. */ -function pythonPassedOn( +function passedOn( importer: string, + language: Language, statements: ReadonlyArray<{ source: string; module: Module; onlyName?: string }>, reader: ImportReader, ): Set | null | undefined { @@ -214,15 +253,25 @@ function pythonPassedOn( const pass = (names: Set | null): void => { passes = passes === null || names === null ? null : new Set([...(passes ?? []), ...names]); }; - const stars = new Set(reader.starImports(importer, 'python')); + const passing = (module: Module, name: string): boolean => module.passes === null || module.passes.has(name); + if (language !== 'python') { + for (const rex of reader.reExports(importer, language)) { + for (const { source, module } of statements) { + if (source !== rex.source) continue; + if (rex.kind === 'wildcard') pass(module.passes); + else if (rex.kind === 'namespace') pass(new Set([rex.exportedName])); + else if (passing(module, rex.originalName)) pass(new Set([rex.exportedName])); + } + } + return passes; + } + const stars = new Set(reader.starImports(importer, language)); const isPackage = importer === '__init__.py' || importer.endsWith('/__init__.py'); for (const { source, module, onlyName } of statements) { if (onlyName === undefined && stars.has(source)) pass(module.passes); if (!isPackage) continue; - const names = reader.mappings(importer, 'python') - .filter((m) => m.source === source && (onlyName !== undefined - ? m.exportedName === onlyName - : module.passes === null || module.passes.has(m.exportedName))) + const names = reader.mappings(importer, language) + .filter((m) => m.source === source && (onlyName !== undefined ? m.exportedName === onlyName : passing(module, m.exportedName))) .map((m) => m.localName); if (names.length > 0) pass(new Set(names)); } @@ -296,7 +345,8 @@ function addPythonImporters( /** * Per importer, what its statements naming the modules import: the modules - * named whole (not the package around one), and the local names bound. + * named whole (not the package around one), and the local names bound. An ES + * namespace import (`import * as z`) reads whatever its module passes on. */ function bindingsOf(importers: Importers, reader: ImportReader): Map { const bindings = new Map(); @@ -305,7 +355,9 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map { const only = sources.get(m.source); - return only === null || (only !== undefined && only.has(m.exportedName)); + if (only === null) return true; + if (only === undefined) return false; + return only.has(m.exportedName) || (m.isNamespace && language !== 'python' && only.size > 0); }) .map((m) => m.localName) );