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