diff --git a/.github/workflows/impact.yml b/.github/workflows/impact.yml new file mode 100644 index 0000000..57211a4 --- /dev/null +++ b/.github/workflows/impact.yml @@ -0,0 +1,19 @@ +name: Impact + +# Comments on each pull request with what its changes can reach. This repository uses its own action. +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./ diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c7b34..0ed35a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,15 @@ All notable changes to [`codegraph-engine`](https://www.npmjs.com/package/codegr ## Unreleased +### Fixed +- **Diffs no longer inflate the blast radius.** An edit on a line that declares a local variable is a change to its function (it used to be attributed to the variable, which has no callers, so the function's blast radius came out empty), and a module-level edit such as an import line no longer counts every symbol in the file as changed. Symbols a diff adds in full are reported as new instead of as changes. The pull request comment now counts only callers outside the files the change edits, says plainly when a change only adds code, and gives a test command per ecosystem (`go test ./pkg`, `pytest`, `vitest`/`jest`). +- **Python function-local imports** (`def f(): from x import y`) are scoped to that function instead of replacing the file's import of the same name, and shadow a same-named function outside it. Removes one `resolved` error each in Flask and Rich in the Jedi check. + ### Added +- **Go support.** `.go` files are indexed (CLI, web app, MCP, pull request comments): packages resolved through `go.mod`, methods attached to their types, struct embedding and promoted methods, interfaces, typed variables through constructors, results, ranges and fields, and `//go:build` for linux/amd64. Checked against the Go type checker on Cobra, Gin, Logrus and gorilla/mux: 98.5% of the calls it resolves are found, and every one of 7,550 edges is correct (first run, before any fix: 96.8% and 7,336 of 7,339). Details and limits in `docs/ORACLE-EVAL.md`. +- **Private repositories in the web app**: paste a GitHub personal access token on the home page. It stays in the browser tab, is sent only to `api.github.com`, and also raises GitHub's limit from 60 to 5,000 requests an hour. +- **`codegraph mcp`**: an MCP server (stdio) so AI agents can ask what a change touches: impact of a symbol or of the current diff, callers and callees, symbol search, an overview of the riskiest code. +- **A GitHub Action** (`uses: harsharajkumar-273/CodeGraph@main`) that comments on pull requests with the symbols, files and tests a change can reach, with an optional `fail-above` limit. `codegraph impact --format markdown` prints the same text. - **A proper website around the app.** A new homepage with an animated pipeline (get the code, parse it, link it, label every edge, show the blast radius), a product preview, features, the accuracy results with their caveats, and the CLI commands. Analyzing now shows the same pipeline live, stage by stage, with real progress. Fonts are bundled (no third-party requests), the page works on phones, respects reduced-motion settings and passes an automated accessibility check. - **The web app analyzes any repository, in the browser.** Paste a public GitHub repository (or `owner/repo@branch`, or a `github.com` link) or drop a project folder; the parser (WebAssembly) runs in a Web Worker and nothing is uploaded. diff --git a/README.md b/README.md index db6ee15..f64eff9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm downloads](https://img.shields.io/npm/dm/codegraph-engine.svg)](https://www.npmjs.com/package/codegraph-engine) [![license](https://img.shields.io/npm/l/codegraph-engine.svg)](LICENSE) -**See what breaks before you change it.** An AST dependency, call-graph and blast-radius engine for TypeScript, JavaScript and Python repositories, with a web app that runs entirely in your browser. +**See what breaks before you change it.** An AST dependency, call-graph and blast-radius engine for TypeScript, JavaScript, Python and Go repositories, with a web app that runs entirely in your browser. ![CodeGraph's blast radius for url_for() in Flask: 40 symbols in 19 files, with the affected test files folded together](docs/screenshots/workspace.png) @@ -23,12 +23,13 @@ Impact 3 symbols in 6 files (3 resolved · 0 probable · 0 ambiguous) d3 resolved src/flask/app.py#Flask.__call__ ``` -Every edge is labelled **resolved / probable / ambiguous**, so you can see *how much* of a blast radius is proven vs. guessed, and filter accordingly. Checked against independent oracles (the TypeScript compiler, and Jedi for Python) on every call site of six open-source repositories, not a sample. The repositories were chosen before measuring, but fixes the checks prompted are included, so a fresh repository may score a little lower: +Every edge is labelled **resolved / probable / ambiguous**, so you can see *how much* of a blast radius is proven vs. guessed, and filter accordingly. Checked against independent oracles (the TypeScript compiler, Jedi for Python, and the Go type checker) on every call site of ten open-source repositories, not a sample. The repositories were chosen before measuring, but fixes the checks prompted are included, so a fresh repository may score a little lower: | | `resolved` | `probable` | `ambiguous` | |---|---|---|---| | TypeScript: Hono, Immer, NestJS (vs. the TypeScript compiler) | 98% | 69% | 35% | | Python: Requests, HTTPX, Rich (vs. Jedi) | 99.8% | 80% | 16% | +| Go: Cobra, Gin, Logrus, mux (vs. the Go type checker) | 100% (7,550 of 7,550) | none emitted | none emitted | Trust `resolved`; treat `probable` as a lead to check and `ambiguous` as a list of name matches. See [`docs/ORACLE-EVAL.md`](docs/ORACLE-EVAL.md) for the numbers and their limits (no JavaScript-only repos yet, no adoption or independent-user evidence yet). The earlier LLM-labeled samples are in [`docs/PRECISION.md`](docs/PRECISION.md) and [`docs/EVALUATION.md`](docs/EVALUATION.md). @@ -36,8 +37,8 @@ Trust `resolved`; treat `probable` as a lead to check and `ambiguous` as a list Open the app, then paste `owner/repo` (or `owner/repo@branch`, or any `github.com` link), or drop a folder, some `.ts`/`.js`/`.py` files or a `.zip` (such as GitHub's "Download ZIP") from your machine. You land on a map of the repository's folders and a short list of the code that is riskiest to change. Pick any function, class or file to see its blast radius: what could break, how sure CodeGraph is about each link, and which tests to run. Share an analysis with `?repo=owner/repo` on the app's URL. -- **Public GitHub repositories, or anything on your own machine** (a folder, loose files, a `.zip`), in TypeScript, JavaScript and Python. Private repositories: download or clone them and use the folder or the CLI. Local files are read in the page and never uploaded. -- **Anonymous GitHub API limits** apply: about 30 analyses an hour per visitor. Local files, folders and zips don't use GitHub. Dragging a folder in is faster than choosing it, because `node_modules` and `.git` are skipped before anything is read. +- **Public GitHub repositories, or anything on your own machine** (a folder, loose files, a `.zip`), in TypeScript, JavaScript, Python and Go. Private repositories: paste a GitHub token (fine-grained, read-only "Contents"). It stays in the tab, goes only to api.github.com, and is forgotten when the tab closes. Or clone the repository and drop the folder. Local files are read in the page and never uploaded. +- **Anonymous GitHub API limits** apply: about 30 analyses an hour per visitor (a token raises it to 5,000 requests an hour). Local files, folders and zips don't use GitHub. Dragging a folder in is faster than choosing it, because `node_modules` and `.git` are skipped before anything is read. - **Large repositories** are trimmed to 3,000 source files, tests and examples first, and the page says so. - Host it yourself as a static site, for free on Vercel: see [`docs/DEPLOY.md`](docs/DEPLOY.md). @@ -87,15 +88,51 @@ To host the web app yourself (Vercel, Docker, Google Cloud Run or AWS), see [`do Targets accepted by `impact` / `slice`: `src/a.ts`, `src/a.ts#Foo.bar`, `src/a.ts:42`, or a bare `Foo.bar`. +## Use it with AI agents (MCP) + +`codegraph mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio. Agents such as Claude +Code can ask what a change touches before they make it. Tools: `codegraph_impact`, `codegraph_diff_impact`, +`codegraph_find`, `codegraph_callers_callees`, `codegraph_overview`, `codegraph_reindex`. + +```bash +claude mcp add codegraph -- npx -y codegraph-engine mcp . +``` + +Other clients take the same command in their MCP settings (`"command": "npx", "args": ["-y", "codegraph-engine", "mcp", "."]`). +The graph is built on the first question and held in memory; `codegraph_reindex` refreshes it after files change. + +## On pull requests + +The repository root is a GitHub Action that comments on each pull request with the symbols, files and tests its +changes can reach, and updates that comment as the pull request changes. + +```yaml +# .github/workflows/impact.yml +on: pull_request +permissions: { contents: read, pull-requests: write } +jobs: + impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } # the base commit must be present + - uses: harsharajkumar-273/CodeGraph@main + # with: { min-confidence: probable, fail-above: 200 } +``` + +`fail-above` makes the check fail when a change reaches more symbols than that. Pull requests from forks have a +read-only token, so the result goes to the job summary instead of a comment. The same text is available locally with +`codegraph impact --diff --range main...HEAD --format markdown`. + ## What it understands -| | TypeScript / JavaScript | Python | -|---|---|---| -| Imports | ESM, `require()`, dynamic `import()`, barrels (`export *`), `tsconfig` paths, workspace packages | absolute/relative imports, `__init__.py` re-exports, `from pkg import submodule` | -| Symbols | functions, arrows, classes, methods, `Foo.prototype.x =`, `module.exports` | functions, classes, methods, nested functions | -| Calls | plain, member, `this`, `new`, JSX components, callbacks passed by name | plain, member, `self`/`super()`, decorators, callbacks | -| Type-aware | annotations, `new Foo()`, constructor parameter properties | annotations, `x = Foo()`, `self.x = Foo()` | -| Variables | `const`/`let`/`var`, params, `for`/`for-of`, `catch`, `+=` | assignment, params, `for`, `+=` | +| | TypeScript / JavaScript | Python | Go | +|---|---|---|---| +| Imports | ESM, `require()`, dynamic `import()`, barrels (`export *`), `tsconfig` paths, workspace packages | absolute/relative imports, `__init__.py` re-exports, `from pkg import submodule` | import paths resolved through `go.mod`; a package is its directory, so calls inside it need no import | +| Symbols | functions, arrows, classes, methods, `Foo.prototype.x =`, `module.exports` | functions, classes, methods, nested functions | functions, methods (attached to their receiver type wherever it is declared), structs, interfaces and interface methods | +| Calls | plain, member, `this`, `new`, JSX components, callbacks passed by name | plain, member, `self`/`super()`, decorators, callbacks | plain, `pkg.F()`, `x.M()` including promoted methods of embedded types, calls on results (`New().Run()`), `defer`/`go` | +| Type-aware | annotations, `new Foo()`, constructor parameter properties | annotations, `x = Foo()`, `self.x = Foo()` | parameters, `var x T`, `x := T{}` / `&T{}` / `new(T)` / `F()` results, `for _, v := range`, fields and field chains, typed constants, `//go:build` for linux/amd64 | +| Variables | `const`/`let`/`var`, params, `for`/`for-of`, `catch`, `+=` | assignment, params, `for`, `+=` | not tracked yet | Variable lineage is **intra-procedural** (stage 1): it tracks a variable's value only within its own function, not across closures or object fields (`this.x`, `self.x`) — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#6-variable-lineage--stage-1-intra-procedural) for exactly what is and isn't tracked yet. @@ -108,7 +145,7 @@ packages/extractors per-file fact extraction (incl. variable def/use) + cross-f packages/analysis blast radius, slice, variable lineage, diff→symbols, import cycles (pure TS: Node or browser) packages/cli codegraph index | impact | slice | vars | cycles | serve packages/web the web app: GitHub / folder loaders, an indexing Web Worker, and the React Flow UI -fixtures/ tiny TS/JS and Python repos with hand-verified expected edges +fixtures/ tiny TS/JS, Python and Go repos with hand-verified expected edges docs/ARCHITECTURE.md design, schema, resolution algorithm, decisions, roadmap ``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..49bfcb0 --- /dev/null +++ b/action.yml @@ -0,0 +1,80 @@ +name: CodeGraph impact comment +description: Comments on a pull request with the functions, files and tests that its changes can reach. +author: harsharajkumar-273 +branding: + icon: git-pull-request + color: blue + +inputs: + github-token: + description: "Token used to post the comment. The default needs `pull-requests: write` on the workflow." + default: ${{ github.token }} + min-confidence: + description: "Weakest link to count (resolved, probable or ambiguous)." + default: probable + fail-above: + description: "Fail the check when more than this many symbols are affected. Leave empty to only comment." + default: '' + +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install CodeGraph + shell: bash + working-directory: ${{ github.action_path }} + run: npm ci --no-audit --no-fund + - name: Work out what the pull request can reach + id: impact + shell: bash + working-directory: ${{ github.action_path }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + MIN: ${{ inputs.min-confidence }} + WORKSPACE: ${{ github.workspace }} + OUT: ${{ runner.temp }}/codegraph-comment.md + run: | + if [ -z "$BASE_SHA" ]; then echo "::error::Run this action on pull_request events."; exit 1; fi + if ! git -C "$WORKSPACE" cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + echo "::error::The base commit is not in the checkout. Use actions/checkout with fetch-depth: 0."; exit 1 + fi + npx tsx packages/cli/src/bin.ts impact --diff --range "$BASE_SHA...HEAD" --repo "$WORKSPACE" \ + --min-confidence "$MIN" --format markdown > "$OUT" + # The headline number in the comment is "**N symbols in M files**"; no match means nothing was affected. + N=$(grep -oE '\*\*[0-9]+ symbols' "$OUT" | grep -oE '[0-9]+' | head -1 || true) + echo "symbols=${N:-0}" >> "$GITHUB_OUTPUT" + echo "file=$OUT" >> "$GITHUB_OUTPUT" + - name: Post or update the comment + if: steps.impact.outputs.file != '' + uses: actions/github-script@v7 + env: + FILE: ${{ steps.impact.outputs.file }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const body = fs.readFileSync(process.env.FILE, 'utf8').trim(); + if (!body) { core.info('No changed source symbols, nothing to say.'); return; } + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + try { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const mine = comments.find((c) => c.body && c.body.startsWith(marker)); + if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } catch (e) { + // Pull requests from forks get a read-only token. Show the result in the run summary instead. + core.warning(`Could not post the comment (${e.message}). Writing it to the job summary instead.`); + await core.summary.addRaw(body).write(); + } + - name: Enforce the limit + if: inputs.fail-above != '' && steps.impact.outputs.symbols != '' + shell: bash + env: + SYMBOLS: ${{ steps.impact.outputs.symbols }} + LIMIT: ${{ inputs.fail-above }} + run: | + if [ "$SYMBOLS" -gt "$LIMIT" ]; then echo "::error::This change can reach $SYMBOLS symbols, over the limit of $LIMIT."; exit 1; fi diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 22f8346..2c5a08d 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -26,7 +26,7 @@ Share a ready-made analysis with a link such as `https://.vercel.app/?r **Limits to know about** - GitHub allows 60 anonymous API requests per hour per visitor IP. One analysis uses 2 (1 when the branch is given), so a visitor can run about 30 analyses an hour. The page explains it when a visitor hits the limit. -- Only public repositories can be read from GitHub by a browser. For a private one, download it (or clone it) and drop the folder or the .zip onto the page, or use the CLI. +- Visitors can read a private repository by pasting a GitHub personal access token on the home page. The token stays in their tab (`sessionStorage`) and is sent only to `api.github.com`, never to your host. Files are then fetched through the contents API, which counts against the token's 5,000 requests an hour. Without a token, only public repositories are readable; for a private one, drop a clone or a .zip onto the page, or use the CLI. - Very large repositories are trimmed to 3,000 source files (tests and examples first), and files over 1 MB are skipped; the page says when it does this. diff --git a/docs/ORACLE-EVAL.md b/docs/ORACLE-EVAL.md index edbd198..f012081 100644 --- a/docs/ORACLE-EVAL.md +++ b/docs/ORACLE-EVAL.md @@ -1,4 +1,4 @@ -# Oracle evaluation (TypeScript compiler, Python Jedi) +# Oracle evaluation (TypeScript compiler, Python Jedi, Go type checker) The earlier evaluations ([`PRECISION.md`](PRECISION.md), [`EVALUATION.md`](EVALUATION.md)) were labeled by the analyzer author's LLM on a few hundred call sites. They were also partly on repos the resolver was tuned against. @@ -214,10 +214,13 @@ Pooled over the three fresh repos: `resolved` 8,251 / 8,267 (**99.8%**), `probab 1,798/1,798. Real CodeGraph errors this surfaced: -- **Function-local imports are ignored.** Flask's `test_client` does `from .testing import FlaskClient as cls` and - calls `cls(…)`, but the edge goes to `FlaskCliRunner`, which another function imports under the same local name. - Rich's `get_windows_console_features` imports a same-named function inside its body, and the call is linked to the - enclosing function. These are the only `resolved` errors found in Python. +- **Function-local imports were ignored (fixed).** Flask's `test_client` does `from .testing import FlaskClient as cls` + and calls `cls(…)`, but the edge went to `FlaskCliRunner`, which another function imports under the same local name. + Imports inside a function body are now scoped to that function, and shadow a same-named function declared outside + it (`docs/eval/oracle/py-*.local-imports.json`). Re-run on the same four repositories, this removes one error each + in Flask (`resolved` 1,040/1,040) and Rich (`resolved` 5,647/5,652, where `get_windows_console_features` had its + own-name import linked to the enclosing function), and changes nothing in Requests or HTTPX. The fix is correct but + its effect on these four repositories is small. - **Sync/async twins:** `client.get()` on an `httpx.AsyncClient` goes to `Client.get`. This is most of HTTPX's `probable` errors. - **Built-in collisions, as in TypeScript:** `str.rstrip` → `Text.rstrip`, `list.extend` → `Lines.extend`, and @@ -227,9 +230,77 @@ Python coverage is better than TypeScript's: only 9% of call sites are `unknown` TypeScript checker without dependencies installed. A known limit is that Jedi infers types less completely than a type checker, so calls on untyped receivers are not scored. +## Go (the Go type checker as the oracle) + +Go is the third language, and the only one added after the oracle method existed, so it was built against the oracle +from the start (`scripts/eval/go-oracle/`, a Go program using `go/packages` and `go/types`). The oracle type-checks +every package of a repository, tests included, on linux/amd64 with its real dependencies, and reports for each call +`f()`, `pkg.F()` and `x.M()` the declaration it binds to: `internal` (a function, method or interface method of the +repo that has a graph node), `external` (standard library, a dependency or a built-in), or `nosym` (a function value held +in a variable or field, or a type conversion). Type errors would make a site `unknown`; none occurred. + +**What was tuned on, in order.** The resolver was developed against a hand-written fixture and **chi** (`go-chi/chi`), +which is a development repository and not evidence. Cobra, Gin, Logrus and gorilla/mux were chosen and cloned before the +first measurement, and their first-run output was saved before any resolver change +(`docs/eval/oracle/go-*.first-run.json`). The fixes that run prompted were made afterwards, so the second set of numbers +is not a blind test. + +| First run, before any change | internal call sites | recall | `resolved` | name-guess (`ambiguous`) | calls to outside code given an edge | +|---|---|---|---|---|---| +| Cobra | 1,921 | 99.8% | 1,884/1,884 | 33/52 | 19/2,117 | +| Gin | 3,636 | 97.0% | 3,517/3,517 | 11/242 | 115/5,098 | +| Logrus | 1,141 | 98.8% | 1,120/1,123 | 7/66 | 22/1,380 | +| mux | 964 | 87.2% | 815/815 | 26/44 | 1/784 | +| **Pooled** | 7,662 | **96.8%** | 7,336/7,339 (**99.96%**) | 77/404 (19.1%) | 157/9,379 | + +Nothing was labeled `probable`: Go's static types make an edge either proven or absent. The three `resolved` errors were +all in Logrus, where a function is declared once per platform (`terminal_check_unix.go`, `…_bsd.go`) and CodeGraph +picked a different file from the one the type checker builds on Linux. + +What the first run exposed, and the change that followed each: +- **Platform variants** (the three errors above): files are now chosen by their `_linux.go`-style suffix and `//go:build` + line, as for linux/amd64. +- **Typed constants** (`const ( Debug Level = iota; Info )`, then `Debug.String()`), **`new(T).M()`**, **indexed + receivers** (`r.items[i].M()`), and **`x := packageVar`** aliases were not typed. +- **Closures sharing a scope**: a function literal's parameter replaced an outer variable of the same name (chi's + `r := chi.NewRouter()` against `func(r *http.Request)`), so each literal now has its own scope, and where a name is + declared more than once the nearest declaration above the call wins. +- **The name-guess fallback was removed.** When a receiver's type could not be worked out, CodeGraph used to offer + same-named methods nearby as `ambiguous`. Over the four repositories that was right 77 times in 404, and it made 157 + edges into standard-library or dependency code. Go is statically typed, so not knowing the type usually means the + receiver is outside the repo. It now gives no edge. + +| After the fixes | recall | `resolved` | calls to outside code given an edge | +|---|---|---|---| +| Cobra | 98.1% | 1,884/1,884 | 0/2,117 | +| Gin | 98.5% | 3,580/3,580 | 0/5,098 | +| Logrus | 98.9% | 1,128/1,128 | 0/1,380 | +| mux | 99.4% | 958/958 | 0/784 | +| **Pooled** | **98.5%** | 7,550/7,550 | 0/9,379 | +| *chi (development)* | 99.1% | 1,315/1,315 | 0/2,038 | + +Recall fell a little where the guess was removed (it had found some calls correctly) and rose where the fixes added +typing. Per-repo JSON is in `docs/eval/oracle/go-*.json`. Another 615 `nosym` sites (function values, conversions) got +no edge. + +Go-specific limits: +- **Interfaces.** A call through an interface goes to the interface's method, which is what the type checker reports + too. CodeGraph does not add edges to the types that implement the interface, so the blast radius of a change to an + implementation misses callers that only hold the interface. +- **One platform.** Build constraints are evaluated for linux/amd64 with cgo on; code only built for other platforms is + indexed but its calls are matched against that platform's choices. +- **Not tracked:** results of a function-typed field (`s.hook(x).Foo()`), type switches (`switch v := x.(type)`), + generic type parameters (`T` in `func F[T Stringer]`), reflection, and generated code that is not checked in. The + remaining 1.5% of calls the type checker resolves and CodeGraph does not find (112 of 7,662) were not categorized + one by one; the ones looked at were calls on values whose type comes through a route not listed above (an + interface-typed result, a loop variable over a call's result). +- **Variable lineage** (`codegraph vars`) is not implemented for Go. +- **Import edges** connect a file to every non-test file of the package it imports, since a change in any of them can + reach the importer. A package with many files makes many edges. + ## Limits of this evaluation -- **Scope.** TypeScript (compiler) and Python (Jedi) are covered; JavaScript (Express, Immer's JS tests) is not. Only the +- **Scope.** TypeScript (compiler), Python (Jedi) and Go (type checker) are covered; JavaScript (Express, Immer's JS tests) is not. Only the call shapes CodeGraph extracts are scored, and JSX is skipped. - **Coverage.** With dependencies not installed, `unknown` is large. It is mostly test-framework globals (`expect`, `describe`), so it is mostly external anyway. Even so, a share of CodeGraph's edges cannot be scored at all: @@ -255,3 +326,16 @@ node scripts/eval/score-oracle.mjs hono.sites.jsonl hono.oracle.jsonl --examples ``` Each repo takes well under a minute. + +Go: + +```bash +git clone https://github.com/spf13/cobra && git -C cobra checkout adbc8813901bba65827259daa8e22ff94ec1f30e +# gin 0f09c3a9b4626d9fe9979cf4d0521d6ae32ab646 · logrus 6ef0748d1177dadaeee9aa79ffef604570f487d7 +# mux db9d1d0073d27a0a2d9a8c1bc52aa0af4374d265 · chi 9f8adb1aabaa8a91180a821111d3f8e668d1c0bf (development) +(cd cobra && go mod download) +npm run cg -- index cobra +npx tsx scripts/eval/dump-call-sites.mts cobra cobra.sites.jsonl +(cd scripts/eval/go-oracle && go run . ../../../cobra ../../../cobra/.codegraph/graph.json ../../../cobra.oracle.jsonl) +node scripts/eval/score-oracle.mjs cobra.sites.jsonl cobra.oracle.jsonl --examples 10 +``` diff --git a/docs/eval/oracle/go-chi.json b/docs/eval/oracle/go-chi.json new file mode 100644 index 0000000..a25737c --- /dev/null +++ b/docs/eval/oracle/go-chi.json @@ -0,0 +1,192 @@ +{ + "all": { + "sites": { + "internal": 1327, + "nosym": 230, + "external": 2038, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1315, + "wrongOnly": 0, + "unresolved": 12, + "notExtracted": 0 + }, + "nosym": { + "clean": 230, + "falseEdge": 0 + }, + "external": { + "clean": 2038, + "falseEdge": 0 + }, + "edges": { + "correct": 1315, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1315, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1315, + "precision": 100, + "recall": 99.1, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 1327, + "nosym": 230, + "external": 2038, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1315, + "wrongOnly": 0, + "unresolved": 12, + "notExtracted": 0 + }, + "nosym": { + "clean": 230, + "falseEdge": 0 + }, + "external": { + "clean": 2038, + "falseEdge": 0 + }, + "edges": { + "correct": 1315, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1315, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1315, + "precision": 100, + "recall": 99.1, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "middleware/route_headers.go:93", + "callee": "IsMatch", + "want": "middleware/route_headers.go#HeaderRoute.IsMatch", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:71", + "callee": "Tee", + "want": "middleware/wrap_writer.go#WrapResponseWriter.Tee", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:74", + "callee": "Discard", + "want": "middleware/wrap_writer.go#WrapResponseWriter.Discard", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:81", + "callee": "Status", + "want": "middleware/wrap_writer.go#WrapResponseWriter.Status", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:83", + "callee": "BytesWritten", + "want": "middleware/wrap_writer.go#WrapResponseWriter.BytesWritten", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:90", + "callee": "Status", + "want": "middleware/wrap_writer.go#WrapResponseWriter.Status", + "got": [] + }, + { + "at": "middleware/wrap_writer_test.go:91", + "callee": "BytesWritten", + "want": "middleware/wrap_writer.go#WrapResponseWriter.BytesWritten", + "got": [] + }, + { + "at": "tree.go:471", + "callee": "isLeaf", + "want": "tree.go#node.isLeaf", + "got": [] + }, + { + "at": "tree.go:494", + "callee": "findRoute", + "want": "tree.go#node.findRoute", + "got": [] + }, + { + "at": "tree.go:519", + "callee": "isLeaf", + "want": "tree.go#node.isLeaf", + "got": [] + }, + { + "at": "tree.go:542", + "callee": "findRoute", + "want": "tree.go#node.findRoute", + "got": [] + }, + { + "at": "tree.go:725", + "callee": "walk", + "want": "tree.go#node.walk", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-cobra.first-run.json b/docs/eval/oracle/go-cobra.first-run.json new file mode 100644 index 0000000..96a8972 --- /dev/null +++ b/docs/eval/oracle/go-cobra.first-run.json @@ -0,0 +1,251 @@ +{ + "all": { + "sites": { + "internal": 1921, + "nosym": 71, + "external": 2117, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1917, + "wrongOnly": 0, + "unresolved": 4, + "notExtracted": 0 + }, + "nosym": { + "clean": 71, + "falseEdge": 0 + }, + "external": { + "clean": 2098, + "falseEdge": 19 + }, + "edges": { + "correct": 1917, + "incorrect": 19, + "byTier": { + "resolved": { + "correct": 1884, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 33, + "incorrect": 19, + "precision": 63.5 + } + } + }, + "emitted": 1936, + "precision": 99, + "recall": 99.8, + "externalFalseEdgeRate": 0.9, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 1921, + "nosym": 71, + "external": 2117, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1917, + "wrongOnly": 0, + "unresolved": 4, + "notExtracted": 0 + }, + "nosym": { + "clean": 71, + "falseEdge": 0 + }, + "external": { + "clean": 2098, + "falseEdge": 19 + }, + "edges": { + "correct": 1917, + "incorrect": 19, + "byTier": { + "resolved": { + "correct": 1884, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 33, + "incorrect": 19, + "precision": 63.5 + } + } + }, + "emitted": 1936, + "precision": 99, + "recall": 99.8, + "externalFalseEdgeRate": 0.9, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [ + { + "at": "cobra.go:120", + "callee": "Len", + "want": "reflect", + "got": [ + "command.go#commandSorterByName.Len (ambiguous)" + ] + }, + { + "at": "cobra.go:124", + "callee": "String", + "want": "reflect", + "got": [ + "completions_test.go#customMultiString.String (ambiguous)" + ] + }, + { + "at": "cobra.go:131", + "callee": "Len", + "want": "reflect", + "got": [ + "command.go#commandSorterByName.Len (ambiguous)" + ] + }, + { + "at": "cobra.go:135", + "callee": "String", + "want": "reflect", + "got": [ + "completions_test.go#customMultiString.String (ambiguous)" + ] + }, + { + "at": "cobra.go:154", + "callee": "String", + "want": "reflect", + "got": [ + "completions_test.go#customMultiString.String (ambiguous)" + ] + }, + { + "at": "cobra.go:154", + "callee": "String", + "want": "reflect", + "got": [ + "completions_test.go#customMultiString.String (ambiguous)" + ] + }, + { + "at": "cobra.go:186", + "callee": "Execute", + "want": "text/template", + "got": [ + "command.go#Command.Execute (ambiguous)" + ] + }, + { + "at": "doc/man_docs_test.go:169", + "callee": "Error", + "want": "universe", + "got": [ + "completions.go#flagCompError.Error (ambiguous)" + ] + }, + { + "at": "doc/man_docs_test.go:222", + "callee": "Name", + "want": "os", + "got": [ + "command.go#Command.Name (ambiguous)" + ] + }, + { + "at": "doc/md_docs_test.go:116", + "callee": "Name", + "want": "os", + "got": [ + "command.go#Command.Name (ambiguous)" + ] + }, + { + "at": "doc/rest_docs_test.go:85", + "callee": "Error", + "want": "universe", + "got": [ + "completions.go#flagCompError.Error (ambiguous)" + ] + }, + { + "at": "doc/rest_docs_test.go:103", + "callee": "Name", + "want": "os", + "got": [ + "command.go#Command.Name (ambiguous)" + ] + } + ], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "command.go:1329", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command.go:1329", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "doc/util.go:52", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "doc/util.go:52", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-cobra.json b/docs/eval/oracle/go-cobra.json new file mode 100644 index 0000000..317db55 --- /dev/null +++ b/docs/eval/oracle/go-cobra.json @@ -0,0 +1,192 @@ +{ + "all": { + "sites": { + "internal": 1921, + "nosym": 71, + "external": 2117, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1884, + "wrongOnly": 0, + "unresolved": 37, + "notExtracted": 0 + }, + "nosym": { + "clean": 71, + "falseEdge": 0 + }, + "external": { + "clean": 2117, + "falseEdge": 0 + }, + "edges": { + "correct": 1884, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1884, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1884, + "precision": 100, + "recall": 98.1, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 1921, + "nosym": 71, + "external": 2117, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1884, + "wrongOnly": 0, + "unresolved": 37, + "notExtracted": 0 + }, + "nosym": { + "clean": 71, + "falseEdge": 0 + }, + "external": { + "clean": 2117, + "falseEdge": 0 + }, + "edges": { + "correct": 1884, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1884, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1884, + "precision": 100, + "recall": 98.1, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "bash_completions.go:450", + "callee": "IsAvailableCommand", + "want": "command.go#Command.IsAvailableCommand", + "got": [] + }, + { + "at": "bash_completions.go:453", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "bash_completions.go:654", + "callee": "IsAvailableCommand", + "want": "command.go#Command.IsAvailableCommand", + "got": [] + }, + { + "at": "command.go:1285", + "callee": "IsAvailableCommand", + "want": "command.go#Command.IsAvailableCommand", + "got": [] + }, + { + "at": "command.go:1286", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command.go:1287", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command.go:1329", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command.go:1329", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command_test.go:1926", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "command_test.go:1947", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + }, + { + "at": "completions.go:521", + "callee": "IsAvailableCommand", + "want": "command.go#Command.IsAvailableCommand", + "got": [] + }, + { + "at": "completions.go:522", + "callee": "Name", + "want": "command.go#Command.Name", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-gin.first-run.json b/docs/eval/oracle/go-gin.first-run.json new file mode 100644 index 0000000..9c0cccf --- /dev/null +++ b/docs/eval/oracle/go-gin.first-run.json @@ -0,0 +1,301 @@ +{ + "all": { + "sites": { + "internal": 3636, + "nosym": 270, + "external": 5098, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 3528, + "wrongOnly": 0, + "unresolved": 108, + "notExtracted": 0 + }, + "nosym": { + "clean": 270, + "falseEdge": 0 + }, + "external": { + "clean": 4983, + "falseEdge": 115 + }, + "edges": { + "correct": 3528, + "incorrect": 231, + "byTier": { + "resolved": { + "correct": 3517, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 11, + "incorrect": 231, + "precision": 4.5 + } + } + }, + "emitted": 3759, + "precision": 93.9, + "recall": 97, + "externalFalseEdgeRate": 2.3, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 3636, + "nosym": 270, + "external": 5098, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 3528, + "wrongOnly": 0, + "unresolved": 108, + "notExtracted": 0 + }, + "nosym": { + "clean": 270, + "falseEdge": 0 + }, + "external": { + "clean": 4983, + "falseEdge": 115 + }, + "edges": { + "correct": 3528, + "incorrect": 231, + "byTier": { + "resolved": { + "correct": 3517, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 11, + "incorrect": 231, + "precision": 4.5 + } + } + }, + "emitted": 3759, + "precision": 93.9, + "recall": 97, + "externalFalseEdgeRate": 2.3, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [ + { + "at": "auth_test.go:94", + "callee": "Set", + "want": "net/http", + "got": [ + "context.go#Context.Set (ambiguous)" + ] + }, + { + "at": "auth_test.go:98", + "callee": "String", + "want": "bytes", + "got": [ + "context.go#Context.String (ambiguous)", + "errors.go#errorMsgs.String (ambiguous)" + ] + }, + { + "at": "auth_test.go:113", + "callee": "Set", + "want": "net/http", + "got": [ + "context.go#Context.Set (ambiguous)" + ] + }, + { + "at": "auth_test.go:133", + "callee": "Set", + "want": "net/http", + "got": [ + "context.go#Context.Set (ambiguous)" + ] + }, + { + "at": "auth_test.go:151", + "callee": "Set", + "want": "net/http", + "got": [ + "context.go#Context.Set (ambiguous)" + ] + }, + { + "at": "auth_test.go:155", + "callee": "String", + "want": "bytes", + "got": [ + "context.go#Context.String (ambiguous)", + "errors.go#errorMsgs.String (ambiguous)" + ] + }, + { + "at": "auth_test.go:170", + "callee": "Set", + "want": "net/http", + "got": [ + "context.go#Context.Set (ambiguous)" + ] + }, + { + "at": "binding/binding_test.go:535", + "callee": "Close", + "want": "mime/multipart", + "got": [ + "binding/binding_test.go#failRead.Close (ambiguous)" + ] + }, + { + "at": "binding/binding_test.go:543", + "callee": "Close", + "want": "os", + "got": [ + "binding/binding_test.go#failRead.Close (ambiguous)" + ] + }, + { + "at": "binding/binding_test.go:560", + "callee": "Close", + "want": "mime/multipart", + "got": [ + "binding/binding_test.go#failRead.Close (ambiguous)" + ] + }, + { + "at": "binding/binding_test.go:568", + "callee": "Close", + "want": "os", + "got": [ + "binding/binding_test.go#failRead.Close (ambiguous)" + ] + }, + { + "at": "binding/binding_test.go:585", + "callee": "Close", + "want": "mime/multipart", + "got": [ + "binding/binding_test.go#failRead.Close (ambiguous)" + ] + } + ], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "binding/binding_test.go:437", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:446", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:792", + "callee": "Name", + "want": "binding/binding.go#Binding.Name", + "got": [] + }, + { + "at": "binding/binding_test.go:801", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:811", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:817", + "callee": "Name", + "want": "binding/binding.go#BindingUri.Name", + "got": [] + }, + { + "at": "binding/binding_test.go:860", + "callee": "Name", + "want": "binding/binding.go#Binding.Name", + "got": [] + }, + { + "at": "binding/binding_test.go:867", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:876", + "callee": "Name", + "want": "binding/binding.go#Binding.Name", + "got": [] + }, + { + "at": "binding/binding_test.go:883", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + }, + { + "at": "binding/binding_test.go:896", + "callee": "Name", + "want": "binding/binding.go#Binding.Name", + "got": [] + }, + { + "at": "binding/binding_test.go:903", + "callee": "Bind", + "want": "binding/binding.go#Binding.Bind", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-gin.json b/docs/eval/oracle/go-gin.json new file mode 100644 index 0000000..cc00cc1 --- /dev/null +++ b/docs/eval/oracle/go-gin.json @@ -0,0 +1,192 @@ +{ + "all": { + "sites": { + "internal": 3636, + "nosym": 270, + "external": 5098, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 3580, + "wrongOnly": 0, + "unresolved": 56, + "notExtracted": 0 + }, + "nosym": { + "clean": 270, + "falseEdge": 0 + }, + "external": { + "clean": 5098, + "falseEdge": 0 + }, + "edges": { + "correct": 3580, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 3580, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 3580, + "precision": 100, + "recall": 98.5, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 3636, + "nosym": 270, + "external": 5098, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 3580, + "wrongOnly": 0, + "unresolved": 56, + "notExtracted": 0 + }, + "nosym": { + "clean": 270, + "falseEdge": 0 + }, + "external": { + "clean": 5098, + "falseEdge": 0 + }, + "edges": { + "correct": 3580, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 3580, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 3580, + "precision": 100, + "recall": 98.5, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "binding/default_validator_test.go:44", + "callee": "Error", + "want": "binding/default_validator.go#SliceValidationError.Error", + "got": [] + }, + { + "at": "binding/default_validator_test.go:85", + "callee": "ValidateStruct", + "want": "binding/default_validator.go#defaultValidator.ValidateStruct", + "got": [] + }, + { + "at": "binding/form_mapping.go:194", + "callee": "UnmarshalParam", + "want": "binding/form_mapping.go#BindUnmarshaler.UnmarshalParam", + "got": [] + }, + { + "at": "context_test.go:769", + "callee": "Error", + "want": "errors.go#Error.Error", + "got": [] + }, + { + "at": "context_test.go:769", + "callee": "Error", + "want": "errors.go#Error.Error", + "got": [] + }, + { + "at": "context_test.go:770", + "callee": "Error", + "want": "errors.go#Error.Error", + "got": [] + }, + { + "at": "context_test.go:770", + "callee": "Error", + "want": "errors.go#Error.Error", + "got": [] + }, + { + "at": "context_test.go:909", + "callee": "initQueryCache", + "want": "context.go#Context.initQueryCache", + "got": [] + }, + { + "at": "context_test.go:3522", + "callee": "Value", + "want": "context.go#Context.Value", + "got": [] + }, + { + "at": "errors.go:107", + "callee": "IsType", + "want": "errors.go#Error.IsType", + "got": [] + }, + { + "at": "errors.go:136", + "callee": "Error", + "want": "errors.go#Error.Error", + "got": [] + }, + { + "at": "errors.go:150", + "callee": "JSON", + "want": "errors.go#Error.JSON", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-logrus.first-run.json b/docs/eval/oracle/go-logrus.first-run.json new file mode 100644 index 0000000..8ead173 --- /dev/null +++ b/docs/eval/oracle/go-logrus.first-run.json @@ -0,0 +1,346 @@ +{ + "all": { + "sites": { + "internal": 1141, + "nosym": 174, + "external": 1380, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1127, + "wrongOnly": 3, + "unresolved": 11, + "notExtracted": 0 + }, + "nosym": { + "clean": 174, + "falseEdge": 0 + }, + "external": { + "clean": 1358, + "falseEdge": 22 + }, + "edges": { + "correct": 1127, + "incorrect": 62, + "byTier": { + "resolved": { + "correct": 1120, + "incorrect": 3, + "precision": 99.7 + }, + "ambiguous": { + "correct": 7, + "incorrect": 59, + "precision": 10.6 + } + } + }, + "emitted": 1189, + "precision": 94.8, + "recall": 98.8, + "externalFalseEdgeRate": 1.6, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 1105, + "nosym": 174, + "external": 1325, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1091, + "wrongOnly": 3, + "unresolved": 11, + "notExtracted": 0 + }, + "nosym": { + "clean": 174, + "falseEdge": 0 + }, + "external": { + "clean": 1303, + "falseEdge": 22 + }, + "edges": { + "correct": 1091, + "incorrect": 62, + "byTier": { + "resolved": { + "correct": 1084, + "incorrect": 3, + "precision": 99.7 + }, + "ambiguous": { + "correct": 7, + "incorrect": 59, + "precision": 10.6 + } + } + }, + "emitted": 1153, + "precision": 94.6, + "recall": 98.7, + "externalFalseEdgeRate": 1.7, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 36, + "nosym": 0, + "external": 55, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 36, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 55, + "falseEdge": 0 + }, + "edges": { + "correct": 36, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 36, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 36, + "precision": 100, + "recall": 100, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [ + { + "at": "terminal_check_notappengine.go:17", + "callee": "isTerminal", + "want": "terminal_check_unix.go#isTerminal", + "got": [ + "terminal_check_bsd.go#isTerminal (resolved)" + ] + }, + { + "at": "text_formatter.go:122", + "callee": "checkIfTerminal", + "want": "terminal_check_notappengine.go#checkIfTerminal", + "got": [ + "terminal_check_appengine.go#checkIfTerminal (resolved)" + ] + }, + { + "at": "text_formatter_test.go:579", + "callee": "checkIfTerminal", + "want": "terminal_check_notappengine.go#checkIfTerminal", + "got": [ + "terminal_check_appengine.go#checkIfTerminal (resolved)" + ] + } + ], + "falseEdgeExternal": [ + { + "at": "hooks/slog/handler_test.go:25", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:143", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:159", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:160", + "callee": "Warn", + "want": "log/slog", + "got": [ + "entry.go#Entry.Warn (ambiguous)", + "logger.go#Logger.Warn (ambiguous)", + "logrus.go#WarnLogger.Warn (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:177", + "callee": "WithGroup", + "want": "log/slog", + "got": [ + "hooks/slog/slog_test.go#errorHandler.WithGroup (ambiguous)", + "hooks/slog/handler.go#Handler.WithGroup (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:179", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:260", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:280", + "callee": "WithGroup", + "want": "log/slog", + "got": [ + "hooks/slog/slog_test.go#errorHandler.WithGroup (ambiguous)", + "hooks/slog/handler.go#Handler.WithGroup (ambiguous)" + ] + }, + { + "at": "hooks/slog/handler_test.go:281", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/level_example_test.go:39", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/level_example_test.go:45", + "callee": "Info", + "want": "log/slog", + "got": [ + "entry.go#Entry.Info (ambiguous)", + "logger.go#Logger.Info (ambiguous)", + "logrus.go#InfoLogger.Info (ambiguous)" + ] + }, + { + "at": "hooks/slog/level_example_test.go:46", + "callee": "Warn", + "want": "log/slog", + "got": [ + "entry.go#Entry.Warn (ambiguous)", + "logger.go#Logger.Warn (ambiguous)", + "logrus.go#WarnLogger.Warn (ambiguous)" + ] + } + ], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "entry_test.go:119", + "callee": "Error", + "want": "entry.go#Entry.Error", + "got": [] + }, + { + "at": "hooks/slog/slog_test.go:188", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:531", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:532", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:533", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:534", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:535", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:536", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:537", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "text_formatter_test.go:318", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "text_formatter_test.go:319", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-logrus.json b/docs/eval/oracle/go-logrus.json new file mode 100644 index 0000000..8efa690 --- /dev/null +++ b/docs/eval/oracle/go-logrus.json @@ -0,0 +1,198 @@ +{ + "all": { + "sites": { + "internal": 1141, + "nosym": 174, + "external": 1380, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1128, + "wrongOnly": 0, + "unresolved": 13, + "notExtracted": 0 + }, + "nosym": { + "clean": 174, + "falseEdge": 0 + }, + "external": { + "clean": 1380, + "falseEdge": 0 + }, + "edges": { + "correct": 1128, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1128, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1128, + "precision": 100, + "recall": 98.9, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 1105, + "nosym": 174, + "external": 1325, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 1092, + "wrongOnly": 0, + "unresolved": 13, + "notExtracted": 0 + }, + "nosym": { + "clean": 174, + "falseEdge": 0 + }, + "external": { + "clean": 1325, + "falseEdge": 0 + }, + "edges": { + "correct": 1092, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 1092, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 1092, + "precision": 100, + "recall": 98.8, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 36, + "nosym": 0, + "external": 55, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 36, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 55, + "falseEdge": 0 + }, + "edges": { + "correct": 36, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 36, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 36, + "precision": 100, + "recall": 100, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "entry_bench_test.go:242", + "callee": "Info", + "want": "entry.go#Entry.Info", + "got": [] + }, + { + "at": "entry_test.go:119", + "callee": "Error", + "want": "entry.go#Entry.Error", + "got": [] + }, + { + "at": "hooks.go:28", + "callee": "Fire", + "want": "hooks.go#Hook.Fire", + "got": [] + }, + { + "at": "hooks/slog/slog_test.go:188", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:531", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:532", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:533", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:534", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:535", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:536", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "logrus_test.go:537", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + }, + { + "at": "text_formatter_test.go:318", + "callee": "String", + "want": "logrus.go#Level.String", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-mux.first-run.json b/docs/eval/oracle/go-mux.first-run.json new file mode 100644 index 0000000..26695ae --- /dev/null +++ b/docs/eval/oracle/go-mux.first-run.json @@ -0,0 +1,212 @@ +{ + "all": { + "sites": { + "internal": 964, + "nosym": 100, + "external": 784, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 841, + "wrongOnly": 0, + "unresolved": 123, + "notExtracted": 0 + }, + "nosym": { + "clean": 100, + "falseEdge": 0 + }, + "external": { + "clean": 783, + "falseEdge": 1 + }, + "edges": { + "correct": 841, + "incorrect": 18, + "byTier": { + "resolved": { + "correct": 815, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 26, + "incorrect": 18, + "precision": 59.1 + } + } + }, + "emitted": 859, + "precision": 97.9, + "recall": 87.2, + "externalFalseEdgeRate": 0.1, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 964, + "nosym": 100, + "external": 784, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 841, + "wrongOnly": 0, + "unresolved": 123, + "notExtracted": 0 + }, + "nosym": { + "clean": 100, + "falseEdge": 0 + }, + "external": { + "clean": 783, + "falseEdge": 1 + }, + "edges": { + "correct": 841, + "incorrect": 18, + "byTier": { + "resolved": { + "correct": 815, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 26, + "incorrect": 18, + "precision": 59.1 + } + } + }, + "emitted": 859, + "precision": 97.9, + "recall": 87.2, + "externalFalseEdgeRate": 0.1, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [ + { + "at": "mux_test.go:3207", + "callee": "Write", + "want": "net/http", + "got": [ + "mux_test.go#TestA301ResponseWriter.Write (ambiguous)", + "old_test.go#ResponseRecorder.Write (ambiguous)" + ] + } + ], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "mux.go:157", + "callee": "Middleware", + "want": "middleware.go#middleware.Middleware", + "got": [] + }, + { + "at": "mux_test.go:60", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:69", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:78", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:87", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:96", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:105", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:114", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:123", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:132", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:142", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + }, + { + "at": "mux_test.go:152", + "callee": "Host", + "want": "route.go#Route.Host", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/go-mux.json b/docs/eval/oracle/go-mux.json new file mode 100644 index 0000000..ef3d52b --- /dev/null +++ b/docs/eval/oracle/go-mux.json @@ -0,0 +1,156 @@ +{ + "all": { + "sites": { + "internal": 964, + "nosym": 100, + "external": 784, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 958, + "wrongOnly": 0, + "unresolved": 6, + "notExtracted": 0 + }, + "nosym": { + "clean": 100, + "falseEdge": 0 + }, + "external": { + "clean": 784, + "falseEdge": 0 + }, + "edges": { + "correct": 958, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 958, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 958, + "precision": 100, + "recall": 99.4, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 964, + "nosym": 100, + "external": 784, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 958, + "wrongOnly": 0, + "unresolved": 6, + "notExtracted": 0 + }, + "nosym": { + "clean": 100, + "falseEdge": 0 + }, + "external": { + "clean": 784, + "falseEdge": 0 + }, + "edges": { + "correct": 958, + "incorrect": 0, + "byTier": { + "resolved": { + "correct": 958, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 958, + "precision": 100, + "recall": 99.4, + "externalFalseEdgeRate": 0, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 0, + "nosym": 0, + "external": 0, + "abstract": 0, + "unknown": 0 + }, + "internal": { + "found": 0, + "wrongOnly": 0, + "unresolved": 0, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 0, + "falseEdge": 0 + }, + "edges": { + "correct": 0, + "incorrect": 0, + "byTier": {} + }, + "emitted": 0, + "precision": null, + "recall": null, + "externalFalseEdgeRate": null, + "nosymFalseEdgeRate": null + }, + "examples": { + "wrongOnly": [], + "falseEdgeExternal": [], + "falseEdgeNosym": [], + "unresolved": [ + { + "at": "mux_test.go:1631", + "callee": "PathPrefix", + "want": "mux.go#Router.PathPrefix", + "got": [] + }, + { + "at": "mux_test.go:1632", + "callee": "PathPrefix", + "want": "mux.go#Router.PathPrefix", + "got": [] + }, + { + "at": "mux_test.go:1633", + "callee": "PathPrefix", + "want": "mux.go#Router.PathPrefix", + "got": [] + }, + { + "at": "mux_test.go:1634", + "callee": "PathPrefix", + "want": "mux.go#Router.PathPrefix", + "got": [] + }, + { + "at": "mux_test.go:1635", + "callee": "PathPrefix", + "want": "mux.go#Router.PathPrefix", + "got": [] + }, + { + "at": "mux_test.go:1636", + "callee": "Path", + "want": "mux.go#Router.Path", + "got": [] + } + ] + } +} diff --git a/docs/eval/oracle/py-flask.local-imports.json b/docs/eval/oracle/py-flask.local-imports.json new file mode 100644 index 0000000..4574b86 --- /dev/null +++ b/docs/eval/oracle/py-flask.local-imports.json @@ -0,0 +1,149 @@ +{ + "all": { + "sites": { + "internal": 1220, + "nosym": 24, + "external": 1074, + "abstract": 0, + "unknown": 1586 + }, + "internal": { + "found": 1132, + "wrongOnly": 1, + "unresolved": 76, + "notExtracted": 11 + }, + "nosym": { + "clean": 24, + "falseEdge": 0 + }, + "external": { + "clean": 1039, + "falseEdge": 35 + }, + "edges": { + "correct": 1132, + "incorrect": 69, + "byTier": { + "resolved": { + "correct": 1040, + "incorrect": 0, + "precision": 100 + }, + "probable": { + "correct": 72, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 20, + "incorrect": 69, + "precision": 22.5 + } + } + }, + "emitted": 1201, + "precision": 94.3, + "recall": 92.8, + "externalFalseEdgeRate": 3.3, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 368, + "nosym": 18, + "external": 667, + "abstract": 0, + "unknown": 300 + }, + "internal": { + "found": 332, + "wrongOnly": 0, + "unresolved": 25, + "notExtracted": 11 + }, + "nosym": { + "clean": 18, + "falseEdge": 0 + }, + "external": { + "clean": 635, + "falseEdge": 32 + }, + "edges": { + "correct": 332, + "incorrect": 64, + "byTier": { + "resolved": { + "correct": 288, + "incorrect": 0, + "precision": 100 + }, + "probable": { + "correct": 29, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 15, + "incorrect": 64, + "precision": 19 + } + } + }, + "emitted": 396, + "precision": 83.8, + "recall": 90.2, + "externalFalseEdgeRate": 4.8, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 852, + "nosym": 6, + "external": 407, + "abstract": 0, + "unknown": 1286 + }, + "internal": { + "found": 800, + "wrongOnly": 1, + "unresolved": 51, + "notExtracted": 0 + }, + "nosym": { + "clean": 6, + "falseEdge": 0 + }, + "external": { + "clean": 404, + "falseEdge": 3 + }, + "edges": { + "correct": 800, + "incorrect": 5, + "byTier": { + "resolved": { + "correct": 752, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 5, + "incorrect": 5, + "precision": 50 + }, + "probable": { + "correct": 43, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 805, + "precision": 99.4, + "recall": 93.9, + "externalFalseEdgeRate": 0.7, + "nosymFalseEdgeRate": 0 + } +} diff --git a/docs/eval/oracle/py-httpx.local-imports.json b/docs/eval/oracle/py-httpx.local-imports.json new file mode 100644 index 0000000..5a4adc0 --- /dev/null +++ b/docs/eval/oracle/py-httpx.local-imports.json @@ -0,0 +1,149 @@ +{ + "all": { + "sites": { + "internal": 2071, + "nosym": 1, + "external": 1540, + "abstract": 0, + "unknown": 576 + }, + "internal": { + "found": 1973, + "wrongOnly": 75, + "unresolved": 16, + "notExtracted": 7 + }, + "nosym": { + "clean": 1, + "falseEdge": 0 + }, + "external": { + "clean": 1386, + "falseEdge": 154 + }, + "edges": { + "correct": 1975, + "incorrect": 309, + "byTier": { + "resolved": { + "correct": 1839, + "incorrect": 10, + "precision": 99.5 + }, + "probable": { + "correct": 124, + "incorrect": 71, + "precision": 63.6 + }, + "ambiguous": { + "correct": 12, + "incorrect": 228, + "precision": 5 + } + } + }, + "emitted": 2284, + "precision": 86.5, + "recall": 95.3, + "externalFalseEdgeRate": 10, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 496, + "nosym": 1, + "external": 851, + "abstract": 0, + "unknown": 210 + }, + "internal": { + "found": 474, + "wrongOnly": 6, + "unresolved": 9, + "notExtracted": 7 + }, + "nosym": { + "clean": 1, + "falseEdge": 0 + }, + "external": { + "clean": 780, + "falseEdge": 71 + }, + "edges": { + "correct": 476, + "incorrect": 125, + "byTier": { + "resolved": { + "correct": 446, + "incorrect": 8, + "precision": 98.2 + }, + "probable": { + "correct": 22, + "incorrect": 2, + "precision": 91.7 + }, + "ambiguous": { + "correct": 8, + "incorrect": 115, + "precision": 6.5 + } + } + }, + "emitted": 601, + "precision": 79.2, + "recall": 95.6, + "externalFalseEdgeRate": 8.3, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 1575, + "nosym": 0, + "external": 689, + "abstract": 0, + "unknown": 366 + }, + "internal": { + "found": 1499, + "wrongOnly": 69, + "unresolved": 7, + "notExtracted": 0 + }, + "nosym": { + "clean": 0, + "falseEdge": 0 + }, + "external": { + "clean": 606, + "falseEdge": 83 + }, + "edges": { + "correct": 1499, + "incorrect": 184, + "byTier": { + "resolved": { + "correct": 1393, + "incorrect": 2, + "precision": 99.9 + }, + "probable": { + "correct": 102, + "incorrect": 69, + "precision": 59.6 + }, + "ambiguous": { + "correct": 4, + "incorrect": 113, + "precision": 3.4 + } + } + }, + "emitted": 1683, + "precision": 89.1, + "recall": 95.2, + "externalFalseEdgeRate": 12, + "nosymFalseEdgeRate": null + } +} diff --git a/docs/eval/oracle/py-requests.local-imports.json b/docs/eval/oracle/py-requests.local-imports.json new file mode 100644 index 0000000..f66ce09 --- /dev/null +++ b/docs/eval/oracle/py-requests.local-imports.json @@ -0,0 +1,149 @@ +{ + "all": { + "sites": { + "internal": 1076, + "nosym": 9, + "external": 1108, + "abstract": 0, + "unknown": 491 + }, + "internal": { + "found": 1033, + "wrongOnly": 0, + "unresolved": 35, + "notExtracted": 8 + }, + "nosym": { + "clean": 9, + "falseEdge": 0 + }, + "external": { + "clean": 1034, + "falseEdge": 74 + }, + "edges": { + "correct": 1033, + "incorrect": 163, + "byTier": { + "resolved": { + "correct": 971, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 53, + "incorrect": 161, + "precision": 24.8 + }, + "probable": { + "correct": 9, + "incorrect": 2, + "precision": 81.8 + } + } + }, + "emitted": 1196, + "precision": 86.4, + "recall": 96, + "externalFalseEdgeRate": 6.7, + "nosymFalseEdgeRate": 0 + }, + "src": { + "sites": { + "internal": 292, + "nosym": 8, + "external": 617, + "abstract": 0, + "unknown": 75 + }, + "internal": { + "found": 279, + "wrongOnly": 0, + "unresolved": 5, + "notExtracted": 8 + }, + "nosym": { + "clean": 8, + "falseEdge": 0 + }, + "external": { + "clean": 576, + "falseEdge": 41 + }, + "edges": { + "correct": 279, + "incorrect": 78, + "byTier": { + "resolved": { + "correct": 275, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 3, + "incorrect": 76, + "precision": 3.8 + }, + "probable": { + "correct": 1, + "incorrect": 2, + "precision": 33.3 + } + } + }, + "emitted": 357, + "precision": 78.2, + "recall": 95.5, + "externalFalseEdgeRate": 6.6, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 784, + "nosym": 1, + "external": 491, + "abstract": 0, + "unknown": 416 + }, + "internal": { + "found": 754, + "wrongOnly": 0, + "unresolved": 30, + "notExtracted": 0 + }, + "nosym": { + "clean": 1, + "falseEdge": 0 + }, + "external": { + "clean": 458, + "falseEdge": 33 + }, + "edges": { + "correct": 754, + "incorrect": 85, + "byTier": { + "resolved": { + "correct": 696, + "incorrect": 0, + "precision": 100 + }, + "ambiguous": { + "correct": 50, + "incorrect": 85, + "precision": 37 + }, + "probable": { + "correct": 8, + "incorrect": 0, + "precision": 100 + } + } + }, + "emitted": 839, + "precision": 89.9, + "recall": 96.2, + "externalFalseEdgeRate": 6.7, + "nosymFalseEdgeRate": 0 + } +} diff --git a/docs/eval/oracle/py-rich.local-imports.json b/docs/eval/oracle/py-rich.local-imports.json new file mode 100644 index 0000000..efce9d5 --- /dev/null +++ b/docs/eval/oracle/py-rich.local-imports.json @@ -0,0 +1,149 @@ +{ + "all": { + "sites": { + "internal": 6233, + "nosym": 46, + "external": 3065, + "abstract": 0, + "unknown": 491 + }, + "internal": { + "found": 6062, + "wrongOnly": 0, + "unresolved": 155, + "notExtracted": 16 + }, + "nosym": { + "clean": 44, + "falseEdge": 2 + }, + "external": { + "clean": 2839, + "falseEdge": 226 + }, + "edges": { + "correct": 6062, + "incorrect": 401, + "byTier": { + "resolved": { + "correct": 5647, + "incorrect": 5, + "precision": 99.9 + }, + "probable": { + "correct": 335, + "incorrect": 44, + "precision": 88.4 + }, + "ambiguous": { + "correct": 80, + "incorrect": 352, + "precision": 18.5 + } + } + }, + "emitted": 6463, + "precision": 93.8, + "recall": 97.3, + "externalFalseEdgeRate": 7.4, + "nosymFalseEdgeRate": 4.3 + }, + "src": { + "sites": { + "internal": 2848, + "nosym": 40, + "external": 2010, + "abstract": 0, + "unknown": 170 + }, + "internal": { + "found": 2679, + "wrongOnly": 0, + "unresolved": 153, + "notExtracted": 16 + }, + "nosym": { + "clean": 40, + "falseEdge": 0 + }, + "external": { + "clean": 1812, + "falseEdge": 198 + }, + "edges": { + "correct": 2679, + "incorrect": 335, + "byTier": { + "resolved": { + "correct": 2408, + "incorrect": 1, + "precision": 100 + }, + "probable": { + "correct": 217, + "incorrect": 43, + "precision": 83.5 + }, + "ambiguous": { + "correct": 54, + "incorrect": 291, + "precision": 15.7 + } + } + }, + "emitted": 3014, + "precision": 88.9, + "recall": 94.1, + "externalFalseEdgeRate": 9.9, + "nosymFalseEdgeRate": 0 + }, + "test": { + "sites": { + "internal": 3385, + "nosym": 6, + "external": 1055, + "abstract": 0, + "unknown": 321 + }, + "internal": { + "found": 3383, + "wrongOnly": 0, + "unresolved": 2, + "notExtracted": 0 + }, + "nosym": { + "clean": 4, + "falseEdge": 2 + }, + "external": { + "clean": 1027, + "falseEdge": 28 + }, + "edges": { + "correct": 3383, + "incorrect": 66, + "byTier": { + "resolved": { + "correct": 3239, + "incorrect": 4, + "precision": 99.9 + }, + "probable": { + "correct": 118, + "incorrect": 1, + "precision": 99.2 + }, + "ambiguous": { + "correct": 26, + "incorrect": 61, + "precision": 29.9 + } + } + }, + "emitted": 3449, + "precision": 98.1, + "recall": 99.9, + "externalFalseEdgeRate": 2.7, + "nosymFalseEdgeRate": 33.3 + } +} diff --git a/fixtures/go-basic/app.go b/fixtures/go-basic/app.go new file mode 100644 index 0000000..23dbf46 --- /dev/null +++ b/fixtures/go-basic/app.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + + "example.com/app/store" + "example.com/app/util" +) + +type Base struct{} + +func (b *Base) Ping() {} + +type App struct { + *Base + util.Logger + db *util.DB + items []Item +} + +type Item struct{} + +func (i Item) Run() {} + +func NewApp(db *util.DB) *App { return &App{db: db} } + +func (a *App) Start() { + a.Ping() + a.Log("start") + rows := a.db.Query("select") + for _, r := range rows { + r.Describe() + } + for _, it := range a.items { + it.Run() + } + a.items[0].Run() + a.helper() + run := func(a *App) { a.helper() } + run(a) + fmt.Println(store.Debug.String()) +} + +func (a *App) helper() {} + +func main() { + db, _ := util.Open("x") + app := NewApp(db) + app.Start() + NewApp(nil).Start() + new(App).Start() + store.Default.Get("k") + s := store.New() + s.Get("k") + fmt.Println(util.Platform()) +} diff --git a/fixtures/go-basic/app_test.go b/fixtures/go-basic/app_test.go new file mode 100644 index 0000000..024c11e --- /dev/null +++ b/fixtures/go-basic/app_test.go @@ -0,0 +1,8 @@ +package main + +import "testing" + +func TestStart(t *testing.T) { + a := NewApp(nil) + a.Start() +} diff --git a/fixtures/go-basic/go.mod b/fixtures/go-basic/go.mod new file mode 100644 index 0000000..925d4b7 --- /dev/null +++ b/fixtures/go-basic/go.mod @@ -0,0 +1,3 @@ +module example.com/app + +go 1.22 diff --git a/fixtures/go-basic/store/store.go b/fixtures/go-basic/store/store.go new file mode 100644 index 0000000..f792879 --- /dev/null +++ b/fixtures/go-basic/store/store.go @@ -0,0 +1,26 @@ +package store + +import "example.com/app/util" + +type Store interface { + Get(key string) string +} + +type memStore struct { + data map[string]string +} + +func (m *memStore) Get(key string) string { return m.data[key] } + +func New() Store { return &memStore{data: map[string]string{}} } + +var Default = New() + +type Level int + +const ( + Debug Level = iota + Info +) + +func (l Level) String() string { return util.Hash("level") } diff --git a/fixtures/go-basic/util/hash_unix.go b/fixtures/go-basic/util/hash_unix.go new file mode 100644 index 0000000..f1850fa --- /dev/null +++ b/fixtures/go-basic/util/hash_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package util + +func platform() string { return "unix" } + +func Platform() string { return platform() } diff --git a/fixtures/go-basic/util/hash_windows.go b/fixtures/go-basic/util/hash_windows.go new file mode 100644 index 0000000..3959ccd --- /dev/null +++ b/fixtures/go-basic/util/hash_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package util + +func platform() string { return "windows" } diff --git a/fixtures/go-basic/util/util.go b/fixtures/go-basic/util/util.go new file mode 100644 index 0000000..e255dae --- /dev/null +++ b/fixtures/go-basic/util/util.go @@ -0,0 +1,18 @@ +package util + +// Logger is embedded by other types, so its methods are promoted to them. +type Logger struct{} + +func (l *Logger) Log(msg string) {} + +type DB struct{} + +func (d *DB) Query(q string) []Row { return nil } + +type Row struct{ ID int } + +func (r Row) Describe() string { return "" } + +func Open(path string) (*DB, error) { return &DB{}, nil } + +func Hash(s string) string { return s } diff --git a/packages/analysis/src/blast.ts b/packages/analysis/src/blast.ts index 6d1a4b9..d2ab34e 100644 --- a/packages/analysis/src/blast.ts +++ b/packages/analysis/src/blast.ts @@ -7,6 +7,12 @@ export interface ImpactOptions { minConfidence?: Confidence; /** Also report files that import the changed files (default true). */ includeImporters?: boolean; + /** + * A file seed stands for every symbol it defines (default true: "this file changed, anything in it may have"). Pass + * false for a diff whose edit sat outside every symbol (an import line, a constant): the importers are still + * reported, but the symbols are not all counted as changed. + */ + expandFiles?: boolean; } export interface ImpactedNode { @@ -32,7 +38,7 @@ export interface ImpactResult { } const REVERSE_KINDS: EdgeKind[] = ['calls', 'extends']; -const TEST_RE = /(^|\/)(tests?|__tests__|spec|specs)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]*\.py$|_test\.py$/; +const TEST_RE = /(^|\/)(tests?|__tests__|spec|specs)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]*\.py$|_test\.py$|_test\.go$/; export const isTestPath = (p: string) => TEST_RE.test(p); @@ -56,7 +62,7 @@ export function blastRadius(graph: CodeGraph, seedIds: string[], opts: ImpactOpt const n = graph.node(id); if (!n) continue; seeds.add(id); - if (n.kind === 'file' && n.file) for (const s of graph.symbolsInFile(n.file)) seeds.add(s.id); + if (n.kind === 'file' && n.file && opts.expandFiles !== false) for (const s of graph.symbolsInFile(n.file)) seeds.add(s.id); } for (const s of seeds) { state.set(s, { conf: 'resolved', depth: 0 }); diff --git a/packages/analysis/src/diff.ts b/packages/analysis/src/diff.ts index 8e7fc44..c37f131 100644 --- a/packages/analysis/src/diff.ts +++ b/packages/analysis/src/diff.ts @@ -4,51 +4,86 @@ export interface FileChange { file: string; /** New-side line ranges, 1-based inclusive. */ ranges: [number, number][]; + /** The subset of `ranges` that only inserts lines (nothing existing was edited or removed there). */ + inserted: [number, number][]; + /** The whole file is new in this diff. */ + added?: boolean; + /** The file was deleted (it has no symbols in the new graph). */ + deleted?: boolean; } /** Parse `git diff -U0` output into per-file changed line ranges (new side). */ export function parseUnifiedDiff(text: string): FileChange[] { - const out = new Map(); + const out = new Map(); + let oldFile: string | null = null; + let oldIsNull = false; // only a `--- /dev/null` line says the file is new; a diff without header lines says nothing let file: string | null = null; for (const line of text.split('\n')) { - if (line.startsWith('+++ ')) { + if (line.startsWith('diff ')) { + oldFile = null; + oldIsNull = false; + } else if (line.startsWith('--- ')) { + const f = line.slice(4).trim(); + oldIsNull = f === '/dev/null'; + oldFile = oldIsNull ? null : f.replace(/^a\//, ''); + } else if (line.startsWith('+++ ')) { const f = line.slice(4).trim(); file = f === '/dev/null' ? null : f.replace(/^b\//, ''); - if (file && !out.has(file)) out.set(file, []); + if (file && !out.has(file)) out.set(file, { file, ranges: [], inserted: [], ...(oldIsNull ? { added: true } : {}) }); + else if (!file && oldFile && !out.has(oldFile)) out.set(oldFile, { file: oldFile, ranges: [], inserted: [], deleted: true }); } else if (line.startsWith('@@') && file) { - const m = /\+(\d+)(?:,(\d+))?/.exec(line); + const m = /-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?/.exec(line); if (!m) continue; - const start = Number(m[1]); - const len = m[2] === undefined ? 1 : Number(m[2]); - out.get(file)!.push(len === 0 ? [Math.max(start, 1), Math.max(start, 1)] : [start, start + len - 1]); + const oldLen = m[2] === undefined ? 1 : Number(m[2]); + const start = Number(m[3]); + const len = m[4] === undefined ? 1 : Number(m[4]); + const ch = out.get(file)!; + ch.ranges.push(len === 0 ? [Math.max(start, 1), Math.max(start, 1)] : [start, start + len - 1]); + if (oldLen === 0 && len > 0) ch.inserted.push([start, start + len - 1]); } } - return [...out].map(([file, ranges]) => ({ file, ranges })); + return [...out.values()]; } -/** Map changed line ranges to the innermost symbols they touch (falling back to the file node). */ -export function changedNodes(graph: CodeGraph, changes: FileChange[]): { nodes: GraphNode[]; unmapped: string[] } { +/** + * Map changed line ranges to the innermost symbols they touch (falling back to the file node). `created` holds symbols + * that this diff adds in full (new files, or a function inserted between others): nothing that exists today calls them, + * so they cannot break anyone and are kept out of `nodes`. + */ +export function changedNodes(graph: CodeGraph, changes: FileChange[]): { nodes: GraphNode[]; created: GraphNode[]; unmapped: string[] } { const nodes = new Map(); + const created = new Map(); const unmapped: string[] = []; for (const ch of changes) { + if (ch.deleted) continue; const fnode = graph.node(fileId(ch.file)); if (!fnode) { unmapped.push(ch.file); continue; } const syms = graph.symbolsInFile(ch.file); + const isNew = (n: GraphNode) => !!ch.added || ch.inserted.some(([a, b]) => a <= n.startLine! && b >= n.endLine!); let anySym = false; for (const [a, b] of ch.ranges) { const hit = syms.filter((s) => s.startLine! <= b && s.endLine! >= a); // keep only innermost: drop A if another hit lies strictly inside A const leaf = hit.filter((s) => !hit.some((o) => o !== s && o.startLine! >= s.startLine! && o.endLine! <= s.endLine! && (o.startLine! > s.startLine! || o.endLine! < s.endLine!))); - for (const s of leaf) { - nodes.set(s.id, s); + for (const leafNode of leaf) { + // An edit on a line that declares a local variable changes the function around it: a variable has no callers. + let s = leafNode; + while (s.kind === 'variable' && s.parent && s.parent !== fnode.id && graph.node(s.parent)) s = graph.node(s.parent)!; + if (isNew(s)) { + // report the outermost symbol that is new in full (a whole inserted function, not its parameters) + while (s.parent && s.parent !== fnode.id && graph.node(s.parent) && isNew(graph.node(s.parent)!)) s = graph.node(s.parent)!; + created.set(s.id, s); + } else nodes.set(s.id, s); anySym = true; } - if (!leaf.length) nodes.set(fnode.id, fnode); // module-level statement + if (!leaf.length && !ch.added) nodes.set(fnode.id, fnode); // module-level statement in an existing file } - if (!anySym && !nodes.has(fnode.id)) nodes.set(fnode.id, fnode); + if (!anySym && !ch.added && !nodes.has(fnode.id)) nodes.set(fnode.id, fnode); } - return { nodes: [...nodes.values()], unmapped }; + // A symbol is only "created" when nothing about it was also edited; a modified method inside a new-looking range stays modified. + for (const id of nodes.keys()) created.delete(id); + return { nodes: [...nodes.values()], created: [...created.values()], unmapped }; } diff --git a/packages/analysis/test/analysis.test.ts b/packages/analysis/test/analysis.test.ts index 93f76c6..ba07a06 100644 --- a/packages/analysis/test/analysis.test.ts +++ b/packages/analysis/test/analysis.test.ts @@ -64,6 +64,17 @@ describe('slice', () => { }); }); +describe('file seeds', () => { + it('expand to every symbol the file defines unless told not to', () => { + const file = resolveTargets(g, 'src/math.ts').map((n) => n.id); + const all = blastRadius(g, file); + const fileOnly = blastRadius(g, file, { expandFiles: false }); + expect(all.seeds.length).toBeGreaterThan(fileOnly.seeds.length); + expect(fileOnly.seeds).toEqual(file); + expect(fileOnly.files.some((f) => f.path === 'src/app.ts')).toBe(true); // importers are still reported + }); +}); + describe('diff mapping', () => { it('maps changed line ranges to the innermost symbol', () => { const diff = ['--- a/src/math.ts', '+++ b/src/math.ts', '@@ -6,0 +7,1 @@ ', '+ // touch'].join('\n'); @@ -72,12 +83,50 @@ describe('diff mapping', () => { }); it('attributes a module-level variable edit to that variable, not the whole file', () => { - const diff = ['+++ b/src/app.ts', '@@ -5,0 +6,1 @@'].join('\n'); // `const svc = new UserService(...)` + const diff = ['+++ b/src/app.ts', '@@ -6 +6 @@'].join('\n'); // `const svc = new UserService(...)` rewritten const { nodes } = changedNodes(g, parseUnifiedDiff(diff)); expect(nodes.map((n) => n.kind)).toEqual(['variable']); expect(nodes[0].qualifiedName).toBe('svc'); }); + it('treats a symbol inserted in full as new, so it is not a change that can break callers', () => { + // src/math.ts: `mul` starts on line 6; a pure insertion covering all of it is a new function + const mul = g.nodes('function').find((n) => n.qualifiedName === 'mul')!; + const diff = ['--- a/src/math.ts', '+++ b/src/math.ts', `@@ -${mul.startLine! - 1},0 +${mul.startLine},${mul.endLine! - mul.startLine! + 1} @@`].join('\n'); + const r = changedNodes(g, parseUnifiedDiff(diff)); + expect(r.created.map((n) => n.qualifiedName)).toEqual(['mul']); + expect(r.nodes.map((n) => n.qualifiedName)).not.toContain('mul'); + }); + + it('attributes an edit to a local variable declaration to the function around it', () => { + const mul = g.nodes('function').find((n) => n.qualifiedName === 'mul')!; + const local = g.symbolsInFile('src/math.ts').find((n) => n.kind === 'variable' && n.parent === mul.id)!; + const diff = ['--- a/src/math.ts', '+++ b/src/math.ts', `@@ -${local.startLine} +${local.startLine} @@`].join('\n'); + expect(changedNodes(g, parseUnifiedDiff(diff)).nodes.map((n) => n.qualifiedName)).toEqual(['mul']); + }); + + it('keeps a function modified when lines are inserted inside it', () => { + const mul = g.nodes('function').find((n) => n.qualifiedName === 'mul')!; + const diff = ['--- a/src/math.ts', '+++ b/src/math.ts', `@@ -${mul.startLine},0 +${mul.startLine! + 1},1 @@`].join('\n'); + const r = changedNodes(g, parseUnifiedDiff(diff)); + expect(r.nodes.map((n) => n.qualifiedName)).toEqual(['mul']); + expect(r.created).toHaveLength(0); + }); + + it('treats every symbol of an added file as new and ignores deleted files', () => { + const diff = [ + 'diff --git a/src/math.ts b/src/math.ts', '--- /dev/null', '+++ b/src/math.ts', '@@ -0,0 +1,20 @@', + 'diff --git a/old.ts b/old.ts', '--- a/old.ts', '+++ /dev/null', '@@ -1,3 +0,0 @@', + ].join('\n'); + const changes = parseUnifiedDiff(diff); + expect(changes.find((c) => c.file === 'src/math.ts')?.added).toBe(true); + expect(changes.find((c) => c.file === 'old.ts')?.deleted).toBe(true); + const r = changedNodes(g, changes); + expect(r.nodes).toHaveLength(0); + expect(r.created.length).toBeGreaterThan(0); + expect(r.unmapped).not.toContain('old.ts'); + }); + it('falls back to the file when the line matches no tracked symbol', () => { const diff = ['+++ b/src/app.ts', '@@ -0,0 +1,1 @@'].join('\n'); // an import line const { nodes } = changedNodes(g, parseUnifiedDiff(diff)); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index d015181..8859ed6 100755 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,16 +1,19 @@ import { parseArgs } from 'node:util'; import { cmdCycles, cmdImpact, cmdIndex, cmdServe, cmdSlice, cmdVars, type Flags } from './commands'; +import { cmdMcp } from './mcp'; const HELP = `codegraph — AST dependency, call-graph and blast-radius engine Usage: codegraph index [path|owner/repo|github-url] [-o graph.json] codegraph impact [--repo path] [--depth N] [--min-confidence resolved|probable|ambiguous] [--json] + (impact also takes --format markdown for a pull request comment) codegraph impact --diff [--range main...HEAD] [--repo path] blast radius of a git diff (default: working tree vs HEAD) codegraph slice [--callers N] [--callees N] [--print] codegraph vars | local variables, or one variable's dataflow lineage codegraph cycles [path] codegraph serve [path] [--port 4173] + codegraph mcp [path] MCP server over stdio, so AI agents can ask what a change touches Targets: src/a.ts | src/a.ts#Foo.bar | src/a.ts:42 | Foo.bar Any command also accepts --graph to skip re-indexing. @@ -33,6 +36,7 @@ const { positionals, values } = parseArgs({ diff: { type: 'boolean' }, range: { type: 'string' }, json: { type: 'boolean' }, + format: { type: 'string' }, print: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, }, @@ -47,6 +51,7 @@ const commands: Record Promise> = { vars: cmdVars, cycles: cmdCycles, serve: cmdServe, + mcp: cmdMcp, }; if (!cmd || flags.help || !commands[cmd]) { diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 6aa64dd..6b3e55c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -10,6 +10,7 @@ import { blastRadius, changedNodes, importCycles, parseUnifiedDiff, resolveTargets, slice, summarizeImpact, variableLineage, } from '@codegraph/analysis'; import { resolveRepo } from './repo'; +import { impactMarkdown, symbolLabel } from './report'; const exec = promisify(execFile); @@ -39,6 +40,17 @@ async function load(repoArg: string, flags: Flags): Promise<{ graph: CodeGraph; return { graph, root }; } +/** How the project runs its JavaScript tests, from package.json. */ +async function jsRunner(root: string): Promise<'vitest' | 'jest' | undefined> { + try { + const pkg = JSON.parse(await fs.readFile(join(root, 'package.json'), 'utf8')); + const all = { ...pkg.dependencies, ...pkg.devDependencies }; + return 'vitest' in all ? 'vitest' : 'jest' in all ? 'jest' : undefined; + } catch { + return undefined; + } +} + // Default to 'probable': ambiguous edges are, by design, low-confidence name-only // matches, and a blast radius or slice that defaults to including them is more // likely to mislead than help. Pass --min-confidence ambiguous to see everything. @@ -65,13 +77,18 @@ export async function cmdImpact(pos: string[], flags: Flags) { const repo = typeof flags.repo === 'string' ? flags.repo : '.'; const { graph, root } = await load(repo, flags); let seeds: string[] = []; + let diffInfo: { changedFiles: Set; created: number; deletedFiles: string[] } | undefined; if (flags.diff) { const range = [typeof flags.range === 'string' ? flags.range : 'HEAD']; const { stdout } = await exec('git', ['diff', '-U0', ...range], { cwd: root, maxBuffer: 64 * 1024 * 1024 }); - const { nodes, unmapped } = changedNodes(graph, parseUnifiedDiff(stdout)); + const changes = parseUnifiedDiff(stdout); + const { nodes, created, unmapped } = changedNodes(graph, changes); seeds = nodes.map((n) => n.id); + diffInfo = { changedFiles: new Set(changes.filter((c) => !c.deleted).map((c) => c.file)), created: created.length, deletedFiles: changes.filter((c) => c.deleted).map((c) => c.file) }; if (unmapped.length) console.error(`${color.dim}not in graph (non-source or new): ${unmapped.slice(0, 8).join(', ')}${color.off}`); - if (!seeds.length) return console.log('No changed source symbols found in diff.'); + if (!seeds.length && !(flags.format === 'markdown' && created.length)) { + return console.log(flags.format === 'markdown' ? '' : 'No changed source symbols found in diff.'); + } } else { if (!pos[0]) throw new Error('usage: codegraph impact | --diff [range]'); const found = resolveTargets(graph, pos[0]); @@ -82,7 +99,12 @@ export async function cmdImpact(pos: string[], flags: Flags) { const res = blastRadius(graph, seeds, { maxDepth: flags.depth ? Number(flags.depth) : undefined, minConfidence: minConf(flags), + expandFiles: !flags.diff, }); + if (flags.format === 'markdown') { + console.log(impactMarkdown(graph, res, { ...diffInfo, jsRunner: await jsRunner(root) })); + return; + } if (flags.json) { console.log(JSON.stringify({ summary: summarizeImpact(graph, res), impacted: res.impacted.map((i) => ({ ...i, label: label(graph, i.id) })), files: res.files, tests: res.tests, testsTransitive: res.testsTransitive }, null, 2)); return; diff --git a/packages/cli/src/mcp.ts b/packages/cli/src/mcp.ts new file mode 100644 index 0000000..e02847c --- /dev/null +++ b/packages/cli/src/mcp.ts @@ -0,0 +1,205 @@ +// A small Model Context Protocol server (JSON-RPC over stdio) so AI agents can ask what a change touches. +// Hand-written on purpose: it needs three methods, and this keeps the published package free of an SDK dependency. +import { execFile } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { promisify } from 'node:util'; +import type { CodeGraph, Confidence } from '@codegraph/core'; +import { indexRepo } from '@codegraph/extractors'; +import { blastRadius, changedNodes, parseUnifiedDiff, resolveTargets, summarizeImpact } from '@codegraph/analysis'; +import { resolveRepo } from './repo'; +import { symbolLabel } from './report'; +import type { Flags } from './commands'; + +const exec = promisify(execFile); +const VERSION = '0.2.0'; + +interface Tool { + name: string; + description: string; + inputSchema: Record; + run(args: Record): Promise; +} + +const confidence = (v: unknown): Confidence => (v === 'resolved' || v === 'probable' || v === 'ambiguous' ? v : 'probable'); +const str = (v: unknown) => (typeof v === 'string' ? v : ''); +const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d); +const where = (g: CodeGraph, id: string) => { + const n = g.node(id); + return n?.startLine ? `${symbolLabel(g, id)} (line ${n.startLine})` : symbolLabel(g, id); +}; + +export function buildTools(getGraph: () => Promise<{ graph: CodeGraph; root: string }>, reindex: () => void): Tool[] { + return [ + { + name: 'codegraph_impact', + description: + 'What could break if this function, class or file changes? Returns the callers that reach it (with how sure the link is), the files involved and the test files to run. Use before editing shared code.', + inputSchema: { + type: 'object', + required: ['target'], + properties: { + target: { type: 'string', description: 'A file (src/a.ts), a symbol (src/a.ts#Foo.bar or Foo.bar) or a line (src/a.ts:42).' }, + depth: { type: 'number', description: 'How many hops to follow (default 4).' }, + min_confidence: { type: 'string', enum: ['resolved', 'probable', 'ambiguous'], description: 'Weakest link to include (default probable).' }, + }, + }, + async run(a) { + const { graph } = await getGraph(); + const found = resolveTargets(graph, str(a.target)); + if (!found.length) return `No symbol or file matches "${str(a.target)}". Try codegraph_find.`; + const res = blastRadius(graph, found.map((n) => n.id), { maxDepth: num(a.depth, 4), minConfidence: confidence(a.min_confidence) }); + const s = summarizeImpact(graph, res); + const lines = [ + `Changing ${s.changed.slice(0, 5).join(', ')}${s.changed.length > 5 ? ` (+${s.changed.length - 5})` : ''} can reach ${s.impactedSymbols} symbols in ${s.files} files ` + + `(${s.byConfidence.resolved} proven, ${s.byConfidence.probable} probable, ${s.byConfidence.ambiguous} name-match guesses).`, + ]; + for (const i of res.impacted.slice(0, 30)) lines.push(` hop ${i.depth} ${i.confidence.padEnd(9)} ${where(graph, i.id)}`); + if (res.impacted.length > 30) lines.push(` ...and ${res.impacted.length - 30} more`); + if (res.tests.length) lines.push('Tests to run:', ...res.tests.slice(0, 20).map((t) => ` ${t}`)); + return lines.join('\n'); + }, + }, + { + name: 'codegraph_diff_impact', + description: 'What could the current uncommitted changes (or a git range such as main...HEAD) break? Maps the diff onto symbols and returns the combined blast radius and tests to run.', + inputSchema: { type: 'object', properties: { range: { type: 'string', description: 'Git diff range. Default: working tree against HEAD.' } } }, + async run(a) { + const { graph, root } = await getGraph(); + const range = str(a.range) || 'HEAD'; + if (!/^[\w./~^@:-]+(\.\.\.?[\w./~^@:-]+)?$/.test(range)) return `Not a valid git range: ${range}`; + const { stdout } = await exec('git', ['diff', '-U0', range], { cwd: root, maxBuffer: 64 * 1024 * 1024 }); + const { nodes } = changedNodes(graph, parseUnifiedDiff(stdout)); + if (!nodes.length) return 'No changed source symbols in that diff.'; + const res = blastRadius(graph, nodes.map((n) => n.id), { maxDepth: 4, minConfidence: 'probable', expandFiles: false }); + const s = summarizeImpact(graph, res); + const lines = [`${nodes.length} changed symbols can reach ${s.impactedSymbols} symbols in ${s.files} files (${s.byConfidence.resolved} proven, ${s.byConfidence.probable} probable).`]; + for (const i of res.impacted.slice(0, 30)) lines.push(` hop ${i.depth} ${i.confidence.padEnd(9)} ${where(graph, i.id)}`); + if (res.tests.length) lines.push('Tests to run:', ...res.tests.slice(0, 20).map((t) => ` ${t}`)); + return lines.join('\n'); + }, + }, + { + name: 'codegraph_find', + description: 'Find functions, methods and classes by name (substring match), most-called first. Use to get the exact target for the other tools.', + inputSchema: { type: 'object', required: ['query'], properties: { query: { type: 'string' }, limit: { type: 'number' } } }, + async run(a) { + const { graph } = await getGraph(); + const q = str(a.query).toLowerCase(); + if (!q) return 'Give a name to search for.'; + const hits = graph + .nodes(['function', 'method', 'class']) + .filter((n) => (n.qualifiedName ?? n.name).toLowerCase().includes(q)) + .map((n) => ({ n, callers: graph.incoming(n.id, ['calls']).length })) + .sort((x, y) => y.callers - x.callers || (x.n.qualifiedName ?? x.n.name).length - (y.n.qualifiedName ?? y.n.name).length) + .slice(0, num(a.limit, 15)); + return hits.length ? hits.map((h) => `${where(graph, h.n.id)} ${h.n.kind}, ${h.callers} callers`).join('\n') : `Nothing named like "${str(a.query)}".`; + }, + }, + { + name: 'codegraph_callers_callees', + description: 'Who calls this symbol, and what does it call? One hop each way, with how sure each link is.', + inputSchema: { type: 'object', required: ['target'], properties: { target: { type: 'string' } } }, + async run(a) { + const { graph } = await getGraph(); + const found = resolveTargets(graph, str(a.target)).slice(0, 3); + if (!found.length) return `No symbol or file matches "${str(a.target)}".`; + const out: string[] = []; + for (const n of found) { + out.push(where(graph, n.id)); + const callers = graph.incoming(n.id, ['calls']); + const callees = graph.outgoing(n.id, ['calls']); + out.push(` called by ${callers.length}:`, ...callers.slice(0, 15).map((e) => ` ${e.confidence.padEnd(9)} ${where(graph, e.from)}`)); + out.push(` calls ${callees.length}:`, ...callees.slice(0, 15).map((e) => ` ${e.confidence.padEnd(9)} ${where(graph, e.to)}`)); + } + return out.join('\n'); + }, + }, + { + name: 'codegraph_overview', + description: 'Size of the indexed repository and the most-called symbols, which are the riskiest to change.', + inputSchema: { type: 'object', properties: {} }, + async run() { + const { graph, root } = await getGraph(); + const top = graph + .nodes(['function', 'method', 'class']) + .map((n) => ({ n, c: graph.incoming(n.id, ['calls']).length })) + .sort((x, y) => y.c - x.c) + .slice(0, 10); + return [`${root}: ${graph.size.nodes} nodes, ${graph.size.edges} edges.`, 'Most-called (riskiest to change):', ...top.map((t) => ` ${t.c} callers ${where(graph, t.n.id)}`)].join('\n'); + }, + }, + { + name: 'codegraph_reindex', + description: 'Re-read the repository after files changed on disk. Other tools use the index built at the first call.', + inputSchema: { type: 'object', properties: {} }, + async run() { + reindex(); + const { graph } = await getGraph(); + return `Re-indexed: ${graph.size.nodes} nodes, ${graph.size.edges} edges.`; + }, + }, + ]; +} + +interface Rpc { + jsonrpc: '2.0'; + id?: number | string | null; + method?: string; + params?: Record; +} + +/** Handles one JSON-RPC message; returns the response, or undefined for notifications. */ +export async function handle(tools: Tool[], msg: Rpc): Promise { + const reply = (result: unknown) => ({ jsonrpc: '2.0', id: msg.id, result }); + const fail = (code: number, message: string) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } }); + if (msg.id === undefined) return undefined; // notification (e.g. notifications/initialized) + switch (msg.method) { + case 'initialize': + return reply({ + protocolVersion: typeof msg.params?.protocolVersion === 'string' ? msg.params.protocolVersion : '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'codegraph', version: VERSION }, + instructions: 'Call codegraph_impact before changing shared code, and codegraph_diff_impact before committing, to see which callers and tests a change can reach.', + }); + case 'ping': + return reply({}); + case 'tools/list': + return reply({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }); + case 'tools/call': { + const tool = tools.find((t) => t.name === msg.params?.name); + if (!tool) return fail(-32602, `Unknown tool: ${String(msg.params?.name)}`); + try { + const text = await tool.run((msg.params?.arguments as Record) ?? {}); + return reply({ content: [{ type: 'text', text }] }); + } catch (e) { + return reply({ isError: true, content: [{ type: 'text', text: `error: ${(e as Error).message}` }] }); + } + } + default: + return fail(-32601, `Method not found: ${String(msg.method)}`); + } +} + +export async function cmdMcp(pos: string[], _flags: Flags) { + const root = await resolveRepo(pos[0] ?? '.'); + let cached: Promise<{ graph: CodeGraph; root: string }> | undefined; + const getGraph = () => + (cached ??= indexRepo(root).then(({ graph, stats }) => { + console.error(`codegraph: indexed ${stats.files} files, ${stats.symbols} symbols`); + return { graph, root }; + })); + const tools = buildTools(getGraph, () => (cached = undefined)); + const rl = createInterface({ input: process.stdin }); + for await (const line of rl) { + if (!line.trim()) continue; + let msg: Rpc; + try { + msg = JSON.parse(line) as Rpc; + } catch { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n'); + continue; + } + const res = await handle(tools, msg); + if (res) process.stdout.write(JSON.stringify(res) + '\n'); + } +} diff --git a/packages/cli/src/report.ts b/packages/cli/src/report.ts new file mode 100644 index 0000000..2863665 --- /dev/null +++ b/packages/cli/src/report.ts @@ -0,0 +1,90 @@ +import type { CodeGraph, Confidence } from '@codegraph/core'; +import { summarizeImpact, type ImpactResult } from '@codegraph/analysis'; + +export const COMMENT_MARKER = ''; + +export const symbolLabel = (g: CodeGraph, id: string) => { + const n = g.node(id); + if (!n) return id; + return n.kind === 'file' ? n.file! : `${n.file}#${n.qualifiedName}`; +}; + +const DOT: Record = { resolved: '🟢', probable: '🟡', ambiguous: '🔴' }; + +export interface MarkdownOptions { + limit?: number; + title?: string; + /** Files this change edits: impact inside them is the change itself, not a risk to someone else. */ + changedFiles?: Set; + /** Symbols the change adds in full, and files it deletes (whose symbols no longer exist to be checked). */ + created?: number; + deletedFiles?: string[]; + /** How the project runs its JavaScript tests, when it can be told from package.json. */ + jsRunner?: 'vitest' | 'jest'; +} + +/** Commands that run the given test files, one line per ecosystem. */ +export function testCommands(tests: string[], jsRunner?: 'vitest' | 'jest'): string[] { + const out: string[] = []; + const go = [...new Set(tests.filter((t) => t.endsWith('_test.go')).map((t) => (t.includes('/') ? './' + t.slice(0, t.lastIndexOf('/')) : '.')))]; + if (go.length) out.push(`go test ${go.join(' ')}`); + const py = tests.filter((t) => t.endsWith('.py')); + if (py.length) out.push(`pytest ${py.join(' ')}`); + const js = tests.filter((t) => /\.[cm]?[jt]sx?$/.test(t)); + if (js.length && jsRunner) out.push(jsRunner === 'vitest' ? `npx vitest run ${js.join(' ')}` : `npx jest ${js.join(' ')}`); + return out; +} + +/** + * A pull request comment: how far a change reaches, how sure we are, and which tests to run. The headline counts only + * code outside the files the change edits, because that is the code whose author did not look at the change. + */ +export function impactMarkdown(g: CodeGraph, r: ImpactResult, opts: MarkdownOptions = {}): string { + const limit = opts.limit ?? 10; + const s = summarizeImpact(g, r); + const inside = opts.changedFiles ?? new Set(); + const outside = r.impacted.filter((i) => { + const f = g.node(i.id)?.file; + return !!f && !inside.has(f); + }); + const files = new Set(outside.map((i) => g.node(i.id)?.file)); + const by = { resolved: 0, probable: 0, ambiguous: 0 } as Record; + for (const i of outside) by[i.confidence]++; + const out: string[] = [COMMENT_MARKER, `### ${opts.title ?? 'CodeGraph: what this change can reach'}`, '']; + + const fileSeeds = r.seeds.filter((id) => g.node(id)?.kind === 'file').length; + const changed = r.seeds.length - fileSeeds; // existing symbols edited; a module-level edit is counted apart + if (!changed && fileSeeds && !outside.length) { + out.push(`This change edits top-level code (imports, constants, statements) in ${fileSeeds} file${fileSeeds === 1 ? '' : 's'} and no function or class in them.${opts.created ? ` It also adds ${opts.created} new symbols.` : ''}`); + } else if (!changed) { + out.push(`This change only adds new code${opts.created ? ` (${opts.created} new symbols)` : ''}. Nothing that exists today calls it, so there is nothing to break.`); + } else if (!outside.length) { + out.push(`This change edits ${changed} existing symbol${changed === 1 ? '' : 's'}, and nothing outside the files it edits calls ${changed === 1 ? 'it' : 'them'}.`); + } else { + out.push( + `**${outside.length} symbols in ${files.size} files** you did not edit could be affected by the ${changed} existing symbol${changed === 1 ? '' : 's'} this change edits ` + + `(🟢 ${by.resolved} proven · 🟡 ${by.probable} probable · 🔴 ${by.ambiguous} guessed by name).`, + ); + const shown = outside.slice(0, limit); + out.push('', '
Callers outside this change', '', '| | Hops | Symbol |', '|---|---|---|'); + for (const i of shown) out.push(`| ${DOT[i.confidence]} | ${i.depth} | \`${symbolLabel(g, i.id)}\` |`); + if (outside.length > shown.length) out.push(`| | | …and ${outside.length - shown.length} more |`); + out.push('', '
'); + } + const notes: string[] = []; + if (fileSeeds && changed) notes.push(`edits top-level code in ${fileSeeds} file${fileSeeds === 1 ? '' : 's'}`); + if (changed && opts.created) notes.push(`adds ${opts.created} new symbol${opts.created === 1 ? '' : 's'}`); + if (opts.deletedFiles?.length) notes.push(`deletes ${opts.deletedFiles.length} file${opts.deletedFiles.length === 1 ? '' : 's'} (references to what they defined are not checked)`); + if (notes.length) out.push('', `Also: ${notes.join('; ')}.`); + + if (r.tests.length) { + const cmds = testCommands(r.tests, opts.jsRunner); + out.push('', `
${r.tests.length} test file${r.tests.length === 1 ? '' : 's'} to run`, ''); + if (cmds.length) out.push('```bash', ...cmds, '```', ''); + out.push(...r.tests.slice(0, 30).map((t) => `- \`${t}\``)); + if (r.tests.length > 30) out.push(`- …and ${r.tests.length - 30} more`); + out.push('', '
'); + } + out.push('', `Static analysis of calls and imports (TypeScript, JavaScript, Python, Go). 🟡 and 🔴 links are name matches, so check them before relying on them. Dynamic calls are invisible to it. [CodeGraph](https://github.com/harsharajkumar-273/CodeGraph)`); + return out.join('\n'); +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..10dad04 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { CodeGraph } from '@codegraph/core'; +import { indexRepo } from '@codegraph/extractors'; +import { blastRadius, resolveTargets } from '@codegraph/analysis'; +import { fixture } from '../../extractors/test/helpers'; +import { buildTools, handle } from '../src/mcp'; +import { COMMENT_MARKER, impactMarkdown, testCommands } from '../src/report'; + +let graph: CodeGraph; +let tools: ReturnType; +beforeAll(async () => { + graph = (await indexRepo(fixture('ts-basic'))).graph; + tools = buildTools(async () => ({ graph, root: fixture('ts-basic') }), () => {}); +}); + +const call = async (name: string, args: object) => { + const res = (await handle(tools, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } })) as { result: { content: { text: string }[]; isError?: boolean } }; + return res.result; +}; + +describe('MCP server', () => { + it('answers initialize and lists its tools', async () => { + const init = (await handle(tools, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } })) as { result: { protocolVersion: string; capabilities: { tools: object } } }; + expect(init.result.protocolVersion).toBe('2025-03-26'); + expect(init.result.capabilities.tools).toEqual({}); + const list = (await handle(tools, { jsonrpc: '2.0', id: 2, method: 'tools/list' })) as { result: { tools: { name: string; inputSchema: { type: string } }[] } }; + expect(list.result.tools.map((t) => t.name)).toEqual(expect.arrayContaining(['codegraph_impact', 'codegraph_diff_impact', 'codegraph_find', 'codegraph_callers_callees', 'codegraph_overview'])); + for (const t of list.result.tools) expect(t.inputSchema.type).toBe('object'); + }); + + it('does not answer notifications', async () => { + expect(await handle(tools, { jsonrpc: '2.0', method: 'notifications/initialized' })).toBeUndefined(); + }); + + it('reports the callers of a changed function', async () => { + const r = await call('codegraph_impact', { target: 'src/math.ts#add' }); + expect(r.content[0].text).toMatch(/can reach \d+ symbols/); + expect(r.content[0].text).toContain('resolved'); + }); + + it('says so when nothing matches, and rejects unknown tools and methods', async () => { + expect((await call('codegraph_impact', { target: 'nope.ts#x' })).content[0].text).toMatch(/No symbol or file matches/); + const bad = (await handle(tools, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'nope' } })) as { error: { code: number } }; + expect(bad.error.code).toBe(-32602); + const unk = (await handle(tools, { jsonrpc: '2.0', id: 4, method: 'resources/list' })) as { error: { code: number } }; + expect(unk.error.code).toBe(-32601); + }); + + it('refuses a git range that could be a flag', async () => { + expect((await call('codegraph_diff_impact', { range: '--output=/tmp/x' })).content[0].text).toMatch(/Not a valid git range/); + }); + + it('finds symbols by name, most-called first', async () => { + const r = await call('codegraph_find', { query: 'add' }); + expect(r.content[0].text).toContain('src/math.ts#add'); + }); +}); + +describe('pull request comment', () => { + it('summarizes the blast radius with confidence and tests', () => { + const seeds = resolveTargets(graph, 'src/math.ts#add').map((n) => n.id); + const md = impactMarkdown(graph, blastRadius(graph, seeds, { minConfidence: 'probable' })); + expect(md.startsWith(COMMENT_MARKER)).toBe(true); + expect(md).toMatch(/symbols in \d+ files?/); + expect(md).toContain('🟢'); + expect(md).toContain('name matches'); + }); + + it('handles a change nobody calls', () => { + const seeds = resolveTargets(graph, 'src/app.ts#main').map((n) => n.id); + const md = impactMarkdown(graph, blastRadius(graph, seeds, { minConfidence: 'probable' })); + expect(md).toContain(COMMENT_MARKER); + }); + + it('counts only callers outside the files the change edits', () => { + const seeds = resolveTargets(graph, 'src/math.ts#add').map((n) => n.id); + const r = blastRadius(graph, seeds, { minConfidence: 'probable' }); + const everyFile = new Set(r.impacted.map((i) => graph.node(i.id)?.file as string).concat('src/math.ts')); + const md = impactMarkdown(graph, r, { changedFiles: everyFile }); + expect(md).toMatch(/nothing outside the files it edits calls/); + expect(md).not.toMatch(/\*\*\d+ symbols in/); + }); + + it('says plainly when a change only adds code', () => { + const empty = blastRadius(graph, [], { minConfidence: 'probable' }); + const md = impactMarkdown(graph, empty, { created: 12 }); + expect(md).toMatch(/only adds new code \(12 new symbols\)/); + }); + + it('notes new symbols and deleted files next to a real change', () => { + const seeds = resolveTargets(graph, 'src/math.ts#add').map((n) => n.id); + const md = impactMarkdown(graph, blastRadius(graph, seeds, { minConfidence: 'probable' }), { changedFiles: new Set(['src/math.ts']), created: 3, deletedFiles: ['old.ts'] }); + expect(md).toMatch(/adds 3 new symbols; deletes 1 file/); + }); +}); + +describe('test commands', () => { + it('gives one command per ecosystem, and Go packages rather than files', () => { + expect(testCommands(['a/x_test.go', 'a/y_test.go', 'b/z_test.go', 'tests/test_a.py', 'web/a.test.ts'], 'vitest')).toEqual([ + 'go test ./a ./b', + 'pytest tests/test_a.py', + 'npx vitest run web/a.test.ts', + ]); + }); + + it('offers no JavaScript command when the runner is unknown', () => { + expect(testCommands(['web/a.test.ts'])).toEqual([]); + }); +}); diff --git a/packages/extractors/src/extract.ts b/packages/extractors/src/extract.ts index be5f61b..62b938b 100644 --- a/packages/extractors/src/extract.ts +++ b/packages/extractors/src/extract.ts @@ -1,4 +1,5 @@ import { familyOf, langForPath, parse } from '@codegraph/parser'; +import { extractGo } from './go'; import { extractJs } from './js'; import { extractPython } from './python'; import type { FileFacts } from './types'; @@ -9,7 +10,8 @@ export async function extractFile(path: string, source: string): Promise n.startPosition.row + 1; + +/** The scope a function-literal scope sits in: `F/λ1/λ2` -> `F/λ1` -> `F` -> package level (null). */ +export function outerScope(scope: string): string | null { + const i = scope.lastIndexOf('/'); + return i <= 0 ? null : scope.slice(0, i); +} + +const unquote = (s: string) => s.replace(/^["`]|["`]$/g, ''); + +type Shape = Omit; + +/** + * A Go type as the resolver wants it, reduced to what decides which methods exist: pointers and generic arguments are + * dropped, `pkg.T` stays qualified, slices are `[]T`, maps are `{}V` (their value type), and anything that has no repo + * methods of its own (functions, channels, structs written inline) is a keyword. + */ +function typeStr(n: Node | null | undefined): string | undefined { + if (!n) return undefined; + switch (n.type) { + case 'type_identifier': + return n.text; + case 'qualified_type': { + const pkg = n.childForFieldName('package'); + const nm = n.childForFieldName('name'); + return pkg && nm ? `${pkg.text}.${nm.text}` : undefined; + } + case 'pointer_type': + case 'parenthesized_type': + return typeStr(named(n)[0]); + case 'generic_type': + return typeStr(n.childForFieldName('type')); + case 'slice_type': + case 'array_type': + case 'implicit_length_array_type': { + const el = typeStr(n.childForFieldName('element')); + return el ? `[]${el}` : 'slice'; + } + case 'map_type': { + const v = typeStr(n.childForFieldName('value')); + return v ? `{}${v}` : 'map'; + } + case 'function_type': + return 'func'; + case 'channel_type': + return 'chan'; + case 'interface_type': + return 'interface'; + case 'struct_type': + return 'struct'; + default: + return undefined; + } +} + +/** `a`, `a.b.c` as text when every step is a plain name; otherwise null. */ +function chainText(n: Node | null | undefined): string | null { + if (!n) return null; + if (n.type === 'identifier') return n.text; + if (n.type === 'selector_expression') { + const o = chainText(n.childForFieldName('operand')); + const f = n.childForFieldName('field'); + return o && f ? `${o}.${f.text}` : null; + } + if (n.type === 'parenthesized_expression') return chainText(named(n)[0]); + if (n.type === 'index_expression') { + const o = chainText(n.childForFieldName('operand')); + return o ? `${o}[]` : null; // an element of a slice or map + } + if (n.type === 'unary_expression' && n.text.startsWith('*')) return chainText(named(n)[0]); + return null; +} + +/** The shape of a call expression (null for conversions, immediately-invoked literals and anything computed). */ +function callShape(call: Node): Shape | null { + let fn = call.childForFieldName('function'); + while (fn && (fn.type === 'parenthesized_expression' || fn.type === 'index_expression' || fn.type === 'generic_type')) { + fn = fn.type === 'index_expression' ? fn.childForFieldName('operand') : fn.type === 'generic_type' ? fn.childForFieldName('type') : named(fn)[0]; + } + if (!fn) return null; + if (fn.type === 'identifier') return { callee: fn.text, kind: 'plain' }; + if (fn.type !== 'selector_expression') return null; + const field = fn.childForFieldName('field'); + if (!field) return null; + let o = fn.childForFieldName('operand'); + while (o && o.type === 'parenthesized_expression') o = named(o)[0] ?? null; + const base: Shape = { callee: field.text, kind: 'member' }; + const text = chainText(o); + if (text) return { ...base, receiver: text }; + if (o?.type === 'call_expression') { + const f = o.childForFieldName('function'); + if (f?.type === 'identifier' && f.text === 'new') { + const t = typeStr(named(o.childForFieldName('arguments'))[0]); + return t ? { ...base, recvType: t } : base; + } + const inner = callShape(o); + return inner ? { ...base, recvCall: inner } : base; + } + if (o?.type === 'composite_literal') { + const t = typeStr(o.childForFieldName('type')); + return t ? { ...base, recvType: t } : base; + } + if (o?.type === 'unary_expression' && o.text.startsWith('&')) { + const inner = named(o)[0]; + const t = inner?.type === 'composite_literal' ? typeStr(inner.childForFieldName('type')) : undefined; + return t ? { ...base, recvType: t } : base; + } + return base; +} + +interface Ctx { + /** qname of the enclosing function or method; closures keep it. */ + caller: string | null; + /** Lexical scope: the caller, plus `/λN` for each function literal entered (a literal's parameters must not replace the outer variables). */ + scope: string | null; +} + +export function extractGo(path: string, lang: string, root: Node): FileFacts { + const facts: FileFacts = { + path, lang, family: 'go', symbols: [], imports: [], exports: [], calls: [], types: [], vars: [], callTypes: [], + go: { pkg: '', results: {}, fields: {}, locals: {} }, + }; + const go = facts.go!; + const declared = new Set(); + + const addSymbol = (s: SymbolFact) => { + if (declared.has(s.qname)) return; + declared.add(s.qname); + facts.symbols.push(s); + if (!s.className && /^[A-Z]/.test(s.name)) facts.exports.push({ name: s.name, local: s.qname }); + }; + let literals = 0; + const local = (scope: string | null, name: string) => { + if (!scope || name === '_') return; + (go.locals[scope] ??= []).push(name); + }; + const typed = (scope: string | null, name: string, type: string | undefined, at: Node) => { + if (type && name !== '_') facts.types.push({ scope, name, type, line: line(at) }); + }; + + /** Types of the variables seen so far in each function, so `y := x` can copy `x`'s type. */ + const known = new Map(); + const key = (scope: string | null, name: string) => `${scope ?? ''}|${name}`; + const setType = (scope: string | null, name: string, type: string | undefined, at: Node) => { + local(scope, name); + if (!type) return; + typed(scope, name, type, at); + known.set(key(scope, name), type); + }; + + /** What an expression's value is: a type we can read off the syntax, or a call whose result type the resolver finds. */ + function valueOf(e: Node | null | undefined, scope: string | null): { type?: string; call?: Shape } { + if (!e) return {}; + switch (e.type) { + case 'composite_literal': + return { type: typeStr(e.childForFieldName('type')) }; + case 'parenthesized_expression': + return valueOf(named(e)[0], scope); + case 'unary_expression': + return e.text.startsWith('&') ? valueOf(named(e)[0], scope) : {}; + case 'type_assertion_expression': + return { type: typeStr(e.childForFieldName('type')) }; + case 'identifier': + case 'selector_expression': { + const text = chainText(e); + if (!text) return {}; + for (let s: string | null = scope; ; s = s === null ? null : outerScope(s)) { + const t = known.get(key(s, text)); + if (t) return { type: t }; + if (s === null) break; + } + return { type: `@alias:${text}` }; // a package-level variable, or a field: the resolver knows its type + } + case 'call_expression': { + const fn = e.childForFieldName('function'); + if (fn?.type === 'identifier' && (fn.text === 'new' || fn.text === 'make')) { + return { type: typeStr(named(e.childForFieldName('arguments'))[0]) }; + } + const shape = callShape(e); + return shape ? { call: shape } : {}; + } + default: + return {}; + } + } + + function bind(scope: string | null, names: Node[], values: Node[]) { + names.forEach((nameNode, i) => { + if (nameNode.type !== 'identifier') return; + const name = nameNode.text; + if (name === '_') return; + const single = values.length === 1 && names.length > 1; + const v = valueOf(single ? values[0] : values[i], scope); + if (v.call) { + local(scope, name); + facts.callTypes!.push({ scope, name, call: { ...v.call, line: line(nameNode) }, awaited: false, ...(single ? { index: i } : {}) }); + } else setType(scope, name, v.type, nameNode); + }); + } + + function params(list: Node | null | undefined, scope: string | null) { + for (const p of named(list)) { + if (p.type !== 'parameter_declaration' && p.type !== 'variadic_parameter_declaration') continue; + let t = typeStr(p.childForFieldName('type')); + if (p.type === 'variadic_parameter_declaration' && t) t = `[]${t}`; + for (const n of named(p)) if (n.type === 'identifier') setType(scope, n.text, t, n); + } + } + + /** The type of each result, one entry per returned value (`(a, b int)` has two). */ + function results(r: Node | null | undefined): string[] { + if (!r) return []; + if (r.type !== 'parameter_list') return [typeStr(r) ?? '?']; + const out: string[] = []; + for (const p of named(r)) { + const t = typeStr(p.childForFieldName('type')) ?? '?'; + const n = named(p).filter((c) => c.type === 'identifier').length; + for (let i = 0; i < Math.max(1, n); i++) out.push(t); + } + return out; + } + + function declareFunction(node: Node) { + const nm = node.childForFieldName('name'); + if (!nm) return; + const recv = node.type === 'method_declaration' ? named(node.childForFieldName('receiver'))[0] : undefined; + const recvType = recv ? typeStr(recv.childForFieldName('type')) : undefined; + const isMethod = !!recvType; + const qname = isMethod ? `${recvType}.${nm.text}` : nm.text; + const res = results(node.childForFieldName('result')); + go.results[qname] = res; + addSymbol({ + name: nm.text, qname, kind: isMethod ? 'method' : 'function', startLine: line(node), endLine: node.endPosition.row + 1, + ...(isMethod ? { className: recvType } : {}), ...(res[0] && res[0] !== '?' ? { returns: res[0] } : {}), + }); + if (recv) for (const n of named(recv)) if (n.type === 'identifier') setType(qname, n.text, recvType, n); + params(node.childForFieldName('parameters'), qname); + const rl = node.childForFieldName('result'); + if (rl?.type === 'parameter_list') params(rl, qname); // named results are variables too + const body = node.childForFieldName('body'); + if (body) visit(body, { caller: qname, scope: qname }); + } + + function declareType(spec: Node) { + const nm = spec.childForFieldName('name'); + const t = spec.childForFieldName('type'); + if (!nm) return; + const name = nm.text; + const bases: string[] = []; + const fields: Record = {}; + if (spec.type === 'type_alias') { + const b = typeStr(t); + if (b) bases.push(b); + } else if (t?.type === 'struct_type') { + for (const fd of named(named(t)[0])) { + if (fd.type !== 'field_declaration') continue; + const ft = typeStr(fd.childForFieldName('type')); + const names = named(fd).filter((c) => c.type === 'field_identifier'); + if (!ft) continue; + if (names.length) for (const n of names) fields[n.text] = ft; + else { + fields[ft.slice(ft.lastIndexOf('.') + 1)] = ft; // an embedded field is named after its type + bases.push(ft); + } + } + } else if (t?.type === 'interface_type') { + for (const m of named(t)) { + if (m.type === 'method_spec') { + const mn = m.childForFieldName('name'); + if (!mn) continue; + const q = `${name}.${mn.text}`; + go.results[q] = results(m.childForFieldName('result')); + addSymbol({ name: mn.text, qname: q, kind: 'method', className: name, startLine: line(m), endLine: m.endPosition.row + 1 }); + } else { + const e = named(m).length === 1 ? named(m)[0] : null; // `A | B` unions are constraints, not embedding + const b = typeStr(e?.type === 'type_elem' ? named(e)[0] : e); + if (b) bases.push(b); + } + } + } + go.fields[name] = fields; + addSymbol({ name, qname: name, kind: 'class', startLine: line(spec), endLine: spec.endPosition.row + 1, bases }); + } + + function declareVars(spec: Node, scope: string | null) { + const names = named(spec).filter((c) => c.type === 'identifier'); + const t = typeStr(spec.childForFieldName('type')); + if (t) { + for (const n of names) setType(scope, n.text, t, n); + return; + } + const value = spec.childForFieldName('value'); + bind(scope, names, named(value)); + } + + function visit(node: Node, ctx: Ctx) { + switch (node.type) { + case 'function_declaration': + case 'method_declaration': + declareFunction(node); + return; + case 'type_declaration': + for (const c of named(node)) if (c.type === 'type_spec' || c.type === 'type_alias') declareType(c); + return; + case 'var_declaration': + for (const spec of named(node)) { + const list = spec.type === 'var_spec_list' ? named(spec) : [spec]; + for (const s of list) if (s.type === 'var_spec') declareVars(s, ctx.scope); + for (const s of list) for (const v of named(s.childForFieldName('value'))) visit(v, ctx); + } + return; + case 'const_declaration': { + // `const ( A Level = iota; B; C )`: a spec without a type repeats the previous one's. + let last: string | undefined; + for (const s of named(node)) { + if (s.type !== 'const_spec') continue; + const t = typeStr(s.childForFieldName('type')); + const vals = named(s.childForFieldName('value')); + if (t) last = t; + else if (vals.length) last = undefined; + const useType = t ?? (vals.length ? undefined : last); + for (const n of named(s).filter((c) => c.type === 'identifier')) { + if (useType) setType(ctx.scope, n.text, useType, n); + else local(ctx.scope, n.text); + } + for (const v of vals) visit(v, ctx); + } + return; + } + case 'func_literal': { + const inner: Ctx = { caller: ctx.caller, scope: `${ctx.scope ?? ''}/λ${++literals}` }; + params(node.childForFieldName('parameters'), inner.scope); + const rl = node.childForFieldName('result'); + if (rl?.type === 'parameter_list') params(rl, inner.scope); + const body = node.childForFieldName('body'); + if (body) visit(body, inner); + return; + } + case 'short_var_declaration': + bind(ctx.scope, named(node.childForFieldName('left')), named(node.childForFieldName('right'))); + break; + case 'range_clause': { + const left = named(node.childForFieldName('left')); + const right = node.childForFieldName('right'); + // `for _, v := range xs`: v is xs's element type. Only the ranged variable's type is known here, so a field or + // call result is left to the resolver through an `@elem:` marker. + const target = left.length >= 2 ? left[1] : null; + if (target?.type === 'identifier' && target.text !== '_') { + const chain = chainText(right); + const direct = chain ? known.get(key(ctx.scope, chain)) : undefined; + if (direct?.startsWith('[]') || direct?.startsWith('{}')) setType(ctx.scope, target.text, direct.slice(2), target); + else setType(ctx.scope, target.text, chain ? `@elem:${chain}` : undefined, target); + } + if (left[0]?.type === 'identifier') local(ctx.scope, left[0].text); + break; + } + case 'call_expression': { + const shape = callShape(node); + if (shape) facts.calls.push({ caller: ctx.caller, ...shape, line: line(node), ...(ctx.scope ? { scope: ctx.scope } : {}) }); + for (const a of named(node.childForFieldName('arguments'))) { + if (a.type === 'identifier') facts.calls.push({ caller: ctx.caller, callee: a.text, kind: 'ref', line: line(a), ...(ctx.scope ? { scope: ctx.scope } : {}) }); + } + break; + } + } + for (const c of named(node)) visit(c, ctx); + } + + for (const top of named(root)) { + if (top.type === 'comment' && !go.pkg) { + const m = /^\/\/go:build\s+(.+)$/.exec(top.text); + if (m) go.build = m[1].trim(); + } + switch (top.type) { + case 'package_clause': + go.pkg = named(top)[0]?.text ?? ''; + break; + case 'import_declaration': + for (const spec of named(top).flatMap((c) => (c.type === 'import_spec_list' ? named(c) : [c]))) { + if (spec.type !== 'import_spec') continue; + const p = spec.childForFieldName('path'); + if (!p) continue; + const specifier = unquote(p.text); + const alias = spec.childForFieldName('name'); + const local = alias ? (alias.type === 'dot' ? '.' : alias.text) : specifier.slice(specifier.lastIndexOf('/') + 1); + facts.imports.push({ specifier, bindings: [{ local, imported: '*' }], line: line(spec) }); + } + break; + default: + visit(top, { caller: null, scope: null }); + } + } + return facts; +} diff --git a/packages/extractors/src/python.ts b/packages/extractors/src/python.ts index 325b163..442cffb 100644 --- a/packages/extractors/src/python.ts +++ b/packages/extractors/src/python.ts @@ -151,21 +151,23 @@ export function extractPython(path: string, lang: string, root: Node): FileFacts if (body) for (const c of named(body)) visit(c, { caller: qname, cls: qname }); } - function handleImport(node: Node, typeOnly: boolean) { - const bindings: ImportBinding[] = []; + function handleImport(node: Node, ctx: Ctx) { + const typeOnly = ctx.typeOnly ?? false; + const scope = ctx.caller ? { scope: ctx.caller } : {}; for (const c of named(node)) { if (c.type === 'dotted_name') { - facts.imports.push({ specifier: c.text, bindings: [{ local: c.text, imported: '*' }], line: line(node), ...(typeOnly ? { typeOnly } : {}) }); + facts.imports.push({ specifier: c.text, bindings: [{ local: c.text, imported: '*' }], line: line(node), ...scope, ...(typeOnly ? { typeOnly } : {}) }); } else if (c.type === 'aliased_import') { const nm = c.childForFieldName('name'); const al = c.childForFieldName('alias'); - if (nm) facts.imports.push({ specifier: nm.text, bindings: [{ local: (al ?? nm).text, imported: '*' }], line: line(node), ...(typeOnly ? { typeOnly } : {}) }); + if (nm) facts.imports.push({ specifier: nm.text, bindings: [{ local: (al ?? nm).text, imported: '*' }], line: line(node), ...scope, ...(typeOnly ? { typeOnly } : {}) }); } } - void bindings; } - function handleFrom(node: Node, typeOnly: boolean) { + function handleFrom(node: Node, ctx: Ctx) { + const typeOnly = ctx.typeOnly ?? false; + const scope = ctx.caller ? { scope: ctx.caller } : {}; const mod = node.childForFieldName('module_name'); if (!mod) return; let level = 0; @@ -190,7 +192,7 @@ export function extractPython(path: string, lang: string, root: Node): FileFacts if (nm) bindings.push({ local: (al ?? nm).text, imported: nm.text }); } } - facts.imports.push({ specifier, bindings, line: line(node), level, star, ...(typeOnly ? { typeOnly } : {}) }); + facts.imports.push({ specifier, bindings, line: line(node), level, star, ...scope, ...(typeOnly ? { typeOnly } : {}) }); } function push(c: Omit, ctx: Ctx) { @@ -223,10 +225,10 @@ export function extractPython(path: string, lang: string, root: Node): FileFacts function visit(node: Node, ctx: Ctx) { switch (node.type) { case 'import_statement': - handleImport(node, ctx.typeOnly ?? false); + handleImport(node, ctx); return; case 'import_from_statement': - handleFrom(node, ctx.typeOnly ?? false); + handleFrom(node, ctx); return; case 'if_statement': { const cond = node.childForFieldName('condition'); diff --git a/packages/extractors/src/resolve-go.ts b/packages/extractors/src/resolve-go.ts new file mode 100644 index 0000000..e5b145d --- /dev/null +++ b/packages/extractors/src/resolve-go.ts @@ -0,0 +1,520 @@ +// Resolves Go calls. Go's unit is the package (a directory), not the file: every file of a package sees the others' +// functions and types without importing them, packages are imported by module path, methods attach to types wherever +// they are declared, and embedding promotes methods. So this does not share the file-and-import model of resolve.ts. +import * as p from './posix'; +import { CodeGraph, externalId, fileId, symbolId, type Confidence, type GraphNode } from '@codegraph/core'; +import type { BuildStats, CallTrace } from './resolve'; +import { outerScope } from './go'; +import type { CallFact, FileFacts, SymbolFact, TypeHint, CallTypeHint } from './types'; + +interface Ref { + file: FileFacts; + sym: SymbolFact; +} +interface TypeInfo extends Ref { + pkg: Pkg; + fields: Record; +} +interface Pkg { + dir: string; + name: string; + files: FileFacts[]; + funcs: Map; + types: Map; + methods: Map>; + /** Package-level variables with a declared or inferable type, as written in the file that declares them. */ + vars: Map; +} +/** A resolved type: a repo type, a type with no repo methods, a slice or map (with its element), or unknown. */ +type TypeRef = { pkg: Pkg; name: string } | { elem: TypeRef } | 'builtin' | 'external' | undefined; +/** A call without its caller; `line` is absent on a call that only describes a receiver. */ +type Shape = Omit & { line?: number }; +type Target = { ref: Ref; confidence: Confidence }; + +const BUILTIN_TYPES = new Set([ + 'bool', 'string', 'int', 'int8', 'int16', 'int32', 'int64', 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr', + 'byte', 'rune', 'float32', 'float64', 'complex64', 'complex128', 'error', 'any', 'comparable', 'func', 'chan', 'interface', + 'struct', 'slice', 'map', +]); + +const isNamed = (t: TypeRef): t is { pkg: Pkg; name: string } => typeof t === 'object' && t !== undefined && 'pkg' in t; +const isTestFile = (path: string) => path.endsWith('_test.go'); +const isUpper = (s: string) => /^[A-Z]/.test(s); + +const GOOS = new Set(['aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos', 'ios', 'js', 'linux', 'nacl', 'netbsd', 'openbsd', 'plan9', 'solaris', 'wasip1', 'windows', 'zos']); +const GOARCH = new Set(['386', 'amd64', 'amd64p32', 'arm', 'armbe', 'arm64', 'arm64be', 'loong64', 'mips', 'mipsle', 'mips64', 'mips64le', 'mips64p32', 'mips64p32le', 'ppc', 'ppc64', 'ppc64le', 'riscv', 'riscv64', 's390', 's390x', 'sparc', 'sparc64', 'wasm']); +const TAGS_ON = new Set(['linux', 'amd64', 'unix', 'gc', 'cgo']); + +/** Evaluates a `//go:build` expression for linux/amd64, the platform the type checker used (unknown tags are off). */ +function buildOk(expr: string): boolean { + const tokens = expr.match(/\|\||&&|!|\(|\)|[\w.]+/g) ?? []; + let i = 0; + const atom = (): boolean => { + const t = tokens[i++]; + if (t === '!') return !atom(); + if (t === '(') { + const v = or(); + i++; // ')' + return v; + } + return TAGS_ON.has(t) || /^go1\.\d+$/.test(t); + }; + const and = (): boolean => { + let v = atom(); + while (tokens[i] === '&&') { + i++; + v = atom() && v; + } + return v; + }; + const or = (): boolean => { + let v = and(); + while (tokens[i] === '||') { + i++; + v = and() || v; + } + return v; + }; + return or(); +} + +/** Whether a file is part of the build on linux/amd64: its name suffix (`_windows.go`, `_arm64.go`) and `//go:build` line. */ +function activeFile(f: FileFacts): boolean { + const parts = p.basename(f.path).replace(/\.go$/, '').replace(/_test$/, '').split('_'); + const last = parts[parts.length - 1]; + const prev = parts[parts.length - 2]; + if (parts.length > 1 && GOARCH.has(last)) { + if (last !== 'amd64') return false; + if (parts.length > 2 && GOOS.has(prev) && prev !== 'linux') return false; + } else if (parts.length > 1 && GOOS.has(last) && last !== 'linux') return false; + return f.go?.build ? buildOk(f.go.build) : true; +} + +export class GoResolver { + private pkgs: Pkg[] = []; + private pkgOf = new Map(); + private modules: { path: string; dir: string }[] = []; + private aliases = new Map>(); // file -> local name -> import path + private dotImports = new Map(); + private importCache = new Map(); + private localSets = new Map>(); + private varCache = new Map(); + private depth = 0; + private activeCache = new Map(); + + constructor( + private graph: CodeGraph, + private files: FileFacts[], + configs: Map, + private stats: BuildStats, + private trace?: (t: CallTrace) => void, + ) { + for (const [path, text] of configs) { + if (p.basename(path) !== 'go.mod') continue; + const m = /^\s*module\s+("?)([^\s"]+)\1/m.exec(text); + if (m) this.modules.push({ path: m[2], dir: p.dirname(path) === '.' ? '' : p.dirname(path) }); + } + this.modules.sort((a, b) => b.path.length - a.path.length); + this.group(); + } + + private active(f: FileFacts): boolean { + let v = this.activeCache.get(f.path); + if (v === undefined) this.activeCache.set(f.path, (v = activeFile(f))); + return v; + } + + // ---------------------------------------------------------------- packages + + private group() { + const byKey = new Map(); + for (const f of this.files) { + const dir = p.dirname(f.path) === '.' ? '' : p.dirname(f.path); + const name = f.go?.pkg ?? ''; + const key = `${dir}|${name}`; + let pkg = byKey.get(key); + if (!pkg) { + pkg = { dir, name, files: [], funcs: new Map(), types: new Map(), methods: new Map(), vars: new Map() }; + byKey.set(key, pkg); + this.pkgs.push(pkg); + } + pkg.files.push(f); + this.pkgOf.set(f.path, pkg); + } + for (const pkg of this.pkgs) { + for (const f of pkg.files) { + for (const s of f.symbols) { + // The same name may be declared once per platform (`_unix.go`, `_windows.go`): the active file's wins. + const wins = (cur: Ref | undefined) => !cur || (!this.active(cur.file) && this.active(f)); + if (s.kind === 'function') { + if (s.name !== 'init' && s.name !== '_' && wins(pkg.funcs.get(s.name))) pkg.funcs.set(s.name, { file: f, sym: s }); + } else if (s.kind === 'class') { + if (wins(pkg.types.get(s.name))) pkg.types.set(s.name, { file: f, sym: s, pkg, fields: f.go?.fields[s.name] ?? {} }); + } else if (s.className) { + const m = pkg.methods.get(s.className) ?? new Map(); + if (wins(m.get(s.name))) m.set(s.name, { file: f, sym: s }); + pkg.methods.set(s.className, m); + } + } + for (const t of f.types) if (t.scope === null && !pkg.vars.has(t.name)) pkg.vars.set(t.name, { file: f, type: t.type }); + for (const c of f.callTypes ?? []) if (c.scope === null && !pkg.vars.has(c.name)) pkg.vars.set(c.name, { file: f, call: { shape: c.call, index: c.index ?? 0 } }); + const al = new Map(); + const dots: string[] = []; + for (const imp of f.imports) { + const local = imp.bindings[0]?.local; + if (local === '.') dots.push(imp.specifier); + else if (local && local !== '_') al.set(local, imp.specifier); + } + this.aliases.set(f.path, al); + this.dotImports.set(f.path, dots); + } + } + // An import without an alias is named by the package's own clause, which may differ from the last path element. + for (const f of this.files) { + const al = this.aliases.get(f.path)!; + for (const [local, spec] of [...al]) { + const target = this.importTarget(spec); + if (target && target.name && target.name !== local && spec.endsWith('/' + local)) al.set(target.name, spec); + } + } + } + + /** The repo package an import path names, or null for the standard library and third-party code. */ + private importTarget(spec: string): Pkg | null { + const hit = this.importCache.get(spec); + if (hit !== undefined) return hit; + let dir: string | undefined; + for (const m of this.modules) { + if (spec === m.path) dir = m.dir; + else if (spec.startsWith(m.path + '/')) dir = p.join(m.dir, spec.slice(m.path.length + 1)); + if (dir !== undefined) break; + } + let result: Pkg | null = null; + if (dir !== undefined) { + const here = this.pkgs.filter((k) => k.dir === dir && !k.name.endsWith('_test')); + result = here.sort((a, b) => Number(a.name === 'main') - Number(b.name === 'main') || b.files.length - a.files.length)[0] ?? null; + } + this.importCache.set(spec, result); + return result; + } + + // ---------------------------------------------------------------- types + + private resolveType(file: FileFacts, s: string | undefined): TypeRef { + if (!s || s === '?') return undefined; + if (s.startsWith('[]') || s.startsWith('{}')) return { elem: this.resolveType(file, s.slice(2)) }; + const pkg = this.pkgOf.get(file.path)!; + const dot = s.indexOf('.'); + if (dot < 0) { + const t = pkg.types.get(s); + if (t) return { pkg, name: s }; + if (BUILTIN_TYPES.has(s)) return 'builtin'; + for (const spec of this.dotImports.get(file.path) ?? []) { + const target = this.importTarget(spec); + if (target?.types.has(s)) return { pkg: target, name: s }; + } + return undefined; + } + const spec = this.aliases.get(file.path)?.get(s.slice(0, dot)); + if (!spec) return undefined; + const target = this.importTarget(spec); + if (!target) return 'external'; + const name = s.slice(dot + 1); + return target.types.has(name) ? { pkg: target, name } : undefined; + } + + private typeInfo(t: { pkg: Pkg; name: string }) { + return t.pkg.types.get(t.name); + } + + /** A method of `t`, own or promoted through embedded types. */ + private findMethod(t: { pkg: Pkg; name: string }, name: string, depth = 0, seen = new Set()): Ref | 'external' | undefined { + const k = `${t.pkg.dir}|${t.pkg.name}|${t.name}`; + if (depth > 5 || seen.has(k)) return undefined; + seen.add(k); + const own = t.pkg.methods.get(t.name)?.get(name); + if (own) return own; + const info = this.typeInfo(t); + if (!info) return undefined; + let sawExternal = false; + for (const base of info.sym.bases ?? []) { + const b = this.resolveType(info.file, base); + if (b === 'external') sawExternal = true; + else if (isNamed(b)) { + const r = this.findMethod(b, name, depth + 1, seen); + if (r && r !== 'external') return r; + if (r === 'external') sawExternal = true; + } + } + return sawExternal ? 'external' : undefined; + } + + private fieldType(t: { pkg: Pkg; name: string }, name: string, depth = 0): TypeRef { + const info = this.typeInfo(t); + if (!info || depth > 5) return undefined; + const own = info.fields[name]; + if (own) return this.resolveType(info.file, own); + for (const base of info.sym.bases ?? []) { + const b = this.resolveType(info.file, base); + if (isNamed(b)) { + const r = this.fieldType(b, name, depth + 1); + if (r) return r; + } + } + return undefined; + } + + // ---------------------------------------------------------------- variables + + /** The scopes visible from `scope`, innermost first (`F/λ1`, `F`), ending at package level (null). */ + private *scopes(scope: string | null): Generator { + for (let s = scope; ; s = s === null ? null : outerScope(s)) { + yield s; + if (s === null) return; + } + } + + private localSet(file: FileFacts, scope: string): Set { + const k = `${file.path}|${scope}`; + let s = this.localSets.get(k); + if (!s) { + s = new Set(file.go?.locals[scope] ?? []); + this.localSets.set(k, s); + } + return s; + } + + /** True when `name` is a variable or parameter of the function (or of a literal around the call) rather than a package-level name. */ + private isLocal(file: FileFacts, scope: string | null, name: string): boolean { + for (const s of this.scopes(scope)) if (s !== null && this.localSet(file, s).has(name)) return true; + return false; + } + + /** + * The type of a variable visible at `scope` and `line`: 'unknown' when it is a local we cannot type, 'none' when it is + * not a variable. Where a name is declared more than once in a scope, the nearest declaration above `line` wins. + */ + private varType(file: FileFacts, scope: string | null, name: string, line: number): TypeRef | 'unknown' | 'none' { + for (const s of this.scopes(scope)) { + if (s === null || !this.localSet(file, s).has(name)) continue; + const hints: { line: number; type?: TypeHint; call?: CallTypeHint }[] = []; + for (const t of file.types) if (t.scope === s && t.name === name) hints.push({ line: t.line ?? 0, type: t }); + for (const c of file.callTypes ?? []) if (c.scope === s && c.name === name) hints.push({ line: c.call.line, call: c }); + if (!hints.length) return 'unknown'; + const before = hints.filter((h) => h.line <= line); + const h = (before.length ? before : hints).reduce((x, y) => ((before.length ? y.line >= x.line : y.line <= x.line) ? y : x)); + const k = `${file.path}|${s}|${name}|${h.line}`; + const cached = this.varCache.get(k); + if (cached === 'pending') return 'unknown'; + if (this.varCache.has(k)) return (cached as TypeRef) ?? 'unknown'; + this.varCache.set(k, 'pending'); + // `for _, c := range c.commands` and `x := x.next` read the name they are about to declare: look above this line. + const r = h.type ? this.fromHint(file, s, h.type.type, h.line - 1) : this.callResult(file, s, h.call!.call, h.call!.index ?? 0); + this.varCache.set(k, r); + return r ?? 'unknown'; + } + const pkg = this.pkgOf.get(file.path)!; + const v = pkg.vars.get(name); + if (!v) return 'none'; + const k = `${v.file.path}||${name}`; + const cached = this.varCache.get(k); + if (cached === 'pending') return 'unknown'; + if (this.varCache.has(k)) return (cached as TypeRef) ?? 'unknown'; + this.varCache.set(k, 'pending'); + const r = v.type ? this.resolveType(v.file, v.type) : v.call ? this.callResult(v.file, null, v.call.shape, v.call.index) : undefined; + this.varCache.set(k, r); + return r ?? 'unknown'; + } + + private fromHint(file: FileFacts, scope: string | null, type: string, line: number): TypeRef { + if (type.startsWith('@alias:')) return this.chainType(file, scope, type.slice(7), line); + if (!type.startsWith('@elem:')) return this.resolveType(file, type); + const c = this.chainType(file, scope, type.slice(6), line); + return typeof c === 'object' && c && 'elem' in c ? c.elem : undefined; + } + + /** The type of `a.b.c` where `a` is a variable (fields followed through structs and embedding) or an imported package's variable. */ + private chainType(file: FileFacts, scope: string | null, text: string, line: number): TypeRef { + if (this.depth > 12) return undefined; + this.depth++; + try { + const parts = text.split('.'); + const indexed = (t: TypeRef, part: string): TypeRef => { + for (let n = (part.match(/\[\]/g) ?? []).length; n > 0; n--) t = typeof t === 'object' && t && 'elem' in t ? t.elem : undefined; + return t; + }; + const bare = (part: string) => part.replace(/(\[\])+$/, ''); + let cur: TypeRef; + let i = 1; + const head = this.varType(file, scope, bare(parts[0]), line); + if (head === 'none') { + const spec = this.aliases.get(file.path)?.get(parts[0]); + const target = spec ? this.importTarget(spec) : null; + if (!spec) return undefined; + if (!target) return 'external'; + const name = parts[1] ? bare(parts[1]) : ''; + const v = name ? target.vars.get(name) : undefined; + if (!v || !isUpper(name)) return undefined; + cur = indexed(v.type ? this.resolveType(v.file, v.type) : v.call ? this.callResult(v.file, null, v.call.shape, v.call.index) : undefined, parts[1]); + i = 2; + } else cur = indexed(head === 'unknown' ? undefined : head, parts[0]); + for (; i < parts.length; i++) { + if (!isNamed(cur)) return cur === 'external' ? 'external' : undefined; + cur = indexed(this.fieldType(cur, bare(parts[i])), parts[i]); + } + return cur; + } finally { + this.depth--; + } + } + + /** What `shape` returns at result position `index`, in the terms of the file that declares the callee. */ + private callResult(file: FileFacts, scope: string | null, shape: Shape, index: number): TypeRef { + if (this.depth > 12) return undefined; + this.depth++; + try { + const conv = this.conversion(file, shape); + if (conv) return conv; + const caller = scope === null ? null : scope.split('/')[0] || null; + const targets = this.resolve(file, { ...shape, caller, scope: scope ?? undefined, line: shape.line ?? 0 }); + const t = targets.find((x) => x.confidence === 'resolved') ?? targets[0]; + if (!t) return undefined; + const res = t.ref.file.go?.results[t.ref.sym.qname]?.[index]; + return this.resolveType(t.ref.file, res); + } finally { + this.depth--; + } + } + + /** `T(x)` and `pkg.T(x)` are conversions to a type, not calls. */ + private conversion(file: FileFacts, shape: Shape): TypeRef { + const pkg = this.pkgOf.get(file.path)!; + if (shape.kind === 'plain' && !pkg.funcs.has(shape.callee) && pkg.types.has(shape.callee)) return { pkg, name: shape.callee }; + if (shape.kind === 'member' && shape.receiver && !shape.receiver.includes('.')) { + const spec = this.aliases.get(file.path)?.get(shape.receiver); + const target = spec ? this.importTarget(spec) : null; + if (target && !target.funcs.has(shape.callee) && target.types.has(shape.callee)) return { pkg: target, name: shape.callee }; + } + return undefined; + } + + // ---------------------------------------------------------------- calls + + private resolve(file: FileFacts, call: CallFact): Target[] { + const pkg = this.pkgOf.get(file.path)!; + const scope = call.scope ?? call.caller; + const one = (ref: Ref | undefined, confidence: Confidence = 'resolved'): Target[] => (ref ? [{ ref, confidence }] : []); + + if (call.kind === 'plain' || call.kind === 'ref') { + if (this.isLocal(file, scope, call.callee)) return []; // a closure or function value held in a variable + const own = pkg.funcs.get(call.callee); + if (own) return one(own, call.kind === 'ref' ? 'probable' : 'resolved'); + for (const spec of this.dotImports.get(file.path) ?? []) { + const f = this.importTarget(spec)?.funcs.get(call.callee); + if (f && isUpper(call.callee)) return one(f, call.kind === 'ref' ? 'probable' : 'resolved'); + } + return []; + } + + let type: TypeRef; + if (call.recvType) type = this.resolveType(file, call.recvType); + else if (call.recvCall) type = this.callResult(file, scope, { ...call.recvCall, line: call.line }, 0); + else if (call.receiver) { + const head = call.receiver.split('.')[0].replace(/(\[\])+$/, ''); + const v = this.varType(file, scope, head, call.line); + if (v === 'none') { + const spec = this.aliases.get(file.path)?.get(head); + if (!spec) return []; // not a variable and not an import we know: nothing to say + const target = this.importTarget(spec); + if (!target) return []; + if (call.receiver === head) return isUpper(call.callee) ? one(target.funcs.get(call.callee)) : []; + type = this.chainType(file, scope, call.receiver, call.line); + } else { + type = this.chainType(file, scope, call.receiver, call.line); + } + } else return []; + + if (isNamed(type)) { + const m = this.findMethod(type, call.callee); + return m && m !== 'external' ? one(m) : []; + } + // A variable whose type could not be worked out gets no edge: Go is statically typed, so a guess by method name + // mostly lands on the wrong receiver (measured: about one in eight such guesses was right). + return []; + } + + // ---------------------------------------------------------------- graph + + build() { + const g = this.graph; + for (const f of this.files) { + g.addNode({ id: fileId(f.path), kind: 'file', name: p.basename(f.path), qualifiedName: f.path, file: f.path, lang: f.lang, startLine: 1, endLine: f.lineCount }); + } + for (const f of this.files) { + const pkg = this.pkgOf.get(f.path)!; + for (const s of f.symbols) { + const owner = s.className ? pkg.types.get(s.className) : undefined; + const parent = owner ? symbolId(owner.file.path, owner.sym.qname) : fileId(f.path); + const exported = isUpper(s.name) && (!s.className || isUpper(s.className)); + const node: GraphNode = { id: symbolId(f.path, s.qname), kind: s.kind, name: s.name, qualifiedName: s.qname, file: f.path, lang: f.lang, startLine: s.startLine, endLine: s.endLine, exported, parent }; + g.addNode(node); + } + } + for (const f of this.files) { + for (const s of f.symbols) { + const id = symbolId(f.path, s.qname); + g.addEdge({ from: g.node(id)!.parent!, to: id, kind: 'defines', confidence: 'resolved', line: s.startLine }); + } + } + for (const f of this.files) this.addImports(f); + for (const f of this.files) this.addExtends(f); + for (const f of this.files) this.addCalls(f); + } + + private addImports(f: FileFacts) { + const g = this.graph; + const from = fileId(f.path); + for (const imp of f.imports) { + const target = this.importTarget(imp.specifier); + if (target) { + // Importing a package depends on all of it: a change in any of its files can reach this one. + for (const t of target.files) if (!isTestFile(t.path)) g.addEdge({ from, to: fileId(t.path), kind: 'imports', confidence: 'resolved', line: imp.line }); + } else { + const id = externalId(imp.specifier); + g.addNode({ id, kind: 'external', name: imp.specifier }); + g.addEdge({ from, to: id, kind: 'imports', confidence: 'resolved', line: imp.line }); + } + } + } + + private addExtends(f: FileFacts) { + for (const s of f.symbols) { + if (s.kind !== 'class') continue; + for (const base of s.bases ?? []) { + const b = this.resolveType(f, base); + if (!isNamed(b)) continue; + const info = this.typeInfo(b); + if (info) this.graph.addEdge({ from: symbolId(f.path, s.qname), to: symbolId(info.file.path, info.sym.qname), kind: 'extends', confidence: 'resolved' }); + } + } + } + + private addCalls(f: FileFacts) { + for (const call of f.calls) { + const from = call.caller ? symbolId(f.path, call.caller) : fileId(f.path); + if (call.caller && !this.graph.hasNode(from)) continue; + const targets = this.resolve(f, call); + this.trace?.({ file: f.path, call, targets: targets.map((t) => ({ file: t.ref.file.path, qname: t.ref.sym.qname, confidence: t.confidence })) }); + if (!targets.length) { + this.stats.unresolvedCalls++; + continue; + } + for (const t of targets) { + this.graph.addEdge({ from, to: symbolId(t.ref.file.path, t.ref.sym.qname), kind: 'calls', confidence: t.confidence, line: call.line }); + if (t.confidence === 'resolved') this.stats.resolvedCalls++; + else if (t.confidence === 'probable') this.stats.probableCalls++; + else this.stats.ambiguousCalls++; + } + } + } +} diff --git a/packages/extractors/src/resolve.ts b/packages/extractors/src/resolve.ts index acf7751..9d70742 100644 --- a/packages/extractors/src/resolve.ts +++ b/packages/extractors/src/resolve.ts @@ -1,5 +1,6 @@ import * as p from './posix'; import { CodeGraph, externalId, fileId, symbolId, variableId, type Confidence, type GraphNode } from '@codegraph/core'; +import { GoResolver } from './resolve-go'; import type { CallFact, CallTypeHint, ExportFact, FileFacts, ImportBinding, ImportFact, SymbolFact, VarFact } from './types'; interface Entry { @@ -7,6 +8,8 @@ interface Entry { symbols: Map; exportsByName: Map; bindings: Map; + /** Imports inside a function body, keyed `scope|local`; they shadow the file-level ones for code in that function. */ + scopedBindings: Map; methods: Map>; // class qname -> method name -> symbol types: Map; // `${scope}|${name}` -> class name /** Variables typed by a call's declared return type (see inferCallTypes), already resolved to a class. */ @@ -126,7 +129,16 @@ export function buildGraph( root = '.', trace?: (t: CallTrace) => void, ): { graph: CodeGraph; stats: BuildStats } { - return new Resolver(factsList, configs, root, trace).build(); + const go = factsList.filter((f) => f.family === 'go'); + const res = new Resolver(go.length ? factsList.filter((f) => f.family !== 'go') : factsList, configs, root, trace).build(); + if (go.length) { + new GoResolver(res.graph, go, configs, res.stats, trace).build(); + res.stats.files += go.length; + res.stats.symbols += go.reduce((n, f) => n + f.symbols.length, 0); + res.stats.importEdges = res.graph.edges('imports').length; + res.stats.callEdges = res.graph.edges('calls').length; + } + return res; } class Resolver { @@ -163,12 +175,14 @@ class Resolver { } } const bindings = new Map(); - for (const imp of f.imports) for (const b of imp.bindings) bindings.set(b.local, { imp, b }); + const scopedBindings = new Map(); + for (const imp of f.imports) for (const b of imp.bindings) (imp.scope ? scopedBindings : bindings).set(imp.scope ? `${imp.scope}|${b.local}` : b.local, { imp, b }); this.entries.set(f.path, { facts: f, symbols, exportsByName: new Map(f.exports.map((e) => [e.name, e])), bindings, + scopedBindings, methods, types: new Map(f.types.map((t) => [`${t.scope ?? ''}|${t.name}`, t.type])), callTypes: new Map(), @@ -473,7 +487,7 @@ class Resolver { if (dot < 0) { const s = this.scopeLookup(entry, caller, name); if (s?.kind === 'class') return { file: entry.facts.path, sym: s }; - const b = entry.bindings.get(name); + const b = this.bindingOf(entry, caller, name); if (b) { const t = this.resolveBinding(entry.facts.path, b.imp, b.b); if (t?.type === 'symbol' && t.sym.kind === 'class') return { file: t.file, sym: t.sym }; @@ -482,7 +496,7 @@ class Resolver { } const head = name.slice(0, dot); const rest = name.slice(dot + 1); - const b = entry.bindings.get(head) ?? entry.bindings.get(name.slice(0, name.lastIndexOf('.'))); + const b = this.bindingOf(entry, caller, head) ?? this.bindingOf(entry, caller, name.slice(0, name.lastIndexOf('.'))); if (b) { const t = this.resolveBinding(entry.facts.path, b.imp, b.b); if (t?.type === 'module') { @@ -690,6 +704,11 @@ class Resolver { return this.scoped(entry.types, caller, recv); } + /** The import that binds `name` where `caller` runs: a function-local import first, then the file's. */ + private bindingOf(entry: Entry, caller: string | null, name: string) { + return (caller && entry.scopedBindings.size ? this.scoped(entry.scopedBindings, caller, name) : undefined) ?? entry.bindings.get(name); + } + /** `name` looked up from the caller's scope outwards (`a.b|x`, `a|x`, `|x`). */ private scoped(map: Map, caller: string | null, name: string): V | undefined { const parts = caller ? caller.split('.') : []; @@ -844,9 +863,11 @@ class Resolver { const strength: Confidence = call.kind === 'ref' ? 'probable' : 'resolved'; if (call.kind === 'plain' || call.kind === 'new' || call.kind === 'ref') { - const local = this.scopeLookup(entry, call.caller, call.callee); + // `def f(): from x import f; f()`: a function-local import shadows a same-named function declared outside it. + const shadowed = !!call.caller && entry.scopedBindings.size > 0 && this.scoped(entry.scopedBindings, call.caller, call.callee) !== undefined; + const local = shadowed ? undefined : this.scopeLookup(entry, call.caller, call.callee); if (local && !(call.kind === 'new' && local.kind !== 'class')) return one(path, local, strength); - const b = entry.bindings.get(call.callee); + const b = this.bindingOf(entry, call.caller, call.callee); if (b) { const t = this.resolveBinding(path, b.imp, b.b); if (t?.type === 'symbol') return one(t.file, t.sym, strength); @@ -917,11 +938,11 @@ class Resolver { if (m) return one(m.file, m.sym, 'resolved'); } // imported module / class - let b = entry.bindings.get(recv); + let b = this.bindingOf(entry, call.caller, recv); let rest = ''; if (!b) { const head = recv.split('.')[0]; - b = entry.bindings.get(head); + b = this.bindingOf(entry, call.caller, head); rest = recv.slice(head.length + 1); } if (b) { diff --git a/packages/extractors/src/types.ts b/packages/extractors/src/types.ts index bf9292e..f20f438 100644 --- a/packages/extractors/src/types.ts +++ b/packages/extractors/src/types.ts @@ -40,6 +40,8 @@ export interface ImportFact { star?: boolean; /** Inside `if TYPE_CHECKING:` (Python) — erased at runtime, not a real dependency edge. */ typeOnly?: boolean; + /** qname of the function the import sits inside (Python `def f(): from x import y`); absent for module-level imports. */ + scope?: string; } export interface ExportFact { @@ -67,6 +69,12 @@ export interface CallFact { */ kind: 'plain' | 'member' | 'this' | 'new' | 'ref'; line: number; + /** Go: the receiver is a composite literal or conversion of this type (`T{}.M()`). */ + recvType?: string; + /** Go: the receiver is the result of this call (`NewX().Run()`, `a.B().C()`). */ + recvCall?: Omit; + /** Go: the lexical scope of the call: the function's qname, plus `/λN` for each function literal around it. */ + scope?: string; } /** Declared/inferred type of a variable, parameter or field: enables `x.method()` resolution. */ @@ -77,6 +85,8 @@ export interface TypeHint { name: string; /** Class name as written (`Foo`, `ns.Foo`). */ type: string; + /** Go: where the variable is declared, to pick the nearest preceding declaration when a name is reused. */ + line?: number; } /** @@ -89,6 +99,8 @@ export interface CallTypeHint { name: string; call: Omit; awaited: boolean; + /** Go: which of the call's results the variable takes (`a, err := f()` => 0 and 1). */ + index?: number; } /** @@ -107,10 +119,24 @@ export interface VarFact { reads: string[]; } +/** What only Go needs: its unit is the package (a directory), and its types carry fields, embedding and results. */ +export interface GoFacts { + /** The `package` clause. */ + pkg: string; + /** The `//go:build` expression, if the file has one. */ + build?: string; + /** Result types of each function or method, as written (`error`, `*Server`, `[]Item`). */ + results: Record; + /** Struct fields by type name; an embedded type is a field named after itself. */ + fields: Record>; + /** Every name declared in a function (parameters, receiver, variables), typed or not: such a name is not a package or a function. */ + locals: Record; +} + export interface FileFacts { path: string; lang: string; - family: 'js' | 'python'; + family: 'js' | 'python' | 'go'; symbols: SymbolFact[]; imports: ImportFact[]; exports: ExportFact[]; @@ -118,5 +144,6 @@ export interface FileFacts { types: TypeHint[]; vars: VarFact[]; callTypes?: CallTypeHint[]; + go?: GoFacts; lineCount?: number; } diff --git a/packages/extractors/test/browser-parity.test.ts b/packages/extractors/test/browser-parity.test.ts index 85d23d4..0640a50 100644 --- a/packages/extractors/test/browser-parity.test.ts +++ b/packages/extractors/test/browser-parity.test.ts @@ -52,12 +52,12 @@ describe('browser path parity', () => { expect(f.ignoresFile('types/index.d.ts')).toBe(true); expect(f.ignoresFile('vendor.min.js')).toBe(true); expect(f.ignoresFile('src/a.ts')).toBe(false); - expect(f.isSource('a.ts') && f.isSource('a.py') && f.isSource('A.JSX')).toBe(true); + expect(f.isSource('a.ts') && f.isSource('a.py') && f.isSource('main.go') && f.isSource('A.JSX')).toBe(true); expect(f.isSource('README.md') || f.isSource('Makefile')).toBe(false); - expect(f.isConfig('tsconfig.json') && f.isConfig('package.json') && !f.isConfig('data.json')).toBe(true); + expect(f.isConfig('tsconfig.json') && f.isConfig('package.json') && f.isConfig('go.mod') && !f.isConfig('data.json')).toBe(true); }); - for (const name of ['ts-basic', 'py-basic', 'py-affinity', 'express-proto', 'zod-versions', 'ts-grammar', 'ts-returns']) { + for (const name of ['ts-basic', 'go-basic', 'py-basic', 'py-affinity', 'express-proto', 'zod-versions', 'ts-grammar', 'ts-returns']) { it(`indexing in-memory files gives the same graph as indexing the disk: ${name}`, async () => { const root = fixture(name); const disk = await indexRepo(root); diff --git a/packages/extractors/test/go.test.ts b/packages/extractors/test/go.test.ts new file mode 100644 index 0000000..4c6ae0f --- /dev/null +++ b/packages/extractors/test/go.test.ts @@ -0,0 +1,86 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { CodeGraph } from '@codegraph/core'; +import { indexFiles, indexRepo } from '../src'; +import { conf, edge, fixture } from './helpers'; + +let g: CodeGraph; +beforeAll(async () => { + g = (await indexRepo(fixture('go-basic'))).graph; +}); + +const call = (from: string, to: string) => conf(g, 'calls', from, to); + +describe('Go extraction', () => { + it('indexes .go files with methods attached to their types and interface methods as methods', () => { + const kinds = new Map(g.nodes(['function', 'method', 'class']).map((n) => [`${n.file}#${n.qualifiedName}`, n.kind])); + expect(kinds.get('app.go#App')).toBe('class'); + expect(kinds.get('app.go#App.Start')).toBe('method'); + expect(kinds.get('app.go#NewApp')).toBe('function'); + expect(kinds.get('store/store.go#Store.Get')).toBe('method'); + }); + + it('resolves package-qualified calls through go.mod and plain calls inside a package', () => { + expect(call('app.go#main', 'util/util.go#Open')).toBe('resolved'); + expect(call('app.go#main', 'app.go#NewApp')).toBe('resolved'); + }); + + it('imports a package as all of its files and treats the standard library as external', () => { + expect(edge(g, 'imports', 'app.go', 'util/util.go')).toBeTruthy(); + expect(edge(g, 'imports', 'app.go', 'util/hash_unix.go')).toBeTruthy(); + expect(g.nodes('external').some((n) => n.name === 'fmt')).toBe(true); + }); + + it('follows struct embedding: promoted methods resolve to the embedded type, in this package or another', () => { + expect(call('app.go#App.Start', 'app.go#Base.Ping')).toBe('resolved'); + expect(call('app.go#App.Start', 'util/util.go#Logger.Log')).toBe('resolved'); + expect(edge(g, 'extends', 'app.go#App', 'app.go#Base')).toBeTruthy(); + }); + + it('types variables from constructors, results, range clauses and index expressions', () => { + expect(call('app.go#main', 'app.go#App.Start')).toBe('resolved'); // app := NewApp(db) + expect(call('app.go#App.Start', 'util/util.go#DB.Query')).toBe('resolved'); // a.db.Query: a field of a field + expect(call('app.go#App.Start', 'util/util.go#Row.Describe')).toBe('resolved'); // for _, r := range rows (rows := a.db.Query()) + expect(call('app.go#App.Start', 'app.go#Item.Run')).toBe('resolved'); // for _, it := range a.items, and a.items[0].Run() + }); + + it('resolves a call on the result of a call, new(T), and a typed constant', () => { + expect(call('app.go#main', 'app.go#App.Start')).toBe('resolved'); // NewApp(nil).Start(), new(App).Start() + expect(call('app.go#App.Start', 'store/store.go#Level.String')).toBe('resolved'); // store.Debug.String() + }); + + it('calls an interface method through a package variable or a constructor result', () => { + expect(call('app.go#main', 'store/store.go#Store.Get')).toBe('resolved'); + expect(call('app.go#main', 'store/store.go#New')).toBe('resolved'); + }); + + it('gives a closure its own scope, so its parameter does not replace the outer variable', () => { + expect(call('app.go#App.Start', 'app.go#App.helper')).toBe('resolved'); + }); + + it('prefers the file that is built on linux/amd64 when a name is declared once per platform', () => { + expect(call('util/hash_unix.go#Platform', 'util/hash_unix.go#platform')).toBe('resolved'); + expect(edge(g, 'calls', 'util/hash_unix.go#Platform', 'util/hash_windows.go#platform')).toBeFalsy(); + }); + + it('creates no edge for a function value, a conversion or the standard library', () => { + const out = g.edges('calls').filter((e) => e.from.endsWith('App.Start')); + expect(out.every((e) => !e.to.includes('fmt'))).toBe(true); + expect(edge(g, 'calls', 'app.go#App.Start', 'app.go#App.Start')).toBeFalsy(); // run(a) is a closure + }); + + it('guesses nothing for a variable whose type it cannot work out', async () => { + const { graph } = await indexFiles({ + 'go.mod': 'module m\n', + 'a.go': 'package a\n\ntype T struct{}\n\nfunc (T) Close() {}\n', + 'b.go': 'package a\n\nimport "os"\n\nfunc use() {\n\tf, _ := os.Open("x")\n\tf.Close()\n}\n', + }); + expect(graph.edges('calls').filter((e) => e.to.endsWith('T.Close'))).toHaveLength(0); + }); + + it('works without go.mod: same-package calls still resolve, repo-qualified imports stay external', async () => { + const { graph } = await indexFiles({ + 'x.go': 'package x\n\nfunc A() { B() }\n\nfunc B() {}\n', + }); + expect(conf(graph, 'calls', 'x.go#A', 'x.go#B')).toBe('resolved'); + }); +}); diff --git a/packages/extractors/test/python.test.ts b/packages/extractors/test/python.test.ts index b2254a3..6c9d2c8 100644 --- a/packages/extractors/test/python.test.ts +++ b/packages/extractors/test/python.test.ts @@ -87,4 +87,47 @@ describe('Python extraction', () => { expect(edge(graph, 'calls', 'classes.py#Derived.__init__', 'classes.py#Other.__init__')).toBeFalsy(); expect(graph.edges('calls').filter((e) => label(graph, e.from) === 'classes.py#Derived.__init__')).toHaveLength(0); }); + + it('uses a function-local import only inside that function', async () => { + const { graph } = await indexFiles({ + 'a.py': 'class Alpha:\n pass\n', + 'b.py': 'class Beta:\n pass\n', + 'use.py': [ + 'def one():', + ' from a import Alpha as cls', + ' return cls()', + '', + 'def two():', + ' from b import Beta as cls', + ' return cls()', + '', + 'def three():', + ' from a import Alpha', + ' return Alpha.make()', + ].join('\n'), + }); + // Both functions import a different class under the same local name; each call must follow its own import. + expect(conf(graph, 'calls', 'use.py#one', 'a.py#Alpha')).toBe('resolved'); + expect(conf(graph, 'calls', 'use.py#two', 'b.py#Beta')).toBe('resolved'); + expect(edge(graph, 'calls', 'use.py#one', 'b.py#Beta')).toBeFalsy(); + expect(edge(graph, 'calls', 'use.py#two', 'a.py#Alpha')).toBeFalsy(); + }); + + it('does not leak a function-local import to other functions', async () => { + const { graph } = await indexFiles({ + 'a.py': 'def helper():\n pass\n', + 'use.py': ['def one():', ' from a import helper', ' helper()', '', 'def two():', ' helper()'].join('\n'), + }); + expect(conf(graph, 'calls', 'use.py#one', 'a.py#helper')).toBe('resolved'); + expect(conf(graph, 'calls', 'use.py#two', 'a.py#helper')).not.toBe('resolved'); + }); + + it('lets a function-local import shadow a function of the same name', async () => { + const { graph } = await indexFiles({ + 'impl.py': 'def probe():\n pass\n', + 'use.py': ['def probe():', ' from impl import probe', ' return probe()'].join('\n'), + }); + expect(conf(graph, 'calls', 'use.py#probe', 'impl.py#probe')).toBe('resolved'); + expect(edge(graph, 'calls', 'use.py#probe', 'use.py#probe')).toBeFalsy(); + }); }); diff --git a/packages/parser/src/browser.ts b/packages/parser/src/browser.ts index 7a79d21..484816c 100644 --- a/packages/parser/src/browser.ts +++ b/packages/parser/src/browser.ts @@ -1,6 +1,7 @@ // Browser entry (the web app aliases `@codegraph/parser` to this file). Each .wasm is a bundler-emitted asset, and a // grammar is only downloaded when a file of that language is parsed. import runtime from 'web-tree-sitter/tree-sitter.wasm?url'; +import go from 'tree-sitter-wasms/out/tree-sitter-go.wasm?url'; import javascript from 'tree-sitter-wasms/out/tree-sitter-javascript.wasm?url'; import python from 'tree-sitter-wasms/out/tree-sitter-python.wasm?url'; import tsx from 'tree-sitter-wasms/out/tree-sitter-tsx.wasm?url'; @@ -9,5 +10,5 @@ import { configureWasm, type LangId } from './core'; export * from './core'; -const GRAMMARS: Record = { javascript, python, tsx, typescript }; +const GRAMMARS: Record = { go, javascript, python, tsx, typescript }; configureWasm({ runtime, grammar: (lang) => GRAMMARS[lang] }); diff --git a/packages/parser/src/core.ts b/packages/parser/src/core.ts index a812a19..04421e1 100644 --- a/packages/parser/src/core.ts +++ b/packages/parser/src/core.ts @@ -1,7 +1,7 @@ import { Language, Parser, type Node, type Tree } from 'web-tree-sitter'; export type { Node, Tree }; -export type LangId = 'typescript' | 'tsx' | 'javascript' | 'python'; +export type LangId = 'typescript' | 'tsx' | 'javascript' | 'python' | 'go'; const EXT_TO_LANG: Record = { '.ts': 'typescript', @@ -13,6 +13,7 @@ const EXT_TO_LANG: Record = { '.mjs': 'javascript', '.cjs': 'javascript', '.py': 'python', + '.go': 'go', }; export const SUPPORTED_EXTENSIONS = Object.keys(EXT_TO_LANG); @@ -23,8 +24,8 @@ export function langForPath(path: string): LangId | undefined { } /** Coarse language family used by extractors (ts/tsx/js share one extractor). */ -export function familyOf(lang: LangId): 'js' | 'python' { - return lang === 'python' ? 'python' : 'js'; +export function familyOf(lang: LangId): 'js' | 'python' | 'go' { + return lang === 'python' ? 'python' : lang === 'go' ? 'go' : 'js'; } /** diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a5dae18..2f23c94 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { GraphJSON } from '@codegraph/core'; import { analyze, sessionFromGraph, type Session } from './session'; import { loadGithub } from './sources/github'; +import { getToken } from './sources/token'; import { loadFolder, type Picked } from './sources/local'; import { parseRepoInput, repoLabel, type RepoRef } from './sources/parse-repo'; import { LoadError, type LoadProgress } from './sources/types'; @@ -58,7 +59,7 @@ export function App() { setScreen({ kind: 'landing', prefill: input, error: { message: "That doesn't look like a GitHub repository.", hint: 'Use owner/repo, a github.com link, or owner/repo@branch.' } }); return; } - void run(repoLabel(ref), async (signal, progress) => analyze(await loadGithub(ref, { signal, onProgress: progress }), { signal, onProgress: progress }), shareUrlFor(ref), input); + void run(repoLabel(ref), async (signal, progress) => analyze(await loadGithub(ref, { signal, onProgress: progress, token: getToken() || undefined }), { signal, onProgress: progress }), shareUrlFor(ref), input); }, [run], ); diff --git a/packages/web/src/sources/github.ts b/packages/web/src/sources/github.ts index e2dae5a..21fc23e 100644 --- a/packages/web/src/sources/github.ts +++ b/packages/web/src/sources/github.ts @@ -1,6 +1,7 @@ -// Reads a public GitHub repository straight from the browser: one call to the tree API lists every file, then the -// source files are fetched from raw.githubusercontent.com. Both hosts send `access-control-allow-origin: *`, so no -// server is involved and nothing is uploaded anywhere. +// Reads a GitHub repository straight from the browser: one call to the tree API lists every file, then the source +// files are fetched from raw.githubusercontent.com. Both hosts send `access-control-allow-origin: *`, so no server is +// involved and nothing is uploaded anywhere. With a token (private repositories, or a higher rate limit) the files come +// from the contents API instead, because that is the endpoint that accepts an Authorization header from a browser. import { createFilter } from '@codegraph/extractors/browser'; import { repoLabel, type RepoRef } from './parse-repo'; import { LIMITS, LoadError, type LoadProgress, type LoadedSource } from './types'; @@ -17,11 +18,13 @@ export interface TreeEntry { export interface GithubOptions { signal?: AbortSignal; onProgress?: (p: LoadProgress) => void; + /** A personal access token. Sent only to api.github.com. */ + token?: string; /** For tests. */ fetchImpl?: typeof fetch; } -const TESTISH = /(^|\/)(tests?|__tests__|spec|e2e|integration|fixtures?|examples?|docs?|benchmarks?|bench)(\/|$)|\.(test|spec)\.[a-z]+$|(^|\/)test_[^/]*\.py$|_test\.py$/; +const TESTISH = /(^|\/)(tests?|__tests__|spec|e2e|integration|fixtures?|examples?|docs?|benchmarks?|bench)(\/|$)|\.(test|spec)\.[a-z]+$|(^|\/)test_[^/]*\.py$|_test\.py$|_test\.go$/; const encodePath = (p: string) => p.split('/').map(encodeURIComponent).join('/'); export interface Selection { @@ -92,7 +95,10 @@ export async function loadGithub(repoRef: RepoRef, opts: GithubOptions = {}): Pr const get = async (url: string, accept?: string): Promise => { try { - return await f(url, { signal: opts.signal, headers: accept ? { Accept: accept } : undefined }); + const headers: Record = {}; + if (accept) headers.Accept = accept; + if (opts.token && url.startsWith(API)) headers.Authorization = `Bearer ${opts.token}`; // never to any other host + return await f(url, { signal: opts.signal, headers: Object.keys(headers).length ? headers : undefined }); } catch (e) { if (opts.signal?.aborted || (e as Error).name === 'AbortError') throw cancelled(); throw new LoadError('network', "Couldn't reach GitHub.", 'Check your connection and try again.'); @@ -106,19 +112,24 @@ export async function loadGithub(repoRef: RepoRef, opts: GithubOptions = {}): Pr throw new LoadError( 'not-found', `Couldn't find ${what}.`, - 'Check the spelling. Private repositories can\'t be read from the browser: run `npx codegraph-engine index .` on a clone instead.', + opts.token + ? 'Check the spelling, and that your token can read this repository (a fine-grained token needs "Contents: read" on it).' + : 'Check the spelling. For a private repository, add a GitHub token below, or drop a clone of it onto this page.', ); } // 403/429 from the anonymous API is almost always its rate limit. The remaining-requests header says so for sure, // but a browser can only read it when the server exposes it, so a missing header counts as "probably". const remaining = res.headers.get('x-ratelimit-remaining'); + if (res.status === 401) throw new LoadError('auth', 'GitHub did not accept that token.', 'It may be mistyped, expired or revoked. Remove it, or paste a new one.'); if (res.status === 429 || (res.status === 403 && (remaining === '0' || remaining === null))) { const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000; const mins = reset ? Math.max(1, Math.ceil((reset - Date.now()) / 60000)) : undefined; throw new LoadError( 'rate-limit', - remaining === '0' ? "GitHub's limit for anonymous requests has been reached." : 'GitHub refused the request, most likely because its limit for anonymous requests has been reached.', - `${mins ? `It resets in about ${mins} minute${mins === 1 ? '' : 's'}. ` : 'Wait a few minutes and try again. '}Meanwhile you can analyze a local folder, which doesn't use GitHub.`, + remaining === '0' + ? `GitHub's limit for ${opts.token ? 'this token' : 'anonymous requests'} has been reached.` + : `GitHub refused the request, most likely because its limit for ${opts.token ? 'this token' : 'anonymous requests'} has been reached.`, + `${mins ? `It resets in about ${mins} minute${mins === 1 ? '' : 's'}. ` : 'Wait a few minutes and try again. '}${opts.token ? '' : 'A GitHub token raises the limit to 5,000 an hour. '}Meanwhile you can analyze a local folder, which doesn't use GitHub.`, ); } throw new LoadError('network', `GitHub answered ${res.status} for ${what}.`, 'Try again in a moment.'); @@ -137,10 +148,11 @@ export async function loadGithub(repoRef: RepoRef, opts: GithubOptions = {}): Pr if (opts.signal?.aborted) throw cancelled(); const rawUrl = (path: string) => `${RAW}/${repoRef.owner}/${repoRef.repo}/${encodePath(branch!)}/${encodePath(path)}`; + const contentsUrl = (path: string) => `${API}/repos/${repoRef.owner}/${repoRef.repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(branch!)}`; const fetchText = async (path: string): Promise => { for (let attempt = 0; attempt < 2; attempt++) { try { - const res = await get(rawUrl(path)); + const res = opts.token ? await get(contentsUrl(path), 'application/vnd.github.raw+json') : await get(rawUrl(path)); if (res.ok) return await res.text(); if (res.status === 404) return undefined; } catch (e) { @@ -162,7 +174,7 @@ export async function loadGithub(repoRef: RepoRef, opts: GithubOptions = {}): Pr throw new LoadError( 'empty', `${label} has no TypeScript, JavaScript or Python files to analyze${repoRef.subdir ? ` in ${repoRef.subdir}` : ''}.`, - 'CodeGraph reads .ts, .tsx, .js, .jsx, .mjs, .cjs and .py files.', + 'CodeGraph reads .ts, .tsx, .js, .jsx, .mjs, .cjs, .py and .go files.', ); } diff --git a/packages/web/src/sources/local.ts b/packages/web/src/sources/local.ts index 15844c1..e783d89 100644 --- a/packages/web/src/sources/local.ts +++ b/packages/web/src/sources/local.ts @@ -19,7 +19,7 @@ export interface Picked { notes?: string[]; } -const NOTHING = () => new LoadError('invalid-input', 'There is nothing to analyze there.', 'Pick a project folder, some .ts, .js or .py files, or a .zip.'); +const NOTHING = () => new LoadError('invalid-input', 'There is nothing to analyze there.', 'Pick a project folder, some .ts, .js, .py or .go files, or a .zip.'); /** Distinct paths for loose files (two picked files may share a name when they came from different folders). */ function flat(files: File[]): FolderFile[] { @@ -140,7 +140,7 @@ export async function loadFolder( else sources.push(f); } if (!sources.length) { - throw new LoadError('empty', `No TypeScript, JavaScript or Python files found in ${folder.name}.`, 'CodeGraph reads .ts, .tsx, .js, .jsx, .mjs, .cjs and .py files, from a folder, loose files or a .zip.'); + throw new LoadError('empty', `No TypeScript, JavaScript, Python or Go files found in ${folder.name}.`, 'CodeGraph reads .ts, .tsx, .js, .jsx, .mjs, .cjs, .py and .go files, from a folder, loose files or a .zip.'); } sources.sort((a, b) => (a.path < b.path ? -1 : 1)); diff --git a/packages/web/src/sources/token.ts b/packages/web/src/sources/token.ts new file mode 100644 index 0000000..24b546f --- /dev/null +++ b/packages/web/src/sources/token.ts @@ -0,0 +1,23 @@ +// A GitHub token, for private repositories and for GitHub's higher rate limit. It stays in this browser tab +// (sessionStorage, gone when the tab closes) and is only ever sent to api.github.com. +const KEY = 'codegraph.github-token'; +let memory = ''; + +export function getToken(): string { + if (memory) return memory; + try { + return sessionStorage.getItem(KEY) ?? ''; + } catch { + return ''; + } +} + +export function setToken(value: string) { + memory = value.trim(); + try { + if (memory) sessionStorage.setItem(KEY, memory); + else sessionStorage.removeItem(KEY); + } catch { + /* storage blocked: the token still works until the page is closed */ + } +} diff --git a/packages/web/src/sources/types.ts b/packages/web/src/sources/types.ts index 74da481..fb2b5e8 100644 --- a/packages/web/src/sources/types.ts +++ b/packages/web/src/sources/types.ts @@ -20,7 +20,7 @@ export interface LoadProgress { message: string; } -export type LoadErrorCode = 'invalid-input' | 'not-found' | 'rate-limit' | 'network' | 'empty' | 'cancelled'; +export type LoadErrorCode = 'invalid-input' | 'not-found' | 'rate-limit' | 'auth' | 'network' | 'empty' | 'cancelled'; /** An expected failure with a message that can be shown to the user as is. */ export class LoadError extends Error { diff --git a/packages/web/src/sources/zip.ts b/packages/web/src/sources/zip.ts index fedecd7..c31e6bf 100644 --- a/packages/web/src/sources/zip.ts +++ b/packages/web/src/sources/zip.ts @@ -9,7 +9,7 @@ import { LIMITS, LoadError } from './types'; const MAX_ZIP_BYTES = 250_000_000; const KEEP_NAMES = new Set(['.gitignore', '.codegraphignore']); /** Files that say "this folder is a project root". */ -const PROJECT_MARKERS = new Set(['package.json', 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'tsconfig.json']); +const PROJECT_MARKERS = new Set(['package.json', 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'tsconfig.json', 'go.mod']); const base = (p: string) => p.slice(p.lastIndexOf('/') + 1); export const isZip = (f: File) => /\.zip$/i.test(f.name) || f.type === 'application/zip' || f.type === 'application/x-zip-compressed'; diff --git a/packages/web/src/views/Landing.tsx b/packages/web/src/views/Landing.tsx index d007af4..53bbe6c 100644 --- a/packages/web/src/views/Landing.tsx +++ b/packages/web/src/views/Landing.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; import { IconArrowRight, IconGithub, IconUpload } from '../ui/icons'; +import { getToken, setToken } from '../sources/token'; import { entriesFromDrop, entriesFromFileList, type Picked } from '../sources/local'; import { Accuracy, Cta, Features, Footer, HowItWorks, Nav, Preview, Terminal } from './landing/sections'; import './landing/landing.css'; @@ -39,6 +40,7 @@ export function Landing({ const filesInput = useRef(null); const graphInput = useRef(null); const depth = useRef(0); + const [token, setTokenState] = useState(getToken); // A dropped folder, files or zip anywhere on the page is accepted, and the browser must not navigate to them. useEffect(() => { @@ -100,7 +102,7 @@ export function Landing({ See what breaks before you change it

- Paste a GitHub repository, or drop a folder, some files or a .zip from your computer. CodeGraph maps the imports and calls across TypeScript, JavaScript and Python, then shows exactly which functions, files and tests a change could reach. + Paste a GitHub repository, or drop a folder, some files or a .zip from your computer. CodeGraph maps the imports and calls across TypeScript, JavaScript, Python and Go, then shows exactly which functions, files and tests a change could reach.

@@ -137,6 +139,30 @@ export function Landing({ ))} +
+ Private repository, or hit GitHub's rate limit? Add a token +

+ Paste a GitHub personal access token to read a private repository and to raise GitHub's limit from 60 to 5,000 requests an + hour. Create a{' '} + + fine-grained token + {' '} + with read-only “Contents” access to that repository. It is kept in this tab only, sent only to api.github.com, and forgotten when you close the tab. +

+ { + setTokenState(e.target.value); + setToken(e.target.value); + }} + /> +
+
@@ -182,7 +208,7 @@ export function Landing({ type="file" multiple hidden - accept=".ts,.tsx,.mts,.cts,.js,.jsx,.mjs,.cjs,.py,.zip" + accept=".ts,.tsx,.mts,.cts,.js,.jsx,.mjs,.cjs,.py,.go,.zip" onChange={(e) => { const files = Array.from(e.target.files ?? []); e.target.value = ''; diff --git a/packages/web/src/views/landing/landing.css b/packages/web/src/views/landing/landing.css index b590068..fd4ecc2 100644 --- a/packages/web/src/views/landing/landing.css +++ b/packages/web/src/views/landing/landing.css @@ -614,7 +614,7 @@ html { .features { margin-top: 52px; display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } .feature-wrap { @@ -659,7 +659,7 @@ html { .panels { margin-top: 48px; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } .panel { @@ -711,6 +711,9 @@ html { .t-ambiguous { color: var(--bad); } +.t-found { + color: var(--accent-2); +} .meter-value { font: 600 20px var(--sans); letter-spacing: -0.02em; @@ -741,6 +744,9 @@ html { .m-ambiguous { background: linear-gradient(90deg, #e04f4f, #ff6b6b); } +.m-found { + background: linear-gradient(90deg, #3f6fe0, #5b8cff); +} .meters small { display: block; margin-top: 7px; @@ -1048,3 +1054,14 @@ html { transition: none; } } + +/* Token for private repositories */ +.token { max-width: 720px; margin: 14px auto 0; text-align: left; color: var(--dim); font-size: 13px; } +.token summary { cursor: pointer; width: fit-content; margin: 0 auto; padding: 4px 8px; border-radius: 8px; color: var(--dim); } +.token summary:hover { color: var(--text); } +.token summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.token[open] summary { margin-bottom: 8px; } +.token p { margin: 0 0 10px; line-height: 1.55; } +.token a { color: var(--accent); } +.token input { width: 100%; height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--panel); color: var(--text); font: 13px var(--mono); } +.token input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } diff --git a/packages/web/src/views/landing/sections.tsx b/packages/web/src/views/landing/sections.tsx index 4217b6b..3fc1023 100644 --- a/packages/web/src/views/landing/sections.tsx +++ b/packages/web/src/views/landing/sections.tsx @@ -116,7 +116,7 @@ const FEATURES: { title: string; text: string; icon: ReactNode }[] = [ }, { title: 'Bring your own code', - text: 'A public GitHub repository, a folder, a few files, or a .zip. TypeScript, JavaScript and Python, in one graph.', + text: 'A public GitHub repository, a folder, a few files, or a .zip. TypeScript, JavaScript, Python and Go, in one graph.', icon: , }, { @@ -149,7 +149,7 @@ export function Features() { } interface Row { - label: 'resolved' | 'probable' | 'ambiguous'; + label: 'resolved' | 'probable' | 'ambiguous' | 'found'; value: number; note: string; } @@ -164,6 +164,11 @@ const PY_ROWS: Row[] = [ { label: 'ambiguous', value: 16, note: 'several candidates share the name' }, ]; +const GO_ROWS: Row[] = [ + { label: 'resolved', value: 100, note: 'every link follows the types' }, + { label: 'found', value: 98.5, note: 'of the calls the Go type checker resolves' }, +]; + function Panel({ title, oracle, repos, rows }: { title: string; oracle: string; repos: string; rows: Row[] }) { const [ref, seen] = useInView(); return ( @@ -179,7 +184,7 @@ function Panel({ title, oracle, repos, rows }: { title: string; oracle: string; {r.label} {r.value}%
-
+
{r.note} @@ -201,7 +206,7 @@ export function Accuracy() { Measured, not claimed } - sub="We checked every call in six open-source repositories against tools that know the right answer, and publish the numbers, including the weak ones." + sub="We checked every call in ten open-source repositories against tools that know the right answer, and publish the numbers, including the weak ones." >
@@ -210,6 +215,9 @@ export function Accuracy() { + + +

diff --git a/packages/web/test/sources.test.ts b/packages/web/test/sources.test.ts index 0f7d64c..5ec0eb5 100644 --- a/packages/web/test/sources.test.ts +++ b/packages/web/test/sources.test.ts @@ -305,4 +305,58 @@ describe('local folders, files and zips', () => { const bad = new File([new Uint8Array([1, 2, 3, 4])], 'broken.zip', { type: 'application/zip' }); await expect(entriesFromFileList([bad])).rejects.toMatchObject({ code: 'invalid-input' }); }); + + describe('with a token (private repositories)', () => { + const repo = new Map([['src/a.ts', 'export const a = 1;\n'], ['src/b.ts', "import { a } from './a';\nexport const b = a;\n"]]); + /** A private repository: everything needs the right bearer token, and the contents API serves the files. */ + function privateGithub() { + const seen: { url: string; auth?: string }[] = []; + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const auth = (init?.headers as Record | undefined)?.Authorization; + seen.push({ url, auth }); + if (!url.startsWith('https://api.github.com/')) return new Response('404: Not Found', { status: 404 }); // raw.githubusercontent.com can't see a private repo + if (auth === 'Bearer bad') return json({ message: 'Bad credentials' }, 401); + if (auth !== 'Bearer good') return json({ message: 'Not Found' }, 404); + if (url === 'https://api.github.com/repos/o/r') return json({ default_branch: 'main' }); + if (url.includes('/git/trees/')) return json({ tree: [...repo].map(([path, t]) => ({ path, type: 'blob', size: t.length })) }); + const m = /\/contents\/(.+)\?ref=main$/.exec(url); + return m && repo.has(decodeURIComponent(m[1])) ? new Response(repo.get(decodeURIComponent(m[1]))) : new Response('', { status: 404 }); + }) as typeof fetch; + return { fetchImpl, seen }; + } + + it('reads a private repository through the contents API, sending the token only to api.github.com', async () => { + const gh = privateGithub(); + const src = await loadGithub({ owner: 'o', repo: 'r' }, { fetchImpl: gh.fetchImpl, token: 'good' }); + expect([...src.files.keys()].sort()).toEqual(['src/a.ts', 'src/b.ts']); + expect(src.files.get('src/a.ts')).toContain('export const a'); + expect(gh.seen.every((c) => c.url.startsWith('https://api.github.com/'))).toBe(true); + expect(gh.seen.every((c) => c.auth === 'Bearer good')).toBe(true); + }); + + it('explains a missing repository differently with and without a token', async () => { + const without = await loadGithub({ owner: 'o', repo: 'r' }, { fetchImpl: privateGithub().fetchImpl }).catch((e) => e); + expect(without).toBeInstanceOf(LoadError); + expect(without.code).toBe('not-found'); + expect(without.hint).toMatch(/add a GitHub token/); + const wrong = await loadGithub({ owner: 'o', repo: 'r' }, { fetchImpl: privateGithub().fetchImpl, token: 'other' }).catch((e) => e); + expect(wrong.code).toBe('not-found'); + expect(wrong.hint).toMatch(/token can read/); + }); + + it('says when GitHub rejects the token', async () => { + const err = await loadGithub({ owner: 'o', repo: 'r' }, { fetchImpl: privateGithub().fetchImpl, token: 'bad' }).catch((e) => e); + expect(err.code).toBe('auth'); + }); + + it('does not send an Authorization header without a token', async () => { + const gh = fakeGithub(new Map([['a.ts', 'export const a = 1;']])); + const seen: (HeadersInit | undefined)[] = []; + const spy = ((i: RequestInfo | URL, init?: RequestInit) => (seen.push(init?.headers), gh.fetchImpl(i, init))) as typeof fetch; + await loadGithub({ owner: 'o', repo: 'r' }, { fetchImpl: spy }); + expect(seen.every((h) => !h || !('Authorization' in (h as object)))).toBe(true); + }); + }); }); diff --git a/scripts/eval/go-oracle/go.mod b/scripts/eval/go-oracle/go.mod new file mode 100644 index 0000000..069c119 --- /dev/null +++ b/scripts/eval/go-oracle/go.mod @@ -0,0 +1,10 @@ +module codegraph-go-oracle + +go 1.22 + +require golang.org/x/tools v0.24.0 + +require ( + golang.org/x/mod v0.20.0 // indirect + golang.org/x/sync v0.8.0 // indirect +) diff --git a/scripts/eval/go-oracle/go.sum b/scripts/eval/go-oracle/go.sum new file mode 100644 index 0000000..c489598 --- /dev/null +++ b/scripts/eval/go-oracle/go.sum @@ -0,0 +1,6 @@ +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= diff --git a/scripts/eval/go-oracle/main.go b/scripts/eval/go-oracle/main.go new file mode 100644 index 0000000..465b35e --- /dev/null +++ b/scripts/eval/go-oracle/main.go @@ -0,0 +1,204 @@ +// Independent ground truth for Go call edges: asks the Go type checker (go/types, through go/packages) which +// declaration every call site binds to. One JSON line per call site, same format and verdicts as ts-oracle.mts and +// py-oracle.py. +// +// go run . +// +// Only calls whose function is a plain name, `pkg.Name`, `x.Name` or `f[T]` are considered (the shapes CodeGraph +// extracts). Verdicts: +// +// internal binds to a function, method or interface method declared in this repo that has a graph node +// nosym binds to repo code with no graph node: a function value held in a variable or field, or a type conversion +// external binds outside the repo (standard library, dependencies) or is a built-in -> any internal edge is wrong +// unknown the checker could not resolve it (type errors) -> not scored +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/token" + "go/types" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +type row struct { + File string `json:"file"` + Line int `json:"line"` + Callee string `json:"callee"` + IsNew bool `json:"isNew"` + Verdict string `json:"verdict"` + Target string `json:"target,omitempty"` + Alts []string `json:"alts,omitempty"` + At string `json:"at,omitempty"` +} + +func main() { + root, err := filepath.Abs(os.Args[1]) + check(err) + nodes := loadNodes(os.Args[2]) + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps, + Dir: root, + Tests: true, + } + pkgs, err := packages.Load(cfg, "./...") + check(err) + + rel := func(abs string) (string, bool) { + r, err := filepath.Rel(root, abs) + if err != nil || strings.HasPrefix(r, "..") { + return "", false + } + return filepath.ToSlash(r), true + } + seen := map[string]bool{} + var rows []row + for _, pkg := range pkgs { + for _, f := range pkg.Syntax { + path, ok := rel(pkg.Fset.Position(f.Pos()).Filename) + if !ok { + continue + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + name, id, sel := callee(call.Fun) + if name == "" { + return true + } + pos := pkg.Fset.Position(call.Pos()) + k := fmt.Sprintf("%s:%d:%d", path, pos.Line, pos.Column) + if seen[k] { + return true + } + seen[k] = true + r := row{File: path, Line: pos.Line, Callee: name} + r.Verdict, r.Target, r.Alts, r.At = classify(pkg, id, sel, rel, nodes) + rows = append(rows, r) + return true + }) + } + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].File != rows[j].File { + return rows[i].File < rows[j].File + } + return rows[i].Line < rows[j].Line + }) + out, err := os.Create(os.Args[3]) + check(err) + defer out.Close() + counts := map[string]int{} + enc := json.NewEncoder(out) + for _, r := range rows { + counts[r.Verdict]++ + check(enc.Encode(r)) + } + fmt.Printf("%s: %d call sites %v\n", os.Args[3], len(rows), counts) +} + +// callee unwraps the callee expression to the name CodeGraph records. +func callee(fun ast.Expr) (string, *ast.Ident, *ast.SelectorExpr) { + for { + switch f := fun.(type) { + case *ast.ParenExpr: + fun = f.X + case *ast.IndexExpr: + fun = f.X + case *ast.IndexListExpr: + fun = f.X + case *ast.Ident: + return f.Name, f, nil + case *ast.SelectorExpr: + return f.Sel.Name, nil, f + default: + return "", nil, nil + } + } +} + +func classify(pkg *packages.Package, id *ast.Ident, sel *ast.SelectorExpr, rel func(string) (string, bool), nodes map[string]bool) (verdict, target string, alts []string, at string) { + info := pkg.TypesInfo + var obj types.Object + if id != nil { + obj = info.Uses[id] + } else if s, ok := info.Selections[sel]; ok { + obj = s.Obj() + } else { + obj = info.Uses[sel.Sel] + } + switch o := obj.(type) { + case nil: + return "unknown", "", nil, "" + case *types.Builtin: + return "external", "", nil, "builtin" + case *types.TypeName: + return "nosym", "", nil, "conversion" + case *types.Var: + return "nosym", "", nil, "function value" + case *types.Func: + fn := o.Origin() + if fn.Pkg() == nil { + return "external", "", nil, "universe" + } + path, ok := rel(pkg.Fset.Position(fn.Pos()).Filename) + if !ok { + return "external", "", nil, fn.Pkg().Path() + } + q := fn.Name() + if sig, ok := fn.Type().(*types.Signature); ok && sig.Recv() != nil { + t := sig.Recv().Type() + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + if n, ok := t.(*types.Named); ok { + q = n.Obj().Name() + "." + fn.Name() + } else { + return "nosym", "", nil, path // a method of an unnamed interface + } + } + node := path + "#" + q + if nodes[node] { + return "internal", node, []string{node}, node + } + return "nosym", "", nil, node + } + return "unknown", "", nil, "" +} + +func loadNodes(path string) map[string]bool { + b, err := os.ReadFile(path) + check(err) + var g struct { + Nodes []struct { + Kind string `json:"kind"` + File string `json:"file"` + QualifiedName string `json:"qualifiedName"` + } `json:"nodes"` + } + check(json.Unmarshal(b, &g)) + out := map[string]bool{} + for _, n := range g.Nodes { + if n.File != "" && (n.Kind == "function" || n.Kind == "method" || n.Kind == "class") { + out[n.File+"#"+n.QualifiedName] = true + } + } + return out +} + +func check(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var _ = token.NoPos