dsh-scout 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,26 @@
3
3
  状态:Draft 0.1
4
4
  目标:把公司与岗位尽调收敛为一个可复现、可追溯、能支持下一步行动的 DSH 场景插件。
5
5
 
6
- 当前实现进度:首个公开切片已经覆盖 session 隔离的内存 case、封闭来源类型、主体核验、claim 证据历史、保守三态决策、结构化报告、卸载清理和临时 DSH profile 安装门禁;v0.2 已落地持久化五文件导出(`scout_export` / `scout_import`,含可回放事件流 `events.jsonl` 与导入后决策重算);v0.3 已落地可配置存储(`scoutDir` / `autoPersist` 插件配置)与面试问题生成(`scout_questions`);v0.4 已落地信息采集登记(`scout_ingest`:批量登记采集结果并按 URL 自动推断来源类型与证据等级);v0.5 已落地公司/岗位对比(`scout_compare`:2–5 案例并排对比报告与合并面试问题);v0.6 已落地采集→主张流转(`scout_ingest` 支持 claim 草稿,登记来源同时自动添加主张);v0.7 已落地 Provider 深度集成(`scout_search`:接入 DSH Web Provider,搜索结果自动登记为来源)。
6
+ 当前实现进度:首个公开切片已经覆盖 session 隔离的内存 case、封闭来源类型、主体核验、claim 证据历史、保守三态决策、结构化报告、卸载清理和临时 DSH profile 安装门禁;v0.2 已落地持久化五文件导出(`scout_export` / `scout_import`,含可回放事件流 `events.jsonl` 与导入后决策重算);v0.3 已落地可配置存储(`scoutDir` / `autoPersist` 插件配置)与面试问题生成(`scout_questions`);v0.4 已落地信息采集登记(`scout_ingest`:批量登记采集结果并按 URL 自动推断来源类型与证据等级);v0.5 已落地公司/岗位对比(`scout_compare`:2–5 案例并排对比报告与合并面试问题);v0.6 已落地采集→主张流转(`scout_ingest` 支持 claim 草稿,登记来源同时自动添加主张);v0.7 已落地 Provider 深度集成(`scout_search`:接入 DSH Web Provider,搜索结果自动登记为来源);v0.8.x 完成 npm 首发与 DSH 0.2 rc 通道对齐(`exports` 映射、Schemastery `Config` 声明、profile smoke 门禁);v0.9.0 适配 DSH 0.2 运行时,并建立四个精确版本的兼容矩阵与夜间兼容检查。
7
+
8
+ **v0.9.1** 把插件从「能跑」推到「在真实 DSH 里表现正确、便宜、可解释」,主题是接缝而不是新功能:
9
+
10
+ - **输出契约**:变更类工具不再回吐整份 case JSON,改为「结构化对象 `value` + 整串(含省略号)不超过 1 KB 的紧凑 render」;全量数据仍由 `scout_report` / `scout_export` 提供。
11
+ - **落盘可信度**:`autoPersist` 失败可见化(返回值 `persist.persisted=false` + `persist.error`);导出统一为「先记事件、后写文件」;导出目录写 `export.complete.json` 完成标记(**先置 `complete:false`、五文件写完再置 `complete:true`**);`scout_import` 读标记的 `complete` 字段,缺失或不完整时报告 `complete: false`;写盘转发 `exec.signal`。
12
+ - **服务绑定与路径**:`fs` / `web` 改为调用时解析(不再在 `apply()` 时快照);导出返回解析后的绝对路径;缓存未命中时,**只有完整导出**(完成标记 `complete: true`)才从 `<scoutDir>/<caseId>/` 惰性回填并记 `case_rehydrated`(0.9.0 旧导出无完成标记,需显式 `scout_import`)。案例对齐只在 `decide` 路径生效(重算决策的工具调用与缓存未命中时的惰性回填经过它;回填由第一个访问该 case 的工具调用触发,所以冷启动后即使只调 `scout_report` 也可能带上一次对齐);**直接读磁盘 `case.json`、从不经过插件的第三方工具看不到对齐**。
13
+ - **可解释与双语**:新增 `language: 'zh-CN' | 'en'`(默认 `zh-CN`),未编辑的出厂默认文案会自动对齐到部署语言——精选面试问题按**下标逐条**判定(长度与出厂数组相同时逐条替换,用户改过的那条保持原样;长度不同则整组原样保留),`decisionObjective` / `role.fitQuestion` 逐字段判定;报告新增 `## Coverage` 调查覆盖度区块。
14
+ - **显式入口**:新增 `/scout` 命令(`status` / `questions` / `report` / `export` 四个子动作)与插件注册的 `scout` Skill。
15
+ - **分发契约**:`engines.dsh`、`dsh.manifestVersion: 1`、`locale/en.json` + `locale/zh.json`(必须写进 `exports`,否则被静默忽略)、包内 `icon`。
16
+
17
+ 0.9.1 明确不做的事见 [CHANGELOG](../CHANGELOG.md):client 面板、自造 session event、`ctx.storageDomain` 持久化重写、评分与排名(0.9.2 沿用同一非目标)。
18
+
19
+ **v0.9.2(当前版本)**是收口版本:把 0.9.1 验证报告点名的最大盲区(U11)补成真实宿主门禁,并清掉三轮独立验证留下的文档税。本版本**不新增工具、不新增命令、不新增 Skill 条目**:
20
+
21
+ - **F2 · 真实宿主端到端门禁(关闭 U11)**:`scripts/profile-smoke.mjs` 扩展为六步真实宿主场景链——创建 → 导出 → 冷重启 → 惰性回填 → 跨语言导入 → 卸载,另加完成标记「缺失 / `complete:false`」两条负向对照;四版本矩阵在扩展后的门禁下 4/4 通过。**仍未覆盖**:真实 LLM / agent loop 与 approval 策略、相对路径、`autoPersist`、真实进程中断,以及其余 6 个工具(仅断言注册)。
22
+ - **F1 · 面试问题按下标逐条对齐(关闭 D10)**:精选面试问题由「整组 all-or-nothing」改为按下标逐条——长度与出厂数组相同时逐条替换,用户改过的那条保持原样;长度不同则整组原样保留。默认行为对**未编辑**场景零变化。
23
+ - **F5 · render 整串上限加固(关闭 O10)**:整串(含省略号)不超过 1024 B,裁切预算由省略号自身的 UTF-8 长度推导(不再依赖手写余量)。
24
+ - **F4 · 依赖对齐**:`@deepseek-ai/schemastery` 由 `^3.18.2` 改为 `~3.18.4`,与宿主系列一致;`peerDependencies` 与 `engines.dsh` 范围不变。
25
+ - **已知边界 G1(有意不修,推迟 0.10.0)**:冷启动惰性回填会把决策理由措辞由初始文案换成 `decisionReasons.identity`(并补 `decisionReasonCode`),`decision` 不变;详见 [0.9.2 门禁发现](dsh-scout-0.9.2-gate-findings.md)(仓库文档,不在 npm 包内)。
7
26
 
8
27
  ## 1. 产品承诺
9
28
 
@@ -41,15 +60,24 @@ scout-cases/<case-id>/
41
60
  claims.json
42
61
  events.jsonl
43
62
  report.md
63
+ export.complete.json # 0.9.1 起:完成标记(先置 complete:false,五文件落盘后改写 complete:true)
44
64
  ```
45
65
 
66
+ 0.9.1 起,五个数据文件之外还有一个完成标记 `export.complete.json`,内容形如 `{ schemaVersion, caseId, complete, files, eventCount }`。写入顺序是:**先**把标记写成 `complete: false`,五个数据文件全部落盘后**再**改写为 `complete: true`。因此**陈旧标记不会为半份导出宣称完整**——在覆盖写场景下,上一次成功导出的标记会在新文件写入前先被降级为 `complete: false`。
67
+
68
+ 标记里的 `eventCount` **只有在 `complete: true` 时才与磁盘上 `events.jsonl` 的行数一致**:导出中断时,它记录的是那次失败尝试**期望**的事件数,可能与实际行数不同。读取方请以 `complete` 为准,不要只看 `eventCount`。
69
+
70
+ 标记里的 `files` 只列**五个数据文件**;`scout_export` 返回值里的 `files` 是这次调用写出的**六个名字**(五个数据文件 + 标记自身)。同名不同义,两者都正确。
71
+
72
+ `scout_import` 读的是标记里的 **`complete` 字段**,而不是「标记文件是否存在」:标记缺失或 `complete: false` 时,只要**五个数据文件都已落盘**,导入仍会进行,但返回值报告 `complete: false`,让调用方自己决定是否信任这份快照。也就是说:中断的导出只要五个数据文件都在,仍可导入,但会被如实标为不完整;五个数据文件缺任何一个(例如在写 `claims.json` 时中断的全新导出)仍是硬失败。
73
+
46
74
  首版不要求精确的总分。报告必须包含:
47
75
 
48
76
  - 一句话判断和判断状态
49
77
  - 3 个支持判断的关键证据
50
78
  - 3 个阻断或待核验风险
51
79
  - 岗位可能的真实任务与权限假设
52
- - 5-10 个面试反问
80
+ - 面试反问清单:**上限 12 条**;案例自带的精选问题**永远保留名额**,其余名额按「待办下一步 → 缺失必需维度 → 未调查维度」的优先级填充
53
81
  - 下一步最小核验动作
54
82
 
55
83
  ## 4. 证据模型
@@ -72,11 +100,28 @@ scout-cases/<case-id>/
72
100
  首版作为外部 bundle,优先使用现有工具、子 Agent、任务和文件系统能力:
73
101
 
74
102
  - 工具:启动 case、核验 claim、比较公司、生成面试问题、导出报告。
75
- - 事件观察:记录工具结果和来源归档,不重写 Agent Loop。
76
- - 配置:来源目录、报告目录、并发数、超时和输出语言必须是可配置字段。
103
+ - 事件观察:记录工具结果和来源归档,不重写 Agent Loop。插件自己的 `events.jsonl` 事件只存在于导出文件里,**不写进 session log**(理由见 §5.1)。
104
+ - 配置:来源目录、报告目录和输出语言必须是可配置字段。`scoutDir` / `autoPersist` 已在 v0.3 落地,`language`(`'zh-CN' | 'en'`,默认 `'zh-CN'`)在 0.9.1 落地——`case.json` 里的 `language` 是**本部署的渲染语言**,不是文件内容的语言:对齐只按这个标签判定,不校验标签与内容是否自洽,手工改写的外来文件不做校正。**并发数与超时 0.9.1 明确不做**:DSH 工具默认串行(未声明 `isConcurrencySafe` 即 exclusive),插件这一层没有可并发的调度面;超时按需在 0.10.0 引入。契约里的字段要么实现、要么写明为什么不做,不留未解释的占位。
77
105
  - 生命周期:所有 watcher、后台任务、连接和临时文件都必须在卸载时清理。
78
106
 
79
- 首版不新增模型可见的持久化事件;如果未来需要让 case 状态进入模型上下文,必须为其设计可回放的 session event。
107
+ ### 5.1 撤回「自造 session event」计划(红线)
108
+
109
+ 0.9.0 的路线图曾写:「如果未来需要让 case 状态进入模型上下文,必须为其设计可回放的 session event」。**该计划在 DSH 0.2.0-rc.2 上撤回**——这不是取舍,而是硬约束:
110
+
111
+ - 类型层确实开放:`SessionEventMap` 可以 declaration-merge,官方文档也写明「A plugin may declaration-merge extra `SessionEventMap` types」。但**落盘读路径不认**:`validateStoredEvents()` 对任何不在 `KNOWN_SESSION_EVENT_TYPES`(构建期生成的 59 个名字)内、且 `ignorable !== true` 的事件类型直接抛错(*refusing to interpret the log*,`@deepseek-ai/dsh-session-persistence/lib/index.js:184`)。
112
+ - 而 `Session.append()` 构造的信封只有 `{ type, seq, time, data, …surfaceMetadata }`,**没有任何路径能写入 `ignorable`**(`@deepseek-ai/dsh-session/lib/index.js:1441-1459`;`surfaceMetadata` 只带 `sourceEventSeqs` / `surfaceOp`)。唯一例外是创建期的 seed 事件(`assertSessionEventEnvelope` 的白名单包含 `ignorable`,`dsh-session/lib/types/index.js:141,154`),活会话里追加没有出路。
113
+ - 后果:插件自造的事件会让**插件自己写的会话在重启 / 恢复时无法重建**。一个主打「证据可追溯」的插件不能拿用户的会话日志去赌这个。
114
+
115
+ ### 5.2 替代路线:只用宿主已知词表内的接缝
116
+
117
+ | 需求 | 0.2.0-rc.2 里可用的接缝 | 落地版本 |
118
+ | --- | --- | --- |
119
+ | 用户显式动作(`/scout status` / `questions` / `report` / `export`) | `ctx.commands.register(def)`;宿主在处理前后自动落 `command/run` + `command/done`,两个名字都在 `KNOWN_SESSION_EVENT_TYPES` 内 | 0.9.1 |
120
+ | 结构化 UI 载荷 | `output.presentationMeta(args, value)` → 持久化在 `tool/result.meta`(无损 JSON,随会话日志走) | 0.10.0(0.9.1 不引入:没有 client 消费者时它没有可见收益) |
121
+ | 跨会话 / 跨重启保存台账 | `ctx.storageDomain`(`defineDomain` + `ctx.storageDomain.open()`,读同步、写持久、`domain/changed` 有序) | 0.10.0(会改变 owner 语义,需与「case 是否跨会话共享」一起决策) |
122
+ | 把「尽调方法」交给模型 | `ctx.skills.register({ name, description, content, … })`——无需落文件、无需 client bundle,卸载即消失 | 0.9.1 |
123
+
124
+ > 一句话规则:**不要自造 session event。** 用户显式动作走 `command/run` + `command/done`,结构化 UI 载荷走 `output.presentationMeta` → `tool/result.meta`,持久状态走 `ctx.storageDomain`。
80
125
 
81
126
  ## 6. 明确非目标
82
127
 
@@ -86,6 +131,7 @@ scout-cases/<case-id>/
86
131
  - 不把个人简历、身份信息或求职意图默认发送给第三方服务。
87
132
  - 不提供法律、投资或医疗结论。
88
133
  - 不在首版实现完整 Web UI、实时数据库或公司评分排行榜。
134
+ - client 面板与 `ctx.storageDomain` 持久化重写排在 **0.10.0**(见 §5.2):后者要引入 domain schema、生命周期与迁移策略,并改变现在的 owner 语义(按 `SessionId` 分桶)。在它们落地之前,跨会话持久化仍由五文件导出 / 导入承担。
89
135
 
90
136
  ## 7. 首个验收案例
91
137
 
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Scout",
4
+ "description": "Evidence-driven company and job due diligence for DeepSeek Harness: every claim is sourced, verifiable, and traceable."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "司察 Scout",
4
+ "description": "证据驱动的公司与岗位尽调插件:每条主张都有出处、可核验、可追溯。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "dsh-scout",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Evidence-driven company and job intelligence plugin for DeepSeek Harness.",
5
+ "icon": "./assets/icon.svg",
5
6
  "license": "MIT",
6
7
  "type": "module",
7
8
  "main": "dist/index.js",
@@ -12,11 +13,15 @@
12
13
  "default": "./dist/index.js"
13
14
  },
14
15
  "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./locale/en.json": "./locale/en.json",
17
+ "./locale/zh.json": "./locale/zh.json",
15
18
  "./package.json": "./package.json"
16
19
  },
17
20
  "files": [
18
21
  "dist",
19
22
  "cordis.patch.yml",
23
+ "locale",
24
+ "assets",
20
25
  "scripts/check-release.mjs",
21
26
  "scripts/profile-smoke.mjs",
22
27
  "README.md",
@@ -24,7 +29,11 @@
24
29
  "docs/dsh-scout-product-contract.md",
25
30
  "docs/fixtures/dsh-scout/snapmaker-hr-head.json",
26
31
  "tests/model.test.mjs",
27
- "README.en.md"
32
+ "tests/package.test.mjs",
33
+ "tests/tools.test.mjs",
34
+ "tests/acceptance.test.mjs",
35
+ "README.en.md",
36
+ "CHANGELOG.md"
28
37
  ],
29
38
  "scripts": {
30
39
  "build": "tsc -p tsconfig.json",
@@ -35,7 +44,8 @@
35
44
  "prepare": "npm run build"
36
45
  },
37
46
  "engines": {
38
- "node": ">=22.19.0"
47
+ "node": ">=22.19.0",
48
+ "dsh": ">=0.1.0-rc.6 <0.3.0-0"
39
49
  },
40
50
  "peerDependencies": {
41
51
  "@deepseek-ai/cordis": ">=4.0.2 <5",
@@ -44,9 +54,6 @@
44
54
  "peerDependenciesMeta": {
45
55
  "@deepseek-ai/cordis": {
46
56
  "optional": true
47
- },
48
- "@deepseek-ai/dsh-tools": {
49
- "optional": true
50
57
  }
51
58
  },
52
59
  "devDependencies": {
@@ -55,9 +62,10 @@
55
62
  "typescript": "^5.8.3"
56
63
  },
57
64
  "dependencies": {
58
- "@deepseek-ai/schemastery": "^3.18.2"
65
+ "@deepseek-ai/schemastery": "~3.18.4"
59
66
  },
60
67
  "dsh": {
68
+ "manifestVersion": 1,
61
69
  "bundle": {
62
70
  "patch": "./cordis.patch.yml"
63
71
  }
@@ -1,12 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-scout release gate.
4
+ *
5
+ * Runs the unit tests, the real-invocation profile smoke against every DSH
6
+ * version in the verified matrix, and the packaging dry run.
7
+ *
8
+ * DSH 0.9.1 addition — declared-range classification. `engines.dsh` in
9
+ * package.json is the declared compatibility window; `DSH compatibility is
10
+ * declarative until a reader enforces it` (dsh-package-manifest), so this gate
11
+ * is where the declaration is actually checked. Each matrix failure is now
12
+ * classified:
13
+ * - a failure INSIDE `engines.dsh` is a REGRESSION (the plugin used to work
14
+ * and the code or the host changed);
15
+ * - a failure OUTSIDE `engines.dsh` is reported with the `OUT OF DECLARED
16
+ * RANGE` prefix: it is expected drift when DSH publishes a new major line
17
+ * (for example `0.3.0-alpha.1` against `<0.3.0-0`), not a code regression.
18
+ * Set `DSH_ALLOW_OUT_OF_RANGE=1` to keep an out-of-range channel job green while
19
+ * still printing the distinct prefix.
20
+ *
21
+ * Environment:
22
+ * DSH_VERSIONS override the verified version matrix
23
+ * (comma- or whitespace-separated, e.g.
24
+ * `DSH_VERSIONS=0.2.0-rc.2 node scripts/check-release.mjs`)
25
+ * DSH_ALLOW_OUT_OF_RANGE `1` downgrades out-of-range failures to warnings
26
+ *
27
+ * SemVer semantics (do NOT "fix" this matcher): this script has no runtime
28
+ * dependency, so it carries a small comparator-set matcher that mirrors
29
+ * node-semver's `{ includePrerelease: true }` mode — the exact mode DSH uses to
30
+ * evaluate plugin compatibility (`semver.satisfies(runtimeVersion, requirement,
31
+ * { includePrerelease: true })`, `@deepseek-ai/dsh-app-boot/lib/index.js:300`).
32
+ * In plain node-semver mode a prerelease version only satisfies a comparator set
33
+ * when some comparator shares its [major, minor, patch] tuple, which would
34
+ * reject the entire verified 0.2 prerelease line. Measured with semver 7.8.5
35
+ * against `engines.dsh = ">=0.1.0-rc.6 <0.3.0-0"`:
36
+ *
37
+ * version plain includePrerelease
38
+ * 0.1.0-rc.6 true true
39
+ * 0.1.2-rc.1 false true
40
+ * 0.2.0-rc.2 false true
41
+ * 0.2.1-alpha.2 false true
42
+ * 0.2.9 true true
43
+ * 0.3.0-alpha.1 false false
44
+ * 0.3.0 false false
45
+ *
46
+ * This matcher was differentially tested against semver 7.8.5 with
47
+ * `{ includePrerelease: true }` and agrees on every case above plus
48
+ * 0.1.0-rc.5 / 0.1.0 / 0.1.1 / 0.2.0-alpha.0 / 0.3.0-0 / 0.2.1 / 1.0.0 / 0.0.9.
49
+ * Unsupported range syntax (for example `1.x`, `*`, `latest`) is reported as
50
+ * UNKNOWN instead of being guessed.
51
+ * Note: `engines.dsh` itself is declarative in DSH 0.2.0-rc.2 (`dsh-package-manifest`:
52
+ * "DSH compatibility is declarative until a reader enforces it"); the range DSH
53
+ * actually enforces is the `peerDependencies` one at the call site above, so this
54
+ * gate is where the `engines.dsh` declaration is really checked.
55
+ */
1
56
  import { spawnSync } from 'node:child_process'
57
+ import { readFileSync } from 'node:fs'
2
58
 
3
- const command = name => process.platform === 'win32' ? `${name}.cmd` : name
4
- const verifiedDshVersions = [
59
+ const DEFAULT_VERIFIED_DSH_VERSIONS = [
5
60
  '0.1.0-rc.6',
6
61
  '0.1.2-rc.1',
7
62
  '0.2.0-rc.2',
8
63
  '0.2.1-alpha.2',
9
64
  ]
65
+ const ALLOW_OUT_OF_RANGE = process.env.DSH_ALLOW_OUT_OF_RANGE === '1'
66
+ const command = name => process.platform === 'win32' ? `${name}.cmd` : name
67
+
68
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
69
+ const declaredRange = typeof packageJson.engines?.dsh === 'string' ? packageJson.engines.dsh.trim() : ''
70
+
71
+ function resolveVersions() {
72
+ const override = (process.env.DSH_VERSIONS ?? '').trim()
73
+ if (!override) return { versions: DEFAULT_VERIFIED_DSH_VERSIONS, source: 'default matrix' }
74
+ const versions = override.split(/[,\s]+/).filter(Boolean)
75
+ if (versions.length === 0) throw new Error(`DSH_VERSIONS is set but names no version: ${JSON.stringify(process.env.DSH_VERSIONS)}`)
76
+ return { versions, source: 'DSH_VERSIONS override' }
77
+ }
78
+
79
+ // --- minimal SemVer comparator support (see the module doc for the deviation) --
80
+
81
+ const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
82
+
83
+ function parseVersion(text) {
84
+ const match = VERSION_PATTERN.exec(String(text).trim())
85
+ if (match === null) return null
86
+ return {
87
+ major: Number(match[1]),
88
+ minor: Number(match[2]),
89
+ patch: Number(match[3]),
90
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
91
+ }
92
+ }
93
+
94
+ function compareIdentifiers(a, b) {
95
+ const numeric = /^\d+$/
96
+ const aNumeric = numeric.test(a)
97
+ const bNumeric = numeric.test(b)
98
+ if (aNumeric && bNumeric) return Number(a) === Number(b) ? 0 : Number(a) < Number(b) ? -1 : 1
99
+ if (aNumeric) return -1
100
+ if (bNumeric) return 1
101
+ return a === b ? 0 : a < b ? -1 : 1
102
+ }
103
+
104
+ function comparePrerelease(a, b) {
105
+ if (a.length === 0 && b.length === 0) return 0
106
+ if (a.length === 0) return 1
107
+ if (b.length === 0) return -1
108
+ const length = Math.max(a.length, b.length)
109
+ for (let index = 0; index < length; index += 1) {
110
+ if (a[index] === undefined) return -1
111
+ if (b[index] === undefined) return 1
112
+ const order = compareIdentifiers(a[index], b[index])
113
+ if (order !== 0) return order
114
+ }
115
+ return 0
116
+ }
117
+
118
+ function compareVersions(a, b) {
119
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
120
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
121
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
122
+ return comparePrerelease(a.prerelease, b.prerelease)
123
+ }
124
+
125
+ function parseRange(text) {
126
+ const orSets = String(text).trim().split('||').map(part => part.trim()).filter(Boolean)
127
+ if (orSets.length === 0) return { sets: null, unsupported: String(text) }
128
+ const sets = []
129
+ for (const part of orSets) {
130
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(part)
131
+ const tokens = hyphen === null ? part.split(/\s+/).filter(Boolean) : [`>=${hyphen[1]}`, `<=${hyphen[2]}`]
132
+ const comparators = []
133
+ for (const token of tokens) {
134
+ const match = /^(>=|<=|>|<|=|\^|~)?(.*)$/.exec(token)
135
+ const operator = match[1] === undefined ? '=' : match[1]
136
+ const version = parseVersion(match[2])
137
+ if (version === null) return { sets: null, unsupported: token }
138
+ if (operator === '^' || operator === '~') {
139
+ comparators.push({ operator: '>=', version })
140
+ comparators.push({
141
+ operator: '<',
142
+ version: operator === '^'
143
+ ? { major: version.major + 1, minor: 0, patch: 0, prerelease: ['0'] }
144
+ : { major: version.major, minor: version.minor + 1, patch: 0, prerelease: ['0'] },
145
+ })
146
+ } else {
147
+ comparators.push({ operator, version })
148
+ }
149
+ }
150
+ sets.push(comparators)
151
+ }
152
+ return { sets, unsupported: null }
153
+ }
154
+
155
+ function satisfiesSet(version, comparators) {
156
+ return comparators.every(comparator => {
157
+ const order = compareVersions(version, comparator.version)
158
+ switch (comparator.operator) {
159
+ case '>=': return order >= 0
160
+ case '>': return order > 0
161
+ case '<=': return order <= 0
162
+ case '<': return order < 0
163
+ case '=': return order === 0
164
+ default: return false
165
+ }
166
+ })
167
+ }
168
+
169
+ /** Classify one DSH version against `engines.dsh`. */
170
+ function classifyVersion(version) {
171
+ if (declaredRange === '') return { status: 'unknown', reason: 'package.json declares no engines.dsh' }
172
+ const parsedVersion = parseVersion(version)
173
+ if (parsedVersion === null) return { status: 'unknown', reason: `not a SemVer version: ${version}` }
174
+ const range = parseRange(declaredRange)
175
+ if (range.sets === null) return { status: 'unknown', reason: `unsupported engines.dsh syntax near "${range.unsupported}"` }
176
+ const satisfied = range.sets.some(set => satisfiesSet(parsedVersion, set))
177
+ return satisfied
178
+ ? { status: 'in-range', reason: `satisfies engines.dsh "${declaredRange}"` }
179
+ : { status: 'out-of-range', reason: `outside engines.dsh "${declaredRange}"` }
180
+ }
181
+
182
+ // --- gate ---------------------------------------------------------------------
10
183
 
11
184
  function run(program, args, env = process.env) {
12
185
  const result = spawnSync(command(program), args, { env, stdio: 'inherit' })
@@ -16,8 +189,50 @@ function run(program, args, env = process.env) {
16
189
  }
17
190
  }
18
191
 
192
+ const { versions, source } = resolveVersions()
193
+ console.log('dsh-scout release gate')
194
+ console.log(` declared engines.dsh: ${declaredRange === '' ? '(none declared — failures cannot be classified)' : declaredRange}`)
195
+ console.log(' range semantics: node-semver includePrerelease:true (mirrors dsh-app-boot evaluatePluginCompatibility:300)')
196
+ console.log(` profile matrix: ${versions.join(', ')} (${source})`)
197
+ console.log(` out-of-range failures: ${ALLOW_OUT_OF_RANGE ? 'warn only (DSH_ALLOW_OUT_OF_RANGE=1)' : 'fatal, reported as OUT OF DECLARED RANGE'}`)
198
+
19
199
  run('npm', ['test'])
20
- for (const version of verifiedDshVersions) {
21
- run('npm', ['run', 'test:profile'], { ...process.env, DSH_VERSION: version })
200
+
201
+ const regressions = []
202
+ const outOfRangeFailures = []
203
+ const unknownRangeFailures = []
204
+ for (const version of versions) {
205
+ const classification = classifyVersion(version)
206
+ try {
207
+ run('npm', ['run', 'test:profile'], { ...process.env, DSH_VERSION: version })
208
+ console.log(`[ok] dsh ${version} — profile smoke passed (${classification.reason})`)
209
+ } catch (error) {
210
+ if (classification.status === 'out-of-range') {
211
+ console.error(`[expected drift] OUT OF DECLARED RANGE dsh ${version} — profile smoke failed ${classification.reason}`)
212
+ outOfRangeFailures.push({ version, reason: classification.reason })
213
+ } else if (classification.status === 'unknown') {
214
+ console.error(`[regression] dsh ${version} — profile smoke failed and the version cannot be classified (${classification.reason})`)
215
+ unknownRangeFailures.push({ version, reason: classification.reason })
216
+ } else {
217
+ console.error(`[regression] dsh ${version} — profile smoke failed inside the declared range`)
218
+ regressions.push({ version, reason: classification.reason })
219
+ }
220
+ }
22
221
  }
222
+
23
223
  run('pnpm', ['pack', '--dry-run'])
224
+
225
+ const fatal = regressions.length > 0 || unknownRangeFailures.length > 0 || (outOfRangeFailures.length > 0 && !ALLOW_OUT_OF_RANGE)
226
+ if (regressions.length > 0 || outOfRangeFailures.length > 0 || unknownRangeFailures.length > 0) {
227
+ console.error('')
228
+ console.error(`dsh-scout release gate: ${fatal ? 'FAIL' : 'PASS (with expected out-of-range drift)'}`)
229
+ for (const failure of regressions) console.error(` REGRESSION (declared range): dsh ${failure.version} — ${failure.reason}`)
230
+ for (const failure of unknownRangeFailures) console.error(` RANGE UNKNOWN (cannot classify): dsh ${failure.version} — ${failure.reason}`)
231
+ for (const failure of outOfRangeFailures) {
232
+ const kind = ALLOW_OUT_OF_RANGE ? 'OUT OF DECLARED RANGE (warn only)' : 'OUT OF DECLARED RANGE'
233
+ console.error(` ${kind}: dsh ${failure.version} — ${failure.reason}; widen engines.dsh only after verifying the new line`)
234
+ }
235
+ if (regressions.length > 0 || unknownRangeFailures.length > 0 || !ALLOW_OUT_OF_RANGE) process.exitCode = 1
236
+ } else {
237
+ console.log(`dsh-scout release gate: PASS (${versions.length} DSH versions, ${declaredRange || 'no declared range'})`)
238
+ }