From 95f6f0f4a576272b54fcc36d3f879d915a62069b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 20:32:07 +0000 Subject: [PATCH 1/7] fix(viewer): a directed Flow looks up both ends by their exact names (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flow strip's from/to were run through explore's free-text token filter, which drops an Objective-C selector (`initWithFrame:`), a Ruby predicate (`valid?`) and any name under three letters (`ok`) — then reported the OTHER end as naming nothing in the index. Directed mode now takes the two names as written; explore's named mode is unchanged. Co-authored-by: Claude Opus 5.5 --- __tests__/flow-directed-exact-names.test.ts | 66 +++++++++++++++++++++ docs/viewer-launch-changelog.md | 2 + src/graph/named-symbol-flow.ts | 9 ++- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 __tests__/flow-directed-exact-names.test.ts diff --git a/__tests__/flow-directed-exact-names.test.ts b/__tests__/flow-directed-exact-names.test.ts new file mode 100644 index 0000000000..6636c837fa --- /dev/null +++ b/__tests__/flow-directed-exact-names.test.ts @@ -0,0 +1,66 @@ +/** + * A directed Flow question ("how does X reach Y") names its two ends exactly, + * as the viewer's links write them — a symbol's own name. Names the free-text + * token filter drops were lost: an Objective-C selector (`initWithFrame:`), a + * Ruby predicate (`valid?`), a two-letter method (`ok`). The other end was + * then reported as naming nothing, though it was in the index. + */ +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 { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flow-exact-')); + const files: Record = { + 'src/check.ts': `export function ok(): number { + return go(); +} + +export function go(): number { + return 1; +} +`, + 'app/models/order.rb': `class Order + def save! + raise ArgumentError unless valid? + end + + def valid? + true + end +end +`, + }; + 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 directed = (from: string, to: string) => + resolveNamedSymbolFlow(cg, `${from} ${to}`, { mode: 'directed', from, to }); + +describe('a directed flow question', () => { + it('reaches a two-letter name', () => { + const flow = directed('ok', 'go'); + expect(flow.chains[0]?.steps.map((s) => s.node.name)).toEqual(['ok', 'go']); + }); + + it('reaches a name with punctuation', () => { + const flow = directed('save!', 'valid?'); + expect(flow.tokens).toEqual(['save!', 'valid?']); + expect(flow.chains[0]?.steps.map((s) => s.node.name)).toEqual(['save!', 'valid?']); + }); +}); diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 8d2864db25..20e2fee083 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -171,6 +171,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel ## Fixes — Symbols, tests and the viewer +- **A Flow between two symbols finds names of any shape.** A Flow opened from a Steps link, an entry point or the search box names its two ends by the symbols' own names, and names that don't look like plain identifiers were dropped: an Objective-C selector like `initWithFrame:`, a Ruby method like `save!` or `valid?`, and any name shorter than three letters, like `ok`. The strip then said the other end "names nothing in this index", even though it was there. Both ends are now looked up exactly as written. + - **Entry points and the Symbol view no longer go blank.** The Symbol view of every Vue, Svelte and Astro component stayed on "Loading…", because its member list held the component's script symbols twice, along with the component and its file. Entry points went blank on apps with inline route handlers like `app.get('/x', async (c) => { … })`, which came back once per call in the handler, each call listed as if it were the handler. Each member is now listed once, and each route is one row: the handler it is bound to, or "inline handler" when the handler is written at the registration. - **Entry points' most-depended-on list shows your code, not a vendored bundle's.** A minified library or bundled docs script (`n`, `t`, `Buffer` with thousands of callers, all from inside the bundle) could top the list. Symbols in generated files are now left out of it, like test files. diff --git a/src/graph/named-symbol-flow.ts b/src/graph/named-symbol-flow.ts index a83edf2d82..1652a2c6da 100644 --- a/src/graph/named-symbol-flow.ts +++ b/src/graph/named-symbol-flow.ts @@ -287,7 +287,14 @@ export function resolveNamedTokens( ): NamedSymbolFlow { const directed = opts.mode === 'directed'; const out = EMPTY_FLOW(); - const tokens = flowTokens(query); + // A directed question names its two ends exactly — a symbol's own name, as + // the viewer's links write it: an Objective-C selector (`initWithFrame:`), a + // Ruby predicate (`valid?`), a two-letter method (`ok`, Go's `Do`). The + // free-text filter below is for explore's bag of words, and it dropped every + // one of those, leaving the other end reported as naming nothing. + const tokens = directed && opts.from && opts.to + ? [...new Set([normalizeToken(opts.from), normalizeToken(opts.to)])] + : flowTokens(query); out.tokens = tokens; if (tokens.length < 2) return out; From c4babfce6afbd5b95ea10ddd9c4ccddb5910b3b7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 20:37:37 +0000 Subject: [PATCH 2/7] fix(js): a CommonJS require and a module path import the file they name (#2263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(js): a CommonJS require and a module path import the file they name A `require('./application')` was only a call, so a CommonJS file depended on nothing at file level: Express's lib/express.js showed no imports and impact missed the files it loads. Local require specifiers are now read after extraction (both the wasm extractor and the kernel, including its deferred-decode transport) as `imports` refs from the file, and a JS/TS module path resolves to its file the way the runtime finds it — extensions, index files, aliases — which also lands an extensionless `import './polyfills'` on its file instead of its own import node. Co-Authored-By: Claude Opus 5.5 * fix(js): read require imports inside each extractor, so kernel parity holds Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/commonjs-require-imports.test.ts | 103 +++++++++++++++++++++ src/extraction/commonjs-requires.ts | 68 ++++++++++++++ src/extraction/index.ts | 1 + src/extraction/kernel/index.ts | 5 + src/extraction/parse-worker.ts | 4 +- src/extraction/store-worker.ts | 1 + src/extraction/store-writer.ts | 2 + src/extraction/tree-sitter.ts | 5 + src/resolution/import-resolver.ts | 31 +++++++ src/resolution/index.ts | 8 +- 11 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 __tests__/commonjs-require-imports.test.ts create mode 100644 src/extraction/commonjs-requires.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ce9fd79f..ded85aa294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In JavaScript and TypeScript, a CommonJS `require('./application')` now counts as an import of that file, the way an ESM `import` does. A module path also resolves the way Node and bundlers find it: extensions, `index` files and path aliases. Before, a CommonJS file depended on nothing at file level, so Express's own `lib/express.js` showed no imports and impact analysis missed the files it loads. An extensionless side-effect import like `import './polyfills'` also stopped at its own import statement instead of reaching the file. - An Express route whose handler is written inline as `function (req, res) {…}`, the form Express's own examples use, now links to what that handler calls, just like an arrow handler does. The handler is the route's last argument, so an inline middleware before a named handler no longer stands in for it, and a wrapper like `asyncHandler(async function (req, res) {…})` and a trailing comma both work. Before, a route such as `POST /login` linked to nothing. - A Python module member used through the imported module, like `generic.ObjectEditView` after `from netbox.views import generic`, `serializers.ValidationError` or `utils.assert_deserialize(…)`, now also follows the package's re-exports to its definition. Before, netbox's view classes lost their `generic.*` base classes, and mealie's test helpers called through `utils.` went unresolved. - A Python name imported from a package now follows the package's re-exports to where it's defined. That covers both `from .users import *` and `from .tokens import Token` in its `__init__.py`. Before, httpx's own tests' `httpx.Client(…)` and `httpx.URL(…)`, pytest's `pytest.raises(…)` and `pytest.param(…)`, and netbox's `from users.models import User` went unresolved or to a same-named class elsewhere, such as a test's `User` or an Alembic migration's copy of a model. diff --git a/__tests__/commonjs-require-imports.test.ts b/__tests__/commonjs-require-imports.test.ts new file mode 100644 index 0000000000..f09f325289 --- /dev/null +++ b/__tests__/commonjs-require-imports.test.ts @@ -0,0 +1,103 @@ +/** + * A CommonJS `require('./x')` is a file import, as ESM's `import` is: Express's + * `lib/express.js` requires `./application`, `./request` and `./response`, and + * the index said it imported nothing — an empty Imports rail in the viewer and + * no file dependency — because those bindings are used as values, never + * called. A module path also resolves the way the runtime finds it, so an + * extensionless ESM side-effect import (`import './polyfills'`) reaches its + * file instead of stopping at its own import statement. A package + * (`require('express')`) stays outside the index. + */ +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-cjs-require-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'web', main: 'index.js' }), + 'index.js': `module.exports = require('./lib/express'); +`, + 'lib/express.js': `var EventEmitter = require('events').EventEmitter; +var mixin = require('merge-descriptors'); +var proto = require('./application'); +var req = require('./request'); + +function createApplication() { + var app = function () {}; + mixin(app, EventEmitter.prototype, false); + mixin(app, proto, false); + app.request = Object.create(req); + return app; +} + +module.exports = createApplication; +`, + 'lib/application.js': `var app = exports = module.exports = {}; +app.init = function init() {}; +`, + 'lib/request.js': `var req = Object.create({}); +module.exports = req; +`, + 'test/app.js': `var express = require('..'); +var assert = require("assert"); +`, + 'src/main.ts': `import './polyfills'; +import helper = require('./helper'); +export const value = helper.run(); +`, + 'src/polyfills.ts': `export {}; +`, + 'src/helper.ts': `export function run(): number { + return 1; +} +`, + }; + 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 importedFiles = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return [...new Set(cg.getOutgoingEdgesFrom(ids, ['imports']) + .map((e) => cg.getNode(e.target)!.filePath) + .filter((p) => p !== file))].sort(); +}; + +describe('CommonJS require', () => { + it('imports the files a module requires, used as values or not, and no package', () => { + expect(importedFiles('lib/express.js')).toEqual(['lib/application.js', 'lib/request.js']); + expect(cg.getFileDependencies('lib/express.js')).toEqual(expect.arrayContaining(['lib/application.js', 'lib/request.js'])); + }); + + it('follows a directory require to its package entry', () => { + expect(importedFiles('index.js')).toEqual(['lib/express.js']); + expect(importedFiles('test/app.js')).toEqual(['index.js']); + }); +}); + +describe('a module path', () => { + it('reaches an extensionless side-effect import’s file', () => { + expect(importedFiles('src/main.ts')).toContain('src/polyfills.ts'); + }); + + it('reaches a TypeScript import-equals file once', () => { + const ids = cg.getNodesInFile('src/main.ts').map((n) => n.id); + const toHelper = cg.getOutgoingEdgesFrom(ids, ['imports']) + .filter((e) => cg.getNode(e.target)!.filePath === 'src/helper.ts' && cg.getNode(e.target)!.kind === 'file'); + expect(toHelper.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/extraction/commonjs-requires.ts b/src/extraction/commonjs-requires.ts new file mode 100644 index 0000000000..436a03385c --- /dev/null +++ b/src/extraction/commonjs-requires.ts @@ -0,0 +1,68 @@ +/** + * CommonJS `require('…')` as an import. + * + * An ESM `import … from './application'` is an `import_statement` the + * extractor turns into an `imports` reference from the file, which the import + * resolver lands on the module's file. A `require('./application')` is just a + * call, so a CommonJS file depended on nothing at file level: Express's + * `lib/express.js` requires `./application`, `./request` and `./response` and + * the index said it imports nothing — an empty Imports rail in the viewer, no + * link on the Map, and no file dependency for impact. A binding used only as a + * value (`mixin(app, proto)`) never made a call edge to stand in for it either. + * + * Read from the source after extraction rather than in a walker, so the wasm + * extractor and the native kernel get it alike — including the kernel's + * deferred-decode transport, which carries these beside its buffers. Only the + * module's own file reference is emitted — the names a binding is called + * through already resolve through the require binding (import-resolver's + * CommonJS reader). + */ + +import type { Language, UnresolvedReference } from '../types'; +import { stripCommentsForRegex } from '../resolution/strip-comments'; + +const COMMONJS_LANGUAGES: ReadonlySet = new Set(['javascript', 'jsx', 'typescript', 'tsx']); + +/** + * `require('x')` with a literal specifier, not a member's (`foo.require(…)`) + * or a property (`require.resolve(…)`). A template literal counts only when + * it has no `${…}` hole. + */ +const REQUIRE_CALL = /(?`. */ +export function commonJsRequireRefs(filePath: string, source: string, language: Language): UnresolvedReference[] { + if (!COMMONJS_LANGUAGES.has(language) || !source.includes('require')) return []; + const safe = stripCommentsForRegex(source, language === 'javascript' || language === 'jsx' ? 'javascript' : 'typescript'); + const out: UnresolvedReference[] = []; + const seen = new Set(); + REQUIRE_CALL.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = REQUIRE_CALL.exec(safe)) !== null) { + const specifier = (m[1] ?? m[2] ?? m[3] ?? '').trim(); + if (!LOCAL_SPECIFIER.test(specifier) || seen.has(specifier)) continue; + const lineStart = safe.lastIndexOf('\n', m.index - 1) + 1; + if (IMPORT_EQUALS.test(safe.slice(lineStart, m.index))) continue; + seen.add(specifier); + out.push({ + fromNodeId: `file:${filePath}`, + referenceName: specifier, + referenceKind: 'imports', + line: safe.slice(0, m.index).split('\n').length, + column: m.index - lineStart, + filePath, + language, + }); + } + return out; +} diff --git a/src/extraction/index.ts b/src/extraction/index.ts index e71ffabe6b..80016bb59c 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -2229,6 +2229,7 @@ export class ExtractionOrchestrator { language, buffers: result.kernelBuffers, file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors), + ...(result.unresolvedReferences.length > 0 ? { extraRefs: result.unresolvedReferences } : {}), }); } else { storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result)); diff --git a/src/extraction/kernel/index.ts b/src/extraction/kernel/index.ts index c4337a0912..1e71d170c3 100644 --- a/src/extraction/kernel/index.ts +++ b/src/extraction/kernel/index.ts @@ -15,6 +15,7 @@ */ import type { ExtractionResult, Language } from '../../types'; +import { commonJsRequireRefs } from '../commonjs-requires'; import { EXTRACTORS } from '../languages'; import { getKernel, kernelSupports } from './loader'; import { decodeExtractBuffers } from './decode'; @@ -269,6 +270,8 @@ export function materializeKernelResult( language ); decoded.durationMs = result.durationMs; + // References read beside the tables (a CommonJS `require`) ride on the transport. + if (result.unresolvedReferences.length > 0) decoded.unresolvedReferences.push(...result.unresolvedReferences); return decoded; } @@ -294,6 +297,8 @@ export function tryKernelExtract( const buffers = kernel.extractFile(filePath, pre, language); const result = decodeExtractBuffers(buffers, filePath, language); POST_PASSES[language]?.(result, source); + // Read beside the tables, as TreeSitterExtractor does (a CommonJS `require`). + result.unresolvedReferences.push(...commonJsRequireRefs(filePath, source, language)); result.durationMs = Date.now() - t0; return result; } catch (err) { diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts index b5be212b39..e1ec6bc281 100644 --- a/src/extraction/parse-worker.ts +++ b/src/extraction/parse-worker.ts @@ -17,6 +17,7 @@ import { parentPort } from 'worker_threads'; import { extractFromSource } from './tree-sitter'; import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars'; import { tryKernelExtractRaw } from './kernel'; +import { commonJsRequireRefs } from './commonjs-requires'; import { getAllFrameworkResolvers, getApplicableFrameworks } from '../resolution/frameworks'; import type { Language, ExtractionResult } from '../types'; @@ -103,7 +104,8 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st result = { nodes: [], edges: [], - unresolvedReferences: [], + // Read beside the kernel's tables (extractFromSource's own post-step). + unresolvedReferences: commonJsRequireRefs(filePath!, content!, language), errors: raw.errors, durationMs: 0, kernelBuffers: raw.buffers, diff --git a/src/extraction/store-worker.ts b/src/extraction/store-worker.ts index 6493e0040e..a562f557af 100644 --- a/src/extraction/store-worker.ts +++ b/src/extraction/store-worker.ts @@ -70,6 +70,7 @@ function decodeKernelBundle(bundle: KernelStoreBundle): StoreBundle { bundle.filePath, bundle.language ); + if (bundle.extraRefs?.length) decoded.unresolvedReferences.push(...bundle.extraRefs); return finalizeStoreBundle(decoded, bundle.filePath, bundle.language, bundle.file); } diff --git a/src/extraction/store-writer.ts b/src/extraction/store-writer.ts index a766bd813b..683f09117e 100644 --- a/src/extraction/store-writer.ts +++ b/src/extraction/store-writer.ts @@ -30,6 +30,8 @@ export interface KernelStoreBundle { language: Language; buffers: NonNullable; file: FileRecord; + /** References read beside the kernel's tables (a CommonJS `require`). */ + extraRefs?: ExtractionResult['unresolvedReferences']; } /** diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7c9baa69d0..5c27523acc 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -32,6 +32,7 @@ import { VueExtractor } from './vue-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; import { CfmlExtractor } from './cfml-extractor'; import { tryKernelExtract, takeDeferredPreParse } from './kernel'; +import { commonJsRequireRefs } from './commonjs-requires'; import { getAllFrameworkResolvers, getApplicableFrameworks, @@ -657,6 +658,10 @@ export class TreeSitterExtractor { if (packageNodeId) this.nodeStack.pop(); this.nodeStack.pop(); + // A CommonJS `require('./x')` is a file import, like ESM's `import`. The + // kernel reads the same (kernel/index.ts and the parse worker's transport). + 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; if (this.tree?.rootNode.hasError && symbolCount === 0) { diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index d1ca2c1fe0..3c908fdd07 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1529,10 +1529,41 @@ export function resolvePhpImportedStaticCall( return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' }; } +const JS_MODULE_LANGUAGES: ReadonlySet = new Set(['javascript', 'jsx', 'typescript', 'tsx', 'arkts']); + +/** + * A module specifier written as a path — relative (`./x`, `../x`), rooted, or + * an alias with a folder in it (`@/lib/x`, `~/x`, `$lib/x`) — rather than an + * imported binding's name or a bare package (`react`, `lodash`). + */ +function isJsPathSpecifier(name: string): boolean { + return name.startsWith('./') || name.startsWith('../') || name === '.' || name === '..' || + (name.includes('/') && !/\s/.test(name) && !/^@[\w.-]+\/[\w.-]+$/.test(name)); +} + +/** + * A JS/TS `imports` reference that names a module by path. It names a FILE, not + * a symbol, so it skips the resolver's name-exists pre-filter — a CommonJS + * `require('./x')` has no import node of that name to pass it. + */ +export function isJsPathImportRef(ref: UnresolvedRef): boolean { + return ref.referenceKind === 'imports' && JS_MODULE_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName); +} + export function resolveViaImport( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + // A JS/TS module specifier — `import './polyfills'`, the module of `import x + // from '../lib/a'`, a CommonJS `require('./application')` — names a FILE, + // found the way the runtime finds it: extensions, `index` files, path + // aliases. Matching the basename instead missed every extensionless one + // (the common spelling) and could land on a same-named file elsewhere. + if (isJsPathImportRef(ref)) { + const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); + const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined; + if (fileNode) return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' }; + } // C/C++ #include references — resolve directly to the included file // (file→file edge), bypassing symbol lookup. The extractor emits these // with `referenceKind: 'imports'` and `referenceName: ` diff --git a/src/resolution/index.ts b/src/resolution/index.ts index d05bc41a4a..e12d24979c 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -26,7 +26,7 @@ import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibili import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; -import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; +import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; import { detectFrameworks } from './frameworks'; @@ -1100,8 +1100,9 @@ export class ReferenceResolver { // ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that // routes them to the decorator-gated matcher; the symbol itself is // indexed under the bare name, so the existence check strips the dot. - // Nix static path imports (`import ./x.nix`) name a FILE, not a symbol — - // they bypass the symbol-existence check and resolve via resolveViaImport. + // Nix static path imports (`import ./x.nix`) and JS/TS module paths + // (`require('./x')`) name a FILE, not a symbol — they bypass the + // symbol-existence check and resolve via resolveViaImport. let existenceName = ref.language === 'arkts' && ref.referenceName.startsWith('.') ? ref.referenceName.slice(1) @@ -1112,6 +1113,7 @@ export class ReferenceResolver { const tPre = this.profileStages ? process.hrtime.bigint() : 0n; const preFilterPass = isNixPathImportRef(ref) || + isJsPathImportRef(ref) || this.hasAnyPossibleMatch(existenceName) || // PHP, Pascal, CFML, COBOL and VB.NET names ignore case: `formatprice()` // calls `FormatPrice`, which the exact-name set never lists. From d6a2bdb0d6efc74eec35ccc49bbf5eaf56d05cf1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 21:24:06 +0000 Subject: [PATCH 3/7] fix(sfc): a Vue/Svelte/Astro component owns its script (#2268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-file-component extractors kept the delegated script's file node and made the component contain it and every node, so the hierarchy ran file-inside-component, members had two parents, and a file with no script had no file node. And a per-instance script's top-level calls stayed on the file, so a walk from the component found nothing. Now: one file node (the whole file) holds the component; the component holds what the script holds at top level, nested symbols keep their parent; +`); + const file = r.nodes.find((n) => n.kind === 'file')!; + const component = componentOf(r); + expect(file).toMatchObject({ id: 'file:pages/index.vue', startLine: 1, endLine: 9 }); + const p = parents(r); + expect(p.get(component.id)).toEqual([file.id]); + expect(p.get(file.id)).toBeUndefined(); + const load = r.nodes.find((n) => n.name === 'load')!; + expect(p.get(load.id)).toEqual([component.id]); + for (const [, list] of p) expect(list).toHaveLength(1); + }); + + it('gives a +`); + expect(from(r, 'useFetch').filter((id) => id.startsWith('component:'))).toHaveLength(1); + expect(r.unresolvedReferences.filter((ref) => ref.referenceKind === 'imports').every((ref) => ref.fromNodeId === 'file:pages/index.vue')).toBe(true); + }); + + it('leaves a plain +`); + expect(from(r, 'registerWidget')).toEqual(['file:src/Widget.vue']); + }); + + it('gives a Svelte instance script to the component, and a module script to the file', () => { + const r = extractFromSource('src/routes/+page.svelte', ` + + +

items

+`); + const component = componentOf(r).id; + expect(from(r, 'onMount')).toEqual([component]); + expect(from(r, 'preload')).toEqual(['file:src/routes/+page.svelte']); + expect(from(r, 'register')).toEqual(['file:src/routes/+page.svelte']); + }); + + it('gives Astro frontmatter to the component, and makes a file node for a file without one', () => { + const r = extractFromSource('src/pages/blog.astro', `--- +const posts = await getCollection('blog'); +--- +
    {posts.map((p) =>
  • {p.title}
  • )}
+`); + expect(from(r, 'getCollection')).toEqual([componentOf(r).id]); + const bare = extractFromSource('src/pages/about.astro', `

about

\n`); + expect(bare.nodes.map((n) => n.kind).sort()).toEqual(['component', 'file']); + }); +}); diff --git a/src/extraction/astro-extractor.ts b/src/extraction/astro-extractor.ts index e38989375f..efdc33f795 100644 --- a/src/extraction/astro-extractor.ts +++ b/src/extraction/astro-extractor.ts @@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } fr import { generateNodeId } from './tree-sitter-helpers'; import { TreeSitterExtractor } from './tree-sitter'; import { isLanguageSupported } from './grammars'; +import { foldScriptResult, sfcFileNode } from './sfc-script'; /** * Astro built-in components — compiler-provided (``) or shipped by @@ -45,8 +46,10 @@ export class AstroExtractor { const startTime = Date.now(); try { - // Create component node for the .astro file itself + // The file, holding the component the .astro file is + this.nodes.push(sfcFileNode(this.filePath, this.source, 'astro')); const componentNode = this.createComponentNode(); + this.edges.push({ source: `file:${this.filePath}`, target: componentNode.id, kind: 'contains' }); // Extract and process the frontmatter block (--- fenced, TypeScript) const frontmatter = this.extractFrontmatter(); @@ -199,45 +202,13 @@ export class AstroExtractor { const extractor = new TreeSitterExtractor(this.filePath, block.content, 'typescript'); const result = extractor.extract(); - // Offset line numbers from the block back to .astro file positions - for (const node of result.nodes) { - node.startLine += block.startLine; - node.endLine += block.startLine; - node.language = 'astro'; // Mark as astro, not TS - - this.nodes.push(node); - - // Add containment edge from component to this node - this.edges.push({ - source: componentNodeId, - target: node.id, - kind: 'contains', - }); - } - - // Offset edges (they reference line numbers) - for (const edge of result.edges) { - if (edge.line) { - edge.line += block.startLine; - } - this.edges.push(edge); - } - - // Offset unresolved references - for (const ref of result.unresolvedReferences) { - ref.line += block.startLine; - ref.filePath = this.filePath; - ref.language = 'astro'; - this.unresolvedReferences.push(ref); - } - - // Carry over errors - for (const error of result.errors) { - if (error.line) { - error.line += block.startLine; - } - this.errors.push(error); - } + // Frontmatter runs on every render of the component, and a +`, + 'pages/admin.vue': ` +`, + 'pages/orders/index.vue': ` +`, + 'components/index.vue': ` +`, + }, + astro: { + 'package.json': JSON.stringify({ name: 'site', private: true, dependencies: { astro: '^4.0.0' } }), + 'src/pages/index.astro': `--- +const title = 'Home'; +--- +

{title}

+`, + 'src/pages/about.astro': `

about

+`, + 'src/pages/blog/index.astro': `

blog

+`, + 'src/pages/api/hello.ts': `export const GET = async () => new Response('hi'); +export async function POST() { return new Response('ok'); } +`, + }, +}; + +const graphs: Record = {}; + +beforeAll(async () => { + for (const [name, files] of Object.entries(projects)) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `cg-page-${name}-`)); + 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); + } + graphs[name] = { root, cg: await CodeGraph.init(root, { index: true }) }; + } +}); + +afterAll(() => { + for (const { root, cg } of Object.values(graphs)) { + cg.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +/** route name → the files of what it calls or references. */ +function served(cg: CodeGraph): Record { + const out: Record = {}; + for (const route of cg.getNodesByKind('route')) { + out[route.name] = cg.getOutgoingEdgesFrom([route.id], ['calls', 'references']) + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.kind} ${n.filePath}`); + } + return out; +} + +describe('a Nuxt page', () => { + it('is served by its own file’s component', () => { + const map = served(graphs.nuxt!.cg); + expect(map['/']).toEqual(['component pages/index.vue']); + expect(map['/admin']).toEqual(['component pages/admin.vue']); + expect(map['/orders']).toEqual(['component pages/orders/index.vue']); + }); + + it('is listed with the component that serves it', () => { + const { entries } = buildRoutes(graphs.nuxt!.cg, new URLSearchParams()); + expect(entries.map((e) => `${e.url} ${e.handler} ${e.file}`).sort()).toEqual([ + '/ index pages/index.vue', + '/admin admin pages/admin.vue', + '/orders index pages/orders/index.vue', + ]); + }); +}); + +describe('an Astro page', () => { + it('is served by its own file’s component, and an endpoint by the verbs it exports', () => { + const map = served(graphs.astro!.cg); + expect(map['/']).toEqual(['component src/pages/index.astro']); + expect(map['/about']).toEqual(['component src/pages/about.astro']); + expect(map['/blog']).toEqual(['component src/pages/blog/index.astro']); + expect(map['/api/hello']).toEqual(['function src/pages/api/hello.ts', 'function src/pages/api/hello.ts']); + }); +}); diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 7376e5517c..71b2a2e10d 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -125,6 +125,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel ## Fixes — Screens, links and navigation +- **Routes served by a component are listed.** Entry points and the Steps picker listed a route only when a function, method or class served it. A Vue Router, Nuxt, Svelte or Astro screen is served by a component, so those apps' routes weren't listed at all. They are now, each with the component that serves it. + - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. - **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller. diff --git a/src/db/queries.ts b/src/db/queries.ts index 443b851372..655f98a981 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1204,7 +1204,8 @@ export class QueryBuilder { if (!this.stmts.getRoutingManifest) { // Edge kind varies across framework resolvers: Spring/Rails/ // Laravel/Drupal emit `references`, Express emits `calls`. Accept - // both — the semantic is the same (route → its handler). + // both — the semantic is the same (route → its handler). A screen in + // a Vue / Svelte / Astro app is served by a `component`. this.stmts.getRoutingManifest = this.db.prepare(` SELECT r.name AS url, @@ -1220,7 +1221,7 @@ export class QueryBuilder { JOIN nodes h ON e.target = h.id WHERE r.kind = 'route' AND e.kind IN ('references', 'calls') - AND h.kind IN ('function', 'method', 'class', 'constant', 'variable') + AND h.kind IN ('function', 'method', 'class', 'constant', 'variable', 'component') ORDER BY r.file_path, r.start_line LIMIT ? `); diff --git a/src/resolution/frameworks/astro.ts b/src/resolution/frameworks/astro.ts index 4645335bad..635d19e971 100644 --- a/src/resolution/frameworks/astro.ts +++ b/src/resolution/frameworks/astro.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { pageComponentRef, resolvePageComponent } from './page-component'; /** * Astro virtual module prefixes — framework-provided, not user code @@ -47,6 +48,10 @@ export const astroResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // A page route names the component its file is. + const page = resolvePageComponent(ref, context); + if (page) return page; + // Pattern 1: the `Astro` global (Astro.props, Astro.url, Astro.params, …) // — runtime-provided in every component's frontmatter. Resolving it as // framework-provided keeps it from name-matching a user symbol named Astro. @@ -95,8 +100,9 @@ export const astroResolver: FrameworkResolver = { return null; }, - extract(filePath: string, _content: string) { + extract(filePath: string, content: string) { const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); // Normalize to forward slashes @@ -118,8 +124,9 @@ export const astroResolver: FrameworkResolver = { !/\.config\.[a-z]+$/.test(base) ) { const routePath = filePathToAstroRoute(afterPages); - - nodes.push({ + const isPage = normalized.endsWith('.astro'); + const language = isPage ? 'astro' : 'typescript'; + const route: Node = { id: `route:${filePath}:${routePath}:1`, kind: 'route', name: routePath, @@ -129,16 +136,31 @@ export const astroResolver: FrameworkResolver = { endLine: 1, startColumn: 0, endColumn: 0, - language: normalized.endsWith('.astro') ? 'astro' : 'typescript', + language, updatedAt: now, - }); + }; + nodes.push(route); + if (isPage) { + references.push(pageComponentRef(route, '.astro', 'astro')); + } else { + // An endpoint is served by the verbs it exports: `export const GET: + // APIRoute = …`, `export async function POST(…)`. + for (const m of content.matchAll(ENDPOINT_EXPORT)) { + const verb = m[1] ?? m[2]!; + const line = content.slice(0, m.index).split('\n').length; + references.push({ fromNodeId: route.id, referenceName: verb, referenceKind: 'references', line, column: 0, filePath, language, candidates: [verb] }); + } + } } } - return { nodes, references: [] }; + return { nodes, references }; }, }; +/** An Astro endpoint's exported handler: `export const GET`, `export async function POST`. */ +const ENDPOINT_EXPORT = /^\s*export\s+(?:(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|ALL)\b|const\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|ALL)\b)/gm; + /** * Check if string is PascalCase */ diff --git a/src/resolution/frameworks/page-component.ts b/src/resolution/frameworks/page-component.ts new file mode 100644 index 0000000000..38d311b113 --- /dev/null +++ b/src/resolution/frameworks/page-component.ts @@ -0,0 +1,44 @@ +/** + * A file-routed page — Nuxt's `pages/admin.vue`, Astro's `src/pages/about.astro` + * — is served by the component its file IS: the single-file-component + * extractor's one component per file, named after the file. Without the link a + * page route stood alone, so the viewer's Steps tab drew nothing for a page and + * the routes list never named what serves it. + * + * The route says so with a `calls` reference by that name, the way a Next.js + * page names its default export, and {@link resolvePageComponent} lands it on + * the SAME file's component — never on a same-named one elsewhere, since every + * `index.vue` is a component named `index`. + */ + +import type { Language, Node } from '../../types'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from '../types'; + +/** The component name a single-file-component extractor gives a file: its basename, extension off. */ +export function pageComponentName(filePath: string, extension: string): string { + const base = filePath.split(/[/\\]/).pop() ?? filePath; + return base.endsWith(extension) ? base.slice(0, -extension.length) : base; +} + +export function pageComponentRef(route: Node, extension: string, language: Language): UnresolvedRef { + const name = pageComponentName(route.filePath, extension); + return { + fromNodeId: route.id, + referenceName: name, + referenceKind: 'calls', + line: 1, + column: 0, + filePath: route.filePath, + language, + candidates: [name], + }; +} + +/** A page route's reference to its file's own component, or null when this is not one. */ +export function resolvePageComponent(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'calls' || !ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) return null; + const component = context + .getNodesInFile(ref.filePath) + .find((n) => n.kind === 'component' && n.name === ref.referenceName); + return component ? { original: ref, targetNodeId: component.id, confidence: 0.95, resolvedBy: 'framework' } : null; +} diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index e894e9b468..6a5d9027c4 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -8,6 +8,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; +import { pageComponentRef, resolvePageComponent } from './page-component'; /** The languages a Vue app's scripts are written in. */ const VUE_SCRIPT_LANGUAGES: ReadonlySet = new Set(['vue', 'javascript', 'typescript', 'tsx', 'jsx']); @@ -212,12 +213,14 @@ export const nuxtResolver: FrameworkResolver = { return context.getAllFiles().some((f) => /(?:^|\/)nuxt\.config\.(?:[cm]?[jt]s)$/.test(f)); }, - resolve(): ResolvedRef | null { - return null; + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // A page route names the component its file is. + return resolvePageComponent(ref, context); }, extract(filePath: string, _content: string) { const nodes: Node[] = []; + const references: UnresolvedRef[] = []; const now = Date.now(); // Forward slashes, and a leading `/` so an app at the repository root @@ -229,7 +232,7 @@ export const nuxtResolver: FrameworkResolver = { if (pagesIndex !== -1 && normalized.endsWith('.vue')) { const routePath = filePathToNuxtRoute(normalized, pagesIndex + '/pages/'.length); if (routePath !== null) { - nodes.push({ + const route: Node = { id: `route:${filePath}:${routePath}:1`, kind: 'route', name: routePath, @@ -241,7 +244,9 @@ export const nuxtResolver: FrameworkResolver = { endColumn: 0, language: 'vue', updatedAt: now, - }); + }; + nodes.push(route); + references.push(pageComponentRef(route, '.vue', 'vue')); } } @@ -292,7 +297,7 @@ export const nuxtResolver: FrameworkResolver = { }); } - return { nodes, references: [] }; + return { nodes, references }; }, };