From 0a670dc8cc4c7011c62e9a9cbf8c78aab9f93914 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 23:18:41 +0000 Subject: [PATCH 1/9] fix(liquid): JSON under templates/ or sections/ is Liquid only inside a Shopify theme (#2393) isShopifyLiquidJson matched every templates/**/*.json and sections/**/*.json by path alone, so a .NET project template's template.json (jasontaylordev/ CleanArchitecture), schematics and CMS content were indexed as empty Liquid files, and JSON listing `sections` could link to an unrelated Liquid file. The rule now needs the directory holding templates/ or sections/ to also hold layout/theme.liquid or config/settings_schema.json, looked up under the project root. isSourceFile, detectLanguage and preloadLanguagesForFiles take an optional rootDir, and every caller that decides what to index or how to parse passes it: the scans, git change detection, scoped sync, the watcher, indexAll, indexFileWithContent and CodeGraph.extractFromSource. Without a root there is no theme to find. A sync removes JSON an older index stored this way. Shopify/dawn, expressjs/express and gofynd/Astra index byte-identically; CleanArchitecture loses its 2 bogus Liquid files. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 98 ++++++++++++++++++++++++++++++++++++ src/extraction/grammars.ts | 46 +++++++++++++---- src/extraction/index.ts | 46 +++++++++-------- src/index.ts | 6 ++- src/sync/watcher.ts | 2 +- 6 files changed, 165 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d900662d1..fc0e4a0b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, an annotation is no longer lost when a comment sits between it and the declaration, like `@override` followed by `// ignore: must_call_super`, a `///` comment written below the annotations, or `@riverpod` followed by an `// expect_lint: …` line. Before, the comment hid the annotation, so a provider written that way was missing from the annotation's `callers` and impact. Re-index Dart projects after upgrading. - In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading. - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. +- JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 3ce4a524d6..768e08da57 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -7427,6 +7427,9 @@ describe('Liquid Shopify JSON template section resolution', () => { // looked unused. The JSON is now indexed and its `type`s linked. fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true }); fs.mkdirSync(path.join(tempDir, 'templates/customers'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'layout'), { recursive: true }); + // Every Shopify theme has layout/theme.liquid beside templates/ and sections/. + fs.writeFileSync(path.join(tempDir, 'layout/theme.liquid'), `{{ content_for_layout }}\n`); fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `
{{ product.title }}
\n`); fs.writeFileSync(path.join(tempDir, 'sections/main-login.liquid'), `
{{ 'customer.login' | t }}
\n`); fs.writeFileSync(path.join(tempDir, 'templates/product.json'), JSON.stringify({ sections: { main: { type: 'main-product' } }, order: ['main'] })); @@ -7444,6 +7447,101 @@ describe('Liquid Shopify JSON template section resolution', () => { expect(cg.getFileDependents(product!.filePath).some((p) => p.endsWith('templates/product.json')), 'top-level JSON template links its section').toBe(true); expect(cg.getFileDependents(login!.filePath).some((p) => p.endsWith('customers/login.json')), 'nested JSON template links its section').toBe(true); }); + + const jsonTemplate = (type: string): string => JSON.stringify({ sections: { main: { type } }, order: ['main'] }); + const indexedJson = (): string[] => + cg.getNodesByKind('file').map((n) => n.filePath).filter((p) => p.endsWith('.json')).sort(); + + it('leaves JSON under templates/ or sections/ out when no Shopify theme holds it', async () => { + // jasontaylordev/CleanArchitecture ships a .NET project template under + // templates/, and a CMS keeps its content under sections/: JSON in folders + // with those names, but nothing beside them makes a Shopify theme, so it is + // not Liquid — even when a Liquid file is named like one of its "types". + fs.mkdirSync(path.join(tempDir, 'templates/ca-use-case/.template.config'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'sections'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'templates/ca-use-case/.template.config/dotnetcli.host.json'), JSON.stringify({ symbolInfo: { UseCase: { longName: 'name' } } })); + fs.writeFileSync(path.join(tempDir, 'templates/ca-use-case/.template.config/template.json'), JSON.stringify({ identity: 'CleanArchitecture.UseCase', sections: { main: { type: 'hero' } } })); + fs.writeFileSync(path.join(tempDir, 'sections/home.json'), jsonTemplate('hero')); + fs.writeFileSync(path.join(tempDir, 'sections/hero.liquid'), `

{{ page.title }}

\n`); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + expect(indexedJson()).toEqual([]); + const hero = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('sections/hero.liquid')); + expect(hero, 'the Liquid file itself is still indexed').toBeDefined(); + expect(cg.getFileDependents(hero!.filePath)).toEqual([]); + }); + + it('finds a Shopify theme in a subdirectory by its config/settings_schema.json', async () => { + // A theme under theme/ with no layout/theme.liquid committed: its settings + // schema marks it. The same JSON under tools/templates/ is in no theme. + for (const dir of ['theme/config', 'theme/sections', 'theme/templates', 'tools/templates']) { + fs.mkdirSync(path.join(tempDir, dir), { recursive: true }); + } + fs.writeFileSync(path.join(tempDir, 'theme/config/settings_schema.json'), JSON.stringify([{ name: 'theme_info' }])); + fs.writeFileSync(path.join(tempDir, 'theme/sections/main-product.liquid'), `
{{ product.title }}
\n`); + fs.writeFileSync(path.join(tempDir, 'theme/templates/product.json'), jsonTemplate('main-product')); + fs.writeFileSync(path.join(tempDir, 'tools/templates/product.json'), jsonTemplate('main-product')); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + expect(indexedJson()).toEqual(['theme/templates/product.json']); + expect(cg.getFileDependents('theme/sections/main-product.liquid')).toEqual(['theme/templates/product.json']); + // Extracting without storing tells them apart the same way. + const refsOf = (file: string) => cg.extractFromSource(file, jsonTemplate('main-product')).unresolvedReferences.map((r) => r.referenceName); + expect(refsOf('theme/templates/product.json')).toEqual(['sections/main-product.liquid']); + expect(refsOf('tools/templates/product.json')).toEqual([]); + }); + + it('drops JSON templates once their folder is no longer a theme, and takes them back when it is', async () => { + // The same path an index built before this check takes: JSON it stored as + // Liquid outside any theme leaves on the next sync, without a re-index. + for (const dir of ['layout', 'sections', 'templates']) fs.mkdirSync(path.join(tempDir, dir), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'layout/theme.liquid'), `{{ content_for_layout }}\n`); + fs.writeFileSync(path.join(tempDir, 'sections/main-product.liquid'), `
{{ product.title }}
\n`); + fs.writeFileSync(path.join(tempDir, 'templates/page.json'), jsonTemplate('main-product')); + fs.writeFileSync(path.join(tempDir, 'templates/product.json'), jsonTemplate('main-product')); + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + expect(indexedJson()).toEqual(['templates/page.json', 'templates/product.json']); + + fs.rmSync(path.join(tempDir, 'layout'), { recursive: true, force: true }); + // A sync of only the reported paths (the watcher's) and a full sync agree. + await cg.sync({ paths: ['templates/product.json'] }); + expect(indexedJson()).toEqual(['templates/page.json']); + await cg.sync(); + expect(indexedJson()).toEqual([]); + + fs.mkdirSync(path.join(tempDir, 'config'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'config/settings_schema.json'), '[]'); + await cg.sync(); + expect(indexedJson()).toEqual(['templates/page.json', 'templates/product.json']); + }); + + it('reads JSON under templates/ or sections/ as Liquid only inside a theme under the project root', () => { + fs.mkdirSync(path.join(tempDir, 'shop/layout'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'shop/layout/theme.liquid'), ''); + + for (const file of ['shop/templates/product.json', 'shop/templates/customers/login.json', 'shop/sections/header-group.json']) { + expect(isSourceFile(file, undefined, tempDir), file).toBe(true); + expect(detectLanguage(file, undefined, undefined, tempDir), file).toBe('liquid'); + // Without the project root there is no theme to look for. + expect(isSourceFile(file), file).toBe(false); + expect(detectLanguage(file), file).toBe('unknown'); + } + // Beside the theme, not in it; and the theme's other JSON was never a template. + for (const file of ['templates/product.json', 'other/sections/header-group.json', 'shop/config/settings_data.json']) { + expect(isSourceFile(file, undefined, tempDir), file).toBe(false); + expect(detectLanguage(file, undefined, undefined, tempDir), file).toBe('unknown'); + } + // Outside a theme, JSON gets whatever the project maps `.json` to, as any other JSON does. + expect(detectLanguage('templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('yaml'); + expect(detectLanguage('shop/templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('liquid'); + }); }); describe('Lua/Luau require resolution', () => { diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 0a12e61d7d..215925a219 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -7,6 +7,7 @@ */ import * as path from 'path'; +import * as fs from 'fs'; import * as fsp from 'fs/promises'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { ExtractionError, Language } from '../types'; @@ -241,10 +242,14 @@ export function hasMpegTsExtension(filePath: string): boolean { * `overrides` is the project's validated custom extension → language map (from * `codegraph.json`); when present its extensions count as indexable in addition * to the built-ins. Omitting it is byte-identical to the zero-config behavior. + * + * `rootDir` is the project root `filePath` is relative to. A Shopify theme's + * JSON templates count only when it is given, since telling a theme apart takes + * a look at the files beside them (see `isShopifyLiquidJson`). */ -export function isSourceFile(filePath: string, overrides?: Record): boolean { +export function isSourceFile(filePath: string, overrides?: Record, rootDir?: string): boolean { if (isPlayRoutesFile(filePath)) return true; // Play `conf/routes` is extensionless - if (isShopifyLiquidJson(filePath)) return true; // Shopify OS 2.0 JSON templates / section groups + if (isShopifyLiquidJson(filePath, rootDir)) return true; // Shopify OS 2.0 JSON templates / section groups if (isErlangAppFile(filePath)) return true; // OTP `.app`/`.app.src` resource files const dot = filePath.lastIndexOf('.'); if (dot < 0) return false; @@ -252,15 +257,35 @@ export function isSourceFile(filePath: string, overrides?: Record/.template.config/template.json`, schematics, CMS content — + * and it is not Liquid. The theme is looked for under `rootDir`, the project + * root `filePath` is relative to; without one there is no theme to find. + */ +export function isShopifyLiquidJson(filePath: string, rootDir?: string): boolean { // Allow nested template dirs (`templates/customers/login.json`), not just // top-level (`templates/product.json`). - return /(^|\/)(templates|sections)\/.+\.json$/i.test(filePath); + if (rootDir === undefined || !/(^|\/)(templates|sections)\/.+\.json$/i.test(filePath)) return false; + const segments = filePath.split('/'); + for (let i = 0; i < segments.length - 1; i++) { + if (!/^(templates|sections)$/i.test(segments[i]!)) continue; + const themeDir = path.join(rootDir, ...segments.slice(0, i)); + if (SHOPIFY_THEME_MARKERS.some((marker) => fs.existsSync(path.join(themeDir, marker)))) return true; + } + return false; } /** @@ -536,15 +561,18 @@ export function getParser(language: Language): Parser | null { * `overrides` is the project's validated custom extension → language map (from * `codegraph.json`); when present its mappings take precedence over the built-in * `EXTENSION_MAP`. Omitting it is byte-identical to the zero-config behavior. + * + * `rootDir` is the project root `filePath` is relative to, as for + * `isSourceFile`: a Shopify theme's JSON templates are Liquid only with it. */ -export function detectLanguage(filePath: string, source?: string, overrides?: Record): Language { +export function detectLanguage(filePath: string, source?: string, overrides?: Record, rootDir?: string): Language { // Play `conf/routes` has no grammar — route through the no-symbol path; the // Play framework resolver extracts route nodes from it. if (isPlayRoutesFile(filePath)) return 'yaml'; const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase(); - // Shopify OS 2.0 JSON templates / section groups → the Liquid extractor (it - // links each section `"type"` to its `sections/.liquid`). - if (isShopifyLiquidJson(filePath)) return 'liquid'; + // A Shopify theme's OS 2.0 JSON templates / section groups → the Liquid + // extractor (it links each section `"type"` to its `sections/.liquid`). + if (isShopifyLiquidJson(filePath, rootDir)) return 'liquid'; // OTP `.app`/`.app.src` resource files — Erlang terms the grammar parses as // top-level expressions (last-dot ext `.src` is too generic for the map). if (isErlangAppFile(filePath)) return 'erlang'; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 4d768ddda9..51445af38b 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -602,7 +602,7 @@ function collectIncludedFiles( if (defaults.ignores(rel)) return; if (!include.ignores(rel)) return; if (exclude && exclude.ignores(rel)) return; - if (!isSourceFile(rel, overrides)) return; + if (!isSourceFile(rel, overrides, rootDir)) return; out.add(rel); } }; @@ -811,9 +811,10 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] { */ export function preloadLanguagesForFiles( files: string[], - overrides?: Record + overrides?: Record, + rootDir?: string ): Language[] { - const languages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))]; + const languages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides, rootDir)))]; // A Flow-typed `.js` is read with the TSX grammar (see detectLanguage). if ((languages.includes('javascript') || languages.includes('jsx')) && !languages.includes('tsx')) languages.push('tsx'); if (languages.includes('c')) { @@ -1358,7 +1359,8 @@ export function getGitChangedFiles(rootDir: string, sinceCommit?: string | null) // Custom extension → language overrides from the project's codegraph.json, // so change detection sees the same custom-extension files the full index does. const overrides = loadExtensionOverrides(rootDir); - collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined); + const isSource = (filePath: string): boolean => isSourceFile(filePath, overrides, rootDir); + collectGitStatus(rootDir, '', changes, isSource, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined); return changes; } catch { return null; @@ -1430,7 +1432,7 @@ export function canTrustGitFastPath(rootDir: string, sinceCommit?: string | null } } -function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record, includeIgnored: Ignore | null = null, exclude: Ignore | null = null, sinceCommit?: string): void { +function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSource: (filePath: string) => boolean, includeIgnored: Ignore | null = null, exclude: Ignore | null = null, sinceCommit?: string): void { const output = execFileSync( 'git', // `-uall` lists individual untracked files instead of collapsing an @@ -1459,7 +1461,7 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over // filtered by exactly the rules a working-tree change is (#766, #999, #1829). const classify = (statusCode: string, rel: string): void => { const filePath = normalizePath(prefix + rel); - if (!isSourceFile(filePath, overrides)) return; + if (!isSource(filePath)) return; if (statusCode.includes('D')) { // Deletions stay unfiltered: getChangedFiles acts on one only when the @@ -1523,11 +1525,11 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over // and they are left alone (#970, #976), mirroring the full-index scan. for (const rel of untrackedDirs) { for (const repoRel of findNestedGitRepos(path.join(repoDir, rel), rel)) { - collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, overrides, includeIgnored, exclude); + collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, isSource, includeIgnored, exclude); } } for (const rel of findIgnoredEmbeddedRepos(repoDir, includeIgnored, prefix)) { - collectGitStatus(path.join(repoDir, rel), prefix + rel, out, overrides, includeIgnored, exclude); + collectGitStatus(path.join(repoDir, rel), prefix + rel, out, isSource, includeIgnored, exclude); } } @@ -1551,7 +1553,7 @@ export function scanDirectory( const files: string[] = []; let count = 0; for (const filePath of gitFiles) { - if (isSourceFile(filePath, overrides)) { + if (isSourceFile(filePath, overrides, rootDir)) { files.push(filePath); count++; onProgress?.(count, filePath); @@ -1602,7 +1604,7 @@ export async function scanDirectoryAsync( const files: string[] = []; let count = 0; for (const filePath of gitFiles) { - if (isSourceFile(filePath, overrides)) { + if (isSourceFile(filePath, overrides, rootDir)) { files.push(filePath); count++; onProgress?.(count, filePath); @@ -1710,7 +1712,7 @@ function scanDirectoryWalk( } } else if (stat.isFile()) { if (!isIgnored(fullPath, false, active)) { - if (isSourceFile(relativePath, overrides)) { + if (isSourceFile(relativePath, overrides, rootDir)) { files.push(relativePath); count++; onProgress?.(count, relativePath); @@ -1731,7 +1733,7 @@ function scanDirectoryWalk( } } else if (entry.isFile()) { if (!isIgnored(fullPath, false, active)) { - if (isSourceFile(relativePath, overrides)) { + if (isSourceFile(relativePath, overrides, rootDir)) { files.push(relativePath); count++; onProgress?.(count, relativePath); @@ -2186,7 +2188,7 @@ export class ExtractionOrchestrator { await new Promise(resolve => setImmediate(resolve)); // Detect needed languages and load grammars in the parse worker - const neededLanguages = preloadLanguagesForFiles(files, overrides); + const neededLanguages = preloadLanguagesForFiles(files, overrides, this.rootDir); // Parse files on a pool of worker threads (keeps the main thread free for UI // and uses every core). Falls back to in-process parsing when the compiled @@ -2257,7 +2259,7 @@ export class ExtractionOrchestrator { * here on the main thread, where the codegraph.json overrides are loaded. */ const parseFile = (filePath: string, content: string): Promise => { - const language = detectLanguage(filePath, content, overrides); + const language = detectLanguage(filePath, content, overrides, this.rootDir); const names = this.frameworksForFile(filePath, frameworkNames); if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names)); return pool.requestParse({ filePath, content, language, frameworkNames: names }); @@ -2308,7 +2310,7 @@ export class ExtractionOrchestrator { // Store: on the writer thread when active (fresh DB — bundles applied // in the same file order this chain dispatches them), else on the main // thread (SQLite connections are per-thread). - const language = detectLanguage(filePath, content, overrides); + const language = detectLanguage(filePath, content, overrides, this.rootDir); if (grammarUnavailable) { // Store nothing: a row from an earlier run keeps its data, and with no // row (or an older hash) the next sync or index retries the file. @@ -2352,7 +2354,7 @@ export class ExtractionOrchestrator { // Files with no symbols but no errors (yaml, twig, properties) are // tracked at the file level — count them as indexed so the CLI doesn't // misleadingly report "No files found to index". - const lang = detectLanguage(filePath, content, overrides); + const lang = detectLanguage(filePath, content, overrides, this.rootDir); if (isFileLevelOnlyLanguage(lang)) { filesIndexed++; } else { @@ -2640,7 +2642,7 @@ export class ExtractionOrchestrator { // so decode here — otherwise a kernel-language retry passes the gate // below via `errors.length === 0`, stores nothing, and the file is // permanently recorded as "(0 symbols)" with the error erased (#1541). - const language = detectLanguage(filePath, content, overrides); + const language = detectLanguage(filePath, content, overrides, this.rootDir); result = materializeKernelResult(result, filePath, language); if (result.nodes.length > 0 || result.errors.length === 0) { @@ -2695,7 +2697,7 @@ export class ExtractionOrchestrator { } // Same undecoded-transport hazard as the first retry pass (#1541). - const language = detectLanguage(filePath, fullContent, overrides); + const language = detectLanguage(filePath, fullContent, overrides, this.rootDir); result = materializeKernelResult(result, filePath, language); if (result.nodes.length > 0 || result.errors.length === 0) { @@ -2857,7 +2859,7 @@ export class ExtractionOrchestrator { }; } - const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir)); + const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir), this.rootDir); // Check file size if (stats.size > MAX_SOURCE_FILE_SIZE_BYTES) { @@ -3350,7 +3352,7 @@ export class ExtractionOrchestrator { const overrides = loadExtensionOverrides(this.rootDir); currentFiles = unique.filter( (p) => - isSourceFile(p, overrides) && + isSourceFile(p, overrides, this.rootDir) && !scope.ignores(p) && fs.existsSync(path.join(this.rootDir, p)) ); @@ -3500,7 +3502,7 @@ export class ExtractionOrchestrator { // Load only grammars needed for changed files if (filesToIndex.length > 0) { const overrides = loadExtensionOverrides(this.rootDir); - await loadGrammarsForLanguages(preloadLanguagesForFiles(filesToIndex, overrides)); + await loadGrammarsForLanguages(preloadLanguagesForFiles(filesToIndex, overrides, this.rootDir)); } // Index changed files @@ -3627,7 +3629,7 @@ export class ExtractionOrchestrator { for (const filePath of candidates) { const tracked = this.queries.getFileByPath(filePath); const fullPath = path.join(this.rootDir, filePath); - if (!isSourceFile(filePath, overrides) || scope.ignores(filePath) || !fs.existsSync(fullPath)) { + if (!isSourceFile(filePath, overrides, this.rootDir) || scope.ignores(filePath) || !fs.existsSync(fullPath)) { if (tracked) removed.push(filePath); continue; } diff --git a/src/index.ts b/src/index.ts index 4d852af522..170498ba3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,7 @@ import { extractFromSource, initGrammars, } from './extraction'; -import { hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars'; +import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars'; import { ReferenceResolver, createResolver, @@ -1415,7 +1415,9 @@ export class CodeGraph { * Extract nodes and edges from source code (without storing) */ extractFromSource(filePath: string, source: string): ExtractionResult { - return extractFromSource(filePath, source); + // The project root is what tells a Shopify theme's JSON templates (Liquid) + // apart from other JSON. + return extractFromSource(filePath, source, detectLanguage(filePath, source, undefined, this.projectRoot)); } // =========================================================================== diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 17722a8fbd..3742a98429 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -650,7 +650,7 @@ export class FileWatcher { this.refreshScope(rel); return; } - if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) { + if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot), this.projectRoot)) { this.maybeScheduleForRemovedDir(rel); return; } From ac1f430d3ea170992ef56b41f07d7cfdcda45d7e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 23:27:58 +0000 Subject: [PATCH 2/9] fix(c,cpp): a pointer, array or function member is not a supertype (#2394) extractInheritance carried Go's struct-embedding check (a field_declaration with no direct field_identifier names an embedded type) into every grammar with a field_declaration_list. tree-sitter-c/cpp, and Objective-C's C structs, nest a member's field_identifier inside its pointer/array/ function/reference declarator, so `SharedState* shared;`, `jv elements[];` and `virtual Status Put(...) = 0;` all became `extends` edges to the member's type: false supertypes in the type hierarchy, blast radius and codegraph_explore, and false cpp-override call edges built on them (google/leveldb: 246 of 302 extends edges). Gate the branch to Go in the TS extractor, drop the mirrored arm from the C/C++ kernel port (and the "kept for parity" quirk note), and drop the inert copy from the Rust-language port. C++ base_class_clause, Objective-C superclasses and Go embedding are unchanged. Validated on leveldb, jq, llm.c and redis: every removed extends edge is a member site, no base-clause edge is lost; gin, SDWebImage and ripgrep dumps are byte-identical; kernel/wasm parity sweeps show 0 diffs. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/c-cpp-member-supertypes.test.ts | 273 ++++++++++++++++++ codegraph-kernel/src/ccpp/mod.rs | 25 +- codegraph-kernel/src/rustlang.rs | 20 +- docs/design/ccpp-kernel-port-checklist.md | 2 +- .../design/rust-lang-kernel-port-checklist.md | 8 +- src/extraction/tree-sitter.ts | 8 +- 7 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 __tests__/c-cpp-member-supertypes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0e4a0b2e..1a8377da5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading. - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. +- 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/c-cpp-member-supertypes.test.ts b/__tests__/c-cpp-member-supertypes.test.ts new file mode 100644 index 0000000000..4321ded4cf --- /dev/null +++ b/__tests__/c-cpp-member-supertypes.test.ts @@ -0,0 +1,273 @@ +/** + * A C or C++ struct/class member whose declarator is a pointer, reference, + * array or function was recorded as an `extends` edge to the member's type: + * + * struct ThreadState { SharedState* shared; }; // ThreadState extends SharedState + * typedef struct { int length; jv elements[]; } jvp_array; // jvp_array extends jv + * class DB { virtual Status Put(const Slice& k) = 0; }; // DB extends Status + * + * Both extractors carried Go's struct-embedding check (`type DB struct { *Head }` + * — a `field_declaration` with no `field_identifier` names an embedded type) + * and ran it for every grammar with a `field_declaration_list`. tree-sitter-c + * and -cpp, and the C structs of Objective-C, nest the member's name inside + * its declarator, so every such member looked embedded and its type showed up + * as a supertype: in the type hierarchy, the "extends" chips, blast radius + * and codegraph_explore. google/leveldb had 246 of its 302 `extends` edges + * from members. + * + * Embedding is Go's alone, so the check now runs for Go only. C++ base + * classes and Objective-C superclasses come from their own clauses and are + * untouched. Runs against the native kernel (when built) and the wasm + * extractor, which must agree. + */ +import { describe, it, expect, beforeAll, 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 { buildTypeHierarchy } from '../src/graph/type-hierarchy'; +import type { ExtractionResult, Language, Node } 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'; + +/** jqlang/jq's shapes: flexible and fixed arrays, pointers, function pointers in a union. */ +const C_SOURCE = `typedef struct { int refcnt; } jv; +typedef struct jq_state jq_state; +typedef struct { int length; jv elements[]; } jvp_array; +struct jv_parser { + jv* stack; + jv next[2]; + jv value; + int stackpos; +}; +union cfunction { + jv (*a1)(jq_state *, jv); + jv (*a2)(jq_state *, jv, jv); +}; +struct node { + struct node *next; + const jv *const *table; +}; +`; + +/** google/leveldb's shapes, plus base classes that must survive, one on a later line. */ +const CPP_SOURCE = `class Cache {}; +class DB {}; +class Status {}; +class Slice {}; +struct SharedState { int total; }; +struct ThreadState { + SharedState* shared; + const Cache& cache; + Cache shards[4]; + Status (*hook)(int); +}; +class Benchmark { + public: + Status Open(int k); + virtual Status Put(const Slice& key) = 0; + Slice operator[](int n) const; + private: + DB* db_; + Cache* cache_; +}; +class Base {}; +namespace ns { template class Tpl {}; } +class Derived : public Base, private ns::Tpl { + Base* parent; +}; +struct Plain : Base { Base* next; }; +class Wrapped + : public Base { + Base* other; +}; +`; + +/** Go embedding is real inheritance-by-composition and keeps its edges. */ +const GO_SOURCE = `package store + +type Engine struct{} + +type Reader interface{ Read() } + +type Store struct { + *Engine + Reader + name string + engine *Engine +} +`; + +/** Objective-C reuses the C struct grammar; its superclass comes from @interface. */ +const OBJC_SOURCE = `#import +typedef struct { int width; } Size; +struct Frame { + Size *size; + Size corners[4]; + Size origin; +}; +@interface Image : NSObject { + Size *cached; +} +@end +@implementation Image +@end +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` for every extends/implements ref, sorted. */ +function supertypeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements') + .map((r) => `${byId.get(r.fromNodeId)?.name ?? r.fromNodeId} ${r.referenceKind} ${r.referenceName}`) + .sort(); +} + +describe('a C/C++ member is not a supertype of its struct', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['c', 'cpp', 'go', 'objc']); + }); + + 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', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`C pointer, array and function-pointer members: %s${label}`, (backend) => { + const result = extract(backend, 'src/jv.c', eol(C_SOURCE), 'c'); + expect(result.nodes.map((n) => n.name)).toEqual( + expect.arrayContaining(['jvp_array', 'jv_parser', 'cfunction', 'node']) + ); + expect(supertypeRefs(result)).toEqual([]); + }); + + it.each(backends)(`C++ members, methods and operators; base classes kept: %s${label}`, (backend) => { + const result = extract(backend, 'benchmarks/db_bench.cc', eol(CPP_SOURCE), 'cpp'); + expect(supertypeRefs(result)).toEqual([ + 'Derived extends Base', + 'Derived extends ns::Tpl', + 'Plain extends Base', + 'Wrapped extends Base', + ]); + }); + + it.each(backends)(`Go embedded fields still extend: %s${label}`, (backend) => { + const result = extract(backend, 'store/store.go', eol(GO_SOURCE), 'go'); + expect(supertypeRefs(result)).toEqual(['Store extends Engine', 'Store extends Reader']); + }); + + // Objective-C has no kernel walker; the wasm extractor is its only path. + it(`Objective-C C-struct members; @interface superclass kept: wasm${label}`, () => { + const result = extractFromSource('Image.m', eol(OBJC_SOURCE), 'objc'); + expect(supertypeRefs(result)).toEqual(['Image extends NSObject', 'Image implements NSCopying']); + }); + } +}); + +describe('the type hierarchy of an indexed C/C++ project', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + it('holds only declared bases and Go embeddings', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-member-supertypes-')); + const files: Record = { + 'include/db.h': 'class Cache {};\nclass DB {\n public:\n virtual ~DB();\n};\n', + 'benchmarks/db_bench.cc': [ + '#include "db.h"', + 'struct SharedState { int total; };', + 'struct ThreadState {', + ' SharedState* shared;', + '};', + 'class Benchmark {', + ' Cache* cache_;', + ' DB* db_;', + '};', + 'class MemDB : public DB {', + ' Cache* cache_;', + '};', + '', + ].join('\n'), + 'src/jv.c': C_SOURCE, + 'store/store.go': GO_SOURCE, + }; + 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 }); + const graph = cg; + + const one = (name: string): Node => { + const found = graph.getNodesByName(name).filter((n) => n.kind !== 'file' && n.kind !== 'import'); + expect(found, name).toHaveLength(1); + return found[0]!; + }; + + const ids = ['ThreadState', 'Benchmark', 'MemDB', 'jvp_array', 'jv_parser', 'cfunction', 'node', 'Store'].map( + (name) => one(name).id + ); + const supertypes = graph + .getOutgoingEdgesFrom(ids, ['extends', 'implements']) + .map((e) => `${graph.getNode(e.source)?.name} ${e.kind} ${graph.getNode(e.target)?.name}`) + .sort(); + // An embedded interface resolves as `implements`. + expect(supertypes).toEqual(['MemDB extends DB', 'Store extends Engine', 'Store implements Reader']); + + // What the viewer's Type hierarchy and the "extends" chips are built from. + expect(buildTypeHierarchy(graph, one('ThreadState'))).toBeNull(); + expect(buildTypeHierarchy(graph, one('SharedState'))).toBeNull(); + expect(buildTypeHierarchy(graph, one('jv'))).toBeNull(); + expect(buildTypeHierarchy(graph, one('DB'))?.descendants.map((d) => d.node.name)).toEqual(['MemDB']); + expect(buildTypeHierarchy(graph, one('Cache'))).toBeNull(); + expect(buildTypeHierarchy(graph, one('Engine'))?.descendants.map((d) => d.node.name)).toEqual(['Store']); + }, 60_000); +}); diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index ef546e7261..d92bc33141 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -33,11 +33,6 @@ //! (loses uninit scalars by design); cpp declarations instead take the TS //! GENERIC fallback (direct identifier children only → `int x;` extracts, //! `int x = 5;` does not — bug-for-bug). -//! - inheritance quirk: extractInheritance recurses into -//! field_declaration_list, where a field_declaration with no DIRECT -//! field_identifier child (pointer/array/method members) but a direct -//! type_identifier emits an `extends` ref to that type (the Go-embedding -//! branch matching c/cpp shapes). Kept: the parity gate pins today's graph. //! - static-member/value-read pass (cpp only): `field_expression` is in //! MEMBER_ACCESS_TYPES (listed for Scala, same node kind in cpp), so //! `Capitalized.member` / `Capitalized->member` VALUE reads emit @@ -1866,8 +1861,10 @@ impl<'t> Walker<'t> { // --- inheritance --------------------------------------------------------- /// extractInheritance — the branches whose node kinds occur in the c/cpp - /// grammars: base_class_clause (#1043), the field_declaration Go-embedding - /// shape, and the field_declaration_list recursion that reaches it. + /// grammars: base_class_clause (#1043) and the field_declaration_list + /// recursion. A member `field_declaration` is never a supertype: the TS + /// Go-embedding check is gated to Go, since c/cpp members nest their + /// field_identifier inside a pointer/array/function declarator. fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) { stack_guard!(); let extends_kind = edge_kind_index("extends").unwrap(); @@ -1886,20 +1883,6 @@ impl<'t> Walker<'t> { } } } - "field_declaration" => { - let has_field_identifier = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .any(|c| c.kind() == "field_identifier"); - if !has_field_identifier { - let type_id = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { - let name = self.text(type_id).to_string(); - self.push_ref_at(class_row, &name, extends_kind, type_id); - } - } - } "field_declaration_list" | "class_heritage" => { self.extract_inheritance(child, class_row); } diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 64253f0434..64b8923ddc 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -1061,9 +1061,9 @@ impl<'t> Walker<'t> { /// extractInheritance — the rust-reachable cases: trait_bounds /// (supertraits; a scoped `fmt::Debug` bound matches NO case and is - /// dropped), the Go embedding check on field_declaration (inert in rust — - /// every field has a field_identifier), and the field_declaration_list - /// recursion that reaches it. + /// dropped) and the field_declaration_list recursion. The TS Go-embedding + /// check on field_declaration is gated to Go (and was inert here anyway — + /// every rust field has a field_identifier). fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) { stack_guard!(); let extends_kind = edge_kind_index("extends").unwrap(); @@ -1102,20 +1102,6 @@ impl<'t> Walker<'t> { } } } - "field_declaration" => { - let has_field_identifier = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .any(|c| c.kind() == "field_identifier"); - if !has_field_identifier { - let type_id = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { - let name = self.text(type_id).to_string(); - self.push_ref_at(class_row, &name, extends_kind, type_id); - } - } - } "field_declaration_list" | "class_heritage" => { self.extract_inheritance(child, class_row); } diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 3fe0562b31..148bad5252 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -194,7 +194,7 @@ function_declarator → null, 12-hop guard). | 4545 | local fn-ptr call fan-out (#932-adjacent) | bare-identifier callee found in cppLocalFnPtrs[callerId] → emit one `calls` ref PER recorded target, suppress the local name | | 5173 | stack construction (#1035) | cpp `declaration` where `type` ∈ {type_identifier, template_type, qualified_identifier} and any init_declarator has `value` ∈ {argument_list, initializer_list} → extractInstantiation | | 5183 | fn-ptr binding recording | inside a body: `declaration>init_declarator` (identifier declarator) or `assignment_expression` (identifier left); value must be pointer_expression whose child(0) is `&`; target ∈ {identifier, template_function, qualified_identifier}, template-stripped; per-callerId map of per-local Sets (branch reassignments accumulate) | -| 5408 | base_class_clause → extends (#1043) | per base: type_identifier / qualified_identifier / template_type, stripCppTemplateArgs'd; access-specifier keywords skipped | +| 5408 | base_class_clause → extends (#1043) | per base: type_identifier / qualified_identifier / template_type, stripCppTemplateArgs'd; access-specifier keywords skipped. The only c/cpp supertype source: the Go struct-embedding branch (`field_declaration` with no direct `field_identifier`) runs for Go only, so a pointer/array/reference/function member (`Cache* cache_;`) is never an `extends` | | 4740 | static member refs — **cpp only** (c not in STATIC_MEMBER_LANGS, line 345) | `Foo::BAR` value reads → `references` edge; VERIFY the MEMBER_ACCESS_TYPES shapes for qualified_identifier + the call-callee skip during the port | | — | value-reference edges | **c: YES** (VALUE_REF_LANGS line 401), **cpp: NO**. Port the value-ref machinery for C only (crib go.rs / tsjs — shadow prune, scope stack, MAX_VALUE_REF_NODES cap, CODEGRAPH_VALUE_REFS=0 kill) | | — | fn-ref capture (#756) | function-ref.ts:376: `c: cFamilySpec()`, `cpp: cFamilySpec({ addressOfOnly: true })`; note line ~582: `&Cls::m` exemption from the bare-ids-are-free-functions rule — read cFamilySpec fully when porting | diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index 605013a9e8..e086eb0899 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -317,10 +317,10 @@ trait_item — supertraits `trait Sub: Super + Display`). Per bound child: Each yields an `extends` ref from the trait node at the bound's position. Struct/enum extraction also calls extractInheritance; rust struct_item children -include `field_declaration_list` → the 5652 recursion descends, but rust -`field_declaration` always carries a `field_identifier` name so the Go -struct-embedding branch (5496) never fires. Verify with the torture fixture -anyway. +include `field_declaration_list` → the 5652 recursion descends and finds +nothing: the Go struct-embedding branch (5496) runs for Go only (it was inert +here anyway — rust `field_declaration` always carries a `field_identifier` +name), so rustlang.rs has no `field_declaration` arm. ### impl Trait for Type — extractRustImplItem (5690) diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 29debc3a1e..9ea465cada 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7001,8 +7001,12 @@ export class TreeSitterExtractor { } // Go struct embedding: field_declaration without field_identifier - // e.g. `type DB struct { *Head; Queryable }` — no field name means embedded type - if (child.type === 'field_declaration') { + // e.g. `type DB struct { *Head; Queryable }` — no field name means embedded type. + // Go only: C, C++ and Objective-C members are field_declarations too, but + // nest their name inside the declarator (`Cache* cache_;`, `jv elems[];`, + // `Status Get(int);`), so this test read every such member's type as a + // supertype. Their supertypes come from base_class_clause / superclass. + if (this.language === 'go' && child.type === 'field_declaration') { const hasFieldIdentifier = child.namedChildren.some((c: SyntaxNode) => c.type === 'field_identifier'); if (!hasFieldIdentifier) { const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier'); From c513f1472a81bd14f9ff4c99f1eab110d2d30b80 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 23:38:11 +0000 Subject: [PATCH 3/9] fix(frameworks): detection reads an app's package.json three or more levels down (#2395) declaredDependencies read the root manifest and the ones one or two directories down. An app whose package.json sits deeper was never read: an ASP.NET solution's src/Web/ClientApp/, or prometheus's web/ui/mantine-ui/ under a workspace root that declares only tooling. Every resolver gated on dependsOn() then stayed off for it: Angular Router and the Angular template pass, React Router's navigation, Vue Router, SvelteKit and TanStack Router. The existing pass is unchanged. When it leaves slots open, a second pass probes the directories three or more levels down that hold indexed JS/TS code, shallowest first and sorted within a level, at most 192 of them, never inside node_modules. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/package-deps.test.ts | 246 ++++++++++++++++++++++ docs/design/framework-coverage.md | 10 + src/resolution/frameworks/package-deps.ts | 60 +++++- 4 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 __tests__/package-deps.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8377da5d..ff0c7c36c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. - 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. +- 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/package-deps.test.ts b/__tests__/package-deps.test.ts new file mode 100644 index 0000000000..1bfa7b669b --- /dev/null +++ b/__tests__/package-deps.test.ts @@ -0,0 +1,246 @@ +/** + * Which `package.json` files framework detection reads + * (`src/resolution/frameworks/package-deps.ts`). + * + * The index lists source files, never manifests, so the candidates are + * directories probed on disk. An ASP.NET solution keeps its single-page app + * three levels down: jasontaylordev/CleanArchitecture's Angular app is + * `src/Web/ClientApp/` and its React twin `src/Web/ClientApp-React/`. + * prometheus keeps its React apps in `web/ui/mantine-ui/` and + * `web/ui/react-app/`, under a workspace root at `web/ui/` that declares only + * tooling. Read two levels deep, none of those manifests was read, so Angular + * Router, the Angular template pass and React Router's navigation never ran, + * and every template-bound handler was listed as dead code. + */ +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 { buildDeadCodeReport } from '../src/graph/dead-code'; +import { declaredDependencies, dependsOn } from '../src/resolution/frameworks/package-deps'; +import type { ResolutionContext } from '../src/resolution/types'; + +/** A context over an indexed file list and the manifests on disk, recording every probe. */ +function tree(files: string[], manifests: Record>>) { + const probes: string[] = []; + const context = { + getAllFiles: () => files, + fileExists: (p: string) => { + probes.push(p); + return p in manifests; + }, + readFile: (p: string) => (p in manifests ? JSON.stringify(manifests[p]) : null), + } as unknown as ResolutionContext; + return { context, probes }; +} + +describe('declaredDependencies: manifests below the first two levels', () => { + it.each([ + ['src/Web/ClientApp/', 'an ASP.NET solution'], + ['src/Presentation/Web/ClientApp/', 'a solution folder deeper still'], + ])('reads %s, where %s keeps its app', (app) => { + const { context } = tree( + ['src/Web/Program.cs', `${app}karma.conf.js`, `${app}src/main.ts`, `${app}src/app/app.module.ts`], + { [`${app}package.json`]: { dependencies: { '@angular/core': '^21.1.5', '@angular/router': '^21.1.5' } } } + ); + expect(dependsOn(context, '@angular/router')).toBe(true); + }); + + it('reads a workspace root two levels down and the apps a level below it', () => { + const { context } = tree( + ['cmd/prometheus/main.go', 'web/ui/mantine-ui/src/main.tsx', 'web/ui/react-app/src/App.tsx', 'web/ui/module/lezer-promql/src/index.ts'], + { + 'web/ui/package.json': { devDependencies: { typescript: '*', vite: '*' } }, + 'web/ui/mantine-ui/package.json': { dependencies: { react: '*', 'react-router-dom': '*' } }, + 'web/ui/react-app/package.json': { dependencies: { react: '*', 'react-router': '*' } }, + } + ); + const names = declaredDependencies(context); + expect([...names].sort()).toEqual(['react', 'react-router', 'react-router-dom', 'typescript', 'vite']); + }); + + it('never reads a manifest inside node_modules', () => { + const { context, probes } = tree( + ['tools/site/node_modules/framework/dist/index.js', 'tools/site/src/index.ts'], + { 'tools/site/node_modules/framework/package.json': { dependencies: { next: '*' } } } + ); + expect(dependsOn(context, 'next')).toBe(false); + expect(probes.filter((p) => p.includes('node_modules'))).toEqual([]); + }); + + it('probes only above JS/TS code, and a bounded number of directories however many hold it', () => { + const files = ['src/Api/Tools/Generator.cs']; + for (let i = 0; i < 3000; i++) files.push(`src/app/feature${i}/parts/widget.component.ts`); + const { context, probes } = tree(files, { 'src/Api/Tools/package.json': { dependencies: { express: '*' } } }); + expect(dependsOn(context, 'express')).toBe(false); + // `src/`, `src/Api/`, `src/app/` from the first two levels; the rest below them. + expect(probes.length).toBeLessThanOrEqual(3 + 192); + }); + + it('reads the same manifests whatever order the files are listed in', () => { + const files: string[] = []; + const manifests: Record>> = {}; + for (let i = 0; i < 40; i++) { + files.push(`clients/web/app${i}/src/index.ts`); + manifests[`clients/web/app${i}/package.json`] = { dependencies: { [`dep-${i}`]: '*' } }; + } + const scanned = declaredDependencies(tree(files, manifests).context); + const reversed = declaredDependencies(tree([...files].reverse(), manifests).context); + expect([...reversed].sort()).toEqual([...scanned].sort()); + // A sample, as above two levels: the root and 24 more. + expect(scanned.size).toBe(24); + }); +}); + +// --------------------------------------------------------------------------- +// End to end: CleanArchitecture's shape +// --------------------------------------------------------------------------- + +const ngComponent = (name: string, selector: string, templateUrl: string, body = '', imports = '') => `import { Component } from '@angular/core'; +${imports} +@Component({ + standalone: false, + selector: '${selector}', + templateUrl: '${templateUrl}' +}) +export class ${name} { +${body} +} +`; + +describe('an ASP.NET solution with its single-page apps three levels down, indexed', () => { + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deep-manifest-')); + const files: Record = { + 'src/Web/Program.cs': 'var app = WebApplication.CreateBuilder(args).Build();\napp.Run();\n', + 'src/Web/ClientApp/package.json': JSON.stringify({ dependencies: { '@angular/core': '^21.1.5', '@angular/router': '^21.1.5' } }), + 'src/Web/ClientApp/angular.json': '{}', + 'src/Web/ClientApp/src/app/app.module.ts': `import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { HomeComponent } from './home/home.component'; +import { CounterComponent } from './counter/counter.component'; +import { LoginComponent } from './login/login.component'; + +@NgModule({ + imports: [ + RouterModule.forRoot([ + { path: '', component: HomeComponent, pathMatch: 'full' }, + { path: 'counter', component: CounterComponent }, + { path: 'login', component: LoginComponent } + ]) + ] +}) +export class AppModule {} +`, + 'src/Web/ClientApp/src/app/home/home.component.ts': ngComponent('HomeComponent', 'app-home', './home.component.html'), + 'src/Web/ClientApp/src/app/home/home.component.html': '

Hello, world!

\n', + 'src/Web/ClientApp/src/app/login/login.component.ts': ngComponent('LoginComponent', 'app-login', './login.component.html'), + 'src/Web/ClientApp/src/app/login/login.component.html': '

Log in

\n', + 'src/Web/ClientApp/src/app/counter/counter.component.ts': ngComponent( + 'CounterComponent', + 'app-counter-component', + './counter.component.html', + ' public currentCount = 0;\n\n public incrementCounter() {\n this.currentCount++;\n }' + ), + 'src/Web/ClientApp/src/app/counter/counter.component.html': '

Current count: {{ currentCount }}

\n\n', + 'src/Web/ClientApp/src/app/nav-menu/nav-menu.component.ts': ngComponent( + 'NavMenuComponent', + 'app-nav-menu', + './nav-menu.component.html', + " constructor(private router: Router) {}\n\n logout(): void {\n this.router.navigate(['/login']);\n }", + "import { Router } from '@angular/router';" + ), + 'src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html': `Counter +Log out +`, + 'src/Web/ClientApp-React/package.json': JSON.stringify({ dependencies: { react: '^19.1.0', 'react-dom': '^19.1.0', 'react-router-dom': '^7.6.1' } }), + 'src/Web/ClientApp-React/src/App.jsx': `import { Routes, Route } from 'react-router-dom'; +import { Home } from './components/Home'; +import { Counter } from './components/Counter'; +import { LoginPage } from './components/LoginPage'; + +export default function App() { + return ( + + } /> + } /> + } /> + + ); +} +`, + 'src/Web/ClientApp-React/src/components/Home.jsx': 'export function Home() {\n return

Hello, world!

;\n}\n', + 'src/Web/ClientApp-React/src/components/Counter.jsx': 'export function Counter() {\n return

Counter

;\n}\n', + 'src/Web/ClientApp-React/src/components/LoginPage.jsx': 'export function LoginPage() {\n return

Log in

;\n}\n', + 'src/Web/ClientApp-React/src/components/NavMenu.jsx': `import { Link, useNavigate } from 'react-router-dom'; + +export function NavMenu() { + const navigate = useNavigate(); + const handleLogout = (e) => { + e.preventDefault(); + navigate('/login'); + }; + return ( + + ); +} +`, + }; + 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(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const routesUnder = (dir: string) => cg.getNodesByKind('route').filter((r) => r.filePath.startsWith(dir)); + const navigations = (dir: string) => + routesUnder(dir) + .flatMap((r) => + cg.getIncomingEdgesTo([r.id], ['navigates']).map((e) => `${cg.getNode(e.source)?.name} -> ${r.name} (${(e.metadata as Record).navMethod})`) + ) + .sort(); + + it('detects the frameworks the nested manifests declare', () => { + expect(cg.getDetectedFrameworks()).toEqual(expect.arrayContaining(['angular-router', 'react-router'])); + }); + + it("reads the Angular app's routes, bound to their components", () => { + const routes = routesUnder('src/Web/ClientApp/') + .map((r) => `${r.name} -> ${cg.getOutgoingEdgesFrom([r.id], ['references']).map((e) => cg.getNode(e.target)?.name).join(', ')}`) + .sort(); + expect(routes).toEqual(['/ -> HomeComponent', '/counter -> CounterComponent', '/login -> LoginComponent']); + }); + + it("draws the Angular app's navigation from routerLink and router.navigate", () => { + expect(navigations('src/Web/ClientApp/')).toEqual(['NavMenuComponent -> /counter (routerLink)', 'logout -> /login (navigate)']); + }); + + it("links a template's event binding to its handler, so the handler is not dead code", () => { + const counter = cg.getNodesByKind('class').find((c) => c.name === 'CounterComponent')!; + const handlers = cg + .getOutgoingEdgesFrom([counter.id], ['calls']) + .filter((e) => (e.metadata as Record | undefined)?.synthesizedBy === 'angular-event') + .map((e) => cg.getNode(e.target)?.name); + expect(handlers).toEqual(['incrementCounter']); + const dead = buildDeadCodeReport(cg, { limit: 1000 }).entries.map((e) => e.node.name); + expect(dead).not.toContain('incrementCounter'); + expect(dead).not.toContain('logout'); + }); + + it("resolves the React twin's navigate() as well as its ", () => { + expect(navigations('src/Web/ClientApp-React/')).toEqual(['NavMenu -> /counter (link)', 'handleLogout -> /login (navigate)']); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index d4aa59834c..cbd66ffef8 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -231,6 +231,16 @@ Each of these cost real debugging time; they are not hypothetical. per file-count for exactly this reason — an earlier version cached the empty pre-index answer and every framework whose dependency lived one directory down stayed undetected. +12. **An app's manifest can sit three or more levels down.** An ASP.NET + solution keeps its single-page app in `src/Web/ClientApp/`, and prometheus + keeps its React apps in `web/ui/mantine-ui/` under a workspace root at + `web/ui/` that declares only tooling. `declaredDependencies` reads the root + and the first two levels, then gives the slots left to the directories + above JS/TS code, shallowest first. Read only two levels deep, Angular + Router never ran on jasontaylordev/CleanArchitecture, and every + template-bound handler there was listed as dead code. A new detector that + gates on a dependency should ask `dependsOn`, not read a `package.json` + itself, so it sees the same manifests every other detector does. --- diff --git a/src/resolution/frameworks/package-deps.ts b/src/resolution/frameworks/package-deps.ts index 1793848fee..907c679431 100644 --- a/src/resolution/frameworks/package-deps.ts +++ b/src/resolution/frameworks/package-deps.ts @@ -1,10 +1,12 @@ /** - * The dependencies a project declares — in its root `package.json` and in - * the ones one or two directories down (`apps/web/package.json`, - * `frontend/package.json`, `packages/api/package.json`). A framework detector - * that reads only the root misses every monorepo: proshop keeps `react` in - * `frontend/`, a Turborepo keeps `next` in `apps/web/`, and the resolver for - * that framework then never runs, so its routes never exist. + * The dependencies a project declares — in its root `package.json`, in the + * ones one or two directories down (`apps/web/package.json`, + * `frontend/package.json`, `packages/api/package.json`), and in the ones + * deeper still above JS/TS code (`src/Web/ClientApp/package.json`). A + * framework detector that reads only the root misses every monorepo: proshop + * keeps `react` in `frontend/`, a Turborepo keeps `next` in `apps/web/`, an + * ASP.NET solution keeps its Angular app in `src/Web/ClientApp/`, and the + * resolver for that framework then never runs, so its routes never exist. */ import type { ResolutionContext } from '../types'; @@ -12,6 +14,12 @@ import type { ResolutionContext } from '../types'; /** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */ const MAX_MANIFESTS = 24; +/** Directories probed for a manifest below the first two levels, at most. */ +const MAX_DEEP_PROBES = MAX_MANIFESTS * 8; + +/** A file a JavaScript framework's app is written in. Its app's manifest is in a directory above it. */ +const SCRIPT_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/; + /** * Cached per context, keyed by how many files are indexed. * @@ -26,7 +34,7 @@ const MAX_MANIFESTS = 24; */ const cache = new WeakMap }>(); -/** Every dependency name declared at the root or up to two directories down, de-duplicated. */ +/** Every dependency name declared in the manifests read (see the file comment), de-duplicated. */ export function declaredDependencies(context: ResolutionContext): Set { const files = context.getAllFiles(); const cached = cache.get(context); @@ -46,6 +54,16 @@ export function declaredDependencies(context: ResolutionContext): Set { if (manifests.length > MAX_MANIFESTS) break; if (!dir.includes('node_modules') && context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json'); } + // An app can sit deeper: an ASP.NET solution's single-page app in + // `src/Web/ClientApp/`, prometheus's React apps in `web/ui/mantine-ui/` + // under a workspace root at `web/ui/`. The slots left go to the directories + // above JS/TS code, shallowest first. + if (manifests.length <= MAX_MANIFESTS) { + for (const dir of deepScriptDirectories(files)) { + if (manifests.length > MAX_MANIFESTS) break; + if (context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json'); + } + } for (const manifest of manifests) { const content = context.readFile(manifest); if (!content) continue; @@ -62,6 +80,34 @@ export function declaredDependencies(context: ResolutionContext): Set { return names; } +/** + * The directories three or more levels down that hold indexed JS/TS code or a + * directory that does, never inside `node_modules`: shallowest first, at most + * {@link MAX_DEEP_PROBES} of them. Sorted within a level, so the sample does + * not depend on the order the files come in. Extraction lists them as git or a + * directory walk returns them, the resolver in path order, and each decides + * from its own list which frameworks run. + */ +function deepScriptDirectories(files: readonly string[]): string[] { + // levels[n]: the directories n levels down (`a/b/c/` is 3). + const levels: Set[] = []; + for (const file of files) { + if (!SCRIPT_FILE.test(file) || file.includes('node_modules')) continue; + let depth = 0; + for (let slash = file.indexOf('/'); slash >= 0; slash = file.indexOf('/', slash + 1)) { + if (++depth >= 3) (levels[depth] ??= new Set()).add(file.slice(0, slash + 1)); + } + } + const dirs: string[] = []; + for (let depth = 3; depth < levels.length; depth++) { + for (const dir of [...(levels[depth] ?? [])].sort()) { + if (dirs.length === MAX_DEEP_PROBES) return dirs; + dirs.push(dir); + } + } + return dirs; +} + /** True when any of `deps` is declared anywhere the project's manifests are read. */ export function dependsOn(context: ResolutionContext, ...deps: string[]): boolean { const names = declaredDependencies(context); From f797b8cad29ba09ea8f48c7c1e9c4c254a3d4fce Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 00:26:15 +0000 Subject: [PATCH 4/9] fix(liquid): a Shopify theme's sections and snippets resolve only inside that theme (#2396) A `{% render %}`, `{% section %}` or JSON template section `type` written in a file inside a Shopify theme now resolves to that theme's own `sections/` or `snippets/` file, or to nothing. It no longer reaches another theme's copy by path tail (panoply/syncify: using-tailwind's templates linked straps/dusk's sections), nor the root theme's copy over a folder theme's own. The theme detector is shared with isShopifyLiquidJson (#2393): shopifyThemeRoot in grammars.ts. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 78 +++++++++++++++++++++++++++++++- src/extraction/grammars.ts | 34 +++++++++++--- src/resolution/index.ts | 6 +++ src/resolution/shopify-themes.ts | 39 ++++++++++++++++ 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 src/resolution/shopify-themes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0c7c36c2..f6c0e71160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading. - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. +- 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. - 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. - 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. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 768e08da57..6dfa8cb42f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11,7 +11,7 @@ import * as os from 'os'; import { execFileSync } from 'child_process'; import { CodeGraph } from '../src'; import { extractFromSource, scanDirectory, scanDirectoryAsync, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore, type ScanSkipStats } from '../src/extraction'; -import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars'; +import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile, shopifyThemeRoot } from '../src/extraction/grammars'; import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp'; import { normalizePath } from '../src/utils'; import { generateNodeId } from '../src/extraction/tree-sitter-helpers'; @@ -7542,6 +7542,82 @@ describe('Liquid Shopify JSON template section resolution', () => { expect(detectLanguage('templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('yaml'); expect(detectLanguage('shop/templates/product.json', undefined, { '.json': 'yaml' }, tempDir)).toBe('liquid'); }); + + const writeFiles = (files: Record): void => { + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tempDir, file)), { recursive: true }); + fs.writeFileSync(path.join(tempDir, file), text); + } + }; + // A theme at the project root, a theme in a folder marked only by its settings + // schema, and one in a folder with no 404 section and no price snippet of its + // own — the shape of panoply/syncify's example themes. + const severalThemes = { + 'layout/theme.liquid': `{% section 'header' %}{{ content_for_layout }}\n`, + 'sections/header.liquid': `{% render 'price' %}\n`, + 'sections/main.liquid': `
\n`, + 'snippets/price.liquid': `{{ product.price | money }}\n`, + 'straps/dusk/config/settings_schema.json': '[]', + 'straps/dusk/sections/404.liquid': `

{{ 'templates.404.title' | t }}

\n`, + 'straps/dusk/templates/404.json': jsonTemplate('404'), + 'examples/tailwind/layout/theme.liquid': `{% section 'header' %}{{ content_for_layout }}\n`, + 'examples/tailwind/sections/header.liquid': `{% liquid\n render 'price'\n render 'icon'\n%}\n`, + 'examples/tailwind/sections/main.liquid': `
\n`, + 'examples/tailwind/snippets/icon.liquid': `\n`, + 'examples/tailwind/templates/index.json': jsonTemplate('main'), + 'examples/tailwind/templates/404.json': jsonTemplate('404'), + // Liquid in no theme: an app's theme extension. + 'extensions/reviews/blocks/stars.liquid': `{% render 'star' %}\n`, + 'extensions/reviews/snippets/star.liquid': `\n`, + }; + + it("links a theme's sections and snippets only inside that theme", async () => { + // Shopify looks a `{% render %}`, a `{% section %}` or a JSON template's + // section `type` up in the theme the file belongs to, never in another one. + // Matched by path tail, a theme without the 404 section linked to another + // theme's, and a theme in a folder even linked the project root's + // sections/header.liquid over its own. + writeFiles(severalThemes); + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + const linksOf = (file: string): string[] => cg.getFileDependencies(file).sort(); + expect(linksOf('layout/theme.liquid')).toEqual(['sections/header.liquid']); + expect(linksOf('sections/header.liquid')).toEqual(['snippets/price.liquid']); + expect(linksOf('straps/dusk/templates/404.json')).toEqual(['straps/dusk/sections/404.liquid']); + expect(linksOf('examples/tailwind/layout/theme.liquid')).toEqual(['examples/tailwind/sections/header.liquid']); + expect(linksOf('examples/tailwind/templates/index.json')).toEqual(['examples/tailwind/sections/main.liquid']); + // What the theme lacks stays unlinked: no other theme's 404 section or price snippet. + expect(linksOf('examples/tailwind/templates/404.json')).toEqual([]); + expect(linksOf('examples/tailwind/sections/header.liquid')).toEqual(['examples/tailwind/snippets/icon.liquid']); + // Outside any theme, a path still resolves as before. + expect(linksOf('extensions/reviews/blocks/stars.liquid')).toEqual(['extensions/reviews/snippets/star.liquid']); + }); + + it("keeps a theme's references inside it when its own section is deleted", async () => { + // Sync resolves again what named a deleted file; another theme's 404 + // section is no stand-in for the theme's own. + writeFiles({ ...severalThemes, 'examples/tailwind/sections/404.liquid': `

404

\n` }); + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + expect(cg.getFileDependencies('examples/tailwind/templates/404.json')).toEqual(['examples/tailwind/sections/404.liquid']); + + fs.rmSync(path.join(tempDir, 'examples/tailwind/sections/404.liquid')); + await cg.sync(); + expect(cg.getFileDependencies('examples/tailwind/templates/404.json')).toEqual([]); + }); + + it('finds the theme a file is in from the theme folder it sits in', () => { + const markers = new Set(['layout/theme.liquid', 'examples/tailwind/config/settings_schema.json']); + const exists = (relativePath: string): boolean => markers.has(relativePath); + expect(shopifyThemeRoot('sections/header.liquid', exists)).toBe(''); + expect(shopifyThemeRoot('templates/customers/login.json', exists)).toBe(''); + expect(shopifyThemeRoot('examples/tailwind/snippets/icon.liquid', exists)).toBe('examples/tailwind'); + // A copy kept elsewhere under the root theme is not part of it, nor is a file beside its folders. + expect(shopifyThemeRoot('vendor/legacy/snippets/icon.liquid', exists)).toBeUndefined(); + expect(shopifyThemeRoot('theme.liquid', exists)).toBeUndefined(); + }); }); describe('Lua/Luau require resolution', () => { diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 215925a219..ed1966c705 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -263,6 +263,31 @@ export function isSourceFile(filePath: string, overrides?: Record boolean, + folders: RegExp = SHOPIFY_THEME_FOLDERS, +): string | undefined { + const segments = filePath.split('/'); + for (let i = segments.length - 2; i >= 0; i--) { + if (!folders.test(segments[i]!)) continue; + const themeDir = segments.slice(0, i).join('/'); + if (SHOPIFY_THEME_MARKERS.some((marker) => exists(themeDir ? `${themeDir}/${marker}` : marker))) return themeDir; + } + return undefined; +} + /** * Shopify OS 2.0 JSON template (`templates/*.json`) or section group * (`sections/*.json`) — these reference sections by `"type"`, so the Liquid @@ -279,13 +304,8 @@ export function isShopifyLiquidJson(filePath: string, rootDir?: string): boolean // Allow nested template dirs (`templates/customers/login.json`), not just // top-level (`templates/product.json`). if (rootDir === undefined || !/(^|\/)(templates|sections)\/.+\.json$/i.test(filePath)) return false; - const segments = filePath.split('/'); - for (let i = 0; i < segments.length - 1; i++) { - if (!/^(templates|sections)$/i.test(segments[i]!)) continue; - const themeDir = path.join(rootDir, ...segments.slice(0, i)); - if (SHOPIFY_THEME_MARKERS.some((marker) => fs.existsSync(path.join(themeDir, marker)))) return true; - } - return false; + const exists = (relativePath: string): boolean => fs.existsSync(path.join(rootDir, relativePath)); + return shopifyThemeRoot(filePath, exists, /^(templates|sections)$/i) !== undefined; } /** diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 41a15843ec..5da83c0a21 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -29,6 +29,7 @@ import { clearDartLibraryMemos } from './dart-libraries'; import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers'; import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; +import { matchShopifyThemeFile } from './shopify-themes'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -1153,6 +1154,11 @@ export class ReferenceResolver { // constructor call, as written — never a method or function by its name. if (isDartAnnotation(ref)) return matchDartAnnotation(ref, this.context); + // 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). + const themeFile = matchShopifyThemeFile(ref, this.context); + if (themeFile !== undefined) return themeFile; + // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { return null; diff --git a/src/resolution/shopify-themes.ts b/src/resolution/shopify-themes.ts new file mode 100644 index 0000000000..3c2f3c8b5c --- /dev/null +++ b/src/resolution/shopify-themes.ts @@ -0,0 +1,39 @@ +/** + * A Shopify theme's section and snippet references → that theme's own files. + * + * `{% render 'price' %}` renders the `snippets/price.liquid` of the theme the + * file belongs to; `{% section 'header' %}` and a JSON template's section + * `"type"` name a file in that theme's `sections/`. Shopify never looks in + * another theme. The Liquid extractor records these as `snippets/.liquid` + * and `sections/.liquid`, and the path matcher took any file whose path + * ends with that, so in a repository holding several themes (a theme tool's + * examples, a store's seasonal themes) a theme missing a section linked to + * another theme's copy of it. + * + * From a file inside a theme (see `shopifyThemeRoot`), these references + * resolve to the theme's own file or to nothing, and never fall through to the + * generic name strategies. A reference from a file in no theme resolves as + * before. + */ +import { shopifyThemeRoot } from '../extraction/grammars'; +import type { ResolvedRef, ResolutionContext, UnresolvedRef } from './types'; + +/** + * The file a section or snippet reference made inside a Shopify theme names: + * the theme's own `sections/.liquid` or `snippets/.liquid`. + * undefined means the reference is not one, or is made outside any theme; + * null means the theme has no such file, so no other strategy may guess one. + */ +export function matchShopifyThemeFile(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'liquid' || !/^(sections|snippets)\//.test(ref.referenceName)) return undefined; + const theme = shopifyThemeRoot(ref.filePath, (relativePath) => context.fileExists(relativePath)); + if (theme === undefined) return undefined; + const target = theme === '' ? ref.referenceName : `${theme}/${ref.referenceName}`; + const file = context + .getNodesByName(target.slice(target.lastIndexOf('/') + 1)) + .find((n) => n.kind === 'file' && n.filePath === target); + if (!file) return null; + // The confidence the path matcher gives the same link: an exact path for a + // theme at the project root, a path tail for one in a folder. + return { original: ref, targetNodeId: file.id, confidence: theme === '' ? 0.95 : 0.85, resolvedBy: 'file-path' }; +} From 24501fa66c11a81da5a499425b5eabc79a2fb3f5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 00:29:50 +0000 Subject: [PATCH 5/9] fix(go): an interface links the interfaces it embeds (#2397) extractInheritance's Go interface-embedding branch matched a `constraint_elem`, a node type neither the vendored tree-sitter-go wasm nor the kernel's tree-sitter-go 0.23 crate has: both parse an embedded interface as a `type_elem` holding one type. So no Go interface ever extended anything, in either engine; gin's IRouter never reached IRoutes. Read an embedded type the same way in an interface and in a struct: `T`, `*T`, `pkg.T` and `T[X]` all embed `T`, recorded at the name so resolution reads the package back from the source (#2322). A type_elem holding a union, a `~T` term or a lone predeclared type is a type-set constraint and records nothing, and predeclared names (`error`, `any`) are skipped in structs too. Qualified and generic struct embeddings, skipped until now, link the same way. Resolution keeps an embedded type in its package. The Go framework resolver's naming heuristics no longer take extends/implements refs: its PascalCase-model pattern saw structs only, so an embedded interface went to another package's same-named struct (promql/parser's Node, etcd's Lease), as #1827 did for Spring. And a Go supertype written through a package that is none of the file's imports as indexed (`yaml` under an unaliased go.yaml.in/yaml/v3, registered as `v3`) stays unresolved instead of binding by its bare name. Validated on gin, prometheus and etcd (before/after edge diffs, every added supertype edge checked against its package; kernel/wasm parity sweeps unchanged). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fixtures/kernel-parity/torture.go | 13 + __tests__/go-interface-embedding.test.ts | 425 ++++++++++++++++++++ codegraph-kernel/src/go.rs | 56 ++- src/extraction/languages/go.ts | 26 ++ src/extraction/tree-sitter.ts | 42 +- src/resolution/frameworks/go.ts | 9 + src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 53 ++- 9 files changed, 583 insertions(+), 49 deletions(-) create mode 100644 __tests__/go-interface-embedding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c0e71160..a474c49531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,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. - 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. - 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 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/fixtures/kernel-parity/torture.go b/__tests__/fixtures/kernel-parity/torture.go index f89b8d429d..3aada94afc 100644 --- a/__tests__/fixtures/kernel-parity/torture.go +++ b/__tests__/fixtures/kernel-parity/torture.go @@ -17,6 +17,10 @@ var handlerTable = map[string]func(int){ type Widget struct { *Base Queryable + pkga.Embedded + *pkga.Pointer `json:"-"` + Stack[int] + error name string } @@ -26,10 +30,19 @@ type Stack[T any] struct { type Core interface { Reader + pkga.Closer // qualified + Lister[int] + error Marshal(v any) ([]byte, error) Unmarshal(data []byte) error } +type Number interface { + ~int | ~float64 +} + +type Exact interface{ int64 } + type Dur int func NewRegistry() *Registry { diff --git a/__tests__/go-interface-embedding.test.ts b/__tests__/go-interface-embedding.test.ts new file mode 100644 index 0000000000..fece4451e7 --- /dev/null +++ b/__tests__/go-interface-embedding.test.ts @@ -0,0 +1,425 @@ +/** + * Go interface embedding produced no supertype edge in either extractor: + * + * type IRouter interface { // gin-gonic/gin: IRouter never reached IRoutes + * IRoutes + * Group(string, ...HandlerFunc) *RouterGroup + * } + * + * extractInheritance looked for a `constraint_elem`, a node type that neither + * the vendored tree-sitter-go wasm nor the kernel's grammar crate has. Both + * parse an embedded interface as a `type_elem` holding one type: a + * `type_identifier` for `IRoutes`, a `qualified_type` for `io.Closer`. A + * `type_elem` is also how a type-set constraint is written (`~int | ~string`, + * `float32 | float64`, a lone `int64`), and a constraint is no supertype. + * + * An embedded type is now read the same way in an interface and in a struct: + * `T`, `*T`, `pkg.T` and `T[X]` all embed `T`, recorded at the name itself so + * resolution reads the package back from the source. `storage.LabelQuerier` + * reaches that package's interface; `io.Closer` and `sync.Mutex` stay + * unresolved rather than landing on a project type that shares the name. + * Go's predeclared types are never recorded: `int64` is a constraint's term, + * and `error`, `any` and `comparable` exist in no graph. Runs against the + * native kernel (when built) and the wasm extractor, which must agree. + * + * Resolution keeps an embedded type in its package. A bare name is its own + * package's type, not another package's struct of that name, which the Go + * framework heuristics preferred (promql/parser's `Node` interface, etcd's + * `Lease`). A package that is none of the file's imports as indexed (`yaml` + * under an unaliased `go.yaml.in/yaml/v3`) leaves the type unresolved. + */ +import { describe, it, expect, beforeAll, 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 { buildTypeHierarchy } from '../src/graph/type-hierarchy'; +import type { ExtractionResult, Node } 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'; + +/** Embedded interfaces of every shape, beside constraints that embed nothing. */ +const INTERFACE_SOURCE = `package store + +import ( + "io" + + "example.com/app/storage" +) + +type Queryable interface{ Q() } + +type Querier interface { + Queryable + io.Closer + storage.LabelQuerier // a project package's interface + Select(sorted bool) int +} + +type Lister[T any] interface{ List() []T } + +type Pager interface { + Lister[int] + Page() int +} + +type Timeout interface { + error + Timeout() bool +} + +type Number interface { + ~int | ~float64 +} + +type Float interface{ float32 | float64 } + +type Exact interface{ int64 } + +type Text interface{ ~string } + +type Ordered interface { + Number | Float | ~string +} +`; + +/** gin-gonic/gin's router interfaces. */ +const GIN_SOURCE = `package gin + +type HandlerFunc func(*Context) + +type IRoutes interface { + Use(...HandlerFunc) IRoutes + Handle(string, string, ...HandlerFunc) IRoutes +} + +type IRouter interface { + IRoutes + Group(string, ...HandlerFunc) *RouterGroup +} +`; + +/** Struct embedding takes the same shapes, and keeps its named fields out. */ +const STRUCT_SOURCE = `package store + +import ( + "sync" + + "example.com/app/storage" +) + +type Store struct { + *Engine + Reader + sync.Mutex + *storage.Base + List[int] + error + name string + engine *Engine + items []storage.Base +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` for every extends/implements ref, sorted. */ +function supertypeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements') + .map((r) => `${byId.get(r.fromNodeId)?.name ?? r.fromNodeId} ${r.referenceKind} ${r.referenceName}`) + .sort(); +} + +describe('Go embedded types are supertypes', () => { + 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', file: string, source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, 'go'); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** Every supertype ref sits on its name, where resolution reads a package qualifier back. */ + function expectRefsOnTheirNames(result: ExtractionResult, source: string): void { + const lines = source.split(/\r?\n/); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); + expect(refs.length).toBeGreaterThan(0); + for (const r of refs) { + expect(lines[r.line - 1]!.startsWith(r.referenceName, r.column), `${r.referenceName} at ${r.line}:${r.column}`).toBe(true); + } + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`an embedded interface extends; a constraint does not: %s${label}`, (backend) => { + const source = eol(INTERFACE_SOURCE); + const result = extract(backend, 'store/querier.go', source); + expect(result.nodes.filter((n) => n.kind === 'interface').map((n) => n.name).sort()).toEqual( + ['Exact', 'Float', 'Lister', 'Number', 'Ordered', 'Pager', 'Querier', 'Queryable', 'Text', 'Timeout'] + ); + expect(supertypeRefs(result)).toEqual([ + 'Pager extends Lister', + 'Querier extends Closer', + 'Querier extends LabelQuerier', + 'Querier extends Queryable', + ]); + expectRefsOnTheirNames(result, source); + // The interfaces' own methods are still its members, the embeddings are not. + const querier = result.nodes.find((n) => n.name === 'Querier')!; + const members = result.edges + .filter((e) => e.kind === 'contains' && e.source === querier.id) + .map((e) => result.nodes.find((n) => n.id === e.target)?.name); + expect(members).toEqual(['Select']); + }); + + it.each(backends)(`gin's IRouter extends IRoutes: %s${label}`, (backend) => { + const result = extract(backend, 'routergroup.go', eol(GIN_SOURCE)); + expect(supertypeRefs(result)).toEqual(['IRouter extends IRoutes']); + }); + + it.each(backends)(`an embedded struct field of any shape extends: %s${label}`, (backend) => { + const source = eol(STRUCT_SOURCE); + const result = extract(backend, 'store/store.go', source); + expect(supertypeRefs(result)).toEqual([ + 'Store extends Base', + 'Store extends Engine', + 'Store extends List', + 'Store extends Mutex', + 'Store extends Reader', + ]); + expectRefsOnTheirNames(result, source); + }); + } +}); + +describe('the type hierarchy of an indexed Go module', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + it('links embedded interfaces and structs to the package that declares them', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-embedding-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'storage/storage.go': [ + 'package storage', + '', + 'type LabelQuerier interface {', + '\tLabelValues(name string) []string', + '}', + '', + 'type Base struct{}', + '', + 'func (b *Base) Close() error { return nil }', + '', + ].join('\n'), + // Named like io.Closer, io.Reader, sync.Mutex and yaml.Node: never what those name. + 'other/other.go': [ + 'package other', + '', + 'type Closer interface{ Close() error }', + '', + 'type Mutex struct{}', + '', + 'type Reader interface{ Read(p []byte) (int, error) }', + '', + 'type Node struct{}', + '', + ].join('\n'), + 'store/store.go': [ + 'package store', + '', + 'import (', + '\t"io"', + '\t"sync"', + '', + '\t"example.com/app/storage"', + '\t"go.yaml.in/yaml/v3"', + ')', + '', + '// The package is yaml, though the index knows its import as v3.', + 'type RuleGroupNode struct {', + '\tyaml.Node', + '\tName string', + '}', + '', + 'type Queryable interface{ Q() }', + '', + 'type Querier interface {', + '\tQueryable', + '\tio.Closer', + '\tstorage.LabelQuerier', + '\tSelect(sorted bool) int', + '}', + '', + 'type Number interface {', + '\t~int | ~float64', + '}', + '', + 'type Engine struct{}', + '', + 'type Reader interface{ Read() }', + '', + 'type List[T any] struct{ items []T }', + '', + 'type Store struct {', + '\t*Engine', + '\tReader', + '\tsync.Mutex', + '\t*storage.Base', + '\tList[int]', + '}', + '', + ].join('\n'), + // Named like the io.Reader it embeds. + 'wrap/wrap.go': [ + 'package wrap', + '', + 'import "io"', + '', + 'type Reader interface {', + '\tio.Reader', + '\tPeek() byte', + '}', + '', + ].join('\n'), + 'gin/routergroup.go': GIN_SOURCE, + // prometheus's promql/parser: its own `Node` interface, while other/ has a `Node` struct. + 'parser/ast.go': [ + 'package parser', + '', + 'type Node interface{ String() string }', + '', + 'type Expr interface {', + '\tNode', + '\tType() string', + '}', + '', + 'type exprWrapper struct {', + '\tNode', + '}', + '', + ].join('\n'), + }; + 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 }); + const graph = cg; + + const one = (name: string, file: string): Node => { + const found = graph.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; + }; + const where = (n: Node | null) => `${n?.name}@${n?.filePath}`; + + const sources = [ + one('Querier', 'store/store.go'), + one('Number', 'store/store.go'), + one('Store', 'store/store.go'), + one('RuleGroupNode', 'store/store.go'), + one('Reader', 'wrap/wrap.go'), + one('IRouter', 'gin/routergroup.go'), + one('Expr', 'parser/ast.go'), + one('exprWrapper', 'parser/ast.go'), + ]; + const supertypes = graph + .getOutgoingEdgesFrom(sources.map((n) => n.id), ['extends', 'implements']) + .map((e) => `${graph.getNode(e.source)?.name} ${e.kind} ${where(graph.getNode(e.target))}`) + .sort(); + // An embedded interface of a struct resolves as `implements`; one of an + // interface stays `extends`. io.Closer, sync.Mutex, io.Reader and yaml.Node + // are outside the project, so nothing named like them is linked. A bare + // name is its own package's type, whatever kind another package's is. + expect(supertypes).toEqual([ + 'Expr extends Node@parser/ast.go', + 'IRouter extends IRoutes@gin/routergroup.go', + 'Querier extends LabelQuerier@storage/storage.go', + 'Querier extends Queryable@store/store.go', + 'Store extends Base@storage/storage.go', + 'Store extends Engine@store/store.go', + 'Store extends List@store/store.go', + 'Store implements Reader@store/store.go', + 'exprWrapper implements Node@parser/ast.go', + ]); + + // The outside supertypes are kept as unresolved references, not dropped. + const unresolved = (n: Node) => + graph + .getUnresolvedReferencesFrom(n.id) + .filter((r) => r.referenceKind === 'extends') + .map((r) => r.referenceName) + .sort(); + expect(unresolved(one('Querier', 'store/store.go'))).toEqual(['Closer']); + expect(unresolved(one('Store', 'store/store.go'))).toEqual(['Mutex']); + expect(unresolved(one('RuleGroupNode', 'store/store.go'))).toEqual(['Node']); + expect(unresolved(one('Reader', 'wrap/wrap.go'))).toEqual(['Reader']); + + // What the viewer's Type hierarchy and codegraph_explore read. Declared + // subtypes only: Go's implicit-satisfaction edges are synthesized from + // method names (Base's Close() satisfies other.Closer) and drawn apart. + const subtypes = (n: Node) => + (buildTypeHierarchy(graph, n)?.descendants ?? []) + .filter((d) => !d.synthesized) + .map((d) => `${d.relation} ${where(d.node)}`); + expect(subtypes(one('IRoutes', 'gin/routergroup.go'))).toEqual(['extends IRouter@gin/routergroup.go']); + expect(subtypes(one('LabelQuerier', 'storage/storage.go'))).toEqual(['extends Querier@store/store.go']); + expect(subtypes(one('Base', 'storage/storage.go'))).toEqual(['extends Store@store/store.go']); + const declaredInto = graph + .getIncomingEdgesTo(['Closer', 'Mutex', 'Reader', 'Node'].map((name) => one(name, 'other/other.go').id), ['extends', 'implements']) + .filter((e) => e.provenance !== 'heuristic'); + expect(declaredInto).toEqual([]); + expect( + (buildTypeHierarchy(graph, one('IRouter', 'gin/routergroup.go'))?.ancestors ?? []).map((a) => `${a.relation} ${where(a.node)}`) + ).toEqual(['extends IRoutes@gin/routergroup.go']); + }, 60_000); +}); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index 239c788a6e..efc67971bb 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -4,8 +4,8 @@ //! Go's shape quirks, mirrored exactly: methods are top-level with a receiver //! (qualifiedName override `Recv::name` + a contains edge to the FIRST //! earlier-in-file struct of that name), structs/interfaces arrive as -//! `type_spec` and classify via the inner type node (struct embedding → -//! extends; interface method_elems become method nodes), composite literals +//! `type_spec` and classify via the inner type node (struct and interface +//! embedding → extends; interface method_elems become method nodes), composite literals //! (`pkga.Widget{}`) keep their package qualifier as `instantiates` refs, //! top-level var/const specs walk their initializers ATTRIBUTED to the //! declared symbol (#693), 2-hop field chains (`t.conn.Exec`) keep the chain @@ -846,20 +846,42 @@ impl<'t> Walker<'t> { } } - /// extractInheritance — the Go branches: interface embedding - /// (constraint_elem) and struct embedding (field_declaration without a - /// field_identifier), plus the field_declaration_list recursion. + /// goEmbeddedTypeName (languages/go.ts): the name node of the type an + /// embedding names — `T`, `*T` (the `*` is a sibling token), `pkg.T` and + /// `T[X]` all embed `T`. A qualified type yields its name; resolution reads + /// the package back from the source at that position. None for any other + /// type and for a predeclared one. + fn embedded_type_name(&self, ty: Option>) -> Option> { + let mut ty = ty?; + if ty.kind() == "generic_type" { + ty = ty.child_by_field_name("type")?; + } + match ty.kind() { + "qualified_type" => ty.child_by_field_name("name"), + "type_identifier" if !is_go_predeclared_type(self.text(ty)) => Some(ty), + _ => None, + } + } + + /// extractInheritance — the Go branches: interface embedding (a type_elem + /// holding one named type; a union, `~T` or basic type is a constraint) + /// and struct embedding (field_declaration without a field_identifier), + /// plus the field_declaration_list recursion. fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) { stack_guard!(); let extends_kind = edge_kind_index("extends").unwrap(); for i in 0..node.named_child_count() { let Some(child) = node.named_child(i) else { continue }; match child.kind() { - "constraint_elem" => { - let type_id = (0..child.named_child_count()) + "type_elem" => { + let mut terms = (0..child.named_child_count()) .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { + .filter(|c| c.kind() != "comment"); + let only = match (terms.next(), terms.next()) { + (Some(term), None) => Some(term), + _ => None, + }; + if let Some(type_id) = self.embedded_type_name(only) { let name = self.text(type_id).to_string(); self.push_ref_at(class_row, &name, extends_kind, type_id); } @@ -869,10 +891,7 @@ impl<'t> Walker<'t> { .filter_map(|j| child.named_child(j)) .any(|c| c.kind() == "field_identifier"); if !has_field_identifier { - let type_id = (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .find(|c| c.kind() == "type_identifier"); - if let Some(type_id) = type_id { + if let Some(type_id) = self.embedded_type_name(child.child_by_field_name("type")) { let name = self.text(type_id).to_string(); self.push_ref_at(class_row, &name, extends_kind, type_id); } @@ -1253,6 +1272,17 @@ fn is_builtin_type(name: &str) -> bool { ) } +/// GO_PREDECLARED_TYPES (languages/go.ts): never a node in the graph, and as +/// the lone term of an interface a basic type is a constraint, not an embedding. +fn is_go_predeclared_type(name: &str) -> bool { + matches!( + name, + "any" | "bool" | "byte" | "comparable" | "complex64" | "complex128" | "error" | "float32" + | "float64" | "int" | "int8" | "int16" | "int32" | "int64" | "rune" | "string" | "uint" + | "uint8" | "uint16" | "uint32" | "uint64" | "uintptr" + ) +} + fn opt_str(arena: &mut Arena, s: Option<&str>) -> StrRef { match s { Some(s) => arena.put(s), diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index 5e41fbfaa6..1bad7ff225 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -38,6 +38,32 @@ function extractGoReturnType(node: SyntaxNode, source: string): string | undefin return last; } +/** + * Go's predeclared types. None is ever a node in the graph, and as the lone + * term of an interface (`interface{ int64 }`) a basic type is a type-set + * constraint, not an embedded interface. + */ +const GO_PREDECLARED_TYPES: ReadonlySet = new Set([ + 'any', 'bool', 'byte', 'comparable', 'complex64', 'complex128', 'error', 'float32', 'float64', + 'int', 'int8', 'int16', 'int32', 'int64', 'rune', 'string', 'uint', 'uint8', 'uint16', 'uint32', + 'uint64', 'uintptr', +]); + +/** + * The name node of the type a Go embedding names, in a struct or an interface: + * `Base`, `*Base` (the `*` is a sibling token), `pkg.Base` and `Base[T]` all + * embed `Base`. A qualified type yields its name, not its package: the package + * stays in the source text, and resolution reads it back from the reference's + * position (#2322). Undefined for any other type (a literal, `~T`, a union's + * term) and for a predeclared one. + */ +export function goEmbeddedTypeName(type: SyntaxNode | null | undefined, source: string): SyntaxNode | undefined { + if (type?.type === 'generic_type') type = getChildByField(type, 'type'); + if (type?.type === 'qualified_type') return getChildByField(type, 'name') ?? undefined; + if (type?.type !== 'type_identifier') return undefined; + return GO_PREDECLARED_TYPES.has(getNodeText(type, source)) ? undefined : type; +} + export const goExtractor: LanguageExtractor = { functionTypes: ['function_declaration'], classTypes: [], // Go doesn't have classes diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9ea465cada..997d735170 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -23,6 +23,7 @@ import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; +import { goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -3795,7 +3796,7 @@ export class TreeSitterExtractor { * contained by the interface (e.g. `Marshal`, `Unmarshal` of a `Core` * interface). tree-sitter-go names these `method_elem` (newer) or * `method_spec` (older). Embedded interfaces (`Reader` inside `ReadWriter`) - * are `type_identifier`s, not methods, and are left to inheritance extraction. + * are `type_elem`s, not methods, and are left to inheritance extraction. */ private extractGoInterfaceMethods(interfaceType: SyntaxNode, ifaceId: string): void { this.nodeStack.push(ifaceId); @@ -6984,15 +6985,18 @@ export class TreeSitterExtractor { } } - // Go interface embedding: `type Querier interface { LabelQuerier; ... }` - // constraint_elem wraps the embedded interface type identifier - if (child.type === 'constraint_elem') { - const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier'); + // Go interface embedding: `type IRouter interface { IRoutes; Group(…) }`. + // tree-sitter-go parses every element of an interface that is not a + // method as a `type_elem`; one holding a single named type embeds that + // interface. A union (`float32 | float64`), an underlying type (`~int`) + // or a lone basic type (`int64`) is a type-set constraint instead. + if (this.language === 'go' && child.type === 'type_elem') { + const terms = child.namedChildren.filter((c: SyntaxNode) => c.type !== 'comment'); + const typeId = terms.length === 1 ? goEmbeddedTypeName(terms[0], this.source) : undefined; if (typeId) { - const name = getNodeText(typeId, this.source); this.unresolvedReferences.push({ fromNodeId: classId, - referenceName: name, + referenceName: getNodeText(typeId, this.source), referenceKind: 'extends', line: typeId.startPosition.row + 1, column: typeId.startPosition.column, @@ -7001,25 +7005,23 @@ export class TreeSitterExtractor { } // Go struct embedding: field_declaration without field_identifier - // e.g. `type DB struct { *Head; Queryable }` — no field name means embedded type. + // e.g. `type DB struct { *Head; Queryable; sync.Mutex }` — no field name + // means an embedded type, read the way an interface's is. // Go only: C, C++ and Objective-C members are field_declarations too, but // nest their name inside the declarator (`Cache* cache_;`, `jv elems[];`, // `Status Get(int);`), so this test read every such member's type as a // supertype. Their supertypes come from base_class_clause / superclass. if (this.language === 'go' && child.type === 'field_declaration') { const hasFieldIdentifier = child.namedChildren.some((c: SyntaxNode) => c.type === 'field_identifier'); - if (!hasFieldIdentifier) { - const typeId = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier'); - if (typeId) { - const name = getNodeText(typeId, this.source); - this.unresolvedReferences.push({ - fromNodeId: classId, - referenceName: name, - referenceKind: 'extends', - line: typeId.startPosition.row + 1, - column: typeId.startPosition.column, - }); - } + const typeId = hasFieldIdentifier ? undefined : goEmbeddedTypeName(getChildByField(child, 'type'), this.source); + if (typeId) { + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: getNodeText(typeId, this.source), + referenceKind: 'extends', + line: typeId.startPosition.row + 1, + column: typeId.startPosition.column, + }); } } diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index 5e819060eb..b90d20deaa 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -26,6 +26,15 @@ export const goResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // An embedded type is the one its package declares under that name: the + // same package's for `Node`, the import's for `pkg.Node`, which name + // matching and the import resolver work out. The patterns below pick among + // every same-named node by folder convention, and Pattern 4 sees structs + // only: `Node` embedded beside promql/parser's `type Node interface` went + // to discovery/kubernetes's struct `Node`, etcd's `Client` embedding its + // own `Lease` interface to the server's `Lease` struct. + if (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') return null; + // Pattern 1: Handler references if (ref.referenceName.endsWith('Handler') || ref.referenceName.startsWith('Handle')) { const result = resolveByNameAndKind(ref, 'function', HANDLER_DIRS, context); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5da83c0a21..bf0917ad7e 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 } 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 } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -3056,6 +3056,11 @@ export class ReferenceResolver { result = { ...result, targetNodeId: type.id }; } if (isBoundToOutOfRepoImport(ref, this.context)) return null; + // A Go type embedded through a package (`yaml.Node`) is that package's. + // When the package is none of the file's imports as indexed, a type found + // by its bare name is some other package's namesake: prometheus's + // `RuleGroupNode` embeds yaml's `Node`, not discovery/kubernetes's. + if (ref.language === 'go' && isGoUnknownQualified(ref, this.context)) return null; return result; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d86047ebd3..794d2af014 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1883,32 +1883,55 @@ function rustModuleDir(filePath: string): string { return path.posix.join(dir, base.replace(/\.rs$/, '')); } -const GO_QUALIFIERS = new WeakMap>(); +interface GoQualification { + /** The package name written before the reference's name, as spelled. */ + written?: string; + /** The file's import that name is. */ + imported?: ImportMapping; +} -/** - * 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. - */ -export function goRefQualifier(ref: UnresolvedRef, context: ResolutionContext): ImportMapping | undefined { - if (ref.referenceKind === 'imports') return undefined; +const GO_QUALIFIERS = new WeakMap>(); + +function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQualification { + if (ref.referenceKind === 'imports') return {}; const name = ref.referenceName.split('.').pop()!; - if (!/^[A-Za-z_]\w*$/.test(name)) return undefined; + if (!/^[A-Za-z_]\w*$/.test(name)) return {}; let memo = GO_QUALIFIERS.get(context); if (!memo) GO_QUALIFIERS.set(context, (memo = new Map())); const key = `${ref.filePath}\0${ref.line}\0${ref.column}\0${ref.referenceName}`; const hit = memo.get(key); - if (hit !== undefined) return hit ?? undefined; + 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. - const before = line.startsWith(name, at) ? /(?:^|[^\w.])([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] + const written = line.startsWith(name, at) ? /(?:^|[^\w.])([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] : !new RegExp(`(? m.localName === before) : undefined; - memo.set(key, imported ?? null); - return imported; + const imported = written ? context.getImportMappings(ref.filePath, 'go').find((m) => m.localName === written) : undefined; + const found = { written, imported }; + 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. + */ +export function goRefQualifier(ref: UnresolvedRef, context: ResolutionContext): ImportMapping | undefined { + return goRefQualification(ref, context).imported; +} + +/** + * Whether a Go reference is written through a package that is none of its + * file's imports as the index knows them: `yaml` in `yaml.Node` under an + * unaliased `import "go.yaml.in/yaml/v3"`, which is recorded under its last + * path element, `v3`. Which package that is cannot be told from here. + */ +export function isGoUnknownQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { + const { written, imported } = goRefQualification(ref, context); + return written !== undefined && imported === undefined; } /** From 9a274a4b47e2189452560f35bc6147c5d08ecacb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 00:33:38 +0000 Subject: [PATCH 6/9] fix(angular): a binding, interpolation or control-flow block links the member it calls (#2398) The Angular template pass read own-member calls only in event bindings, so a member a template calls from `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading())` had no incoming edge. With signals that is the usual way a template reads state, and the dead-code derivation listed such members as unused (CleanArchitecture's ThemeToggleComponent icon and label). Every render-time expression is now read: property bindings, interpolations, structural-directive microsyntax, control-flow blocks and @let. A call of one of the component's own members becomes an `angular-binding` edge with the binding as `via` and no trigger. It runs at render, and a trigger drew computed signals as user actions in Steps. A read without a call counts only for a method-kind member: a getter is called, a function handed to a child is a fnRef reference, and a field a call filled is a plain reference. Property reads link nothing, as in TypeScript. Pipes, object and microsyntax keys, members of other objects, template locals, strings and comments link nothing either. Validated on CleanArchitecture, angular-realworld, ngx-admin and Ghostfolio: 272 edges added, none removed, and every registeredAt line checked. The proshop_mern and next-saas-starter dumps are byte-identical. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/angular-router.test.ts | 258 +++++++++ docs/design/framework-coverage.md | 32 +- docs/viewer-launch-changelog.md | 2 + .../angular-template-synthesizer.ts | 492 +++++++++++++++++- 5 files changed, 773 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a474c49531..700436a781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - 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 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. +- 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/angular-router.test.ts b/__tests__/angular-router.test.ts index 52c13ca47d..20f9e236aa 100644 --- a/__tests__/angular-router.test.ts +++ b/__tests__/angular-router.test.ts @@ -14,6 +14,8 @@ import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router'; +import { readMicrosyntax, templateMemberUses } from '../src/resolution/angular-template-synthesizer'; +import { buildDeadCodeReport } from '../src/graph/dead-code'; import { buildScreens } from '../src/ui-server/api/screens'; import { buildSteps } from '../src/ui-server/api/steps'; @@ -111,6 +113,83 @@ describe('Angular destinations', () => { }); }); +describe('what a template reads at render', () => { + const uses = (template: string) => templateMemberUses(template).map((u) => `${u.name}${u.call ? '()' : ''} ${u.via}`); + + it('reads property bindings, interpolations, structural directives, control flow and @let', () => { + expect(uses(``)).toEqual([ + 'label() [attr.aria-label]', + 'icon() [name]', + ]); + expect(uses(`

{{ count() }} {{ total }}

`)).toEqual([ + 'isOpen() *ngIf', + 'count() {{ }}', + 'total {{ }}', + ]); + expect(uses(`@if (loading()) { x } @else if (failed()) { y } @switch (mode()) { @case (Mode.A) { a } }`)).toEqual([ + 'loading() @if', + 'failed() @else if', + 'mode() @switch', + 'Mode @case', + ]); + // `on timer(5s)` is a trigger, not a call. + expect(uses(`@defer (on viewport; when ready(); prefetch on timer(5s)) { x }`)).toEqual(['ready() @defer']); + expect(uses(`@let total = price() * quantity; `)).toEqual(['price() @let', 'quantity @let', 'title [(ngModel)]']); + }); + + it("leaves out a pipe, an object key, another object's member, a string, a comment and an event binding", () => { + expect(uses(`{{ total | currency: code() }} `)).toEqual([ + 'total {{ }}', + 'code() {{ }}', + 'isActive() [ngClass]', + 'form [x]', + 'own() [x]', + ]); + expect(uses(` {{ 'quoted()' }} `)).toEqual([]); + // A template literal's text is a string; its holes are expressions. + expect(uses('')).toEqual([ + 'isToday() [class.today]', + 'key [class.today]', + 'formatDay() [class.today]', + 'day [class.today]', + ]); + }); + + it('leaves out the names the template declares, where they are visible', () => { + // A microsyntax's locals, keys and context names belong to the directive. + expect(uses(`
  • {{ i }}
  • `)).toEqual([ + 'items() *ngFor', + 'trackById *ngFor', + ]); + // An alias belongs to the body, not to the condition that names it. + expect(uses(`@if (user(); as user) { {{ user.name }} }`)).toEqual(['user() @if']); + expect(uses(`
    {{ user.name }}
    `)).toEqual(['user() *ngIf']); + // `track` sees the loop's own names. + expect(uses(`@for (item of items(); track item.id; let odd = $odd) { {{ item.name }} {{ odd }} }`)).toEqual(['items() @for']); + expect(uses(`@let total = price(); {{ total }}`)).toEqual(['price() @let']); + // A reference variable is seen across the template, even before it is declared. + expect(uses(``)).toEqual([]); + expect(uses(`{{ row.name }} {{ i }}`)).toEqual([]); + }); + + it('splits a microsyntax into its expressions and its locals', () => { + expect(readMicrosyntax('let item of items | async; index as i; trackBy: trackFn')).toEqual({ + expressions: [ + { at: 12, text: 'items | async' }, + { at: 48, text: 'trackFn' }, + ], + locals: ['item', 'i'], + }); + expect(readMicrosyntax('user$ | async as user; else loading')).toEqual({ + expressions: [ + { at: 0, text: 'user$ | async ' }, + { at: 28, text: 'loading' }, + ], + locals: ['user'], + }); + }); +}); + // --------------------------------------------------------------------------- // End to end // --------------------------------------------------------------------------- @@ -318,6 +397,185 @@ export class AdminRoutingModule {} }); }); +describe("an Angular template's bindings, indexed", () => { + let cg: CodeGraph; + let root: string; + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-bindings-')); + projects.push(root); + const files: Record = { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0', '@angular/router': '^19.0.0' } }), + 'src/app/app.routes.ts': `import { Routes } from '@angular/router'; +import { TasksComponent } from './tasks/tasks.component'; +export const routes: Routes = [{ path: 'tasks', component: TasksComponent }]; +`, + // An inline template: its bindings are written in the class's own file. + 'src/app/nav/nav.component.ts': `import { Component, signal } from '@angular/core'; +@Component({ + selector: 'app-nav', + template: \`\`, +}) +export class NavComponent { + isOpen = signal(false); +} +`, + 'src/app/theme-toggle/theme-toggle.component.ts': `import { Component, computed, signal } from '@angular/core'; +@Component({ selector: 'app-theme-toggle', templateUrl: './theme-toggle.component.html' }) +export class ThemeToggleComponent { + private readonly theme = signal<'light' | 'dark'>('light'); + icon = computed(() => (this.theme() === 'dark' ? 'moon' : 'sun')); + label = computed(() => (this.theme() === 'dark' ? 'Dark' : 'Light')); + cycle(): void { + this.theme.set(this.theme() === 'dark' ? 'light' : 'dark'); + } + unused(): void {} +} +`, + 'src/app/theme-toggle/theme-toggle.component.html': ` +`, + 'src/app/tasks/tasks.component.ts': `import { Component, computed, signal } from '@angular/core'; +@Component({ selector: 'app-tasks', templateUrl: './tasks.component.html' }) +export class TasksComponent { + lists = signal<{ id: number; title: string; items: { done: boolean }[] }[]>([]); + selectedListId = signal(null); + selected = computed(() => this.lists().find((l) => l.id === this.selectedListId())); + editing = signal(false); + title = 'Tasks'; + form = { reset() {} }; + days = Array.from({ length: 3 }, (_, i) => i + 1); + row = computed(() => 0); + nameInput = (value: string) => value; + + get canSave(): boolean { + return this.lists().length > 0; + } + remainingItems(list: { items: { done: boolean }[] }): number { + return list.items.filter((i) => !i.done).length; + } + trackById(index: number, list: { id: number }): number { + return list.id; + } + displayFn(list: { title: string }): string { + return list.title; + } + format(value: string): string { + return value; + } + reset(): void {} + set(): void {} + legacy(): string { + return ''; + } + quoted(): string { + return ''; + } + save(): void {} +} +`, + 'src/app/tasks/tasks.component.html': ` +

    {{ 'quoted()' }}

    + +@if (editing()) { + editing +} +
      + @for (list of lists(); track trackById($index, list)) { +
    • + {{ list.title | format }} {{ remainingItems(list) }} +
    • + } +
    +@for (row of [selected()]; track row) { + {{ row?.title }} +} +@for (day of days; track day) { + {{ day }} +} + + +`, + }; + 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()); + + /** `Source -> target [kind] via @ file:line` for every template edge of one mechanism. */ + const templateEdges = (synthesizedBy: string) => + cg + .getNodesByKind('class') + .flatMap((c) => + cg + .getOutgoingEdgesFrom([c.id], ['calls', 'references']) + .filter((e) => (e.metadata as Record | undefined)?.synthesizedBy === synthesizedBy) + .map((e) => { + const meta = e.metadata as Record; + const target = cg.getNode(e.target)!; + return `${c.name} -> ${target.kind} ${target.name} [${e.kind}${meta.fnRef ? ' fnRef' : ''}] ${String(meta.via)} @ ${String(meta.registeredAt)}`; + }) + ) + .sort(); + + it("links each binding's own-member call — and a callable it only reads — to the component, at the binding's line", () => { + expect(templateEdges('angular-binding')).toEqual([ + 'NavComponent -> property isOpen [calls] [class.open] @ src/app/nav/nav.component.ts:5', + // A getter runs when it is read. + 'TasksComponent -> method canSave [calls] [disabled] @ src/app/tasks/tasks.component.html:21', + // A field a call filled is read, not called. + 'TasksComponent -> method days [references] @for @ src/app/tasks/tasks.component.html:17', + // A function handed to a child is a function reference. + 'TasksComponent -> method displayFn [references fnRef] [displayWith] @ src/app/tasks/tasks.component.html:20', + 'TasksComponent -> method remainingItems [calls] {{ }} @ src/app/tasks/tasks.component.html:10', + 'TasksComponent -> method trackById [calls] @for @ src/app/tasks/tasks.component.html:8', + // A signal is a field, and calling it reads it. + 'TasksComponent -> property editing [calls] @if @ src/app/tasks/tasks.component.html:4', + 'TasksComponent -> property lists [calls] @for @ src/app/tasks/tasks.component.html:8', + 'TasksComponent -> property selectedListId [calls] [class.active] @ src/app/tasks/tasks.component.html:9', + 'ThemeToggleComponent -> method icon [calls] [name] @ src/app/theme-toggle/theme-toggle.component.html:2', + 'ThemeToggleComponent -> method label [calls] [attr.aria-label] @ src/app/theme-toggle/theme-toggle.component.html:1', + ]); + // An inline template's edge sits on its own line of the class's file. + const nav = cg.getNodesByKind('class').find((c) => c.name === 'NavComponent')!; + expect(cg.getOutgoingEdgesFrom([nav.id], ['calls']).find((e) => cg.getNode(e.target)?.name === 'isOpen')?.line).toBe(5); + }); + + it('links nothing for a field read, a pipe, a member of another object, a comment, a string or a name the template declares', () => { + const tasks = cg.getNodesByKind('class').find((c) => c.name === 'TasksComponent')!; + const linked = cg.getOutgoingEdgesFrom([tasks.id], ['calls', 'references']).map((e) => cg.getNode(e.target)?.name); + for (const name of ['title', 'format', 'reset', 'set', 'legacy', 'quoted', 'row', 'nameInput']) expect(linked).not.toContain(name); + // A member the event bindings already link keeps that edge: `selected()` is both. + expect(templateEdges('angular-event')).toEqual([ + 'TasksComponent -> method save [calls] (blur) @ src/app/tasks/tasks.component.html:3', + 'TasksComponent -> method selected [calls] (blur) @ src/app/tasks/tasks.component.html:3', + 'ThemeToggleComponent -> method cycle [calls] (click) @ src/app/theme-toggle/theme-toggle.component.html:1', + ]); + }); + + it('takes members a template uses off the dead-code list, and keeps the ones it does not', () => { + const listed = buildDeadCodeReport(cg, { limit: 100 }).entries.map((e) => e.node.name); + expect(listed).toContain('unused'); + expect(listed).not.toContain('icon'); + expect(listed).not.toContain('label'); + expect(listed).not.toContain('remainingItems'); + expect(listed).not.toContain('canSave'); + }); + + it('draws a binding as render-time work in Steps, not as something the user triggers', async () => { + const screen = cg.getNodesByKind('route').find((r) => r.name === '/tasks')!; + const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: screen.id })); + const labels = payload.steps.map((s) => s.label); + expect(payload.steps.find((s) => s.label === 'save')?.trigger).toMatchObject({ kind: 'prop', name: '(blur)', of: 'input' }); + for (const name of ['remainingItems', 'canSave', 'trackById', 'lists']) expect(labels).not.toContain(name); + }); +}); + describe('an Angular workspace split across libraries', () => { const roots: string[] = []; afterAll(() => { diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index cbd66ffef8..e08bb44262 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -1,6 +1,6 @@ # Framework & language coverage — what is done, what is left -**Last verified: 2026-08-29** (Angular row: 2026-09-29) against the build at that date. Re-verify with the +**Last verified: 2026-08-29** (Angular row: 2026-10-06) against the build at that date. Re-verify with the queries in [Checking this file is still true](#checking-this-file-is-still-true) before trusting a row; this is a snapshot, not a live view. @@ -42,7 +42,7 @@ guessed. | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | -| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders), Ghostfolio (189 edges, 170 renders), ngx-admin (routes and renders; its menus are config), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) | +| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders, 32 bindings), Ghostfolio (189 edges, 170 renders, 215 bindings), ngx-admin (routes and renders; its menus are config; 13 bindings), CleanArchitecture's `src/Web/ClientApp` (signals; 12 bindings), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) | Shared machinery all seven use, in `frameworks/expo-router.ts`: `RouteTable` / `RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`, @@ -59,13 +59,35 @@ field written in a component's class (a tab bar's or a menu's config, bound in a loop elsewhere) counts as a link from that component. An event binding (`(click)="save()"`) is a `calls` edge from the component to its own method carrying `metadata.trigger`, which Steps uses in place of reading a trigger -at the edge's line (the binding is in the template, not the source there). A route with +at the edge's line (the binding is in the template, not the source there). +A property binding, an interpolation, a structural directive or a +control-flow block that calls one of the component's own members +(`[name]="icon()"`, `{{ label() }}`, `*matRowDef="let row; columns: +displayedColumns()"`, `@if (loading())`, `@let total = price();`) is a +`calls` edge too (`angular-binding`, the binding as `via`), with no trigger: +it runs when the template renders, so Steps folds it into the screen rather +than drawing a handler the user fires — a trigger there drew `computed` +signals as user actions. A member read without a call counts only when it is +method-kind: a getter is called, a method handed to a child +(`[displayWith]="displayFn"`, `trackBy: trackById`) is a `references` edge +with `fnRef`, and a field a call filled (`days = Array.from(…)`) a plain +`references`. A property read (`[value]="title"`) links nothing, as in +TypeScript, which records no property reads either; on angular-realworld, +ngx-admin, Ghostfolio and CleanArchitecture it would have added four times +the edges and taken nothing off the Dead code list, which never asks about +properties. A pipe, an object key, a +microsyntax key (`of`, `trackBy:`, `index`), a template's own names (`let +item`, `as user`, `#ref`, `@let x`, `let-row`) and a commented-out binding +link nothing. A route with `children` is a layout: its component carries a `references` edge marked `layout: true` from each screen nested in it, and `routeLayouts` in `route-roots.ts` gives Screens every screen a layout serves. Known limits: a route with a custom `matcher` has no static address, a relative navigation -(`relativeTo`) is left unresolved, and an edit to a template file alone is -picked up at the next sync of any source file (templates are not watched). +(`relativeTo`) is left unresolved, an edit to a template file alone is +picked up at the next sync of any source file (templates are not watched), a +member a component inherits from a base class is not linked from its +template, and a template's local hides a same-named member for the rest of +the template rather than only inside its block. --- diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 0342462797..f7a4174137 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -209,6 +209,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel - **A file's outside imports are listed in more languages.** The file screen lists the imports that name nothing in your project, like a third-party package or a system header. In C and C++, Objective-C, JavaScript and TypeScript, Python, C#, Lua, R, Solidity, Svelte and Vue, an import that named no file had been linked to its own import statement, so it was listed nowhere. Re-index after upgrading. +- **What an Angular template uses is no longer dead code.** A signal, `computed` value, getter or method that a component's template calls from a property binding, an interpolation, a structural directive or a control-flow block — `[name]="icon()"`, `{{ label() }}`, `*matRowDef="let row; columns: displayedColumns()"`, `@if (loading())` — or hands to a child, like `[displayWith]="displayFn"`, now counts as reached, so it leaves the Dead code tab. Steps draws it as part of what the screen renders, not as something the user does. Re-index Angular projects after upgrading. + ## Entries whose graph and `codegraph_explore` half already shipped The release notes carry a reworded version of each of these. Keep only the viewer half at launch. diff --git a/src/resolution/angular-template-synthesizer.ts b/src/resolution/angular-template-synthesizer.ts index 9ec76c1e5d..b5b7ce206d 100644 --- a/src/resolution/angular-template-synthesizer.ts +++ b/src/resolution/angular-template-synthesizer.ts @@ -2,7 +2,7 @@ * Angular templates: what a component renders and where it links. * * An Angular component's markup is a template — a `templateUrl` file beside - * it, or an inline `template:` string — that the index never parses, so three + * it, or an inline `template:` string — that the index never parses, so four * things a reader relies on were missing from the graph: * * - **The component tree.** `` in the @@ -23,6 +23,21 @@ * (`synthesizedBy: 'angular-event'`) carries the binding as its * `trigger`, the label Steps puts on the hop — read from the template, so * it cannot be read back from the source at the edge's line. + * - **Bindings.** `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` and + * `@if (loading()) {` call the component's own members at every render — + * with signals (`icon = computed(…)`), the usual way a template reads + * state, and often a member's only caller. Each is a `calls` edge + * (`synthesizedBy: 'angular-binding'`, the binding as `via`) WITHOUT a + * trigger: the call runs when the template renders, not when a user acts, + * the way a JSX attribute's `name={icon()}` carries none. Reading a member + * without calling it counts only when the member is method-kind: a getter + * (`[disabled]="!canSave"`) runs, so it is called; a method handed to a + * child (`[displayWith]="displayFn"`, `trackBy: trackById`) is a function + * reference (`references`, `fnRef`), as `this.handler` passed as an + * argument is in TypeScript; a field a call filled (`days = + * Array.from(…)`) is a plain `references`. A property read + * (`[value]="title"`) links nothing — TypeScript records no property reads + * either, and the dead-code list does not ask about properties. * * A child is matched by its element selector (`selector: 'app-article-list'`), * in the same app first; a selector two components share there is left @@ -186,6 +201,430 @@ function plainHref(value: string): HrefLiteral | null { return withHoles.startsWith('/') ? namesSomewhere(toHref(withHoles)) : null; } +// ============================================================================= +// What a template reads at render +// ============================================================================= + +/** `[name]="…"`, `[attr.aria-label]="…"`, `[class.done]="…"`, `[(ngModel)]="…"`: a binding whose value is an expression. */ +const PROPERTY_BINDING = /\[(\([A-Za-z][\w.-]*\)|[A-Za-z@][\w.@-]*)\]\s*=\s*(?:"([^"]*)"|'([^']*)')/g; +/** `*ngIf="…"`, `*ngFor="let item of items; trackBy: trackById"`: a structural directive, in its microsyntax. */ +const STRUCTURAL_DIRECTIVE = /(?])/g; +/** `let-row`, `let-i="index"` on an ``: a local of what it renders. */ +const TEMPLATE_INPUT = /(?<=\s)let-([A-Za-z_$][\w$]*)(?=[\s=/>])/g; +const HTML_COMMENT = //g; +/** A name an expression writes: `this.` may lead it; a member of something else (`form.reset`) is not one. */ +const EXPRESSION_NAME = /(?= 0) { + out += ` ${blankStrings(text.slice(i + 2, close))} `; + i = close + 1; + continue; + } + const step = text[i] === '\\' ? Math.min(2, text.length - i) : 1; + out += ' '.repeat(step); + i += step; + } + } else { + out += ch; + i++; + } + } + return out; +} + +const isBlank = (ch: string | undefined): boolean => ch !== undefined && /\s/.test(ch); + +/** The names an expression calls or reads, minus a pipe's name and an object literal's keys. */ +function expressionUses(expression: string): Array<{ name: string; at: number; call: boolean }> { + const text = blankStrings(expression); + const out: Array<{ name: string; at: number; call: boolean }> = []; + EXPRESSION_NAME.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = EXPRESSION_NAME.exec(text)) !== null) { + const name = m[1]!; + const end = m.index + m[0].length; + let before = m.index - 1; + while (isBlank(text[before])) before--; + let after = end; + while (isBlank(text[after])) after++; + const prev = text[before]; + const next = text[after]; + // `form .reset()`: a member of something else, however it is spaced. + if (prev === '.') continue; + // A pipe's name: `total | currency: code()` — but `a || b` is no pipe. + if (prev === '|' && text[before - 1] !== '|') continue; + // An object literal's key: `[ngClass]="{ active: isActive() }"`. + if ((prev === '{' || prev === ',') && next === ':') continue; + out.push({ name, at: end - name.length, call: next === '(' }); + } + return out; +} + +/** + * Where the expression starting at `from` ends: a `;` or `,` outside any + * bracket, or the words `as` / `let` that open the next microsyntax binding. + */ +function expressionEnd(text: string, from: number): number { + let depth = 0; + for (let i = from; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + const end = skipString(text, i); + if (end < 0) return text.length; + i = end; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + else if (depth === 0 && (ch === ';' || ch === ',')) return i; + else if (depth === 0 && isBlank(text[i - 1]) && /^(?:as|let)(?![\w$])/.test(text.slice(i, i + 4))) return i; + } + return text.length; +} + +/** `text` split at each `;` outside brackets and strings, with each part's offset. */ +function topLevelParts(text: string): Array<{ at: number; text: string }> { + const parts: Array<{ at: number; text: string }> = []; + let start = 0; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + const end = skipString(text, i); + if (end < 0) break; + i = end; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth--; + else if (ch === ';' && depth === 0) { + parts.push({ at: start, text: text.slice(start, i) }); + start = i + 1; + } + } + parts.push({ at: start, text: text.slice(start) }); + return parts; +} + +/** + * A structural directive's microsyntax, read into the expressions it + * evaluates and the locals it declares: `let item of items; let i = index; + * trackBy: trackById` evaluates `items` and `trackById` and declares `item` + * and `i`. A key (`of`, `trackBy:`, `else`) and the context name a binding + * takes (`index`, `index as i`) belong to the directive, never the component. + */ +export function readMicrosyntax(text: string): { expressions: Array<{ at: number; text: string }>; locals: string[] } { + const expressions: Array<{ at: number; text: string }> = []; + const locals: string[] = []; + let i = 0; + const skip = (separators: boolean) => { + while (i < text.length && (isBlank(text[i]) || (separators && (text[i] === ';' || text[i] === ',')))) i++; + }; + const word = (): string | null => { + NAME_AT.lastIndex = i; + const m = NAME_AT.exec(text); + if (!m) return null; + i += m[0].length; + return m[0]; + }; + let first = true; + while (i < text.length) { + skip(true); + if (i >= text.length) break; + const start = i; + const key = word(); + if (key === 'let' && isBlank(text[i])) { + // `let item`, `let i = index`: a local, and the context name it takes. + skip(false); + const local = word(); + if (local) locals.push(local); + skip(false); + if (text[i] === '=') { + i++; + skip(false); + word(); + } + first = false; + continue; + } + if (first || key === null) { + // The directive's own expression (`*ngIf="cond"`) has no key. + i = start; + } else { + skip(false); + if (text[i] === ':') i++; + skip(false); + } + const end = expressionEnd(text, i); + if (end > i) expressions.push({ at: i, text: text.slice(i, end) }); + i = end; + skip(false); + if (/^as(?![\w$])/.test(text.slice(i, i + 3))) { + i += 2; + skip(false); + const alias = word(); + if (alias) locals.push(alias); + } + // Nothing read (a stray character): step past it. + if (i === start) i++; + first = false; + } + return { expressions, locals }; +} + +/** The `;` that ends a `@let` declaration's expression, or -1. */ +function statementEnd(text: string, from: number): number { + let depth = 0; + for (let i = from; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'" || ch === '`') { + const end = skipString(text, i); + if (end < 0) return -1; + i = end; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') { + if (--depth < 0) return -1; + } else if (ch === ';' && depth === 0) return i; + } + return -1; +} + +/** + * Every name a template's bindings call or read at render — property + * bindings, interpolations, structural directives, control flow and `@let` — + * in template order, minus what the template names itself: a pipe, an object + * key, a microsyntax key, and any local it declares (`let item`, `as user`, + * `#input`, `@let total`). Event bindings are not here: they run when the + * user acts, and are read on their own. A commented-out binding reads nothing. + */ +export function templateMemberUses(template: string): TemplateMemberUse[] { + const text = template.replace(HTML_COMMENT, (c) => c.replace(/[^\n]/g, ' ')); + const expressions: TemplateExpression[] = []; + const locals: TemplateLocal[] = []; + let scope = 0; + const valueAt = (m: RegExpExecArray, value: string) => m.index + m[0].length - 1 - value.length; + const tagStart = (at: number) => Math.max(0, text.lastIndexOf('<', at)); + + PROPERTY_BINDING.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = PROPERTY_BINDING.exec(text)) !== null) { + const value = m[2] ?? m[3] ?? ''; + expressions.push({ via: `[${m[1]!}]`, at: valueAt(m, value), text: value, scope: ++scope, seesOwnLocals: false }); + } + STRUCTURAL_DIRECTIVE.lastIndex = 0; + while ((m = STRUCTURAL_DIRECTIVE.exec(text)) !== null) { + const value = m[2] ?? m[3] ?? ''; + const at = valueAt(m, value); + const own = ++scope; + const read = readMicrosyntax(value); + for (const e of read.expressions) expressions.push({ via: `*${m[1]!}`, at: at + e.at, text: e.text, scope: own, seesOwnLocals: false }); + // The host element and everything inside it see the directive's locals. + for (const name of read.locals) locals.push({ name, from: tagStart(m.index), scope: own }); + } + INTERPOLATION.lastIndex = 0; + while ((m = INTERPOLATION.exec(text)) !== null) { + expressions.push({ via: '{{ }}', at: m.index + 2, text: m[1]!, scope: ++scope, seesOwnLocals: false }); + } + CONTROL_FLOW.lastIndex = 0; + while ((m = CONTROL_FLOW.exec(text)) !== null) { + const open = m.index + m[0].length - 1; + const close = matchBracket(text, open); + if (close < 0) continue; + const kind = m[1]!.replace(/\s+/g, ' '); + const via = `@${kind}`; + const own = ++scope; + const parts = topLevelParts(text.slice(open + 1, close)).map((p) => ({ at: open + 1 + p.at, text: p.text })); + if (kind === 'switch' || kind === 'case') { + for (const p of parts) expressions.push({ via, at: p.at, text: p.text, scope: own, seesOwnLocals: false }); + } else if (kind === 'defer') { + // `when cond` is an expression; `on viewport`, `on timer(5s)` are triggers. + for (const p of parts) { + const when = /^\s*(?:(?:prefetch|hydrate)\s+)?when(?![\w$])/.exec(p.text); + if (when) expressions.push({ via, at: p.at + when[0].length, text: p.text.slice(when[0].length), scope: own, seesOwnLocals: false }); + } + } else if (kind === 'for') { + // `item of items(); track item.id; let i = $index, odd = $odd` + const [head, ...rest] = parts; + const loop = head ? /^(\s*)([A-Za-z_$][\w$]*)\s+of(?![\w$])/.exec(head.text) : null; + if (head && loop) { + locals.push({ name: loop[2]!, from: m.index, scope: own }); + expressions.push({ via, at: head.at + loop[0].length, text: head.text.slice(loop[0].length), scope: own, seesOwnLocals: false }); + } + for (const p of rest) { + const track = /^\s*track(?![\w$])/.exec(p.text); + if (track) { + expressions.push({ via, at: p.at + track[0].length, text: p.text.slice(track[0].length), scope: own, seesOwnLocals: true }); + continue; + } + if (/^\s*let(?![\w$])/.test(p.text)) { + for (const alias of p.text.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*\$?[\w$]+/g)) locals.push({ name: alias[1]!, from: m.index, scope: own }); + } + } + } else { + // `@if (user(); as user)`: the condition, then the alias its body sees. + const [head, ...rest] = parts; + if (head) expressions.push({ via, at: head.at, text: head.text, scope: own, seesOwnLocals: false }); + for (const p of rest) { + const alias = /^\s*as\s+([A-Za-z_$][\w$]*)/.exec(p.text); + if (alias) locals.push({ name: alias[1]!, from: m.index, scope: own }); + } + } + } + LET_DECLARATION.lastIndex = 0; + while ((m = LET_DECLARATION.exec(text)) !== null) { + const at = m.index + m[0].length; + const end = statementEnd(text, at); + if (end < 0) continue; + const own = ++scope; + expressions.push({ via: '@let', at, text: text.slice(at, end), scope: own, seesOwnLocals: false }); + locals.push({ name: m[1]!, from: m.index, scope: own }); + } + REFERENCE_VARIABLE.lastIndex = 0; + while ((m = REFERENCE_VARIABLE.exec(text)) !== null) locals.push({ name: m[1]!, from: 0, scope: -1 }); + TEMPLATE_INPUT.lastIndex = 0; + while ((m = TEMPLATE_INPUT.exec(text)) !== null) locals.push({ name: m[1]!, from: tagStart(m.index), scope: -1 }); + + const uses: TemplateMemberUse[] = []; + for (const e of expressions) { + for (const u of expressionUses(e.text)) { + const at = e.at + u.at; + const shadowed = locals.some((l) => l.name === u.name && l.from <= at && (l.scope !== e.scope || e.seesOwnLocals)); + if (!shadowed) uses.push({ name: u.name, at, call: u.call, via: e.via }); + } + } + return uses.sort((a, b) => a.at - b.at); +} + +/** + * What a method-kind member's own declaration says it is: an accessor (`get + * canSave() {`), a function (`displayFn(p) {`, `onPick = (p) => …`), or a + * field holding what a call returned (`days = Array.from({ length: 31 }, (_, + * i) => i + 1)`, `total = computed(() => …)`) — a method only because a + * callback is written in its initializer, and as likely plain data. + */ +type MemberShape = 'get' | 'set' | 'function' | 'value'; + +function memberShape(lines: readonly string[], member: Node): MemberShape { + const head = lines + .slice(member.startLine - 1, member.startLine + 4) + .join('\n') + .slice(member.startColumn); + let from = 0; + for (;;) { + const at = head.indexOf(member.name, from); + if (at < 0) return 'function'; + from = at + member.name.length; + // The name itself — not a longer name, and not a decorator's `'value'` argument. + if (/[\w$'"`]/.test(head[at - 1] ?? '') || /[\w$'"`]/.test(head[from] ?? '')) continue; + const accessor = /(?` is not the `=`). + const field = /^[?!]?\s*(?::[^=;]*(?:=>[^=;]*)*)?=(?![=>])\s*/.exec(head.slice(from)); + if (!field) return 'function'; + const value = head.slice(from + field[0].length).replace(/^async\s+/, ''); + if (/^(?:function(?![\w$])|[A-Za-z_$][\w$]*\s*=>)/.test(value)) return 'function'; + const close = value.startsWith('(') ? matchBracket(value, 0) : -1; + return close > 0 && /^\s*(?::[^=]*)?=>/.test(value.slice(close + 1)) ? 'function' : 'value'; + } +} + +/** + * The member one use links, and how. A call links what it calls: a method, + * or a signal held in a field (`count = signal(0)` is a property). A name + * read without a call links only a method-kind member — a getter runs when + * it is read, so it is called; a function handed over uncalled + * (`[displayWith]="displayFn"`) is a function reference; a field a call + * filled (`@for (day of days)`) is a plain reference. Same-named methods + * resolve as the event bindings' do: the last declared (an overload's body). + */ +function bindingTarget( + candidates: readonly Node[] | undefined, + call: boolean, + shape: (n: Node) => MemberShape +): { node: Node; kind: 'calls' | 'references'; fnRef: boolean } | null { + if (!candidates) return null; + const methods = candidates.filter((n) => n.kind === 'method'); + if (call) { + const target = + [...methods].reverse().find((n) => shape(n) === 'function' || shape(n) === 'value') ?? + methods[0] ?? + candidates.find((n) => n.kind === 'property'); + return target ? { node: target, kind: 'calls', fnRef: false } : null; + } + const getter = methods.find((n) => shape(n) === 'get'); + if (getter) return { node: getter, kind: 'calls', fnRef: false }; + const read = [...methods].reverse().find((n) => shape(n) !== 'set'); + return read ? { node: read, kind: 'references', fnRef: shape(read) === 'function' } : null; +} + // ============================================================================= // The pass // ============================================================================= @@ -250,12 +689,12 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb // Event bindings: `(click)="toggleFavorite()"` runs the component's own // method when the user acts. The binding is the trigger Steps draws — // known only from the template, so it rides on the edge. - const members = new Map( - ctx - .getNodesInFile(component.file) - .filter((n) => n.kind === 'method' && n.qualifiedName.startsWith(`${component.node.qualifiedName}::`)) - .map((n) => [n.name, n]) - ); + const own = ctx + .getNodesInFile(component.file) + .filter((n) => (n.kind === 'method' || n.kind === 'property') && n.qualifiedName.startsWith(`${component.node.qualifiedName}::`)); + const members = new Map(own.filter((n) => n.kind === 'method').map((n) => [n.name, n])); + /** Members this template already links — only the first edge between two nodes survives the merge. */ + const linked = new Set(); let handlers = 0; EVENT_BINDING.lastIndex = 0; let ev: RegExpExecArray | null; @@ -271,6 +710,7 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb const key = `${component.node.id}>${method.id}>${event}`; if (seen.has(key)) continue; seen.add(key); + linked.add(method.id); handlers++; edges.push({ source: component.node.id, @@ -288,6 +728,44 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb } } + // Bindings: `[name]="icon()"`, `{{ label() }}`, `@if (loading())` read the + // component every time it renders, so the hop carries no trigger. At most + // one edge per member, so the class's own size bounds them. + let declaration: string[] | null = null; + const shapes = new Map(); + const shape = (n: Node): MemberShape => { + const known = shapes.get(n.id); + if (known) return known; + declaration ??= (ctx.readFile(component.file) ?? '').split('\n'); + const read = memberShape(declaration, n); + shapes.set(n.id, read); + return read; + }; + const named = new Map(); + for (const n of own) { + const list = named.get(n.name); + if (list) list.push(n); + else named.set(n.name, [n]); + } + for (const use of templateMemberUses(text)) { + const found = bindingTarget(named.get(use.name), use.call, shape); + if (!found || linked.has(found.node.id)) continue; + linked.add(found.node.id); + edges.push({ + source: component.node.id, + target: found.node.id, + kind: found.kind, + line: siteLine(use.at), + provenance: 'heuristic', + metadata: { + synthesizedBy: 'angular-binding', + via: use.via, + registeredAt: registeredAt(use.at), + ...(found.fnRef ? { fnRef: true } : {}), + }, + }); + } + const routes = angularRoutesFor(table, component.file); if (!routes || routes.exact.size === 0) continue; let links = 0; From 02d22aebedd7fa70ddfe8b368c43bb24a80bdf0a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 01:02:35 +0000 Subject: [PATCH 7/9] fix(cpp): a receiver declared through a typedef or using alias calls the type it names (#2399) google/leveldb's MemTable::Add calls `table_.Insert(buf)` on `Table table_;`, declared next to `typedef SkipList Table;` in the class. Receiver inference returned `Table`, no class `Table` has an `Insert` (the one class `Table` is the unrelated leveldb::Table, and C++ type_alias nodes record no aliased type), so the call fell through to the receiver-word guess HandleTable::Insert. It now reaches SkipList::Insert. cpp-type-aliases.ts reads the alias from source and looks it up the way C++ does: an alias declared earlier in the calling function, the caller's class and its bases (read from the class's own base clause), then the namespaces around it. A qualified name resolves a segment at a time (Table::Iterator is SkipList::Iterator). Aliases of a template parameter, a dependent typename or a decltype name no particular class. The constructor path uses the same lookup, so `Table::Iterator iter(&table_);` constructs SkipList::Iterator and an alias hides an outer class of its name. When the aliased type lacks the method, a `.` call or a call through a pointer on a receiver the caller itself declares gets no guessed edge. A `->` through an iterator or smart pointer, a project class template (whose specializations can add members) and declarations read from outside the caller keep the previous behavior. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-type-alias-receiver.test.ts | 555 ++++++++++++++++++++++ src/resolution/cpp-constructor.ts | 25 +- src/resolution/cpp-type-aliases.ts | 542 +++++++++++++++++++++ src/resolution/index.ts | 2 + src/resolution/name-matcher.ts | 128 ++++- 6 files changed, 1239 insertions(+), 14 deletions(-) create mode 100644 __tests__/cpp-type-alias-receiver.test.ts create mode 100644 src/resolution/cpp-type-aliases.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 700436a781..fb52ae2892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. - 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. - 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. - 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 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. - 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. diff --git a/__tests__/cpp-type-alias-receiver.test.ts b/__tests__/cpp-type-alias-receiver.test.ts new file mode 100644 index 0000000000..835b20361a --- /dev/null +++ b/__tests__/cpp-type-alias-receiver.test.ts @@ -0,0 +1,555 @@ +/** + * A C++ receiver declared through a `typedef` / `using` alias calls the type + * the alias names. + * + * google/leveldb's `MemTable::Add` calls `table_.Insert(buf)` on a member + * declared `Table table_;`, beside `typedef SkipList Table;` in the same class. Receiver inference read the + * declared type as `Table`; no class `Table` has an `Insert` (the one class + * `Table` is the unrelated `leveldb::Table`), and the C/C++ extractors record + * no aliased type on a `type_alias` node, so the call fell through to a guess + * by the receiver's name: `HandleTable::Insert` in util/cache.cc. + * + * The alias is now looked up the way C++ looks a name up: an alias declared + * earlier in the calling function, then the caller's class and the classes it + * inherits from, then the namespaces around it, innermost first, where a + * class of that name stops the search. The alias's declaration is read and + * its type followed, so the call reaches `SkipList::Insert`, a + * `Table::Iterator` declaration constructs `SkipList::Iterator`, and its + * methods resolve there. When the aliased type has no such method (an alias + * of `std::vector`, say) or the alias names a template parameter, no method + * is guessed by the receiver's name — unless the call goes through `->` on an + * iterator or smart pointer, which reaches a type the alias doesn't name, or + * the declaration was read from outside the calling function and its class. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { cppTypeSegments } from '../src/resolution/cpp-type-aliases'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-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 }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName (file)`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} (${r.node.filePath})`) + .sort(); +} + +/** google/leveldb's shapes, trimmed: the skip list, the unrelated `Table`, and the decoy `Insert`. */ +const LEVELDB = { + 'include/leveldb/table.h': [ + 'namespace leveldb {', + 'class Table {', + ' public:', + ' explicit Table(int* rep);', + ' int ApproximateOffsetOf(int key) const;', + '};', + '} // namespace leveldb', + '', + ].join('\n'), + 'util/cache.cc': [ + 'namespace leveldb {', + 'class HandleTable {', + ' public:', + ' void Insert(int h) { last_ = h; }', + ' private:', + ' int last_;', + '};', + '} // namespace leveldb', + '', + ].join('\n'), + 'db/skiplist.h': [ + 'namespace leveldb {', + 'template ', + 'class SkipList {', + ' public:', + ' explicit SkipList(Comparator cmp, int* arena);', + ' void Insert(const Key& key);', + ' class Iterator {', + ' public:', + ' explicit Iterator(const SkipList* list);', + ' void Seek(const Key& target);', + ' };', + '};', + 'template ', + 'SkipList::SkipList(Comparator cmp, int* arena) {}', + 'template ', + 'void SkipList::Insert(const Key& key) {}', + 'template ', + 'SkipList::Iterator::Iterator(const SkipList* list) {}', + 'template ', + 'void SkipList::Iterator::Seek(const Key& target) {}', + '} // namespace leveldb', + '', + ].join('\n'), + 'db/memtable.h': [ + '#include "db/skiplist.h"', + 'namespace leveldb {', + 'struct KeyComparator {', + ' int operator()(const char* a, const char* b) const;', + '};', + 'class MemTable {', + ' public:', + ' void Add(const char* buf);', + ' bool Get(const char* key);', + ' void Rebuild(int* arena);', + ' void Index(const char* key);', + ' private:', + ' typedef SkipList Table;', + ' using Lookup = Table;', + ' Table table_;', + ' Lookup lookup_;', + '};', + '} // namespace leveldb', + '', + ].join('\n'), + 'db/memtable.cc': [ + '#include "db/memtable.h"', + 'namespace leveldb {', + 'void MemTable::Add(const char* buf) {', + ' table_.Insert(buf);', + '}', + 'bool MemTable::Get(const char* key) {', + ' Table::Iterator iter(&table_);', + ' iter.Seek(key);', + ' return true;', + '}', + 'void MemTable::Rebuild(int* arena) {', + ' KeyComparator cmp;', + ' Table fresh(cmp, &arena[0]);', + ' Table stale(&arena[1]);', + '}', + 'void MemTable::Index(const char* key) {', + ' lookup_.Insert(key);', + '}', + '} // namespace leveldb', + '', + ].join('\n'), +}; + +describe('a C++ receiver declared through an alias', () => { + it('reproduction: `table_.Insert` reaches SkipList::Insert, not a class named after the receiver', async () => { + const cg = await indexed(LEVELDB); + try { + expect(calls(cg, 'leveldb::MemTable::Add')).toEqual(['leveldb::SkipList::Insert (db/skiplist.h)']); + expect(cg.getCallers(callable(cg, 'leveldb::HandleTable::Insert').id).filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a nested type reached through the alias is constructed and called on the aliased type', async () => { + const cg = await indexed(LEVELDB); + try { + expect(calls(cg, 'leveldb::MemTable::Get')).toEqual([ + 'leveldb::SkipList::Iterator::Iterator (db/skiplist.h)', + 'leveldb::SkipList::Iterator::Seek (db/skiplist.h)', + ]); + } finally { + cg.close(); + } + }); + + it('a local object of the alias type constructs the aliased type, never an outer class the alias hides', async () => { + const cg = await indexed(LEVELDB); + try { + // `Table fresh(cmp, &arena[0])` is SkipList's two-argument constructor; + // `Table stale(&arena[1])` fits no SkipList constructor, and + // `leveldb::Table(int*)` is not the `Table` this scope names. + expect(calls(cg, 'leveldb::MemTable::Rebuild')).toEqual(['leveldb::SkipList::SkipList (db/skiplist.h)']); + expect(cg.getCallers(callable(cg, 'leveldb::Table::Table').id).filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('follows an alias of an alias', async () => { + const cg = await indexed(LEVELDB); + try { + expect(calls(cg, 'leveldb::MemTable::Index')).toEqual(['leveldb::SkipList::Insert (db/skiplist.h)']); + } finally { + cg.close(); + } + }); + + it('an alias spelled from outside its class, and one declared in the calling function', async () => { + const cg = await indexed({ + ...LEVELDB, + 'db/builder.cc': [ + '#include "db/memtable.h"', + 'namespace leveldb {', + 'void Fill(MemTable::Table* t) {', + ' t->Insert("k");', + '}', + 'void Build(int* arena) {', + ' typedef SkipList List;', + ' KeyComparator cmp;', + ' List list(cmp, &arena[0]);', + ' list.Insert("k");', + '}', + '} // namespace leveldb', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'leveldb::Fill')).toEqual(['leveldb::SkipList::Insert (db/skiplist.h)']); + expect(calls(cg, 'leveldb::Build')).toEqual([ + 'leveldb::SkipList::Insert (db/skiplist.h)', + 'leveldb::SkipList::SkipList (db/skiplist.h)', + ]); + } finally { + cg.close(); + } + }); + + it('no guess by the receiver\'s name when the aliased type lacks the method', async () => { + const cg = await indexed({ + ...LEVELDB, + 'include/leveldb/slice.h': [ + 'namespace leveldb {', + 'class Slice {', + ' public:', + ' void clear() { size_ = 0; }', + ' private:', + ' int size_;', + '};', + '} // namespace leveldb', + '', + ].join('\n'), + 'db/version_set.h': [ + '#include ', + 'namespace leveldb {', + 'class VersionSet {', + ' public:', + ' void Clear();', + ' private:', + ' using Table = std::vector;', + ' Table files_;', + '};', + '} // namespace leveldb', + '', + ].join('\n'), + 'db/version_set.cc': [ + '#include "db/version_set.h"', + 'namespace leveldb {', + 'void VersionSet::Clear() {', + ' files_.clear();', + '}', + '} // namespace leveldb', + '', + ].join('\n'), + }); + try { + // `files_` is a std::vector: not Slice::clear, the one project `clear`. + expect(calls(cg, 'leveldb::VersionSet::Clear')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('nor when the call continues on the next line', async () => { + // protocolbuffers/protobuf's descriptor.cc writes `symbols_by_parent_` + // and `.insert(…)` on separate lines. + const cg = await indexed({ + 'src/descriptor.cc': [ + '#include "absl/container/flat_hash_set.h"', + 'namespace google { namespace protobuf {', + 'class Arena {', + ' public:', + ' void insert(int block) {}', + '};', + 'class FileDescriptorTables {', + ' public:', + ' bool AddAliasUnderParent(int symbol);', + ' private:', + ' typedef absl::flat_hash_set SymbolsByParentSet;', + ' SymbolsByParentSet symbols_by_parent_;', + '};', + 'bool FileDescriptorTables::AddAliasUnderParent(int symbol) {', + ' return symbols_by_parent_', + ' .insert(symbol)', + ' .second;', + '}', + '} }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'google::protobuf::FileDescriptorTables::AddAliasUnderParent')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a class declared in an inner scope hides an outer alias of the same name; outside it the alias applies', async () => { + const cg = await indexed({ + 'app/holder.h': [ + 'namespace app {', + 'class Gadget {', + ' public:', + ' void Spin();', + '};', + 'using Widget = Gadget;', + 'class Holder {', + ' public:', + ' class Widget {', + ' public:', + ' void Spin();', + ' };', + ' void Use();', + ' private:', + ' Widget w_;', + '};', + '} // namespace app', + '', + ].join('\n'), + 'app/holder.cc': [ + '#include "app/holder.h"', + 'namespace app {', + 'void Gadget::Spin() {}', + 'void Holder::Widget::Spin() {}', + 'void Holder::Use() {', + ' w_.Spin();', + '}', + 'void Twirl(Widget& w) {', + ' w.Spin();', + '}', + '} // namespace app', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'app::Holder::Use')).toEqual(['app::Holder::Widget::Spin (app/holder.cc)']); + // Outside Holder, `Widget` is the namespace's alias of Gadget. + expect(calls(cg, 'app::Twirl')).toEqual(['app::Gadget::Spin (app/holder.cc)']); + } finally { + cg.close(); + } + }); +}); + +describe('an alias a class inherits', () => { + it('is found in the base class before an enclosing namespace, and a dependent alias is not followed', async () => { + // protocolbuffers/protobuf's json traits: `Field` in the derived traits is + // the base's `const FieldDescriptor*`, not the namespace's alias template. + const cg = await indexed({ + 'protobuf/descriptor.h': [ + 'namespace google { namespace protobuf {', + 'class FieldDescriptor {', + ' public:', + ' bool is_repeated() const;', + '};', + 'inline bool FieldDescriptor::is_repeated() const { return false; }', + 'class OneofDescriptor {', + ' public:', + ' bool is_repeated() const { return false; }', + '};', + '} }', + '', + ].join('\n'), + 'protobuf/json/traits.h': [ + '#include "protobuf/descriptor.h"', + 'namespace google { namespace protobuf { namespace json_internal {', + 'template ', + 'using Field = typename Traits::Field;', + 'struct Proto2Descriptor {', + ' using Field = const FieldDescriptor*;', + '};', + 'struct ParseProto2Descriptor : Proto2Descriptor {', + ' static bool NewMsg(Field f) { return f->is_repeated(); }', + '};', + '} } }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'google::protobuf::json_internal::ParseProto2Descriptor::NewMsg')).toEqual([ + 'google::protobuf::FieldDescriptor::is_repeated (protobuf/descriptor.h)', + ]); + } finally { + cg.close(); + } + }); +}); + +describe('an alias of a template parameter', () => { + it('names no type, so a class that shares the alias\'s name is not it', async () => { + // protocolbuffers/protobuf: `GenericTypeHandler` declares + // `using Type = GenericType;`, and the generated `google.protobuf.Type` + // message is a class named `Type` with a `Clear`. + const cg = await indexed({ + 'protobuf/type.pb.h': [ + 'namespace google { namespace protobuf {', + 'class Type {', + ' public:', + ' void Clear();', + '};', + 'inline void Type::Clear() {}', + '} }', + '', + ].join('\n'), + 'protobuf/repeated_ptr_field.h': [ + 'namespace google { namespace protobuf { namespace internal {', + 'template ', + 'class GenericTypeHandler {', + ' public:', + ' using Type = GenericType;', + ' static void Reset(Type* value) { value->Clear(); }', + '};', + '} } }', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'google::protobuf::internal::GenericTypeHandler::Reset')).toEqual([]); + } finally { + cg.close(); + } + }); +}); + +describe('what does not rule a guess out', () => { + it('`->` through an alias of an iterator reaches the element type, which the alias does not name', async () => { + // google/googletest's gmock: `ExpectationSet::const_iterator` is a + // `std::set` iterator, and `it->` is an Expectation. + const cg = await indexed({ + 'gmock/expectation.h': [ + '#include ', + 'namespace testing {', + 'class Expectation {', + ' public:', + ' int expectation_base() const { return 0; }', + '};', + 'class ExpectationSet {', + ' public:', + ' typedef ::std::set Set;', + ' typedef Set::const_iterator const_iterator;', + '};', + 'int Describe(const ExpectationSet& set) {', + ' ExpectationSet::const_iterator it;', + ' return it->expectation_base();', + '}', + '} // namespace testing', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'testing::Describe')).toEqual(['testing::Expectation::expectation_base (gmock/expectation.h)']); + } finally { + cg.close(); + } + }); + + it('a class template\'s specialization can declare a member the template itself does not', async () => { + // facebook/rocksdb's toku order-maintenance tree: `get_marked` is a member + // of `omt_node_templated` only. + const cg = await indexed({ + 'util/omt.h': [ + 'namespace toku {', + 'namespace omt_internal {', + 'template ', + 'class omt_node_templated {', + ' public:', + ' void clear_stolen_bits() {}', + '};', + 'template ', + 'class omt_node_templated {', + ' public:', + ' bool get_marked() const { return false; }', + '};', + '} // namespace omt_internal', + 'template ', + 'class omt {', + ' public:', + ' bool has_marks() const;', + ' private:', + ' typedef omt_internal::omt_node_templated omt_node;', + ' omt_node root_;', + '};', + 'template ', + 'bool omt::has_marks() const {', + ' const omt_node &node = root_;', + ' return node.get_marked();', + '}', + '} // namespace toku', + '', + ].join('\n'), + }); + try { + const callees = cg.getCallees(callable(cg, 'toku::omt::has_marks').id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.name); + expect(callees).toEqual(['get_marked']); + } finally { + cg.close(); + } + }); + + it('a declaration read from another class is not known to be the receiver\'s', async () => { + // fmtlib/fmt's scan.h: `auto data = scan_data()` deduces nothing + // the reader can see, and the `data` it then finds is another class's + // `const scan_arg* data;`. + const cg = await indexed({ + 'test/scan.h': [ + 'namespace fmt {', + 'class basic_scan_arg {', + ' public:', + ' int type() const { return 0; }', + '};', + 'using scan_arg = basic_scan_arg;', + 'class scan_args {', + ' const scan_arg* data;', + '};', + 'template class scan_data {', + ' public:', + ' int make_args() { return 0; }', + '};', + 'template int scan() {', + ' auto data = scan_data();', + ' return data.make_args();', + '}', + '} // namespace fmt', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'fmt::scan')).toEqual(['fmt::scan_data::make_args (test/scan.h)']); + } finally { + cg.close(); + } + }); +}); + +describe('cppTypeSegments', () => { + it('reads a written type as its `::` segments, without qualifiers or template arguments', () => { + expect(cppTypeSegments('SkipList')).toEqual(['SkipList']); + expect(cppTypeSegments('const ::leveldb::SkipList>::Iterator&')).toEqual(['leveldb', 'SkipList', 'Iterator']); + expect(cppTypeSegments('typename Base::Iter')).toEqual(['Base', 'Iter']); + expect(cppTypeSegments('struct Node*')).toEqual(['Node']); + expect(cppTypeSegments('unsigned int')).toBeNull(); + expect(cppTypeSegments('void (*)(int)')).toBeNull(); + }); +}); diff --git a/src/resolution/cpp-constructor.ts b/src/resolution/cpp-constructor.ts index 92d2f4d7a2..30c756de04 100644 --- a/src/resolution/cpp-constructor.ts +++ b/src/resolution/cpp-constructor.ts @@ -7,6 +7,8 @@ * - the type is looked up in the LEXICAL namespaces of the call site — for * `second::use()` writing `Widget w;`, `second::Widget` before a global * `Widget`; a `::T` spelling is global only; + * - a type named through a `typedef` / `using` alias is the type the alias + * names, looked up from the alias's own scope (cpp-type-aliases.ts); * - among that type's constructors, the single overload whose parameter * count admits the argument count wins (defaults and `...` widen a range); * two admitting overloads (`T(int)` / `T(double)` for `T w(x)`) resolve to @@ -17,6 +19,7 @@ */ import type { Node } from '../types'; import type { ResolvedRef, ResolutionContext, UnresolvedRef } from './types'; +import { resolveCppAliasedType } from './cpp-type-aliases'; const CONSTRUCTOR_REF = /^(.*)::([^:]+)\/(\d+)$/; @@ -80,14 +83,26 @@ function constructorShape(signature: string | undefined): { min: number; max: nu export function matchCppConstructor(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { const match = ref.referenceName.match(CONSTRUCTOR_REF); if (!match) return null; - const [, rawType, name, count] = match; - const type = rawType!.replace(/^::/, ''); - if (type.split('::').pop() !== name) return null; + const [, rawType, ctorName, count] = match; + let type = rawType!.replace(/^::/, ''); + if (type.split('::').pop() !== ctorName) return null; const argc = Number(count); // Innermost lexical namespace first, then outward, then global. const caller = context.getNodeById?.(ref.fromNodeId); - const scopes = rawType!.startsWith('::') ? [] : (caller?.qualifiedName.split('::') ?? []); + let scopes = rawType!.startsWith('::') ? [] : (caller?.qualifiedName.split('::') ?? []); + // A type named through an alias (`Table::Iterator iter(&table_);` under + // `typedef SkipList<…> Table;`) constructs the type the alias names, looked + // up from where the alias wrote it — never an outer class the alias hides. + if (!rawType!.startsWith('::')) { + const aliased = resolveCppAliasedType(type, ref, context); + if (aliased === null) return null; + if (aliased) { + type = [...aliased.target, ...aliased.rest].join('::'); + scopes = aliased.scope ? aliased.scope.split('::') : []; + } + } + const name = type.split('::').pop()!; const candidates: string[] = []; for (let i = scopes.length; i > 0; i--) candidates.push(`${scopes.slice(0, i).join('::')}::${type}`); candidates.push(type); @@ -98,7 +113,7 @@ export function matchCppConstructor(ref: UnresolvedRef, context: ResolutionConte .filter((n) => n.language === 'cpp' && (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union')); if (owners.length === 0) continue; const constructors = context - .getNodesByName(name!) + .getNodesByName(name) .filter((n) => n.language === 'cpp' && n.kind === 'method' && n.qualifiedName === `${qualified}::${name}`); // Brace-init prefers an initializer_list overload over arity — that // choice needs the argument types, so decline. diff --git a/src/resolution/cpp-type-aliases.ts b/src/resolution/cpp-type-aliases.ts new file mode 100644 index 0000000000..82070e8031 --- /dev/null +++ b/src/resolution/cpp-type-aliases.ts @@ -0,0 +1,542 @@ +/** + * C++ type aliases in a declared type: `typedef X Y;` and `using Y = X;`. + * + * Both extractors index an alias as a `type_alias` node and record nothing + * about the type it names, so a receiver declared through one used to be + * looked up by the alias's own name. google/leveldb's `MemTable::Add` calls + * `table_.Insert(buf)` on a member `Table table_;`, with + * `typedef SkipList Table;` declared beside it in + * the class. No class `Table` has an `Insert` (the one class `Table` is the + * unrelated `leveldb::Table`), so the call fell through to a guess by the + * receiver's name and reached `HandleTable::Insert`. + * + * A name is looked up the way C++ looks it up: from the innermost scope of + * the code that uses it outwards. That is an alias declared earlier in the + * calling function, then the caller's class and that class's bases, then the + * classes and namespaces around it, and the global scope. The first scope + * that declares the name decides; a class there is the type itself. An alias + * there is read from its own declaration and followed from the alias's own + * scope, for a few hops at most. A qualified name is resolved a segment at a + * time, so `Table::Iterator` is `SkipList::Iterator` and `MemTable::Table` + * is `SkipList`. Template arguments are dropped. An alias of what only a + * template argument decides (`using Type = GenericType;` in the template + * that declares `GenericType`, `typename Traits::Field`) or of a + * `decltype(…)` names no particular class, and is reported as such. + */ +import type { Node } from '../types'; +import type { ResolutionContext, UnresolvedRef } from './types'; + +/** Kinds that declare a type name in a scope, besides an alias. */ +const DECLARING_KINDS: ReadonlySet = new Set([ + 'class', 'struct', 'union', 'enum', 'interface', 'namespace', 'module', +]); +const CLASS_KINDS: ReadonlySet = new Set(['class', 'struct', 'union']); + +/** How many aliases one name is followed through (`A` → `B` → `C`). */ +const MAX_HOPS = 4; +/** How many base classes deep a member is looked for. */ +const MAX_BASE_DEPTH = 4; +/** How far past a class's first line its base clause may run. */ +const HEAD_LINES = 12; + +/** + * What a type named through an alias resolved to: the aliased type as written + * in `scope` (`['SkipList']`), then the rest of the name that followed the + * alias (`['Iterator']` for `Table::Iterator`). + */ +export interface CppAliasedType { + target: string[]; + rest: string[]; + /** The qualified scope `target` is written in (`''` for the global scope). */ + scope: string; + /** An alias on the way named a pointer (`using Field = const FieldDescriptor*;`). */ + pointer: boolean; + /** + * The type is a class template of the project (or a class nested in one), + * whose specializations can declare members the primary template doesn't. + */ + classTemplate: boolean; +} + +/** A written type's `::` segments, each with whether template arguments followed it. */ +interface TypeName { + names: string[]; + templated: boolean[]; +} + +/** `SkipList>::Iterator` → `SkipList::Iterator`. */ +function stripTemplateArguments(text: string): string { + let out = ''; + let depth = 0; + for (const c of text) { + if (c === '<') depth++; + else if (c === '>') depth = Math.max(0, depth - 1); + else if (depth === 0) out += c; + } + return out; +} + +/** The `::` segments of a written type, or null when it is not a plain (possibly qualified) type name. */ +function cppTypeName(raw: string): TypeName | null { + const parts: string[] = []; + const templated: boolean[] = []; + let depth = 0; + let part = ''; + let args = false; + const text = raw.replace(/\b(?:const|volatile|mutable|typename|template|class|struct|union|enum)\b/g, ' ').replace(/[&*]+/g, ' '); + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (c === '<') { + depth++; + args = true; + } else if (c === '>') depth = Math.max(0, depth - 1); + else if (depth > 0) continue; + else if (c === ':' && text[i + 1] === ':') { + parts.push(part.trim()); + templated.push(args); + part = ''; + args = false; + i++; + } else part += c; + } + parts.push(part.trim()); + templated.push(args); + if (parts[0] === '') { + // A leading `::` names the global scope. + parts.shift(); + templated.shift(); + } + if (parts.length === 0 || !parts.every((p) => /^[A-Za-z_]\w*$/.test(p))) return null; + return { names: parts, templated }; +} + +/** Is a written type a pointer (`Table*`, `const Foo* const`), not counting its template arguments? */ +export function isCppPointerType(raw: string): boolean { + return stripTemplateArguments(raw).includes('*'); +} + +/** + * The `::` segments of a written type: `const ns::Foo&` → `['ns', 'Foo']`. + * Null when what is written is not a plain (possibly qualified) type name — + * `unsigned int`, `decltype(…)`, a function type. + */ +export function cppTypeSegments(raw: string): string[] | null { + return cppTypeName(raw)?.names ?? null; +} + +/** C and C++ declare the names a C++ file can see (a header may be either). */ +function isCFamily(n: Node): boolean { + return n.language === 'cpp' || n.language === 'c'; +} + +/** Scopes visible from inside the scope `qualified`, innermost first, ending with the global scope. */ +function scopesWithin(qualified: string): string[] { + const parts = qualified ? qualified.split('::') : []; + const scopes: string[] = []; + for (let i = parts.length; i >= 0; i--) scopes.push(parts.slice(0, i).join('::')); + return scopes; +} + +/** The scope a declaration sits in: `leveldb::MemTable` for `leveldb::MemTable::Table`. */ +function parentScope(qualified: string): string { + const cut = qualified.lastIndexOf('::'); + return cut < 0 ? '' : qualified.slice(0, cut); +} + +/** The scope the code that makes `ref` is written in: its class for a method, itself for a class. */ +function callerScope(caller: Node | null | undefined): string { + if (!caller || caller.kind === 'file') return ''; + return DECLARING_KINDS.has(caller.kind) ? caller.qualifiedName : parentScope(caller.qualifiedName); +} + +function linesOf(filePath: string, context: ResolutionContext): string[] | null { + return context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? null; +} + +const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** The text of `node`'s declaration, cut to its columns when they fit the lines. */ +function declarationText(node: Node, context: ResolutionContext): string | null { + const lines = linesOf(node.filePath, context); + if (!lines) return null; + const span = lines.slice(node.startLine - 1, node.endLine ?? node.startLine); + if (span.length === 0) return null; + const whole = span.join('\n'); + if (span.length === 1) { + span[0] = span[0]!.slice(node.startColumn, node.endColumn); + } else { + span[0] = span[0]!.slice(node.startColumn); + span[span.length - 1] = span[span.length - 1]!.slice(0, node.endColumn); + } + const cut = span.join('\n'); + return /\b(?:typedef|using)\b/.test(cut) ? cut : whole; +} + +/** Split `text` on commas outside `<>`, `()`, `[]` and `{}`. */ +function splitTopLevel(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if ('<([{'.includes(c)) depth++; + else if ('>)]}'.includes(c)) depth = Math.max(0, depth - 1); + else if (c === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts; +} + +/** + * What an alias of `name` declares in `text`, as written: `SkipList` for `typedef SkipList + * Table;` and for `using Table = SkipList;`. + * Null when `text` declares no alias of that name. + */ +function aliasedTypeIn(text: string, name: string): string | null { + const escaped = escapeRegExp(name); + const using = new RegExp(String.raw`\busing\s+${escaped}\s*(?:\[\[[^\]]*\]\]\s*)?=\s*([^;]+)`).exec(text); + if (using) return using[1]!.trim(); + const typedef = /\btypedef\b([^;]+)/.exec(text); + if (!typedef) return null; + // `typedef Foo* FooPtr, FooObj;` indexes its first declarator; the type is + // what precedes that declarator's name. + const declared = new RegExp(String.raw`^([\s\S]*?)\b${escaped}\s*$`).exec(splitTopLevel(typedef[1]!)[0]!); + return declared?.[1]?.trim() || null; +} + +/** Is `alias` an alias template (`template using Vec = std::vector;`)? */ +function isAliasTemplate(alias: Node, context: ResolutionContext): boolean { + const lines = linesOf(alias.filePath, context); + if (!lines) return false; + const before = [ + ...lines.slice(Math.max(0, alias.startLine - 4), alias.startLine - 1), + (lines[alias.startLine - 1] ?? '').slice(0, alias.startColumn), + ].join(' '); + return /\btemplate\s*<[^;{}]*>\s*$/.test(before); +} + +const LOCAL_ALIAS_LINES = new WeakMap>(); + +/** The lines of a function's body that declare an alias (`typedef …;`, `using X = …;`), last first. */ +function localAliasLines(caller: Node, lines: readonly string[], context: ResolutionContext): number[] { + let memo = LOCAL_ALIAS_LINES.get(context); + if (!memo) LOCAL_ALIAS_LINES.set(context, (memo = new Map())); + let found = memo.get(caller.id); + if (!found) { + found = []; + const last = Math.min(caller.endLine ?? caller.startLine, lines.length); + for (let i = last - 1; i >= caller.startLine - 1 && i >= 0; i--) { + if (/\b(?:typedef\b|using\s+\w+\s*=)/.test(lines[i]!)) found.push(i); + } + memo.set(caller.id, found); + } + return found; +} + +/** + * The type an alias declared earlier in the calling function names, as + * written. Those aliases are not indexed, so the function's own lines are + * read, from the call back to the function's first line. + */ +function localAliasedType(name: string, caller: Node | null | undefined, ref: UnresolvedRef, context: ResolutionContext): string | null | undefined { + if (!caller || (caller.kind !== 'function' && caller.kind !== 'method') || caller.filePath !== ref.filePath) return undefined; + const lines = linesOf(ref.filePath, context); + if (!lines) return undefined; + const declares = new RegExp(String.raw`\b(?:using\s+${escapeRegExp(name)}\s*=|typedef\b[^;]*\b${escapeRegExp(name)}\s*[;,])`); + for (const i of localAliasLines(caller, lines, context)) { + if (i > ref.line - 1) continue; + const line = lines[i]!; + if (line.includes(name) && declares.test(line)) return aliasedTypeIn(line, name); + } + return undefined; +} + +/** What a scope declares under a name. */ +type Declared = + | { alias: Node } + /** A class, struct or union, by its qualified name. */ + | { cls: string } + /** An enum, namespace or other non-class type. */ + | 'other' + /** Aliases naming different types, none of them the calling file's. */ + | 'ambiguous' + | undefined; + +/** C and C++ declarations named `name` that can stand for a type or scope. */ +function typeDeclarations(name: string, context: ResolutionContext): Node[] { + return context.getNodesByName(name).filter((n) => isCFamily(n) && (n.kind === 'type_alias' || DECLARING_KINDS.has(n.kind))); +} + +function declaredIn( + scope: string, + name: string, + named: readonly Node[], + templated: boolean, + ref: UnresolvedRef, + context: ResolutionContext, +): Declared { + const qualified = scope ? `${scope}::${name}` : name; + let here = named.filter((n) => n.qualifiedName === qualified); + // An alias template is only ever named with template arguments. + if (!templated) here = here.filter((n) => n.kind !== 'type_alias' || !isAliasTemplate(n, context)); + if (here.length === 0) return undefined; + // `typedef struct Node Node;` declares the struct as well. + if (here.some((n) => CLASS_KINDS.has(n.kind))) return { cls: qualified }; + if (here.some((n) => n.kind !== 'type_alias')) return 'other'; + const alias = pickAlias(here, ref, context); + return alias ? { alias } : 'ambiguous'; +} + +/** + * One of several aliases a scope declares under one name (a translation unit + * sees one of them): the one in the calling file, then in its header, then + * any of them when they all name the same type. + */ +function pickAlias(aliases: Node[], ref: UnresolvedRef, context: ResolutionContext): Node | null { + if (aliases.length === 1) return aliases[0]!; + const sameFile = aliases.filter((a) => a.filePath === ref.filePath); + if (sameFile.length === 1) return sameFile[0]!; + const stem = (p: string): string => p.replace(/\.[^./\\]+$/, ''); + const header = aliases.filter((a) => stem(a.filePath) === stem(ref.filePath)); + if (header.length === 1) return header[0]!; + const named = new Set(aliases.map((a) => { + const text = declarationText(a, context); + const type = text ? aliasedTypeIn(text, a.name) : null; + return type ? (cppTypeSegments(type)?.join('::') ?? type) : null; + })); + return named.size === 1 && !named.has(null) ? aliases[0]! : null; +} + +const BASES = new WeakMap>(); +const TEMPLATE_PARAMETERS = new WeakMap>>(); + +/** Drop the memos of class bases, template parameters and local aliases (see ReferenceResolver.clearCaches). */ +export function clearCppTypeAliasMemos(context: ResolutionContext): void { + BASES.delete(context); + TEMPLATE_PARAMETERS.delete(context); + LOCAL_ALIAS_LINES.delete(context); +} + +/** Add the parameter names of the `template <…>` header that ends right where a declaration starts. */ +function collectTemplateHeader(lines: readonly string[], startLine: number, startColumn: number, names: Set): void { + const before = [...lines.slice(Math.max(0, startLine - 4), startLine - 1), (lines[startLine - 1] ?? '').slice(0, startColumn)].join('\n'); + const at = before.lastIndexOf('template'); + const header = at < 0 ? null : /^template\s*<([\s\S]*)>\s*$/.exec(before.slice(at)); + if (!header) return; + for (const item of splitTopLevel(header[1]!)) { + // `typename T = int`, `template class Policy`, `typename... Ts`, `int N`. + const declared = stripTemplateArguments(item).split('=')[0]!; + const name = /([A-Za-z_]\w*)\s*$/.exec(declared)?.[1]; + if (name && name !== 'typename' && name !== 'class') names.add(name); + } +} + +/** + * The template parameters in scope where `node` is declared: its own + * `template <…>` header and those of the class templates around it. + */ +function templateParametersAround(node: Node, context: ResolutionContext): ReadonlySet { + let memo = TEMPLATE_PARAMETERS.get(context); + if (!memo) TEMPLATE_PARAMETERS.set(context, (memo = new Map())); + const hit = memo.get(node.id); + if (hit) return hit; + const names = new Set(); + const lines = linesOf(node.filePath, context); + if (lines) { + collectTemplateHeader(lines, node.startLine, node.startColumn, names); + for (let scope = parentScope(node.qualifiedName); scope; scope = parentScope(scope)) { + for (const cls of context.getNodesByQualifiedName(scope)) { + if (!isCFamily(cls) || !CLASS_KINDS.has(cls.kind) || cls.filePath !== node.filePath) continue; + if (node.startLine < cls.startLine || node.startLine > (cls.endLine ?? cls.startLine)) continue; + collectTemplateHeader(lines, cls.startLine, cls.startColumn, names); + } + } + } + memo.set(node.id, names); + return names; +} + +/** + * The qualified names of the classes `cls` derives from, read from the base + * clause of its own declaration (`struct ParseProto2Descriptor : + * Proto2Descriptor {`) and looked up from the scope the class is declared in. + */ +function baseClassesOf(cls: string, context: ResolutionContext): string[] { + let memo = BASES.get(context); + if (!memo) BASES.set(context, (memo = new Map())); + const hit = memo.get(cls); + if (hit) return hit; + memo.set(cls, []); + const bases: string[] = []; + for (const decl of context.getNodesByQualifiedName(cls)) { + if (!isCFamily(decl) || !CLASS_KINDS.has(decl.kind)) continue; + const lines = linesOf(decl.filePath, context); + if (!lines) continue; + const head = lines.slice(decl.startLine - 1, decl.startLine - 1 + HEAD_LINES); + if (head.length === 0) continue; + head[0] = head[0]!.slice(decl.startColumn); + const text = head.join('\n'); + const brace = text.indexOf('{'); + // A forward declaration (`class Foo;`) has no base clause of its own. + if (brace < 0 || text.slice(0, brace).includes(';')) continue; + const colon = text.slice(0, brace).search(/(? isCFamily(n) && CLASS_KINDS.has(n.kind))) return qualified; + } + return undefined; +} + +/** `name` declared in class `cls` or one of its bases. */ +function memberOf( + cls: string, + name: string, + named: readonly Node[], + templated: boolean, + ref: UnresolvedRef, + context: ResolutionContext, + depth = 0, + seen: Set = new Set(), +): Declared { + const own = declaredIn(cls, name, named, templated, ref, context); + if (own !== undefined || depth >= MAX_BASE_DEPTH) return own; + seen.add(cls); + for (const base of baseClassesOf(cls, context)) { + if (seen.has(base)) continue; + const inherited = memberOf(base, name, named, templated, ref, context, depth + 1, seen); + if (inherited !== undefined) return inherited; + } + return undefined; +} + +/** `name` looked up unqualified from `scopes`, innermost first; a class scope includes its bases. */ +function lookupUnqualified(name: string, templated: boolean, scopes: readonly string[], ref: UnresolvedRef, context: ResolutionContext): Declared { + const named = typeDeclarations(name, context); + if (named.length === 0) return undefined; + for (const scope of scopes) { + const found = scope ? memberOf(scope, name, named, templated, ref, context) : declaredIn('', name, named, templated, ref, context); + if (found !== undefined) return found; + } + return undefined; +} + +/** + * The type a C++ declared type names once the aliases in it are followed, + * looked up from the code that makes `ref`. + * - `undefined`: the type is not an alias; the caller keeps its own reading. + * - `null`: an alias that names no particular class: a template + * parameter's type (`using Type = GenericType;`, `typename + * Traits::Field`), a `decltype(…)` or a function type, or a chain of + * aliases too long to follow. + */ +export function resolveCppAliasedType(rawType: string, ref: UnresolvedRef, context: ResolutionContext): CppAliasedType | null | undefined { + const name = cppTypeName(rawType); + if (!name) return undefined; + const caller = context.getNodeById?.(ref.fromNodeId); + const scope = callerScope(caller); + const local = localAliasedType(name.names[0]!, caller, ref, context); + const resolved = local !== undefined + ? expand(local, name.names.slice(1), name.templated.slice(1), scope, ref, context, 1, + caller ? templateParametersAround(caller, context) : undefined) + : follow(name, scope, ref, context, 0); + // `Table* table` is a pointer whatever `Table` names. + return resolved && isCppPointerType(rawType) ? { ...resolved, pointer: true } : resolved; +} + +/** + * The name receiver inference gives a type resolved through aliases: the + * aliased type's last segment (`SkipList`), as for any declared type, with + * the nested part of a name reached through the alias kept on it + * (`SkipList::Iterator`), since that owner is what names the nested type. + */ +export function cppAliasedTypeName(resolved: CppAliasedType): string { + return [resolved.target[resolved.target.length - 1]!, ...resolved.rest].join('::'); +} + +function follow( + name: TypeName, + scope: string, + ref: UnresolvedRef, + context: ResolutionContext, + hops: number, +): CppAliasedType | null | undefined { + const scopes = scopesWithin(scope); + let found = lookupUnqualified(name.names[0]!, name.templated[0]!, scopes, ref, context); + let next = 1; + if (found === undefined && name.names.length > 1) { + // Namespaces are not indexed: `detail::buffer_t` from inside `fmt` is + // found by its spelling in an enclosing scope, longest prefix first. + for (let i = name.names.length; i >= 2 && found === undefined; i--) { + const spelled = name.names.slice(0, i); + const named = typeDeclarations(spelled[spelled.length - 1]!, context); + for (const s of scopes) { + found = declaredIn(s ? `${s}::${spelled.slice(0, -1).join('::')}` : spelled.slice(0, -1).join('::'), + spelled[spelled.length - 1]!, named, name.templated[i - 1]!, ref, context); + if (found !== undefined) break; + } + next = i; + } + } + // `MemTable::Table`, `Table::Iterator`: one segment at a time. + while (found && typeof found === 'object' && 'cls' in found && next < name.names.length) { + found = memberOf(found.cls, name.names[next]!, typeDeclarations(name.names[next]!, context), name.templated[next]!, ref, context); + next++; + } + if (!found || typeof found !== 'object' || !('alias' in found)) return undefined; + const alias = found.alias; + if (hops >= MAX_HOPS) return null; + const text = declarationText(alias, context); + // The aliased type is written in the alias's own scope. + return expand(text ? aliasedTypeIn(text, alias.name) : null, name.names.slice(next), name.templated.slice(next), + parentScope(alias.qualifiedName), ref, context, hops + 1, templateParametersAround(alias, context)); +} + +/** + * Continue from the type an alias names, as written (`rest` followed the + * alias in the name; `parameters` are the template parameters where it is + * written). + */ +function expand( + written: string | null, + rest: readonly string[], + restTemplated: readonly boolean[], + scope: string, + ref: UnresolvedRef, + context: ResolutionContext, + hops: number, + parameters?: ReadonlySet, +): CppAliasedType | null { + // `typename Traits::Field`, `PolicyTemplate` inside the template + // that declares `PolicyTemplate`: whatever a template argument makes it. + if (!written || /\btypename\b/.test(written)) return null; + const target = cppTypeName(written); + if (!target || parameters?.has(target.names[0]!)) return null; + const pointer = isCppPointerType(written); + const names = [...target.names, ...rest]; + const further = follow({ names, templated: [...target.templated, ...restTemplated] }, scope, ref, context, hops); + if (further === null) return null; + if (further) return { ...further, pointer: further.pointer || pointer }; + const cls = classNamed(names, scopesWithin(scope), context); + const classTemplate = !!cls && context.getNodesByQualifiedName(cls) + .some((n) => isCFamily(n) && CLASS_KINDS.has(n.kind) && templateParametersAround(n, context).size > 0); + return { target: target.names, rest: [...rest], scope, pointer, classTemplate }; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index bf0917ad7e..7e6d2034a4 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -29,6 +29,7 @@ import { clearDartLibraryMemos } from './dart-libraries'; import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers'; import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; +import { clearCppTypeAliasMemos } from './cpp-type-aliases'; import { matchShopifyThemeFile } from './shopify-themes'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; @@ -476,6 +477,7 @@ export class ReferenceResolver { clearVbnetReceiverMemos(this.context); clearTypeParameterMemos(this.context); clearDartLocalScopeMemos(this.context); + clearCppTypeAliasMemos(this.context); } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 794d2af014..0238266417 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14,6 +14,7 @@ import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-vis import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; import { isDartLocallyBound } from './dart-local-scope'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; +import { cppAliasedTypeName, isCppPointerType, resolveCppAliasedType } from './cpp-type-aliases'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace'; @@ -8250,11 +8251,102 @@ function buildDeclaratorRegex(escapedReceiver: string): RegExp { ); } +/** What C++ receiver inference made of a receiver's declared type. */ +interface CppReceiverDeclaration { + /** + * The declared type is an alias: `followed` to the type it names, or + * `unreadable` when that type is a template parameter's (`using Type = + * GenericType;`, `typename Traits::Field`) or a `decltype(…)` — no + * particular class. + */ + aliased?: 'followed' | 'unreadable'; + /** The type as written in the declaration, without following an alias (`Table`). */ + written?: string; + /** The type is reached through a pointer (`Table* t`, `using Field = const FieldDescriptor*;`). */ + pointer?: boolean; + /** The type is one of the project's class templates, whose specializations may add members. */ + classTemplate?: boolean; +} + +/** + * A declared C++ type as receiver inference reads it: its own last segment, + * or, when it is an alias the caller's own declaration uses, the type the + * alias names (cpp-type-aliases.ts) — null when that type can't be known. An + * alias is never a class, so its own name is never looked up as one: that is + * how protobuf's `Field f; f->number()` (`using Field = const + * FieldDescriptor*;`) reached the generated `Field` message's `number`. + */ +function cppDeclaredType( + raw: string, + normalized: string, + inCallerScope: boolean, + ref: UnresolvedRef, + context: ResolutionContext, + found?: CppReceiverDeclaration, +): string | null { + // A declaration read from elsewhere (an earlier function, another class) + // may not be this receiver's, so it is taken as it was before aliases + // were followed. + if (!inCallerScope) return normalized; + const aliased = resolveCppAliasedType(raw, ref, context); + if (aliased === undefined) return normalized; + if (found) { + found.aliased = aliased ? 'followed' : 'unreadable'; + found.written = normalized; + found.pointer = aliased ? aliased.pointer : isCppPointerType(raw); + found.classTemplate = aliased?.classTemplate ?? false; + } + return aliased ? cppAliasedTypeName(aliased) : null; +} + +/** + * Is line `line` of `file` — where `text` declares a C++ receiver at column + * `at` — the calling function's own code, or a member declaration of the + * caller's class? Receiver inference reads back to the top of the file and + * through the header, so a declaration it finds elsewhere (an earlier + * function, another class in the header, a comment) may be another variable + * of the same name. + */ +function isCppCallersDeclaration(file: string, line: number, text: string, at: number, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (/^\s*(?:\/\/|\/\*|\*)/.test(text)) return false; + const comment = text.indexOf('//'); + if (comment >= 0 && comment < at) return false; + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller || (caller.kind !== 'method' && caller.kind !== 'function')) return false; + if (file === ref.filePath && line >= caller.startLine && line <= ref.line) return true; + const cut = caller.qualifiedName.lastIndexOf('::'); + if (cut < 0) return false; + return context.getNodesByQualifiedName(caller.qualifiedName.slice(0, cut)).some((owner) => + (owner.kind === 'class' || owner.kind === 'struct' || owner.kind === 'union') && owner.filePath === file && + line >= owner.startLine && line <= (owner.endLine ?? owner.startLine)); +} + +/** Is the C++ member call at `ref` written `receiver.method(…)`, not through `->`? */ +function isCppDotCall(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const at = lines?.[ref.line - 1]?.slice(ref.column); + if (!lines || !at?.startsWith(receiver)) return false; + let rest = at.slice(receiver.length); + // `symbols_by_parent_` ending its line, `.insert(…)` starting the next. + for (let next = ref.line; !rest.trim() && next < Math.min(lines.length, ref.line + 3); next++) rest = lines[next]!; + return /^\s*\./.test(rest); +} + +/** + * A type name that reads as a type (capitalized) yet names no class, struct + * or interface the project declares: one from outside the project. + */ +function isUndeclaredTypeName(typeName: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + return /^[A-Z]/.test(typeName) && + !context.getNodesByName(typeName).some((n) => isMethodOwnerKind(n) && sameLanguageFamily(n.language, ref.language)); +} + function inferCppReceiverType( receiverName: string, ref: UnresolvedRef, context: ResolutionContext, depth = 0, + found?: CppReceiverDeclaration, ): string | null { // Per-file lines cache when available — this runs per `receiver->method()` // ref and re-splitting the file each time is the same quadratic as the @@ -8283,7 +8375,8 @@ function inferCppReceiverType( if (initType) return initType; // No usable initializer on this line — keep scanning earlier ones. } else if (normalized) { - return normalized; + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, line, declaratorMatch.index ?? 0, ref, context); + return cppDeclaredType(declaratorMatch[1]!, normalized, inCallerScope, ref, context, found); } } } @@ -8301,12 +8394,16 @@ function inferCppReceiverType( : (context.readFile(headerPath)?.split(/\r?\n/) ?? null); if (!headerLines) continue; - for (const line of headerLines) { + for (let i = 0; i < headerLines.length; i++) { + const line = headerLines[i]!; if (!receiverPattern.test(line)) continue; const declaratorMatch = line.match(declaratorRegex); if (!declaratorMatch) continue; const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); - if (normalized && normalized !== 'auto') return normalized; + if (normalized && normalized !== 'auto') { + const inCallerScope = isCppCallersDeclaration(headerPath, i + 1, line, declaratorMatch.index ?? 0, ref, context); + return cppDeclaredType(declaratorMatch[1]!, normalized, inCallerScope, ref, context, found); + } } } @@ -9957,9 +10054,10 @@ export function matchMethodCall( if (typed !== undefined) return typed; } const decl: { raw?: string } = {}; + const cppDecl: CppReceiverDeclaration = {}; let inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp' - ? inferCppReceiverType(objectOrClass!, ref, context) + ? inferCppReceiverType(objectOrClass!, ref, context, 0, cppDecl) : inferLocalReceiverType(objectOrClass!, ref, context, decl)); // 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`. @@ -10036,11 +10134,23 @@ export function matchMethodCall( // list wrapper's `add`, commons-lang's `s.length()` to a writer's. // (Only a type name — `java.util.List`, not a call chain like Python's // `Device.objects.create(…)` the initializer pattern also captures.) - const typeName = inferredType.split('.').pop()!; - if (/^[A-Z]/.test(typeName) && - !context.getNodesByName(typeName).some((n) => isMethodOwnerKind(n) && sameLanguageFamily(n.language, ref.language))) { - return null; - } + if (isUndeclaredTypeName(inferredType.split('.').pop()!, ref, context)) return null; + } + if (cppDecl.aliased) { + // A C++ receiver the calling function or its class declares through an + // alias has the type the alias names. When that type and its + // supertypes lack the method (`files_.clear()` through `using Files = + // std::vector;`), or the alias names a template parameter's type, + // a method picked by the receiver's name below would be some other + // type's. + if (!cppDecl.classTemplate && (cppDecl.pointer || isCppDotCall(objectOrClass!, ref, context))) return null; + // Not so for `it->m()` on an iterator or smart pointer, whose `->` + // reaches an element type the alias doesn't name, nor for one of the + // project's class templates, whose specializations may declare members + // the template itself doesn't (rocksdb's `omt_node_templated` + // adds `get_marked`): that call goes on as it did before aliases were + // followed. + if (isUndeclaredTypeName(cppDecl.written!, ref, context)) return null; } } From ccf1f35acb28d44e762934311b41746a55f9a066 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 03:45:23 -0600 Subject: [PATCH 8/9] fix(go): distinguish interface aliases from type-set terms --- CHANGELOG.md | 2 + README.md | 2 +- __tests__/go-interface-embedding.test.ts | 50 +++++++++++++++++++ codegraph-kernel/src/go/mod.rs | 2 +- codegraph-kernel/src/resolve/fields.rs | 38 ++++++++++++++ codegraph-kernel/src/resolve/pipeline.rs | 6 +++ .../content/docs/guides/framework-routes.md | 2 +- site/src/content/docs/reference/languages.md | 2 +- src/extraction/languages/go.ts | 2 +- 9 files changed, 101 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fefda5b732..07205cd071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Go interface type-set terms no longer appear as base interfaces; named aliases still link when their declarations reach an interface. + - `codegraph_explore` reports requested-file limits and uncovered indexed continuation ranges so partial answers have a clear next query. - Explore retains explicitly pinned code in mixed documentation/code requests, returns parameterized test callbacks, and preserves quoted source terms that match the filename stem. diff --git a/README.md b/README.md index 5eb97d6ddd..4e6c93dcc7 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ C# property accessors and expression-bodied properties contribute calls and refe C# field and property initializers retain their calls and references under the member that owns them. Target-typed `new()` resolves relative declared types through enclosing namespaces and honors `global::` qualification without requiring a redundant `using`. VB.NET resolves typed receivers, enclosing and inherited members, and field or property reads through values and Shared types without choosing unrelated project declarations. Names beginning with keywords, such as `SharedCache`, `Dimension` and `NewItem`, retain their declarations. -Go interfaces link named embedded interfaces; unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. +Go interfaces link named embedded interfaces and aliases that resolve to interfaces. Named scalar or struct terms, unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. Named JavaScript and TypeScript object literals own their function members, including local objects and classic-script global assignments. Member calls follow the visible object; loop-local objects stay within their scope. Destructured member calls resolve across lines while preserving the source binding at the destructure declaration; unrelated bare names stay unresolved. Vue template expressions contribute calls to script bindings while preserving component ownership. Encoded attribute expressions retain their original source positions, and template-local bindings stay within their scope. diff --git a/__tests__/go-interface-embedding.test.ts b/__tests__/go-interface-embedding.test.ts index fece4451e7..d3f9b07d0a 100644 --- a/__tests__/go-interface-embedding.test.ts +++ b/__tests__/go-interface-embedding.test.ts @@ -244,6 +244,56 @@ describe('the type hierarchy of an indexed Go module', () => { root = ''; }); + it.each(['native', 'generic'] as const)('rejects named type-set terms and retains interface aliases: %s', async (backend) => { + const saved = process.env.CODEGRAPH_KERNEL; + try { + if (backend === 'generic') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + resetKernelForTests(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-interface-target-')); + fs.writeFileSync(path.join(root, 'go.mod'), 'module example.com/app\n\ngo 1.22\n'); + fs.mkdirSync(path.join(root, 'api')); + fs.writeFileSync(path.join(root, 'api', 'api.go'), `package api + type Reader interface { Read() } + type ReaderAlias = Reader + type NamedInt int + `); + fs.writeFileSync(path.join(root, 'main.go'), `package app + import "example.com/app/api" + type Reader interface { Read() } + type Alias = Reader + type Chain = Alias + type Defined Reader + type NamedInt int + type ScalarAlias = NamedInt + type Record struct{} + type CycleA = CycleB + type CycleB = CycleA + type Direct interface { Reader } + type ViaAlias interface { Alias } + type ViaChain interface { Chain } + type ViaDefined interface { Defined } + type ViaPackage interface { api.ReaderAlias } + type Only interface { NamedInt } + type OnlyAlias interface { ScalarAlias } + type OnlyRecord interface { Record } + type OnlyPackage interface { api.NamedInt } + type OnlyCycle interface { CycleA } + `); + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const sources = graph.getNodesByKind('interface').filter((n) => n.filePath === 'main.go' && n.name !== 'Reader'); + const links = graph.getOutgoingEdgesFrom(sources.map((n) => n.id), ['extends', 'implements']) + .map((e) => `${graph.getNode(e.source)?.name}->${graph.getNode(e.target)?.name}`).sort(); + expect(links.filter((link) => link.startsWith('Only'))).toEqual([]); + expect(links).toEqual(['Direct->Reader', 'ViaAlias->Alias', 'ViaChain->Chain', 'ViaDefined->Defined', 'ViaPackage->ReaderAlias']); + } finally { + if (saved === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = saved; + resetKernelForTests(); + } + }, 60_000); + it('links embedded interfaces and structs to the package that declares them', async () => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-embedding-')); const files: Record = { diff --git a/codegraph-kernel/src/go/mod.rs b/codegraph-kernel/src/go/mod.rs index a7a4ccd6bb..af4c91eabb 100644 --- a/codegraph-kernel/src/go/mod.rs +++ b/codegraph-kernel/src/go/mod.rs @@ -289,7 +289,7 @@ impl<'t> Walker<'t> { } else if kind == "method_declaration" { self.extract_method(node); skip_children = true; - } else if kind == "type_spec" { + } else if matches!(kind, "type_spec" | "type_alias") { skip_children = self.extract_type_alias(node); } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration") && !self.inside_class_like() diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index f02091c5db..57b0e8e8c8 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -149,6 +149,44 @@ impl KernelResolver { Ok(Some((named.to_string(), pos_dirname(file).to_string()))) } + /// A named term in an interface is a supertype only if its declaration + /// reaches an interface. Named scalar/struct terms constrain a type set. + pub(super) fn go_interface_target(&mut self, target: &KNode, r: &ResolveRefIn) -> Res { + let mut target = target.clone(); + let mut seen = HashSet::new(); + loop { + if target.language != "go" || !seen.insert(target.id.clone()) { return Ok(false); } + if target.kind == "interface" { return Ok(true); } + if target.kind != "type_alias" { return Ok(false); } + let Some(source) = self.read_file(&target.file_path) else { return Ok(false); }; + let site = r.clone().at(&target); + let Some(tree) = self.parsed_tree(&source, &site) else { return Ok(false); }; + let mut declaration = super::iteration::descendant_for_position( + tree.root_node(), source.text(), + ((target.start_line - 1).max(0) as usize, target.start_column.max(0) as usize + 1), + ); + while !matches!(declaration.kind(), "type_spec" | "type_alias") { + let Some(parent) = declaration.parent() else { return Ok(false); }; + declaration = parent; + } + let Some(mut ty) = declaration.child_by_field_name("type") else { return Ok(false); }; + if ty.kind() == "interface_type" { return Ok(true); } + if ty.kind() == "generic_type" { + let Some(base) = ty.child_by_field_name("type") else { return Ok(false); }; + ty = base; + } + if !matches!(ty.kind(), "type_identifier" | "qualified_type") { return Ok(false); } + let raw = &source.text()[ty.start_byte()..ty.end_byte()]; + let Some((name, dir)) = self.go_type_package(raw, &target.file_path)? else { return Ok(false); }; + let candidates: Vec<_> = self.nodes_by_name(&name)?.iter() + .filter(|n| n.language == "go" && pos_dirname(&n.file_path) == dir + && matches!(n.kind.as_str(), "interface" | "struct" | "type_alias")) + .cloned().collect(); + let [only] = candidates.as_slice() else { return Ok(false); }; + target = (**only).clone(); + } + } + pub(super) fn go_method_on_declared_type( &mut self, raw: &str, diff --git a/codegraph-kernel/src/resolve/pipeline.rs b/codegraph-kernel/src/resolve/pipeline.rs index 738d432939..b54cd92be9 100644 --- a/codegraph-kernel/src/resolve/pipeline.rs +++ b/codegraph-kernel/src/resolve/pipeline.rs @@ -954,6 +954,12 @@ if r.language == "dart" && r.reference_kind == "references" && winner.node.id == r.from_node_id { return Ok(ResolveOutcome::unresolved()); } + if r.language == "go" && r.reference_kind == "extends" + && self.node_by_id(&r.from_node_id)?.is_some_and(|n| n.kind == "interface") + && !self.go_interface_target(&winner.node, r)? + { + return Ok(ResolveOutcome::unresolved()); + } winner=self.retarget_overload(winner,r)?; if r.reference_kind == "calls" { // memberName = the last `.` segment — `Cls::member` and bare diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index bff1814740..87d09a5e34 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -90,4 +90,4 @@ Express treats the last registration argument as the handler, excluding a traili Axum and Actix handler links use the handler argument position, including multiline and nested registrations. Actix scope prefixes remain part of the route path. -Angular template calls in bindings, interpolation and control-flow blocks link the component member they invoke. Template locals hide same-named component members; inherited component members remain unsupported. Framework detection reads nested app manifests recursively. +Angular template calls in property bindings, interpolations, structural directives and control-flow blocks link the component member they invoke. A method passed to a child, such as `[displayWith]="displayFn"`, links as a function reference. Template locals hide same-named component members; inherited component members remain unsupported. Framework detection reads the root manifest and the first two directory levels. Remaining slots cover directories above JS/TS code, shallowest first, within the manifest limit. diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index fb91a59990..da0f6cb444 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -9,7 +9,7 @@ C# property accessors and expression-bodied properties contribute calls and refe C# field and property initializers retain their calls and references under the member that owns them. Target-typed `new()` resolves relative declared types through enclosing namespaces and honors `global::` qualification without requiring a redundant `using`. VB.NET resolves typed receivers, enclosing and inherited members, and field or property reads through values and Shared types without choosing unrelated project declarations. Names beginning with keywords, such as `SharedCache`, `Dimension` and `NewItem`, retain their declarations. -Go interfaces link named embedded interfaces; unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. +Go interfaces link named embedded interfaces and aliases that resolve to interfaces. Named scalar or struct terms, unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. Named JavaScript and TypeScript object literals own their function members, including local objects and classic-script global assignments. Member calls follow the visible object; loop-local objects stay within their scope. Destructured member calls resolve across lines while preserving the source binding at the destructure declaration; unrelated bare names stay unresolved. Vue template expressions contribute calls to script bindings while preserving component ownership. Encoded attribute expressions retain their original source positions, and template-local bindings stay within their scope. diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index fa5e53ee87..f4b008bb4e 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -58,7 +58,7 @@ export const goExtractor: LanguageExtractor = { interfaceTypes: [], // Handled via type_spec → resolveTypeAliasKind structTypes: [], // Handled via type_spec → resolveTypeAliasKind enumTypes: [], - typeAliasTypes: ['type_spec'], // Go type declarations + typeAliasTypes: ['type_spec', 'type_alias'], // Go type definitions and aliases importTypes: ['import_declaration'], callTypes: ['call_expression'], variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'], From 93ab92be24562e92b46d6692a84d0d2b80df792f Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 04:07:29 -0600 Subject: [PATCH 9/9] fix(sync): reconcile Shopify theme context changes --- CHANGELOG.md | 2 + README.md | 2 +- __tests__/shopify-marker-watcher.test.ts | 63 ++++++++++++++++ site/src/content/docs/reference/languages.md | 2 +- src/extraction/index.ts | 77 +++++++++++++++++--- src/sync/watcher.ts | 8 +- 6 files changed, 139 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07205cd071..1e880cc69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Shopify templates and Liquid links now stay current when a theme marker is added or removed, including committed changes after reopening and ignored marker files in otherwise included themes. + - Go interface type-set terms no longer appear as base interfaces; named aliases still link when their declarations reach an interface. - `codegraph_explore` reports requested-file limits and uncovered indexed continuation ranges so partial answers have a clear next query. diff --git a/README.md b/README.md index 4e6c93dcc7..c80324c330 100644 --- a/README.md +++ b/README.md @@ -567,7 +567,7 @@ These frameworks additionally emit **`navigates`** edges: the function that send | **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`, `[id=matcher]` → `:id`, `(group)` folders stripped), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `` that is a link in a SvelteKit app | | **Angular** | `Routes` arrays (`RouterModule.forRoot` / `forChild`, `provideRouter`, a routes file's default export) with `component` or a lazy `loadComponent`; `children` and lazy `loadChildren` (an NgModule's through its routing module) joined into full paths; paths written as route constants or `$localize` strings; a route with children is a layout around the screens inside it | `router.navigate([...])`, `navigateByUrl`, a guard's `createUrlTree` / `parseUrl` — a command array, a route constant, or a component property holding one — `routerLink` / `[routerLink]` in the component's template, and `redirectTo`. Each template's child components (``) are linked to the component that renders them. Calls in bindings, interpolation and control-flow blocks link the component member they invoke | -In a repository holding several apps, each app's routes are matched only against navigation written inside that app. Framework detection reads each app manifest recursively. Shopify section and snippet references stay inside their nearest theme; JSON under `templates/` or `sections/` is Liquid only beside a theme marker. +In a repository holding several apps, each app's routes are matched only against navigation written inside that app. Framework detection checks the root and first two directory levels, then uses shallow JavaScript and TypeScript source paths for a bounded set of deeper app manifests. Shopify section and snippet references stay inside their nearest theme; JSON under `templates/` or `sections/` is Liquid only beside a theme marker. Sync reconciles JSON membership and unchanged Liquid links when a theme marker is added or removed. --- diff --git a/__tests__/shopify-marker-watcher.test.ts b/__tests__/shopify-marker-watcher.test.ts index 049672742d..61c547306b 100644 --- a/__tests__/shopify-marker-watcher.test.ts +++ b/__tests__/shopify-marker-watcher.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { CodeGraph } from '../src'; import { FileWatcher, __emitWatchEventForTests } from '../src/sync/watcher'; @@ -115,3 +116,65 @@ it('keeps unchanged marker events and ordinary files out of full reconciliation' await emit('sections/header.liquid'); expect(sync.mock.calls.map(call => call[0])).toEqual([['sections/header.liquid']]); }); + +it('detects committed schema markers and rebinds unchanged Liquid references after reopening', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-shopify-marker-')); + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Test'); + write('.gitignore', '.codegraph/\n'); + write('a/sections/header.liquid', "{% render 'card' %}"); + write('a/templates/index.json', '{"sections": {}}'); + write('b/config/settings_schema.json', '[]'); + write('b/snippets/card.liquid', '

    Card

    '); + git('add', '-A'); git('commit', '-m', 'initial themes'); + graph = await CodeGraph.init(root, { index: true }); + const targets = () => { + const source = graph!.getNodesByKind('file').find(n => n.filePath === 'a/sections/header.liquid')!; + return graph!.getOutgoingEdges(source.id).map(e => graph!.getNode(e.target)?.filePath); + }; + expect(targets()).toContain('b/snippets/card.liquid'); + write('a/config/settings_schema.json', '[]'); + git('add', '-A'); git('commit', '-m', 'mark second theme'); + graph.close(); graph = CodeGraph.openSync(root); + expect.soft(graph.getChangedFiles().added).toContain('a/templates/index.json'); + expect((await graph.sync()).filesModified).toBe(1); + expect.soft(targets()).not.toContain('b/snippets/card.liquid'); + expect(graph.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] }); + fs.unlinkSync(path.join(root, 'a/config/settings_schema.json')); + git('add', '-A'); git('commit', '-m', 'remove second marker'); + graph.close(); graph = CodeGraph.openSync(root); + expect(graph.getChangedFiles().removed).toContain('a/templates/index.json'); + await graph.sync(); + expect(targets()).toContain('b/snippets/card.liquid'); + expect((await graph.sync()).filesModified).toBe(0); +}); + +it('honors file-ignored markers while excluding entire theme subtrees', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-shopify-marker-')); + write('.gitignore', 'a/config/settings_schema.json\nexcluded/\n'); + write('a/templates/index.json', '{"sections": {}}'); + write('excluded/templates/index.json', '{"sections": {}}'); + graph = await CodeGraph.init(root, { index: true }); + let complete!: () => void; + const sync = vi.fn(async () => { + const result = await graph!.sync(); + return { filesChanged: result.filesAdded + result.filesModified + result.filesRemoved, durationMs: 0 }; + }); + watcher = new FileWatcher(root, sync, { inertForTests: true, debounceMs: 10, onSyncComplete: () => complete() }); + watcher.start(); await watcher.waitUntilReady(); vi.useFakeTimers(); + write('excluded/config/settings_schema.json', '[]'); + await emit('excluded/config/settings_schema.json'); + expect(sync).not.toHaveBeenCalled(); + write('a/config/settings_schema.json', '[]'); + const done = new Promise(resolve => { complete = resolve; }); + await emit('a/config/settings_schema.json'); + expect(sync).toHaveBeenCalledOnce(); + await done; + expect(graph.getNodesByKind('file').map(n => n.filePath)).toContain('a/templates/index.json'); + expect(sync).toHaveBeenCalledOnce(); + fs.unlinkSync(path.join(root, 'a/config/settings_schema.json')); + const removed = new Promise(resolve => { complete = resolve; }); + await emit('a/config/settings_schema.json'); await removed; + expect(graph.getNodesByKind('file').map(n => n.filePath)).not.toContain('a/templates/index.json'); + expect(sync).toHaveBeenCalledTimes(2); +}); diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index da0f6cb444..317bc14419 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -83,4 +83,4 @@ Python `super()` and `super(Cls, self)` calls follow the class's method resoluti A Python method passed as a value through a module-level variable, such as `pool.submit(settings.conn.fetch)` after `global conn; conn = Store()`, links to the method of the class assigned to it. Those classes come from constructor calls or annotations (`conn: Store = make_store()`) in the variable's module, at module level or in a function or class body that declares it `global`, and from writes like `settings.conn = Store()` in other production modules. With several classes, the link goes to the declaration they all inherit. The module may be spelled in full (`import pkg.settings`, then `pkg.settings.conn.fetch`), but a longer chain such as `settings.conn.pool.fetch` reads an attribute and keeps the usual lookup. The usual lookup also decides when an unannotated assignment is something other than a constructor call (a factory, a conditional, a tuple target, a loop, `with`, `del`, an import or a star import), when an annotation and the constructor name different classes, when the writing function or its module rebinds the class's name (`Store = Decoy`) or the file imports it from two places, when `setattr`, `patch.object`, `__dict__` (a computed key included), `globals()`, `vars()` or `sys.modules[__name__]` can rebind the variable, when another module writes it through a name its function or module rebinds or that it imports from two places, when a class or function of the same name rebinds it, and when the calling function binds the name itself. Test files and `if __name__ == "__main__":` blocks don't change the variable's classes. A reference links to nothing when any of the variable's classes lacks a callable method of that name, or when it sits in a test file that installs its own double. -C++ receivers declared through typedef or using aliases follow the alias declaration scope and nested owners. Pointer, array and function members are not supertypes. Shopify section and snippet references stay inside their nearest theme; JSON under `templates/` or `sections/` is Liquid only beside a theme marker. +C++ receivers declared through typedef or using aliases follow the alias declaration scope and nested owners. Pointer, array and function members are not supertypes. Shopify section and snippet references stay inside their nearest theme; JSON under `templates/` or `sections/` is Liquid only beside a theme marker. Adding or removing a marker reconciles JSON membership and re-resolves unchanged Liquid links, including after reopening the index. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 1148a271d4..fcba87814f 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -25,7 +25,7 @@ import { DEFAULT_PARSE_POOL_CAP, FILES_PER_PARSE_WORKER, ParseWorkerPool, isRetr import { StoreWriter, StoreBundle, finalizeStoreBundle, attachBindings } from './store-writer'; import { materializeKernelResult } from './kernel'; import { detectGeneratedFile } from './generated-detection'; -import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, isMpegTransportStream, hasMpegTsExtension, MPEG_TS_SNIFF_BYTES, hasGrammarLoadFailure } from './grammars'; +import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, isMpegTransportStream, hasMpegTsExtension, MPEG_TS_SNIFF_BYTES, hasGrammarLoadFailure, isShopifyThemeMarker, shopifyThemeRoot } from './grammars'; import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns, PROJECT_CONFIG_FILENAME } from '../project-config'; import { isCodeGraphDataDir } from '../directory'; import { logDebug, logWarn } from '../errors'; @@ -1464,7 +1464,8 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo // filtered by exactly the rules a working-tree change is (#766, #999, #1829). const classify = (statusCode: string, rel: string): void => { const filePath = normalizePath(prefix + rel); - if (!isSource(filePath)) return; + const themeMarker = isShopifyThemeMarker(filePath); + if (!themeMarker && !isSource(filePath)) return; if (statusCode.includes('D')) { // Deletions stay unfiltered: getChangedFiles acts on one only when the @@ -1474,14 +1475,14 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, isSo return; } - // Added (`??`) / modified files inside an excluded dir must not enter the - // index — match against the repo-relative path, same as the full scan. (#766) - if (ig.ignores(rel)) return; + // Marker candidates only force a scoped full scan; they are not indexed. + // Ordinary source candidates retain the full scan's exclusion rules. + if (!themeMarker && ig.ignores(rel)) return; // User `codegraph.json` `exclude` (#999) is project-root-relative, so it's // matched against the full path — sync must not re-add a tracked file the // full index now keeps out. Deletions above stay unfiltered so a file that // WAS indexed before an exclude was added still cleans itself out. - if (exclude && exclude.ignores(filePath)) return; + if (!themeMarker && exclude && exclude.ignores(filePath)) return; if (statusCode === '??') { out.added.push(filePath); @@ -1885,6 +1886,7 @@ export class ExtractionOrchestrator { */ private detectedFrameworkNames: string[] | null = null; private conventionInvalidatedFiles = new Set(); + private pendingLiquidThemeRoots: Record | null = null; /** * Scope matcher for SCOPED syncs, memoized on the mtimes of the two root * files it is derived from (`codegraph.json`, `.gitignore`). See @@ -2156,6 +2158,7 @@ export class ExtractionOrchestrator { currentFile: file, }); }, skipStats); + this.prepareLiquidThemeRoots(files); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`); /** Only meaningful when nothing was indexable — see IndexResult (#1502). */ const skipSummary = (): Pick => { @@ -3438,6 +3441,8 @@ export class ExtractionOrchestrator { trackedFiles = this.queries.getAllFiles(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`); } + const previousThemes = this.indexedLiquidThemeRoots(); + this.prepareLiquidThemeRoots(currentFiles, scopedPaths?.length ? scopedPaths : undefined); const currentSet = new Set(currentFiles); const trackedMap = new Map(); for (const f of trackedFiles) { @@ -3499,13 +3504,16 @@ export class ExtractionOrchestrator { // size, mtime and hash all match. Rows with a real parse error are // deterministic and are not retried. const neverParsed = tracked !== undefined && hasGrammarLoadFailure(tracked.errors); + const themeChanged = tracked?.language === 'liquid' && + previousThemes?.[filePath] !== this.liquidThemeRoot(filePath); + if (themeChanged) this.conventionInvalidatedFiles.add(filePath); // Cheap pre-filter: an already-indexed file whose size AND mtime both match // the DB is unchanged — skip it without reading or hashing. (A content // change that preserves both exactly is the blind spot every mtime-based // incremental tool accepts; `index --force` is the escape hatch. Git bumps // mtime on every file it writes during checkout/merge, so pulls are caught.) - if (tracked && !neverParsed) { + if (tracked && !neverParsed && !themeChanged) { try { const stat = fs.statSync(fullPath); if (stat.size === tracked.size && Math.floor(stat.mtimeMs) === Math.floor(tracked.modifiedAt)) { @@ -3542,7 +3550,7 @@ export class ExtractionOrchestrator { changedFilePaths.push(filePath); addedFilePaths.push(filePath); filesAdded++; - } else if (tracked.contentHash !== contentHash || neverParsed) { + } else if (tracked.contentHash !== contentHash || neverParsed || themeChanged) { onFileChange?.(filePath, content); filesToIndex.push(filePath); changedFilePaths.push(filePath); @@ -3673,8 +3681,38 @@ export class ExtractionOrchestrator { } catch { return null; } } + private liquidThemeRoot(filePath: string): string | null { + return shopifyThemeRoot(filePath, marker => fs.existsSync(path.join(this.rootDir, marker))) ?? null; + } + + private indexedLiquidThemeRoots(): Record | null { + try { + const roots = JSON.parse(this.queries.getMetadata('indexed_liquid_theme_roots') ?? 'null'); + if (!roots || typeof roots !== 'object' || Array.isArray(roots)) return null; + return Object.values(roots).every(root => root === null || typeof root === 'string') ? roots : null; + } catch { return null; } + } + + /** Capture context before parsing; a later marker edit must remain pending. */ + private prepareLiquidThemeRoots(files: string[], scopedPaths?: string[]): void { + const previous = this.indexedLiquidThemeRoots(); + const roots: Record = Object.create(null); + if (scopedPaths && previous) Object.assign(roots, previous); + for (const file of scopedPaths ?? []) delete roots[file]; + const overrides = loadExtensionOverrides(this.rootDir); + for (const file of files) { + if (detectLanguage(file, undefined, overrides, this.rootDir) !== 'liquid') continue; + if (!fs.existsSync(path.join(this.rootDir, file))) continue; + roots[file] = this.liquidThemeRoot(file); + if (previous?.[file] !== roots[file]) this.conventionInvalidatedFiles.add(file); + } + // A scoped write cannot certify untouched files from a legacy index. + this.pendingLiquidThemeRoots = scopedPaths && previous === null ? null : roots; + } + /** Capture before file reads. In-flight/failed full writes must not claim freshness. */ beginGitIndexState(full: boolean): { head: string; stamp: string; dirty: string[] | null } { + this.pendingLiquidThemeRoots = null; const head = getGitHeadSha(this.rootDir) ?? ''; const stamp = this.queries.getMetadata(INDEXED_AT_COMMIT_KEY) ?? ''; const prior = this.indexedDirtyPaths(stamp); @@ -3694,6 +3732,16 @@ export class ExtractionOrchestrator { } finishGitIndexState(snapshot: { head: string; stamp: string; dirty: string[] | null }, full: boolean, retries: string[] = []): void { + if (this.pendingLiquidThemeRoots) { + const roots = this.pendingLiquidThemeRoots; + const previous = this.indexedLiquidThemeRoots(); + for (const file of retries) { + if (previous && Object.hasOwn(previous, file)) roots[file] = previous[file]!; + else delete roots[file]; + } + this.queries.setMetadata('indexed_liquid_theme_roots', JSON.stringify(roots)); + this.pendingLiquidThemeRoots = null; + } const after = getGitChangedFiles(this.rootDir); if (!snapshot.dirty || !after) return; const commit = full ? snapshot.head : snapshot.stamp; @@ -3719,8 +3767,13 @@ export class ExtractionOrchestrator { const gitChanges = dirtyPaths !== null && canTrustGitFastPath(this.rootDir, sinceCommit) ? getGitChangedFiles(this.rootDir, sinceCommit) : null; + const previousThemes = this.indexedLiquidThemeRoots(); + const themeChanged = previousThemes === null || Object.entries(previousThemes).some( + ([file, root]) => root !== this.liquidThemeRoot(file) + ); + const markerChanged = gitChanges && [...gitChanges.added, ...gitChanges.modified, ...gitChanges.deleted, ...dirtyPaths!].some(isShopifyThemeMarker); - if (gitChanges) { + if (gitChanges && !markerChanged && !themeChanged) { // === Git fast path === const added: string[] = []; const modified: string[] = []; @@ -3752,7 +3805,8 @@ export class ExtractionOrchestrator { continue; } if (!tracked) added.push(filePath); - else if (tracked.contentHash !== hashContent(content)) modified.push(filePath); + else if (tracked.contentHash !== hashContent(content) || + (tracked.language === 'liquid' && previousThemes?.[filePath] !== this.liquidThemeRoot(filePath))) modified.push(filePath); } return { added, modified, removed }; @@ -3800,7 +3854,8 @@ export class ExtractionOrchestrator { if (!tracked) { added.push(filePath); - } else if (tracked.contentHash !== contentHash) { + } else if (tracked.contentHash !== contentHash || + (tracked.language === 'liquid' && previousThemes?.[filePath] !== this.liquidThemeRoot(filePath))) { modified.push(filePath); } } diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 8d14adc83f..350524cd5f 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -640,7 +640,12 @@ export class FileWatcher { this.refreshScope(rel); return; } - if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return; + const themeMarker = isShopifyThemeMarker(rel); + // A file-level marker exclusion does not exclude its theme's source files. + // Entire excluded theme trees still stay silent. + const themeDir = themeMarker ? path.posix.dirname(path.posix.dirname(rel)) : null; + const scopePath = themeDir === '.' ? null : themeDir ? `${themeDir}/` : rel; + if (scopePath && this.ignoreMatcher?.ignores(scopePath)) return; // A nested `.gitignore` (an embedded child repo's own rules, #514, or a // subdirectory rule the git-backed full scan honors) is only a scope // change when it sits INSIDE the current scope — checked after the matcher @@ -651,7 +656,6 @@ export class FileWatcher { this.refreshScope(rel); return; } - const themeMarker = isShopifyThemeMarker(rel); if (!themeMarker && !isSourceFile(rel, loadExtensionOverrides(this.projectRoot), this.projectRoot)) { this.maybeScheduleForRemovedDir(rel); return;