@follenfang/wowdoc 0.0.7 → 0.0.9
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 +115 -59
- package/README.zh-CN.md +216 -0
- package/bin/run.mjs +4 -2
- package/package.json +14 -3
- package/scripts/download.mjs +332 -0
- package/scripts/install.mjs +41 -19
- package/scripts/platform.mjs +39 -0
- package/skill/SKILL.md +2 -1
- package/skill/references/commands.md +6 -1
- package/scripts/check-package.mjs +0 -10
- package/scripts/evaluate-quality.mjs +0 -104
package/README.md
CHANGED
|
@@ -1,48 +1,87 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# wowdoc
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
**Versioned World of Warcraft source, ready to cite.**
|
|
6
|
+
|
|
7
|
+
Turn a game build, AddOn version, Tag, or Commit into exact code references for coding agents and AddOn authors.
|
|
8
|
+
|
|
9
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
10
|
+
|
|
11
|
+
[](https://github.com/Follen/wowdoc/actions/workflows/ci.yml)
|
|
12
|
+
[](https://github.com/Follen/wowdoc/actions/workflows/release.yml)
|
|
13
|
+
[](https://www.npmjs.com/package/@follenfang/wowdoc)
|
|
14
|
+
[](https://www.npmjs.com/package/@follenfang/wowdoc)
|
|
15
|
+
[](https://github.com/Follen/wowdoc/releases/latest)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
6
19
|
|
|
7
|
-
|
|
20
|
+
---
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
`wowdoc` is a local, CLI-only source intelligence tool. It resolves mutable names such as `latest`, `retail`, or an AddOn version to an immutable Git Commit, then returns the repository path, line, excerpt, and SHA-256 behind each result.
|
|
23
|
+
|
|
24
|
+
The bundled Agent Skill handles the choice of source, product, version, and command. The CLI handles Git, parsing, indexing, and evidence. Queries stay local after initialization.
|
|
10
25
|
|
|
11
26
|
## Install
|
|
12
27
|
|
|
13
28
|
```powershell
|
|
14
29
|
npm install -g @follenfang/wowdoc
|
|
15
30
|
wowdoc --version
|
|
16
|
-
wowdoc
|
|
31
|
+
wowdoc init
|
|
17
32
|
```
|
|
18
33
|
|
|
19
|
-
The package installs
|
|
34
|
+
The npm package installs:
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
- one executable: `wowdoc`
|
|
37
|
+
- one user-level Skill: `~/.agents/skills/wowdoc`
|
|
38
|
+
|
|
39
|
+
The native executable comes from an npm platform package first, so proxy handling, caching, retries, and package integrity stay with npm. If that package is unavailable, the installer falls back to the matching GitHub Release, reports byte progress, verifies `SHA256SUMS`, resumes a managed `.part` file with HTTP Range, and stops after bounded timeouts and retries. To expose complete lifecycle logs, use:
|
|
22
40
|
|
|
23
41
|
```powershell
|
|
24
|
-
wowdoc
|
|
42
|
+
npm install -g @follenfang/wowdoc --foreground-scripts --verbose
|
|
25
43
|
```
|
|
26
44
|
|
|
27
|
-
|
|
45
|
+
`wowdoc init` creates `~/.wowdoc`, downloads the configured bare Git mirrors, and builds the searchable snapshots. Up to three source mirrors synchronize concurrently, every Git progress line carries a source ID, and transient network errors are retried. Reruns reuse complete Git objects, ref batches, repositories, snapshots, and indexes. An unfinished individual Git pack may be transferred again on the next attempt.
|
|
28
46
|
|
|
29
|
-
|
|
47
|
+
> Git missing? `wowdoc init` detects the platform package manager, shows the command it will run, installs Git, refreshes `PATH`, and verifies `git --version`. `wowdoc doctor` only reports state and never changes it.
|
|
48
|
+
|
|
49
|
+
## What you get
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
branch / Tag / version / Commit
|
|
53
|
+
│
|
|
54
|
+
▼
|
|
55
|
+
immutable Commit
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
Lua · XML · TOC · assets · symbols · relations
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
path · line · excerpt · SHA-256 · resolved Commit
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Version-accurate**: Tag and branch names are resolved once and stored as immutable snapshots.
|
|
65
|
+
- **Traceable**: every code reference carries enough information to verify it against the Git blob.
|
|
66
|
+
- **Concurrent**: each parser task gets a detached worktree; queries never depend on checkout state.
|
|
67
|
+
- **Local after init**: search reads SQLite and immutable Pack objects, without switching Git branches or reaching the network.
|
|
68
|
+
- **Built for agents**: stable, narrow commands with JSON output and explicit diagnostics.
|
|
30
69
|
|
|
31
70
|
## Supported source
|
|
32
71
|
|
|
33
|
-
| Source | Products |
|
|
72
|
+
| Source | Products / channels |
|
|
34
73
|
| --- | --- |
|
|
35
74
|
| Blizzard UI source | Retail, PTR, PTR2, Beta, Classic, Classic PTR/Beta, Classic Era/PTR, Anniversary, Titan |
|
|
36
|
-
| ElvUI | main, PTR |
|
|
37
|
-
| WeakAuras | main |
|
|
38
|
-
| NDui | main, Classic, Era, Anniversary, Titan |
|
|
39
|
-
| EllesmereUI | main |
|
|
75
|
+
| [ElvUI](https://github.com/tukui-org/ElvUI) | main, PTR |
|
|
76
|
+
| [WeakAuras](https://github.com/WeakAuras/WeakAuras2) | main |
|
|
77
|
+
| [NDui](https://github.com/siweia/NDui) | main, Classic, Era, Anniversary, Titan |
|
|
78
|
+
| [EllesmereUI](https://github.com/EllesmereGaming/EllesmereUI) | main |
|
|
40
79
|
|
|
41
|
-
|
|
80
|
+
For third-party AddOns, version truth is `Tag -> Commit -> snapshot`. If a requested version has no matching Tag, the Agent Skill may use the latest snapshot, but the result is clearly marked as a latest fallback and still identifies the resolved Commit.
|
|
42
81
|
|
|
43
|
-
##
|
|
82
|
+
## Use it
|
|
44
83
|
|
|
45
|
-
Find an API definition
|
|
84
|
+
### Find an API definition
|
|
46
85
|
|
|
47
86
|
```powershell
|
|
48
87
|
wowdoc query `
|
|
@@ -53,7 +92,7 @@ wowdoc query `
|
|
|
53
92
|
--text C_AuctionHouse.GetItemSearchResultInfo
|
|
54
93
|
```
|
|
55
94
|
|
|
56
|
-
Inspect
|
|
95
|
+
### Inspect an ElvUI symbol at a released version
|
|
57
96
|
|
|
58
97
|
```powershell
|
|
59
98
|
wowdoc inspect `
|
|
@@ -63,7 +102,7 @@ wowdoc inspect `
|
|
|
63
102
|
--symbol 'lib:RegisterPlugin'
|
|
64
103
|
```
|
|
65
104
|
|
|
66
|
-
Compare two
|
|
105
|
+
### Compare two WeakAuras versions
|
|
67
106
|
|
|
68
107
|
```powershell
|
|
69
108
|
wowdoc diff `
|
|
@@ -73,74 +112,91 @@ wowdoc diff `
|
|
|
73
112
|
--to 5.21.9
|
|
74
113
|
```
|
|
75
114
|
|
|
76
|
-
|
|
115
|
+
### Validate an AddOn against a target snapshot
|
|
77
116
|
|
|
78
|
-
|
|
117
|
+
```powershell
|
|
118
|
+
wowdoc validate `
|
|
119
|
+
--path D:\AddOns\MyAddon `
|
|
120
|
+
--toc MyAddon_Mainline.toc `
|
|
121
|
+
--source wow-ui-source `
|
|
122
|
+
--product retail `
|
|
123
|
+
--ref latest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
With `--toc`, wowdoc validates only the ordered Lua/XML closure actually loaded by that TOC, including recursive XML `Script` and `Include` references. The JSON result includes every file's load order and sources, exact Tag and immutable Commit evidence, diagnostics, unresolved dynamic references, and static coverage counts. Omit `--toc` to retain the original recursive Lua scan.
|
|
127
|
+
|
|
128
|
+
Validate several client TOCs together with a matrix file:
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
wowdoc validate-matrix --config wowdoc.matrix.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The matrix preserves each target result and merges API/event signatures, XML templates, Mixins, Frame types, TOC Interface evidence, target-only files, diagnostics, and unresolved items. A valid static result means no error was found by the covered checks; it is not a guarantee of perfect in-game behavior.
|
|
135
|
+
|
|
136
|
+
Every successful reference identifies the source, product, requested ref, matching Tag when available, resolved Commit, repository path, line, excerpt, and content hash.
|
|
137
|
+
|
|
138
|
+
## Command map
|
|
79
139
|
|
|
80
140
|
```text
|
|
81
|
-
wowdoc query|explore|inspect|diff|validate
|
|
82
|
-
wowdoc source list|check|sync
|
|
83
|
-
wowdoc index build|refresh|status
|
|
84
|
-
wowdoc doctor
|
|
85
|
-
wowdoc init|update|clean|uninstall
|
|
141
|
+
Search wowdoc query | explore | inspect | diff | validate | validate-matrix
|
|
142
|
+
Sources wowdoc source list | check | sync
|
|
143
|
+
Indexes wowdoc index build | refresh | status
|
|
144
|
+
Health wowdoc doctor
|
|
145
|
+
Lifecycle wowdoc init | update | clean | uninstall
|
|
86
146
|
```
|
|
87
147
|
|
|
88
|
-
|
|
148
|
+
A normal update flow is explicit:
|
|
89
149
|
|
|
90
150
|
```powershell
|
|
91
|
-
# Check whether a branch changed without modifying local state
|
|
92
151
|
wowdoc source check --source elvui --product main
|
|
93
|
-
|
|
94
|
-
# Explicitly fetch new Git metadata and source objects
|
|
95
152
|
wowdoc source sync --source elvui --product main
|
|
96
|
-
|
|
97
|
-
# Build and atomically publish the new snapshot
|
|
98
153
|
wowdoc index refresh --source elvui --product main --ref latest
|
|
99
|
-
|
|
100
|
-
# Preview cleanup; no files are deleted
|
|
101
|
-
wowdoc clean
|
|
102
154
|
```
|
|
103
155
|
|
|
104
|
-
|
|
156
|
+
`wowdoc update` updates the npm package and Skill. It does not fetch repositories or rebuild indexes. `wowdoc clean` is a preview unless `--yes` is supplied.
|
|
105
157
|
|
|
106
|
-
## Storage
|
|
158
|
+
## Storage
|
|
107
159
|
|
|
108
|
-
|
|
160
|
+
All local data lives under `~/.wowdoc` by default:
|
|
109
161
|
|
|
110
162
|
```text
|
|
111
|
-
config/
|
|
163
|
+
config/ source catalog and local configuration
|
|
112
164
|
repositories/ complete bare Git mirrors
|
|
113
|
-
objects/
|
|
114
|
-
|
|
115
|
-
ast/ auditable legacy per-file syntax trees (new builds use Pack)
|
|
116
|
-
indexes/ one shared content DB plus one WAL SQLite database per product branch
|
|
165
|
+
objects/packs/ immutable, content-addressed Pack segments
|
|
166
|
+
indexes/ shared content DBs and branch-local WAL/FTS databases
|
|
117
167
|
manifests/ immutable snapshot manifests
|
|
118
168
|
state/ initialization and task state
|
|
119
|
-
tmp/worktrees/ leased detached worktrees used
|
|
120
|
-
locks/ repository and
|
|
169
|
+
tmp/worktrees/ leased detached worktrees used while parsing
|
|
170
|
+
locks/ bounded repository and publish locks
|
|
121
171
|
logs/ local diagnostics
|
|
122
172
|
```
|
|
123
173
|
|
|
124
|
-
|
|
174
|
+
Set `WOWDOC_HOME` to move the data directory:
|
|
175
|
+
|
|
176
|
+
```powershell
|
|
177
|
+
$env:WOWDOC_HOME = 'D:\WOWData\wowdoc'
|
|
178
|
+
wowdoc doctor
|
|
179
|
+
wowdoc init
|
|
180
|
+
```
|
|
125
181
|
|
|
126
|
-
|
|
182
|
+
Content-identical source, AST, and asset bytes are stored once in immutable Pack segments and reused across Tags and branches. A source-level SQLite database holds shared facts; branch databases keep their own snapshot membership and FTS statistics, preserving version filtering and BM25 ordering.
|
|
127
183
|
|
|
128
|
-
|
|
184
|
+
## Performance
|
|
129
185
|
|
|
130
|
-
Measured on Windows 11
|
|
186
|
+
Measured on Windows 11 with a complete local mirror, 8 parser workers, and the same 3,685-file Retail Commit:
|
|
131
187
|
|
|
132
|
-
|
|
|
133
|
-
| --- | ---: | ---: | ---: |
|
|
134
|
-
| `b201d38`
|
|
135
|
-
| compact
|
|
188
|
+
| Pipeline | Cold build | SQLite | Complete home |
|
|
189
|
+
| --- | ---: | ---: | ---: |
|
|
190
|
+
| baseline `b201d38` | 41.1 s | 158.6 MB | 352.2 MB |
|
|
191
|
+
| compact content store | 11.4 s | 67.7 MB | 170.0 MB |
|
|
136
192
|
|
|
137
|
-
|
|
193
|
+
That run reduced parse/index time by 72%, SQLite size by 57%, and total local data by 52%, while retaining full Lua/XML search coverage and identical source evidence. See [the reproducible benchmark notes](docs/performance.md) for the scenario, full-catalog numbers, and trade-offs.
|
|
138
194
|
|
|
139
195
|
## Agent integration
|
|
140
196
|
|
|
141
|
-
The installed Skill contains source/product aliases
|
|
197
|
+
The installed Skill contains command-selection rules and source/product aliases, not copied source facts. An Agent uses the narrowest stable identifier in the question, calls `wowdoc`, and cites the returned evidence.
|
|
142
198
|
|
|
143
|
-
The quality suite
|
|
199
|
+
The quality suite covers 50 realistic AddOn-author questions across product branches and historical Tags. A pass requires the first reference to be correct, relevant, context-complete, version-correct, and byte-for-byte traceable to the resolved Git blob.
|
|
144
200
|
|
|
145
201
|
## Development
|
|
146
202
|
|
|
@@ -153,7 +209,7 @@ npm pack --dry-run
|
|
|
153
209
|
go run ./cmd/wowdoc --help
|
|
154
210
|
```
|
|
155
211
|
|
|
156
|
-
|
|
212
|
+
Tags follow `vMAJOR.MINOR.PATCH`. GitHub Actions tests Windows, Linux, and macOS, builds five CLI binaries, creates a GitHub Release with checksums, and publishes the matching npm package through Trusted Publisher OIDC with provenance.
|
|
157
213
|
|
|
158
214
|
## License
|
|
159
215
|
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# wowdoc
|
|
4
|
+
|
|
5
|
+
**可定位、可验证、带版本的魔兽世界源码参考。**
|
|
6
|
+
|
|
7
|
+
把游戏 Build、插件版本、Tag 或 Commit,变成 Agent 和插件作者可以直接引用的代码证据。
|
|
8
|
+
|
|
9
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
10
|
+
|
|
11
|
+
[](https://github.com/Follen/wowdoc/actions/workflows/ci.yml)
|
|
12
|
+
[](https://github.com/Follen/wowdoc/actions/workflows/release.yml)
|
|
13
|
+
[](https://www.npmjs.com/package/@follenfang/wowdoc)
|
|
14
|
+
[](https://www.npmjs.com/package/@follenfang/wowdoc)
|
|
15
|
+
[](https://github.com/Follen/wowdoc/releases/latest)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
`wowdoc` 是一个纯本地、纯 CLI 的源码检索工具。它把 `latest`、`retail`、插件版本这类会变化的名字固定到不可变 Git Commit,然后给出每条结果对应的仓库路径、行号、代码片段和 SHA-256。
|
|
23
|
+
|
|
24
|
+
随 npm 安装的 Agent Skill 负责判断查哪个源码、产品、版本和命令;CLI 负责 Git、解析、索引和证据。初始化完成后,查询过程全部在本地执行。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
npm install -g @follenfang/wowdoc
|
|
30
|
+
wowdoc --version
|
|
31
|
+
wowdoc init
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
npm 包会安装:
|
|
35
|
+
|
|
36
|
+
- 一个可执行命令:`wowdoc`
|
|
37
|
+
- 一个用户级 Skill:`~/.agents/skills/wowdoc`
|
|
38
|
+
|
|
39
|
+
当前平台的原生程序优先由 npm 平台包提供,因此代理、缓存、重试和包完整性都沿用 npm。平台包不可用时,安装器才回退到同版本 GitHub Release;回退下载会显示字节进度,校验 `SHA256SUMS`,使用 `.part` 与 HTTP Range 续传,并在有界超时和重试后给出明确失败原因。需要查看完整 lifecycle 日志时使用:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
npm install -g @follenfang/wowdoc --foreground-scripts --verbose
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`wowdoc init` 会创建 `~/.wowdoc`,下载配置好的 bare Git mirror,并构建可查询的源码快照。最多三个 source mirror 并发同步,每条 Git 进度都带 source ID;瞬时网络错误会自动重试。初始化按已完整接收的 Git 对象、ref 批次、repository、snapshot 和索引续跑;网络或进程中断后,重新执行同一个命令即可继续。单个尚未完成的 Git pack 可能在下一次尝试中重新传输。
|
|
46
|
+
|
|
47
|
+
> 本机没有 Git 时,`wowdoc init` 会识别系统包管理器,显示即将执行的命令,安装 Git,刷新 `PATH`,最后执行 `git --version` 验证。`wowdoc doctor` 只检查状态,不修改系统。
|
|
48
|
+
|
|
49
|
+
## 它会产出什么
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
分支 / Tag / 版本 / Commit
|
|
53
|
+
│
|
|
54
|
+
▼
|
|
55
|
+
不可变 Commit
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
Lua · XML · TOC · 素材 · 符号 · 调用关系
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
路径 · 行号 · 代码片段 · SHA-256 · 实际 Commit
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **版本准确**:Tag 和分支只解析一次,随后以不可变快照保存。
|
|
65
|
+
- **可以复查**:每条代码参考都能回到对应 Git blob 验证。
|
|
66
|
+
- **支持并发**:每个解析任务使用独立 detached worktree,查询不依赖 checkout 状态。
|
|
67
|
+
- **初始化后纯本地**:查询只读 SQLite 和不可变 Pack,不切分支,也不临时联网。
|
|
68
|
+
- **适合 Agent**:命令稳定、职责单一,提供 JSON 输出和明确诊断。
|
|
69
|
+
|
|
70
|
+
## 支持的源码
|
|
71
|
+
|
|
72
|
+
| 源码 | 产品 / 通道 |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| 暴雪 UI 源码 | Retail、PTR、PTR2、Beta、Classic、Classic PTR/Beta、Classic Era/PTR、Anniversary、Titan |
|
|
75
|
+
| [ElvUI](https://github.com/tukui-org/ElvUI) | main、PTR |
|
|
76
|
+
| [WeakAuras](https://github.com/WeakAuras/WeakAuras2) | main |
|
|
77
|
+
| [NDui](https://github.com/siweia/NDui) | main、Classic、Era、Anniversary、Titan |
|
|
78
|
+
| [EllesmereUI](https://github.com/EllesmereGaming/EllesmereUI) | main |
|
|
79
|
+
|
|
80
|
+
第三方插件的版本依据是 `Tag -> Commit -> snapshot`。如果用户提供的版本没有匹配 Tag,Agent Skill 可以退回该产品的最新快照,但会明确标记这是 latest fallback,并保留实际 Commit。
|
|
81
|
+
|
|
82
|
+
## 常用查询
|
|
83
|
+
|
|
84
|
+
### 查一个 API 定义
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
wowdoc query `
|
|
88
|
+
--source wow-ui-source `
|
|
89
|
+
--product retail `
|
|
90
|
+
--ref latest `
|
|
91
|
+
--topic api `
|
|
92
|
+
--text C_AuctionHouse.GetItemSearchResultInfo
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 查看指定 ElvUI 版本里的符号
|
|
96
|
+
|
|
97
|
+
```powershell
|
|
98
|
+
wowdoc inspect `
|
|
99
|
+
--source elvui `
|
|
100
|
+
--product main `
|
|
101
|
+
--ref v15.18 `
|
|
102
|
+
--symbol 'lib:RegisterPlugin'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 比较两个 WeakAuras 版本
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
wowdoc diff `
|
|
109
|
+
--source weakauras `
|
|
110
|
+
--product main `
|
|
111
|
+
--from 5.20.7 `
|
|
112
|
+
--to 5.21.9
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 按目标版本检查插件
|
|
116
|
+
|
|
117
|
+
```powershell
|
|
118
|
+
wowdoc validate `
|
|
119
|
+
--path D:\AddOns\MyAddon `
|
|
120
|
+
--toc MyAddon_Mainline.toc `
|
|
121
|
+
--source wow-ui-source `
|
|
122
|
+
--product retail `
|
|
123
|
+
--ref latest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
传入 `--toc` 后,wowdoc 只验证该 TOC 实际加载的有序 Lua/XML 闭包,并递归处理 XML `Script` 与 `Include`。JSON 会返回文件加载顺序和来源、精确 Tag、不可变 Commit、诊断、动态未决项和静态覆盖计数。不传 `--toc` 时继续使用原有递归 Lua 扫描。
|
|
127
|
+
|
|
128
|
+
多客户端可以通过矩阵配置一次验证:
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
wowdoc validate-matrix --config wowdoc.matrix.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
矩阵保留每个目标的完整结果,并归并 API/事件签名、XML 模板、Mixin、Frame 类型、TOC Interface、各端独有文件、诊断和动态未决项。静态结果有效只表示已覆盖检查中未发现 error,不保证插件在游戏内完美运行。
|
|
135
|
+
|
|
136
|
+
查询成功后会返回 source、product、用户请求的 ref、匹配到的 Tag、实际 Commit、仓库路径、行号、代码片段和内容哈希。
|
|
137
|
+
|
|
138
|
+
## 命令一览
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
检索 wowdoc query | explore | inspect | diff | validate | validate-matrix
|
|
142
|
+
源码 wowdoc source list | check | sync
|
|
143
|
+
索引 wowdoc index build | refresh | status
|
|
144
|
+
检查 wowdoc doctor
|
|
145
|
+
生命周期 wowdoc init | update | clean | uninstall
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
正常更新流程是显式的:
|
|
149
|
+
|
|
150
|
+
```powershell
|
|
151
|
+
wowdoc source check --source elvui --product main
|
|
152
|
+
wowdoc source sync --source elvui --product main
|
|
153
|
+
wowdoc index refresh --source elvui --product main --ref latest
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`wowdoc update` 只更新 npm 包和 Skill,不拉取源码,也不重建索引。`wowdoc clean` 默认只预览,加入 `--yes` 才执行清理。
|
|
157
|
+
|
|
158
|
+
## 本地存储
|
|
159
|
+
|
|
160
|
+
默认情况下,所有数据都放在 `~/.wowdoc`:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
config/ 源码目录和本地配置
|
|
164
|
+
repositories/ 完整 bare Git mirror
|
|
165
|
+
objects/packs/ 不可变、按内容寻址的 Pack 分段
|
|
166
|
+
indexes/ 共享内容库和分支独立的 WAL/FTS 数据库
|
|
167
|
+
manifests/ 不可变快照清单
|
|
168
|
+
state/ 初始化和任务状态
|
|
169
|
+
tmp/worktrees/ 解析期间使用的临时 detached worktree
|
|
170
|
+
locks/ 仓库和发布锁
|
|
171
|
+
logs/ 本地诊断日志
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
通过 `WOWDOC_HOME` 可以修改数据目录:
|
|
175
|
+
|
|
176
|
+
```powershell
|
|
177
|
+
$env:WOWDOC_HOME = 'D:\WOWData\wowdoc'
|
|
178
|
+
wowdoc doctor
|
|
179
|
+
wowdoc init
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
内容相同的源码、AST 和素材只在不可变 Pack 中保存一份,在不同 Tag 和分支之间复用。source 级 SQLite 保存共享事实;每个分支保留自己的快照成员关系和 FTS 统计,因此版本过滤和 BM25 排序不会被其他分支影响。
|
|
183
|
+
|
|
184
|
+
## 性能
|
|
185
|
+
|
|
186
|
+
以下数据来自 Windows 11、完整本地 mirror、8 个解析线程,以及同一个包含 3,685 个文件的 Retail Commit:
|
|
187
|
+
|
|
188
|
+
| 方案 | 冷构建 | SQLite | 完整数据目录 |
|
|
189
|
+
| --- | ---: | ---: | ---: |
|
|
190
|
+
| 基线 `b201d38` | 41.1 秒 | 158.6 MB | 352.2 MB |
|
|
191
|
+
| 紧凑内容存储 | 11.4 秒 | 67.7 MB | 170.0 MB |
|
|
192
|
+
|
|
193
|
+
这次测试中,解析和索引时间减少 72%,SQLite 减少 57%,完整本地数据减少 52%;Lua/XML 搜索覆盖和返回的源码证据保持一致。详细场景、全量 catalog 数据和取舍见[性能记录](docs/performance.md)。
|
|
194
|
+
|
|
195
|
+
## Agent 怎么使用
|
|
196
|
+
|
|
197
|
+
安装的 Skill 只保存命令选择规则和 source/product 别名,不复制源码知识。Agent 会从问题中选择最窄、最稳定的标识符,调用 `wowdoc`,然后引用 CLI 返回的证据。
|
|
198
|
+
|
|
199
|
+
质量回归包含 50 个真实插件开发问题,覆盖不同产品分支和历史 Tag。首条参考必须同时满足正确、相关、上下文完整、版本正确,并且能逐字节回到实际 Git blob,才算通过。
|
|
200
|
+
|
|
201
|
+
## 开发
|
|
202
|
+
|
|
203
|
+
需要 Go 1.23+、Node.js 20+ 和 Git。
|
|
204
|
+
|
|
205
|
+
```powershell
|
|
206
|
+
go test ./...
|
|
207
|
+
go vet ./...
|
|
208
|
+
npm pack --dry-run
|
|
209
|
+
go run ./cmd/wowdoc --help
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
发布 Tag 使用 `vMAJOR.MINOR.PATCH`。GitHub Actions 会测试 Windows、Linux 和 macOS,构建五个平台二进制,生成校验和与 GitHub Release,再通过 Trusted Publisher OIDC 发布带 provenance 的 npm 包。
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
[MIT](LICENSE)
|
package/bin/run.mjs
CHANGED
|
@@ -2,12 +2,14 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { optionalBinary } from "../scripts/platform.mjs";
|
|
5
6
|
|
|
6
7
|
export function run(name) {
|
|
7
8
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
9
|
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
9
|
-
|
|
10
|
-
if (!existsSync(executable))
|
|
10
|
+
let executable = join(packageRoot, "native", name + suffix);
|
|
11
|
+
if (!existsSync(executable)) executable = optionalBinary(packageRoot);
|
|
12
|
+
if (!executable || !existsSync(executable)) {
|
|
11
13
|
process.stderr.write(`${name}: native binary is missing; reinstall @follenfang/wowdoc\n`);
|
|
12
14
|
process.exit(4);
|
|
13
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@follenfang/wowdoc",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Auditable WoW UI source intelligence CLI for coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,14 +13,25 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bin/",
|
|
16
|
-
"scripts/",
|
|
16
|
+
"scripts/download.mjs",
|
|
17
|
+
"scripts/install.mjs",
|
|
18
|
+
"scripts/platform.mjs",
|
|
17
19
|
"skill/",
|
|
18
20
|
"README.md",
|
|
19
21
|
"LICENSE"
|
|
20
22
|
],
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@follenfang/wowdoc-darwin-arm64": "0.0.9",
|
|
25
|
+
"@follenfang/wowdoc-darwin-x64": "0.0.9",
|
|
26
|
+
"@follenfang/wowdoc-linux-arm64": "0.0.9",
|
|
27
|
+
"@follenfang/wowdoc-linux-x64": "0.0.9",
|
|
28
|
+
"@follenfang/wowdoc-win32-x64": "0.0.9"
|
|
29
|
+
},
|
|
21
30
|
"scripts": {
|
|
22
31
|
"postinstall": "node scripts/install.mjs",
|
|
23
|
-
"test": "
|
|
32
|
+
"test": "npm run test:node && npm run test:go",
|
|
33
|
+
"test:node": "node --test scripts/download.test.mjs",
|
|
34
|
+
"test:go": "go test ./...",
|
|
24
35
|
"prepack": "node scripts/check-package.mjs"
|
|
25
36
|
},
|
|
26
37
|
"engines": {
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { platformKey, releaseAsset } from "./platform.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ATTEMPTS = 5;
|
|
9
|
+
const DEFAULT_CONNECT_MS = 15 * 1000;
|
|
10
|
+
const DEFAULT_ATTEMPT_MS = 10 * 60 * 1000;
|
|
11
|
+
const DEFAULT_IDLE_MS = 45 * 1000;
|
|
12
|
+
const DEFAULT_TOTAL_MS = 45 * 60 * 1000;
|
|
13
|
+
const MAX_BINARY_BYTES = 128 * 1024 * 1024;
|
|
14
|
+
const MAX_CHECKSUM_BYTES = 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export async function downloadRelease({ version, stderr = process.stderr, fetchImpl = fetch, attempts = DEFAULT_ATTEMPTS, connectMs = DEFAULT_CONNECT_MS, attemptMs = DEFAULT_ATTEMPT_MS, idleMs = DEFAULT_IDLE_MS, totalMs = DEFAULT_TOTAL_MS, retryBaseMs = 1_000 } = {}) {
|
|
17
|
+
const asset = releaseAsset(version);
|
|
18
|
+
const cacheRoot = process.env.WOWDOC_CACHE_DIR || join(homedir(), ".cache", "wowdoc", "downloads");
|
|
19
|
+
const dir = join(cacheRoot, version, platformKey());
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
const target = join(dir, asset);
|
|
22
|
+
const part = `${target}.part`;
|
|
23
|
+
const lock = `${target}.lock`;
|
|
24
|
+
const checksumMarker = `${target}.sha256`;
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
await acquireLock(lock, stderr, totalMs);
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(target)) {
|
|
29
|
+
const localChecksum = readChecksumMarker(checksumMarker);
|
|
30
|
+
if (localChecksum && await hashFile(target) === localChecksum) {
|
|
31
|
+
progress(stderr, `using verified cache ${asset}`);
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
if (await verifyFile(target, version, asset, fetchImpl, stderr, connectMs)) {
|
|
35
|
+
writeChecksumMarker(checksumMarker, await hashFile(target));
|
|
36
|
+
progress(stderr, `using verified cache ${asset}`);
|
|
37
|
+
return target;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const checksumBudget = remainingBudget(started, totalMs);
|
|
41
|
+
const checksum = await getChecksum(version, asset, fetchImpl, stderr, attempts, connectMs, attemptMs, idleMs, checksumBudget);
|
|
42
|
+
let lastError;
|
|
43
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
44
|
+
const remaining = totalMs - (Date.now() - started);
|
|
45
|
+
if (remaining <= 0) throw new Error("download_total_timeout");
|
|
46
|
+
try {
|
|
47
|
+
await fetchToPart({ version, asset, part, checksum, fetchImpl, stderr, attempt, attempts, connectMs, timeoutMs: Math.min(attemptMs, remaining), idleMs });
|
|
48
|
+
try { unlinkSync(target); } catch {}
|
|
49
|
+
renameSync(part, target);
|
|
50
|
+
writeChecksumMarker(checksumMarker, checksum);
|
|
51
|
+
return target;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
lastError = error;
|
|
54
|
+
try { if (error.code === "checksum_mismatch" || error.code === "content_invalid") unlinkSync(part); } catch {}
|
|
55
|
+
if (!retryable(error) || attempt === attempts) break;
|
|
56
|
+
const waitMs = error.retryAfter ?? Math.min(30_000, retryBaseMs * 2 ** (attempt - 1)) + Math.floor(Math.random() * Math.min(250, retryBaseMs));
|
|
57
|
+
progress(stderr, `retry ${attempt}/${attempts - 1} in ${waitMs}ms: ${safeMessage(error)}`);
|
|
58
|
+
await sleepWithinBudget(waitMs, started, totalMs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw lastError || new Error("download_failed");
|
|
62
|
+
} finally {
|
|
63
|
+
try { rmSync(lock, { recursive: true, force: true }); } catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchToPart({ version, asset, part, checksum, fetchImpl, stderr, attempt, attempts, connectMs, timeoutMs, idleMs }) {
|
|
68
|
+
const url = releaseURL(version, asset);
|
|
69
|
+
const attemptStarted = Date.now();
|
|
70
|
+
let existing = existsSync(part) ? statSync(part).size : 0;
|
|
71
|
+
const headers = existing > 0 ? { Range: `bytes=${existing}-`, "Accept-Encoding": "identity" } : { "Accept-Encoding": "identity" };
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(new Error("download_attempt_timeout")), timeoutMs);
|
|
74
|
+
let connectTimer = setTimeout(() => controller.abort(new Error("download_connection_timeout")), Math.min(connectMs, timeoutMs));
|
|
75
|
+
let idleTimer;
|
|
76
|
+
let output;
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetchImpl(url, { redirect: "follow", headers, signal: controller.signal });
|
|
79
|
+
clearTimeout(connectTimer);
|
|
80
|
+
connectTimer = null;
|
|
81
|
+
assertReleaseURL(response.url);
|
|
82
|
+
if (response.status === 416 && existing > 0) {
|
|
83
|
+
if (await hashFile(part) === checksum) return;
|
|
84
|
+
unlinkSync(part);
|
|
85
|
+
existing = 0;
|
|
86
|
+
const remaining = timeoutMs - (Date.now() - attemptStarted);
|
|
87
|
+
if (remaining <= 0) throw new Error("download_attempt_timeout");
|
|
88
|
+
return fetchToPart({ version, asset, part, checksum, fetchImpl, stderr, attempt, attempts, connectMs, timeoutMs: remaining, idleMs });
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const error = new Error(`http_${response.status}`);
|
|
92
|
+
error.status = response.status;
|
|
93
|
+
error.retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const resumed = existing > 0 && response.status === 206 && contentRangeStartsAt(response.headers.get("content-range"), existing);
|
|
97
|
+
if (!resumed) {
|
|
98
|
+
existing = 0;
|
|
99
|
+
const fd = openSync(part, "w");
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
const expected = expectedLength(response, existing);
|
|
103
|
+
if (expected > MAX_BINARY_BYTES || existing > MAX_BINARY_BYTES) {
|
|
104
|
+
const error = new Error("content_too_large");
|
|
105
|
+
error.code = "content_too_large";
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
output = createWriteStream(part, { flags: resumed ? "a" : "w" });
|
|
109
|
+
let received = existing;
|
|
110
|
+
const transferStarted = Date.now();
|
|
111
|
+
const transferOffset = existing;
|
|
112
|
+
let lastReport = 0;
|
|
113
|
+
const report = () => {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (now - lastReport < 500 && received !== expected) return;
|
|
116
|
+
lastReport = now;
|
|
117
|
+
const total = expected > 0 ? `${formatBytes(received)}/${formatBytes(expected)}` : formatBytes(received);
|
|
118
|
+
const elapsedMs = Math.max(1, now - transferStarted);
|
|
119
|
+
const speed = (received - transferOffset) * 1000 / elapsedMs;
|
|
120
|
+
progress(stderr, `download ${asset} ${total} ${formatBytes(speed)}/s elapsed ${formatDuration(elapsedMs)} attempt ${attempt}/${attempts}`, true);
|
|
121
|
+
};
|
|
122
|
+
idleTimer = setInterval(() => controller.abort(new Error("download_idle_timeout")), idleMs);
|
|
123
|
+
if (!response.body) throw new Error("download_empty_body");
|
|
124
|
+
for await (const chunk of response.body) {
|
|
125
|
+
clearInterval(idleTimer);
|
|
126
|
+
idleTimer = setInterval(() => controller.abort(new Error("download_idle_timeout")), idleMs);
|
|
127
|
+
if (!output.write(chunk)) await onceDrain(output);
|
|
128
|
+
received += chunk.length;
|
|
129
|
+
if (received > MAX_BINARY_BYTES) {
|
|
130
|
+
const error = new Error("content_too_large");
|
|
131
|
+
error.code = "content_too_large";
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
report();
|
|
135
|
+
}
|
|
136
|
+
await new Promise((resolve, reject) => { output.end(error => error ? reject(error) : resolve()); });
|
|
137
|
+
if (expected > 0 && received !== expected) {
|
|
138
|
+
const error = new Error(`content_length_mismatch: got ${received}, expected ${expected}`);
|
|
139
|
+
error.code = "content_invalid";
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const actual = await hashFile(part);
|
|
143
|
+
if (actual !== checksum) {
|
|
144
|
+
const error = new Error(`checksum_mismatch: got ${actual}`);
|
|
145
|
+
error.code = "checksum_mismatch";
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
progress(stderr, `verified ${asset} sha256=${actual}`);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (output && !output.closed) await closeWriteStream(output);
|
|
151
|
+
if (controller.signal.aborted && controller.signal.reason) throw controller.signal.reason;
|
|
152
|
+
throw error;
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
156
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function getChecksum(version, asset, fetchImpl, stderr, attempts, connectMs, attemptMs, idleMs, totalMs) {
|
|
161
|
+
const checksums = await fetchTextWithRetry(releaseURL(version, "SHA256SUMS"), fetchImpl, stderr, attempts, connectMs, attemptMs, idleMs, totalMs);
|
|
162
|
+
const row = checksums.split(/\r?\n/).find(line => {
|
|
163
|
+
const match = line.trim().match(/^[a-fA-F0-9]{64}\s+[ *]?(.+)$/);
|
|
164
|
+
return match && basename(match[1]) === asset;
|
|
165
|
+
});
|
|
166
|
+
if (!row) throw new Error(`checksum_missing: ${asset}`);
|
|
167
|
+
const value = row.trim().split(/\s+/)[0].toLowerCase();
|
|
168
|
+
if (!/^[a-f0-9]{64}$/.test(value)) throw new Error("checksum_invalid");
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchTextWithRetry(url, fetchImpl, stderr, attempts, connectMs, attemptMs, idleMs, totalMs) {
|
|
173
|
+
let lastError;
|
|
174
|
+
const started = Date.now();
|
|
175
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
176
|
+
const remaining = remainingBudget(started, totalMs);
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timer = setTimeout(() => controller.abort(new Error("download_attempt_timeout")), Math.min(attemptMs, remaining));
|
|
179
|
+
let connectTimer = setTimeout(() => controller.abort(new Error("download_connection_timeout")), Math.min(connectMs, attemptMs, remaining));
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetchImpl(url, { redirect: "follow", signal: controller.signal });
|
|
182
|
+
clearTimeout(connectTimer);
|
|
183
|
+
connectTimer = null;
|
|
184
|
+
assertReleaseURL(response.url);
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const e = new Error(`http_${response.status}`);
|
|
187
|
+
e.status = response.status;
|
|
188
|
+
e.retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
const declared = Number(response.headers.get("content-length"));
|
|
192
|
+
if (Number.isFinite(declared) && declared > MAX_CHECKSUM_BYTES) throw new Error("checksum_manifest_too_large");
|
|
193
|
+
return await readTextBody(response, controller, idleMs, MAX_CHECKSUM_BYTES);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
lastError = controller.signal.aborted && controller.signal.reason ? controller.signal.reason : error;
|
|
196
|
+
if (!retryable(lastError) || attempt === attempts) throw lastError;
|
|
197
|
+
const waitMs = lastError.retryAfter ?? Math.min(30_000, 1_000 * 2 ** (attempt - 1));
|
|
198
|
+
progress(stderr, `checksum retry ${attempt}/${attempts - 1} in ${waitMs}ms: ${safeMessage(lastError)}`);
|
|
199
|
+
await sleepWithinBudget(waitMs, started, totalMs);
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw lastError;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function verifyFile(path, version, asset, fetchImpl, stderr, connectMs) {
|
|
209
|
+
try {
|
|
210
|
+
const checksum = await getChecksum(version, asset, fetchImpl, stderr, 2, connectMs, 30_000, 15_000, 60_000);
|
|
211
|
+
return await hashFile(path) === checksum;
|
|
212
|
+
} catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function releaseURL(version, asset) { return `https://github.com/Follen/wowdoc/releases/download/v${version}/${asset}`; }
|
|
216
|
+
function contentRangeStartsAt(value, offset) { return Boolean(value && new RegExp(`^bytes ${offset}-\\d+\\/\\d+$`).test(value)); }
|
|
217
|
+
function expectedLength(response, offset) { const length = Number(response.headers.get("content-length")); return Number.isFinite(length) && length >= 0 ? offset + length : 0; }
|
|
218
|
+
function retryable(error) { return error?.name === "AbortError" || error?.code === "ETIMEDOUT" || error?.code === "ECONNRESET" || error?.code === "checksum_mismatch" || error?.code === "content_invalid" || error?.status === 408 || error?.status === 429 || error?.status >= 500 || /timeout|reset|network|fetch failed|dns|tls/i.test(String(error?.message)); }
|
|
219
|
+
function safeMessage(error) { return String(error?.message || error).replace(/https?:\/\/\S+/g, "<url>"); }
|
|
220
|
+
const progressState = new WeakMap();
|
|
221
|
+
function progress(stderr, message, transient = false) {
|
|
222
|
+
if (!stderr?.write) return;
|
|
223
|
+
const line = `wowdoc: ${message}`;
|
|
224
|
+
if (stderr.isTTY !== true) {
|
|
225
|
+
stderr.write(`${line}\n`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const state = progressState.get(stderr) || { active: false, width: 0, lastAt: 0 };
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (transient) {
|
|
231
|
+
if (state.active && now - state.lastAt < 250) return;
|
|
232
|
+
stderr.write(`\r${line.padEnd(state.width)}`);
|
|
233
|
+
state.active = true;
|
|
234
|
+
state.width = Math.max(state.width, line.length);
|
|
235
|
+
state.lastAt = now;
|
|
236
|
+
} else {
|
|
237
|
+
stderr.write(state.active ? `\r${line.padEnd(state.width)}\n` : `${line}\n`);
|
|
238
|
+
state.active = false;
|
|
239
|
+
state.width = 0;
|
|
240
|
+
state.lastAt = now;
|
|
241
|
+
}
|
|
242
|
+
progressState.set(stderr, state);
|
|
243
|
+
}
|
|
244
|
+
function parseRetryAfter(value) {
|
|
245
|
+
if (!value) return undefined;
|
|
246
|
+
const seconds = Number(value);
|
|
247
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(30_000, seconds * 1000);
|
|
248
|
+
const date = Date.parse(value);
|
|
249
|
+
return Number.isFinite(date) ? Math.min(30_000, Math.max(0, date - Date.now())) : undefined;
|
|
250
|
+
}
|
|
251
|
+
function remainingBudget(started, totalMs) {
|
|
252
|
+
const remaining = totalMs - (Date.now() - started);
|
|
253
|
+
if (remaining <= 0) throw new Error("download_total_timeout");
|
|
254
|
+
return remaining;
|
|
255
|
+
}
|
|
256
|
+
async function sleepWithinBudget(waitMs, started, totalMs) {
|
|
257
|
+
if (waitMs >= remainingBudget(started, totalMs)) throw new Error("download_total_timeout");
|
|
258
|
+
await sleep(waitMs);
|
|
259
|
+
}
|
|
260
|
+
async function readTextBody(response, controller, idleMs, maxBytes) {
|
|
261
|
+
if (!response.body) throw new Error("download_empty_body");
|
|
262
|
+
const chunks = [];
|
|
263
|
+
let size = 0;
|
|
264
|
+
let idleTimer;
|
|
265
|
+
const resetIdle = () => {
|
|
266
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
267
|
+
idleTimer = setTimeout(() => controller.abort(new Error("download_idle_timeout")), idleMs);
|
|
268
|
+
};
|
|
269
|
+
resetIdle();
|
|
270
|
+
try {
|
|
271
|
+
for await (const chunk of response.body) {
|
|
272
|
+
resetIdle();
|
|
273
|
+
size += chunk.length;
|
|
274
|
+
if (size > maxBytes) throw new Error("checksum_manifest_too_large");
|
|
275
|
+
chunks.push(chunk);
|
|
276
|
+
}
|
|
277
|
+
return Buffer.concat(chunks, size).toString("utf8");
|
|
278
|
+
} finally {
|
|
279
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function formatBytes(value) { if (value < 1024) return `${Math.round(value)} B`; if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`; return `${(value / 1024 ** 2).toFixed(1)} MiB`; }
|
|
283
|
+
function formatDuration(value) { return value < 1000 ? `${value}ms` : `${(value / 1000).toFixed(1)}s`; }
|
|
284
|
+
function hashFile(path) { return new Promise((resolve, reject) => { const hash = createHash("sha256"); const stream = createReadStream(path); stream.on("data", chunk => hash.update(chunk)); stream.on("error", reject); stream.on("end", () => resolve(hash.digest("hex"))); }); }
|
|
285
|
+
function onceDrain(stream) { return new Promise(resolve => stream.once("drain", resolve)); }
|
|
286
|
+
function closeWriteStream(stream) { return new Promise(resolve => { stream.once("close", resolve); stream.destroy(); if (stream.closed) resolve(); }); }
|
|
287
|
+
function assertReleaseURL(value) {
|
|
288
|
+
if (!value) return;
|
|
289
|
+
const url = new URL(value);
|
|
290
|
+
if (url.protocol !== "https:" || !(url.hostname === "github.com" || url.hostname.endsWith(".githubusercontent.com"))) {
|
|
291
|
+
throw new Error("download_redirect_not_allowed");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function readChecksumMarker(path) {
|
|
295
|
+
try {
|
|
296
|
+
const value = readFileSync(path, "utf8").trim().toLowerCase();
|
|
297
|
+
return /^[a-f0-9]{64}$/.test(value) ? value : null;
|
|
298
|
+
} catch { return null; }
|
|
299
|
+
}
|
|
300
|
+
function writeChecksumMarker(path, checksum) {
|
|
301
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
302
|
+
writeFileSync(temporary, `${checksum}\n`, { mode: 0o600 });
|
|
303
|
+
try { renameSync(temporary, path); }
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code !== "EEXIST" && error.code !== "EPERM") throw error;
|
|
306
|
+
rmSync(path, { force: true });
|
|
307
|
+
renameSync(temporary, path);
|
|
308
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function acquireLock(path, stderr, timeoutMs) {
|
|
312
|
+
const started = Date.now();
|
|
313
|
+
while (true) {
|
|
314
|
+
try {
|
|
315
|
+
mkdirSync(path);
|
|
316
|
+
writeFileSync(join(path, "owner.json"), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
317
|
+
return;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error.code !== "EEXIST") throw error;
|
|
320
|
+
try {
|
|
321
|
+
if (Date.now() - statSync(path).mtimeMs > 60 * 60 * 1000) {
|
|
322
|
+
rmSync(path, { recursive: true, force: true });
|
|
323
|
+
progress(stderr, "removed stale installer lock");
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
if (Date.now() - started > timeoutMs) throw new Error("download_lock_timeout");
|
|
328
|
+
progress(stderr, "waiting for another installer");
|
|
329
|
+
await sleep(1_000);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
package/scripts/install.mjs
CHANGED
|
@@ -1,40 +1,46 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { dirname, join, relative } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { downloadRelease } from "./download.mjs";
|
|
8
|
+
import { optionalBinary, platformKey } from "./platform.mjs";
|
|
7
9
|
|
|
8
10
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
11
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
10
12
|
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
11
|
-
|
|
12
|
-
"linux-x64": "linux-amd64",
|
|
13
|
-
"linux-arm64": "linux-arm64",
|
|
14
|
-
"win32-x64": "windows-amd64",
|
|
15
|
-
"darwin-x64": "darwin-amd64",
|
|
16
|
-
"darwin-arm64": "darwin-arm64",
|
|
17
|
-
};
|
|
18
|
-
const platform = platformNames[`${process.platform}-${process.arch}`];
|
|
19
|
-
if (!platform) throw new Error(`unsupported_platform: ${process.platform}-${process.arch}`);
|
|
13
|
+
platformKey();
|
|
20
14
|
const nativeDir = join(root, "native");
|
|
21
15
|
mkdirSync(nativeDir, { recursive: true });
|
|
22
16
|
|
|
23
17
|
const name = "wowdoc";
|
|
24
18
|
const target = join(nativeDir, name + suffix);
|
|
25
19
|
const supplied = process.env.WOWDOC_BINARY_DIR && join(process.env.WOWDOC_BINARY_DIR, name + suffix);
|
|
20
|
+
const packaged = optionalBinary(root);
|
|
26
21
|
if (supplied && existsSync(supplied)) {
|
|
27
|
-
|
|
22
|
+
process.stderr.write(`wowdoc: using supplied ${platformKey()} binary\n`);
|
|
23
|
+
installBinary(supplied, target);
|
|
28
24
|
} else if (existsSync(join(root, "go.mod"))) {
|
|
29
|
-
|
|
25
|
+
process.stderr.write(`wowdoc: building ${platformKey()} binary from source\n`);
|
|
26
|
+
const built = `${target}.build-${process.pid}`;
|
|
27
|
+
execFileSync("go", ["build", "-trimpath", "-ldflags", `-s -w -X github.com/follenfang/wowdoc/internal/app.Version=${pkg.version}`, "-o", built, `./cmd/${name}`], { cwd: root, stdio: "inherit" });
|
|
28
|
+
installBinary(built, target);
|
|
29
|
+
rmSync(built, { force: true });
|
|
30
|
+
} else if (packaged && existsSync(packaged)) {
|
|
31
|
+
process.stderr.write(`wowdoc: using verified ${platformKey()} platform package\n`);
|
|
32
|
+
installBinary(packaged, target);
|
|
30
33
|
} else {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
process.stderr.write(`wowdoc: platform package unavailable; downloading verified GitHub Release fallback\n`);
|
|
35
|
+
try {
|
|
36
|
+
const downloaded = await downloadRelease({ version: pkg.version, root, stderr: process.stderr });
|
|
37
|
+
installBinary(downloaded, target);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
process.stderr.write(`wowdoc: binary download failed after bounded retries: ${error.message}\n`);
|
|
40
|
+
process.stderr.write(`wowdoc: retry npm install with --foreground-scripts --verbose after checking npm and GitHub connectivity\n`);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
|
-
if (process.platform !== "win32") chmodSync(target, 0o755);
|
|
38
44
|
|
|
39
45
|
const skillSource = join(root, "skill");
|
|
40
46
|
const skillTarget = join(homedir(), ".agents", "skills", "wowdoc");
|
|
@@ -70,6 +76,22 @@ function files(directory) {
|
|
|
70
76
|
return output;
|
|
71
77
|
}
|
|
72
78
|
function hash(buffer) { return createHash("sha256").update(buffer).digest("hex"); }
|
|
79
|
+
function installBinary(source, destination) {
|
|
80
|
+
const temporary = `${destination}.tmp-${process.pid}`;
|
|
81
|
+
rmSync(temporary, { force: true });
|
|
82
|
+
try {
|
|
83
|
+
cpSync(source, temporary);
|
|
84
|
+
if (process.platform !== "win32") chmodSync(temporary, 0o755);
|
|
85
|
+
try { renameSync(temporary, destination); }
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code !== "EEXIST" && error.code !== "EPERM") throw error;
|
|
88
|
+
rmSync(destination, { force: true });
|
|
89
|
+
renameSync(temporary, destination);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(temporary, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
73
95
|
function writeManifest(target, sourceFiles) {
|
|
74
96
|
const mapped = {};
|
|
75
97
|
for (const source of sourceFiles) mapped[relative(skillSource, source).replaceAll("\\", "/")] = hash(readFileSync(source));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
export function platformKey() {
|
|
7
|
+
const key = `${process.platform}-${process.arch}`;
|
|
8
|
+
const supported = new Set(["linux-x64", "linux-arm64", "win32-x64", "darwin-x64", "darwin-arm64"]);
|
|
9
|
+
if (!supported.has(key)) throw new Error(`unsupported_platform: ${key}`);
|
|
10
|
+
return key;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function binaryName() {
|
|
14
|
+
return process.platform === "win32" ? "wowdoc.exe" : "wowdoc";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function releaseAsset(version) {
|
|
18
|
+
const names = {
|
|
19
|
+
"linux-x64": "linux-amd64",
|
|
20
|
+
"linux-arm64": "linux-arm64",
|
|
21
|
+
"win32-x64": "windows-amd64",
|
|
22
|
+
"darwin-x64": "darwin-amd64",
|
|
23
|
+
"darwin-arm64": "darwin-arm64",
|
|
24
|
+
};
|
|
25
|
+
return `wowdoc-${names[platformKey()]}${process.platform === "win32" ? ".exe" : ""}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function optionalPackageName() {
|
|
29
|
+
return `@follenfang/wowdoc-${platformKey()}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function optionalBinary(root) {
|
|
33
|
+
try {
|
|
34
|
+
const packageJSON = require.resolve(`${optionalPackageName()}/package.json`, { paths: [root] });
|
|
35
|
+
return join(dirname(packageJSON), "bin", binaryName());
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -9,11 +9,12 @@ Translate the user's wording into explicit `source`, `product`, `ref`, `topic`,
|
|
|
9
9
|
|
|
10
10
|
1. Run `wowdoc source list --source SOURCE --product PRODUCT` when source, product, or available versions are uncertain.
|
|
11
11
|
2. Prefer an exact displayed plugin version. The CLI maps it only to an exact Tag and immutable Commit.
|
|
12
|
-
3. Use one read command: `query` for a focused question, `explore` for a subsystem, `inspect` for a known symbol/path, `diff` for two versions, or `validate` for
|
|
12
|
+
3. Use one read command: `query` for a focused question, `explore` for a subsystem, `inspect` for a known symbol/path, `diff` for two versions, `validate --toc` for one client AddOn load closure, or `validate-matrix` for multiple client TOCs. Omit `--toc` only for the legacy recursive Lua scan.
|
|
13
13
|
4. If the CLI returns `snapshot_not_ready`, run only the listed `source sync` and `index build|refresh` steps, then retry the original read command.
|
|
14
14
|
5. If an exact plugin version returns `version_not_found`, keep the same source and product, run `source check`, synchronize and refresh if needed, then query `latest`. Mark the answer with `requestedVersion`, `matchedTag=null`, `resolutionMode=latest_fallback`, product branch, resolved Commit, and state that the evidence is from latest rather than the requested version.
|
|
15
15
|
6. Do not apply latest fallback to `ambiguous_version`, `unsupported_build`, `ref_not_found`, or update failures.
|
|
16
16
|
7. Cite `sourceId`, product, Tag when present, resolved Commit, path, line, and the returned excerpt. Treat `dynamic-unresolved` edges as unresolved, not exact.
|
|
17
|
+
8. A successful static validation means the reported checks found no error. Never describe it as a guarantee that the AddOn runs perfectly in game.
|
|
17
18
|
|
|
18
19
|
The data directory defaults to `~/.wowdoc`. `WOWDOC_HOME` may override it with another writable path for an isolated workspace, another drive, or a server deployment. This changes only wowdoc's source, index, object, lock, and log storage; it does not change the npm installation directory or the Skill directory. Run `wowdoc doctor` to see the resolved data directory.
|
|
19
20
|
|
|
@@ -6,7 +6,8 @@ wowdoc explore --source SOURCE --product PRODUCT --ref REF --topic TOPIC --text
|
|
|
6
6
|
wowdoc inspect --source SOURCE --product PRODUCT --ref REF --symbol QUALIFIED_NAME
|
|
7
7
|
wowdoc inspect --source SOURCE --product PRODUCT --ref REF --path REPOSITORY_PATH
|
|
8
8
|
wowdoc diff --source SOURCE --product PRODUCT --from REF --to REF
|
|
9
|
-
wowdoc validate --path ADDON_DIR --source SOURCE --product PRODUCT --ref REF
|
|
9
|
+
wowdoc validate --path ADDON_DIR --toc TOC_FILE --source SOURCE --product PRODUCT --ref REF
|
|
10
|
+
wowdoc validate-matrix --config MATRIX_JSON
|
|
10
11
|
wowdoc source list --source SOURCE --product PRODUCT
|
|
11
12
|
wowdoc source check --source SOURCE --product PRODUCT
|
|
12
13
|
wowdoc source sync --source SOURCE --product PRODUCT
|
|
@@ -24,6 +25,8 @@ wowdoc uninstall --yes
|
|
|
24
25
|
|
|
25
26
|
Use exact qualified identifiers when known. For natural-language questions, select a topic and use the narrowest stable identifier, event, template, TOC field, asset path, or API name present in the question. Prefer results marked `exact_symbol`; verify relationship confidence and retain the returned excerpt as evidence.
|
|
26
27
|
|
|
28
|
+
Use `validate --toc` when a client TOC is known. It limits checks to that TOC's ordered Lua/XML load closure. Use `validate-matrix` when the AddOn declares multiple client TOCs. Keep `unresolved` items unresolved, and describe `valid: true` as no error found by the reported static checks rather than proof of perfect in-game behavior. Omit `--toc` only when the caller intentionally wants the legacy recursive Lua scan.
|
|
29
|
+
|
|
27
30
|
## Data directory
|
|
28
31
|
|
|
29
32
|
Without configuration, all source mirrors, objects, indexes, manifests, locks, logs, and temporary worktrees live under `~/.wowdoc`.
|
|
@@ -36,6 +39,8 @@ wowdoc doctor
|
|
|
36
39
|
wowdoc init
|
|
37
40
|
```
|
|
38
41
|
|
|
42
|
+
Initialization synchronizes up to three source mirrors concurrently. Git progress is written to stderr with a source ID while stdout remains the JSON result. Transient Git failures are retried, and rerunning the command reuses complete objects, ref batches, repositories, snapshots, and indexes.
|
|
43
|
+
|
|
39
44
|
Bash or zsh:
|
|
40
45
|
|
|
41
46
|
```bash
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
|
|
3
|
-
for (const path of ["bin/wowdoc.mjs", "scripts/install.mjs", "skill/SKILL.md"]) {
|
|
4
|
-
if (!existsSync(new URL(`../${path}`, import.meta.url))) throw new Error(`package file missing: ${path}`);
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
|
-
if (JSON.stringify(pkg.bin) !== JSON.stringify({ wowdoc: "bin/wowdoc.mjs" })) {
|
|
9
|
-
throw new Error("package must expose only the wowdoc executable");
|
|
10
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
|
|
6
|
-
const root = resolve(import.meta.dirname, "..");
|
|
7
|
-
const qualityDir = join(root, "analyze", "quality");
|
|
8
|
-
const scenarios = JSON.parse(readFileSync(join(qualityDir, "scenarios.json"), "utf8"));
|
|
9
|
-
const home = process.env.WOWDOC_HOME;
|
|
10
|
-
if (!home) throw new Error("WOWDOC_HOME is required so quality data stays isolated");
|
|
11
|
-
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
12
|
-
const wowdoc = process.env.WOWDOC_BIN || join(root, "dist", "wowdoc" + suffix);
|
|
13
|
-
if (!existsSync(wowdoc)) throw new Error(`wowdoc binary not found: ${wowdoc}`);
|
|
14
|
-
|
|
15
|
-
const listCache = new Map();
|
|
16
|
-
const results = [];
|
|
17
|
-
for (const [index, scenario] of scenarios.entries()) {
|
|
18
|
-
const listKey = `${scenario.source}:${scenario.product}`;
|
|
19
|
-
let sourceList = listCache.get(listKey);
|
|
20
|
-
if (!sourceList) {
|
|
21
|
-
sourceList = runJSON(["source", "list", "--source", scenario.source, "--product", scenario.product]);
|
|
22
|
-
listCache.set(listKey, sourceList);
|
|
23
|
-
}
|
|
24
|
-
const tags = sourceList.data?.tags ?? [];
|
|
25
|
-
let ref = scenario.ref ?? "latest";
|
|
26
|
-
if (Number.isInteger(scenario.tagIndex)) {
|
|
27
|
-
if (tags.length === 0) {
|
|
28
|
-
results.push(failedWithoutQuery(scenario, "no catalog Tag is available for this product branch"));
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
const selected = tags[Math.min(scenario.tagIndex, tags.length - 1)];
|
|
32
|
-
ref = selected.name ?? selected.Name;
|
|
33
|
-
}
|
|
34
|
-
process.stderr.write(`[${index + 1}/${scenarios.length}] ${scenario.id} ref=${ref}\n`);
|
|
35
|
-
let envelope = runJSON(queryArgs(scenario, ref), true);
|
|
36
|
-
if (!envelope.ok && envelope.error?.code === "snapshot_not_ready") {
|
|
37
|
-
const build = runJSON(["index", "build", "--source", scenario.source, "--product", scenario.product, "--ref", ref], true, 30 * 60 * 1000);
|
|
38
|
-
if (!build.ok) {
|
|
39
|
-
results.push(failedWithoutQuery(scenario, `index build failed: ${build.error?.code ?? "unknown"}`, ref));
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
envelope = runJSON(queryArgs(scenario, ref), true);
|
|
43
|
-
}
|
|
44
|
-
results.push(evaluate(scenario, ref, envelope));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const summary = summarize(results);
|
|
48
|
-
const artifact = { schema: "wowdoc.quality.v1", generatedAt: new Date().toISOString(), home, summary, results };
|
|
49
|
-
mkdirSync(qualityDir, { recursive: true });
|
|
50
|
-
writeFileSync(join(qualityDir, "results.json"), JSON.stringify(artifact, null, 2));
|
|
51
|
-
writeFileSync(join(qualityDir, "report.md"), markdown(artifact));
|
|
52
|
-
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
53
|
-
process.exit(summary.passed === summary.total ? 0 : 1);
|
|
54
|
-
|
|
55
|
-
function queryArgs(s, ref) {
|
|
56
|
-
return ["query", "--source", s.source, "--product", s.product, "--ref", ref, "--topic", s.topic, "--text", s.query, "--limit", "5"];
|
|
57
|
-
}
|
|
58
|
-
function runJSON(args, allowFailure = false, timeout = 120000) {
|
|
59
|
-
const child = spawnSync(wowdoc, args, { cwd: root, env: process.env, encoding: "utf8", timeout, windowsHide: true });
|
|
60
|
-
let parsed;
|
|
61
|
-
try { parsed = JSON.parse(child.stdout); } catch { parsed = { ok: false, error: { code: "invalid_json", message: child.stdout || child.stderr } }; }
|
|
62
|
-
if (!allowFailure && (!parsed.ok || child.status !== 0)) throw new Error(`${args.join(" ")}: ${child.stderr || child.stdout}`);
|
|
63
|
-
return parsed;
|
|
64
|
-
}
|
|
65
|
-
function evaluate(scenario, ref, envelope) {
|
|
66
|
-
const top = envelope.data?.results?.[0];
|
|
67
|
-
if (!envelope.ok || !top) return failedWithoutQuery(scenario, envelope.error?.code ?? "no top reference", ref);
|
|
68
|
-
const expectedName = scenario.expectedName.toLowerCase();
|
|
69
|
-
const correctness = (top.name ?? "").toLowerCase() === expectedName || top.excerpt.toLowerCase().includes(expectedName);
|
|
70
|
-
const relevance = top.path.toLowerCase().includes(scenario.expectedPath.toLowerCase());
|
|
71
|
-
const contextComplete = scenario.context.every(term => top.excerpt.toLowerCase().includes(term.toLowerCase()));
|
|
72
|
-
const version = envelope.data.resolvedCommit && envelope.data.resolvedCommit.length === 40;
|
|
73
|
-
const integrity = verifyGitEvidence(scenario.source, envelope.data.resolvedCommit, top);
|
|
74
|
-
const dimensions = { correctness, relevance, contextComplete, version, traceability: integrity.ok };
|
|
75
|
-
const score = Object.values(dimensions).filter(Boolean).length * 20;
|
|
76
|
-
return { id: scenario.id, question: scenario.question, source: scenario.source, product: scenario.product, ref, query: scenario.query, resolvedCommit: envelope.data.resolvedCommit, matchedTag: envelope.data.matchedTag, top, dimensions, score, passed: score === 100, evidenceDiagnostic: integrity.message };
|
|
77
|
-
}
|
|
78
|
-
function verifyGitEvidence(source, commit, top) {
|
|
79
|
-
try {
|
|
80
|
-
const mirror = join(home, "repositories", source + ".git");
|
|
81
|
-
const blob = execFileSync("git", ["--git-dir", mirror, "show", `${commit}:${top.path}`], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
|
|
82
|
-
const hash = createHash("sha256").update(blob).digest("hex");
|
|
83
|
-
if (hash !== top.contentHash) return { ok: false, message: "content hash differs from Git blob" };
|
|
84
|
-
const lines = blob.toString("utf8").split(/\r?\n/);
|
|
85
|
-
for (const row of top.excerpt.split("\n")) {
|
|
86
|
-
const match = row.match(/^(\d+): (.*)$/s);
|
|
87
|
-
if (!match || lines[Number(match[1]) - 1] !== match[2]) return { ok: false, message: `excerpt differs at ${match?.[1] ?? "unknown line"}` };
|
|
88
|
-
}
|
|
89
|
-
return { ok: true, message: "path, line, excerpt and SHA-256 match the resolved Commit blob" };
|
|
90
|
-
} catch (error) { return { ok: false, message: String(error.message ?? error) }; }
|
|
91
|
-
}
|
|
92
|
-
function failedWithoutQuery(scenario, reason, ref = scenario.ref ?? null) {
|
|
93
|
-
return { id: scenario.id, question: scenario.question, source: scenario.source, product: scenario.product, ref, query: scenario.query, dimensions: { correctness:false,relevance:false,contextComplete:false,version:false,traceability:false }, score: 0, passed: false, evidenceDiagnostic: reason };
|
|
94
|
-
}
|
|
95
|
-
function summarize(items) {
|
|
96
|
-
const dimensions = ["correctness","relevance","contextComplete","version","traceability"];
|
|
97
|
-
const summary = { total: items.length, passed: items.filter(x=>x.passed).length, averageScore: Math.round(items.reduce((n,x)=>n+x.score,0)/items.length), dimensions: {} };
|
|
98
|
-
for (const name of dimensions) summary.dimensions[name] = items.filter(x=>x.dimensions[name]).length;
|
|
99
|
-
summary.failed = summary.total - summary.passed;
|
|
100
|
-
return summary;
|
|
101
|
-
}
|
|
102
|
-
function markdown(artifact) {
|
|
103
|
-
const s=artifact.summary;const rows=["# wowdoc code-reference quality report","",`Generated: ${artifact.generatedAt}`,"",`Strict pass: ${s.passed}/${s.total}; average score: ${s.averageScore}/100.`,"",`Dimensions: correctness ${s.dimensions.correctness}/${s.total}, relevance ${s.dimensions.relevance}/${s.total}, context completeness ${s.dimensions.contextComplete}/${s.total}, version ${s.dimensions.version}/${s.total}, traceability ${s.dimensions.traceability}/${s.total}.`,"","| ID | Product | Ref | Score | Top reference | Result |","| --- | --- | --- | ---: | --- | --- |"];for(const item of artifact.results){const top=item.top?`${item.top.path}:${item.top.line}`:item.evidenceDiagnostic;rows.push(`| ${item.id} | ${item.source}/${item.product} | ${item.ref??""} | ${item.score} | ${String(top).replaceAll("|","\\|")} | ${item.passed?"PASS":"REVIEW"} |`)};rows.push("","A strict pass requires the first code reference to match the expected fact and subsystem, include the required answer context, resolve to an immutable Commit, and reproduce the exact Git blob bytes at the reported path and lines.","");return rows.join("\n")
|
|
104
|
-
}
|