dsh-my-observability 0.3.0 → 0.3.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/CHANGELOG.md +9 -79
- package/README.md +18 -76
- package/lib/ai.js +123 -99
- package/lib/audit-view.js +173 -172
- package/lib/audit.js +175 -166
- package/lib/client.js +1024 -1278
- package/lib/client.src.js +55 -29
- package/lib/constants.js +4 -4
- package/lib/diff.js +61 -56
- package/lib/git.js +97 -98
- package/lib/index.js +46 -47
- package/lib/parts/native-tabs.js +45 -0
- package/lib/resource-monitor.js +82 -134
- package/lib/resource-rules.js +9 -99
- package/lib/review.js +68 -77
- package/lib/routes.js +223 -200
- package/lib/store-persist.js +114 -111
- package/lib/store.js +215 -212
- package/package.json +15 -7
- package/lib/parts/git.js +0 -298
- package/lib/parts/i18n.js +0 -104
- package/lib/parts/replay-ext.js +0 -396
- package/lib/parts/replay.js +0 -386
- package/lib/parts/resource.js +0 -80
- package/lib/parts/styles.js +0 -156
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原生侧边栏页签注册助手(issue #187 批 1;scripts/build.mjs 拼接片段)。
|
|
3
|
+
*
|
|
4
|
+
* 原生结构:每个面板一个**页签类型**(sidebarRightTabs.register)+ 两个
|
|
5
|
+
* **keyed 席位**(slots.register,body 与 title)。id 全局唯一且是席位的
|
|
6
|
+
* key,一个 key 只能注册一个席位 —— 本插件有两个独立面板,因此每个 kind 用
|
|
7
|
+
* 自己的 id(沿用迁移前 better-sidebar 的 tab id)。
|
|
8
|
+
*
|
|
9
|
+
* 片段无 import/export,共享 client.src.js 的 factory 作用域:本函数的参数
|
|
10
|
+
* 全部由调用方(apply)注入,便于单测与阅读。
|
|
11
|
+
*/
|
|
12
|
+
function registerNativeTab(ctx, tabs, slots, spec) {
|
|
13
|
+
ctx.effect(
|
|
14
|
+
() =>
|
|
15
|
+
tabs.register({
|
|
16
|
+
id: spec.id,
|
|
17
|
+
kind: spec.id,
|
|
18
|
+
title: spec.title,
|
|
19
|
+
guide: [{ order: spec.order, title: spec.title }],
|
|
20
|
+
}),
|
|
21
|
+
'dsh-my-observability: ' + spec.label + ' tab',
|
|
22
|
+
)
|
|
23
|
+
ctx.effect(
|
|
24
|
+
() =>
|
|
25
|
+
slots.inject('sidebar.right.pane.tab', () =>
|
|
26
|
+
slots.register({ name: 'sidebar.right.pane.tab', key: spec.id }, spec.Body),
|
|
27
|
+
),
|
|
28
|
+
'dsh-my-observability: ' + spec.label + ' tab body',
|
|
29
|
+
)
|
|
30
|
+
ctx.effect(
|
|
31
|
+
() =>
|
|
32
|
+
slots.inject('sidebar.right.pane.tab.title', () =>
|
|
33
|
+
slots.register({ name: 'sidebar.right.pane.tab.title', key: spec.id }, spec.Title),
|
|
34
|
+
),
|
|
35
|
+
'dsh-my-observability: ' + spec.label + ' tab title',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 原生 tab title 席位:宿主给定标题优先,否则回退面板自带文案。 */
|
|
40
|
+
function nativeTabTitle(fallback) {
|
|
41
|
+
return function TabTitle(props) {
|
|
42
|
+
const info = typeof props.useTabInfo === 'function' ? props.useTabInfo() : undefined
|
|
43
|
+
return createElement('span', null, info?.tab?.title ?? fallback())
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/resource-monitor.js
CHANGED
|
@@ -1,149 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-my-observability — 资源采样监控 +
|
|
2
|
+
* dsh-my-observability — 资源采样监控 + 降级看门狗(**dsh-shared 原语的消费方**)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 15 小时 300GB 写入零监控是事故未被及时发现的主因)。
|
|
4
|
+
* 第三批(issue #198):本文件原本是插件私有的看门狗实现(采样 → 阈值判定 →
|
|
5
|
+
* 连续确认降级/恢复),现已改为 `dsh-shared` 的 `createResourceGuard` 消费方——
|
|
6
|
+
* 抽出来的原语必须有人真的用,否则等于没抽。
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* 保留的插件职责(宿主特有,不进 shared):
|
|
9
|
+
* - **采样源**:`createProcessSampler`(CPU/RSS/审计文件字节/写入速率)
|
|
10
|
+
* + `$DSH_HOME` 目录总字节这个插件特有维度;
|
|
11
|
+
* - **降级动作**:由 index.ts 的 onDegrade/onRecover 决定(停落盘 / 恢复 + 全量快照)。
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 行为等价(改造前后逐条对齐,见 test/host-resource-guard.mjs):
|
|
14
|
+
* - 采样间隔默认 15s;历史 ring buffer 60 样本;
|
|
15
|
+
* - 首个样本无窗口 → 不告警、不进历史,仅作后续窗口的 prev;
|
|
16
|
+
* - 关键阈值只有 write-rate / file-size;CPU/内存超限只告警不降级;
|
|
17
|
+
* - 连续 3 次超限 → 降级;连续 3 次正常 → 恢复;降级中不重复触发回调。
|
|
18
|
+
*
|
|
19
|
+
* 资源开销:采样 15s 一次、<0.01% CPU;history 固定 60 条(不随运行时长增长)。
|
|
20
|
+
* 已知存量代价:`homeBytes` 维度每次采样递归扫 $DSH_HOME(O(文件数)),
|
|
21
|
+
* 新接入方不要复制这个维度(见 shared README 的采样边界)。
|
|
16
22
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import { join } from 'node:path'
|
|
19
|
-
import { homedir } from 'node:os'
|
|
20
|
-
import {
|
|
21
|
-
import { jsonlFile } from './store-persist.js'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const MAX_HISTORY = 60
|
|
25
|
-
|
|
26
|
-
/** 创建资源监控器:{ sample, start, stop }。options: intervalMs / limits / onDegrade / onRecover。 */
|
|
23
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { createProcessSampler, createResourceGuard } from 'dsh-shared';
|
|
27
|
+
import { jsonlFile } from './store-persist.js';
|
|
28
|
+
const DEFAULT_INTERVAL_MS = 15000;
|
|
29
|
+
/** 创建资源监控器:{ sample, start, stop, isDegraded }(薄适配 dsh-shared 看门狗)。 */
|
|
27
30
|
export function createResourceMonitor(ctx, options = {}) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
return monitor
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 采样一次:CPU 使用率(窗口内 user+sys)/RSS/审计文件字节/写入速率 + 告警 + 降级判定。 */
|
|
52
|
-
function sample(state, limits, onDegrade, onRecover) {
|
|
53
|
-
const now = Date.now()
|
|
54
|
-
const cpu = process.cpuUsage()
|
|
55
|
-
const cpuDelta = cpu.user - state.lastCpu.user + (cpu.system - state.lastCpu.system) // µs
|
|
56
|
-
state.lastCpu = cpu
|
|
57
|
-
const mem = process.memoryUsage()
|
|
58
|
-
const memoryBytes = mem.rss
|
|
59
|
-
let fileBytes = 0
|
|
60
|
-
try {
|
|
61
|
-
fileBytes = statSync(state.file).size
|
|
62
|
-
} catch {
|
|
63
|
-
// 审计文件尚未创建:字节为 0
|
|
64
|
-
}
|
|
65
|
-
let homeBytes = 0
|
|
66
|
-
try {
|
|
67
|
-
homeBytes = dshHomeSize(state.dshHome)
|
|
68
|
-
} catch {
|
|
69
|
-
// $DSH_HOME 不可达
|
|
70
|
-
}
|
|
71
|
-
const prev = state.lastSample
|
|
72
|
-
if (prev !== null) {
|
|
73
|
-
const deltaMs = Math.max(now - prev.time, 1)
|
|
74
|
-
// CPU 单核折算:cpuDelta(µs) / deltaMs(ms) / 1000 → 百分比(×100)
|
|
75
|
-
const cpuPercent = (cpuDelta / 1000 / deltaMs) * 100
|
|
76
|
-
const byteDelta = fileBytes - prev.fileBytes
|
|
77
|
-
const writeRateBytesPerHour = byteDelta > 0 ? (byteDelta / deltaMs) * 3600 * 1000 : 0
|
|
78
|
-
const sample = { time: now, cpuPercent, memoryBytes, fileBytes, writeRateBytesPerHour, homeBytes }
|
|
79
|
-
state.history.push(sample)
|
|
80
|
-
if (state.history.length > MAX_HISTORY) state.history.splice(0, state.history.length - MAX_HISTORY)
|
|
81
|
-
state.lastSample = sample
|
|
82
|
-
updateDegradeState(state, limits, onDegrade, onRecover)
|
|
31
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
32
|
+
const collect = options.collect ?? defaultSampler(home);
|
|
33
|
+
// 配置容错保持与改造前一致:非法 intervalMs 回退默认(而不是 fail-fast 让插件起不来)
|
|
34
|
+
const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0
|
|
35
|
+
? options.intervalMs
|
|
36
|
+
: DEFAULT_INTERVAL_MS;
|
|
37
|
+
const guard = createResourceGuard({
|
|
38
|
+
collect,
|
|
39
|
+
limits: options.limits,
|
|
40
|
+
intervalMs,
|
|
41
|
+
historySize: options.historySize,
|
|
42
|
+
enterConfirmCount: options.enterConfirmCount,
|
|
43
|
+
exitConfirmCount: options.exitConfirmCount,
|
|
44
|
+
now: options.now,
|
|
45
|
+
onDegrade: (snapshot) => options.onDegrade?.(snapshot),
|
|
46
|
+
onRecover: (snapshot) => options.onRecover?.(snapshot),
|
|
47
|
+
logger: ctxLogger(ctx),
|
|
48
|
+
prefix: '[dsh-my-observability]',
|
|
49
|
+
});
|
|
83
50
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 降级状态机:未降级且连续超限 → 进入降级(回调);已降级且连续正常 → 退出降级(回调)。
|
|
96
|
-
* 判定纯函数见 resource-rules.js;本函数只持有状态并触发宿主回调。
|
|
97
|
-
*/
|
|
98
|
-
function updateDegradeState(state, limits, onDegrade, onRecover) {
|
|
99
|
-
if (!state.degraded) {
|
|
100
|
-
if (shouldEnterDegrade(state.history, limits)) {
|
|
101
|
-
state.degraded = true
|
|
102
|
-
onDegrade?.()
|
|
103
|
-
}
|
|
104
|
-
} else if (shouldExitDegrade(state.history, limits)) {
|
|
105
|
-
state.degraded = false
|
|
106
|
-
onRecover?.()
|
|
107
|
-
}
|
|
51
|
+
sample: () => guard.sample(),
|
|
52
|
+
start: () => guard.start(),
|
|
53
|
+
stop: () => guard.stop(),
|
|
54
|
+
isDegraded: () => guard.isDegraded(),
|
|
55
|
+
history: () => guard.history(),
|
|
56
|
+
stats: () => guard.stats(),
|
|
57
|
+
guard: () => guard,
|
|
58
|
+
};
|
|
108
59
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}, intervalMs)
|
|
116
|
-
if (state.timer.unref) state.timer.unref()
|
|
117
|
-
}
|
|
118
|
-
return state.timer
|
|
60
|
+
/** 取宿主的 logger(回调异常 warn 用);不可用则返回 undefined(看门狗不因此失效)。 */
|
|
61
|
+
function ctxLogger(ctx) {
|
|
62
|
+
const logger = ctx?.logger;
|
|
63
|
+
if (typeof logger?.warn !== 'function')
|
|
64
|
+
return undefined;
|
|
65
|
+
return { warn: (message) => logger.warn?.(message) };
|
|
119
66
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
67
|
+
/** 默认采样源:进程维度 + 审计文件字节/写入速率 + $DSH_HOME 总字节。 */
|
|
68
|
+
function defaultSampler(home) {
|
|
69
|
+
return createProcessSampler({
|
|
70
|
+
file: jsonlFile(),
|
|
71
|
+
extra: () => ({ homeBytes: dshHomeSize(home) }),
|
|
72
|
+
});
|
|
127
73
|
}
|
|
128
|
-
|
|
129
74
|
/** 递归计算目录总字节(best-effort,跳过不可达文件)。 */
|
|
130
75
|
function dshHomeSize(dir) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
76
|
+
let total = 0;
|
|
77
|
+
try {
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
const full = join(dir, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
total += dshHomeSize(full);
|
|
82
|
+
}
|
|
83
|
+
else if (entry.isFile()) {
|
|
84
|
+
try {
|
|
85
|
+
total += statSync(full).size;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// 文件不可达
|
|
89
|
+
}
|
|
90
|
+
}
|
|
142
91
|
}
|
|
143
|
-
}
|
|
144
92
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
93
|
+
catch {
|
|
94
|
+
// 目录不可达
|
|
95
|
+
}
|
|
96
|
+
return total;
|
|
149
97
|
}
|
package/lib/resource-rules.js
CHANGED
|
@@ -1,102 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-my-observability —
|
|
2
|
+
* dsh-my-observability — 资源阈值判定(**dsh-shared 原语的消费方**,issue #198 第三批)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/** 审计文件写入速率上限:50 MB/小时。 */
|
|
12
|
-
writeRateBytesPerHour: 50 * 1024 * 1024,
|
|
13
|
-
/** 审计文件大小上限:50 MB(循环 compact 后应远小于此)。 */
|
|
14
|
-
fileBytes: 50 * 1024 * 1024,
|
|
15
|
-
/** 本进程 CPU 均值上限:10%(单核折算,采样窗口内 user+sys / 时长)。 */
|
|
16
|
-
cpuPercent: 10,
|
|
17
|
-
/** 本进程 RSS 上限:500 MB。 */
|
|
18
|
-
memoryBytes: 500 * 1024 * 1024,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** 采样数据 → 告警列表([{ rule, level, message, value, limit }])。 */
|
|
22
|
-
export function evaluateResourceAlerts(sample, limits = DEFAULT_LIMITS) {
|
|
23
|
-
const alerts = []
|
|
24
|
-
if (typeof sample.writeRateBytesPerHour === 'number' && sample.writeRateBytesPerHour > limits.writeRateBytesPerHour) {
|
|
25
|
-
alerts.push({
|
|
26
|
-
rule: 'write-rate',
|
|
27
|
-
level: 'error',
|
|
28
|
-
message: `审计写入速率 ${fmtMB(sample.writeRateBytesPerHour)}/h 超过上限 ${fmtMB(limits.writeRateBytesPerHour)}/h(写放大风险)`,
|
|
29
|
-
value: sample.writeRateBytesPerHour,
|
|
30
|
-
limit: limits.writeRateBytesPerHour,
|
|
31
|
-
})
|
|
32
|
-
}
|
|
33
|
-
if (typeof sample.fileBytes === 'number' && sample.fileBytes > limits.fileBytes) {
|
|
34
|
-
alerts.push({
|
|
35
|
-
rule: 'file-size',
|
|
36
|
-
level: 'error',
|
|
37
|
-
message: `审计文件 ${fmtMB(sample.fileBytes)} 超过上限 ${fmtMB(limits.fileBytes)}(请检查 compact/轮转)`,
|
|
38
|
-
value: sample.fileBytes,
|
|
39
|
-
limit: limits.fileBytes,
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
if (typeof sample.cpuPercent === 'number' && sample.cpuPercent > limits.cpuPercent) {
|
|
43
|
-
alerts.push({
|
|
44
|
-
rule: 'cpu',
|
|
45
|
-
level: 'warn',
|
|
46
|
-
message: `本进程 CPU ${Math.round(sample.cpuPercent)}% 超过上限 ${limits.cpuPercent}%`,
|
|
47
|
-
value: sample.cpuPercent,
|
|
48
|
-
limit: limits.cpuPercent,
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
if (typeof sample.memoryBytes === 'number' && sample.memoryBytes > limits.memoryBytes) {
|
|
52
|
-
alerts.push({
|
|
53
|
-
rule: 'memory',
|
|
54
|
-
level: 'warn',
|
|
55
|
-
message: `本进程内存 ${fmtMB(sample.memoryBytes)} 超过上限 ${fmtMB(limits.memoryBytes)}`,
|
|
56
|
-
value: sample.memoryBytes,
|
|
57
|
-
limit: limits.memoryBytes,
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
return alerts
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** 连续多少次采样超限(关键阈值)判定进入降级(模块内默认值)。 */
|
|
64
|
-
const DEGRADE_CONFIRM_COUNT = 3
|
|
65
|
-
|
|
66
|
-
/** 连续多少次采样正常判定退出降级(模块内默认值)。 */
|
|
67
|
-
const RECOVER_CONFIRM_COUNT = 3
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 关键阈值判定:磁盘写放大风险主导(write-rate / file-size 任一超限即记超限)。
|
|
71
|
-
* CPU/内存超限告警但不触发落盘降级(避免误伤正常大请求峰值)。
|
|
72
|
-
*/
|
|
73
|
-
function isCriticalOverLimit(sample, limits = DEFAULT_LIMITS) {
|
|
74
|
-
return (
|
|
75
|
-
(sample.writeRateBytesPerHour ?? 0) > limits.writeRateBytesPerHour || (sample.fileBytes ?? 0) > limits.fileBytes
|
|
76
|
-
)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* 降级判定(纯函数):最近 confirmCount 个样本**全部**关键阈值超限 → 进入降级。
|
|
81
|
-
* 历史样本不足 confirmCount 时不降级(冷启动保护)。
|
|
82
|
-
* 返回布尔(shouldEnterDegrade)。
|
|
83
|
-
*/
|
|
84
|
-
export function shouldEnterDegrade(history, limits = DEFAULT_LIMITS, confirmCount = DEGRADE_CONFIRM_COUNT) {
|
|
85
|
-
const recent = history.slice(-confirmCount)
|
|
86
|
-
if (recent.length < confirmCount) return false
|
|
87
|
-
return recent.every((sample) => isCriticalOverLimit(sample, limits))
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 恢复判定(纯函数):最近 confirmCount 个样本**全部**关键阈值正常 → 退出降级。
|
|
92
|
-
* 历史样本不足 confirmCount 时不恢复(避免刚降级立即恢复抖动)。
|
|
4
|
+
* 第三批把阈值判定与看门狗状态机抽到 `dsh-shared` 的 `resource-rules` /
|
|
5
|
+
* `resource-guard`,本文件只做**别名 re-export**:
|
|
6
|
+
* - 保留插件内既有的导入路径与名称(`DEFAULT_LIMITS` 等),路由/测试/配置契约不变;
|
|
7
|
+
* - 判定口径(50MB/h 写入速率、50MB 文件、10% CPU、500MB RSS;相等不算超限;
|
|
8
|
+
* 连续 3 次确认)由 shared 单点维护,插件不再各存一份。
|
|
9
|
+
*
|
|
10
|
+
* 原语边界与反例见 plugins/dsh-shared/README.md 与 docs/共享工具包/概述.md。
|
|
93
11
|
*/
|
|
94
|
-
export
|
|
95
|
-
const recent = history.slice(-confirmCount)
|
|
96
|
-
if (recent.length < confirmCount) return false
|
|
97
|
-
return recent.every((sample) => !isCriticalOverLimit(sample, limits))
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function fmtMB(bytes) {
|
|
101
|
-
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
102
|
-
}
|
|
12
|
+
export { DEFAULT_RESOURCE_LIMITS as DEFAULT_LIMITS, evaluateResourceAlerts, shouldEnterDegrade, shouldExitDegrade, } from 'dsh-shared';
|
package/lib/review.js
CHANGED
|
@@ -13,118 +13,109 @@
|
|
|
13
13
|
*
|
|
14
14
|
* AI 增强审查见 lib/ai.js(可选,agents 服务可用时追加 LLM 结论)。
|
|
15
15
|
*/
|
|
16
|
-
import { isTestFile, isSourceFile } from './diff.js'
|
|
17
|
-
|
|
16
|
+
import { isTestFile, isSourceFile } from './diff.js';
|
|
18
17
|
/** 单文件变更行数阈值(large-diff 规则)。 */
|
|
19
|
-
export const LARGE_DIFF_LINES = 300
|
|
20
|
-
|
|
18
|
+
export const LARGE_DIFF_LINES = 300;
|
|
21
19
|
/** 对已解析 diff 运行规则引擎,返回 { summary, issues }。 */
|
|
22
20
|
export function reviewRules(parsed) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
const issues = [];
|
|
22
|
+
for (const file of parsed.files) {
|
|
23
|
+
for (const added of file.addedLines) {
|
|
24
|
+
pushIf(issues, debugIssue(file, added));
|
|
25
|
+
pushIf(issues, secretIssue(file, added));
|
|
26
|
+
pushIf(issues, conflictIssue(file, added));
|
|
27
|
+
pushIf(issues, todoIssue(file, added));
|
|
28
|
+
pushIf(issues, trailingIssue(file, added));
|
|
29
|
+
}
|
|
30
|
+
pushIf(issues, largeDiffIssue(file));
|
|
31
|
+
pushIf(issues, binaryIssue(file));
|
|
31
32
|
}
|
|
32
|
-
pushIf(issues,
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
pushIf(issues, noTestIssue(parsed))
|
|
36
|
-
return summarize(parsed, issues)
|
|
33
|
+
pushIf(issues, noTestIssue(parsed));
|
|
34
|
+
return summarize(parsed, issues);
|
|
37
35
|
}
|
|
38
|
-
|
|
39
36
|
/** 汇总:变更统计 + 问题分级计数。 */
|
|
40
37
|
function summarize(parsed, issues) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
const files = parsed.files;
|
|
39
|
+
const insertions = files.reduce((sum, file) => sum + file.insertions, 0);
|
|
40
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
41
|
+
return {
|
|
42
|
+
issues,
|
|
43
|
+
summary: {
|
|
44
|
+
files: files.length,
|
|
45
|
+
insertions,
|
|
46
|
+
deletions,
|
|
47
|
+
issues: issues.length,
|
|
48
|
+
errors: countBy(issues, 'error'),
|
|
49
|
+
warnings: countBy(issues, 'warning'),
|
|
50
|
+
infos: countBy(issues, 'info'),
|
|
51
|
+
binary: parsed.binary,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
57
54
|
}
|
|
58
|
-
|
|
59
55
|
function countBy(issues, severity) {
|
|
60
|
-
|
|
56
|
+
return issues.filter((issue) => issue.severity === severity).length;
|
|
61
57
|
}
|
|
62
|
-
|
|
63
58
|
function pushIf(issues, issue) {
|
|
64
|
-
|
|
59
|
+
if (issue !== undefined)
|
|
60
|
+
issues.push(issue);
|
|
65
61
|
}
|
|
66
|
-
|
|
67
62
|
/** 调试残留(console.* / debugger / print 族)。 */
|
|
68
63
|
function debugIssue(file, added) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if (!/console\.(log|debug|warn|info|error)|debugger\b|\bprint\(|println\(|System\.out\.print/.test(added.text))
|
|
65
|
+
return undefined;
|
|
66
|
+
return issue('warning', 'debug-statement', file, added, '调试残留语句(console/print/debugger)');
|
|
72
67
|
}
|
|
73
|
-
|
|
74
68
|
/** 密钥/凭据硬编码。 */
|
|
75
69
|
function secretIssue(file, added) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return undefined
|
|
80
|
-
return issue('error', 'secret-leak', file, added, '疑似硬编码密钥/凭据')
|
|
70
|
+
if (!/(password|passwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"][^'"]{4,}['"]/i.test(added.text))
|
|
71
|
+
return undefined;
|
|
72
|
+
return issue('error', 'secret-leak', file, added, '疑似硬编码密钥/凭据');
|
|
81
73
|
}
|
|
82
|
-
|
|
83
74
|
/** 合并冲突标记残留。 */
|
|
84
75
|
function conflictIssue(file, added) {
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
if (!/^(<<<<<<<|=======|>>>>>>>)/.test(added.text))
|
|
77
|
+
return undefined;
|
|
78
|
+
return issue('error', 'conflict-marker', file, added, '合并冲突标记残留');
|
|
87
79
|
}
|
|
88
|
-
|
|
89
80
|
/** TODO/FIXME 标记。 */
|
|
90
81
|
function todoIssue(file, added) {
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
if (!/\b(TODO|FIXME|HACK|XXX)\b/.test(added.text))
|
|
83
|
+
return undefined;
|
|
84
|
+
return issue('info', 'todo-marker', file, added, 'TODO/FIXME 标记');
|
|
93
85
|
}
|
|
94
|
-
|
|
95
86
|
/** 尾随空格。 */
|
|
96
87
|
function trailingIssue(file, added) {
|
|
97
|
-
|
|
98
|
-
|
|
88
|
+
if (!/\s+$/.test(added.text))
|
|
89
|
+
return undefined;
|
|
90
|
+
return issue('info', 'trailing-space', file, added, '行尾多余空格');
|
|
99
91
|
}
|
|
100
|
-
|
|
101
92
|
/** 单文件变更超阈值。 */
|
|
102
93
|
function largeDiffIssue(file) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
const changed = file.insertions + file.deletions;
|
|
95
|
+
if (changed <= LARGE_DIFF_LINES || file.binary)
|
|
96
|
+
return undefined;
|
|
97
|
+
return issue('warning', 'large-diff', file, null, `单文件变更 ${changed} 行(>${LARGE_DIFF_LINES}),建议拆分提交`);
|
|
106
98
|
}
|
|
107
|
-
|
|
108
99
|
/** 二进制文件变更。 */
|
|
109
100
|
function binaryIssue(file) {
|
|
110
|
-
|
|
111
|
-
|
|
101
|
+
if (!file.binary)
|
|
102
|
+
return undefined;
|
|
103
|
+
return issue('warning', 'binary-file', file, null, '二进制文件变更');
|
|
112
104
|
}
|
|
113
|
-
|
|
114
105
|
/** 有源码变更但无测试变更。 */
|
|
115
106
|
function noTestIssue(parsed) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
107
|
+
const sourceFiles = parsed.files.filter((file) => isSourceFile(file.path));
|
|
108
|
+
const testFiles = parsed.files.filter((file) => isTestFile(file.path));
|
|
109
|
+
if (sourceFiles.length === 0 || testFiles.length > 0)
|
|
110
|
+
return undefined;
|
|
111
|
+
return issue('info', 'no-test-change', null, null, '有源码变更但没有测试文件变更');
|
|
120
112
|
}
|
|
121
|
-
|
|
122
113
|
function issue(severity, rule, file, added, message) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
114
|
+
return {
|
|
115
|
+
severity,
|
|
116
|
+
rule,
|
|
117
|
+
file: file?.path ?? '',
|
|
118
|
+
line: added?.line ?? 0,
|
|
119
|
+
message,
|
|
120
|
+
};
|
|
130
121
|
}
|