@youichi/repograph 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -5
- package/README.zh-CN.md +189 -0
- package/dist/classes.js +285 -0
- package/dist/index.js +5 -0
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# repograph
|
|
2
2
|
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
Export repository indexes as JSON graphs for other programs using the [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) CLI. No language model, API key, or MCP client is required.
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
@@ -17,13 +19,13 @@ The project pins `codebase-memory-mcp@0.12.1`. The initial installation download
|
|
|
17
19
|
Run these commands from any directory:
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
# Export all nodes and relationships
|
|
22
|
+
# Export all indexed nodes and relationships, plus source-derived class members and dependencies
|
|
21
23
|
npx @youichi/repograph export ../anureo-sdk
|
|
22
24
|
|
|
23
25
|
# Specify an output file
|
|
24
26
|
npx @youichi/repograph export /path/to/repository --out .repograph/repository.json
|
|
25
27
|
|
|
26
|
-
#
|
|
28
|
+
# Export only CALLS edges, functions/methods, and their endpoints
|
|
27
29
|
npx @youichi/repograph export ../anureo-sdk --kind calls --out .repograph/calls.json
|
|
28
30
|
|
|
29
31
|
# Show available options
|
|
@@ -35,7 +37,7 @@ The default output is `.repograph/graph.json` under the current working director
|
|
|
35
37
|
| Option | Default | Purpose |
|
|
36
38
|
| --- | --- | --- |
|
|
37
39
|
| `--out`, `-o` | `.repograph/graph.json` | JSON output path |
|
|
38
|
-
| `--kind` | `all` | `all`
|
|
40
|
+
| `--kind` | `all` | `all` includes class structure and dependencies; `calls` keeps only direct call relationships |
|
|
39
41
|
| `--project` | Directory name + hash of the absolute path | Override the upstream index name |
|
|
40
42
|
| `--page-size` | `500` | Rows per query batch, from 1 to 10000 |
|
|
41
43
|
|
|
@@ -86,10 +88,37 @@ The following is a simplified example. Actual properties depend on the language
|
|
|
86
88
|
}
|
|
87
89
|
```
|
|
88
90
|
|
|
89
|
-
Node IDs and edge IDs are strings in separate namespaces. An edge's `source` and `target` refer to node IDs. IDs link records within the current export and are not guaranteed to remain stable across indexing runs. `indexReport` preserves the upstream indexing report; its counts
|
|
91
|
+
Node IDs and edge IDs are strings in separate namespaces. An edge's `source` and `target` refer to node IDs. IDs link records within the current export and are not guaranteed to remain stable across indexing runs. `indexReport` preserves the upstream indexing report; its counts describe the upstream index and can differ from the exported graph after filtering or adding source-derived class data.
|
|
90
92
|
|
|
91
93
|
TypeScript and JavaScript `Function` and `Method` nodes include `callable` metadata in both `all` and `calls` exports. The exporter parses upstream `properties.signature` and `properties.return_type` with the TypeScript parser, without loading the target project's dependencies or inferring types. Supported file extensions are `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and `.cjs`. Other languages and node labels omit `callable`. The original `properties` remain intact, and this additive field retains `schemaVersion: 1`.
|
|
92
94
|
|
|
95
|
+
In `all` exports, class-like nodes include a normalized `members` array whenever the upstream indexer emits `Field` or `Property` nodes. Each member also remains a graph node and receives a `HAS_PROPERTY` edge from its owning class. Entries include declared type text when available, source lines, visibility, static, optional, and readonly flags. The exporter derives `DEPENDS_ON` edges from upstream field and method type metadata when the target class resolves unambiguously. TypeScript and JavaScript receive an AST fallback for declared fields, accessors, and type references when upstream field nodes are absent. Derived edges identify their source in `properties.derivedFrom` (`upstream-index` or `typescript-ast`) and include a `reason`. `calls` exports intentionally omit class members and these dependencies. Available fields and relationships still depend on what the upstream indexer extracts for each language.
|
|
96
|
+
|
|
97
|
+
For example, `Customer` with an `address?: Address` field and a `save(input: Address): Result` method includes:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"labels": ["Class"],
|
|
102
|
+
"name": "Customer",
|
|
103
|
+
"members": [
|
|
104
|
+
{
|
|
105
|
+
"name": "address",
|
|
106
|
+
"kind": "field",
|
|
107
|
+
"type": "Address",
|
|
108
|
+
"filePath": "model.ts",
|
|
109
|
+
"startLine": 6,
|
|
110
|
+
"endLine": 6,
|
|
111
|
+
"visibility": "public",
|
|
112
|
+
"static": false,
|
|
113
|
+
"optional": true,
|
|
114
|
+
"readonly": false
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The graph also contains a `HAS_PROPERTY` edge from `Customer` to its `address` field node and an edge such as `{ "source": "<Customer id>", "target": "<Address id>", "type": "DEPENDS_ON", "properties": { "derivedFrom": "upstream-index", "reason": "field-type", "member": "address" } }`. Existing upstream `INHERITS` and `IMPLEMENTS` edges are retained as-is.
|
|
121
|
+
|
|
93
122
|
For example, `function load(id: string, count = 1, ...tags: string[]): Promise<string[]>` produces:
|
|
94
123
|
|
|
95
124
|
```json
|
|
@@ -136,15 +165,23 @@ for (const node of graph.nodes) {
|
|
|
136
165
|
bun run typecheck
|
|
137
166
|
bun test
|
|
138
167
|
bun run test:integration
|
|
168
|
+
bun run test:languages
|
|
139
169
|
```
|
|
140
170
|
|
|
141
|
-
Unit tests cover pagination, cursors, truncation handling, file line counts,
|
|
171
|
+
Unit tests cover pagination, cursors, truncation handling, file line counts, structured callable metadata, and completion of asynchronous class enrichment. Integration tests use the real native backend for the original multi-file TypeScript project and a corpus covering all 162 vendored grammars in the pinned backend. The language suite exercises the CLI's JSON output and the API's `calls` projection, definition preservation, file line counts, graph integrity, parse diagnostics, isolated functions, and class metadata. All 66 caller/callee samples require a resolved `CALLS` edge in both export modes. Temporary repositories and indexes are removed after each suite.
|
|
172
|
+
|
|
173
|
+
See [the language corpus](test/fixtures/languages/README.md) for coverage boundaries, fixture provenance, and the optional JSON coverage report. The presence of a vendored grammar does not guarantee that the upstream CLI can discover it as a standalone source file.
|
|
174
|
+
|
|
175
|
+
## Publishing
|
|
176
|
+
|
|
177
|
+
Releases are published from GitHub Actions when a version tag is pushed. Before the first release, configure npm Trusted Publishing for package `@youichi/repograph` with GitHub user `hi-youichi`, repository `repograph`, and workflow file `publish-npm.yml`; allow direct `npm publish` for that publisher. Then update `package.json` to the new version and push the matching tag, for example `v0.1.1`. The workflow checks that the tag matches the package version, installs dependencies, runs typecheck and unit tests, then builds and publishes with npm provenance. No npm token is stored in GitHub Secrets.
|
|
142
178
|
|
|
143
179
|
## Limitations
|
|
144
180
|
|
|
145
181
|
- Exports contain the static relationships actually indexed upstream and do not guarantee coverage of all runtime calls. Support for reflection, dynamic dispatch, and framework conventions varies by language.
|
|
146
182
|
- The upstream indexer skips `node_modules`, files matching ignore rules, and other excluded inputs. Use the skip and parse counts in `indexReport` to assess coverage.
|
|
147
183
|
- `calls` mode retains only `CALLS` edges. It does not treat `CALL_REFERENCE` or `USAGE` as direct calls. The upstream analyzer may also record recursion in properties while omitting self-call edges.
|
|
184
|
+
- The exporter normalizes upstream class fields and type metadata without language-specific adapters. The pinned indexer does not emit every field or type relationship for every language, so missing upstream information cannot be reconstructed generically. Type references are not inferred from unannotated initializers.
|
|
148
185
|
- Keep source files and the project's index unchanged during export, and prevent other processes from rebuilding the index concurrently. Exports are not transactional snapshots across queries.
|
|
149
186
|
- Results are assembled in memory before being written to disk. Large repositories require sufficient memory, and upstream evaluation limits cause an error.
|
|
150
187
|
- Place output outside the target repository to prevent a later indexing run from treating exported files as input. Upstream indexes remain in the local cache.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# repograph
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
使用 [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) CLI 将代码仓库索引导出为 JSON 图数据,供其他程序使用。无需语言模型、API 密钥或 MCP 客户端。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
需要 Node.js 22 或更高版本。通过 `npx` 直接运行:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @youichi/repograph export /path/to/repository
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
本项目固定使用 `codebase-memory-mcp@0.12.1`。首次安装会下载适用于当前平台的原生二进制文件,需要访问 npm 和 GitHub。后续索引操作均在本地运行。无需全局安装,也无需通过 `codebase-memory-mcp install` 配置编辑器或智能体。
|
|
16
|
+
|
|
17
|
+
## 导出图数据
|
|
18
|
+
|
|
19
|
+
可在任意目录运行以下命令:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 导出索引中的全部节点和关系,以及从源码提取的类成员和依赖
|
|
23
|
+
npx @youichi/repograph export ../anureo-sdk
|
|
24
|
+
|
|
25
|
+
# 指定输出文件
|
|
26
|
+
npx @youichi/repograph export /path/to/repository --out .repograph/repository.json
|
|
27
|
+
|
|
28
|
+
# 仅导出 CALLS 边、函数和方法,以及这些边的端点
|
|
29
|
+
npx @youichi/repograph export ../anureo-sdk --kind calls --out .repograph/calls.json
|
|
30
|
+
|
|
31
|
+
# 查看可用选项
|
|
32
|
+
npx @youichi/repograph --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
默认输出路径为当前工作目录下的 `.repograph/graph.json`。进度信息写入 stderr;成功后,stdout 会显示输出路径以及节点和边的数量。失败时以退出码 1 结束,部分结果不会覆盖已有的输出文件。
|
|
36
|
+
|
|
37
|
+
| 选项 | 默认值 | 用途 |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `--out`, `-o` | `.repograph/graph.json` | JSON 输出路径 |
|
|
40
|
+
| `--kind` | `all` | `all` 包含类结构和依赖;`calls` 仅保留直接调用关系 |
|
|
41
|
+
| `--project` | 目录名 + 绝对路径的哈希值 | 自定义上游索引名称 |
|
|
42
|
+
| `--page-size` | `500` | 每批查询的行数,范围为 1 至 10000 |
|
|
43
|
+
|
|
44
|
+
每次导出都会先刷新索引,再分别读取节点和边。查询支持分页和响应游标。如果检测到求值限制、截断后缺少后续游标、重复 ID、缺失端点或数量不匹配,导出会停止。调用图会保留没有调用边的函数和方法。
|
|
45
|
+
|
|
46
|
+
## 输出结构
|
|
47
|
+
|
|
48
|
+
以下为简化示例。实际属性取决于编程语言和上游分析结果:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"schemaVersion": 1,
|
|
53
|
+
"generatedAt": "2026-11-12T00:00:00.000Z",
|
|
54
|
+
"repositoryPath": "/path/to/repository",
|
|
55
|
+
"project": "repository-0123456789ab",
|
|
56
|
+
"kind": "calls",
|
|
57
|
+
"indexReport": { "status": "indexed", "nodes": 2, "edges": 1 },
|
|
58
|
+
"files": {
|
|
59
|
+
"main.ts": { "lines": 7 }
|
|
60
|
+
},
|
|
61
|
+
"nodes": [
|
|
62
|
+
{
|
|
63
|
+
"id": "1",
|
|
64
|
+
"labels": ["Function"],
|
|
65
|
+
"name": "checkout",
|
|
66
|
+
"qualifiedName": "repository.main.checkout",
|
|
67
|
+
"filePath": "main.ts",
|
|
68
|
+
"startLine": 1,
|
|
69
|
+
"endLine": 3,
|
|
70
|
+
"properties": {},
|
|
71
|
+
"callable": { "parameters": [], "returnType": null }
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "2",
|
|
75
|
+
"labels": ["Function"],
|
|
76
|
+
"name": "validate",
|
|
77
|
+
"qualifiedName": "repository.main.validate",
|
|
78
|
+
"filePath": "main.ts",
|
|
79
|
+
"startLine": 5,
|
|
80
|
+
"endLine": 7,
|
|
81
|
+
"properties": {},
|
|
82
|
+
"callable": { "parameters": [], "returnType": null }
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"edges": [
|
|
86
|
+
{ "id": "1", "source": "1", "target": "2", "type": "CALLS", "properties": {} }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
节点 ID 和边 ID 均为字符串,分属独立的命名空间。边的 `source` 和 `target` 指向节点 ID。这些 ID 用于关联当前导出中的记录,不保证在多次索引之间保持稳定。`indexReport` 保留上游索引报告;其中的数量描述的是上游索引,在过滤或添加从源码提取的类数据后,可能与导出的图数据不同。
|
|
92
|
+
|
|
93
|
+
在 `all` 和 `calls` 两种导出模式中,TypeScript 和 JavaScript 的 `Function`、`Method` 节点均包含 `callable` 元数据。导出器使用 TypeScript 解析器解析上游的 `properties.signature` 和 `properties.return_type`,不会加载目标项目的依赖或推断类型。支持的文件扩展名为 `.ts`、`.tsx`、`.mts`、`.cts`、`.js`、`.jsx`、`.mjs` 和 `.cjs`。其他语言和节点标签不包含 `callable`。原始 `properties` 保持不变,新增此字段后仍使用 `schemaVersion: 1`。
|
|
94
|
+
|
|
95
|
+
在 `all` 模式下,只要上游索引器生成了 `Field` 或 `Property` 节点,类及类似结构的节点就会包含规范化的 `members` 数组。每个成员同时保留为图节点,并通过一条从所属类指向该成员的 `HAS_PROPERTY` 边关联。成员条目包含可用的声明类型文本、源码行号、可见性,以及静态、可选和只读标记。当目标类可被唯一确定时,导出器会根据上游字段和方法的类型元数据派生 `DEPENDS_ON` 边。如果缺少上游字段节点,TypeScript 和 JavaScript 会通过 AST 补充解析声明的字段、访问器和类型引用。派生边通过 `properties.derivedFrom` 标明来源(`upstream-index` 或 `typescript-ast`),并包含 `reason`。`calls` 模式不包含类成员及这些依赖。可用字段和关系仍取决于上游索引器对各语言的提取能力。
|
|
96
|
+
|
|
97
|
+
例如,若 `Customer` 包含 `address?: Address` 字段和 `save(input: Address): Result` 方法,导出结果会包含:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"labels": ["Class"],
|
|
102
|
+
"name": "Customer",
|
|
103
|
+
"members": [
|
|
104
|
+
{
|
|
105
|
+
"name": "address",
|
|
106
|
+
"kind": "field",
|
|
107
|
+
"type": "Address",
|
|
108
|
+
"filePath": "model.ts",
|
|
109
|
+
"startLine": 6,
|
|
110
|
+
"endLine": 6,
|
|
111
|
+
"visibility": "public",
|
|
112
|
+
"static": false,
|
|
113
|
+
"optional": true,
|
|
114
|
+
"readonly": false
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
图中还会包含一条从 `Customer` 指向其 `address` 字段节点的 `HAS_PROPERTY` 边,以及类似 `{ "source": "<Customer id>", "target": "<Address id>", "type": "DEPENDS_ON", "properties": { "derivedFrom": "upstream-index", "reason": "field-type", "member": "address" } }` 的边。上游已有的 `INHERITS` 和 `IMPLEMENTS` 边会原样保留。
|
|
121
|
+
|
|
122
|
+
例如,`function load(id: string, count = 1, ...tags: string[]): Promise<string[]>` 会生成:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"callable": {
|
|
127
|
+
"parameters": [
|
|
128
|
+
{ "name": "id", "type": "string", "optional": false, "rest": false },
|
|
129
|
+
{ "name": "count", "type": null, "optional": true, "rest": false },
|
|
130
|
+
{ "name": "tags", "type": "string[]", "optional": false, "rest": true }
|
|
131
|
+
],
|
|
132
|
+
"returnType": "Promise<string[]>"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
参数顺序与签名一致。`name` 保留源码中的绑定名称或解构模式。`type` 为声明的类型文本,没有类型注解时为 `null`。带有 `?` 标记或默认初始值的参数,其 `optional` 为 true;`rest` 用于标识 `...` 参数。默认值不会触发类型推断。如果上游签名包含 TypeScript 源码中的 `this` 参数,该参数也会保留。
|
|
138
|
+
|
|
139
|
+
`parameters: []` 表示已知参数列表为空;`parameters: null` 表示上游签名缺失或无法解析。`returnType` 不包含开头的冒号,没有类型注解时为 `null`;显式声明的 `void`、`never` 和 `Promise<T>` 会保留。如果签名与返回类型注解组合后格式无效,两个字段均为 `null`。缺失的类型不会被替换为 `any` 或 `void`。导出器仅导出每个上游节点保留的签名,不会重建独立的重载、成对的访问器、泛型参数声明或上游遗漏的符号。
|
|
140
|
+
|
|
141
|
+
`files` 以各个 `File` 节点的 `filePath` 为键,通过 `lines` 记录文件的实际行数,包括空行和注释。空文件为 `0` 行,文件末尾的换行符不会额外增加一行。支持 LF、CRLF 和 CR 换行符。无法读取的文件,其行数为 `null`。行数在导出时直接从源码文件读取,不根据上游节点的 `properties.lines` 推断。`all` 和 `calls` 模式都会保留完整索引中的文件信息,即使调用图已过滤掉文件节点。不会扫描索引之外的文件。
|
|
142
|
+
|
|
143
|
+
## 在 TypeScript 中使用
|
|
144
|
+
|
|
145
|
+
本项目也导出了一个轻量封装,内部调用 CLI:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { exportGraph } from "./src/index.ts";
|
|
149
|
+
|
|
150
|
+
const graph = await exportGraph("../anureo-sdk", {
|
|
151
|
+
kind: "calls",
|
|
152
|
+
onProgress: console.error,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
console.log(graph.nodes, graph.edges);
|
|
156
|
+
|
|
157
|
+
for (const node of graph.nodes) {
|
|
158
|
+
if (node.callable) console.log(node.name, node.callable.parameters, node.callable.returnType);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## 开发与验证
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
bun run typecheck
|
|
166
|
+
bun test
|
|
167
|
+
bun run test:integration
|
|
168
|
+
bun run test:languages
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
单元测试覆盖分页、游标、截断处理、文件行数、结构化的可调用对象元数据,以及异步类成员提取完成后才返回的行为。集成测试使用真实原生后端,覆盖原有多文件 TypeScript 项目和固定依赖中的全部 162 种语法样例,校验 CLI JSON 输出、API 的 `calls` 图、定义保留、文件行数、图完整性、解析诊断、孤立函数和类元数据。66 组调用样例均要求在两种导出模式中生成 `CALLS` 边。测试结束后清理临时仓库和索引。
|
|
172
|
+
|
|
173
|
+
覆盖边界、样例来源和可选 JSON 报告见[全语言测试说明](test/fixtures/languages/README.md)。内置语法不一定能通过上游 CLI 作为独立源码文件被识别。
|
|
174
|
+
|
|
175
|
+
## 发布
|
|
176
|
+
|
|
177
|
+
推送版本标签后,GitHub Actions 会发布新版本。首次发布前,请为 npm 包 `@youichi/repograph` 配置 Trusted Publishing,使用 GitHub 用户 `hi-youichi`、仓库 `repograph` 和工作流文件 `publish-npm.yml`,并允许该发布方直接执行 `npm publish`。随后将 `package.json` 更新为新版本,再推送对应标签,例如 `v0.1.1`。工作流会检查标签是否与包版本匹配、安装依赖、运行类型检查和单元测试,然后构建并发布,同时附带 npm provenance 来源证明。GitHub Secrets 中不存储 npm 令牌。
|
|
178
|
+
|
|
179
|
+
## 限制
|
|
180
|
+
|
|
181
|
+
- 导出结果包含上游实际索引到的静态关系,不保证覆盖所有运行时调用。对反射、动态分派和框架约定的支持因语言而异。
|
|
182
|
+
- 上游索引器会跳过 `node_modules`、匹配忽略规则的文件及其他被排除的输入。可通过 `indexReport` 中的跳过和解析数量评估覆盖范围。
|
|
183
|
+
- `calls` 模式仅保留 `CALLS` 边,不会将 `CALL_REFERENCE` 或 `USAGE` 视为直接调用。上游分析器也可能在属性中记录递归信息,而不生成自调用边。
|
|
184
|
+
- 导出器无需针对各语言编写适配器,即可规范化上游类字段和类型元数据。固定版本的索引器并不会为每种语言生成全部字段或类型关系,因此无法用通用方式重建上游缺失的信息。不会根据没有类型注解的初始值推断类型引用。
|
|
185
|
+
- 导出期间请保持源码文件和项目索引不变,并防止其他进程同时重建索引。导出结果不是跨查询的事务性快照。
|
|
186
|
+
- 结果会先在内存中组装,再写入磁盘。大型仓库需要足够内存;触及上游求值限制时会报错。
|
|
187
|
+
- 请将输出放在目标仓库之外,以免后续索引将导出文件视为输入。上游索引会保留在本地缓存中。
|
|
188
|
+
|
|
189
|
+
本项目目前仅导出 JSON,可使用 Graphviz、Cytoscape 或其他前端工具进行可视化。
|
package/dist/classes.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// src/classes.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { extname, resolve } from "node:path";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
var CLASS_LABELS = new Set(["Class", "Interface", "Struct", "Trait"]);
|
|
7
|
+
var SCRIPT_KINDS = {
|
|
8
|
+
".ts": ts.ScriptKind.TS,
|
|
9
|
+
".tsx": ts.ScriptKind.TSX,
|
|
10
|
+
".mts": ts.ScriptKind.TS,
|
|
11
|
+
".cts": ts.ScriptKind.TS,
|
|
12
|
+
".js": ts.ScriptKind.JS,
|
|
13
|
+
".jsx": ts.ScriptKind.JSX,
|
|
14
|
+
".mjs": ts.ScriptKind.JS,
|
|
15
|
+
".cjs": ts.ScriptKind.JS
|
|
16
|
+
};
|
|
17
|
+
function modifiers(node) {
|
|
18
|
+
return ts.canHaveModifiers(node) ? ts.getModifiers(node) ?? [] : [];
|
|
19
|
+
}
|
|
20
|
+
function memberName(name) {
|
|
21
|
+
if (!name)
|
|
22
|
+
return;
|
|
23
|
+
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name))
|
|
24
|
+
return name.text;
|
|
25
|
+
if (ts.isStringLiteral(name) || ts.isNumericLiteral(name))
|
|
26
|
+
return name.text;
|
|
27
|
+
if (ts.isComputedPropertyName(name) && (ts.isStringLiteral(name.expression) || ts.isNumericLiteral(name.expression)))
|
|
28
|
+
return name.expression.text;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
function typeNames(type, into = new Set) {
|
|
32
|
+
const visit = (node) => {
|
|
33
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
34
|
+
const name = node.typeName.getText().split(".").at(-1);
|
|
35
|
+
if (name)
|
|
36
|
+
into.add(name);
|
|
37
|
+
}
|
|
38
|
+
ts.forEachChild(node, visit);
|
|
39
|
+
};
|
|
40
|
+
visit(type);
|
|
41
|
+
return into;
|
|
42
|
+
}
|
|
43
|
+
function referencedNames(typeText) {
|
|
44
|
+
return new Set(typeText.match(/[\p{L}_$][\p{L}\p{N}_$]*/gu) ?? []);
|
|
45
|
+
}
|
|
46
|
+
function propertyText(node, ...keys) {
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const value = node.properties[key];
|
|
49
|
+
if (typeof value === "string" && value.trim())
|
|
50
|
+
return value.replace(/^\s*:\s*/, "").trim();
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
function propertyTypes(value) {
|
|
55
|
+
if (typeof value === "string")
|
|
56
|
+
return [value];
|
|
57
|
+
if (Array.isArray(value))
|
|
58
|
+
return value.filter((item) => typeof item === "string");
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
function sourceLine(file, node) {
|
|
62
|
+
return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
|
|
63
|
+
}
|
|
64
|
+
function sourceEndLine(file, node) {
|
|
65
|
+
return file.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
66
|
+
}
|
|
67
|
+
function toMember(file, filePath, node) {
|
|
68
|
+
if (!ts.isPropertyDeclaration(node) && !ts.isPropertySignature(node) && !ts.isGetAccessorDeclaration(node) && !ts.isSetAccessorDeclaration(node))
|
|
69
|
+
return;
|
|
70
|
+
const name = memberName(node.name);
|
|
71
|
+
if (name === undefined)
|
|
72
|
+
return;
|
|
73
|
+
const flags = modifiers(node);
|
|
74
|
+
const visibility = flags.some((flag) => flag.kind === ts.SyntaxKind.PrivateKeyword) || name.startsWith("#") ? "private" : flags.some((flag) => flag.kind === ts.SyntaxKind.ProtectedKeyword) ? "protected" : "public";
|
|
75
|
+
const kind = ts.isGetAccessorDeclaration(node) ? "getter" : ts.isSetAccessorDeclaration(node) ? "setter" : "field";
|
|
76
|
+
return {
|
|
77
|
+
name,
|
|
78
|
+
kind,
|
|
79
|
+
type: node.type?.getText(file) ?? null,
|
|
80
|
+
filePath,
|
|
81
|
+
startLine: sourceLine(file, node),
|
|
82
|
+
endLine: sourceEndLine(file, node),
|
|
83
|
+
visibility,
|
|
84
|
+
static: flags.some((flag) => flag.kind === ts.SyntaxKind.StaticKeyword),
|
|
85
|
+
optional: !!node.questionToken,
|
|
86
|
+
readonly: flags.some((flag) => flag.kind === ts.SyntaxKind.ReadonlyKeyword)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function declaredType(node) {
|
|
90
|
+
if (ts.isPropertyDeclaration(node) || ts.isPropertySignature(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node))
|
|
91
|
+
return node.type;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
function isClassLike(node) {
|
|
95
|
+
return ts.isClassDeclaration(node) || ts.isClassExpression(node) || ts.isInterfaceDeclaration(node);
|
|
96
|
+
}
|
|
97
|
+
function hash(value) {
|
|
98
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
|
99
|
+
}
|
|
100
|
+
async function addClassMetadata(repositoryPath, nodes, edges) {
|
|
101
|
+
const byFileAndName = new Map;
|
|
102
|
+
const classNodes = nodes.filter((node) => node.labels.some((label) => CLASS_LABELS.has(label)));
|
|
103
|
+
for (const node of classNodes) {
|
|
104
|
+
const key = `${node.filePath}\x00${node.name}`;
|
|
105
|
+
byFileAndName.set(key, [...byFileAndName.get(key) ?? [], node]);
|
|
106
|
+
}
|
|
107
|
+
const byName = new Map;
|
|
108
|
+
for (const node of classNodes)
|
|
109
|
+
byName.set(node.name, [...byName.get(node.name) ?? [], node]);
|
|
110
|
+
const byQualifiedName = new Map;
|
|
111
|
+
for (const node of classNodes) {
|
|
112
|
+
byQualifiedName.set(node.qualifiedName, [...byQualifiedName.get(node.qualifiedName) ?? [], node]);
|
|
113
|
+
}
|
|
114
|
+
const resolveClass = (name, filePath) => {
|
|
115
|
+
const candidates = byName.get(name) ?? [];
|
|
116
|
+
return candidates.find((item) => item.filePath === filePath) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
117
|
+
};
|
|
118
|
+
const sources = new Map;
|
|
119
|
+
const paths = new Set(classNodes.map((node) => node.filePath).filter(Boolean));
|
|
120
|
+
for (const filePath of paths) {
|
|
121
|
+
const scriptKind = SCRIPT_KINDS[extname(filePath).toLowerCase()];
|
|
122
|
+
if (scriptKind === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const text = await readFile(resolve(repositoryPath, filePath), "utf8");
|
|
126
|
+
sources.set(filePath, ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, scriptKind));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
const dependencies = new Map;
|
|
130
|
+
const propertyEdges = new Map;
|
|
131
|
+
const existingDependencyPairs = new Set(edges.filter((edge) => edge.type === "DEPENDS_ON").map((edge) => `${edge.source}\x00${edge.target}`));
|
|
132
|
+
const addDependency = (source, target, reason, member, derivedFrom) => {
|
|
133
|
+
if (source.id === target.id)
|
|
134
|
+
return;
|
|
135
|
+
const pair = `${source.id}\x00${target.id}`;
|
|
136
|
+
if (existingDependencyPairs.has(pair))
|
|
137
|
+
return;
|
|
138
|
+
const id = `derived:${hash(`${source.id}\x00${target.id}\x00${reason}\x00${member}`)}`;
|
|
139
|
+
dependencies.set(id, {
|
|
140
|
+
id,
|
|
141
|
+
source: source.id,
|
|
142
|
+
target: target.id,
|
|
143
|
+
type: "DEPENDS_ON",
|
|
144
|
+
properties: { derivedFrom, reason, member }
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
for (const field of nodes.filter((node) => node.labels.includes("Field") || node.labels.includes("Property"))) {
|
|
148
|
+
const parent = field.properties.parent_class;
|
|
149
|
+
if (typeof parent !== "string")
|
|
150
|
+
continue;
|
|
151
|
+
const candidates = byQualifiedName.get(parent) ?? [];
|
|
152
|
+
const owner = candidates.find((item) => item.filePath === field.filePath) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
153
|
+
if (!owner)
|
|
154
|
+
continue;
|
|
155
|
+
const visibilityText = propertyText(field, "visibility", "access")?.toLowerCase();
|
|
156
|
+
const member = {
|
|
157
|
+
name: field.name,
|
|
158
|
+
kind: "field",
|
|
159
|
+
type: propertyText(field, "return_type", "type", "field_type") ?? null,
|
|
160
|
+
filePath: field.filePath,
|
|
161
|
+
startLine: field.startLine ?? owner.startLine ?? 1,
|
|
162
|
+
endLine: field.endLine ?? field.startLine ?? owner.endLine ?? 1,
|
|
163
|
+
visibility: visibilityText === "private" || visibilityText === "protected" ? visibilityText : "public",
|
|
164
|
+
static: field.properties.static === true || field.properties.is_static === true,
|
|
165
|
+
optional: field.properties.optional === true || field.properties.is_optional === true,
|
|
166
|
+
readonly: field.properties.readonly === true || field.properties.is_readonly === true
|
|
167
|
+
};
|
|
168
|
+
owner.members = [...owner.members ?? [], member];
|
|
169
|
+
const edgeId = `derived:${hash(`${owner.id}\x00${field.id}\x00HAS_PROPERTY`)}`;
|
|
170
|
+
propertyEdges.set(edgeId, {
|
|
171
|
+
id: edgeId,
|
|
172
|
+
source: owner.id,
|
|
173
|
+
target: field.id,
|
|
174
|
+
type: "HAS_PROPERTY",
|
|
175
|
+
properties: { derivedFrom: "upstream-index" }
|
|
176
|
+
});
|
|
177
|
+
if (member.type) {
|
|
178
|
+
for (const typeName of referencedNames(member.type)) {
|
|
179
|
+
const target = resolveClass(typeName, field.filePath);
|
|
180
|
+
if (target)
|
|
181
|
+
addDependency(owner, target, "field-type", field.name, "upstream-index");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const method of nodes.filter((node) => node.labels.includes("Method"))) {
|
|
186
|
+
const parent = method.properties.parent_class;
|
|
187
|
+
if (typeof parent !== "string")
|
|
188
|
+
continue;
|
|
189
|
+
const candidates = byQualifiedName.get(parent) ?? [];
|
|
190
|
+
const owner = candidates.find((item) => item.filePath === method.filePath) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
191
|
+
if (!owner)
|
|
192
|
+
continue;
|
|
193
|
+
const parameterTypes = propertyTypes(method.properties.param_types);
|
|
194
|
+
for (const typeText of parameterTypes) {
|
|
195
|
+
for (const typeName of referencedNames(typeText)) {
|
|
196
|
+
const target = resolveClass(typeName, method.filePath);
|
|
197
|
+
if (target)
|
|
198
|
+
addDependency(owner, target, "method-parameter", method.name, "upstream-index");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const returnType = propertyText(method, "return_type", "returnType");
|
|
202
|
+
if (returnType) {
|
|
203
|
+
for (const typeName of referencedNames(returnType)) {
|
|
204
|
+
const target = resolveClass(typeName, method.filePath);
|
|
205
|
+
if (target)
|
|
206
|
+
addDependency(owner, target, "method-return", method.name, "upstream-index");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const [filePath, file] of sources) {
|
|
211
|
+
const visit = (node) => {
|
|
212
|
+
if (!isClassLike(node) || !node.name) {
|
|
213
|
+
ts.forEachChild(node, visit);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const name = node.name.text;
|
|
217
|
+
const candidates = byFileAndName.get(`${filePath}\x00${name}`) ?? [];
|
|
218
|
+
const start = sourceLine(file, node);
|
|
219
|
+
const owner = candidates.find((candidate) => candidate.startLine === start) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
220
|
+
if (!owner) {
|
|
221
|
+
ts.forEachChild(node, visit);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const members = [];
|
|
225
|
+
const collect = (member) => {
|
|
226
|
+
const value = toMember(file, filePath, member);
|
|
227
|
+
const propertyType = declaredType(member);
|
|
228
|
+
if (value) {
|
|
229
|
+
members.push(value);
|
|
230
|
+
if (propertyType) {
|
|
231
|
+
for (const typeName of typeNames(propertyType)) {
|
|
232
|
+
const target = resolveClass(typeName, filePath);
|
|
233
|
+
if (target)
|
|
234
|
+
addDependency(owner, target, "field-type", value.name, "typescript-ast");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && (member.type || member.parameters.length)) {
|
|
239
|
+
for (const parameter of member.parameters) {
|
|
240
|
+
if (!parameter.type)
|
|
241
|
+
continue;
|
|
242
|
+
for (const typeName of typeNames(parameter.type)) {
|
|
243
|
+
const target = resolveClass(typeName, filePath);
|
|
244
|
+
if (target)
|
|
245
|
+
addDependency(owner, target, "method-parameter", member.name.getText(file), "typescript-ast");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (member.type) {
|
|
249
|
+
for (const typeName of typeNames(member.type)) {
|
|
250
|
+
const target = resolveClass(typeName, filePath);
|
|
251
|
+
if (target)
|
|
252
|
+
addDependency(owner, target, "method-return", member.name.getText(file), "typescript-ast");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
for (const member of node.members)
|
|
258
|
+
collect(member);
|
|
259
|
+
for (const member of members) {
|
|
260
|
+
const isAlreadyIndexed = (owner.members ?? []).some((existing) => existing.name === member.name && existing.filePath === member.filePath && existing.startLine === member.startLine && existing.kind === member.kind);
|
|
261
|
+
if (!isAlreadyIndexed)
|
|
262
|
+
owner.members = [...owner.members ?? [], member];
|
|
263
|
+
}
|
|
264
|
+
ts.forEachChild(node, visit);
|
|
265
|
+
};
|
|
266
|
+
visit(file);
|
|
267
|
+
}
|
|
268
|
+
const classForQualifiedName = (qualifiedName, filePath) => {
|
|
269
|
+
const candidates = byQualifiedName.get(qualifiedName) ?? [];
|
|
270
|
+
return candidates.find((item) => item.filePath === filePath) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
271
|
+
};
|
|
272
|
+
const methodOwners = new Map(nodes.filter((node) => node.labels.includes("Method") && typeof node.properties.parent_class === "string").map((node) => [node.id, classForQualifiedName(node.properties.parent_class, node.filePath)]));
|
|
273
|
+
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
274
|
+
for (const edge of edges.filter((item) => item.type === "CALLS")) {
|
|
275
|
+
const source = methodOwners.get(edge.source);
|
|
276
|
+
const targetNode = nodesById.get(edge.target);
|
|
277
|
+
const target = targetNode && methodOwners.get(targetNode.id) || (targetNode?.labels.some((label) => CLASS_LABELS.has(label)) ? targetNode : undefined);
|
|
278
|
+
if (source && target)
|
|
279
|
+
addDependency(source, target, "method-call", targetNode?.name ?? "", "codebase-memory-mcp");
|
|
280
|
+
}
|
|
281
|
+
edges.push(...propertyEdges.values(), ...dependencies.values());
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
addClassMetadata
|
|
285
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, resolve } from "node:path";
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { collectRows, runTool } from "./cbm.js";
|
|
6
6
|
import { addCallables } from "./callables.js";
|
|
7
|
+
import { addClassMetadata } from "./classes.js";
|
|
7
8
|
var NODE_QUERY = `MATCH (n) RETURN id(n) AS id, labels(n) AS labels,
|
|
8
9
|
n.name AS name, n.qualified_name AS qualifiedName, n.file_path AS filePath,
|
|
9
10
|
n.start_line AS startLine, n.end_line AS endLine, properties(n) AS properties`;
|
|
@@ -118,6 +119,10 @@ async function exportGraph(repository, options = {}, run = runTool) {
|
|
|
118
119
|
const selectedNodes = kind === "calls" ? nodes.filter((node) => endpoints.has(node.id) || node.labels.some((label) => label === "Function" || label === "Method")) : nodes;
|
|
119
120
|
options.onProgress?.("Extracting callable signatures");
|
|
120
121
|
addCallables(selectedNodes);
|
|
122
|
+
if (kind === "all") {
|
|
123
|
+
options.onProgress?.("Extracting class members and type dependencies");
|
|
124
|
+
await addClassMetadata(repositoryPath, nodes, edges);
|
|
125
|
+
}
|
|
121
126
|
return {
|
|
122
127
|
schemaVersion: 1,
|
|
123
128
|
generatedAt: new Date().toISOString(),
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@youichi/repograph",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Export repository code graphs with codebase-memory-mcp.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/hi-youichi/repograph.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"bin": {
|
|
7
11
|
"repograph": "dist/cli.js"
|
|
@@ -11,8 +15,9 @@
|
|
|
11
15
|
"start": "bun run src/cli.ts",
|
|
12
16
|
"typecheck": "tsc --noEmit",
|
|
13
17
|
"test": "bun test",
|
|
14
|
-
"test:integration": "REPOGRAPH_INTEGRATION=1 bun test test/integration.test.ts",
|
|
15
|
-
"
|
|
18
|
+
"test:integration": "REPOGRAPH_INTEGRATION=1 bun test test/integration.test.ts test/languages.integration.test.ts",
|
|
19
|
+
"test:languages": "REPOGRAPH_INTEGRATION=1 bun test test/languages.integration.test.ts",
|
|
20
|
+
"build": "bun build ./src/cli.ts ./src/index.ts ./src/cbm.ts ./src/callables.ts ./src/classes.ts --target node --outdir dist --external \"*\"",
|
|
16
21
|
"prepack": "bun run build"
|
|
17
22
|
},
|
|
18
23
|
"dependencies": {
|
|
@@ -30,5 +35,8 @@
|
|
|
30
35
|
},
|
|
31
36
|
"files": [
|
|
32
37
|
"dist"
|
|
33
|
-
]
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
34
42
|
}
|