@mycf711/dsh-gate 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -62,6 +62,25 @@ if (!result.ok) return
62
62
  `extends` 循环、目标不存在、嵌套超过 8 层:**记录到 `errors`,按本层规则继续**(fail-open)。
63
63
  一个坏配置文件不应该让整个门禁不可用。
64
64
 
65
+ **写盘侧是 fail closed 的**:`writeGateConfig()`(以及面板的 `POST /gates`)是**整层重写**,
66
+ 只写 `version` / `name` / `extends` / `rules` / `semantic`(`extends` 只保留 `false` / `"none"`),
67
+ 不会解析也不会重放基文件。所以:
68
+
69
+ | 写盘时的 `extends` | 行为 |
70
+ |---|---|
71
+ | 不写,文件里也没有 | 正常整层重写 |
72
+ | `false` / `"none"`(入参或文件里已有) | 允许,原样写回 —— 写回后仍是「替换」语义 |
73
+ | 入参是 `true` / `"default"` / 路径 / 内联对象 | 报错,错误里点名**配置路径**与解决方式 |
74
+ | 文件里已有上面这些共享形式 | 同样报错:整层重写会把共享基和它带来的规则一起冲掉 |
75
+
76
+ 错误码 `extends_not_preservable`,`error.path` 是那份配置的绝对路径。想改写这种层,
77
+ 要么直接手工编辑文件,要么先把基规则合并进本层规则、删掉 `extends` 后再从面板保存。
78
+ 坏 JSON 的层例外:原文会先写进 `.bak`,再写入新配置(可恢复,不算静默丢失)。
79
+ `validateConfig()` 会原样保留 `extends`(只做形状校验),写不写由 `writeGateConfig()` 按路径判定 ——
80
+ 否则「面板 → POST /gates → 写盘」这条链上就没人知道调用方传过 `extends` 了。
81
+
82
+ `--config` 传入的配置**不做 `extends` 解析**(调试 / CI 用,只按该文件本层规则跑)。
83
+
65
84
  ### 语义复审只能由用户层打开
66
85
 
67
86
  项目层 / 本地层**只能关闭**语义复审:
@@ -105,6 +124,26 @@ if (!result.ok) return
105
124
  | `destructive` | 禁止破坏性操作建议 | regex | 强推、`rm -rf /`、删分支、关他人 PR |
106
125
  | `empty` | 不能是空回复 | nonEmpty | 空串 / 纯空白(仅回复类触发器) |
107
126
 
127
+ ### 规则启停(`enabled`)
128
+
129
+ 每条规则可选 `enabled`,**缺省 `true`**:
130
+
131
+ | 写法 | 行为 |
132
+ |---|---|
133
+ | 不写 / `true` | 规则生效 |
134
+ | `false` | **本层明确停用**同 id 规则:不参与判定,连 `warn` 都不出 |
135
+
136
+ ```json
137
+ { "id": "secret", "type": "regex", "pattern": "ghp_[A-Za-z0-9]{20,}", "enabled": false }
138
+ ```
139
+
140
+ - 同 id 后层**完整覆盖**前层(含 `enabled`):停用不会被更前层复活;后层写 `enabled: true` 即恢复。
141
+ - 复启必须写**同 id 完整规则**:只写 `{ "id": "secret", "enabled": true }` 缺 `type` 判非法,
142
+ `validateConfig` 返回「规则缺少 type」,面板保存会报错 —— 不会静默变回停用。
143
+ - 写盘保留 `enabled: false`(JSON 往返仍是 `false`);没写 `enabled` 的旧配置写回不凭空加字段。
144
+ - 非布尔 `enabled`(例如字符串 `"false"`)判非法;读配置时按「规则仍生效」处理(fail-closed)。
145
+ - 默认配置 `gates/default.json` 不写 `enabled`,10 条规则全开。
146
+
108
147
  ## API
109
148
 
110
149
  ### 确定性门禁
@@ -114,11 +153,11 @@ if (!result.ok) return
114
153
  | `createGate(options?)` | `GateProvider` | 稳定接口;`options.repo` / `options.config` / `options.semantic` / `options.semanticOptions` |
115
154
  | `loadGateConfig(options?)` | `{ config, sources, errors }` | 四层合并;`repo` / `home` / `env` / `defaultPath` 可注入 |
116
155
  | `mergeConfig(base, override)` | `config` | 同 id 覆盖、不同 id 追加 |
117
- | `normalizeConfig(input)` | `config` | 非法规则静默丢弃(写盘前请用 `validateConfig`) |
118
- | `lintText(text, config, options?)` | `{ ok, hits }` | `options.trigger` / `options.current` |
119
- | `validateConfig(input)` | `{ ok: true, config }` 或 `{ ok: false, error }` | 写盘 / 面板保存前校验 |
156
+ | `normalizeConfig(input)` | `config` | 非法规则静默丢弃(写盘前请用 `validateConfig`);保留规则的 `enabled` |
157
+ | `lintText(text, config, options?)` | `{ ok, hits }` | `options.trigger` / `options.current`;跳过 `enabled: false` 的规则 |
158
+ | `validateConfig(input)` | `{ ok: true, config }` 或 `{ ok: false, error }` | 写盘 / 面板保存前校验;`enabled` 必须是布尔、复启必须写完整规则;`extends` 只校验形状并原样带回 |
120
159
  | `readGateScope(scope, options?)` | `{ path, config, error? }` | `scope` = `default` / `user` / `project` / `local` |
121
- | `writeGateConfig(scope, config, options?)` | `{ path, config }` | 只允许 `user` / `project` / `local`;先 `.bak` 再原子替换 |
160
+ | `writeGateConfig(scope, config, options?)` | `{ path, config }` | 只允许 `user` / `project` / `local`;先 `.bak` 再原子替换;共享 `extends` 抛错(`code: 'extends_not_preservable'`) |
122
161
  | `userGatePath` / `projectGatePath` / `localGatePath` | `string` | 路径派生 |
123
162
  | `DEFAULT_GATE_PATH` / `TRIGGERS` / `RULE_TYPES` | 常量 | 默认规则路径与枚举 |
124
163
 
@@ -151,7 +190,8 @@ interface GateProvider {
151
190
  ```
152
191
 
153
192
  `check()` 先跑确定性规则;只有触发器是 `merge` / `release` **且**配置里
154
- `semantic.enabled === true` 时才追加语义复审。
193
+ `semantic.enabled === true` 时才追加语义复审。**硬门禁已经不过时不再请求模型**
194
+ (此时 `semantic` 为 `{ skipped: true, reason: '硬门禁未通过,未请求模型' }`)。
155
195
 
156
196
  ### 语义复审(`@mycf711/dsh-gate/semantic`)
157
197
 
@@ -210,11 +250,12 @@ printf '正文' | node bin/gate.mjs --trigger pr.reply --body-file - --current 1
210
250
  | `--current` | 当前 issue/PR 编号;缺省时 `foreign-ref` 跳过 |
211
251
  | `--repo` | 仓库目录(默认当前目录) |
212
252
  | `--config` | 直接用一份配置,不读四层(调试 / CI) |
213
- | `--semantic` | 在 `merge` / `release` 上强制跑语义复审 |
253
+ | `--semantic` | 在 `merge` / `release` 上强制跑语义复审(硬门禁已失败时不会请求模型) |
214
254
  | `--json` | 输出 JSON |
215
255
  | `--help` | 帮助 |
216
256
 
217
- 退出码:`0` 通过(warn 不阻断)· `1` 有 `error` 命中 · `2` 用法 / 配置 / IO 错误。
257
+ 退出码:`0` 通过(warn 不阻断)· `1` 有 `error` 命中 · `2` 用法 / 配置 / IO 错误
258
+ (未知参数、缺 `--body-file`、未知 trigger、`--current` 非数字、配置文件不存在或非法 JSON)。
218
259
 
219
260
  ## 安全边界
220
261
 
@@ -224,6 +265,8 @@ printf '正文' | node bin/gate.mjs --trigger pr.reply --body-file - --current 1
224
265
  - **先脱敏再出网**:`redact()` 覆盖 GitHub / GitLab / Slack / Stripe / npm / AWS / Google /
225
266
  JWT / PEM / URL 密码 / 查询参数 / Gitee 令牌等形状。
226
267
  - **写盘可回滚**:`writeGateConfig` 先写 `.bak`,临时文件 `0o600`,`rename` 原子替换。
268
+ - **写盘不丢 `extends`**:整层重写只写 `version/name/rules/semantic`;`false` / `"none"` 原样保留,
269
+ 共享形式(`true` / `"default"` / 路径 / 对象)显式报错并点名配置路径 —— 宁可保存失败,也不悄悄冲掉共享规则。
227
270
  - **坏配置 fail-open(仅语义复审)**:语义层不可用时放行并给 warning;确定性规则仍逐条执行。
228
271
 
229
272
  ## 测试
@@ -233,8 +276,10 @@ cd packages/dsh-gate
233
276
  node --test
234
277
  ```
235
278
 
236
- 覆盖:四层合并与同 id 覆盖、`extends`(替换 / 相对路径 / 循环)、脱敏 / 路径 / 无关引用、
237
- 规则类型与触发器过滤、语义复审只 merge/release 与 fail-open、CLI 的 PASS/FAIL 退出码、
279
+ 覆盖:四层合并与同 id 覆盖、`extends`(替换 / 相对路径 / 循环 / 写盘 fail closed:共享基拒绝、
280
+ `false` / `"none"` 原样保留、读回仍是替换、坏 JSON 先进 `.bak`)、脱敏 / 路径 / 无关引用、
281
+ 规则类型与触发器过滤、规则启停(`enabled` 缺省 / 停用 / 后层复启 / 写盘往返 / 非法形状)、
282
+ 语义复审只 merge/release 与 fail-open、硬门禁失败不请求模型、CLI 的 PASS/FAIL 退出码、
238
283
  默认 JSON 可解析且 10 条规则都真的能命中。
239
284
 
240
285
  ## 未验项
@@ -244,6 +289,8 @@ node --test
244
289
  - 面板 UI(G3)与主插件路由(G2)不在本包范围。
245
290
  - 超大配置文件(数千条规则)的性能未压测。
246
291
  - `extends` 跨盘符 / UNC 路径未单独验证。
292
+ - 两个进程同时写同一层配置(读 extends → `.bak` → rename 之间的 TOCTOU)未压测:本包没有文件锁,
293
+ 只靠 `.bak` + 原子 `rename` 兜底。
247
294
 
248
295
  ## License
249
296
 
package/bin/gate.mjs CHANGED
@@ -146,8 +146,13 @@ async function main(argv) {
146
146
  let semantic = { skipped: true }
147
147
  const semanticEnabled = Boolean(config.semantic && config.semantic.enabled === true)
148
148
  if (SEMANTIC_TRIGGERS.includes(options.trigger) && (options.semantic || semanticEnabled)) {
149
- semantic = await semanticReview(text, config, { trigger: options.trigger, current: options.current })
150
- hits.push(...(semantic.hits || []))
149
+ if (!deterministic.ok) {
150
+ // 先确定性、后语义:硬门禁已经不过,就不再请求模型(也不把正文发出去)。
151
+ semantic = { skipped: true, reason: '硬门禁未通过,未请求模型' }
152
+ } else {
153
+ semantic = await semanticReview(text, config, { trigger: options.trigger, current: options.current })
154
+ hits.push(...(semantic.hits || []))
155
+ }
151
156
  }
152
157
  const ok = hits.every((hit) => hit.severity !== 'error')
153
158
  const result = { ok, trigger: options.trigger, current: options.current === undefined ? null : options.current, hits, semantic }
package/lib/index.js CHANGED
@@ -10,8 +10,9 @@
10
10
  * 3. 项目 <repo>/.superagent/gates.json
11
11
  * 4. 本地覆盖 <repo>/.superagent/gates.local.json
12
12
  *
13
- * 规则按 id 合并:同 id 覆盖,不同 id 追加。项目 / 本地只能**关闭**语义复审,
14
- * 不能替用户开复审或换模型。
13
+ * 规则按 id 合并:同 id 覆盖,不同 id 追加。规则可选 `enabled`(缺省 `true`):
14
+ * `enabled:false` 表示本层明确停用该规则,后层写**同 id 完整规则** + `enabled:true` 即恢复。
15
+ * 项目 / 本地只能**关闭**语义复审,不能替用户开复审或换模型。
15
16
  *
16
17
  * 除 loadGateConfig / readGateScope / writeGateConfig 这类显式 IO 函数外,其余都是纯函数,方便主插件和测试注入。
17
18
  */
@@ -45,7 +46,7 @@ export {
45
46
 
46
47
  export const PACKAGE_NAME = '@mycf711/dsh-gate'
47
48
  /** 与 package.json 的 version 同步(测试守着)。 */
48
- export const PACKAGE_VERSION = '0.1.0'
49
+ export const PACKAGE_VERSION = '0.1.2'
49
50
 
50
51
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
51
52
 
@@ -96,6 +97,76 @@ function configPathFor(scope, { repo = process.cwd(), home = homedir(), env = pr
96
97
  throw new Error('scope 只能是 default / user / project / local,收到:' + String(scope))
97
98
  }
98
99
 
100
+ // ── extends 的写盘安全 ───────────────────────────────────────────────────────
101
+ // 只有 `false` / `'none'` 能原样写回:它们不引用任何外部内容,写回后语义不变(本层替换)。
102
+ // `true` / `'default'` / 路径字符串 / 内联对象都表示「本层以别处为基」,而写盘是整层重写
103
+ // (只写 version/name/extends/rules/semantic,其中 extends 只保留 false/'none'),
104
+ // 写下去就把基规则连同 `extends` 一起冲掉 —— 必须报错。
105
+ const EXTENDS_ABSENT = 'absent'
106
+ const EXTENDS_KEEP = 'keep'
107
+ const EXTENDS_SHARED = 'shared'
108
+ const EXTENDS_INVALID = 'invalid'
109
+
110
+ function hasOwn(object, key) {
111
+ return Boolean(object) && typeof object === 'object'
112
+ && Object.prototype.hasOwnProperty.call(object, key)
113
+ }
114
+
115
+ /**
116
+ * 判断一个 `extends` 值能不能在整层重写里安全保留。
117
+ * 读侧分类见 `readLayerConfig`:false/'none' = 替换,true/'default'/路径/对象 = 共享基。
118
+ */
119
+ function classifyExtends(value) {
120
+ if (value === undefined) return EXTENDS_ABSENT
121
+ if (value === false || value === 'none') return EXTENDS_KEEP
122
+ if (typeof value === 'string') return EXTENDS_SHARED // 含 'default'
123
+ if (value === true) return EXTENDS_SHARED
124
+ if (value && typeof value === 'object' && !Array.isArray(value)) return EXTENDS_SHARED
125
+ return EXTENDS_INVALID
126
+ }
127
+
128
+ function describeExtends(value) {
129
+ let text
130
+ try {
131
+ text = JSON.stringify(value)
132
+ } catch {
133
+ text = ''
134
+ }
135
+ if (text === undefined || text === '') text = String(value)
136
+ return text.length > 80 ? text.slice(0, 79) + '…' : text
137
+ }
138
+
139
+ /** 写盘侧的 extends 拒绝错误:必须点名配置路径 + 解决方式(面板和 CLI 都直接展示它)。 */
140
+ function extendsWriteError(source, path, value) {
141
+ const detail = source === 'existing'
142
+ ? '该文件现有的 extends(' + describeExtends(value) + ')会在整层重写时丢失,它共享的基规则也跟着失效。'
143
+ : '本条配置里的 extends(' + describeExtends(value) + ')无法在整层重写时保留。'
144
+ const fix = source === 'existing'
145
+ ? '解决方式:直接手工编辑该文件;或先把基规则合并进本层规则、删掉 extends 后再从面板保存。'
146
+ : '解决方式:先手工编辑该文件把基规则合并进本层规则,或把 extends 改成 false / "none" 后再保存。'
147
+ const error = new Error('无法安全写入 extends:' + path + '。' + detail + fix)
148
+ error.code = 'extends_not_preservable'
149
+ error.path = path
150
+ return error
151
+ }
152
+
153
+ /**
154
+ * 读磁盘上现有文件的 `extends`。坏 JSON / 非对象:返回 absent ——
155
+ * 那种文件每次读取都会报错,写盘时原文会先进 `.bak`,不会被静默抹掉。
156
+ */
157
+ function existingExtends(path) {
158
+ const absent = { kind: EXTENDS_ABSENT, value: undefined }
159
+ if (!existsSync(path)) return absent
160
+ let raw
161
+ try {
162
+ raw = JSON.parse(readFileSync(path, 'utf8'))
163
+ } catch {
164
+ return absent
165
+ }
166
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !hasOwn(raw, 'extends')) return absent
167
+ return { kind: classifyExtends(raw.extends), value: raw.extends }
168
+ }
169
+
99
170
  function normalizeRule(rule) {
100
171
  if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return null
101
172
  const id = String(rule.id || '')
@@ -127,7 +198,7 @@ function normalizeRule(rule) {
127
198
  // 直接丢弃(validateConfig 会给出具体原因),而不是静默阻断所有回复。
128
199
  if (rule.type === 'contains' && (typeof rule.value !== 'string' || rule.value.length === 0)) return null
129
200
  if (rule.type === 'maxLength' && !(Number.isFinite(Number(rule.value)) && Number(rule.value) > 0)) return null
130
- return {
201
+ const normalized = {
131
202
  id,
132
203
  label: String(rule.label || id),
133
204
  description: String(rule.description || ''),
@@ -139,6 +210,10 @@ function normalizeRule(rule) {
139
210
  value: rule.value,
140
211
  message: String(rule.message || rule.label || '未通过门禁'),
141
212
  }
213
+ // 规则级启停:只保留**显式写了的布尔值**,缺省即启用(旧配置写回不凭空加字段)。
214
+ // 非布尔(例如字符串 "false")一律忽略:宁可规则继续生效,也不静默把保护关掉。
215
+ if (typeof rule.enabled === 'boolean') normalized.enabled = rule.enabled
216
+ return normalized
142
217
  }
143
218
 
144
219
  /** 整理成稳定形状;非法规则被丢弃(`validateConfig` 会给出具体原因)。 */
@@ -330,6 +405,8 @@ export function lintText(text, config, { trigger = 'issue.reply', current } = {}
330
405
  const body = String(text ?? '')
331
406
  const hits = []
332
407
  for (const rule of normalizeConfig(config).rules) {
408
+ // 本层明确停用(enabled:false)的规则不参与判定:连 warn 都不出,ok 口径不变。
409
+ if (rule.enabled === false) continue
333
410
  if (!rule.triggers.includes(trigger)) continue
334
411
  if (!ruleMatches(rule, body, current)) continue
335
412
  hits.push({ id: rule.id, label: rule.label, severity: rule.severity, message: rule.message })
@@ -340,11 +417,20 @@ export function lintText(text, config, { trigger = 'issue.reply', current } = {}
340
417
  /**
341
418
  * 严格校验一份配置(写盘 / 面板保存前用)。
342
419
  * 与 `normalizeConfig` 不同:这里不静默丢弃,返回第一条具体错误。
420
+ *
421
+ * `extends` 在这里只做**形状**校验,并且会原样带在返回的 `config` 上:
422
+ * 面板保存走的是 `validateConfig` → `writeGateConfig`,如果在这一步把 `extends` 抹掉,
423
+ * 写盘端就再也没机会知道调用方传过它(Q1-F3 的静默丢失)。能不能安全写下去由
424
+ * `writeGateConfig` 按目标路径判定(它才拿得到配置路径,报错时能点名)。
343
425
  */
344
426
  export function validateConfig(input) {
345
427
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
346
428
  return { ok: false, error: '配置必须是对象' }
347
429
  }
430
+ const hasExtends = Object.prototype.hasOwnProperty.call(input, 'extends') && input.extends !== undefined
431
+ if (hasExtends && classifyExtends(input.extends) === EXTENDS_INVALID) {
432
+ return { ok: false, error: 'extends 只能是 true、false、字符串或对象' }
433
+ }
348
434
  if (!Array.isArray(input.rules)) return { ok: false, error: 'rules 必须是数组' }
349
435
  if (input.name !== undefined && typeof input.name !== 'string') return { ok: false, error: 'name 必须是字符串' }
350
436
  const seen = new Set()
@@ -356,7 +442,19 @@ export function validateConfig(input) {
356
442
  if (!RULE_ID_RE.test(id)) return { ok: false, error: '规则 id 非法:' + (id || '?') }
357
443
  if (seen.has(id)) return { ok: false, error: '规则 id 重复:' + id }
358
444
  seen.add(id)
359
- if (!RULE_TYPE_SET.has(raw.type)) return { ok: false, error: '规则类型非法:' + id }
445
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
446
+ return { ok: false, error: 'enabled 必须是布尔值:' + id }
447
+ }
448
+ if (!RULE_TYPE_SET.has(raw.type)) {
449
+ // 只写 { id, enabled } 的「复启」不是合法规则:不完整规则会被合并丢掉,
450
+ // 面板必须看到具体原因,而不是以为已经保存、实际还停用着。
451
+ return {
452
+ ok: false,
453
+ error: raw.type === undefined
454
+ ? '规则缺少 type:' + id + '(只写 id + enabled 不能停用或复启,请写完整规则)'
455
+ : '规则类型非法:' + id,
456
+ }
457
+ }
360
458
  if (raw.severity !== undefined && raw.severity !== 'error' && raw.severity !== 'warn') {
361
459
  return { ok: false, error: 'severity 只能是 error / warn:' + id }
362
460
  }
@@ -412,7 +510,9 @@ export function validateConfig(input) {
412
510
  return { ok: false, error: 'semantic.maxInputChars 必须是正数' }
413
511
  }
414
512
  }
415
- return { ok: true, config: normalizeConfig(input) }
513
+ const normalized = normalizeConfig(input)
514
+ if (hasExtends) normalized.extends = input.extends
515
+ return { ok: true, config: normalized }
416
516
  }
417
517
 
418
518
  /** 读某一层的原始配置(给面板展示「这一层现在写的是什么」)。 */
@@ -429,19 +529,52 @@ export function readGateScope(scope, { repo = process.cwd(), home = homedir(), e
429
529
  /**
430
530
  * 写某一层配置:先备份 `.bak`,再原子替换(临时文件 + rename)。
431
531
  * 只允许 user / project / local —— 默认规则随包发布,不能从面板改。
532
+ *
533
+ * 写盘是**整层重写**(只写 version/name/extends/rules/semantic,其中 extends 只保留 false/'none'),
534
+ * 所以 `extends` 走 fail closed:
535
+ * - 入参 `extends: false | 'none'`:允许,原样写回(写回后仍是「替换」语义);
536
+ * - 入参 `extends: true | 'default' | '<路径>' | {…}`:抛错(含配置路径与解决方式);
537
+ * - 文件里已有这类共享 `extends`:不管入参写什么(包括 `false`)都抛错,绝不静默冲掉基规则;
538
+ * - 文件里已有 `false | 'none'` 而入参没写:原样保留,避免「替换」悄悄退化成「合并」。
432
539
  */
433
540
  export function writeGateConfig(scope, config, { repo = process.cwd(), home = homedir(), env = process.env } = {}) {
434
541
  if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
435
542
  throw new Error('scope 只能是 user / project / local')
436
543
  }
544
+ const path = configPathFor(scope, { repo, home, env })
545
+ const input = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
546
+ // 1) 调用方传入的 extends:能原样保留的写回,其余显式报错。
547
+ const incoming = hasOwn(input, 'extends') ? classifyExtends(input.extends) : EXTENDS_ABSENT
548
+ if (incoming === EXTENDS_SHARED || incoming === EXTENDS_INVALID) {
549
+ throw extendsWriteError('incoming', path, input.extends)
550
+ }
551
+ // 2) 磁盘上已有的 extends:整层重写会把它冲掉,同样不能无提示改写。
552
+ const existing = existingExtends(path)
553
+ if (existing.kind === EXTENDS_SHARED || existing.kind === EXTENDS_INVALID) {
554
+ throw extendsWriteError('existing', path, existing.value)
555
+ }
556
+ // 3) 可安全保留的值:入参优先;入参没写就用文件里已有的(否则等于悄悄改了语义)。
557
+ const keepExtends = incoming === EXTENDS_KEEP
558
+ ? { value: input.extends }
559
+ : existing.kind === EXTENDS_KEEP
560
+ ? { value: existing.value }
561
+ : null
437
562
  const checked = validateConfig(config)
438
563
  if (!checked.ok) throw new Error(checked.error)
439
- const path = configPathFor(scope, { repo, home, env })
564
+ const normalized = checked.config
565
+ // 明确的字段顺序:extends 跟在 name 后面,读文件的人一眼能看到这一层挂在哪。
566
+ const output = {
567
+ version: normalized.version,
568
+ name: normalized.name,
569
+ ...(keepExtends ? { extends: keepExtends.value } : {}),
570
+ rules: normalized.rules,
571
+ semantic: normalized.semantic,
572
+ }
440
573
  mkdirSync(dirname(path), { recursive: true })
441
574
  const tmp = path + '.tmp-' + process.pid + '-' + Date.now()
442
575
  try {
443
576
  if (existsSync(path)) copyFileSync(path, path + '.bak')
444
- writeFileSync(tmp, JSON.stringify(checked.config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
577
+ writeFileSync(tmp, JSON.stringify(output, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
445
578
  renameSync(tmp, path)
446
579
  } catch (error) {
447
580
  try {
@@ -451,7 +584,7 @@ export function writeGateConfig(scope, config, { repo = process.cwd(), home = ho
451
584
  }
452
585
  throw error
453
586
  }
454
- return { path, config: checked.config }
587
+ return { path, config: output }
455
588
  }
456
589
 
457
590
  /**
@@ -463,7 +596,8 @@ export function writeGateConfig(scope, config, { repo = process.cwd(), home = ho
463
596
  * if (!result.ok) return
464
597
  * ```
465
598
  *
466
- * 语义复审默认跟随配置(`semantic.enabled`),且只对 merge / release 生效。
599
+ * 语义复审默认跟随配置(`semantic.enabled`),且只对 merge / release 生效;
600
+ * 确定性门禁先跑,硬门禁已经不过时不再请求模型。
467
601
  */
468
602
  export function createGate({
469
603
  id = 'dsh-gate',
@@ -490,8 +624,13 @@ export function createGate({
490
624
  let semanticResult = { skipped: true }
491
625
  const semanticEnabled = Boolean(loaded.config.semantic && loaded.config.semantic.enabled === true)
492
626
  if (semantic !== false && semanticEnabled && SEMANTIC_TRIGGER_SET.has(trigger)) {
493
- semanticResult = await semanticReview(text, loaded.config, { trigger, current, ...semanticOptions })
494
- hits.push(...(semanticResult.hits || []))
627
+ if (!deterministic.ok) {
628
+ // 先确定性、后语义:硬门禁已经不过,就不再请求模型(省一次调用,也不把正文发出去)。
629
+ semanticResult = { skipped: true, reason: '硬门禁未通过,未请求模型' }
630
+ } else {
631
+ semanticResult = await semanticReview(text, loaded.config, { trigger, current, ...semanticOptions })
632
+ hits.push(...(semanticResult.hits || []))
633
+ }
495
634
  }
496
635
  return {
497
636
  ok: hits.every((hit) => hit.severity !== 'error'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycf711/dsh-gate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "内容层门禁:四层配置、确定性规则与可选语义复审,可作为 DSH 插件依赖独立安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",