dsh-my-observability 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.
@@ -0,0 +1,67 @@
1
+ // ── i18n(浏览器语言判定)──────────────────────────────────────────
2
+ function isZh() {
3
+ try {
4
+ const lang = (navigator.language || 'en').toLowerCase()
5
+ return lang.startsWith('zh')
6
+ } catch {
7
+ return false
8
+ }
9
+ }
10
+
11
+ const strings = {
12
+ replayTitle: () => (isZh() ? '轨迹回放' : 'Trajectory'),
13
+ gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
14
+ allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
15
+ filterAll: () => (isZh() ? '全部' : 'All'),
16
+ filterStatus: () => (isZh() ? '状态' : 'Status'),
17
+ filterLlm: () => (isZh() ? '模型流' : 'LLM'),
18
+ filterTools: () => (isZh() ? '工具' : 'Tools'),
19
+ emptyEvents: () => (isZh() ? '暂无审计事件——开始一段对话后,agent 的行为会出现在这里' : 'No audit events yet — agent activity will appear here after a conversation'),
20
+ loadError: () => (isZh() ? '加载失败' : 'Load failed'),
21
+ typeAgentStatus: () => (isZh() ? 'agent 状态' : 'agent status'),
22
+ typeLlmStream: () => (isZh() ? '模型流' : 'LLM stream'),
23
+ typeToolCall: () => (isZh() ? '工具调用' : 'tool call'),
24
+ typeToolResult: () => (isZh() ? '工具结果' : 'tool result'),
25
+ phaseStart: () => (isZh() ? '开始' : 'start'),
26
+ phaseEnd: () => (isZh() ? '结束' : 'end'),
27
+ phaseError: () => (isZh() ? '错误' : 'error'),
28
+ agentTop: () => (isZh() ? '顶层' : 'top'),
29
+ agentSub: () => (isZh() ? '子代理' : 'subagent'),
30
+ agentUnknown: () => (isZh() ? '未知' : 'unknown'),
31
+ toolOk: () => (isZh() ? '成功' : 'ok'),
32
+ toolFail: () => (isZh() ? '失败' : 'failed'),
33
+ // Git 面板
34
+ repoLabel: () => (isZh() ? '仓库路径' : 'Repo path'),
35
+ repoPlaceholder: () => (isZh() ? '如 /path/to/project' : 'e.g. /path/to/project'),
36
+ loadRepo: () => (isZh() ? '加载' : 'Load'),
37
+ branch: () => (isZh() ? '分支' : 'Branch'),
38
+ staged: () => (isZh() ? '已暂存' : 'staged'),
39
+ unstaged: () => (isZh() ? '未暂存' : 'unstaged'),
40
+ clean: () => (isZh() ? '工作区干净' : 'Working tree clean'),
41
+ diffTitle: () => (isZh() ? '差异' : 'Diff'),
42
+ showDiff: () => (isZh() ? '查看差异' : 'Show diff'),
43
+ showStagedDiff: () => (isZh() ? '查看暂存差异' : 'Staged diff'),
44
+ noChanges: () => (isZh() ? '没有变更' : 'No changes'),
45
+ review: () => (isZh() ? '提交前审查' : 'Review'),
46
+ reviewAi: () => (isZh() ? 'AI 审查' : 'AI review'),
47
+ reviewResult: () => (isZh() ? '审查结果' : 'Review result'),
48
+ reviewPass: () => (isZh() ? '未发现问题' : 'No issues found'),
49
+ issues: (count) => (isZh() ? `${count} 个问题` : `${count} issue(s)`),
50
+ commitTitle: () => (isZh() ? '类型化提交' : 'Typed commit'),
51
+ commitType: () => (isZh() ? '类型' : 'Type'),
52
+ commitScope: () => (isZh() ? '范围(可选)' : 'Scope (optional)'),
53
+ commitDesc: () => (isZh() ? '描述' : 'Description'),
54
+ commitBody: () => (isZh() ? '正文(可选)' : 'Body (optional)'),
55
+ commit: () => (isZh() ? '提交' : 'Commit'),
56
+ committed: () => (isZh() ? '已提交' : 'Committed'),
57
+ commitError: () => (isZh() ? '提交失败' : 'Commit failed'),
58
+ severityError: () => (isZh() ? '错误' : 'Error'),
59
+ severityWarning: () => (isZh() ? '警告' : 'Warning'),
60
+ severityInfo: () => (isZh() ? '提示' : 'Info'),
61
+ aiVerdictApprove: () => (isZh() ? 'AI 结论:可以提交' : 'AI verdict: approve'),
62
+ aiVerdictChanges: () => (isZh() ? 'AI 结论:建议修改' : 'AI verdict: changes'),
63
+ aiFailed: () => (isZh() ? 'AI 审查不可用' : 'AI review unavailable'),
64
+ loading: () => (isZh() ? '加载中…' : 'Loading…'),
65
+ emptyDiff: () => (isZh() ? '(空)' : '(empty)'),
66
+ noRepo: () => (isZh() ? '请输入仓库路径' : 'Enter a repo path'),
67
+ }
@@ -0,0 +1,199 @@
1
+ // ── 轨迹回放面板(时间轴)──────────────────────────────────────────
2
+ const REPLAY_POLL_MS = 5000
3
+
4
+ /** 请求插件 API(非 2xx 抛错;返回响应 JSON 的 value 字段)。 */
5
+ function apiJson(path, options) {
6
+ return fetch(path, options).then(async (res) => {
7
+ const data = await res.json()
8
+ if (!res.ok) throw new Error(data.error?.message || `HTTP ${res.status}`)
9
+ return data.value
10
+ })
11
+ }
12
+
13
+ /** 事件类型 → 中文标签。 */
14
+ function typeLabel(event) {
15
+ switch (event.type) {
16
+ case 'agent_status': return strings.typeAgentStatus()
17
+ case 'llm_stream': return strings.typeLlmStream()
18
+ case 'tool_call': return strings.typeToolCall()
19
+ case 'tool_result': return strings.typeToolResult()
20
+ default: return event.type
21
+ }
22
+ }
23
+
24
+ /** 事件类型 → 徽标样式类别。 */
25
+ function badgeKind(event) {
26
+ if (event.type === 'agent_status') return 'status'
27
+ if (event.type === 'llm_stream') return 'llm'
28
+ return 'tool'
29
+ }
30
+
31
+ /** 时间戳 → HH:MM:SS。 */
32
+ function timeText(time) {
33
+ try {
34
+ const date = new Date(time)
35
+ const pad = (n) => String(n).padStart(2, '0')
36
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
37
+ } catch {
38
+ return ''
39
+ }
40
+ }
41
+
42
+ /** agent 类型标记 → 中文。 */
43
+ function agentTypeText(agentType) {
44
+ if (agentType === 'top') return strings.agentTop()
45
+ if (agentType === 'subagent') return strings.agentSub()
46
+ return strings.agentUnknown()
47
+ }
48
+
49
+ /** 模型流阶段 → 中文。 */
50
+ function phaseText(phase) {
51
+ if (phase === 'start') return strings.phaseStart()
52
+ if (phase === 'end') return strings.phaseEnd()
53
+ if (phase === 'error') return strings.phaseError()
54
+ return phase
55
+ }
56
+
57
+ /** agent 状态事件摘要。 */
58
+ function agentMeta(data) {
59
+ return `状态 ${data.status} · ${agentTypeText(data.agentType)}`
60
+ }
61
+
62
+ /** 模型流事件摘要(开始/结束/错误 + 统计)。 */
63
+ function llmMeta(data) {
64
+ const stats = data.phase === 'start' ? '' : ` · ${data.chunks} chunks / ${data.chars} chars / ${data.ms}ms`
65
+ const error = data.message !== undefined ? `:${data.message}` : ''
66
+ return `${phaseText(data.phase)}${stats}${error}`
67
+ }
68
+
69
+ /** 工具调用事件摘要(名称 + 参数摘要)。 */
70
+ function toolCallMeta(data) {
71
+ const args = data.args && data.args.summary !== undefined ? ` — ${data.args.summary}` : ''
72
+ return `${data.name}${args}`
73
+ }
74
+
75
+ /** 工具结果事件摘要(名称 + 成败 + 耗时)。 */
76
+ function toolResultMeta(data) {
77
+ const result = data.ok === false ? strings.toolFail() : strings.toolOk()
78
+ return `${data.name} · ${result} · ${data.ms}ms`
79
+ }
80
+
81
+ /** 事件 → 摘要文本(单行,尽力而为)。 */
82
+ function eventMeta(event) {
83
+ const data = event.data || {}
84
+ if (event.type === 'agent_status') return agentMeta(data)
85
+ if (event.type === 'llm_stream') return llmMeta(data)
86
+ if (event.type === 'tool_call') return toolCallMeta(data)
87
+ if (event.type === 'tool_result') return toolResultMeta(data)
88
+ return ''
89
+ }
90
+
91
+ /** 单条事件行(徽标 + 时间 + 摘要)。 */
92
+ function EventRow({ event }) {
93
+ const meta = eventMeta(event)
94
+ return createElement('div', { className: 'dso-event' },
95
+ createElement('div', { className: 'dso-event-head' },
96
+ createElement('span', { className: `dso-badge dso-badge-${badgeKind(event)}` }, typeLabel(event)),
97
+ createElement('span', { className: 'dso-time' }, timeText(event.time)),
98
+ ),
99
+ meta !== '' ? createElement('div', { className: 'dso-event-meta' }, meta) : null,
100
+ )
101
+ }
102
+
103
+ /** 类型过滤按钮组。 */
104
+ function TypeFilter({ filter, onFilter }) {
105
+ const options = [
106
+ ['', strings.filterAll()],
107
+ ['agent_status', strings.filterStatus()],
108
+ ['llm_stream', strings.filterLlm()],
109
+ ['tool', strings.filterTools()],
110
+ ]
111
+ return createElement('div', { className: 'dso-filters' },
112
+ options.map(([value, label]) => createElement('button', {
113
+ key: value,
114
+ className: `dso-chip${filter === value ? ' dso-chip-active' : ''}`,
115
+ onClick: () => onFilter(value),
116
+ }, label)),
117
+ )
118
+ }
119
+
120
+ /** 按过滤条件筛选事件(tool = tool_call + tool_result)。 */
121
+ function filterEvents(events, filter) {
122
+ if (filter === '') return events
123
+ return events.filter((event) => filter === 'tool'
124
+ ? event.type === 'tool_call' || event.type === 'tool_result'
125
+ : event.type === filter)
126
+ }
127
+
128
+ /** 拉取会话列表与事件(选中为空时自动选当前/首个会话)。 */
129
+ async function loadReplayData(selected, currentSession, setters) {
130
+ try {
131
+ const list = await apiJson('/observability/api/sessions')
132
+ setters.setSessions(list)
133
+ if (selected === '' && list.length > 0) {
134
+ const preferred = list.some((s) => s.sessionId === currentSession) ? currentSession : list[0].sessionId
135
+ setters.setSelected(preferred)
136
+ return
137
+ }
138
+ const query = selected !== ''
139
+ ? `/observability/api/events?sessionId=${encodeURIComponent(selected)}&limit=300`
140
+ : '/observability/api/events?limit=0'
141
+ setters.setEvents(await apiJson(query))
142
+ setters.setError('')
143
+ } catch (err) {
144
+ setters.setError(err instanceof Error ? err.message : String(err))
145
+ } finally {
146
+ setters.setLoading(false)
147
+ }
148
+ }
149
+
150
+ /** 工具栏:会话选择 + 类型过滤。 */
151
+ function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter }) {
152
+ return createElement('div', { className: 'dso-toolbar' },
153
+ createElement('select', {
154
+ className: 'dso-select',
155
+ value: selected,
156
+ onChange: (e) => onSelect(e.target.value),
157
+ },
158
+ sessions.length === 0
159
+ ? createElement('option', { value: '' }, strings.allSessions())
160
+ : sessions.map((s) => createElement('option', { key: s.sessionId, value: s.sessionId }, s.sessionId)),
161
+ ),
162
+ createElement(TypeFilter, { filter, onFilter }),
163
+ )
164
+ }
165
+
166
+ /** 轨迹回放主面板:会话选择 + 类型过滤 + 时间轴(可见时轮询)。 */
167
+ function ReplayPanel(props) {
168
+ const currentSession = props.scope?.sessionId || ''
169
+ const visible = props.visible !== false
170
+ const [sessions, setSessions] = useState([])
171
+ const [selected, setSelected] = useState('')
172
+ const [filter, setFilter] = useState('')
173
+ const [events, setEvents] = useState([])
174
+ const [loading, setLoading] = useState(true)
175
+ const [error, setError] = useState('')
176
+
177
+ useEffect(() => {
178
+ if (!visible) return undefined
179
+ let alive = true
180
+ const setters = { setSessions, setSelected, setEvents, setError, setLoading }
181
+ const tick = () => { if (alive) void loadReplayData(selected, currentSession, setters) }
182
+ tick()
183
+ const timer = setInterval(tick, REPLAY_POLL_MS)
184
+ return () => { alive = false; clearInterval(timer) }
185
+ }, [visible, selected, currentSession])
186
+
187
+ const filtered = filterEvents(events, filter)
188
+ const rows = filtered.map((event, index) => createElement(EventRow, { key: event.id ?? index, event }))
189
+
190
+ return createElement('div', { className: 'dso-panel' },
191
+ createElement(ReplayToolbar, { sessions, selected, onSelect: setSelected, filter, onFilter: setFilter }),
192
+ error !== '' ? createElement('div', { className: 'dso-empty' }, `${strings.loadError()}:${error}`) : null,
193
+ loading && error === '' ? createElement('div', { className: 'dso-empty' }, strings.loading()) : null,
194
+ !loading && error === '' && filtered.length === 0
195
+ ? createElement('div', { className: 'dso-empty' }, strings.emptyEvents())
196
+ : null,
197
+ createElement('div', { className: 'dso-timeline' }, rows),
198
+ )
199
+ }
@@ -0,0 +1,68 @@
1
+ // ── 样式(DSH 语义 token,随 activation 注入 / teardown 卸载)──────
2
+ const STYLES = `
3
+ .dso-panel{display:flex;flex-direction:column;gap:10px;padding:12px;color:var(--dsw-alias-label-primary)}
4
+ .dso-toolbar{display:flex;flex-direction:column;gap:8px}
5
+ .dso-select{flex:1;min-width:0;font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-primary);
6
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px}
7
+ .dso-input{flex:1;min-width:0;font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-primary);
8
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px}
9
+ .dso-input::placeholder{color:var(--dsw-alias-label-tertiary)}
10
+ .dso-repo-row{display:flex;gap:8px;align-items:center}
11
+ .dso-repo-input{flex:1}
12
+ .dso-filters{display:flex;gap:6px;flex-wrap:wrap}
13
+ .dso-chip{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);background:transparent;
14
+ border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;cursor:pointer}
15
+ .dso-chip-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-interactive-primary);
16
+ background:color-mix(in srgb, var(--dsw-alias-interactive-primary) 12%, transparent)}
17
+ .dso-timeline{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh - 240px);overflow-y:auto}
18
+ .dso-event{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px}
19
+ .dso-event-head{display:flex;align-items:center;gap:8px;justify-content:space-between}
20
+ .dso-badge{flex:none;font:var(--dsw-font-xxxs-strong-11);border-radius:4px;padding:1px 6px}
21
+ .dso-badge-status{color:var(--dsw-alias-state-info-primary);background:color-mix(in srgb, var(--dsw-alias-state-info-primary) 14%, transparent)}
22
+ .dso-badge-llm{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent)}
23
+ .dso-badge-tool{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)}
24
+ .dso-time{font:var(--dsw-font-xxxs-11);color:var(--dsw-alias-label-tertiary)}
25
+ .dso-event-meta{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.6;word-break:break-word;margin-top:2px}
26
+ .dso-empty{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary);text-align:center;padding:16px 8px;line-height:1.7}
27
+ .dso-status{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-secondary)}
28
+ .dso-actions{display:flex;gap:8px;flex-wrap:wrap}
29
+ .dso-btn{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);
30
+ border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 12px;cursor:pointer}
31
+ .dso-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
32
+ .dso-btn:disabled{opacity:.5;cursor:default}
33
+ .dso-btn-primary{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-interactive-primary);
34
+ background:color-mix(in srgb, var(--dsw-alias-interactive-primary) 16%, transparent)}
35
+ .dso-section{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}
36
+ .dso-section-title{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-primary)}
37
+ .dso-diff{max-height:240px;overflow:auto;font:var(--dsw-font-mono-xxs);font-size:11px;line-height:1.5;
38
+ color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);
39
+ border-radius:6px;padding:8px;white-space:pre-wrap;word-break:break-all}
40
+ .dso-form{display:flex;flex-direction:column;gap:6px}
41
+ .dso-type{flex:none;width:96px}
42
+ .dso-textarea{min-height:52px;resize:vertical;font:var(--dsw-font-xxs-12)}
43
+ .dso-feedback{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);word-break:break-all;line-height:1.5}
44
+ .dso-issue{display:flex;flex-direction:column;gap:2px;border-radius:6px;padding:6px 8px;font:var(--dsw-font-xxs-12)}
45
+ .dso-issue-error{background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 12%, transparent)}
46
+ .dso-issue-warning{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}
47
+ .dso-issue-info{background:color-mix(in srgb, var(--dsw-alias-state-info-primary) 10%, transparent)}
48
+ .dso-issue-sev{font:var(--dsw-font-xxxs-strong-11);text-transform:uppercase}
49
+ .dso-issue-error .dso-issue-sev{color:var(--dsw-alias-state-danger-primary)}
50
+ .dso-issue-warning .dso-issue-sev{color:var(--dsw-alias-state-warn-primary)}
51
+ .dso-issue-info .dso-issue-sev{color:var(--dsw-alias-state-info-primary)}
52
+ .dso-issue-rule{font:var(--dsw-font-mono-xxs);font-size:11px;color:var(--dsw-alias-label-secondary)}
53
+ .dso-issue-msg{color:var(--dsw-alias-label-primary);line-height:1.5}
54
+ .dso-review-ok{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-state-success-primary)}
55
+ .dso-ai{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.5;
56
+ border:1px dashed var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px}
57
+ `
58
+
59
+ function injectStyles() {
60
+ if (typeof document === 'undefined' || typeof document.head === 'undefined') return () => {}
61
+ const style = document.createElement('style')
62
+ style.setAttribute('data-dsh-my-observability', 'styles')
63
+ style.textContent = STYLES
64
+ document.head.appendChild(style)
65
+ return () => {
66
+ if (style.parentNode !== null) style.parentNode.removeChild(style)
67
+ }
68
+ }
package/lib/review.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * dsh-my-observability — incremental diff review (rule engine).
3
+ *
4
+ * 提交前增量 diff 审查:确定性规则引擎(纯函数,可独立测试),检查:
5
+ * - debug-statement 调试残留(console.* / debugger / print 族)→ warning
6
+ * - secret-leak 密钥/凭据硬编码(password/api_key/secret/token)→ error
7
+ * - conflict-marker 合并冲突标记残留(<<<<<<< / ======= / >>>>>>>)→ error
8
+ * - todo-marker TODO/FIXME/HACK 标记 → info
9
+ * - trailing-space 新增行尾随空格 → info
10
+ * - large-diff 单文件变更超阈值 → warning
11
+ * - binary-file 二进制文件变更 → warning
12
+ * - no-test-change 有源码变更但无测试变更 → info
13
+ *
14
+ * AI 增强审查见 lib/ai.js(可选,agents 服务可用时追加 LLM 结论)。
15
+ */
16
+ import { isTestFile, isSourceFile } from './diff.js'
17
+
18
+ /** 单文件变更行数阈值(large-diff 规则)。 */
19
+ export const LARGE_DIFF_LINES = 300
20
+
21
+ /** 对已解析 diff 运行规则引擎,返回 { summary, issues }。 */
22
+ export function reviewRules(parsed) {
23
+ const issues = []
24
+ for (const file of parsed.files) {
25
+ for (const added of file.addedLines) {
26
+ pushIf(issues, debugIssue(file, added))
27
+ pushIf(issues, secretIssue(file, added))
28
+ pushIf(issues, conflictIssue(file, added))
29
+ pushIf(issues, todoIssue(file, added))
30
+ pushIf(issues, trailingIssue(file, added))
31
+ }
32
+ pushIf(issues, largeDiffIssue(file))
33
+ pushIf(issues, binaryIssue(file))
34
+ }
35
+ pushIf(issues, noTestIssue(parsed))
36
+ return summarize(parsed, issues)
37
+ }
38
+
39
+ /** 汇总:变更统计 + 问题分级计数。 */
40
+ function summarize(parsed, issues) {
41
+ const files = parsed.files
42
+ const insertions = files.reduce((sum, file) => sum + file.insertions, 0)
43
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0)
44
+ return {
45
+ issues,
46
+ summary: {
47
+ files: files.length,
48
+ insertions,
49
+ deletions,
50
+ issues: issues.length,
51
+ errors: countBy(issues, 'error'),
52
+ warnings: countBy(issues, 'warning'),
53
+ infos: countBy(issues, 'info'),
54
+ binary: parsed.binary,
55
+ },
56
+ }
57
+ }
58
+
59
+ function countBy(issues, severity) {
60
+ return issues.filter((issue) => issue.severity === severity).length
61
+ }
62
+
63
+ function pushIf(issues, issue) {
64
+ if (issue !== undefined) issues.push(issue)
65
+ }
66
+
67
+ /** 调试残留(console.* / debugger / print 族)。 */
68
+ function debugIssue(file, added) {
69
+ if (!/console\.(log|debug|warn|info|error)|debugger\b|\bprint\(|println\(|System\.out\.print/.test(added.text)) return undefined
70
+ return issue('warning', 'debug-statement', file, added, '调试残留语句(console/print/debugger)')
71
+ }
72
+
73
+ /** 密钥/凭据硬编码。 */
74
+ function secretIssue(file, added) {
75
+ if (!/(password|passwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"][^'"]{4,}['"]/i.test(added.text)) return undefined
76
+ return issue('error', 'secret-leak', file, added, '疑似硬编码密钥/凭据')
77
+ }
78
+
79
+ /** 合并冲突标记残留。 */
80
+ function conflictIssue(file, added) {
81
+ if (!/^(<<<<<<<|=======|>>>>>>>)/.test(added.text)) return undefined
82
+ return issue('error', 'conflict-marker', file, added, '合并冲突标记残留')
83
+ }
84
+
85
+ /** TODO/FIXME 标记。 */
86
+ function todoIssue(file, added) {
87
+ if (!/\b(TODO|FIXME|HACK|XXX)\b/.test(added.text)) return undefined
88
+ return issue('info', 'todo-marker', file, added, 'TODO/FIXME 标记')
89
+ }
90
+
91
+ /** 尾随空格。 */
92
+ function trailingIssue(file, added) {
93
+ if (!/\s+$/.test(added.text)) return undefined
94
+ return issue('info', 'trailing-space', file, added, '行尾多余空格')
95
+ }
96
+
97
+ /** 单文件变更超阈值。 */
98
+ function largeDiffIssue(file) {
99
+ const changed = file.insertions + file.deletions
100
+ if (changed <= LARGE_DIFF_LINES || file.binary) return undefined
101
+ return issue('warning', 'large-diff', file, null, `单文件变更 ${changed} 行(>${LARGE_DIFF_LINES}),建议拆分提交`)
102
+ }
103
+
104
+ /** 二进制文件变更。 */
105
+ function binaryIssue(file) {
106
+ if (!file.binary) return undefined
107
+ return issue('warning', 'binary-file', file, null, '二进制文件变更')
108
+ }
109
+
110
+ /** 有源码变更但无测试变更。 */
111
+ function noTestIssue(parsed) {
112
+ const sourceFiles = parsed.files.filter((file) => isSourceFile(file.path))
113
+ const testFiles = parsed.files.filter((file) => isTestFile(file.path))
114
+ if (sourceFiles.length === 0 || testFiles.length > 0) return undefined
115
+ return issue('info', 'no-test-change', null, null, '有源码变更但没有测试文件变更')
116
+ }
117
+
118
+ function issue(severity, rule, file, added, message) {
119
+ return {
120
+ severity,
121
+ rule,
122
+ file: file?.path ?? '',
123
+ line: added?.line ?? 0,
124
+ message,
125
+ }
126
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * dsh-my-observability — /observability/api routes.
3
+ *
4
+ * 所有请求先做 loopback 信任围栏(与 /api 网关一致的契约)。方法分派:
5
+ * - GET /sessions — 有审计事件的会话列表
6
+ * - GET /events?sessionId&type&limit — 会话事件(时间轴正序)
7
+ * - GET /status — 审计统计 + 功能开关
8
+ * - GET /git/status?repo= — 仓库状态(分支 + 变更)
9
+ * - GET /git/diff?repo=&staged= — 差异文本
10
+ * - POST /git/commit — 类型化提交(Conventional Commits)
11
+ * - POST /review — 增量 diff 审查(规则引擎 + 可选 AI)
12
+ */
13
+ import { isTrustedApiRequest } from './fence.js'
14
+ import { gitStatus, gitDiff, gitCommit } from './git.js'
15
+ import { parseDiff } from './diff.js'
16
+ import { reviewRules } from './review.js'
17
+ import { runAiReview } from './ai.js'
18
+
19
+ /** 注册 /observability/api 路由(effect 持有 disposer)。 */
20
+ export function registerObservabilityRoutes(ctx, store, options) {
21
+ const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined
22
+ const trustedHosts = webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
23
+ ? webRuntime.trustedHosts
24
+ : []
25
+ const fence = (request) => isTrustedApiRequest(request, trustedHosts)
26
+
27
+ ctx.effect(() => ctx.webServer.register({
28
+ kind: 'prefix',
29
+ path: '/observability/api',
30
+ handler: apiHandler(ctx, fence, store, options),
31
+ }), 'dsh-my-observability: /observability/api routes')
32
+ }
33
+
34
+ /** 统一 handler:fence → 方法分派 → 404/错误兜底。 */
35
+ function apiHandler(ctx, fence, store, options) {
36
+ return async (request, response) => {
37
+ if (!fence(request)) {
38
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
39
+ return
40
+ }
41
+ const url = new URL(request.url ?? '/', 'http://dsh.internal')
42
+ const pathname = url.pathname
43
+ const method = pathname.startsWith('/observability/api/') ? pathname.slice('/observability/api/'.length) : undefined
44
+ try {
45
+ const handled = await dispatchMethod(method, request, response, url, ctx, store, options)
46
+ if (!handled) {
47
+ writeJson(response, 404, { ok: false, error: { message: 'unknown dsh-my-observability API method' } })
48
+ }
49
+ } catch (error) {
50
+ writeError(response, error)
51
+ }
52
+ }
53
+ }
54
+
55
+ /** 方法 + 请求动词匹配。 */
56
+ function isMethod(method, request, name, verb) {
57
+ return method === name && request.method === verb
58
+ }
59
+
60
+ /** 按 method 分派到具体 handler;未识别返回 false(调用方回 404)。 */
61
+ async function dispatchMethod(method, request, response, url, ctx, store, options) {
62
+ if (isMethod(method, request, 'sessions', 'GET')) {
63
+ writeJson(response, 200, { ok: true, value: store.sessions() })
64
+ return true
65
+ }
66
+ if (isMethod(method, request, 'events', 'GET')) {
67
+ writeJson(response, 200, { ok: true, value: store.events(queryOf(url, 'sessionId'), queryOf(url, 'type'), limitOf(url)) })
68
+ return true
69
+ }
70
+ if (isMethod(method, request, 'status', 'GET')) {
71
+ writeJson(response, 200, { ok: true, value: statusValue(store, options) })
72
+ return true
73
+ }
74
+ if (isMethod(method, request, 'git/status', 'GET')) {
75
+ await handleGitStatus(response, repoOf(url))
76
+ return true
77
+ }
78
+ if (isMethod(method, request, 'git/diff', 'GET')) {
79
+ await handleGitDiff(response, repoOf(url), url.searchParams.get('staged') === '1')
80
+ return true
81
+ }
82
+ if (isMethod(method, request, 'git/commit', 'POST')) {
83
+ await handleGitCommit(request, response)
84
+ return true
85
+ }
86
+ if (isMethod(method, request, 'review', 'POST')) {
87
+ await handleReview(ctx, request, response, options)
88
+ return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ // ── handlers ───────────────────────────────────────────────────────────────
94
+
95
+ /** 状态:审计统计 + 功能开关(aiReview 只暴露开关)。 */
96
+ function statusValue(store, options) {
97
+ return {
98
+ auditCount: store.count(),
99
+ sessions: store.sessions().length,
100
+ aiReview: options.aiReview !== false,
101
+ gitEnabled: true,
102
+ }
103
+ }
104
+
105
+ /** git status:非仓库路径 400。 */
106
+ async function handleGitStatus(response, repoPath) {
107
+ if (repoPath === '') {
108
+ writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
109
+ return
110
+ }
111
+ const result = await gitStatus(repoPath)
112
+ if (!result.ok) {
113
+ writeJson(response, 400, { ok: false, error: result.error })
114
+ return
115
+ }
116
+ writeJson(response, 200, { ok: true, value: result })
117
+ }
118
+
119
+ /** git diff:非仓库路径 400。 */
120
+ async function handleGitDiff(response, repoPath, staged) {
121
+ if (repoPath === '') {
122
+ writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
123
+ return
124
+ }
125
+ const result = await gitDiff(repoPath, staged)
126
+ if (!result.ok) {
127
+ writeJson(response, 400, { ok: false, error: result.error })
128
+ return
129
+ }
130
+ writeJson(response, 200, { ok: true, value: { text: result.text } })
131
+ }
132
+
133
+ /** 类型化提交:body { repoPath, type, scope, description, body }。 */
134
+ async function handleGitCommit(request, response) {
135
+ const payload = await readJsonBody(request)
136
+ const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
137
+ if (repoPath === '') {
138
+ writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
139
+ return
140
+ }
141
+ const result = await gitCommit(repoPath, payload)
142
+ if (!result.ok) {
143
+ writeJson(response, 400, { ok: false, error: result.error })
144
+ return
145
+ }
146
+ writeJson(response, 200, {
147
+ ok: true,
148
+ value: { hash: result.hash, message: result.message, summary: result.summary },
149
+ })
150
+ }
151
+
152
+ /** 增量 diff 审查:规则引擎 + 可选 AI 增强(失败不影响规则结果)。 */
153
+ async function handleReview(ctx, request, response, options) {
154
+ const payload = await readJsonBody(request)
155
+ const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
156
+ if (repoPath === '') {
157
+ writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
158
+ return
159
+ }
160
+ const staged = payload.staged === true
161
+ const diffResult = await gitDiff(repoPath, staged)
162
+ if (!diffResult.ok) {
163
+ writeJson(response, 400, { ok: false, error: diffResult.error })
164
+ return
165
+ }
166
+ const report = reviewRules(parseDiff(diffResult.text))
167
+ report.ai = await aiOutcome(ctx, payload, options, diffResult.text, report)
168
+ writeJson(response, 200, { ok: true, value: report })
169
+ }
170
+
171
+ /** AI 审查开关判定:配置开启 + 请求未显式关闭 → 运行(失败降级)。 */
172
+ async function aiOutcome(ctx, payload, options, diffText, report) {
173
+ if (options.aiReview === false) return { enabled: false }
174
+ if (payload.aiReview === false) return { enabled: false }
175
+ return runAiReview(ctx, diffText, report, options.aiTimeoutMs)
176
+ }
177
+
178
+ // ── HTTP helpers ───────────────────────────────────────────────────────────
179
+
180
+ function queryOf(url, name) {
181
+ return url.searchParams.get(name) ?? ''
182
+ }
183
+
184
+ function repoOf(url) {
185
+ const repo = url.searchParams.get('repo')
186
+ return typeof repo === 'string' ? repo : ''
187
+ }
188
+
189
+ function limitOf(url) {
190
+ const raw = url.searchParams.get('limit')
191
+ const parsed = raw === null ? 0 : Number(raw)
192
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
193
+ }
194
+
195
+ /** Read a JSON request body (bounded). */
196
+ async function readJsonBody(request) {
197
+ let body = ''
198
+ for await (const chunk of request) {
199
+ body += chunk
200
+ if (body.length > 1_000_000) throw new Error('request body too large')
201
+ }
202
+ if (body === '') return {}
203
+ return JSON.parse(body)
204
+ }
205
+
206
+ function writeJson(response, status, value) {
207
+ const payload = JSON.stringify(value)
208
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-cache' })
209
+ response.end(payload)
210
+ }
211
+
212
+ function writeError(response, error) {
213
+ const message = error instanceof Error ? error.message : String(error)
214
+ writeJson(response, 400, { ok: false, error: { message } })
215
+ }