diff --git a/package.json b/package.json index c9812eb34..dea1a2bab 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,13 @@ "npm-run-path": "4.0.1", "nx": "22.3.3", "nx-cloud": "19.1.0", + "p-limit": "^6.2.0", "prettier": "^2.6.2", "semver": "^7.6.0", + "strip-indent": "^3.0.0", "tcp-port-used": "^1.0.2", "tree-kill": "^1.2.2", + "tree-sitter-go": "^0.25.0", "ts-jest": "29.4.0", "ts-node": "10.9.1", "tsx": "^4.19.4", @@ -84,6 +87,7 @@ "verdaccio": "6.0.5", "vite": "7.3.1", "vitest": "4.0.9", + "web-tree-sitter": "^0.26.3", "webpack": "^5.90.1", "wrangler": "^4.26.0" }, diff --git a/packages/gonx-e2e/src/static-analysis.spec.ts b/packages/gonx-e2e/src/static-analysis.spec.ts new file mode 100644 index 000000000..b74a60b1a --- /dev/null +++ b/packages/gonx-e2e/src/static-analysis.spec.ts @@ -0,0 +1,194 @@ +import { + uniq, + tmpProjPath, + runNxCommand, + ensureNxProject, + cleanup, + readJson, +} from '@nx/plugin/testing'; +import { join } from 'path'; +import { writeFileSync, readFileSync } from 'fs'; + +describe('Dependency Detection', () => { + beforeEach(() => { + ensureNxProject('@naxodev/gonx', 'dist/packages/gonx'); + + // Initialize Go support + runNxCommand('generate @naxodev/gonx:init'); + }); + + afterEach(() => cleanup()); + + it('should detect dependencies between Go projects', async () => { + const goapp = uniq('goapp'); + const golib = uniq('golib'); + + // Generate a library first + runNxCommand(`generate @naxodev/gonx:library ${golib}`, { + env: { NX_ADD_PLUGINS: 'true' }, + }); + + // Generate an application + runNxCommand(`generate @naxodev/gonx:application ${goapp}`, { + env: { NX_ADD_PLUGINS: 'true' }, + }); + + // Get the library's module path from go.mod + const libGoModPath = join(tmpProjPath(), golib, 'go.mod'); + const libGoModContent = readFileSync(libGoModPath, 'utf-8'); + const moduleMatch = libGoModContent.match(/module\s+(\S+)/); + const libModulePath = moduleMatch ? moduleMatch[1] : `example.com/${golib}`; + + // Update the application to import the library + const mainGoPath = join(tmpProjPath(), goapp, 'main.go'); + writeFileSync( + mainGoPath, + `package main + +import ( + "fmt" + "${libModulePath}" +) + +func main() { + fmt.Println("Hello from ${goapp}") + fmt.Println(${golib}.Hello()) +} +` + ); + + // Add a replace directive to the app's go.mod to point to the library + const appGoModPath = join(tmpProjPath(), goapp, 'go.mod'); + const appGoModContent = readFileSync(appGoModPath, 'utf-8'); + writeFileSync( + appGoModPath, + `${appGoModContent} +replace ${libModulePath} => ../${golib} +` + ); + + // Reset Nx to pick up the changes + runNxCommand('reset'); + + // Run nx graph to generate the project graph JSON + runNxCommand('graph --file=graph.json'); + + // Read the generated graph file + const graphJson = readJson('graph.json'); + + // Verify that the dependency was detected + const appDeps = graphJson.graph?.dependencies?.[goapp] || []; + const hasDependencyOnLib = appDeps.some( + (dep: { target: string }) => dep.target === golib + ); + + expect(hasDependencyOnLib).toBe(true); + }, 120_000); + + it('should detect dependencies in multi-project workspace', async () => { + const goapp = uniq('goapp'); + const golib1 = uniq('golib1'); + const golib2 = uniq('golib2'); + + // Generate two libraries + runNxCommand(`generate @naxodev/gonx:library ${golib1}`, { + env: { NX_ADD_PLUGINS: 'true' }, + }); + runNxCommand(`generate @naxodev/gonx:library ${golib2}`, { + env: { NX_ADD_PLUGINS: 'true' }, + }); + + // Generate an application + runNxCommand(`generate @naxodev/gonx:application ${goapp}`, { + env: { NX_ADD_PLUGINS: 'true' }, + }); + + // Get module paths + const lib1GoModContent = readFileSync( + join(tmpProjPath(), golib1, 'go.mod'), + 'utf-8' + ); + const lib1ModulePath = + lib1GoModContent.match(/module\s+(\S+)/)?.[1] || `example.com/${golib1}`; + + const lib2GoModContent = readFileSync( + join(tmpProjPath(), golib2, 'go.mod'), + 'utf-8' + ); + const lib2ModulePath = + lib2GoModContent.match(/module\s+(\S+)/)?.[1] || `example.com/${golib2}`; + + // Update lib1 to import lib2 + const lib1MainPath = join(tmpProjPath(), golib1, `${golib1}.go`); + writeFileSync( + lib1MainPath, + `package ${golib1} + +import ( + "${lib2ModulePath}" +) + +func Hello() string { + return "Hello from ${golib1}: " + ${golib2}.Hello() +} +` + ); + + // Add replace directive to lib1's go.mod + const lib1GoModPath = join(tmpProjPath(), golib1, 'go.mod'); + writeFileSync( + lib1GoModPath, + `${lib1GoModContent} +replace ${lib2ModulePath} => ../${golib2} +` + ); + + // Update app to import lib1 + const mainGoPath = join(tmpProjPath(), goapp, 'main.go'); + writeFileSync( + mainGoPath, + `package main + +import ( + "fmt" + "${lib1ModulePath}" +) + +func main() { + fmt.Println(${golib1}.Hello()) +} +` + ); + + // Add replace directive to app's go.mod + const appGoModPath = join(tmpProjPath(), goapp, 'go.mod'); + const appGoModContent = readFileSync(appGoModPath, 'utf-8'); + writeFileSync( + appGoModPath, + `${appGoModContent} +replace ${lib1ModulePath} => ../${golib1} +` + ); + + // Reset Nx + runNxCommand('reset'); + + // Generate and check the graph + runNxCommand('graph --file=graph.json'); + const graphJson = readJson('graph.json'); + + // Check app -> lib1 dependency + const appDeps = graphJson.graph?.dependencies?.[goapp] || []; + const appDependsOnLib1 = appDeps.some( + (dep: { target: string }) => dep.target === golib1 + ); + expect(appDependsOnLib1).toBe(true); + + // Check lib1 -> lib2 dependency + const lib1Deps = graphJson.graph?.dependencies?.[golib1] || []; + const lib1DependsOnLib2 = lib1Deps.some( + (dep: { target: string }) => dep.target === golib2 + ); + expect(lib1DependsOnLib2).toBe(true); + }, 180_000); +}); diff --git a/packages/gonx/README.md b/packages/gonx/README.md index 3be956f09..70b877b27 100644 --- a/packages/gonx/README.md +++ b/packages/gonx/README.md @@ -17,24 +17,26 @@
-## ✨ Features - -- ✅ Generate Go Applications - - ✅ Customizable Go module setup - - ✅ Well-structured Go code scaffolding -- ✅ Generate Go Libraries -- ✅ Full Nx integration - - ✅ Inferred Tasks: Build, Generate, Tidy, Test, Run, and Lint - - ✅ Cacheable Tasks: Build, Generate, Tidy, Test, and Lint - - ✅ GraphV2 Support - - ✅ Version Actions for Go release - - ✅ Nx Release Publish executor to release to list the module on the registry -- ✅ Use official Go commands in the background -- ✅ Efficient caching and dependency graph tools for Go projects - -## 🚀 Getting started - -You need to have a [stable version of Go](https://go.dev/dl/) installed on your machine. And... you are ready! +## Features + +- Generate Go Applications + - Customizable Go module setup + - Well-structured Go code scaffolding +- Generate Go Libraries +- Full Nx integration + - Inferred Tasks: Build, Generate, Tidy, Test, Run, and Lint + - Cacheable Tasks: Build, Generate, Tidy, Test, and Lint + - GraphV2 Support + - Version Actions for Go release + - Nx Release Publish executor +- Use official Go commands in the background +- Dependency detection via tree-sitter static analysis (no Go + required) + +## Getting started + +You need to have a [stable version of Go](https://go.dev/dl/) +installed on your machine. And... you are ready! ### Generate a Nx workspace with Go support @@ -48,13 +50,37 @@ npx create-nx-workspace go-workspace --preset=@naxodev/gonx nx add @naxodev/gonx ``` +## Plugin Options + +Configure the plugin in your `nx.json`: + +```json +{ + "plugins": [ + { + "plugin": "@naxodev/gonx", + "options": {} + } + ] +} +``` + +| Option | Type | Default | Description | +| ----------------------- | ------- | ------- | ------------------------------------- | +| `skipGoDependencyCheck` | boolean | `false` | Disable dependency detection entirely | + +See [Dependency Detection](./docs/static-analysis.md) for details on +how dependencies between Go projects are resolved. + ## Docs -To read the full documentation, check out the [docs](https://gonx.naxo.dev/) site. +To read the full documentation, check out the +[docs](https://gonx.naxo.dev/) site. ## Contributors -Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): +Thanks goes to these wonderful people +([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -105,4 +131,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d ## Acknowledgements -This project is a fork of [nx-go](https://github.com/nx-go/nx-go), a plugin for Nx that provides tools for building Go applications. Most credit goes to the original maintainers of nx-go - we've built upon their excellent foundation to modernize the plugin for the latest Nx features. +This project is a fork of [nx-go](https://github.com/nx-go/nx-go), a +plugin for Nx that provides tools for building Go applications. Most +credit goes to the original maintainers of nx-go - we've built upon +their excellent foundation to modernize the plugin for the latest Nx +features. diff --git a/packages/gonx/docs/static-analysis.md b/packages/gonx/docs/static-analysis.md new file mode 100644 index 000000000..81969d741 --- /dev/null +++ b/packages/gonx/docs/static-analysis.md @@ -0,0 +1,71 @@ +# Dependency Detection + +## Overview + +gonx detects dependencies between Go projects using tree-sitter to +parse Go source files directly. This approach does not require Go to +be installed. + +## How It Works + +1. **Module Discovery**: Scans all Nx projects for `go.mod` files and + extracts module paths and replace directives. + +2. **Import Extraction**: Parses `.go` files using tree-sitter to + extract import statements. Excludes `vendor/` and `testdata/` + directories. + +3. **Dependency Resolution**: Uses longest-prefix matching to resolve + imports to Nx projects. For example, importing + `github.com/myorg/shared/utils` resolves to the project containing + `module github.com/myorg/shared`. + +4. **Replace Directives**: Scoped per-project. A replace directive in + one project's `go.mod` only affects that project's imports. + +## Configuration + +In your `nx.json`: + +```json +{ + "plugins": [ + { + "plugin": "@naxodev/gonx", + "options": {} + } + ] +} +``` + +### Options + +| Option | Type | Default | Description | +| ----------------------- | ------- | ------- | ------------------------------------- | +| `skipGoDependencyCheck` | boolean | `false` | Disable dependency detection entirely | + +## Limitations + +- **No build tag support**: All `.go` files are parsed regardless of + `//go:build` constraints. This may include platform-specific + dependencies that wouldn't be compiled in practice. + +- **No cgo support**: The `import "C"` pseudo-import is filtered out. + +- **No dynamic imports**: Only static `import` statements are + detected. + +## Troubleshooting + +### Dependencies not detected + +1. Verify both projects have `go.mod` files +2. Check the module paths match the import statements +3. Ensure the importing project has a replace directive pointing to + the local project (required when not using `go.work`) + +### Debugging + +```bash +NX_VERBOSE_LOGGING=true nx graph +``` diff --git a/packages/gonx/package.json b/packages/gonx/package.json index b15c3be0c..86b57e75e 100644 --- a/packages/gonx/package.json +++ b/packages/gonx/package.json @@ -40,6 +40,9 @@ "tslib": "^2.3.0", "npm-run-path": "4.0.1", "chalk": "^4.1.2", - "@melkeydev/go-blueprint": "0.10.11" + "@melkeydev/go-blueprint": "0.10.11", + "p-limit": "^6.2.0", + "web-tree-sitter": "^0.26.3", + "tree-sitter-go": "^0.25.0" } } diff --git a/packages/gonx/src/graph/create-dependencies.spec.ts b/packages/gonx/src/graph/create-dependencies.spec.ts new file mode 100644 index 000000000..d589217b8 --- /dev/null +++ b/packages/gonx/src/graph/create-dependencies.spec.ts @@ -0,0 +1,65 @@ +jest.mock('./static-analysis'); + +import { DependencyType } from '@nx/devkit'; +import { createDependencies } from './create-dependencies'; +import { createStaticAnalysisDependencies } from './static-analysis'; + +const mockStaticAnalysis = + createStaticAnalysisDependencies as jest.MockedFunction< + typeof createStaticAnalysisDependencies + >; + +describe('createDependencies', () => { + const baseContext = { + projects: {}, + filesToProcess: { + projectFileMap: {}, + nonProjectFiles: [], + }, + nxJsonConfiguration: {}, + workspaceRoot: '/workspace', + fileMap: { + projectFileMap: {}, + nonProjectFiles: [], + }, + externalNodes: {}, + } as Parameters[1]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStaticAnalysis.mockResolvedValue([ + { + type: DependencyType.static, + source: 'app', + target: 'lib', + sourceFile: 'app/main.go', + }, + ]); + }); + + it('should return empty array when skipGoDependencyCheck is true', async () => { + const result = await createDependencies( + { skipGoDependencyCheck: true }, + baseContext + ); + + expect(result).toEqual([]); + expect(mockStaticAnalysis).not.toHaveBeenCalled(); + }); + + it('should delegate to static analysis', async () => { + const result = await createDependencies(undefined, baseContext); + + expect(mockStaticAnalysis).toHaveBeenCalledWith(undefined, baseContext); + expect(result).toHaveLength(1); + }); + + it('should pass options through to static analysis', async () => { + const options = { skipGoDependencyCheck: false }; + + await createDependencies(options, baseContext); + + expect(mockStaticAnalysis).toHaveBeenCalledWith(options, baseContext); + }); +}); diff --git a/packages/gonx/src/graph/create-dependencies.ts b/packages/gonx/src/graph/create-dependencies.ts index bbd706925..d8d03370b 100644 --- a/packages/gonx/src/graph/create-dependencies.ts +++ b/packages/gonx/src/graph/create-dependencies.ts @@ -1,54 +1,14 @@ -import { - CreateDependencies, - DependencyType, - RawProjectGraphDependency, -} from '@nx/devkit'; -import { extname } from 'path'; +import { CreateDependencies } from '@nx/devkit'; +import { createStaticAnalysisDependencies } from './static-analysis'; import { GoPluginOptions } from './types/go-plugin-options'; -import { GoModule } from './types/go-module'; -import { computeGoModules } from './utils/compute-go-modules'; -import { ProjectRootMap } from './types/project-root-map'; -import { extractProjectRootMap } from './utils/extract-project-root-map'; -import { getFileModuleImports } from './utils/get-file-module-imports'; -import { getProjectNameForGoImport } from './utils/get-project-name-for-go-imports'; - -// NOTE: LIMITATION: This assumes the name of the package from the last part of the path. -// So having two package with the same name in different directories will cause issues. export const createDependencies: CreateDependencies = async ( options, context ) => { - const dependencies: RawProjectGraphDependency[] = []; - - let goModules: GoModule[] = null; - let projectRootMap: ProjectRootMap = null; - - for (const projectName in context.filesToProcess.projectFileMap) { - const files = context.filesToProcess.projectFileMap[projectName].filter( - (file) => extname(file.file) === '.go' - ); - - if (files.length > 0 && goModules == null) { - goModules = computeGoModules(options?.skipGoDependencyCheck); - projectRootMap = extractProjectRootMap(context); - } - - for (const file of files) { - dependencies.push( - ...getFileModuleImports(file, goModules) - .map((goImport) => - getProjectNameForGoImport(projectRootMap, goImport) - ) - .filter((target) => target != null) - .map((target) => ({ - type: DependencyType.static, - source: projectName, - target: target, - sourceFile: file.file, - })) - ); - } + if (options?.skipGoDependencyCheck) { + return []; } - return dependencies; + + return createStaticAnalysisDependencies(options, context); }; diff --git a/packages/gonx/src/graph/static-analysis/build-import-map.spec.ts b/packages/gonx/src/graph/static-analysis/build-import-map.spec.ts new file mode 100644 index 000000000..65a367d13 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/build-import-map.spec.ts @@ -0,0 +1,216 @@ +import { ProjectConfiguration } from '@nx/devkit'; +import { buildImportMap } from './build-import-map'; +import { parseGoMod } from './parse-go-mod'; +import { GoModInfo } from '../types/go-mod-info'; + +jest.mock('./parse-go-mod'); + +const mockParseGoMod = parseGoMod as jest.MockedFunction; + +function goMod( + modulePath: string, + replaces?: Record +): GoModInfo { + return { + modulePath, + replaceDirectives: new Map(Object.entries(replaces ?? {})), + }; +} + +describe('buildImportMap', () => { + const workspaceRoot = '/workspace'; + + beforeEach(() => { + jest.clearAllMocks(); + mockParseGoMod.mockResolvedValue(null); + }); + + describe('base import map', () => { + it('should map module path to project name', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/libs/shared/go.mod') { + return goMod('github.com/myorg/shared'); + } + return null; + }); + + const projects: Record = { + 'libs/shared': { root: 'libs/shared' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + expect(result.baseImportMap.get('github.com/myorg/shared')).toBe( + 'libs/shared' + ); + }); + + it('should map multiple projects', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/apps/api/go.mod') { + return goMod('github.com/myorg/api'); + } + if (filePath === '/workspace/libs/shared/go.mod') { + return goMod('github.com/myorg/shared'); + } + if (filePath === '/workspace/libs/utils/go.mod') { + return goMod('github.com/myorg/utils'); + } + return null; + }); + + const projects: Record = { + 'apps/api': { root: 'apps/api' }, + 'libs/shared': { root: 'libs/shared' }, + 'libs/utils': { root: 'libs/utils' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + expect(result.baseImportMap.size).toBe(3); + expect(result.baseImportMap.get('github.com/myorg/api')).toBe('apps/api'); + expect(result.baseImportMap.get('github.com/myorg/shared')).toBe( + 'libs/shared' + ); + expect(result.baseImportMap.get('github.com/myorg/utils')).toBe( + 'libs/utils' + ); + }); + + it('should skip projects without go.mod', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/libs/shared/go.mod') { + return goMod('github.com/myorg/shared'); + } + return null; + }); + + const projects: Record = { + 'libs/shared': { root: 'libs/shared' }, + 'libs/js-lib': { root: 'libs/js-lib' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + expect(result.baseImportMap.size).toBe(1); + expect(result.baseImportMap.get('github.com/myorg/shared')).toBe( + 'libs/shared' + ); + }); + }); + + describe('replace directive handling', () => { + it('should resolve replace directive to target module path', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/apps/api/go.mod') { + return goMod('github.com/myorg/api', { + 'github.com/external/common': '../common', + }); + } + if (filePath === '/workspace/apps/common/go.mod') { + return goMod('github.com/myorg/common'); + } + return null; + }); + + const projects: Record = { + 'apps/api': { root: 'apps/api' }, + 'apps/common': { root: 'apps/common' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + const apiReplaces = result.projectReplaceDirectives.get('apps/api'); + expect(apiReplaces).toBeDefined(); + expect(apiReplaces!.get('github.com/external/common')).toBe( + 'github.com/myorg/common' + ); + }); + + it('should set null for local path pointing to non-Nx directory', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/apps/api/go.mod') { + return goMod('github.com/myorg/api', { + 'github.com/vendor/pkg': './vendor/pkg', + }); + } + return null; + }); + + const projects: Record = { + 'apps/api': { root: 'apps/api' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + const apiReplaces = result.projectReplaceDirectives.get('apps/api'); + expect(apiReplaces).toBeDefined(); + expect(apiReplaces!.get('github.com/vendor/pkg')).toBeNull(); + }); + + it('should handle module-to-module replacement', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/apps/api/go.mod') { + return goMod('github.com/myorg/api', { + 'github.com/old/pkg': 'github.com/new/pkg', + }); + } + return null; + }); + + const projects: Record = { + 'apps/api': { root: 'apps/api' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + const apiReplaces = result.projectReplaceDirectives.get('apps/api'); + expect(apiReplaces).toBeDefined(); + expect(apiReplaces!.get('github.com/old/pkg')).toBe('github.com/new/pkg'); + }); + + it('should scope replace directives per project', async () => { + mockParseGoMod.mockImplementation(async (filePath) => { + if (filePath === '/workspace/apps/api/go.mod') { + return goMod('github.com/myorg/api', { + 'github.com/myorg/common': '../common', + }); + } + if (filePath === '/workspace/apps/web/go.mod') { + return goMod('github.com/myorg/web', { + 'github.com/myorg/common': '../../libs/common', + }); + } + if (filePath === '/workspace/apps/common/go.mod') { + return goMod('github.com/myorg/apps-common'); + } + if (filePath === '/workspace/libs/common/go.mod') { + return goMod('github.com/myorg/libs-common'); + } + return null; + }); + + const projects: Record = { + 'apps/api': { root: 'apps/api' }, + 'apps/web': { root: 'apps/web' }, + 'apps/common': { root: 'apps/common' }, + 'libs/common': { root: 'libs/common' }, + }; + + const result = await buildImportMap(projects, workspaceRoot); + + expect(result.projectReplaceDirectives.has('apps/api')).toBe(true); + expect(result.projectReplaceDirectives.has('apps/web')).toBe(true); + + const apiReplaces = result.projectReplaceDirectives.get('apps/api'); + expect(apiReplaces!.get('github.com/myorg/common')).toBe( + 'github.com/myorg/apps-common' + ); + + const webReplaces = result.projectReplaceDirectives.get('apps/web'); + expect(webReplaces!.get('github.com/myorg/common')).toBe( + 'github.com/myorg/libs-common' + ); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/build-import-map.ts b/packages/gonx/src/graph/static-analysis/build-import-map.ts new file mode 100644 index 000000000..5041139f5 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/build-import-map.ts @@ -0,0 +1,100 @@ +/** + * Builds a mapping of Go module paths to Nx projects. + * This is used to resolve import statements to project dependencies. + */ +import { join, resolve } from 'path'; +import { ProjectConfiguration } from '@nx/devkit'; +import { ImportMapResult } from '../types/import-map-result'; +import { parseGoMod } from './parse-go-mod'; +import { isLocalPath } from './is-local-path'; + +/** + * Builds the import map for all Go projects in the workspace. + * + * This creates: + * 1. A base import map: module path -> project name + * 2. Per-project replace directive maps: old path -> new path (or null for suppression) + * + * @param projects - Map of project names to their configurations + * @param workspaceRoot - Root directory of the Nx workspace + * @returns ImportMapResult with base map and replace directives + */ +export async function buildImportMap( + projects: Record, + workspaceRoot: string +): Promise { + const baseImportMap = new Map(); + const projectReplaceDirectives = new Map< + string, + Map + >(); + + // Build a map of absolute directory paths to project names for local path resolution + const dirToProject = new Map(); + for (const [projectName, config] of Object.entries(projects)) { + const absPath = resolve(workspaceRoot, config.root); + dirToProject.set(absPath, projectName); + } + + // Process each project + for (const [projectName, config] of Object.entries(projects)) { + const goModPath = join(workspaceRoot, config.root, 'go.mod'); + const goModInfo = await parseGoMod(goModPath); + + if (!goModInfo) { + continue; + } + + // Add to base import map + baseImportMap.set(goModInfo.modulePath, projectName); + + // Skip replace directive processing if none exist + if (goModInfo.replaceDirectives.size === 0) { + continue; + } + + const replaceMap = new Map(); + + for (const [oldPath, newPath] of goModInfo.replaceDirectives) { + // Module-to-module replacement + if (!isLocalPath(newPath)) { + replaceMap.set(oldPath, newPath); + continue; + } + + // Resolve local path relative to the project's go.mod directory + const projectDir = resolve(workspaceRoot, config.root); + const resolvedPath = resolve(projectDir, newPath); + const targetProject = dirToProject.get(resolvedPath); + + // Local path doesn't point to an Nx project - suppress to prevent false deps + if (!targetProject) { + replaceMap.set(oldPath, null); + continue; + } + + // Map old path to target project's module path + const targetGoMod = await parseGoMod( + join(workspaceRoot, projects[targetProject].root, 'go.mod') + ); + + // Can't determine target module path - suppress + if (!targetGoMod) { + replaceMap.set(oldPath, null); + continue; + } + + // Local path points to valid Nx project - map to its module path + replaceMap.set(oldPath, targetGoMod.modulePath); + } + + if (replaceMap.size > 0) { + projectReplaceDirectives.set(projectName, replaceMap); + } + } + + return { + baseImportMap, + projectReplaceDirectives, + }; +} diff --git a/packages/gonx/src/graph/static-analysis/extract-imports.spec.ts b/packages/gonx/src/graph/static-analysis/extract-imports.spec.ts new file mode 100644 index 000000000..f2f3a171b --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/extract-imports.spec.ts @@ -0,0 +1,285 @@ +// In ts-jest, require('web-tree-sitter') returns the Emscripten Module +// directly instead of { Parser, Language, ... } named exports. +// Normalize the module shape so Parser and Language resolve correctly. +jest.mock('web-tree-sitter', () => { + const actual = jest.requireActual('web-tree-sitter'); + const Parser = actual.Parser ?? actual; + return { + __esModule: true, + Parser, + get Language() { + return actual.Language ?? Parser.Language; + }, + }; +}); + +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import stripIndent from 'strip-indent'; +import { extractImports } from './extract-imports'; + +describe('extractImports', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'gonx-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('single imports', () => { + it('should extract single import with double quotes', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import "fmt" + + func main() { + fmt.Println("hello") + } + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['fmt']); + }); + + it('should extract single import with backticks', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import \`path/filepath\` + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['path/filepath']); + }); + }); + + describe('grouped imports', () => { + it('should extract multiple imports from import block', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import ( + "fmt" + "strings" + "os" + ) + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['fmt', 'strings', 'os']); + }); + }); + + describe('aliased imports', () => { + it('should extract import with alias', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import f "fmt" + + func main() { + f.Println("hello") + } + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['fmt']); + }); + + it('should extract imports with multiple aliases', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import ( + f "fmt" + s "strings" + ) + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['fmt', 'strings']); + }); + }); + + describe('special imports', () => { + it('should extract dot import', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import . "testing" + + func TestMain(t *T) {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['testing']); + }); + + it('should extract blank import', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import _ "image/png" + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['image/png']); + }); + }); + + describe('cgo filtering', () => { + it('should filter out cgo pseudo-import', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + /* + #include + */ + import "C" + + import "fmt" + + func main() { + fmt.Println("hello") + } + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).not.toContain('C'); + expect(imports).toContain('fmt'); + }); + }); + + describe('edge cases', () => { + it('should return empty array for file without imports', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual([]); + }); + + it('should return empty array for empty file', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile(filePath, ''); + + const imports = await extractImports(filePath); + + expect(imports).toEqual([]); + }); + + it('should return empty array for non-existent file', async () => { + const imports = await extractImports('/nonexistent/main.go'); + + expect(imports).toEqual([]); + }); + + it('should handle third-party package imports', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import ( + "github.com/myorg/shared/utils" + "github.com/external/library" + ) + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual([ + 'github.com/myorg/shared/utils', + 'github.com/external/library', + ]); + }); + + it('should handle mixed standard and third-party imports', async () => { + const filePath = join(tempDir, 'main.go'); + await writeFile( + filePath, + stripIndent(` + package main + + import ( + "fmt" + "os" + + "github.com/myorg/shared" + ) + + func main() {} + `) + ); + + const imports = await extractImports(filePath); + + expect(imports).toEqual(['fmt', 'os', 'github.com/myorg/shared']); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/extract-imports.ts b/packages/gonx/src/graph/static-analysis/extract-imports.ts new file mode 100644 index 000000000..8a9166107 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/extract-imports.ts @@ -0,0 +1,110 @@ +import { readFile } from 'fs/promises'; +import { logger } from '@nx/devkit'; +import { initParser, SyntaxNode } from './parser-init'; + +/** + * Extracts import paths from a Go source file using tree-sitter. + * + * Handles all Go import patterns: + * - Single imports: import "fmt" + * - Grouped imports: import ("fmt"; "strings") + * - Aliased imports: import f "fmt" + * - Dot imports: import . "testing" + * - Blank imports: import _ "image/png" + * - Raw string literals: import `path` + * + * Filters out the cgo pseudo-import "C". + * + * @param filePath - Path to the Go source file + * @returns Array of import paths (excluding "C") + */ +export async function extractImports(filePath: string): Promise { + let content: string; + + try { + content = await readFile(filePath, 'utf-8'); + } catch (error) { + logger.warn(`Failed to read Go file ${filePath}: ${error}`); + return []; + } + + if (!content.trim()) { + return []; + } + + const parser = await initParser(); + const tree = parser.parse(content); + + if (!tree) { + return []; + } + + const imports: string[] = []; + + // Find all import_declaration nodes + visitImportDeclarations(tree.rootNode, (node) => { + const specList = node.namedChildren.find( + (child) => child.type === 'import_spec_list' + ); + + // Collect import specs from either grouped or single imports + const specs = specList + ? specList.namedChildren.filter((child) => child.type === 'import_spec') + : node.namedChildren.filter((child) => child.type === 'import_spec'); + + for (const spec of specs) { + const importPath = extractImportPath(spec); + if (importPath && importPath !== 'C') { + imports.push(importPath); + } + } + }); + + return imports; +} + +/** + * Visits all import_declaration nodes in the AST. + */ +function visitImportDeclarations( + node: SyntaxNode, + callback: (node: SyntaxNode) => void +): void { + if (node.type === 'import_declaration') { + callback(node); + } + + for (const child of node.children) { + visitImportDeclarations(child, callback); + } +} + +/** + * Extracts the import path from an import_spec node. + * + * An import_spec can have: + * - Just a path: "fmt" + * - Alias and path: f "fmt" + * - Dot and path: . "testing" + * - Blank and path: _ "image/png" + */ +function extractImportPath(spec: SyntaxNode): string | null { + // Find the interpreted_string_literal or raw_string_literal child + for (const child of spec.namedChildren) { + if ( + child.type === 'interpreted_string_literal' || + child.type === 'raw_string_literal' + ) { + // Remove quotes (both " and `) + const text = child.text; + if (text.startsWith('"') && text.endsWith('"')) { + return text.slice(1, -1); + } + if (text.startsWith('`') && text.endsWith('`')) { + return text.slice(1, -1); + } + } + } + + return null; +} diff --git a/packages/gonx/src/graph/static-analysis/find-go-files.spec.ts b/packages/gonx/src/graph/static-analysis/find-go-files.spec.ts new file mode 100644 index 000000000..aed1a989d --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/find-go-files.spec.ts @@ -0,0 +1,248 @@ +import { Dirent } from 'fs'; +import { readdir } from 'fs/promises'; +import { findGoFiles } from './find-go-files'; + +jest.mock('fs/promises'); +jest.mock('@nx/devkit', () => ({ logger: { warn: jest.fn() } })); + +const mockReaddir = readdir as jest.MockedFunction; + +function dirent(name: string, type: 'file' | 'directory'): Dirent { + return { + name, + isFile: () => type === 'file', + isDirectory: () => type === 'directory', + } as Dirent; +} + +/** + * Sets up mockReaddir to simulate a filesystem from a flat file map. + * Keys are absolute file paths, values are ignored (only structure matters). + */ +function setupFs(files: Record): void { + const dirs = new Map(); + + for (const filePath of Object.keys(files)) { + const segments = filePath.split('/').filter(Boolean); + + for (let i = 1; i <= segments.length; i++) { + const dirPath = '/' + segments.slice(0, i - 1).join('/') || '/'; + const entryName = segments[i - 1]; + const isFile = i === segments.length; + + if (!dirs.has(dirPath)) { + dirs.set(dirPath, []); + } + + const entries = dirs.get(dirPath)!; + if (!entries.some((e) => e.name === entryName)) { + entries.push(dirent(entryName, isFile ? 'file' : 'directory')); + } + } + } + + mockReaddir.mockImplementation(async (dirPath: any) => { + const entries = dirs.get(dirPath as string); + if (!entries) { + throw Object.assign( + new Error(`ENOENT: no such file or directory, scandir '${dirPath}'`), + { code: 'ENOENT' } + ); + } + return entries as any; + }); +} + +describe('findGoFiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReaddir.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ); + }); + + describe('basic file discovery', () => { + it('should find Go files in root directory', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/utils.go': 'package main', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).toContain('/project/utils.go'); + expect(files).toHaveLength(2); + }); + + it('should find Go files recursively', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/internal/handler.go': 'package internal', + '/project/internal/deep/nested.go': 'package deep', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).toContain('/project/internal/handler.go'); + expect(files).toContain('/project/internal/deep/nested.go'); + expect(files).toHaveLength(3); + }); + }); + + describe('exclusions', () => { + it('should include test files', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/main_test.go': 'package main', + '/project/handler.go': 'package handler', + '/project/handler_test.go': 'package handler', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).toContain('/project/handler.go'); + expect(files).toContain('/project/main_test.go'); + expect(files).toContain('/project/handler_test.go'); + expect(files).toHaveLength(4); + }); + + it('should exclude vendor directory', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/vendor/github.com/pkg/pkg.go': 'package pkg', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/vendor/github.com/pkg/pkg.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude testdata directory', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/testdata/fixtures.go': 'package testdata', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/testdata/fixtures.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude hidden directories', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/.git/hooks/pre-commit.go': 'package hooks', + '/project/.hidden/secret.go': 'package hidden', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/.git/hooks/pre-commit.go'); + expect(files).not.toContain('/project/.hidden/secret.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude node_modules directory', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/node_modules/some-pkg/index.go': 'package somepkg', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/node_modules/some-pkg/index.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude build output directories', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/dist/main.go': 'package main', + '/project/build/main.go': 'package main', + '/project/out/main.go': 'package main', + '/project/bin/main.go': 'package main', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/dist/main.go'); + expect(files).not.toContain('/project/build/main.go'); + expect(files).not.toContain('/project/out/main.go'); + expect(files).not.toContain('/project/bin/main.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude generated code directories', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/gen/models.go': 'package gen', + '/project/generated/types.go': 'package generated', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/gen/models.go'); + expect(files).not.toContain('/project/generated/types.go'); + expect(files).toHaveLength(1); + }); + + it('should exclude temporary directories', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/tmp/scratch.go': 'package tmp', + '/project/temp/scratch.go': 'package temp', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).not.toContain('/project/tmp/scratch.go'); + expect(files).not.toContain('/project/temp/scratch.go'); + expect(files).toHaveLength(1); + }); + }); + + describe('edge cases', () => { + it('should return empty array for non-existent directory', async () => { + const files = await findGoFiles('/nonexistent'); + + expect(files).toEqual([]); + }); + + it('should return empty array for directory with no Go files', async () => { + setupFs({ + '/project/readme.md': '# README', + '/project/config.json': '{}', + }); + + const files = await findGoFiles('/project'); + + expect(files).toEqual([]); + }); + + it('should ignore non-.go files', async () => { + setupFs({ + '/project/main.go': 'package main', + '/project/readme.md': '# README', + '/project/Makefile': 'all:', + '/project/go.mod': 'module example.com/project', + }); + + const files = await findGoFiles('/project'); + + expect(files).toContain('/project/main.go'); + expect(files).toHaveLength(1); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/find-go-files.ts b/packages/gonx/src/graph/static-analysis/find-go-files.ts new file mode 100644 index 000000000..2cb929f8e --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/find-go-files.ts @@ -0,0 +1,70 @@ +import { readdir } from 'fs/promises'; +import { join } from 'path'; +import { logger } from '@nx/devkit'; + +/** + * Directories to exclude when searching for Go files. + * Includes Go-specific, build output, and common generated directories. + */ +const EXCLUDED_DIRS = new Set([ + // Go-specific + 'vendor', + 'testdata', + // Node/JS + 'node_modules', + // Common build/output directories + 'dist', + 'build', + 'out', + 'bin', + // Common generated directories + 'gen', + 'generated', + // Temporary directories + 'tmp', + 'temp', +]); + +/** + * Recursively finds all .go files in a directory. + * + * Excludes: + * - vendor/, testdata/ (Go-specific) + * - node_modules/ + * - dist/, build/, out/, bin/ (build output) + * - gen/, generated/ (generated code) + * - tmp/, temp/ (temporary files) + * - Hidden directories (starting with .) + * + * @param dir - The directory to search in + * @returns Promise resolving to array of absolute file paths + */ +export async function findGoFiles(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }); + + const results = await Promise.all( + entries.map(async (entry) => { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.')) { + return []; + } + return findGoFiles(fullPath); + } + + if (entry.isFile() && entry.name.endsWith('.go')) { + return [fullPath]; + } + + return []; + }) + ); + + return results.flat(); + } catch (error) { + logger.warn(`Failed to read directory ${dir}: ${error}`); + return []; + } +} diff --git a/packages/gonx/src/graph/static-analysis/index.spec.ts b/packages/gonx/src/graph/static-analysis/index.spec.ts new file mode 100644 index 000000000..0553668e4 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/index.spec.ts @@ -0,0 +1,261 @@ +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + workspaceRoot: '/workspace', +})); +jest.mock('p-limit', () => ({ + __esModule: true, + default: + () => + (fn: () => T) => + fn(), +})); +jest.mock('./build-import-map'); +jest.mock('./extract-imports'); +jest.mock('./find-go-files'); +jest.mock('./resolve-import'); + +import { DependencyType, CreateDependenciesContext } from '@nx/devkit'; +import { createStaticAnalysisDependencies } from './index'; +import { buildImportMap } from './build-import-map'; +import { extractImports } from './extract-imports'; +import { findGoFiles } from './find-go-files'; +import { resolveImport } from './resolve-import'; + +const mockBuildImportMap = buildImportMap as jest.MockedFunction< + typeof buildImportMap +>; +const mockExtractImports = extractImports as jest.MockedFunction< + typeof extractImports +>; +const mockFindGoFiles = findGoFiles as jest.MockedFunction; +const mockResolveImport = resolveImport as jest.MockedFunction< + typeof resolveImport +>; + +function makeContext( + overrides: Partial = {} +): CreateDependenciesContext { + return { + projects: {}, + filesToProcess: { + projectFileMap: {}, + nonProjectFiles: [], + }, + nxJsonConfiguration: {}, + workspaceRoot: '/workspace', + fileMap: { + projectFileMap: {}, + nonProjectFiles: [], + }, + externalNodes: {}, + ...overrides, + } as CreateDependenciesContext; +} + +describe('createStaticAnalysisDependencies', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map(), + projectReplaceDirectives: new Map(), + }); + mockExtractImports.mockResolvedValue([]); + mockFindGoFiles.mockResolvedValue([]); + mockResolveImport.mockReturnValue(null); + }); + + it('should return empty array when no Go modules found', async () => { + const result = await createStaticAnalysisDependencies( + undefined, + makeContext() + ); + + expect(result).toEqual([]); + expect(mockExtractImports).not.toHaveBeenCalled(); + }); + + it('should use Go files from context when available', async () => { + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([['github.com/myorg/lib', 'lib']]), + projectReplaceDirectives: new Map(), + }); + mockExtractImports.mockResolvedValue(['github.com/myorg/lib']); + mockResolveImport.mockReturnValue('lib'); + + const context = makeContext({ + projects: { + app: { root: 'app' } as any, + }, + filesToProcess: { + projectFileMap: { + app: [{ file: 'app/main.go', hash: 'abc' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(mockExtractImports).toHaveBeenCalledWith('/workspace/app/main.go'); + expect(mockFindGoFiles).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + type: DependencyType.static, + source: 'app', + target: 'lib', + sourceFile: 'app/main.go', + }, + ]); + }); + + it('should fall back to findGoFiles when context has no Go files', async () => { + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([['github.com/myorg/lib', 'lib']]), + projectReplaceDirectives: new Map(), + }); + mockFindGoFiles.mockResolvedValue(['/workspace/app/main.go']); + mockExtractImports.mockResolvedValue(['github.com/myorg/lib']); + mockResolveImport.mockReturnValue('lib'); + + const context = makeContext({ + projects: { + app: { root: 'app' } as any, + }, + filesToProcess: { + projectFileMap: { + app: [{ file: 'app/go.mod', hash: 'abc' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(mockFindGoFiles).toHaveBeenCalledWith('/workspace/app'); + expect(result).toHaveLength(1); + }); + + it('should skip imports that do not resolve to a project', async () => { + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([['github.com/myorg/lib', 'lib']]), + projectReplaceDirectives: new Map(), + }); + mockExtractImports.mockResolvedValue(['fmt', 'github.com/external/pkg']); + mockResolveImport.mockReturnValue(null); + + const context = makeContext({ + projects: { + app: { root: 'app' } as any, + }, + filesToProcess: { + projectFileMap: { + app: [{ file: 'app/main.go', hash: 'abc' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(result).toEqual([]); + }); + + it('should skip projects missing from context.projects', async () => { + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([['github.com/myorg/lib', 'lib']]), + projectReplaceDirectives: new Map(), + }); + + const context = makeContext({ + projects: {}, + filesToProcess: { + projectFileMap: { + ghost: [{ file: 'ghost/main.go', hash: 'abc' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(mockExtractImports).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should deduplicate dependencies with same source, target, and sourceFile', async () => { + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([['github.com/myorg/lib', 'lib']]), + projectReplaceDirectives: new Map(), + }); + // Two different imports that resolve to the same target project + mockExtractImports.mockResolvedValue([ + 'github.com/myorg/lib', + 'github.com/myorg/lib/sub', + ]); + mockResolveImport.mockReturnValue('lib'); + + const context = makeContext({ + projects: { + app: { root: 'app' } as any, + }, + filesToProcess: { + projectFileMap: { + app: [{ file: 'app/main.go', hash: 'abc' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(result).toHaveLength(1); + }); + + it('should process multiple projects and files', async () => { + const replaceDirectives = new Map([ + ['app', new Map([['github.com/myorg/lib', '../lib']])], + ]); + mockBuildImportMap.mockResolvedValue({ + baseImportMap: new Map([ + ['github.com/myorg/lib', 'lib'], + ['github.com/myorg/utils', 'utils'], + ]), + projectReplaceDirectives: replaceDirectives, + }); + mockExtractImports + .mockResolvedValueOnce(['github.com/myorg/lib']) + .mockResolvedValueOnce(['github.com/myorg/utils']); + mockResolveImport.mockReturnValueOnce('lib').mockReturnValueOnce('utils'); + + const context = makeContext({ + projects: { + app: { root: 'app' } as any, + svc: { root: 'svc' } as any, + }, + filesToProcess: { + projectFileMap: { + app: [{ file: 'app/main.go', hash: 'a1' }], + svc: [{ file: 'svc/main.go', hash: 's1' }], + }, + nonProjectFiles: [], + }, + }); + + const result = await createStaticAnalysisDependencies(undefined, context); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + type: DependencyType.static, + source: 'app', + target: 'lib', + sourceFile: 'app/main.go', + }); + expect(result).toContainEqual({ + type: DependencyType.static, + source: 'svc', + target: 'utils', + sourceFile: 'svc/main.go', + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/index.ts b/packages/gonx/src/graph/static-analysis/index.ts new file mode 100644 index 000000000..af64f235b --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/index.ts @@ -0,0 +1,138 @@ +/** + * Go dependency detection using tree-sitter static analysis. + * Parses Go source files without requiring the Go toolchain. + */ +import { + CreateDependenciesContext, + DependencyType, + RawProjectGraphDependency, + workspaceRoot, +} from '@nx/devkit'; +import { join } from 'path'; +import { GoPluginOptions } from '../types/go-plugin-options'; +import { buildImportMap } from './build-import-map'; +import { extractImports } from './extract-imports'; +import { findGoFiles } from './find-go-files'; +import { resolveImport } from './resolve-import'; + +/** + * Creates project dependencies using static analysis. + * + * - Does NOT require Go to be installed + * - Uses tree-sitter WASM for import parsing + * - Properly handles replace directives in go.mod + * + * @param options - Plugin options + * @param context - Nx dependency context + * @returns Array of project dependencies + */ +export async function createStaticAnalysisDependencies( + options: GoPluginOptions | undefined, + context: CreateDependenciesContext +): Promise { + const dependencies: RawProjectGraphDependency[] = []; + + // Build the import map from all projects + const { baseImportMap, projectReplaceDirectives } = await buildImportMap( + context.projects, + workspaceRoot + ); + + // If no Go modules found, return empty + if (baseImportMap.size === 0) { + return dependencies; + } + + // Process each project that has files to process + const projectsToProcess = Object.keys(context.filesToProcess.projectFileMap); + + // Process projects with concurrency limit. + // p-limit v4+ is ESM-only, so we use dynamic import for CommonJS compatibility. + const pLimit = (await import('p-limit')).default; + const limit = pLimit(10); + await Promise.all( + projectsToProcess.map((projectName) => + limit(async () => { + const projectConfig = context.projects[projectName]; + if (!projectConfig) { + return; + } + + const projectRoot = join(workspaceRoot, projectConfig.root); + + // Get list of Go files to process for this project + // Either from context.filesToProcess or by scanning directory + const goFilesFromContext = context.filesToProcess.projectFileMap[ + projectName + ]?.filter((f) => f.file.endsWith('.go')); + + const goFiles = goFilesFromContext?.length + ? goFilesFromContext.map((f) => join(workspaceRoot, f.file)) + : await findGoFiles(projectRoot); + + // Process each Go file + for (const filePath of goFiles) { + const imports = await extractImports(filePath); + + for (const importPath of imports) { + const targetProject = resolveImport( + importPath, + baseImportMap, + projectName, + projectReplaceDirectives + ); + + if (targetProject) { + // Calculate relative file path for sourceFile + const sourceFile = filePath.startsWith(workspaceRoot) + ? filePath.slice(workspaceRoot.length + 1) + : filePath; + + dependencies.push({ + type: DependencyType.static, + source: projectName, + target: targetProject, + sourceFile, + }); + } + } + } + }) + ) + ); + + // Deduplicate dependencies (same source->target->sourceFile can occur from multiple imports) + return deduplicateDependencies(dependencies); +} + +/** + * Removes duplicate dependencies, keeping one entry per source->target->sourceFile. + * Preserves per-file tracking for incremental updates when files change. + */ +function deduplicateDependencies( + dependencies: RawProjectGraphDependency[] +): RawProjectGraphDependency[] { + const seen = new Set(); + const result: RawProjectGraphDependency[] = []; + + for (const dep of dependencies) { + // sourceFile exists on StaticDependency but not ImplicitDependency + const sourceFile = 'sourceFile' in dep ? dep.sourceFile : ''; + const key = `${dep.source}:${dep.target}:${sourceFile}`; + if (!seen.has(key)) { + seen.add(key); + result.push(dep); + } + } + + return result; +} + +// Re-export for convenience +export { buildImportMap } from './build-import-map'; +export { extractImports } from './extract-imports'; +export { findGoFiles } from './find-go-files'; +export { isLocalPath } from './is-local-path'; +export { parseGoMod } from './parse-go-mod'; +export { resolveImport } from './resolve-import'; +export { initParser, resetParser } from './parser-init'; diff --git a/packages/gonx/src/graph/static-analysis/is-local-path.spec.ts b/packages/gonx/src/graph/static-analysis/is-local-path.spec.ts new file mode 100644 index 000000000..3e546f677 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/is-local-path.spec.ts @@ -0,0 +1,53 @@ +import { isLocalPath } from './is-local-path'; + +describe('isLocalPath', () => { + describe('local paths', () => { + it('should return true for relative path starting with ./', () => { + expect(isLocalPath('./common')).toBe(true); + }); + + it('should return true for relative path starting with ../', () => { + expect(isLocalPath('../libs/common')).toBe(true); + }); + + it('should return true for absolute path', () => { + expect(isLocalPath('/absolute/path/to/module')).toBe(true); + }); + + it('should return true for path without dots in first element', () => { + expect(isLocalPath('local/path')).toBe(true); + }); + }); + + describe('module paths', () => { + it('should return false for github.com path', () => { + expect(isLocalPath('github.com/myorg/myrepo')).toBe(false); + }); + + it('should return false for example.com path', () => { + expect(isLocalPath('example.com/pkg')).toBe(false); + }); + + it('should return false for custom domain path', () => { + expect(isLocalPath('mycompany.io/internal/utils')).toBe(false); + }); + + it('should return false for golang.org path', () => { + expect(isLocalPath('golang.org/x/tools')).toBe(false); + }); + + it('should return false for gopkg.in path', () => { + expect(isLocalPath('gopkg.in/yaml.v3')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return true for single word without dot', () => { + expect(isLocalPath('vendor')).toBe(true); + }); + + it('should return false for word with dot', () => { + expect(isLocalPath('example.test')).toBe(false); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/is-local-path.ts b/packages/gonx/src/graph/static-analysis/is-local-path.ts new file mode 100644 index 000000000..85252378d --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/is-local-path.ts @@ -0,0 +1,32 @@ +/** + * Determines if a path in a replace directive is a local filesystem path + * or a module path. + * + * According to Go's module documentation: + * - If the first path element contains a dot, it's treated as a module path + * - Otherwise, it's a local path + * + * Examples: + * - "./common" -> local + * - "../libs/common" -> local + * - "/absolute/path" -> local + * - "github.com/foo/bar" -> module + * - "example.com/pkg" -> module + */ +export function isLocalPath(source: string): boolean { + // Explicitly relative or absolute paths + if ( + source.startsWith('./') || + source.startsWith('../') || + source.startsWith('/') + ) { + return true; + } + + // Check first path element for a dot. + // Module paths always have a dot in the first element (e.g., "github.com", "example.org"). + // Local paths don't (e.g., "common", "mypackage"). + // So: no dot = local path, has dot = module path. + const firstElement = source.split('/')[0]; + return !firstElement.includes('.'); +} diff --git a/packages/gonx/src/graph/static-analysis/parse-go-mod.spec.ts b/packages/gonx/src/graph/static-analysis/parse-go-mod.spec.ts new file mode 100644 index 000000000..1765a40ab --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/parse-go-mod.spec.ts @@ -0,0 +1,272 @@ +import stripIndent from 'strip-indent'; +import { readFile } from 'fs/promises'; +import { parseGoMod } from './parse-go-mod'; + +jest.mock('fs/promises'); +jest.mock('@nx/devkit', () => ({ logger: { warn: jest.fn() } })); + +const mockReadFile = readFile as jest.MockedFunction; + +describe('parseGoMod', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('module declaration parsing', () => { + it('should parse basic module declaration', async () => { + mockReadFile.mockResolvedValue( + 'module github.com/myorg/myapp\n\ngo 1.21' + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.modulePath).toBe('github.com/myorg/myapp'); + expect(result!.replaceDirectives.size).toBe(0); + }); + + it('should parse quoted module path with double quotes', async () => { + mockReadFile.mockResolvedValue( + 'module "github.com/myorg/myapp"\n\ngo 1.21' + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.modulePath).toBe('github.com/myorg/myapp'); + }); + + it('should parse quoted module path with single quotes', async () => { + mockReadFile.mockResolvedValue( + "module 'github.com/myorg/myapp'\n\ngo 1.21" + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.modulePath).toBe('github.com/myorg/myapp'); + }); + + it('should parse quoted module path with backticks', async () => { + mockReadFile.mockResolvedValue( + 'module `github.com/myorg/myapp`\n\ngo 1.21' + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.modulePath).toBe('github.com/myorg/myapp'); + }); + + it('should return null for missing module declaration', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + go 1.21 + + require ( + github.com/foo/bar v1.0.0 + ) + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).toBeNull(); + }); + + it('should return null for non-existent file', async () => { + mockReadFile.mockRejectedValue( + new Error('ENOENT: no such file or directory') + ); + + const result = await parseGoMod('/nonexistent/go.mod'); + + expect(result).toBeNull(); + }); + + it('should return null for empty module path in quotes', async () => { + mockReadFile.mockResolvedValue('module ""\n\ngo 1.21'); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).toBeNull(); + }); + }); + + describe('single-line replace directives', () => { + it('should parse simple replace directive', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + go 1.21 + + replace github.com/old/pkg => github.com/new/pkg + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.size).toBe(1); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + 'github.com/new/pkg' + ); + }); + + it('should parse replace directive with version on old path', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace github.com/old/pkg v1.0.0 => github.com/new/pkg + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + 'github.com/new/pkg' + ); + }); + + it('should parse replace directive with versions on both paths', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace github.com/old/pkg v1.0.0 => github.com/new/pkg v2.0.0 + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + 'github.com/new/pkg' + ); + }); + + it('should parse replace directive with local path', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace github.com/myorg/common => ../common + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.get('github.com/myorg/common')).toBe( + '../common' + ); + }); + + it('should parse multiple single-line replace directives', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace github.com/pkg1 => ../pkg1 + replace github.com/pkg2 => ../pkg2 + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.size).toBe(2); + expect(result!.replaceDirectives.get('github.com/pkg1')).toBe('../pkg1'); + expect(result!.replaceDirectives.get('github.com/pkg2')).toBe('../pkg2'); + }); + }); + + describe('block replace directives', () => { + it('should parse replace block with single directive', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace ( + github.com/old/pkg => github.com/new/pkg + ) + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.size).toBe(1); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + 'github.com/new/pkg' + ); + }); + + it('should parse replace block with multiple directives', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + replace ( + github.com/pkg1 => ../pkg1 + github.com/pkg2 v1.0.0 => ../pkg2 + github.com/pkg3 => github.com/other/pkg3 v2.0.0 + ) + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.size).toBe(3); + expect(result!.replaceDirectives.get('github.com/pkg1')).toBe('../pkg1'); + expect(result!.replaceDirectives.get('github.com/pkg2')).toBe('../pkg2'); + expect(result!.replaceDirectives.get('github.com/pkg3')).toBe( + 'github.com/other/pkg3' + ); + }); + }); + + describe('comment handling', () => { + it('should ignore inline comments', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp // my app module + + replace github.com/old/pkg => ../pkg // local replacement + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.modulePath).toBe('github.com/myorg/myapp'); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + '../pkg' + ); + }); + + it('should ignore multi-line comments', async () => { + mockReadFile.mockResolvedValue( + stripIndent(` + module github.com/myorg/myapp + + /* This is a + multi-line comment */ + + replace github.com/old/pkg => ../pkg + `) + ); + + const result = await parseGoMod('/project/go.mod'); + + expect(result).not.toBeNull(); + expect(result!.replaceDirectives.get('github.com/old/pkg')).toBe( + '../pkg' + ); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/parse-go-mod.ts b/packages/gonx/src/graph/static-analysis/parse-go-mod.ts new file mode 100644 index 000000000..1bd6ff48a --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/parse-go-mod.ts @@ -0,0 +1,120 @@ +import { readFile } from 'fs/promises'; +import { logger } from '@nx/devkit'; +import { GoModInfo } from '../types/go-mod-info'; + +/** + * Regex to match module declaration. + * Supports quoted paths (double quotes, single quotes, backticks) and unquoted. + * Group 1: quoted path content, Group 2: unquoted path + */ +const MODULE_REGEX = /^\s*module\s+(?:["'`]([^"'`]+)["'`]|(\S+))/m; + +/** + * Regex to match single-line replace directive (with "replace" keyword). + * Pattern: replace old => new + * Handles optional version specifiers on both sides. + */ +const REPLACE_SINGLE_LINE_REGEX = + /^\s*replace\s+(?:["'`]([^"'`]+)["'`]|(\S+))(?:\s+\S+)?\s+=>\s+(?:["'`]([^"'`]+)["'`]|(\S+))(?:\s+\S+)?\s*$/; + +/** + * Regex to match replace block start. + */ +const REPLACE_BLOCK_START_REGEX = /^\s*replace\s*\(\s*$/; + +/** + * Regex to match a line within a replace block (no "replace" keyword). + * Pattern: old => new (with optional versions) + */ +const REPLACE_BLOCK_LINE_REGEX = + /^\s*(?:["'`]([^"'`]+)["'`]|(\S+))(?:\s+\S+)?\s+=>\s+(?:["'`]([^"'`]+)["'`]|(\S+))(?:\s+\S+)?\s*$/; + +/** + * Parses a go.mod file and extracts module information. + * + * @param filePath - Path to the go.mod file + * @returns GoModInfo object or null if parsing fails + */ +export async function parseGoMod(filePath: string): Promise { + let content: string; + + try { + content = await readFile(filePath, 'utf-8'); + } catch (error) { + logger.warn(`Failed to read go.mod file ${filePath}: ${error}`); + return null; + } + + // Remove comments (both // and /* */) + const contentWithoutComments = removeComments(content); + + // Extract module path + const moduleMatch = MODULE_REGEX.exec(contentWithoutComments); + if (!moduleMatch) { + return null; + } + + const modulePath = moduleMatch[1] || moduleMatch[2]; + // Reject empty or quote-only paths (e.g., module "" is invalid) + if (!modulePath || /^["'`]+$/.test(modulePath)) { + return null; + } + + const replaceDirectives = new Map(); + + // Parse line by line to properly handle single-line vs block directives + const lines = contentWithoutComments.split('\n'); + let inReplaceBlock = false; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines + if (!trimmedLine) { + continue; + } + + // Check for block start + if (REPLACE_BLOCK_START_REGEX.test(trimmedLine)) { + inReplaceBlock = true; + continue; + } + + // Check for block end + if (inReplaceBlock && trimmedLine === ')') { + inReplaceBlock = false; + continue; + } + + const regex = inReplaceBlock + ? REPLACE_BLOCK_LINE_REGEX + : REPLACE_SINGLE_LINE_REGEX; + const match = regex.exec(trimmedLine); + + if (match) { + const oldPath = match[1] || match[2]; + const newPath = match[3] || match[4]; + if (oldPath && newPath) { + replaceDirectives.set(oldPath, newPath); + } + } + } + + return { + modulePath, + replaceDirectives, + }; +} + +/** + * Removes Go-style comments from content. + */ +function removeComments(content: string): string { + // Remove multi-line comments + let result = content.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Remove single-line comments but preserve the newline + result = result.replace(/\/\/.*$/gm, ''); + + return result; +} diff --git a/packages/gonx/src/graph/static-analysis/parser-init.ts b/packages/gonx/src/graph/static-analysis/parser-init.ts new file mode 100644 index 000000000..116d9f8d9 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/parser-init.ts @@ -0,0 +1,89 @@ +/** + * Tree-sitter parser initialization and management. + * Provides a singleton parser instance with Go language support. + */ +import { join, dirname } from 'path'; + +// Tree-sitter types (subset of web-tree-sitter's types used by this plugin) +interface Parser { + setLanguage(language: unknown): void; + parse(input: string): Tree | null; +} + +interface Tree { + rootNode: SyntaxNode; +} + +interface SyntaxNode { + type: string; + text: string; + children: SyntaxNode[]; + namedChildren: SyntaxNode[]; + childForFieldName(name: string): SyntaxNode | null; +} + +// Singleton state +let parserInstance: Parser | null = null; +let initPromise: Promise | null = null; + +/** + * Gets the path to the tree-sitter-go.wasm file. + */ +function getWasmPath(): string { + const packageJsonPath = require.resolve('tree-sitter-go/package.json'); + return join(dirname(packageJsonPath), 'tree-sitter-go.wasm'); +} + +/** + * Initializes the tree-sitter parser with Go language support. + * Uses a singleton pattern - only initializes once. + * + * @returns Promise resolving to the initialized parser + */ +export async function initParser(): Promise { + // Return existing instance if available + if (parserInstance) { + return parserInstance; + } + + // Return existing initialization promise to prevent concurrent init + if (initPromise) { + return initPromise; + } + + initPromise = doInit(); + return initPromise; +} + +async function doInit(): Promise { + const { Parser: TreeSitterParser, Language } = await import('web-tree-sitter'); + + await TreeSitterParser.init(); + const parser = new TreeSitterParser(); + + const wasmPath = getWasmPath(); + let Go; + try { + Go = await Language.load(wasmPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to load tree-sitter-go WASM file at "${wasmPath}": ${message}. ` + + `Ensure tree-sitter-go is installed correctly.` + ); + } + parser.setLanguage(Go); + + parserInstance = parser; + return parser; +} + +/** + * Resets the parser singleton (useful for testing). + */ +export function resetParser(): void { + parserInstance = null; + initPromise = null; +} + +export type { Parser, Tree, SyntaxNode }; diff --git a/packages/gonx/src/graph/static-analysis/resolve-import.spec.ts b/packages/gonx/src/graph/static-analysis/resolve-import.spec.ts new file mode 100644 index 000000000..06ccb423c --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/resolve-import.spec.ts @@ -0,0 +1,228 @@ +import { resolveImport } from './resolve-import'; + +describe('resolveImport', () => { + describe('exact match resolution', () => { + it('should resolve exact module match', () => { + const baseImportMap = new Map([ + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'github.com/myorg/shared', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBe('libs/shared'); + }); + }); + + describe('longest-prefix matching', () => { + it('should match subpackage to module', () => { + const baseImportMap = new Map([ + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'github.com/myorg/shared/utils', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBe('libs/shared'); + }); + + it('should use longest matching prefix', () => { + const baseImportMap = new Map([ + ['github.com/myorg', 'libs/myorg'], + ['github.com/myorg/shared', 'libs/shared'], + ['github.com/myorg/shared/internal', 'libs/shared-internal'], + ]); + const projectReplaces = new Map>(); + + // Should match 'github.com/myorg/shared/internal' not 'github.com/myorg/shared' + const result = resolveImport( + 'github.com/myorg/shared/internal/utils', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBe('libs/shared-internal'); + }); + + it('should return null for no matching prefix', () => { + const baseImportMap = new Map([ + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'github.com/external/library', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBeNull(); + }); + }); + + describe('self-reference prevention', () => { + it('should return null for self-referential import', () => { + const baseImportMap = new Map([ + ['github.com/myorg/api', 'apps/api'], + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'github.com/myorg/api/internal', + baseImportMap, + 'apps/api', // source project is same as target + projectReplaces + ); + + expect(result).toBeNull(); + }); + }); + + describe('replace directive handling', () => { + it('should use replace directive over base map', () => { + const baseImportMap = new Map([ + ['github.com/old/pkg', 'libs/old-pkg'], + ['github.com/new/pkg', 'libs/new-pkg'], + ]); + const projectReplaces = new Map([ + ['apps/api', new Map([['github.com/old/pkg', 'github.com/new/pkg']])], + ]); + + const result = resolveImport( + 'github.com/old/pkg', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBe('libs/new-pkg'); + }); + + it('should apply replace directive prefix to subpackages', () => { + const baseImportMap = new Map([['github.com/new/pkg', 'libs/new-pkg']]); + const projectReplaces = new Map([ + ['apps/api', new Map([['github.com/old/pkg', 'github.com/new/pkg']])], + ]); + + const result = resolveImport( + 'github.com/old/pkg/utils', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBe('libs/new-pkg'); + }); + + it('should not apply replace directive from other projects', () => { + const baseImportMap = new Map([ + ['github.com/old/pkg', 'libs/old-pkg'], + ['github.com/new/pkg', 'libs/new-pkg'], + ]); + const projectReplaces = new Map([ + // apps/web has the replace, not apps/api + ['apps/web', new Map([['github.com/old/pkg', 'github.com/new/pkg']])], + ]); + + const result = resolveImport( + 'github.com/old/pkg', + baseImportMap, + 'apps/api', // this project doesn't have the replace + projectReplaces + ); + + expect(result).toBe('libs/old-pkg'); + }); + }); + + describe('null-suppression behavior', () => { + it('should return null when replace directive is null', () => { + const baseImportMap = new Map([ + ['github.com/vendor/pkg', 'libs/vendor-pkg'], + ]); + const projectReplaces = new Map([ + [ + 'apps/api', + new Map([['github.com/vendor/pkg', null]]), + ], + ]); + + const result = resolveImport( + 'github.com/vendor/pkg', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBeNull(); + }); + + it('should suppress subpackages of null replace directive', () => { + const baseImportMap = new Map([ + ['github.com/vendor/pkg', 'libs/vendor-pkg'], + ]); + const projectReplaces = new Map([ + [ + 'apps/api', + new Map([['github.com/vendor/pkg', null]]), + ], + ]); + + const result = resolveImport( + 'github.com/vendor/pkg/utils', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBeNull(); + }); + }); + + describe('standard library', () => { + it('should return null for standard library imports', () => { + const baseImportMap = new Map([ + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'fmt', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBeNull(); + }); + + it('should return null for nested standard library imports', () => { + const baseImportMap = new Map([ + ['github.com/myorg/shared', 'libs/shared'], + ]); + const projectReplaces = new Map>(); + + const result = resolveImport( + 'path/filepath', + baseImportMap, + 'apps/api', + projectReplaces + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/gonx/src/graph/static-analysis/resolve-import.ts b/packages/gonx/src/graph/static-analysis/resolve-import.ts new file mode 100644 index 000000000..756b90406 --- /dev/null +++ b/packages/gonx/src/graph/static-analysis/resolve-import.ts @@ -0,0 +1,132 @@ +/** + * Resolves Go import paths to Nx project names using longest-prefix matching. + */ + +/** + * Cache for sorted module paths to avoid re-sorting on every resolve call. + * Key is the Map reference (via WeakMap), value is the sorted array. + */ +const sortedPathsCache = new WeakMap, string[]>(); + +/** + * Gets or creates a sorted array of keys from a map, sorted by length descending. + * Uses WeakMap caching to avoid re-sorting on every call. + */ +function getSortedPaths(map: Map): string[] { + let sorted = sortedPathsCache.get(map); + if (!sorted) { + sorted = Array.from(map.keys()).sort((a, b) => b.length - a.length); + sortedPathsCache.set(map, sorted); + } + return sorted; +} + +/** + * Resolves an import path to an Nx project name. + * + * Uses longest-prefix matching against the import map, with replace + * directive support for the source project. + * + * Performance: Sorted path arrays are cached to avoid O(n log n) sorting + * on every call. For a workspace with 100 projects and 10,000 Go files, + * this reduces total sort operations from ~10,000 to ~100. + * + * @param importPath - The Go import path (e.g., "github.com/myorg/shared/utils") + * @param baseImportMap - Base mapping of module paths to project names + * @param sourceProject - The project that contains the import statement + * @param projectReplaceDirectives - Per-project replace directive mappings + * @returns Target project name or null if not resolved to workspace project + */ +export function resolveImport( + importPath: string, + baseImportMap: Map, + sourceProject: string, + projectReplaceDirectives: Map> +): string | null { + // Check if source project has replace directives + const replaceMap = projectReplaceDirectives.get(sourceProject); + + if (replaceMap) { + // Check for exact match in replace directives first + if (replaceMap.has(importPath)) { + const replacement = replaceMap.get(importPath); + if (replacement === null) { + // Null-suppression: this import should be ignored + return null; + } + // Use the replacement path for lookup + const targetProject = findProjectByLongestPrefix( + replacement, + baseImportMap + ); + if (targetProject && targetProject !== sourceProject) { + return targetProject; + } + return null; + } + + // Check for prefix match in replace directives (longest match first) + const sortedReplacePaths = getSortedPaths(replaceMap); + + for (const replacePath of sortedReplacePaths) { + if ( + importPath.startsWith(replacePath + '/') || + importPath === replacePath + ) { + const replacement = replaceMap.get(replacePath); + if (replacement === null) { + return null; + } + // Construct the new import path with the replacement. + // Note: suffix is either empty (exact match) or starts with '/' + // because the condition above guarantees startsWith(replacePath + '/'). + const suffix = importPath.slice(replacePath.length); + const newImportPath = replacement + suffix; + const targetProject = findProjectByLongestPrefix( + newImportPath, + baseImportMap + ); + if (targetProject && targetProject !== sourceProject) { + return targetProject; + } + return null; + } + } + } + + // Use base import map with longest prefix matching + const targetProject = findProjectByLongestPrefix(importPath, baseImportMap); + + // Prevent self-referential dependencies + if (targetProject && targetProject !== sourceProject) { + return targetProject; + } + + return null; +} + +/** + * Finds a project using longest-prefix matching. + * + * For import "github.com/myorg/shared/utils": + * - "github.com/myorg/shared" matches -> returns that project + * - "github.com/myorg" would also match but is shorter + * + * @param importPath - The import path to match + * @param importMap - Map of module paths to project names + * @returns Project name or null if no match + */ +function findProjectByLongestPrefix( + importPath: string, + importMap: Map +): string | null { + const sortedPaths = getSortedPaths(importMap); + + for (const modulePath of sortedPaths) { + if (importPath === modulePath || importPath.startsWith(modulePath + '/')) { + return importMap.get(modulePath) || null; + } + } + + return null; +} diff --git a/packages/gonx/src/graph/types/go-import-with-module.ts b/packages/gonx/src/graph/types/go-import-with-module.ts deleted file mode 100644 index ab1e261bc..000000000 --- a/packages/gonx/src/graph/types/go-import-with-module.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GoModule } from './go-module'; - -export interface GoImportWithModule { - import: string; - module: GoModule; -} diff --git a/packages/gonx/src/graph/types/go-list-type.ts b/packages/gonx/src/graph/types/go-list-type.ts deleted file mode 100644 index 015df7278..000000000 --- a/packages/gonx/src/graph/types/go-list-type.ts +++ /dev/null @@ -1 +0,0 @@ -export type GoListType = 'import' | 'use'; diff --git a/packages/gonx/src/graph/types/go-mod-info.ts b/packages/gonx/src/graph/types/go-mod-info.ts new file mode 100644 index 000000000..fb70b815f --- /dev/null +++ b/packages/gonx/src/graph/types/go-mod-info.ts @@ -0,0 +1,16 @@ +/** + * Information parsed from a go.mod file. + */ +export interface GoModInfo { + /** + * The module path declared in the go.mod file. + * e.g., "github.com/myorg/myapp" + */ + modulePath: string; + + /** + * Replace directives mapping old module paths to new paths. + * Values can be local paths (e.g., "../common") or module paths. + */ + replaceDirectives: Map; +} diff --git a/packages/gonx/src/graph/types/go-module.ts b/packages/gonx/src/graph/types/go-module.ts deleted file mode 100644 index 932410221..000000000 --- a/packages/gonx/src/graph/types/go-module.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GoModule { - Path: string; - Dir: string; -} diff --git a/packages/gonx/src/graph/types/go-plugin-options.ts b/packages/gonx/src/graph/types/go-plugin-options.ts index d19b8cedb..2fc0f4dd1 100644 --- a/packages/gonx/src/graph/types/go-plugin-options.ts +++ b/packages/gonx/src/graph/types/go-plugin-options.ts @@ -9,11 +9,7 @@ export interface GoPluginOptions { releasePublishTargetName?: string; tagName?: string; /** - * If true, the plugin will not require - * to have Go installed to compute a Nx workspace graph. - * - * Be aware that if Go is not installed, the plugin will not be able - * to detect dependencies between Go projects and this is source of misunderstanding. + * If true, dependency detection between Go projects is disabled entirely. */ skipGoDependencyCheck?: boolean; } diff --git a/packages/gonx/src/graph/types/import-map-result.ts b/packages/gonx/src/graph/types/import-map-result.ts new file mode 100644 index 000000000..91ee10627 --- /dev/null +++ b/packages/gonx/src/graph/types/import-map-result.ts @@ -0,0 +1,20 @@ +/** + * Result of building the import map for the workspace. + */ +export interface ImportMapResult { + /** + * Base mapping of module paths to Nx project names. + * e.g., "github.com/myorg/shared" -> "libs/shared" + */ + baseImportMap: Map; + + /** + * Per-project replace directive mappings. + * Outer key: project name (source project) + * Inner map: old module path -> new module path or null (for suppression) + * + * null value indicates the import should be suppressed (e.g., points to + * a non-Nx directory). + */ + projectReplaceDirectives: Map>; +} diff --git a/packages/gonx/src/graph/types/project-root-map.ts b/packages/gonx/src/graph/types/project-root-map.ts deleted file mode 100644 index 488adba46..000000000 --- a/packages/gonx/src/graph/types/project-root-map.ts +++ /dev/null @@ -1 +0,0 @@ -export type ProjectRootMap = Map; diff --git a/packages/gonx/src/graph/utils/compute-go-modules.ts b/packages/gonx/src/graph/utils/compute-go-modules.ts deleted file mode 100644 index bc865df2d..000000000 --- a/packages/gonx/src/graph/utils/compute-go-modules.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { workspaceRoot } from '@nx/devkit'; -import { getGoModules } from './get-go-modules'; -import { GoModule } from '../types/go-module'; - -/** - * Computes a list of go modules. - * - * @param failSilently if true, the function will not throw an error if it fails - */ -export const computeGoModules = (failSilently = false): GoModule[] => { - const blocks = getGoModules(workspaceRoot, failSilently); - if (blocks != null) { - return blocks - .split('}') - .filter((block) => block.trim().length > 0) - .map((block) => JSON.parse(`${block}}`) as GoModule) - .sort((module1, module2) => module1.Path.localeCompare(module2.Path)) - .reverse(); - } - throw new Error('Cannot get list of Go modules'); -}; diff --git a/packages/gonx/src/graph/utils/extract-project-root-map.ts b/packages/gonx/src/graph/utils/extract-project-root-map.ts deleted file mode 100644 index f3a85a1fa..000000000 --- a/packages/gonx/src/graph/utils/extract-project-root-map.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CreateDependenciesContext } from '@nx/devkit'; -import { ProjectRootMap } from '../types/project-root-map'; - -/** - * Extracts a map of project root to project name based on context. - * - * @param context the Nx graph context - */ -export const extractProjectRootMap = ( - context: CreateDependenciesContext -): ProjectRootMap => - Object.keys(context.projects).reduce((map, name) => { - map.set(context.projects[name].root, name); - return map; - }, new Map()); diff --git a/packages/gonx/src/graph/utils/get-file-module-imports.ts b/packages/gonx/src/graph/utils/get-file-module-imports.ts deleted file mode 100644 index 8185b8ea2..000000000 --- a/packages/gonx/src/graph/utils/get-file-module-imports.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FileData } from '@nx/devkit'; -import { GoModule } from '../types/go-module'; -import { GoImportWithModule } from '../types/go-import-with-module'; -import { readFileSync } from 'fs'; -import { parseGoList } from './parse-go-list'; - -/** - * Gets a list of go imports with associated module in the file. - * - * @param fileData file object computed by Nx - * @param modules list of go modules - */ -export const getFileModuleImports = ( - fileData: FileData, - modules: GoModule[] -): GoImportWithModule[] => { - const content = readFileSync(fileData.file, 'utf-8')?.toString(); - if (content == null) { - return []; - } - return parseGoList('import', content) - .map((item) => (item.includes('"') ? item.split('"')[1] : item)) - .filter((item) => item != null) - .map((item) => ({ - import: item, - module: modules.find((mod) => item.startsWith(mod.Path)), - })) - .filter((item) => item.module); -}; diff --git a/packages/gonx/src/graph/utils/get-go-modules.ts b/packages/gonx/src/graph/utils/get-go-modules.ts deleted file mode 100644 index 5f2323968..000000000 --- a/packages/gonx/src/graph/utils/get-go-modules.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { execSync } from 'child_process'; - -/** - * Executes the `go list -m -json` command in the - * specified directory and returns the output as a string. - * - * @param cwd the current working directory where the command should be executed. - * @param failSilently if true, the function will return an empty string instead of throwing an error when the command fails. - * @returns The output of the `go list -m -json` command as a string. - * @throws Will throw an error if the command fails and `failSilently` is false. - */ -export const getGoModules = (cwd: string, failSilently: boolean): string => { - try { - return execSync('go list -m -json', { - encoding: 'utf-8', - cwd, - stdio: ['ignore'], - windowsHide: true, - }); - } catch (error) { - if (failSilently) { - return ''; - } else { - throw error; - } - } -}; diff --git a/packages/gonx/src/graph/utils/get-project-name-for-go-imports.ts b/packages/gonx/src/graph/utils/get-project-name-for-go-imports.ts deleted file mode 100644 index e5d60d2db..000000000 --- a/packages/gonx/src/graph/utils/get-project-name-for-go-imports.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { workspaceRoot } from '@nx/devkit'; -import { GoImportWithModule } from '../types/go-import-with-module'; -import { ProjectRootMap } from '../types/project-root-map'; -import { dirname } from 'path'; - -/** - * Gets the project name for the go import by getting the relative path for the import with in the go module system - * then uses that to calculate the relative path on disk and looks up which project in the workspace the import is a part - * of. - * - * @param projectRootMap map with project roots in the workspace - * @param import the go import - * @param module the go module - */ -export const getProjectNameForGoImport = ( - projectRootMap: ProjectRootMap, - { import: goImport, module }: GoImportWithModule -): string | null => { - const relativeImportPath = goImport.substring(module.Path.length + 1); - const relativeModuleDir = module.Dir.substring( - workspaceRoot.length + 1 - ).replace(/\\/g, '/'); - let projectPath = relativeModuleDir - ? `${relativeModuleDir}/${relativeImportPath}` - : relativeImportPath; - - while (projectPath !== '.') { - if (projectPath.endsWith('/')) { - projectPath = projectPath.slice(0, -1); - } - - const projectName = projectRootMap.get(projectPath); - if (projectName) { - return projectName; - } - projectPath = dirname(projectPath); - } - return null; -}; diff --git a/packages/gonx/src/graph/utils/parse-go-list.ts b/packages/gonx/src/graph/utils/parse-go-list.ts deleted file mode 100644 index 2903d8ff3..000000000 --- a/packages/gonx/src/graph/utils/parse-go-list.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { GoListType } from '../types/go-list-type'; -import { REGEXS } from './regexs'; - -/** - * Parses a Go list (also support list with only one item). - * - * @param listType type of list to parse - * @param content list to parse as a string - */ -export const parseGoList = ( - listType: GoListType, - content: string -): string[] => { - const exec = REGEXS[listType].exec(content); - return ( - (exec?.[2] ?? exec?.[3]) - ?.trim() - .split(/\n+/) - .map((line) => line.trim()) ?? [] - ); -}; diff --git a/packages/gonx/src/graph/utils/regexs.ts b/packages/gonx/src/graph/utils/regexs.ts deleted file mode 100644 index 2408abbed..000000000 --- a/packages/gonx/src/graph/utils/regexs.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GoListType } from '../types/go-list-type'; - -export const REGEXS: Record = { - import: /import\s+(?:(\w+)\s+)?"([^"]+)"|\(([\s\S]*?)\)/, - use: /use\s+(\(([^)]*)\)|([^\n]*))/, - version: /go(?\S+) /, -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90407d47d..5dc64356b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,18 +237,27 @@ importers: nx-cloud: specifier: 19.1.0 version: 19.1.0 + p-limit: + specifier: ^6.2.0 + version: 6.2.0 prettier: specifier: ^2.6.2 version: 2.8.8 semver: specifier: ^7.6.0 version: 7.7.1 + strip-indent: + specifier: ^3.0.0 + version: 3.0.0 tcp-port-used: specifier: ^1.0.2 version: 1.0.2 tree-kill: specifier: ^1.2.2 version: 1.2.2 + tree-sitter-go: + specifier: ^0.25.0 + version: 0.25.0 ts-jest: specifier: 29.4.0 version: 29.4.0(@babel/core@7.28.0)(@jest/transform@30.0.5)(@jest/types@30.2.0)(babel-jest@30.0.5(@babel/core@7.28.0))(esbuild@0.19.12)(jest-util@30.0.5)(jest@30.0.5(@types/node@20.12.12)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@types/node@20.12.12)(typescript@5.9.3)))(typescript@5.9.3) @@ -273,6 +282,9 @@ importers: vitest: specifier: 4.0.9 version: 4.0.9(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@20.12.12)(@vitest/ui@4.0.9)(jiti@2.4.2)(jsdom@22.1.0)(less@4.2.2)(sass-embedded@1.86.3)(sass@1.86.3)(stylus@0.64.0)(terser@5.39.0)(tsx@4.19.4)(yaml@2.7.1) + web-tree-sitter: + specifier: ^0.26.3 + version: 0.26.3 webpack: specifier: ^5.90.1 version: 5.99.5(@swc/core@1.5.7(@swc/helpers@0.5.11))(esbuild@0.19.12) @@ -9917,6 +9929,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch-native@1.6.6: resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==} @@ -12047,6 +12063,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tcp-port-used@1.0.2: resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} @@ -12199,6 +12216,14 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-go@0.25.0: + resolution: {integrity: sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -12899,6 +12924,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-tree-sitter@0.26.3: + resolution: {integrity: sha512-JIVgIKFS1w6lejxSntCtsS/QsE/ecTS00en809cMxMPxaor6MvUnQ+ovG8uTTTvQCFosSh4MeDdI5bSGw5SoBw==} + web-vitals@0.2.4: resolution: {integrity: sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==} @@ -25493,6 +25521,8 @@ snapshots: node-addon-api@7.1.1: optional: true + node-addon-api@8.5.0: {} + node-fetch-native@1.6.6: {} node-fetch@2.6.7(encoding@0.1.13): @@ -28162,6 +28192,11 @@ snapshots: tree-kill@1.2.2: {} + tree-sitter-go@0.25.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + trim-lines@3.0.1: {} trim-newlines@3.0.1: {} @@ -28920,6 +28955,8 @@ snapshots: web-namespaces@2.0.1: {} + web-tree-sitter@0.26.3: {} + web-vitals@0.2.4: {} webidl-conversions@3.0.1: {}