@mycf711/dsh-gate 0.0.0-stage → 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * @mycf711/dsh-gate —— 内容层门禁(确定性规则 + 可选语义复审)。
3
+ *
4
+ * 门禁只管「回复正文里有什么」:凭证、本机路径、越界引用、未发布承诺、破坏性建议。
5
+ * 它**不是**工具权限层,不替代官方授权审查。
6
+ *
7
+ * 四层配置,后者覆盖前者:
8
+ * 1. 默认 packages/dsh-gate/gates/default.json(随包发布)
9
+ * 2. 用户全局 ~/.dsh/state/session-superagent/gates.json
10
+ * 3. 项目 <repo>/.superagent/gates.json
11
+ * 4. 本地覆盖 <repo>/.superagent/gates.local.json
12
+ *
13
+ * 规则按 id 合并:同 id 覆盖,不同 id 追加。项目 / 本地只能**关闭**语义复审,
14
+ * 不能替用户开复审或换模型。
15
+ *
16
+ * 除 loadGateConfig / readGateScope / writeGateConfig 这类显式 IO 函数外,其余都是纯函数,方便主插件和测试注入。
17
+ */
18
+ import {
19
+ copyFileSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { basename, dirname, join, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import {
31
+ SEMANTIC_TRIGGERS,
32
+ normalizeSemanticConfig,
33
+ semanticReview,
34
+ } from './semantic.js'
35
+
36
+ export {
37
+ SEMANTIC_TRIGGERS,
38
+ listModels,
39
+ normalizeSemanticConfig,
40
+ optimizePrompt,
41
+ resolveSemanticConfig,
42
+ parseSemanticReply,
43
+ semanticReview,
44
+ } from './semantic.js'
45
+
46
+ export const PACKAGE_NAME = '@mycf711/dsh-gate'
47
+ /** 与 package.json 的 version 同步(测试守着)。 */
48
+ export const PACKAGE_VERSION = '0.1.0'
49
+
50
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
51
+
52
+ /** 门禁支持的全部触发器。 */
53
+ export const TRIGGERS = Object.freeze(['issue.reply', 'pr.reply', 'pr.handle', 'merge', 'release'])
54
+ const TRIGGER_SET = new Set(TRIGGERS)
55
+ const SEMANTIC_TRIGGER_SET = new Set(SEMANTIC_TRIGGERS)
56
+
57
+ /** 确定性规则类型。 */
58
+ export const RULE_TYPES = Object.freeze(['regex', 'contains', 'maxLength', 'nonEmpty', 'foreign-ref'])
59
+ const RULE_TYPE_SET = new Set(RULE_TYPES)
60
+
61
+ const RULE_ID_RE = /^[a-z][a-z0-9-]{1,63}$/
62
+ const MAX_PATTERN_LENGTH = 400
63
+ const MAX_EXTENDS_DEPTH = 8
64
+
65
+ export const DEFAULT_GATE_PATH = join(PACKAGE_ROOT, 'gates', 'default.json')
66
+
67
+ function stateDir({ home = homedir(), env = process.env } = {}) {
68
+ return env.DSH_SUPERAGENT_STATE || join(home, '.dsh', 'state', 'session-superagent')
69
+ }
70
+
71
+ /** 用户全局配置路径;`DSH_GATE_USER_CONFIG` 可整体覆盖,方便测试与自定义安装。 */
72
+ export function userGatePath({ home = homedir(), env = process.env } = {}) {
73
+ return env.DSH_GATE_USER_CONFIG || join(stateDir({ home, env }), 'gates.json')
74
+ }
75
+
76
+ /** 项目配置路径。 */
77
+ export function projectGatePath(repo = process.cwd()) {
78
+ return join(repo || process.cwd(), '.superagent', 'gates.json')
79
+ }
80
+
81
+ /** 本地覆盖路径(不进版本库)。 */
82
+ export function localGatePath(repo = process.cwd()) {
83
+ return join(repo || process.cwd(), '.superagent', 'gates.local.json')
84
+ }
85
+
86
+ // 旧版主插件 lib/gates.js 的导出名,保留给 re-export,避免 G2 迁移时破坏调用点。
87
+ export const USER_GATE_PATH = userGatePath
88
+ export const PROJECT_GATE_PATH = projectGatePath
89
+ export const LOCAL_GATE_PATH = localGatePath
90
+
91
+ function configPathFor(scope, { repo = process.cwd(), home = homedir(), env = process.env, defaultPath = DEFAULT_GATE_PATH } = {}) {
92
+ if (scope === 'default') return defaultPath
93
+ if (scope === 'user') return userGatePath({ home, env })
94
+ if (scope === 'project') return projectGatePath(repo)
95
+ if (scope === 'local') return localGatePath(repo)
96
+ throw new Error('scope 只能是 default / user / project / local,收到:' + String(scope))
97
+ }
98
+
99
+ function normalizeRule(rule) {
100
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return null
101
+ const id = String(rule.id || '')
102
+ if (!RULE_ID_RE.test(id)) return null
103
+ if (!RULE_TYPE_SET.has(rule.type)) return null
104
+ if (rule.severity !== undefined && rule.severity !== 'error' && rule.severity !== 'warn') return null
105
+ const triggers = Array.isArray(rule.triggers) && rule.triggers.length > 0
106
+ ? [...new Set(rule.triggers.map(String).filter((trigger) => TRIGGER_SET.has(trigger)))]
107
+ : ['issue.reply', 'pr.reply']
108
+ if (triggers.length === 0) return null
109
+ const flags = typeof rule.flags === 'string' ? rule.flags : ''
110
+ if (rule.type === 'regex') {
111
+ const pattern = String(rule.pattern || '')
112
+ if (!pattern || [...pattern].length > MAX_PATTERN_LENGTH) return null
113
+ try {
114
+ new RegExp(pattern, flags)
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+ if (rule.type === 'foreign-ref' && rule.pattern !== undefined) {
120
+ try {
121
+ new RegExp(String(rule.pattern), 'g')
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+ // 坏规则不能变成「全拦」:contains 空串 / maxLength 非正数会让每条回复都命中,
127
+ // 直接丢弃(validateConfig 会给出具体原因),而不是静默阻断所有回复。
128
+ if (rule.type === 'contains' && (typeof rule.value !== 'string' || rule.value.length === 0)) return null
129
+ if (rule.type === 'maxLength' && !(Number.isFinite(Number(rule.value)) && Number(rule.value) > 0)) return null
130
+ return {
131
+ id,
132
+ label: String(rule.label || id),
133
+ description: String(rule.description || ''),
134
+ severity: rule.severity === 'warn' ? 'warn' : 'error',
135
+ triggers,
136
+ type: rule.type,
137
+ pattern: rule.pattern === undefined ? undefined : String(rule.pattern),
138
+ flags,
139
+ value: rule.value,
140
+ message: String(rule.message || rule.label || '未通过门禁'),
141
+ }
142
+ }
143
+
144
+ /** 整理成稳定形状;非法规则被丢弃(`validateConfig` 会给出具体原因)。 */
145
+ export function normalizeConfig(input, semanticBase = undefined) {
146
+ const config = input && typeof input === 'object' && !Array.isArray(input) ? input : {}
147
+ const rules = Array.isArray(config.rules) ? config.rules.map(normalizeRule).filter(Boolean) : []
148
+ return {
149
+ version: 1,
150
+ name: typeof config.name === 'string' && config.name.trim() ? config.name.trim() : 'config',
151
+ rules,
152
+ semantic: normalizeSemanticConfig(config.semantic, semanticBase),
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 合并两层配置:同 id 覆盖、不同 id 追加;语义复审按字段覆盖。
158
+ * 规则顺序保持「先出现的位置」,同 id 只更新内容。
159
+ */
160
+ export function mergeConfig(base, override) {
161
+ const a = normalizeConfig(base)
162
+ // 语义配置按字段合并:只写 mode 的层不能把上层 baseURL / model 清空。
163
+ const b = normalizeConfig(override, a.semantic)
164
+ const byId = new Map(a.rules.map((rule) => [rule.id, rule]))
165
+ for (const rule of b.rules) byId.set(rule.id, rule)
166
+ return {
167
+ version: 1,
168
+ name: b.name !== 'config' ? b.name : a.name,
169
+ rules: [...byId.values()],
170
+ semantic: b.semantic,
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 读一个配置层,并解析 `extends`。
176
+ *
177
+ * - 没有 `extends`:只返回本层规则(由调用方叠到已累积配置上)。
178
+ * - `extends: false | 'none'`:返回 `mode: 'replace'`,丢掉之前所有层。
179
+ * - `extends: true | 'default'`:以包内默认配置为基。
180
+ * - `extends: './base.json'`:以相对本文件的配置为基。
181
+ * - `extends: { rules: [...] }`:以对象为基。
182
+ *
183
+ * 读不到 / 循环 / 层级过深时**不抛错**:记录 error,按本层规则继续(fail-open,
184
+ * 避免一个坏配置文件让整个门禁不可用)。
185
+ */
186
+ function readLayerConfig(path, { seen = new Set(), depth = 0 } = {}) {
187
+ const absolute = resolve(path)
188
+ const empty = { config: normalizeConfig(null), mode: 'merge', raw: null, error: null }
189
+ if (depth > MAX_EXTENDS_DEPTH) {
190
+ return { ...empty, error: 'extends 嵌套超过 ' + MAX_EXTENDS_DEPTH + ' 层:' + basename(absolute) }
191
+ }
192
+ if (seen.has(absolute)) {
193
+ return { ...empty, error: 'extends 出现循环:' + basename(absolute) }
194
+ }
195
+ seen.add(absolute)
196
+ if (!existsSync(absolute)) {
197
+ return { ...empty, error: 'extends 目标不存在:' + basename(absolute) }
198
+ }
199
+ let raw
200
+ try {
201
+ raw = JSON.parse(readFileSync(absolute, 'utf8'))
202
+ } catch (error) {
203
+ return { ...empty, error: '配置不是合法 JSON:' + basename(absolute) + '(' + String((error && error.message) || error) + ')' }
204
+ }
205
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
206
+ return { ...empty, error: '配置必须是对象:' + basename(absolute) }
207
+ }
208
+ if (!Object.prototype.hasOwnProperty.call(raw, 'extends')) {
209
+ // 返回**原始对象**,让调用方的 mergeConfig 按字段合并 semantic。
210
+ return { config: raw, mode: 'merge', raw, error: null }
211
+ }
212
+ const extended = raw.extends
213
+ if (extended === false || extended === 'none') {
214
+ return { config: raw, mode: 'replace', raw, error: null }
215
+ }
216
+ if (extended === true || extended === 'default') {
217
+ const base = readLayerConfig(DEFAULT_GATE_PATH, { seen: new Set(seen), depth: depth + 1 })
218
+ return { config: mergeConfig(base.config, raw), mode: 'merge', raw, error: base.error }
219
+ }
220
+ if (typeof extended === 'string') {
221
+ const target = resolve(dirname(absolute), extended)
222
+ const base = readLayerConfig(target, { seen, depth: depth + 1 })
223
+ return { config: mergeConfig(base.config, raw), mode: 'merge', raw, error: base.error }
224
+ }
225
+ if (extended && typeof extended === 'object' && !Array.isArray(extended)) {
226
+ return { config: mergeConfig(extended, raw), mode: 'merge', raw, error: null }
227
+ }
228
+ return { config: raw, mode: 'merge', raw, error: 'extends 必须是字符串、false 或对象:' + basename(absolute) }
229
+ }
230
+
231
+ /**
232
+ * 项目 / 本地层只能关掉语义复审,不能开启或换模型。
233
+ * 返回修正后的配置;尝试开启时往 errors 里记一条。
234
+ */
235
+ function restrictSemantic(config, raw, beforeSemantic, layer, path, errors) {
236
+ const requested = raw && Object.prototype.hasOwnProperty.call(raw, 'semantic') ? raw.semantic : undefined
237
+ const disable = requested === false
238
+ || (requested && typeof requested === 'object' && !Array.isArray(requested) && requested.enabled === false)
239
+ const attempted = requested !== undefined && !disable
240
+ const semantic = beforeSemantic
241
+ ? { ...beforeSemantic, enabled: disable ? false : beforeSemantic.enabled === true }
242
+ : { enabled: false }
243
+ if (attempted) {
244
+ errors.push({ layer, path, error: '项目/本地配置只能关闭语义复审,不能开启或更换模型' })
245
+ }
246
+ return { ...config, semantic }
247
+ }
248
+
249
+ /**
250
+ * 读取四层配置。
251
+ *
252
+ * @returns {{ config: object, sources: Array<{name: string, path: string}>, errors: Array<{layer: string, path: string, error: string}> }}
253
+ * `sources` 只列真实存在的层(按 default → user → project → local 顺序)。
254
+ */
255
+ export function loadGateConfig({
256
+ repo = process.cwd(),
257
+ home = homedir(),
258
+ env = process.env,
259
+ defaultPath = DEFAULT_GATE_PATH,
260
+ } = {}) {
261
+ const sources = []
262
+ const errors = []
263
+ let config = normalizeConfig(null)
264
+ const layers = [
265
+ ['default', defaultPath],
266
+ ['user', userGatePath({ home, env })],
267
+ ['project', projectGatePath(repo)],
268
+ ['local', localGatePath(repo)],
269
+ ]
270
+ for (const [name, path] of layers) {
271
+ if (!existsSync(path)) {
272
+ if (name === 'default') errors.push({ layer: name, path, error: '默认规则文件不存在' })
273
+ continue
274
+ }
275
+ const layer = readLayerConfig(path)
276
+ if (!layer.raw) {
277
+ errors.push({ layer: name, path, error: layer.error || '配置读取失败' })
278
+ continue
279
+ }
280
+ if (layer.error) errors.push({ layer: name, path, error: layer.error })
281
+ sources.push({ name, path })
282
+ const beforeSemantic = config.semantic
283
+ config = layer.mode === 'replace' ? normalizeConfig(layer.config) : mergeConfig(config, layer.config)
284
+ if (name === 'project' || name === 'local') {
285
+ config = restrictSemantic(config, layer.raw, beforeSemantic, name, path, errors)
286
+ }
287
+ }
288
+ return { config, sources, errors }
289
+ }
290
+
291
+ function normalizeCurrent(value) {
292
+ if (value === undefined || value === null) return null
293
+ const text = String(value).trim().replace(/^#/, '')
294
+ if (!/^\d+$/.test(text)) return null
295
+ return String(Number(text))
296
+ }
297
+
298
+ function ruleMatches(rule, text, current) {
299
+ if (rule.type === 'nonEmpty') return text.trim().length === 0
300
+ if (rule.type === 'maxLength') return text.length > Number(rule.value || 0)
301
+ if (rule.type === 'contains') return text.includes(String(rule.value || ''))
302
+ if (rule.type === 'foreign-ref') {
303
+ // 没给当前编号时无法判断「其它」——跳过,不制造假红。
304
+ const currentRef = normalizeCurrent(current)
305
+ if (currentRef === null) return false
306
+ const re = new RegExp(rule.pattern || '#(\\d+)', 'g')
307
+ const refs = [...text.matchAll(re)].map((match) => (match[1] === undefined ? match[0] : match[1]))
308
+ return refs.some((ref) => normalizeCurrent(ref) !== currentRef)
309
+ }
310
+ return new RegExp(rule.pattern, rule.flags || '').test(text)
311
+ }
312
+
313
+ function assertTrigger(trigger) {
314
+ if (!TRIGGER_SET.has(trigger)) {
315
+ throw new Error('trigger 必须是 ' + TRIGGERS.join(' / ') + ' 之一,收到:' + String(trigger))
316
+ }
317
+ }
318
+
319
+ /**
320
+ * 跑确定性门禁。
321
+ *
322
+ * @param {string} text 回复正文
323
+ * @param {object} config 配置(四层合并结果或任意自定义配置)
324
+ * @param {{trigger?: string, current?: number|string}} options
325
+ * @returns {{ ok: boolean, hits: Array<{id: string, label: string, severity: 'error'|'warn', message: string}> }}
326
+ * `ok` 只由 `severity: 'error'` 的命中决定;warn 命中会出现在 hits 里但不阻断。
327
+ */
328
+ export function lintText(text, config, { trigger = 'issue.reply', current } = {}) {
329
+ assertTrigger(trigger)
330
+ const body = String(text ?? '')
331
+ const hits = []
332
+ for (const rule of normalizeConfig(config).rules) {
333
+ if (!rule.triggers.includes(trigger)) continue
334
+ if (!ruleMatches(rule, body, current)) continue
335
+ hits.push({ id: rule.id, label: rule.label, severity: rule.severity, message: rule.message })
336
+ }
337
+ return { ok: hits.every((hit) => hit.severity !== 'error'), hits }
338
+ }
339
+
340
+ /**
341
+ * 严格校验一份配置(写盘 / 面板保存前用)。
342
+ * 与 `normalizeConfig` 不同:这里不静默丢弃,返回第一条具体错误。
343
+ */
344
+ export function validateConfig(input) {
345
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
346
+ return { ok: false, error: '配置必须是对象' }
347
+ }
348
+ if (!Array.isArray(input.rules)) return { ok: false, error: 'rules 必须是数组' }
349
+ if (input.name !== undefined && typeof input.name !== 'string') return { ok: false, error: 'name 必须是字符串' }
350
+ const seen = new Set()
351
+ for (const raw of input.rules) {
352
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
353
+ return { ok: false, error: '规则必须是对象' }
354
+ }
355
+ const id = String(raw.id || '')
356
+ if (!RULE_ID_RE.test(id)) return { ok: false, error: '规则 id 非法:' + (id || '?') }
357
+ if (seen.has(id)) return { ok: false, error: '规则 id 重复:' + id }
358
+ seen.add(id)
359
+ if (!RULE_TYPE_SET.has(raw.type)) return { ok: false, error: '规则类型非法:' + id }
360
+ if (raw.severity !== undefined && raw.severity !== 'error' && raw.severity !== 'warn') {
361
+ return { ok: false, error: 'severity 只能是 error / warn:' + id }
362
+ }
363
+ if (raw.triggers !== undefined) {
364
+ if (!Array.isArray(raw.triggers) || raw.triggers.length === 0) {
365
+ return { ok: false, error: 'triggers 必须是非空数组:' + id }
366
+ }
367
+ for (const trigger of raw.triggers) {
368
+ if (!TRIGGER_SET.has(String(trigger))) {
369
+ return { ok: false, error: '未知 trigger:' + String(trigger) + '(' + id + ')' }
370
+ }
371
+ }
372
+ }
373
+ if (raw.type === 'regex') {
374
+ const pattern = String(raw.pattern || '')
375
+ if (!pattern) return { ok: false, error: 'regex 规则缺少 pattern:' + id }
376
+ if ([...pattern].length > MAX_PATTERN_LENGTH) return { ok: false, error: '正则过长(上限 ' + MAX_PATTERN_LENGTH + ' 字符):' + id }
377
+ if (raw.flags !== undefined && typeof raw.flags !== 'string') return { ok: false, error: 'flags 必须是字符串:' + id }
378
+ try {
379
+ new RegExp(pattern, raw.flags || '')
380
+ } catch (error) {
381
+ return { ok: false, error: '正则非法:' + id + '(' + String((error && error.message) || error) + ')' }
382
+ }
383
+ }
384
+ if (raw.type === 'contains' && (typeof raw.value !== 'string' || raw.value.length === 0)) {
385
+ return { ok: false, error: 'contains 规则缺少 value:' + id }
386
+ }
387
+ if (raw.type === 'maxLength' && !(Number.isFinite(Number(raw.value)) && Number(raw.value) > 0)) {
388
+ return { ok: false, error: 'maxLength 规则的 value 必须是正数:' + id }
389
+ }
390
+ if (raw.type === 'foreign-ref' && raw.pattern !== undefined) {
391
+ try {
392
+ new RegExp(String(raw.pattern), 'g')
393
+ } catch (error) {
394
+ return { ok: false, error: 'foreign-ref 的 pattern 非法:' + id + '(' + String((error && error.message) || error) + ')' }
395
+ }
396
+ }
397
+ }
398
+ if (input.semantic !== undefined && input.semantic !== false) {
399
+ if (typeof input.semantic !== 'object' || Array.isArray(input.semantic)) {
400
+ return { ok: false, error: 'semantic 必须是对象或 false' }
401
+ }
402
+ if (input.semantic.enabled !== undefined && typeof input.semantic.enabled !== 'boolean') {
403
+ return { ok: false, error: 'semantic.enabled 必须是布尔值' }
404
+ }
405
+ if (input.semantic.mode !== undefined && input.semantic.mode !== 'warn' && input.semantic.mode !== 'block') {
406
+ return { ok: false, error: 'semantic.mode 只能是 warn / block' }
407
+ }
408
+ if (input.semantic.timeoutMs !== undefined && !(Number.isFinite(Number(input.semantic.timeoutMs)) && Number(input.semantic.timeoutMs) > 0)) {
409
+ return { ok: false, error: 'semantic.timeoutMs 必须是正数' }
410
+ }
411
+ if (input.semantic.maxInputChars !== undefined && !(Number.isFinite(Number(input.semantic.maxInputChars)) && Number(input.semantic.maxInputChars) > 0)) {
412
+ return { ok: false, error: 'semantic.maxInputChars 必须是正数' }
413
+ }
414
+ }
415
+ return { ok: true, config: normalizeConfig(input) }
416
+ }
417
+
418
+ /** 读某一层的原始配置(给面板展示「这一层现在写的是什么」)。 */
419
+ export function readGateScope(scope, { repo = process.cwd(), home = homedir(), env = process.env, defaultPath = DEFAULT_GATE_PATH } = {}) {
420
+ const path = configPathFor(scope, { repo, home, env, defaultPath })
421
+ try {
422
+ if (!existsSync(path)) return { path, config: null }
423
+ return { path, config: JSON.parse(readFileSync(path, 'utf8')) }
424
+ } catch (error) {
425
+ return { path, config: null, error: '配置不是合法 JSON:' + String((error && error.message) || error) }
426
+ }
427
+ }
428
+
429
+ /**
430
+ * 写某一层配置:先备份 `.bak`,再原子替换(临时文件 + rename)。
431
+ * 只允许 user / project / local —— 默认规则随包发布,不能从面板改。
432
+ */
433
+ export function writeGateConfig(scope, config, { repo = process.cwd(), home = homedir(), env = process.env } = {}) {
434
+ if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
435
+ throw new Error('scope 只能是 user / project / local')
436
+ }
437
+ const checked = validateConfig(config)
438
+ if (!checked.ok) throw new Error(checked.error)
439
+ const path = configPathFor(scope, { repo, home, env })
440
+ mkdirSync(dirname(path), { recursive: true })
441
+ const tmp = path + '.tmp-' + process.pid + '-' + Date.now()
442
+ try {
443
+ if (existsSync(path)) copyFileSync(path, path + '.bak')
444
+ writeFileSync(tmp, JSON.stringify(checked.config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
445
+ renameSync(tmp, path)
446
+ } catch (error) {
447
+ try {
448
+ if (existsSync(tmp)) unlinkSync(tmp)
449
+ } catch {
450
+ // 清理失败不覆盖原始错误
451
+ }
452
+ throw error
453
+ }
454
+ return { path, config: checked.config }
455
+ }
456
+
457
+ /**
458
+ * 稳定 GateProvider 接口(主插件优先 `ctx.get('gate')`,缺失时回退到这里)。
459
+ *
460
+ * ```js
461
+ * const gate = createGate()
462
+ * const result = await gate.check({ trigger: 'issue.reply', text, current, repo })
463
+ * if (!result.ok) return
464
+ * ```
465
+ *
466
+ * 语义复审默认跟随配置(`semantic.enabled`),且只对 merge / release 生效。
467
+ */
468
+ export function createGate({
469
+ id = 'dsh-gate',
470
+ version = PACKAGE_VERSION,
471
+ repo,
472
+ config,
473
+ semantic = true,
474
+ semanticOptions = {},
475
+ ...loadOptions
476
+ } = {}) {
477
+ return {
478
+ id,
479
+ version,
480
+ async check(input = {}) {
481
+ const trigger = input.trigger === undefined ? 'issue.reply' : input.trigger
482
+ assertTrigger(trigger)
483
+ const text = String(input.text ?? '')
484
+ const current = input.current
485
+ const loaded = config !== undefined
486
+ ? { config: normalizeConfig(config), sources: [], errors: [] }
487
+ : loadGateConfig({ ...loadOptions, repo: input.repo || repo || process.cwd() })
488
+ const deterministic = lintText(text, loaded.config, { trigger, current })
489
+ const hits = [...deterministic.hits]
490
+ let semanticResult = { skipped: true }
491
+ const semanticEnabled = Boolean(loaded.config.semantic && loaded.config.semantic.enabled === true)
492
+ if (semantic !== false && semanticEnabled && SEMANTIC_TRIGGER_SET.has(trigger)) {
493
+ semanticResult = await semanticReview(text, loaded.config, { trigger, current, ...semanticOptions })
494
+ hits.push(...(semanticResult.hits || []))
495
+ }
496
+ return {
497
+ ok: hits.every((hit) => hit.severity !== 'error'),
498
+ hits,
499
+ semantic: semanticResult,
500
+ }
501
+ },
502
+ }
503
+ }
package/lib/redact.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 凭证脱敏:任何要进模型上下文的文本都先过这里。
3
+ *
4
+ * 为什么独立包也要带一份:语义复审会把回复正文发给模型,如果正文里本来就有
5
+ * token / 私钥 / 数据库口令,那等于亲手把它们送出去。门禁先脱敏,再发请求。
6
+ *
7
+ * 两层形状:
8
+ * 1. 供应商前缀是精确的(ghp_ / github_pat_ / glpat- / xox / sk_live_ / npm_ / AKIA ...),
9
+ * 误伤极低;
10
+ * 2. 通用形状(URL 里的密码、裸 Bearer、查询参数)必须配上下文词,不做裸扫。
11
+ */
12
+
13
+ /** Patterns that must never reach the transcript. */
14
+ export const SECRET_PATTERNS = [
15
+ /gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub PAT(ghp_ / gho_ / ghu_ / ghs_ / ghr_)
16
+ /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub 细粒度 PAT
17
+ /glpat-[A-Za-z0-9_-]{20,}/g, // GitLab PAT
18
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack
19
+ /sk_live_[A-Za-z0-9]{20,}/g, // Stripe
20
+ /npm_[A-Za-z0-9]{30,}/g, // npm automation token
21
+ /AIza[0-9A-Za-z_-]{35}/g, // Google API key
22
+ /AKIA[0-9A-Z]{16}/g, // AWS access key id
23
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
24
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // 完整 PEM 块
25
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*/g, // 被截断的 PEM:吃到结尾
26
+ /-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----/g,
27
+ /Authorization:\s*(?:token|Bearer|Basic)\s+\S+/gi, // 回显的请求头
28
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/g, // 裸 Bearer(无头名)
29
+ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/]+:[^@\s]+@/gi, // 数据库 URL 里的密码
30
+ /[?&](?:access_token|api_key|apikey|private_token)=[^&\s"']+/gi, // 查询参数里的凭证
31
+ // Gitee 私人令牌:无前缀的 32 位十六进制。裸扫会误伤 md5 / commit sha,
32
+ // 所以要求前面 20 字内出现 gitee / 令牌 / access_token 这类上下文词。
33
+ /\b(?:gitee|私人令牌|access[_ ]?token)[^\n]{0,20}?\b[0-9a-f]{32}\b/gi,
34
+ ]
35
+
36
+ /** Replace every credential-shaped substring with `***`. */
37
+ export function redact(text) {
38
+ let out = String(text ?? '')
39
+ for (const re of SECRET_PATTERNS) out = out.replace(re, '***')
40
+ return out
41
+ }
42
+
43
+ /** True when `text` still contains a credential-shaped substring. */
44
+ export function hasSecret(text) {
45
+ const value = String(text ?? '')
46
+ return SECRET_PATTERNS.some((re) => {
47
+ re.lastIndex = 0
48
+ const hit = re.test(value)
49
+ re.lastIndex = 0
50
+ return hit
51
+ })
52
+ }