dsh-plugin-guide 0.3.29 → 0.3.31

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.
@@ -1,162 +1,162 @@
1
- # dsh-plugin-guide 官方 GitHub Discussions 归档脚本(可复跑,幂等)
2
- # 输出: downloads/github/harness/discussions/
3
- # list.json 全部讨论(title/body/category/answers/labels 等,正文即文档)
4
- # comments-<n>.json 精选线程的全部评论(选择规则见下)
5
- # _selection.tsv 精选线程清单
6
- # README.md 分类统计 + 精选清单
7
- # 说明: GitHub REST API;未提供 Token 时走匿名配额(60/h,可能不够),建议
8
- # $env:GH_TOKEN 或 -Token <token>(经典 PAT,只需 public_repo 读权限)。
9
- # 选择规则(与 08-14 首版覆盖度一致): Announcements 全部; Q&A/Ideas 评论>=1;
10
- # General/Show and tell 评论>=3 且标题不含拉群/招聘/广告词。
11
- # 传输用 Invoke-WebRequest(而非 curl.exe 数组参数): PS 5.1 下 curl 数组参数
12
- # 的引号拆分会把 -H 头打碎,导致静默拿到错误对象——本脚本已规避该坑。
13
- # 参数: -OutDir <dir> 覆盖输出目录; -SkipComments 只刷新 list.json 不拉评论。
14
- param(
15
- [string]$OutDir = '',
16
- [string]$Repo = 'deepseek-ai/deepseek-harness',
17
- [string]$Token = $env:GH_TOKEN,
18
- [switch]$SkipComments
19
- )
20
- $ErrorActionPreference = 'Stop'
21
- $ProgressPreference = 'SilentlyContinue'
22
- if (-not $OutDir) { $OutDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'downloads\github\harness\discussions' }
23
- New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
24
-
25
- $headers = @{ 'User-Agent' = 'dsh-plugin-guide-research'; 'Accept' = 'application/vnd.github+json' }
26
- if ($Token) { $headers['Authorization'] = "Bearer $Token" }
27
-
28
- function Get-Api([string]$url) {
29
- # 返回对象数组;失败/限流时抛错(带状态码),由调用方决定重试或中止。
30
- try {
31
- $resp = Invoke-WebRequest -Uri $url -Headers $headers -TimeoutSec 60 -UseBasicParsing
32
- } catch {
33
- $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
34
- throw "HTTP $code : $url"
35
- }
36
- $obj = $null
37
- try { $obj = $resp.Content | ConvertFrom-Json } catch { throw "JSON parse failed: $url" }
38
- if ($obj -is [System.Management.Automation.PSCustomObject] -and $obj.PSObject.Properties.Name -contains 'message') {
39
- throw "API message '$($obj.message)' : $url"
40
- }
41
- return @($obj)
42
- }
43
-
44
- # ---- 1) 全量 list(按创建序分页;REST 的 direction 参数对 discussions 无效,只能从头翻到尾) ----
45
- $prevCount = 0
46
- if (Test-Path (Join-Path $OutDir 'list.json')) {
47
- try { $prev = @((Get-Content (Join-Path $OutDir 'list.json') -Raw -Encoding UTF8 | ConvertFrom-Json)); $prevCount = $prev.Count } catch { $prevCount = 0 }
48
- }
49
- $all = @()
50
- $page = 1
51
- # REST /discussions caps at 5000 rows (50 pages x 100). The cap is explicit so a
52
- # silent truncation cannot be mistaken for a complete archive: the run reports
53
- # when it stops at the ceiling, and the README records the ceiling alongside the
54
- # GraphQL total_count (which is larger).
55
- $pageCap = 50
56
- while ($page -le $pageCap) {
57
- $items = Get-Api "https://api.github.com/repos/$Repo/discussions?per_page=100&page=$page&sort=created"
58
- if ($items.Count -eq 0) { Write-Output "page ${page}: 空页,列表结束"; break }
59
- $all += $items
60
- Write-Output ("page {0}: +{1} (累计 {2})" -f $page, $items.Count, $all.Count)
61
- if ($items.Count -lt 100) { break }
62
- $page++
63
- Start-Sleep -Milliseconds 500
64
- }
65
- if ($all.Count -ge ($pageCap * 100)) {
66
- Write-Output "WARN: 列表在第 $pageCap 页触顶(REST 5000 条上限)——归档为前 5000 条,非全量;更全的历史需按 GraphQL UPDATED_AT 分页补抓"
67
- }
68
- if ($all.Count -eq 0) { throw 'discussions 列表抓取失败(0 条)' }
69
- if ($prevCount -gt 0 -and $all.Count -lt [Math]::Floor($prevCount * 0.9)) {
70
- throw "列表疑似缩水: 上次 $prevCount 条, 本次仅 $all.Count 条 —— 拒绝覆盖归档,请检查网络/配额后重跑"
71
- }
72
-
73
- # 按 number 去重(分页边界可能重复)
74
- $uniq = @{}
75
- foreach ($d in $all) { $uniq[[int]$d.number] = $d }
76
- $discs = @($uniq.Values | Sort-Object { [int]$_.number })
77
- $listPath = Join-Path $OutDir 'list.json'
78
- $discs | ConvertTo-Json -Depth 6 | Set-Content -Path $listPath -Encoding UTF8
79
- Write-Output "list.json: $($discs.Count) 条 -> $listPath"
80
-
81
- # ---- 2) 精选线程 + 评论 ----
82
- $sel = @()
83
- foreach ($d in $discs) {
84
- $cat = $d.category.name
85
- $c = [int]$d.comments
86
- $t = [string]$d.title
87
- $pick = $false
88
- if ($cat -eq 'Announcements') { $pick = $true }
89
- elseif ($cat -eq 'Q&A' -and $c -ge 1) { $pick = $true }
90
- elseif ($cat -eq 'Ideas' -and $c -ge 1) { $pick = $true }
91
- elseif (($cat -eq 'General' -or $cat -eq 'Show and tell') -and $c -ge 3) {
92
- # 非社群拉群帖: 标题含拉群/扫码/加群/招聘/广告类词的不收
93
- if ($t -notmatch '群|扫码|加群|招聘|内推|广告|投简历|纳新') { $pick = $true }
94
- }
95
- if ($pick) { $sel += $d }
96
- }
97
- $sel = @($sel | Sort-Object { [int]$_.number })
98
- Write-Output "精选线程: $($sel.Count) 条"
99
-
100
- if (-not $SkipComments) {
101
- $n = 0
102
- foreach ($d in $sel) {
103
- $num = [int]$d.number
104
- $comments = @()
105
- $cp = 1
106
- while ($cp -le 20) {
107
- $ci = Get-Api "https://api.github.com/repos/$Repo/discussions/$num/comments?per_page=100&page=$cp"
108
- if ($ci.Count -eq 0) { break }
109
- $comments += $ci
110
- if ($ci.Count -lt 100) { break }
111
- $cp++
112
- Start-Sleep -Milliseconds 300
113
- }
114
- $comments | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $OutDir "comments-$num.json") -Encoding UTF8
115
- $n++
116
- if ($n % 20 -eq 0) { Write-Output "comments: $n / $($sel.Count)" }
117
- Start-Sleep -Milliseconds 200
118
- }
119
- Write-Output "comments: 完成 $n 个线程"
120
- # 清掉列表里已不存在的旧 comments 文件
121
- $keep = New-Object System.Collections.Generic.HashSet[string]
122
- foreach ($d in $sel) { [void]$keep.Add("comments-$([int]$d.number).json") }
123
- Get-ChildItem $OutDir -Filter 'comments-*.json' | Where-Object { -not $keep.Contains($_.Name) } | Remove-Item -Force
124
- } else {
125
- Write-Output 'comments: 跳过(-SkipComments)'
126
- }
127
-
128
- # ---- 3) _selection.tsv 与 README.md ----
129
- $selLines = @('number' + "`t" + 'category' + "`t" + 'comments' + "`t" + 'title')
130
- foreach ($d in $sel) { $selLines += ("{0}`t{1}`t{2}`t{3}" -f $d.number, $d.category.name, $d.comments, ($d.title -replace "`t",' ')) }
131
- Set-Content -Path (Join-Path $OutDir '_selection.tsv') -Value $selLines -Encoding UTF8
132
-
133
- $cats = @{}
134
- foreach ($d in $discs) { $k = [string]$d.category.name; if (-not $cats.ContainsKey($k)) { $cats[$k] = 0 }; $cats[$k]++ }
135
- $utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
136
- $L = @()
137
- $L += '# 官方 GitHub Discussions 归档(自动生成,见抓取时间)'
138
- $L += ''
139
- $L += '> 来源:https://github.com/deepseek-ai/deepseek-harness/discussions (官方仓库 Issues 关闭,Discussions 为反馈主渠道)。'
140
- $L += '> 抓取:GitHub REST API(list 含每条正文;comments-<n>.json 为精选线程的全部评论)。'
141
- $L += "> 更新:提供 ``GH_TOKEN`` 后可由 ``scripts/archive-discussions.ps1`` 幂等刷新;抓取时间 $utc (UTC)。"
142
- $L += ''
143
- $L += '## 文件'
144
- $L += ''
145
- $L += "- list.json — 全部 $($discs.Count) 条讨论(含 title/body/category/answers/labels 等字段,正文即模型可读文档)。"
146
- $L += "- comments-<n>.json — 精选 $($sel.Count) 条线程的评论(选择规则:Announcements/Q&A/Ideas 评论>=1;General/Show and tell 评论>=3 且非社群拉群帖)。"
147
- $L += '- _selection.tsv — 精选线程清单(编号/分类/评论数/标题)。'
148
- $L += ''
149
- $L += '## 分类统计'
150
- $L += ''
151
- foreach ($k in @($cats.Keys | Sort-Object)) { $L += "- $k:$($cats[$k]) 条" }
152
- $L += ''
153
- $L += '## 有归档评论的精选线程'
154
- $L += ''
155
- $L += '| # | 分类 | 评论 | 标题 |'
156
- $L += '|---|---|---|---|'
157
- foreach ($d in $sel) {
158
- $title = ($d.title -replace '\|','\|' -replace "[\r\n]",' ')
159
- $L += "| [#$($d.number)](https://github.com/deepseek-ai/deepseek-harness/discussions/$($d.number)) | $($d.category.name) | $($d.comments) | $title |"
160
- }
161
- Set-Content -Path (Join-Path $OutDir 'README.md') -Value ($L -join "`r`n") -Encoding UTF8
162
- Write-Output "DONE: list=$($discs.Count) selected=$($sel.Count) -> $OutDir"
1
+ # dsh-plugin-guide 官方 GitHub Discussions 归档脚本(可复跑,幂等)
2
+ # 输出: downloads/github/harness/discussions/
3
+ # list.json 全部讨论(title/body/category/answers/labels 等,正文即文档)
4
+ # comments-<n>.json 精选线程的全部评论(选择规则见下)
5
+ # _selection.tsv 精选线程清单
6
+ # README.md 分类统计 + 精选清单
7
+ # 说明: GitHub REST API;未提供 Token 时走匿名配额(60/h,可能不够),建议
8
+ # $env:GH_TOKEN 或 -Token <token>(经典 PAT,只需 public_repo 读权限)。
9
+ # 选择规则(与 08-14 首版覆盖度一致): Announcements 全部; Q&A/Ideas 评论>=1;
10
+ # General/Show and tell 评论>=3 且标题不含拉群/招聘/广告词。
11
+ # 传输用 Invoke-WebRequest(而非 curl.exe 数组参数): PS 5.1 下 curl 数组参数
12
+ # 的引号拆分会把 -H 头打碎,导致静默拿到错误对象——本脚本已规避该坑。
13
+ # 参数: -OutDir <dir> 覆盖输出目录; -SkipComments 只刷新 list.json 不拉评论。
14
+ param(
15
+ [string]$OutDir = '',
16
+ [string]$Repo = 'deepseek-ai/deepseek-harness',
17
+ [string]$Token = $env:GH_TOKEN,
18
+ [switch]$SkipComments
19
+ )
20
+ $ErrorActionPreference = 'Stop'
21
+ $ProgressPreference = 'SilentlyContinue'
22
+ if (-not $OutDir) { $OutDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'downloads\github\harness\discussions' }
23
+ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
24
+
25
+ $headers = @{ 'User-Agent' = 'dsh-plugin-guide-research'; 'Accept' = 'application/vnd.github+json' }
26
+ if ($Token) { $headers['Authorization'] = "Bearer $Token" }
27
+
28
+ function Get-Api([string]$url) {
29
+ # 返回对象数组;失败/限流时抛错(带状态码),由调用方决定重试或中止。
30
+ try {
31
+ $resp = Invoke-WebRequest -Uri $url -Headers $headers -TimeoutSec 60 -UseBasicParsing
32
+ } catch {
33
+ $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
34
+ throw "HTTP $code : $url"
35
+ }
36
+ $obj = $null
37
+ try { $obj = $resp.Content | ConvertFrom-Json } catch { throw "JSON parse failed: $url" }
38
+ if ($obj -is [System.Management.Automation.PSCustomObject] -and $obj.PSObject.Properties.Name -contains 'message') {
39
+ throw "API message '$($obj.message)' : $url"
40
+ }
41
+ return @($obj)
42
+ }
43
+
44
+ # ---- 1) 全量 list(按创建序分页;REST 的 direction 参数对 discussions 无效,只能从头翻到尾) ----
45
+ $prevCount = 0
46
+ if (Test-Path (Join-Path $OutDir 'list.json')) {
47
+ try { $prev = @((Get-Content (Join-Path $OutDir 'list.json') -Raw -Encoding UTF8 | ConvertFrom-Json)); $prevCount = $prev.Count } catch { $prevCount = 0 }
48
+ }
49
+ $all = @()
50
+ $page = 1
51
+ # REST /discussions caps at 5000 rows (50 pages x 100). The cap is explicit so a
52
+ # silent truncation cannot be mistaken for a complete archive: the run reports
53
+ # when it stops at the ceiling, and the README records the ceiling alongside the
54
+ # GraphQL total_count (which is larger).
55
+ $pageCap = 50
56
+ while ($page -le $pageCap) {
57
+ $items = Get-Api "https://api.github.com/repos/$Repo/discussions?per_page=100&page=$page&sort=created"
58
+ if ($items.Count -eq 0) { Write-Output "page ${page}: 空页,列表结束"; break }
59
+ $all += $items
60
+ Write-Output ("page {0}: +{1} (累计 {2})" -f $page, $items.Count, $all.Count)
61
+ if ($items.Count -lt 100) { break }
62
+ $page++
63
+ Start-Sleep -Milliseconds 500
64
+ }
65
+ if ($all.Count -ge ($pageCap * 100)) {
66
+ Write-Output "WARN: 列表在第 $pageCap 页触顶(REST 5000 条上限)——归档为前 5000 条,非全量;更全的历史需按 GraphQL UPDATED_AT 分页补抓"
67
+ }
68
+ if ($all.Count -eq 0) { throw 'discussions 列表抓取失败(0 条)' }
69
+ if ($prevCount -gt 0 -and $all.Count -lt [Math]::Floor($prevCount * 0.9)) {
70
+ throw "列表疑似缩水: 上次 $prevCount 条, 本次仅 $all.Count 条 —— 拒绝覆盖归档,请检查网络/配额后重跑"
71
+ }
72
+
73
+ # 按 number 去重(分页边界可能重复)
74
+ $uniq = @{}
75
+ foreach ($d in $all) { $uniq[[int]$d.number] = $d }
76
+ $discs = @($uniq.Values | Sort-Object { [int]$_.number })
77
+ $listPath = Join-Path $OutDir 'list.json'
78
+ $discs | ConvertTo-Json -Depth 6 | Set-Content -Path $listPath -Encoding UTF8
79
+ Write-Output "list.json: $($discs.Count) 条 -> $listPath"
80
+
81
+ # ---- 2) 精选线程 + 评论 ----
82
+ $sel = @()
83
+ foreach ($d in $discs) {
84
+ $cat = $d.category.name
85
+ $c = [int]$d.comments
86
+ $t = [string]$d.title
87
+ $pick = $false
88
+ if ($cat -eq 'Announcements') { $pick = $true }
89
+ elseif ($cat -eq 'Q&A' -and $c -ge 1) { $pick = $true }
90
+ elseif ($cat -eq 'Ideas' -and $c -ge 1) { $pick = $true }
91
+ elseif (($cat -eq 'General' -or $cat -eq 'Show and tell') -and $c -ge 3) {
92
+ # 非社群拉群帖: 标题含拉群/扫码/加群/招聘/广告类词的不收
93
+ if ($t -notmatch '群|扫码|加群|招聘|内推|广告|投简历|纳新') { $pick = $true }
94
+ }
95
+ if ($pick) { $sel += $d }
96
+ }
97
+ $sel = @($sel | Sort-Object { [int]$_.number })
98
+ Write-Output "精选线程: $($sel.Count) 条"
99
+
100
+ if (-not $SkipComments) {
101
+ $n = 0
102
+ foreach ($d in $sel) {
103
+ $num = [int]$d.number
104
+ $comments = @()
105
+ $cp = 1
106
+ while ($cp -le 20) {
107
+ $ci = Get-Api "https://api.github.com/repos/$Repo/discussions/$num/comments?per_page=100&page=$cp"
108
+ if ($ci.Count -eq 0) { break }
109
+ $comments += $ci
110
+ if ($ci.Count -lt 100) { break }
111
+ $cp++
112
+ Start-Sleep -Milliseconds 300
113
+ }
114
+ $comments | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $OutDir "comments-$num.json") -Encoding UTF8
115
+ $n++
116
+ if ($n % 20 -eq 0) { Write-Output "comments: $n / $($sel.Count)" }
117
+ Start-Sleep -Milliseconds 200
118
+ }
119
+ Write-Output "comments: 完成 $n 个线程"
120
+ # 清掉列表里已不存在的旧 comments 文件
121
+ $keep = New-Object System.Collections.Generic.HashSet[string]
122
+ foreach ($d in $sel) { [void]$keep.Add("comments-$([int]$d.number).json") }
123
+ Get-ChildItem $OutDir -Filter 'comments-*.json' | Where-Object { -not $keep.Contains($_.Name) } | Remove-Item -Force
124
+ } else {
125
+ Write-Output 'comments: 跳过(-SkipComments)'
126
+ }
127
+
128
+ # ---- 3) _selection.tsv 与 README.md ----
129
+ $selLines = @('number' + "`t" + 'category' + "`t" + 'comments' + "`t" + 'title')
130
+ foreach ($d in $sel) { $selLines += ("{0}`t{1}`t{2}`t{3}" -f $d.number, $d.category.name, $d.comments, ($d.title -replace "`t",' ')) }
131
+ Set-Content -Path (Join-Path $OutDir '_selection.tsv') -Value $selLines -Encoding UTF8
132
+
133
+ $cats = @{}
134
+ foreach ($d in $discs) { $k = [string]$d.category.name; if (-not $cats.ContainsKey($k)) { $cats[$k] = 0 }; $cats[$k]++ }
135
+ $utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
136
+ $L = @()
137
+ $L += '# 官方 GitHub Discussions 归档(自动生成,见抓取时间)'
138
+ $L += ''
139
+ $L += '> 来源:https://github.com/deepseek-ai/deepseek-harness/discussions (官方仓库 Issues 关闭,Discussions 为反馈主渠道)。'
140
+ $L += '> 抓取:GitHub REST API(list 含每条正文;comments-<n>.json 为精选线程的全部评论)。'
141
+ $L += "> 更新:提供 ``GH_TOKEN`` 后可由 ``scripts/archive-discussions.ps1`` 幂等刷新;抓取时间 $utc (UTC)。"
142
+ $L += ''
143
+ $L += '## 文件'
144
+ $L += ''
145
+ $L += "- list.json — 全部 $($discs.Count) 条讨论(含 title/body/category/answers/labels 等字段,正文即模型可读文档)。"
146
+ $L += "- comments-<n>.json — 精选 $($sel.Count) 条线程的评论(选择规则:Announcements/Q&A/Ideas 评论>=1;General/Show and tell 评论>=3 且非社群拉群帖)。"
147
+ $L += '- _selection.tsv — 精选线程清单(编号/分类/评论数/标题)。'
148
+ $L += ''
149
+ $L += '## 分类统计'
150
+ $L += ''
151
+ foreach ($k in @($cats.Keys | Sort-Object)) { $L += "- $k:$($cats[$k]) 条" }
152
+ $L += ''
153
+ $L += '## 有归档评论的精选线程'
154
+ $L += ''
155
+ $L += '| # | 分类 | 评论 | 标题 |'
156
+ $L += '|---|---|---|---|'
157
+ foreach ($d in $sel) {
158
+ $title = ($d.title -replace '\|','\|' -replace "[\r\n]",' ')
159
+ $L += "| [#$($d.number)](https://github.com/deepseek-ai/deepseek-harness/discussions/$($d.number)) | $($d.category.name) | $($d.comments) | $title |"
160
+ }
161
+ Set-Content -Path (Join-Path $OutDir 'README.md') -Value ($L -join "`r`n") -Encoding UTF8
162
+ Write-Output "DONE: list=$($discs.Count) selected=$($sel.Count) -> $OutDir"
@@ -1,55 +1,55 @@
1
- # dsh-plugin-guide official-docs freshness probe.
2
- # Purpose: detect drift between the KB mirror (references/official-docs) and the
3
- # upstream deepseek-harness repository WITHOUT a local checkout. It compares the
4
- # upstream branch tip SHA (git ls-remote) against the pinned "Source commit" SHA
5
- # recorded in references/official-docs/SNAPSHOT.md — the SHA-locked authority the
6
- # mirror was synced from. It never edits the mirror; drift is only reported.
7
- # Usage: pwsh -File scripts/check-docs-drift.ps1 [-Repo <url>] [-Branch <name>] [-Snapshot <path>] [-FailOnDrift]
8
- # Exit codes: 0 = FRESH, 1 = DRIFT (with -FailOnDrift), 2 = probe could not run (snapshot/parse/fetch failure).
9
- # In CI it also writes `drift=yes|no|error` to $env:GITHUB_OUTPUT when that variable is set.
10
- param(
11
- [string]$Repo = 'https://github.com/deepseek-ai/deepseek-harness.git',
12
- [string]$Branch = 'master',
13
- [string]$Snapshot = 'references/official-docs/SNAPSHOT.md',
14
- [switch]$FailOnDrift
15
- )
16
- $ErrorActionPreference = 'Stop'
17
- $kit = Split-Path $PSScriptRoot -Parent
18
- $snapshotPath = Join-Path $kit $Snapshot
19
-
20
- # ---- 1) Read the pinned source commit SHA from SNAPSHOT.md ----
21
- $pinned = $null
22
- if (-not (Test-Path $snapshotPath)) { Write-Error "snapshot not found: $snapshotPath"; exit 2 }
23
- foreach ($line in Get-Content $snapshotPath -Encoding UTF8) {
24
- if ($line -match '^\|\s*Source commit\s*\|\s*`([0-9a-f]{40})`\s*\|') {
25
- $pinned = $Matches[1]
26
- break
27
- }
28
- }
29
- if (-not $pinned) { Write-Error "cannot parse the pinned 'Source commit' SHA from $Snapshot"; exit 2 }
30
-
31
- # ---- 2) Fetch the upstream branch tip SHA (no clone required) ----
32
- $remoteLine = (& git ls-remote $Repo "refs/heads/$Branch" 2>$null | Select-Object -First 1)
33
- if (-not $remoteLine -or $remoteLine -notmatch '^([0-9a-f]{40})\s') {
34
- $message = "cannot reach upstream $Repo (branch $Branch)"
35
- Write-Output "ERROR: $message"
36
- if ($env:GITHUB_OUTPUT) { Add-Content -Path $env:GITHUB_OUTPUT -Value 'drift=error' }
37
- exit 2
38
- }
39
- $upstream = $Matches[1]
40
-
41
- # ---- 3) Compare and report ----
42
- $fresh = ($upstream -eq $pinned)
43
- Write-Output "pinned (SNAPSHOT.md): $pinned"
44
- Write-Output "upstream ($Branch): $upstream"
45
- if ($fresh) {
46
- Write-Output 'FRESH: references/official-docs matches the upstream branch tip'
47
- } else {
48
- Write-Output "DRIFT: references/official-docs is pinned to $pinned but upstream is at $upstream"
49
- Write-Output 'Action: run pwsh -File scripts/sync-official-docs.ps1 to refresh the mirror (do not hand-edit the mirror).'
50
- }
51
- if ($env:GITHUB_OUTPUT) {
52
- Add-Content -Path $env:GITHUB_OUTPUT -Value ($(if ($fresh) { 'drift=no' } else { 'drift=yes' }))
53
- }
54
- if (-not $fresh -and $FailOnDrift) { exit 1 }
55
- exit 0
1
+ # dsh-plugin-guide official-docs freshness probe.
2
+ # Purpose: detect drift between the KB mirror (references/official-docs) and the
3
+ # upstream deepseek-harness repository WITHOUT a local checkout. It compares the
4
+ # upstream branch tip SHA (git ls-remote) against the pinned "Source commit" SHA
5
+ # recorded in references/official-docs/SNAPSHOT.md — the SHA-locked authority the
6
+ # mirror was synced from. It never edits the mirror; drift is only reported.
7
+ # Usage: pwsh -File scripts/check-docs-drift.ps1 [-Repo <url>] [-Branch <name>] [-Snapshot <path>] [-FailOnDrift]
8
+ # Exit codes: 0 = FRESH, 1 = DRIFT (with -FailOnDrift), 2 = probe could not run (snapshot/parse/fetch failure).
9
+ # In CI it also writes `drift=yes|no|error` to $env:GITHUB_OUTPUT when that variable is set.
10
+ param(
11
+ [string]$Repo = 'https://github.com/deepseek-ai/deepseek-harness.git',
12
+ [string]$Branch = 'master',
13
+ [string]$Snapshot = 'references/official-docs/SNAPSHOT.md',
14
+ [switch]$FailOnDrift
15
+ )
16
+ $ErrorActionPreference = 'Stop'
17
+ $kit = Split-Path $PSScriptRoot -Parent
18
+ $snapshotPath = Join-Path $kit $Snapshot
19
+
20
+ # ---- 1) Read the pinned source commit SHA from SNAPSHOT.md ----
21
+ $pinned = $null
22
+ if (-not (Test-Path $snapshotPath)) { Write-Error "snapshot not found: $snapshotPath"; exit 2 }
23
+ foreach ($line in Get-Content $snapshotPath -Encoding UTF8) {
24
+ if ($line -match '^\|\s*Source commit\s*\|\s*`([0-9a-f]{40})`\s*\|') {
25
+ $pinned = $Matches[1]
26
+ break
27
+ }
28
+ }
29
+ if (-not $pinned) { Write-Error "cannot parse the pinned 'Source commit' SHA from $Snapshot"; exit 2 }
30
+
31
+ # ---- 2) Fetch the upstream branch tip SHA (no clone required) ----
32
+ $remoteLine = (& git ls-remote $Repo "refs/heads/$Branch" 2>$null | Select-Object -First 1)
33
+ if (-not $remoteLine -or $remoteLine -notmatch '^([0-9a-f]{40})\s') {
34
+ $message = "cannot reach upstream $Repo (branch $Branch)"
35
+ Write-Output "ERROR: $message"
36
+ if ($env:GITHUB_OUTPUT) { Add-Content -Path $env:GITHUB_OUTPUT -Value 'drift=error' }
37
+ exit 2
38
+ }
39
+ $upstream = $Matches[1]
40
+
41
+ # ---- 3) Compare and report ----
42
+ $fresh = ($upstream -eq $pinned)
43
+ Write-Output "pinned (SNAPSHOT.md): $pinned"
44
+ Write-Output "upstream ($Branch): $upstream"
45
+ if ($fresh) {
46
+ Write-Output 'FRESH: references/official-docs matches the upstream branch tip'
47
+ } else {
48
+ Write-Output "DRIFT: references/official-docs is pinned to $pinned but upstream is at $upstream"
49
+ Write-Output 'Action: run pwsh -File scripts/sync-official-docs.ps1 to refresh the mirror (do not hand-edit the mirror).'
50
+ }
51
+ if ($env:GITHUB_OUTPUT) {
52
+ Add-Content -Path $env:GITHUB_OUTPUT -Value ($(if ($fresh) { 'drift=no' } else { 'drift=yes' }))
53
+ }
54
+ if (-not $fresh -and $FailOnDrift) { exit 1 }
55
+ exit 0
@@ -0,0 +1,68 @@
1
+ // Assert every PowerShell script under scripts/ keeps its UTF-8 BOM.
2
+ //
3
+ // Why this exists: PowerShell 5.1 (Windows PowerShell - the only shell on a
4
+ // machine without pwsh) decodes a BOM-less script as ANSI/CP936. `verify-kit.ps1`
5
+ // carries ~1.6 KB of Chinese comments and messages, so the mis-decode corrupts
6
+ // the source and the parser fails with "Missing ')' in method call". That is the
7
+ // repository's own recorded convention (`references/sources.md`), and on
8
+ // 2026-10-13 `verify-kit.ps1` was found to be the only one of nine scripts
9
+ // missing its BOM - and therefore unusable on PowerShell 5.1, which was
10
+ // invisible to CI because CI runs it under `pwsh`, where a missing BOM is
11
+ // harmless.
12
+ //
13
+ // Deliberately implemented in Node, not PowerShell: it must run on the Linux CI
14
+ // runner and on a machine whose only PowerShell is the 5.1 that cannot read the
15
+ // file in the first place.
16
+
17
+ import { readdirSync, readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ const dir = join(process.cwd(), 'scripts')
21
+ const BOM = [0xef, 0xbb, 0xbf]
22
+
23
+ /** True when the buffer starts with a UTF-8 BOM. */
24
+ function hasBom(buf) {
25
+ return buf.length >= 3 && buf[0] === BOM[0] && buf[1] === BOM[1] && buf[2] === BOM[2]
26
+ }
27
+
28
+ /** Count bytes above ASCII, i.e. how much a mis-decode could corrupt. */
29
+ function nonAsciiBytes(buf) {
30
+ let n = 0
31
+ for (const b of buf) if (b > 127) n++
32
+ return n
33
+ }
34
+
35
+ const scripts = readdirSync(dir)
36
+ .filter((f) => f.toLowerCase().endsWith('.ps1'))
37
+ .sort()
38
+
39
+ const problems = []
40
+ const rows = []
41
+
42
+ for (const file of scripts) {
43
+ const buf = readFileSync(join(dir, file))
44
+ const bom = hasBom(buf)
45
+ const nonAscii = nonAsciiBytes(buf)
46
+ rows.push({ file, bom, nonAscii, bytes: buf.length })
47
+ // A BOM matters most when the file contains non-ASCII text; for a pure-ASCII
48
+ // script a missing BOM is harmless, so report it without failing.
49
+ if (!bom && nonAscii > 0) {
50
+ problems.push(
51
+ `${file}: ${nonAscii} non-ASCII bytes but no UTF-8 BOM - PowerShell 5.1 will mis-decode this script`,
52
+ )
53
+ }
54
+ }
55
+
56
+ for (const r of rows) {
57
+ const flag = !r.bom && r.nonAscii > 0 ? 'FAIL' : r.bom ? 'ok ' : 'warn'
58
+ console.log(`${flag} ${r.file.padEnd(34)} bom=${String(r.bom).padEnd(5)} nonAscii=${String(r.nonAscii).padEnd(5)} bytes=${r.bytes}`)
59
+ }
60
+
61
+ if (problems.length > 0) {
62
+ console.error('\nps1-bom: FAIL')
63
+ for (const p of problems) console.error(' ' + p)
64
+ console.error('\nFix: prepend EF BB BF to the file (keep the content byte-identical otherwise).')
65
+ process.exit(1)
66
+ }
67
+
68
+ console.log(`\nps1-bom OK (${scripts.length} scripts; every non-ASCII one carries a BOM)`)
@@ -0,0 +1,149 @@
1
+ // Content-parity gate for the five READMEs and the five quick references.
2
+ //
3
+ // `check-readme-sync.mjs` only compares `## ` headings, so four translation
4
+ // families drifted past it unnoticed: an audit on 2026-10-13 found the es/pt/hi
5
+ // READMEs missing whole entries of "What you get" (including the flagship
6
+ // version-adaptivity document), the quick references still claiming "215 pages"
7
+ // against an actual 429, and a "re-verified on 2026-10-06" date attached to a
8
+ // commit dated 2026-10-09.
9
+ //
10
+ // This gate compares STRUCTURE at the block level — the number of list items and
11
+ // table rows inside each H2 section — instead of only the H2 titles. It cannot
12
+ // judge translation quality; it catches the case that actually happens, a
13
+ // section that gained or lost items in one language only.
14
+ //
15
+ // Usage: node scripts/check-readme-parity.mjs [--json] [--max-drift N]
16
+
17
+ import { readFileSync, existsSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ const root = process.cwd()
21
+ const json = process.argv.includes('--json')
22
+ const maxArg = process.argv.indexOf('--max-drift')
23
+ const maxDrift = maxArg >= 0 ? Number(process.argv[maxArg + 1] ?? 0) : 0
24
+
25
+ /** The English source and its translations, for each family. */
26
+ const FAMILIES = [
27
+ {
28
+ name: 'README',
29
+ base: 'README.md',
30
+ variants: ['README-zh.md', 'README-es.md', 'README-pt.md', 'README-hi.md'],
31
+ },
32
+ {
33
+ name: 'quick-reference',
34
+ base: 'guide/quick-reference.md',
35
+ variants: [
36
+ 'guide/quick-reference.zh-CN.md',
37
+ 'guide/quick-reference.es.md',
38
+ 'guide/quick-reference.pt.md',
39
+ 'guide/quick-reference.hi.md',
40
+ ],
41
+ },
42
+ ]
43
+
44
+ /**
45
+ * Split a markdown document into H2 sections and count countable blocks.
46
+ *
47
+ * Sections are keyed by ORDINAL, not by title: the quick references legitimately
48
+ * translate their headings, so matching on English titles produced 26 false
49
+ * "missing section" reports per language. What must stay identical across a
50
+ * translation family is the SEQUENCE and the SIZE of the sections, because the
51
+ * failure mode this gate exists for is a section that gained or lost content in
52
+ * one language only.
53
+ *
54
+ * @param text - raw markdown.
55
+ * @returns ordered sections with their countable blocks.
56
+ */
57
+ function sectionProfile(text) {
58
+ const lines = text.split(/\r?\n/)
59
+ const out = []
60
+ let current = { index: 0, title: '(preamble)', bullets: 0, tableRows: 0, codeBlocks: 0 }
61
+ out.push(current)
62
+ let inFence = false
63
+
64
+ for (const line of lines) {
65
+ if (/^\s*```/.test(line)) {
66
+ inFence = !inFence
67
+ if (inFence) current.codeBlocks++
68
+ continue
69
+ }
70
+ if (inFence) continue
71
+ const h2 = /^##\s+(.*)$/.exec(line)
72
+ if (h2) {
73
+ current = { index: out.length, title: h2[1].trim(), bullets: 0, tableRows: 0, codeBlocks: 0 }
74
+ out.push(current)
75
+ continue
76
+ }
77
+ if (/^\s*[-*]\s+\S/.test(line)) current.bullets++
78
+ // A table row, excluding the separator row.
79
+ if (/^\s*\|/.test(line) && !/^\s*[\s:|-]+\|\s*$/.test(line)) current.tableRows++
80
+ }
81
+ return out
82
+ }
83
+
84
+ /** Compare one variant against the English source, by section ordinal. */
85
+ function compare(basePath, variantPath) {
86
+ const base = sectionProfile(readFileSync(join(root, basePath), 'utf8'))
87
+ const variant = sectionProfile(readFileSync(join(root, variantPath), 'utf8'))
88
+ const drift = []
89
+
90
+ if (base.length !== variant.length) {
91
+ drift.push({
92
+ kind: 'section-count',
93
+ detail: `${base.length} sections in base vs ${variant.length} in variant`,
94
+ base: null,
95
+ variant: null,
96
+ section: null,
97
+ })
98
+ }
99
+
100
+ const n = Math.min(base.length, variant.length)
101
+ for (let i = 0; i < n; i++) {
102
+ for (const key of ['bullets', 'tableRows', 'codeBlocks']) {
103
+ if (base[i][key] !== variant[i][key]) {
104
+ drift.push({
105
+ section: `#${i} ${base[i].title}`,
106
+ kind: key,
107
+ base: base[i][key],
108
+ variant: variant[i][key],
109
+ detail: `base="${base[i].title}" variant="${variant[i].title}"`,
110
+ })
111
+ }
112
+ }
113
+ }
114
+ return drift
115
+ }
116
+
117
+ let total = 0
118
+ const report = []
119
+ for (const family of FAMILIES) {
120
+ if (!existsSync(join(root, family.base))) continue
121
+ for (const v of family.variants) {
122
+ if (!existsSync(join(root, v))) continue
123
+ const drift = compare(family.base, v)
124
+ total += drift.length
125
+ report.push({ family: family.name, variant: v, drift })
126
+ }
127
+ }
128
+
129
+ if (json) {
130
+ console.log(JSON.stringify({ total, report }, null, 2))
131
+ } else {
132
+ for (const r of report) {
133
+ if (r.drift.length === 0) {
134
+ console.log(`ok ${r.family.padEnd(16)} ${r.variant}`)
135
+ continue
136
+ }
137
+ console.log(`DRIFT ${r.family.padEnd(15)} ${r.variant} (${r.drift.length} block(s))`)
138
+ for (const d of r.drift.slice(0, 12)) {
139
+ if (d.kind === 'section-count') console.log(` ${d.detail}`)
140
+ else if (d.section === null) console.log(` ${d.kind}: ${d.detail ?? ''}`)
141
+ else console.log(` ${d.kind.padEnd(11)} ${d.base} vs ${d.variant} in "${d.section}"`)
142
+ }
143
+ if (r.drift.length > 12) console.log(` ... and ${r.drift.length - 12} more`)
144
+ }
145
+ console.log(`\nblock-level drift entries: ${total}`)
146
+ }
147
+
148
+ // The gate is opt-in strict: run with --max-drift N to allow a known budget.
149
+ process.exit(total > maxDrift ? 1 : 0)