@follenfang/wowdoc 0.0.7 → 0.0.8

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 CHANGED
@@ -1,48 +1,87 @@
1
+ <div align="center">
2
+
1
3
  # wowdoc
2
4
 
3
- [![CI](https://github.com/Follen/wowdoc/actions/workflows/ci.yml/badge.svg)](https://github.com/Follen/wowdoc/actions/workflows/ci.yml)
4
- [![npm](https://img.shields.io/npm/v/@follenfang/wowdoc)](https://www.npmjs.com/package/@follenfang/wowdoc)
5
- [![license](https://img.shields.io/github/license/Follen/wowdoc)](LICENSE)
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
+ [![CI](https://img.shields.io/github/actions/workflow/status/Follen/wowdoc/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/Follen/wowdoc/actions/workflows/ci.yml)
12
+ [![Release](https://img.shields.io/github/actions/workflow/status/Follen/wowdoc/release.yml?style=flat-square&label=release)](https://github.com/Follen/wowdoc/actions/workflows/release.yml)
13
+ [![npm](https://img.shields.io/npm/v/@follenfang/wowdoc?style=flat-square&logo=npm)](https://www.npmjs.com/package/@follenfang/wowdoc)
14
+ [![downloads](https://img.shields.io/npm/dm/@follenfang/wowdoc?style=flat-square&label=downloads)](https://www.npmjs.com/package/@follenfang/wowdoc)
15
+ [![GitHub release](https://img.shields.io/github/v/release/Follen/wowdoc?style=flat-square&sort=semver)](https://github.com/Follen/wowdoc/releases/latest)
16
+ [![license](https://img.shields.io/github/license/Follen/wowdoc?style=flat-square)](LICENSE)
6
17
 
7
- `wowdoc` gives coding agents versioned, auditable code references for World of Warcraft UI source and popular AddOns. It resolves a branch, Tag, version, or Commit to an immutable snapshot and returns the exact repository path, line, excerpt, content hash, and resolved Commit behind every answer.
18
+ </div>
8
19
 
9
- The product is CLI-only. The included Agent Skill translates a natural-language question into small, stable CLI commands; the CLI handles Git, parsing, indexing, and evidence.
20
+ ---
21
+
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 --help
31
+ wowdoc init
17
32
  ```
18
33
 
19
- The package installs the `wowdoc` CLI and the `wowdoc` Skill in `~/.agents/skills/wowdoc`. The single CLI handles queries, source/index operations, initialization, updates, cleanup, and uninstall.
34
+ The npm package installs:
35
+
36
+ - one executable: `wowdoc`
37
+ - one user-level Skill: `~/.agents/skills/wowdoc`
20
38
 
21
- Run the one-time data initialization before querying source:
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 init
42
+ npm install -g @follenfang/wowdoc --foreground-scripts --verbose
25
43
  ```
26
44
 
27
- Initialization creates `~/.wowdoc`, fetches the configured Git mirrors, and builds searchable SQLite snapshots for each product branch and its hot Tags. It can take time and substantial disk space. The command is resumable: rerunning it keeps completed work and continues failed or pending snapshots.
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.
46
+
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.
28
48
 
29
- If Git is missing, `wowdoc init` detects the platform package manager, shows the exact package and installer command, installs Git, refreshes `PATH`, and verifies `git --version`. `wowdoc doctor` remains read-only.
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
- Third-party versions use Git truth: `Tag -> Commit -> snapshot`. Release archives and installed AddOn folders can differ because packaging may inject externals or replace placeholders; wowdoc describes Tag source rather than pretending to reconstruct an installed ZIP byte for byte.
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
- ## Query source
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 a known ElvUI function at an exact plugin version:
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 retained versions:
105
+ ### Compare two WeakAuras versions
67
106
 
68
107
  ```powershell
69
108
  wowdoc diff `
@@ -73,74 +112,80 @@ wowdoc diff `
73
112
  --to 5.21.9
74
113
  ```
75
114
 
76
- Every successful reference identifies its `sourceId`, product, requested ref, matched Tag when present, resolved Commit, repository path, line, excerpt, and SHA-256 content hash. Queries read only published SQLite snapshots and content-addressed objects; they do not switch a shared checkout or silently access the network.
115
+ ### Validate an AddOn against a target snapshot
77
116
 
78
- ## Commands
117
+ ```powershell
118
+ wowdoc validate `
119
+ --path D:\AddOns\MyAddon `
120
+ --source wow-ui-source `
121
+ --product retail `
122
+ --ref latest
123
+ ```
124
+
125
+ Every successful reference identifies the source, product, requested ref, matching Tag when available, resolved Commit, repository path, line, excerpt, and content hash.
126
+
127
+ ## Command map
79
128
 
80
129
  ```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
130
+ Search wowdoc query | explore | inspect | diff | validate
131
+ Sources wowdoc source list | check | sync
132
+ Indexes wowdoc index build | refresh | status
133
+ Health wowdoc doctor
134
+ Lifecycle wowdoc init | update | clean | uninstall
86
135
  ```
87
136
 
88
- Common lifecycle:
137
+ A normal update flow is explicit:
89
138
 
90
139
  ```powershell
91
- # Check whether a branch changed without modifying local state
92
140
  wowdoc source check --source elvui --product main
93
-
94
- # Explicitly fetch new Git metadata and source objects
95
141
  wowdoc source sync --source elvui --product main
96
-
97
- # Build and atomically publish the new snapshot
98
142
  wowdoc index refresh --source elvui --product main --ref latest
99
-
100
- # Preview cleanup; no files are deleted
101
- wowdoc clean
102
143
  ```
103
144
 
104
- Use `wowdoc clean --yes` only after reviewing its candidates. Removing indexed versions requires an explicit version or range. `wowdoc uninstall` requires confirmation and removes the npm package, managed Skill, and `~/.wowdoc` data.
145
+ `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
146
 
106
- ## Storage model
147
+ ## Storage
107
148
 
108
- Local state lives under `~/.wowdoc`:
149
+ All local data lives under `~/.wowdoc` by default:
109
150
 
110
151
  ```text
111
- config/ versioned source catalog and local configuration
152
+ config/ source catalog and local configuration
112
153
  repositories/ complete bare Git mirrors
113
- objects/ legacy content objects plus immutable Pack storage
114
- objects/packs/ sequential Pack segments and their catalog
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
154
+ objects/packs/ immutable, content-addressed Pack segments
155
+ indexes/ shared content DBs and branch-local WAL/FTS databases
117
156
  manifests/ immutable snapshot manifests
118
157
  state/ initialization and task state
119
- tmp/worktrees/ leased detached worktrees used only while parsing
120
- locks/ repository and snapshot build locks
158
+ tmp/worktrees/ leased detached worktrees used while parsing
159
+ locks/ bounded repository and publish locks
121
160
  logs/ local diagnostics
122
161
  ```
123
162
 
124
- Each parser task fixes the requested ref to a Commit and creates its own detached worktree. Published queries never depend on that worktree. Identical Git blobs and AST objects are written once to immutable Pack segments and reused across Tags and branches, while snapshot relationships remain isolated by product and Commit.
163
+ Set `WOWDOC_HOME` to move the data directory:
164
+
165
+ ```powershell
166
+ $env:WOWDOC_HOME = 'D:\WOWData\wowdoc'
167
+ wowdoc doctor
168
+ wowdoc init
169
+ ```
125
170
 
126
- Source objects, AST and assets are appended to one staging Pack per build and atomically published; the Pack catalog verifies original length and SHA-256 on every read. Legacy raw/gzip objects remain readable. One source-level SQLite keeps immutable facts and search-document metadata per content hash, while each product branch keeps its own WAL snapshot mappings and local FTS corpus so BM25 ordering remains branch-equivalent. A full-text hit is resolved back to the exact Pack source line. Initialization downloads up to three source mirrors in parallel, then parses with the normal 4-8 worker budget.
171
+ 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
172
 
128
- ### Performance baseline
173
+ ## Performance
129
174
 
130
- Measured on Windows 11, Git 2.53.0, the same `wow-ui-source` Retail Commit `c878310d8432a65bac029c7bacc24eeb2e662bbe`, 8 parser workers, and a complete local bare mirror:
175
+ Measured on Windows 11 with a complete local mirror, 8 parser workers, and the same 3,685-file Retail Commit:
131
176
 
132
- | Build | Cold build time | Indexed files | SQLite | Home total |
133
- | --- | ---: | ---: | ---: | ---: |
134
- | `b201d38` baseline | 41.1 s | 3,685 | 158,629,888 B | 352,202,349 B |
135
- | compact pipeline | 11.4 s | 3,685 | 67,743,744 B | 169,972,173 B |
177
+ | Pipeline | Cold build | SQLite | Complete home |
178
+ | --- | ---: | ---: | ---: |
179
+ | baseline `b201d38` | 41.1 s | 158.6 MB | 352.2 MB |
180
+ | compact content store | 11.4 s | 67.7 MB | 170.0 MB |
136
181
 
137
- The cold parse/index stage is 3.61x faster (about 72% less time), its SQLite is 57% smaller, and its complete home is 52% smaller. A separate 10-Tag run produced 196,460,544 B of branch SQLite and 342,740,177 B total while preserving complete Lua/XML full-text coverage. Exact symbol, plain-text source, relation, Commit, path, line, excerpt, and SHA-256 checks pass after the storage changes. These are local measurements; network clone/fetch time varies by GitHub and TLS conditions.
182
+ 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
183
 
139
184
  ## Agent integration
140
185
 
141
- The installed Skill contains source/product aliases and command-selection rules, not copied source facts. An Agent chooses the source, product, ref, topic, and narrowest useful identifier, then cites the CLI evidence. A missing exact plugin Tag can fall back to that product branch's latest snapshot only when the Skill labels the result as a latest fallback and preserves the originally requested version.
186
+ 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
187
 
143
- The quality suite contains 50 realistic AddOn-author questions across all configured product branches and historical Tags. A strict pass requires the first reference to be correct, relevant, context-complete, version-correct, and byte-for-byte traceable to the resolved Git blob. Local scenarios and generated reports live in the Git-ignored `analyze/quality` directory.
188
+ 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
189
 
145
190
  ## Development
146
191
 
@@ -153,7 +198,7 @@ npm pack --dry-run
153
198
  go run ./cmd/wowdoc --help
154
199
  ```
155
200
 
156
- Release tags use `vMAJOR.MINOR.PATCH`. GitHub Actions tests the project, builds `wowdoc` for Windows amd64, Linux amd64/arm64, and macOS amd64/arm64, publishes checksums and a GitHub Release, then publishes the matching npm version through npm Trusted Publisher OIDC with provenance.
201
+ 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
202
 
158
203
  ## License
159
204
 
@@ -0,0 +1,205 @@
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
+ [![CI](https://img.shields.io/github/actions/workflow/status/Follen/wowdoc/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/Follen/wowdoc/actions/workflows/ci.yml)
12
+ [![Release](https://img.shields.io/github/actions/workflow/status/Follen/wowdoc/release.yml?style=flat-square&label=release)](https://github.com/Follen/wowdoc/actions/workflows/release.yml)
13
+ [![npm](https://img.shields.io/npm/v/@follenfang/wowdoc?style=flat-square&logo=npm)](https://www.npmjs.com/package/@follenfang/wowdoc)
14
+ [![downloads](https://img.shields.io/npm/dm/@follenfang/wowdoc?style=flat-square&label=downloads)](https://www.npmjs.com/package/@follenfang/wowdoc)
15
+ [![GitHub release](https://img.shields.io/github/v/release/Follen/wowdoc?style=flat-square&sort=semver)](https://github.com/Follen/wowdoc/releases/latest)
16
+ [![license](https://img.shields.io/github/license/Follen/wowdoc?style=flat-square)](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
+ --source wow-ui-source `
121
+ --product retail `
122
+ --ref latest
123
+ ```
124
+
125
+ 查询成功后会返回 source、product、用户请求的 ref、匹配到的 Tag、实际 Commit、仓库路径、行号、代码片段和内容哈希。
126
+
127
+ ## 命令一览
128
+
129
+ ```text
130
+ 检索 wowdoc query | explore | inspect | diff | validate
131
+ 源码 wowdoc source list | check | sync
132
+ 索引 wowdoc index build | refresh | status
133
+ 检查 wowdoc doctor
134
+ 生命周期 wowdoc init | update | clean | uninstall
135
+ ```
136
+
137
+ 正常更新流程是显式的:
138
+
139
+ ```powershell
140
+ wowdoc source check --source elvui --product main
141
+ wowdoc source sync --source elvui --product main
142
+ wowdoc index refresh --source elvui --product main --ref latest
143
+ ```
144
+
145
+ `wowdoc update` 只更新 npm 包和 Skill,不拉取源码,也不重建索引。`wowdoc clean` 默认只预览,加入 `--yes` 才执行清理。
146
+
147
+ ## 本地存储
148
+
149
+ 默认情况下,所有数据都放在 `~/.wowdoc`:
150
+
151
+ ```text
152
+ config/ 源码目录和本地配置
153
+ repositories/ 完整 bare Git mirror
154
+ objects/packs/ 不可变、按内容寻址的 Pack 分段
155
+ indexes/ 共享内容库和分支独立的 WAL/FTS 数据库
156
+ manifests/ 不可变快照清单
157
+ state/ 初始化和任务状态
158
+ tmp/worktrees/ 解析期间使用的临时 detached worktree
159
+ locks/ 仓库和发布锁
160
+ logs/ 本地诊断日志
161
+ ```
162
+
163
+ 通过 `WOWDOC_HOME` 可以修改数据目录:
164
+
165
+ ```powershell
166
+ $env:WOWDOC_HOME = 'D:\WOWData\wowdoc'
167
+ wowdoc doctor
168
+ wowdoc init
169
+ ```
170
+
171
+ 内容相同的源码、AST 和素材只在不可变 Pack 中保存一份,在不同 Tag 和分支之间复用。source 级 SQLite 保存共享事实;每个分支保留自己的快照成员关系和 FTS 统计,因此版本过滤和 BM25 排序不会被其他分支影响。
172
+
173
+ ## 性能
174
+
175
+ 以下数据来自 Windows 11、完整本地 mirror、8 个解析线程,以及同一个包含 3,685 个文件的 Retail Commit:
176
+
177
+ | 方案 | 冷构建 | SQLite | 完整数据目录 |
178
+ | --- | ---: | ---: | ---: |
179
+ | 基线 `b201d38` | 41.1 秒 | 158.6 MB | 352.2 MB |
180
+ | 紧凑内容存储 | 11.4 秒 | 67.7 MB | 170.0 MB |
181
+
182
+ 这次测试中,解析和索引时间减少 72%,SQLite 减少 57%,完整本地数据减少 52%;Lua/XML 搜索覆盖和返回的源码证据保持一致。详细场景、全量 catalog 数据和取舍见[性能记录](docs/performance.md)。
183
+
184
+ ## Agent 怎么使用
185
+
186
+ 安装的 Skill 只保存命令选择规则和 source/product 别名,不复制源码知识。Agent 会从问题中选择最窄、最稳定的标识符,调用 `wowdoc`,然后引用 CLI 返回的证据。
187
+
188
+ 质量回归包含 50 个真实插件开发问题,覆盖不同产品分支和历史 Tag。首条参考必须同时满足正确、相关、上下文完整、版本正确,并且能逐字节回到实际 Git blob,才算通过。
189
+
190
+ ## 开发
191
+
192
+ 需要 Go 1.23+、Node.js 20+ 和 Git。
193
+
194
+ ```powershell
195
+ go test ./...
196
+ go vet ./...
197
+ npm pack --dry-run
198
+ go run ./cmd/wowdoc --help
199
+ ```
200
+
201
+ 发布 Tag 使用 `vMAJOR.MINOR.PATCH`。GitHub Actions 会测试 Windows、Linux 和 macOS,构建五个平台二进制,生成校验和与 GitHub Release,再通过 Trusted Publisher OIDC 发布带 provenance 的 npm 包。
202
+
203
+ ## License
204
+
205
+ [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
- const executable = join(packageRoot, "native", name + suffix);
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.7",
3
+ "version": "0.0.8",
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.8",
25
+ "@follenfang/wowdoc-darwin-x64": "0.0.8",
26
+ "@follenfang/wowdoc-linux-arm64": "0.0.8",
27
+ "@follenfang/wowdoc-linux-x64": "0.0.8",
28
+ "@follenfang/wowdoc-win32-x64": "0.0.8"
29
+ },
21
30
  "scripts": {
22
31
  "postinstall": "node scripts/install.mjs",
23
- "test": "go 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
+ }
@@ -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 { basename, dirname, join, relative } from "node:path";
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
- const platformNames = {
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
- cpSync(supplied, target);
22
+ process.stderr.write(`wowdoc: using supplied ${platformKey()} binary\n`);
23
+ installBinary(supplied, target);
28
24
  } else if (existsSync(join(root, "go.mod"))) {
29
- execFileSync("go", ["build", "-trimpath", "-ldflags", `-s -w -X github.com/follenfang/wowdoc/internal/app.Version=${pkg.version}`, "-o", target, `./cmd/${name}`], { cwd: root, stdio: "inherit" });
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
- const asset = `${name}-${platform}${suffix}`;
32
- const url = `https://github.com/Follen/wowdoc/releases/download/v${pkg.version}/${asset}`;
33
- const response = await fetch(url, { redirect: "follow" });
34
- if (!response.ok) throw new Error(`binary_download_failed: ${response.status} ${url}`);
35
- writeFileSync(target, Buffer.from(await response.arrayBuffer()));
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
+ }
@@ -36,6 +36,8 @@ wowdoc doctor
36
36
  wowdoc init
37
37
  ```
38
38
 
39
+ 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.
40
+
39
41
  Bash or zsh:
40
42
 
41
43
  ```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
- }