dsh-plugin-guide 0.3.31 → 0.3.33

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.
@@ -1655,6 +1655,183 @@ async function initGit(targetDir) {
1655
1655
  });
1656
1656
  }
1657
1657
 
1658
+ //#endregion
1659
+ //#region src/cli/lib/seams.ts
1660
+ /** Resolve the index path, tolerating the source tree and the bundled dist. */
1661
+ function resolveSeamIndexPath() {
1662
+ const here = dirname(fileURLToPath(import.meta.url));
1663
+ const candidates = [
1664
+ join(here, "..", "..", "..", "data", "seams", "index.json"),
1665
+ join(here, "..", "..", "data", "seams", "index.json"),
1666
+ join(here, "..", "data", "seams", "index.json"),
1667
+ join(here, "data", "seams", "index.json")
1668
+ ];
1669
+ for (const c of candidates) try {
1670
+ readFileSync(c);
1671
+ return c;
1672
+ } catch {}
1673
+ throw new Error("seam index not found; expected data/seams/index.json next to the package root");
1674
+ }
1675
+ /** Load and parse the seam index. */
1676
+ function loadSeamIndex(path = resolveSeamIndexPath()) {
1677
+ return JSON.parse(readFileSync(path, "utf8"));
1678
+ }
1679
+ /**
1680
+ * Look up a seam or capability by id, area, or a free-text substring.
1681
+ *
1682
+ * @param index - parsed seam index.
1683
+ * @param query - id (`L3`, `A20`), area substring, or any word in a title.
1684
+ * @returns matches, or `state: 'unknown'` with a reason when nothing matches.
1685
+ */
1686
+ function lookupSeam(index, query) {
1687
+ const q = query.trim().toLowerCase();
1688
+ const exactSeam = index.seams.filter((s) => s.id.toLowerCase() === q);
1689
+ const exactCap = index.capabilities.filter((c) => c.id.toLowerCase() === q);
1690
+ if (exactSeam.length || exactCap.length) return {
1691
+ query,
1692
+ state: "found",
1693
+ seams: exactSeam,
1694
+ capabilities: exactCap
1695
+ };
1696
+ const seams = index.seams.filter((s) => s.title.toLowerCase().includes(q) || s.kind.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.removed.some((r) => r.toLowerCase().includes(q)) || (s.ids ?? []).some((i) => i.toLowerCase().includes(q)));
1697
+ const capabilities = index.capabilities.filter((c) => c.area.toLowerCase().includes(q) || c.introducedIn.toLowerCase().includes(q));
1698
+ if (!seams.length && !capabilities.length) return {
1699
+ query,
1700
+ state: "unknown",
1701
+ seams: [],
1702
+ capabilities: [],
1703
+ reason: `no seam or capability matches "${query}". This is an absence of evidence in the knowledge base, not evidence that the API is absent from your target host — check references/official-docs/ before concluding either way.`
1704
+ };
1705
+ return {
1706
+ query,
1707
+ state: "found",
1708
+ seams,
1709
+ capabilities
1710
+ };
1711
+ }
1712
+ /** Total order over DSH version strings. */
1713
+ function rank(v) {
1714
+ const m = /^(?:dsh-v)?(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)(?:\.(\d+))?)?$/i.exec(v.trim());
1715
+ if (!m) return [
1716
+ NaN,
1717
+ 0,
1718
+ 0,
1719
+ 0,
1720
+ 0
1721
+ ];
1722
+ const channel = m[4] === "alpha" ? 1 : m[4] === "rc" ? 2 : m[4] === void 0 ? 3 : 0;
1723
+ return [
1724
+ Number(m[1]),
1725
+ Number(m[2]),
1726
+ Number(m[3]),
1727
+ channel,
1728
+ Number(m[5] ?? 0)
1729
+ ];
1730
+ }
1731
+ /** Compare two ranks; a NaN on either side compares as "unknown", never "after". */
1732
+ function cmp(a, b) {
1733
+ if (Number.isNaN(a[0]) || Number.isNaN(b[0])) return NaN;
1734
+ for (let i = 0; i < 5; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
1735
+ return 0;
1736
+ }
1737
+ /**
1738
+ * Every recorded change that lands AFTER the given target version.
1739
+ *
1740
+ * A target this build cannot parse yields an empty list rather than everything:
1741
+ * reporting the whole index for an unparseable query would read as "all of this
1742
+ * changed for you", which is worse than reporting nothing.
1743
+ *
1744
+ * @param index - parsed seam index.
1745
+ * @param target - the version the plugin targets.
1746
+ * @returns matching seam records.
1747
+ */
1748
+ function seamsAfter(index, target) {
1749
+ const want = rank(target);
1750
+ if (Number.isNaN(want[0])) return [];
1751
+ return index.seams.filter((s) => {
1752
+ const got = cmp(rank(s.target.replace(/^(?:removed|retired) in /, "")), want);
1753
+ return !Number.isNaN(got) && got > 0;
1754
+ });
1755
+ }
1756
+
1757
+ //#endregion
1758
+ //#region src/cli/commands/seams.ts
1759
+ /** Render one seam record as human-readable text. */
1760
+ function renderSeam(s, out) {
1761
+ out.push(`${s.id} ${s.title}`);
1762
+ out.push(` kind ${s.kind}`);
1763
+ out.push(` lands in ${s.target}`);
1764
+ out.push(` confidence ${s.confidence}${s.inferredFrom ? ` (${s.inferredFrom})` : ""}`);
1765
+ out.push(` official ${s.doc}`);
1766
+ if (s.removed.length) out.push(` removed ${s.removed.join("; ")}`);
1767
+ out.push(` instead ${s.replacement}`);
1768
+ out.push(s.detectable ? ` detectable yes — inspect: ${s.targets.join(", ")}` : ` detectable NO — ${s.undetectableReason ?? "no reason recorded"}`);
1769
+ out.push("");
1770
+ }
1771
+ /** Render one capability record. */
1772
+ function renderCapability(c, out) {
1773
+ out.push(`${c.id} ${c.area}`);
1774
+ out.push(` introduced ${c.introducedIn}`);
1775
+ out.push(` anchor ${c.anchor}`);
1776
+ out.push(` confidence ${c.confidence}${c.inferredFrom ? ` (${c.inferredFrom})` : ""}`);
1777
+ out.push("");
1778
+ }
1779
+ /**
1780
+ * Run the seams command.
1781
+ *
1782
+ * @param index - parsed seam index.
1783
+ * @param options - query, since, json.
1784
+ * @returns exit code: 0 when answered, 2 for a usage problem, 3 for unknown.
1785
+ */
1786
+ function runSeams(index, options) {
1787
+ if (options.since !== void 0) {
1788
+ const hits = seamsAfter(index, options.since);
1789
+ if (options.json) {
1790
+ process.stdout.write(`${JSON.stringify({
1791
+ since: options.since,
1792
+ count: hits.length,
1793
+ seams: hits
1794
+ }, null, 2)}\n`);
1795
+ return 0;
1796
+ }
1797
+ if (hits.length === 0) {
1798
+ process.stdout.write(`No recorded change lands after ${options.since}.\nThat is bounded by this index's coverage (${index.seams.length} records, measured ${index.measuredAt}, baseline ${index.hostBaseline}) — not a guarantee about releases newer than the baseline.\n`);
1799
+ return 0;
1800
+ }
1801
+ const out = [`${hits.length} change(s) land after ${options.since}:`, ""];
1802
+ for (const s of hits) renderSeam(s, out);
1803
+ const undetectable = hits.filter((s) => !s.detectable).length;
1804
+ out.push(`Note: ${undetectable} of ${hits.length} cannot be detected by static inspection; they are advisory and must not be reported as passing.`);
1805
+ process.stdout.write(`${out.join("\n")}\n`);
1806
+ return 0;
1807
+ }
1808
+ const query = options.query;
1809
+ if (!query) {
1810
+ process.stderr.write("seams: give a query (a seam id, a capability area, or a word) or --since <version>\n");
1811
+ return 2;
1812
+ }
1813
+ const result = lookupSeam(index, query);
1814
+ if (options.json) {
1815
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1816
+ return result.state === "unknown" ? 3 : 0;
1817
+ }
1818
+ if (result.state === "unknown") {
1819
+ process.stdout.write(`${result.reason}\n`);
1820
+ return 3;
1821
+ }
1822
+ const out = [];
1823
+ if (result.seams.length) {
1824
+ out.push(`${result.seams.length} seam(s):`, "");
1825
+ for (const s of result.seams) renderSeam(s, out);
1826
+ }
1827
+ if (result.capabilities.length) {
1828
+ out.push(`${result.capabilities.length} capability record(s):`, "");
1829
+ for (const c of result.capabilities) renderCapability(c, out);
1830
+ }
1831
+ process.stdout.write(`${out.join("\n")}\n`);
1832
+ return 0;
1833
+ }
1834
+
1658
1835
  //#endregion
1659
1836
  //#region src/cli/commands/verify.ts
1660
1837
  /**
@@ -1929,6 +2106,13 @@ Commands:
1929
2106
  Flags: --cwd <dir> --dsh <bin> --pnpm <bin> --profile <name>
1930
2107
  --base <spec> --headless <spec> --timeout <ms>
1931
2108
  --smoke-timeout <ms>
2109
+ seams <query> Ask whether an API or capability exists in a target host:
2110
+ a seam id (L3), a capability id (A20), a version, or a word.
2111
+ Exits 3 when the knowledge base does not establish an answer —
2112
+ which is NOT a "no". Flags: --json
2113
+ seams --since <version>
2114
+ List every recorded breaking change that lands AFTER a version
2115
+ you target, flagging which ones static inspection cannot see.
1932
2116
 
1933
2117
  Global:
1934
2118
  -h, --help Show this help
@@ -1975,7 +2159,9 @@ async function main(argv, cwd = process.cwd()) {
1975
2159
  "base",
1976
2160
  "headless",
1977
2161
  "timeout",
1978
- "smoke-timeout"
2162
+ "smoke-timeout",
2163
+ "since",
2164
+ "query"
1979
2165
  ];
1980
2166
  let parsed;
1981
2167
  try {
@@ -2043,6 +2229,15 @@ async function main(argv, cwd = process.cwd()) {
2043
2229
  } else process.stdout.write("verify: OK\n");
2044
2230
  return result.exitCode;
2045
2231
  }
2232
+ case "seams": {
2233
+ const since = flagString(flags, "since");
2234
+ const query = positionals[1] ?? flagString(flags, "query");
2235
+ return runSeams(loadSeamIndex(), {
2236
+ query,
2237
+ since,
2238
+ json: flagBool(flags, "json")
2239
+ });
2240
+ }
2046
2241
  default:
2047
2242
  process.stderr.write(`unknown command "${command}"\n\n${HELP}`);
2048
2243
  return 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-guide",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
4
4
  "description": "DSH plugin development as an on-demand agent skill: official docs mirror, version-compatibility matrix, migration seams, measured pitfalls, plus a scaffolder, checker and pack verifier CLI.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,6 +23,7 @@
23
23
  "locale/*.json",
24
24
  "icon.svg",
25
25
  "SKILL.md",
26
+ "data",
26
27
  "guide",
27
28
  "references",
28
29
  "scripts",
@@ -65,6 +66,9 @@
65
66
  "test": "vitest run",
66
67
  "check:readmes": "node scripts/check-readme-sync.mjs",
67
68
  "check:parity": "node scripts/check-readme-parity.mjs",
69
+ "check:facts": "node scripts/check-facts.mjs",
70
+ "check:docs-drift-detail": "node scripts/check-docs-drift-detail.mjs",
71
+ "measure:mirror": "node scripts/measure-mirror-references.mjs",
68
72
  "check:ps1-bom": "node scripts/check-ps1-bom.mjs",
69
73
  "build:llms": "node scripts/build-llms-full.mjs",
70
74
  "check:kit": "pwsh -File scripts/verify-kit.ps1",
@@ -0,0 +1,78 @@
1
+ # CLI 定位决策:`dsh-plugin-dev check` / `verify` 该不该存在(2026-10-13)
2
+
3
+ > **本文回答一个具体的工程质疑**(来自 `_analysis/v2-6-forward-compat.md` §5):
4
+ > 同族已有 33 项、带裁定语义与退出码契约的 `dsh-plugin-doctor`,本库的 16 项检查是不是**更弱的重复**,
5
+ > 该不该削减甚至删掉?
6
+ >
7
+ > **结论:保留,但把分工写成不可含糊的三条。** 下面是判据与"什么情况下该反过来削减"的触发条件。
8
+
9
+ ---
10
+
11
+ ## 1. 先摆事实(实测,不是估计)
12
+
13
+ | 事实 | 值 | 来源 |
14
+ |---|---|---|
15
+ | 本库检查项 | **16** | `src/cli/skill-sections.ts` 的引用表,`check-facts.mjs` 断言 |
16
+ | 同族 `dsh-plugin-doctor` 检查项 | **33**(规范门禁 21) | `guide/family-toolchain.md` §2 |
17
+ | 本库**独有**的检查 | **5** | `guide/family-toolchain.md` §3.2 交叉对照表 |
18
+ | `check` 是否跑在任何工作流里 | **否** | `.github/workflows/` 无任何步骤调用它 |
19
+ | `dsh-plugin-doctor` 是否是本仓 CI 门禁 | **是** | `plugin-doctor.yml` 以精确版本钉住调用 |
20
+ | 本库 `check` 的代码量 | 781 行 + 13 个测试块 | `src/cli/commands/check.ts` |
21
+
22
+ **光看这张表,"砍掉"是合理结论**:11 项是更弱的重复,且它不在任何门禁里——一份没人跑、又和别人重叠的检查,维护成本是纯支出。
23
+
24
+ ## 2. 但有三个事实让"砍掉"不成立
25
+
26
+ ### 2.1 独有 5 项里有 1 项是家族唯一的检测器
27
+
28
+ `redline-async-apply-registration` 检测 **async `apply()` 在首个 `await` 之后注册**——这个缺陷在卸载窗口内
29
+ 静默丢失注册,表现为"装了、日志全绿、能力时有时无"。同族 `dsh-plugin-upgrade` 的 `legC` 走廊卡
30
+ **明确点名本库这个 lint** 作为 seam E2 的预防门(见 `guide/family-toolchain.md` §3.2)。
31
+ `dsh-plugin-doctor` 的 `K5` 只查 `apply` 的**返回形态**,查不到注册顺序。
32
+
33
+ 删掉它,家族内就没有任何东西能机械发现这个缺陷。
34
+
35
+ ### 2.2 本库的检查**每一项都链回可读章节**
36
+
37
+ `dsh-plugin-doctor` 的裁定有严格的退出码契约与降级语义,但它的输出是**判定**;
38
+ 本库 `check` 的每一项都带 `skillRef`,指向该规则在知识库里的**正文**。
39
+ 两者面向不同需求:**"过没过" vs "为什么、该改哪一行"**。这是本库存在的理由,不是重复。
40
+
41
+ > 这条承诺此前**部分是空的**——审计发现 2 项引用了根本没写该规则的章节,11 项引用的标题已漂移。
42
+ > 本轮已全部修正,并加 `tests/skill-sections.test.ts` 防再漂。**承诺现在是可验证的。**
43
+
44
+ ### 2.3 `verify` 的价值不在强度,而在**零依赖自足**
45
+
46
+ `verify` 在 D 组各轴都弱于 doctor(D9 甚至是反的:它删掉自己的沙箱)。
47
+ 但它**只用 Node 内置模块**,能在没有 network、没有 pnpm 全局状态、没有 doctor 安装的机器上跑完
48
+ `pack → 干净 DSH_HOME 安装 → 启动 → 卸载`。对"第一次接手的作者"和离线环境,这是唯一能跑的那条。
49
+
50
+ ## 3. 因此的分工(写死,不再含糊)
51
+
52
+ | 问题 | 用哪个 | 为什么 |
53
+ |---|---|---|
54
+ | "我这项改动过没过?给个裁定和 CI 退出码" | **`dsh-plugin-doctor`** | 33 项、有裁定语义、有降级检测、已是本仓门禁 |
55
+ | "为什么这条规则存在?我该改哪一行?" | **本库 `check`** | 每项带 `kb` 引用,指向正文 |
56
+ | "我这台机器什么都没有,先自证一下" | **本库 `verify`** | 零依赖、离线可跑 |
57
+ | "这个缺陷同族还有谁能发现?" | **本库 `check`** 的 async-apply lint | 家族唯一 |
58
+
59
+ **发布前建议两个都跑**,`SKILL.md` 与 README 已这样写。
60
+
61
+ ## 4. 反过来:什么情况下应该削减
62
+
63
+ 本文不是"永远保留"。以下任一条件成立,就该重新裁定:
64
+
65
+ 1. **`dsh-plugin-doctor` 获得异步注册顺序检查**(例如 `K5` 扩展为同时校验注册时序)——
66
+ 那 2.1 的唯一性消失,本库的 `check` 就退化为"带文档链接的 doctor 子集",应削减到文档链接层。
67
+ 2. **doctor 的裁定输出开始携带正文引用**——那 2.2 的差异消失。
68
+ 3. **本库 `check` 连续 3 个版本没有新增或修正任何检查**——说明它已停止演化,
69
+ 而维护成本(781 行 + 13 测试块 + 约 45% 的历史 churn)仍在支付。
70
+
71
+ **触发条件应定期核**:`guide/family-toolchain.md` 的交叉对照表是唯一需要更新的地方。
72
+
73
+ ## 5. 本轮已做的、与此决策无关的改进
74
+
75
+ - 修掉 11 处 citation 漂移与 2 处"引用不存在的规则"(`tests/skill-sections.test.ts` 现在守着)。
76
+ - `check --stirct` 这类拼写错误此前静默退出 0,现在报 `UsageError` 并以退出码 2 结束。
77
+ - `manifest-files` 此前无条件要求 `lib`/`dist` 目录,导致 `new --lang js` 过不了自己的 `check`;已修,
78
+ 并在 `verify-artifacts.mjs` 里加了 **scaffold → check 往返断言**——这正是当初漏掉它的原因。
@@ -5,14 +5,17 @@
5
5
  审计(`v2-2`、`v2-7`)建议剔除零引用内容以回收约 80% 体积。**精确复测后这个建议不成立**,
6
6
  因为"零引用"是按**窄口径**统计的,而本库自己声明的路由是**宽 glob**。
7
7
 
8
- ## 复测数据(`_analysis/_mirror-trim-plan.mjs`,可复跑)
8
+ ## 复测数据(可复跑)
9
9
 
10
- 扫描 33 份自有文档,把镜像 708 个文件按"被引用强度"分档:
10
+ 复现命令:`node scripts/measure-mirror-references.mjs`(随包发布,不需要网络)。
11
+
12
+ 扫描 33 份自有文档,把镜像文件按"被引用强度"分档:
11
13
 
12
14
  | 引用强度 | 文件数 | 体积 |
13
15
  |---|---:|---:|
14
- | 按确切路径被点名 | 73 | 2.22 MB |
15
- | 仅被目录 glob 覆盖 | 623 | 20.17 MB |
16
+ | 按确切路径被点名 | 79 | 2.29 MB |
17
+ | 仅被目录 glob 覆盖 | 617 | 20.10 MB |
18
+ | 纯按 basename 命中 | 5 | 0.04 MB |
16
19
  | **完全无引用** | **3** | **≈4 KB** |
17
20
 
18
21
  仅被 glob 覆盖的部分按扩展名拆开:
@@ -20,10 +23,14 @@
20
23
  | 扩展名 | 文件数 | 体积 |
21
24
  |---|---:|---:|
22
25
  | `.json`(历史 session-format schema) | 45 | **13.97 MB** |
23
- | `.md` | 362 | 5.26 MB |
26
+ | `.md` | 356 | 5.19 MB |
24
27
  | `.yaml`(`.i18n.yaml` 翻译哈希) | 212 | 0.54 MB |
25
28
  | `.png` | 4 | 0.39 MB |
26
29
 
30
+ > **口径**:只有当 glob 目录是路径的**前缀**时才算覆盖(`docs/persistence-changes/**` 覆盖其下文件,
31
+ > 而裸 `docs/**` 不能推导出"`docs/` 下全部都被引用")。最初的审计按"段名出现在任意位置"匹配,
32
+ > 因此把镜像算成"绝大多数无引用"——**这正是本轮复测推翻它的地方**。
33
+
27
34
  ## 为什么不能按"零引用"删
28
35
 
29
36
  本库**自己声明**的路由就是宽 glob,这些 glob 出现在 `SKILL.md` 与多份指南里:
@@ -68,5 +75,5 @@
68
75
  **镜像保持逐字完整**。本文件的作用是把 R1 从"待清理"改判为
69
76
  "**已评估、结论是不做,理由是被 glob 覆盖而非零引用**",避免下一个会话重复提出同一建议。
70
77
 
71
- > 复跑方式:`node _analysis/_mirror-trim-plan.mjs`
72
- > (`_analysis/` 在 `.gitignore` 中;如需长期保留本结论,见 `guide/family-toolchain.md` 的说明)
78
+ > 复跑方式:`node scripts/measure-mirror-references.mjs`
79
+ > (脚本随包发布,在 `scripts/` 下;无需网络,不依赖任何一次性工作稿。)
@@ -0,0 +1,145 @@
1
+ // Answer "WHAT changed upstream?", not just "is the mirror stale?".
2
+ //
3
+ // `check-docs-drift.ps1` compares the upstream branch-tip SHA against the SHA
4
+ // recorded in `references/official-docs/SNAPSHOT.md`. When they differ it says so
5
+ // and stops — a maintainer still has to work out which of ~700 mirrored files
6
+ // moved. A drift report that names no file is why the weekly job can sit red for
7
+ // eleven weeks without anyone acting on it.
8
+ //
9
+ // This script names the files: it compares the pinned commit's tree against the
10
+ // upstream head's tree and lists added, removed, and modified paths inside the
11
+ // mirror's declared scope.
12
+ //
13
+ // Usage:
14
+ // node scripts/check-docs-drift-detail.mjs [--commit <sha>] [--json]
15
+ // node scripts/check-docs-drift-detail.mjs --checkout D:\deepseek-harness
16
+ //
17
+ // Exit codes: 0 = fresh or report produced, 2 = could not probe, 1 = drift (with --fail-on-drift).
18
+
19
+ import { execFileSync } from 'node:child_process'
20
+ import { readFileSync, existsSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ const root = process.cwd()
24
+ const argv = process.argv.slice(2)
25
+ const flag = (name, fallback) => {
26
+ const i = argv.indexOf(name)
27
+ return i >= 0 ? (argv[i + 1] ?? true) : fallback
28
+ }
29
+ const asJson = argv.includes('--json')
30
+ const failOnDrift = argv.includes('--fail-on-drift')
31
+ const checkout = flag('--checkout', null)
32
+
33
+ const SNAPSHOT = join(root, 'references', 'official-docs', 'SNAPSHOT.md')
34
+ const MIRROR = join(root, 'references', 'official-docs')
35
+
36
+ // The mirror's declared scope, matching sync-official-docs.ps1 `$paths` exactly.
37
+ // `packages/` contributes only AGENTS.md and README.md, and `vendor/` only
38
+ // README.md — a bare directory prefix would report every package file as
39
+ // "missing from the mirror", which is 2,000 paths of noise.
40
+ const SCOPE_EXACT = new Set([
41
+ 'AGENTS.md',
42
+ 'BENCHMARK.md',
43
+ 'CONTRIBUTING.md',
44
+ 'CONTRIBUTING.zh.md',
45
+ 'CONTRIBUTING.i18n.yaml',
46
+ 'README.zh.md',
47
+ 'README.i18n.yaml',
48
+ 'THIRD_PARTY_NOTICES.md',
49
+ 'LICENSE',
50
+ 'packages/AGENTS.md',
51
+ 'packages/README.md',
52
+ 'vendor/README.md',
53
+ 'website/docs.ts',
54
+ ])
55
+ const inScope = (p) => p.startsWith('docs/') || SCOPE_EXACT.has(p)
56
+
57
+ /** The scope, for the report header. */
58
+ const SCOPE = ['docs/', ...SCOPE_EXACT]
59
+
60
+ /** Read the pinned SHA and scope out of SNAPSHOT.md. */
61
+ function readSnapshot() {
62
+ const text = readFileSync(SNAPSHOT, 'utf8')
63
+ const sha = /^\|\s*Source commit\s*\|\s*`([0-9a-f]{40})`\s*\|/m.exec(text)?.[1]
64
+ if (!sha) throw new Error(`cannot parse the pinned Source commit from ${SNAPSHOT}`)
65
+ return { sha, text }
66
+ }
67
+
68
+ /** git against the local checkout. */
69
+ function git(args) {
70
+ return execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', maxBuffer: 1 << 28 })
71
+ }
72
+
73
+ /** path -> blob sha for a commit's tree, via the local checkout. */
74
+ function treeOf(rev) {
75
+ const out = git(['ls-tree', '-r', '--format=%(objectname) %(path)', rev])
76
+ const map = new Map()
77
+ for (const line of out.split('\n')) {
78
+ const sp = line.indexOf(' ')
79
+ if (sp < 0) continue
80
+ map.set(line.slice(sp + 1), line.slice(0, sp))
81
+ }
82
+ return map
83
+ }
84
+
85
+ const report = { scope: SCOPE, added: [], removed: [], modified: [] }
86
+
87
+ if (checkout) {
88
+ if (!existsSync(checkout)) {
89
+ console.error(`ERROR: checkout not found: ${checkout}`)
90
+ process.exit(2)
91
+ }
92
+ const { sha: pinned } = readSnapshot()
93
+ const head = git(['rev-parse', 'HEAD']).trim()
94
+ const upstreamRev = git(['rev-parse', 'origin/master']).trim()
95
+ const target = flag('--commit', upstreamRev)
96
+ let a
97
+ let b
98
+ try {
99
+ a = treeOf(pinned)
100
+ b = treeOf(target)
101
+ } catch (err) {
102
+ console.error(`ERROR: cannot read a tree (${err.message.split('\n')[0]}). Is ${pinned.slice(0, 7)} present in the checkout?`)
103
+ process.exit(2)
104
+ }
105
+ for (const [p, s] of b) {
106
+ if (!inScope(p)) continue
107
+ if (!a.has(p)) report.added.push(p)
108
+ else if (a.get(p) !== s) report.modified.push(p)
109
+ }
110
+ for (const p of a.keys()) if (inScope(p) && !b.has(p)) report.removed.push(p)
111
+ report.pinned = pinned
112
+ report.target = target
113
+ report.head = head
114
+ report.mode = 'checkout'
115
+ } else {
116
+ console.error('ERROR: pass --checkout <deepseek-harness>; the API mode is not implemented (the mirror is pinned to a commit, and a tree comparison needs both trees).')
117
+ process.exit(2)
118
+ }
119
+
120
+ const total = report.added.length + report.removed.length + report.modified.length
121
+ if (asJson) {
122
+ console.log(JSON.stringify({ ...report, total }, null, 2))
123
+ } else {
124
+ console.log(`pinned : ${report.pinned}`)
125
+ console.log(`target : ${report.target}${report.target === report.head ? ' (checkout HEAD)' : ''}`)
126
+ console.log('')
127
+ if (total === 0) {
128
+ console.log('FRESH: no in-scope file differs between the pinned commit and the target.')
129
+ } else {
130
+ console.log(`DRIFT: ${total} in-scope file(s) differ`)
131
+ const show = (label, list) => {
132
+ if (!list.length) return
133
+ console.log(`\n ${label} (${list.length}):`)
134
+ for (const p of list.slice(0, 25)) console.log(` ${p}`)
135
+ if (list.length > 25) console.log(` ... and ${list.length - 25} more`)
136
+ }
137
+ show('ADDED upstream (the mirror is missing these)', report.added)
138
+ show('REMOVED upstream (the mirror still carries these)', report.removed)
139
+ show('MODIFIED upstream (the mirror has stale content)', report.modified)
140
+ console.log('\nAction: run pwsh -File scripts/sync-official-docs.ps1 to refresh the mirror (never hand-edit it).')
141
+ }
142
+ }
143
+
144
+ if (total > 0 && failOnDrift) process.exit(1)
145
+ process.exit(0)