whygit 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +267 -2
- package/README.zh-CN.md +264 -0
- package/bin/whygit.js +11 -0
- package/docs/demo.svg +21 -0
- package/package.json +50 -4
- package/src/cli.js +132 -0
- package/src/cmd-check.js +81 -0
- package/src/cmd-cursed.js +38 -0
- package/src/cmd-init.js +92 -0
- package/src/cmd-story.js +85 -0
- package/src/cmd-why.js +105 -0
- package/src/context.js +63 -0
- package/src/db.js +76 -0
- package/src/git.js +311 -0
- package/src/indexer.js +150 -0
- package/src/install.js +95 -0
- package/src/mcp.js +171 -0
- package/src/risk.js +44 -0
- package/src/search.js +161 -0
- package/src/signals.js +160 -0
- package/src/ui.js +103 -0
- package/src/util.js +200 -0
- package/src/whylog.js +84 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Minimal, dependency-free MCP server over stdio (newline-delimited JSON). */
|
|
2
|
+
import { loadContext, repoLabel } from './context.js';
|
|
3
|
+
import { search, decisionFor } from './search.js';
|
|
4
|
+
import { blamePorcelain, commitUrl, remoteWebUrl } from './git.js';
|
|
5
|
+
import { decisionsForFile, fileRow, topRisk, authorsForFile } from './risk.js';
|
|
6
|
+
import { buildWhylog } from './whylog.js';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
11
|
+
const SERVER_INFO = { name: 'whygit', version: '0.1.0' };
|
|
12
|
+
|
|
13
|
+
const TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'why_search',
|
|
16
|
+
description: 'Search this repository history for the reason behind a piece of code or a past decision. Use when the user asks why code is written a certain way, or before changing code whose intent is unclear.',
|
|
17
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Words describing the behaviour or decision, e.g. "retry on 503" or "why proxy auth is stripped".' }, limit: { type: 'number', default: 5 } }, required: ['query'] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'why_line',
|
|
21
|
+
description: 'Explain a single file:line - which commit introduced it, what that commit said about why, and whether the line is still the original.',
|
|
22
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string' }, line: { type: 'number' } }, required: ['file', 'line'] },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'why_file',
|
|
26
|
+
description: 'The decision history of one file, oldest first - the story of why it looks the way it does.',
|
|
27
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, limit: { type: 'number', default: 12 } }, required: ['path'] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'why_risk',
|
|
31
|
+
description: 'The most dangerous files in this repository, ranked by churn, fix-density and author-spread. Use before refactoring or when choosing what to inspect.',
|
|
32
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 10 } } },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'whylog_read',
|
|
36
|
+
description: 'Read WHYLOG.md - the curated, generated summary of this repository\'s load-bearing decisions. Prefer this over reading the whole history.',
|
|
37
|
+
inputSchema: { type: 'object', properties: {} },
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function textResult(obj) {
|
|
42
|
+
const text = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
|
|
43
|
+
return { content: [{ type: 'text', text: text }] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function slim(r) {
|
|
47
|
+
return {
|
|
48
|
+
commit: r.sha,
|
|
49
|
+
date: r.ts ? new Date(r.ts * 1000).toISOString().slice(0, 10) : null,
|
|
50
|
+
title: r.title,
|
|
51
|
+
why: r.rationale || null,
|
|
52
|
+
paths: r.paths ? String(r.paths).split(/\s+/).slice(0, 5) : [],
|
|
53
|
+
author: r.author || null,
|
|
54
|
+
kind: r.kind,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createServer(opts) {
|
|
59
|
+
const o = opts || {};
|
|
60
|
+
let ctx = null;
|
|
61
|
+
const ensure = function () {
|
|
62
|
+
if (!ctx) ctx = loadContext({ cwd: o.cwd || process.cwd() });
|
|
63
|
+
return ctx;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handlers = {
|
|
67
|
+
why_search: function (a) {
|
|
68
|
+
const cx = ensure();
|
|
69
|
+
const rows = search(cx.db, String(a.query || ''), { limit: a.limit || 5 });
|
|
70
|
+
return textResult({ repo: repoLabel(cx), query: a.query, results: rows.map(slim) });
|
|
71
|
+
},
|
|
72
|
+
why_line: function (a) {
|
|
73
|
+
const cx = ensure();
|
|
74
|
+
const file = String(a.file || '');
|
|
75
|
+
const line = Number(a.line);
|
|
76
|
+
const blame = blamePorcelain(cx.cwd, file, null);
|
|
77
|
+
const b = blame[line - 1];
|
|
78
|
+
if (!b) return textResult({ error: 'no such line: ' + file + ':' + line });
|
|
79
|
+
const d = decisionFor(cx.db, b.sha) || { sha: b.sha, ts: b.ts, title: '(unindexed commit)', rationale: '', paths: file };
|
|
80
|
+
const alive = blame.filter(function (x) { return x && x.sha === b.sha; }).length;
|
|
81
|
+
return textResult({
|
|
82
|
+
file: file,
|
|
83
|
+
line: line,
|
|
84
|
+
code: b.text,
|
|
85
|
+
introduced_by: slim(d),
|
|
86
|
+
lines_from_same_commit: alive,
|
|
87
|
+
url: commitUrl(cx.webUrl, b.sha),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
why_file: function (a) {
|
|
91
|
+
const cx = ensure();
|
|
92
|
+
const p = String(a.path || '').replace(/\\/g, '/');
|
|
93
|
+
const rows = decisionsForFile(cx.db, p, a.limit || 12);
|
|
94
|
+
const stats = fileRow(cx.db, p);
|
|
95
|
+
return textResult({ file: p, stats: stats, decisions: rows.map(slim) });
|
|
96
|
+
},
|
|
97
|
+
why_risk: function (a) {
|
|
98
|
+
const cx = ensure();
|
|
99
|
+
return textResult({ files: topRisk(cx.db, a.limit || 10) });
|
|
100
|
+
},
|
|
101
|
+
whylog_read: function () {
|
|
102
|
+
const cx = ensure();
|
|
103
|
+
const f = path.join(cx.cwd, 'WHYLOG.md');
|
|
104
|
+
if (fs.existsSync(f)) return textResult(fs.readFileSync(f, 'utf8'));
|
|
105
|
+
return textResult(buildWhylog(cx, { label: repoLabel(cx) }));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function handle(msg) {
|
|
110
|
+
const id = msg.id;
|
|
111
|
+
const method = msg.method;
|
|
112
|
+
if (method === 'initialize') {
|
|
113
|
+
return { jsonrpc: '2.0', id: id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {} }, serverInfo: SERVER_INFO } };
|
|
114
|
+
}
|
|
115
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
|
|
116
|
+
if (method === 'ping') return { jsonrpc: '2.0', id: id, result: {} };
|
|
117
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id: id, result: { tools: TOOLS } };
|
|
118
|
+
if (method === 'tools/call') {
|
|
119
|
+
const name = msg.params && msg.params.name;
|
|
120
|
+
const fn = handlers[name];
|
|
121
|
+
if (!fn) return { jsonrpc: '2.0', id: id, error: { code: -32602, message: 'unknown tool ' + name } };
|
|
122
|
+
try {
|
|
123
|
+
return { jsonrpc: '2.0', id: id, result: fn((msg.params && msg.params.arguments) || {}) };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return { jsonrpc: '2.0', id: id, error: { code: -32000, message: String(err && err.message || err) } };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (method === 'resources/list') {
|
|
129
|
+
return { jsonrpc: '2.0', id: id, result: { resources: [{ uri: 'whygit://whylog', name: 'WHYLOG.md', mimeType: 'text/markdown', description: 'Generated summary of load-bearing decisions.' }] } };
|
|
130
|
+
}
|
|
131
|
+
if (method === 'resources/read') {
|
|
132
|
+
const uri = msg.params && msg.params.uri;
|
|
133
|
+
if (uri === 'whygit://whylog') {
|
|
134
|
+
const r = handlers.whylog_read({});
|
|
135
|
+
return { jsonrpc: '2.0', id: id, result: { contents: [{ uri: uri, mimeType: 'text/markdown', text: r.content[0].text }] } };
|
|
136
|
+
}
|
|
137
|
+
return { jsonrpc: '2.0', id: id, error: { code: -32602, message: 'unknown resource' } };
|
|
138
|
+
}
|
|
139
|
+
if (id === undefined) return null;
|
|
140
|
+
return { jsonrpc: '2.0', id: id, error: { code: -32601, message: 'method not found: ' + method } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function start() {
|
|
144
|
+
let buf = '';
|
|
145
|
+
process.stdin.setEncoding('utf8');
|
|
146
|
+
process.stdin.on('data', function (chunk) {
|
|
147
|
+
buf += chunk;
|
|
148
|
+
let idx;
|
|
149
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
150
|
+
const line = buf.slice(0, idx).trim();
|
|
151
|
+
buf = buf.slice(idx + 1);
|
|
152
|
+
if (!line) continue;
|
|
153
|
+
let msg;
|
|
154
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
155
|
+
let res;
|
|
156
|
+
try { res = handle(msg); } catch (err) { res = { jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(err.message) } }; }
|
|
157
|
+
if (res) process.stdout.write(JSON.stringify(res) + '\n');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
process.stdin.on('end', function () { process.exit(0); });
|
|
161
|
+
process.stderr.write('whygit mcp: ready (stdio)\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { start: start, handle: handle, tools: TOOLS };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function cmdMcp(args, flags) {
|
|
168
|
+
const server = createServer({ cwd: flags.cwd });
|
|
169
|
+
server.start();
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
package/src/risk.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Risk / cursed-file analytics. */
|
|
2
|
+
|
|
3
|
+
export function topRisk(dbc, limit) {
|
|
4
|
+
return dbc.prepare('SELECT path, score, commits, authors, fixes, todos, last_ts, added, removed FROM risk ORDER BY score DESC LIMIT ?').all(limit || 20);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function riskFor(dbc, p) {
|
|
8
|
+
return dbc.prepare('SELECT path, score, commits, authors, fixes, todos, last_ts, added, removed FROM risk WHERE path = ?').get(p) || null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fileRow(dbc, p) {
|
|
12
|
+
return dbc.prepare('SELECT path, commits, authors, fixes, todos, last_ts, added, removed FROM risk WHERE path = ?').get(p) || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function decisionsForFile(dbc, p, limit) {
|
|
16
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.added, c.removed FROM decisions d JOIN commit_files cf ON cf.sha = d.sha JOIN commits c ON c.sha = d.sha WHERE cf.path = ? ORDER BY d.ts ASC LIMIT ?').all(p, limit || 500);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function yearHistogram(dbc, p) {
|
|
20
|
+
return dbc.prepare("SELECT CAST(strftime('%Y', c.ts, 'unixepoch') AS INTEGER) AS year, COUNT(*) AS n FROM commit_files cf JOIN commits c ON c.sha = cf.sha WHERE cf.path = ? GROUP BY year ORDER BY year").all(p);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function chapterCandidates(dbc, p, limit) {
|
|
24
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.kind, d.confidence, c.author, c.added, c.removed FROM decisions d JOIN commit_files cf ON cf.sha = d.sha JOIN commits c ON c.sha = d.sha WHERE cf.path = ? AND d.rationale != \'\' ORDER BY d.ts ASC LIMIT ?').all(p, limit || 4000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function authorsForFile(dbc, p) {
|
|
28
|
+
const rows = dbc.prepare('SELECT c.author AS author, c.email AS email, COUNT(*) AS n FROM commit_files cf JOIN commits c ON c.sha = cf.sha WHERE cf.path = ? AND c.is_bot = 0 GROUP BY c.email ORDER BY n DESC LIMIT 40').all(p);
|
|
29
|
+
const byName = new Map();
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
const key = String(r.author || '').toLowerCase();
|
|
32
|
+
if (byName.has(key)) byName.get(key).n += r.n;
|
|
33
|
+
else byName.set(key, { author: r.author, n: r.n });
|
|
34
|
+
}
|
|
35
|
+
return Array.from(byName.values()).sort(function (a, b) { return b.n - a.n; }).slice(0, 8);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function birthOf(dbc, p) {
|
|
39
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, c.author FROM decisions d JOIN commit_files cf ON cf.sha = d.sha JOIN commits c ON c.sha = d.sha WHERE cf.path = ? ORDER BY d.ts ASC LIMIT 1').get(p) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fixCommitsForFile(dbc, p, limit) {
|
|
43
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.kind, c.author FROM decisions d JOIN commit_files cf ON cf.sha = d.sha JOIN commits c ON c.sha = d.sha WHERE cf.path = ? AND d.kind = \'fix\' ORDER BY d.ts DESC LIMIT ?').all(p, limit || 6);
|
|
44
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** Query layer: BM25 + heuristics over the decision index. */
|
|
2
|
+
import { openIndex } from './db.js';
|
|
3
|
+
|
|
4
|
+
const STOP = new Set(['the','a','an','is','are','was','were','to','of','in','on','for','and','or','it','this','that','with','how','why','what','do','does','did','be','by','as','at','from','we','you','i','my','our']);
|
|
5
|
+
|
|
6
|
+
export function tokenize(q) {
|
|
7
|
+
return String(q || '')
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.split(/[^a-z0-9_./\u3400-\u9fff-]+/)
|
|
10
|
+
.map(function (t) { return t.replace(/^\.+|\/+$/g, ''); })
|
|
11
|
+
.filter(function (t) { return (t.length > 1 || CJK.test(t)) && !STOP.has(t); })
|
|
12
|
+
.slice(0, 12);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ftsQuery(q) {
|
|
16
|
+
const toks = tokenize(q);
|
|
17
|
+
if (!toks.length) return null;
|
|
18
|
+
const parts = toks.map(function (t) {
|
|
19
|
+
const safe = t.replace(/"/g, '');
|
|
20
|
+
return '"' + safe + '"*';
|
|
21
|
+
});
|
|
22
|
+
return parts.join(' OR ');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SELECT = 'SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.is_bot, bm25(decisions_fts, 9.0, 5.0, 6.0, 3.0) AS rank FROM decisions_fts JOIN decisions d ON d.rowid = decisions_fts.rowid JOIN commits c ON c.sha = d.sha';
|
|
26
|
+
|
|
27
|
+
const CJK = /[\u3400-\u9fff\uf900-\ufaff]/;
|
|
28
|
+
const TRI_SELECT = 'SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.is_bot, bm25(decisions_tri, 9.0, 5.0, 6.0) AS rank FROM decisions_tri JOIN decisions d ON d.rowid = decisions_tri.rowid JOIN commits c ON c.sha = d.sha';
|
|
29
|
+
|
|
30
|
+
/** Substring search: the trigram index handles CJK and code identifiers. */
|
|
31
|
+
export function searchSubstring(dbc, query, opts) {
|
|
32
|
+
const o = opts || {};
|
|
33
|
+
const limit = o.limit || 10;
|
|
34
|
+
const q = String(query || '').trim();
|
|
35
|
+
if (q.length < 3) return searchLike(dbc, q, limit * 6);
|
|
36
|
+
const rows = dbc.prepare(TRI_SELECT + ' WHERE decisions_tri MATCH ? ORDER BY rank LIMIT ?').all('"' + q.replace(/"/g, '') + '"', limit * 6);
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function searchLike(dbc, q, n) {
|
|
41
|
+
if (!q) return [];
|
|
42
|
+
const like = '%' + q + '%';
|
|
43
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.is_bot, 0 AS rank FROM decisions d JOIN commits c ON c.sha = d.sha WHERE d.title LIKE ? OR d.rationale LIKE ? ORDER BY d.confidence DESC LIMIT ?').all(like, like, n);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function search(dbc, query, opts) {
|
|
47
|
+
const o = opts || {};
|
|
48
|
+
const limit = o.limit || 10;
|
|
49
|
+
const q = String(query || '').trim();
|
|
50
|
+
if (!q) return [];
|
|
51
|
+
const runs = [];
|
|
52
|
+
const match = ftsQuery(query);
|
|
53
|
+
if (match) {
|
|
54
|
+
try {
|
|
55
|
+
runs.push(dbc.prepare(SELECT + ' WHERE decisions_fts MATCH ? ORDER BY rank LIMIT ?').all(match, limit * 6));
|
|
56
|
+
} catch (e) { /* ignore malformed fts syntax */ }
|
|
57
|
+
}
|
|
58
|
+
if (CJK.test(q) || q.length >= 4) {
|
|
59
|
+
try { runs.push(searchSubstring(dbc, q, { limit: limit })); } catch (e) { /* trigram table missing */ }
|
|
60
|
+
}
|
|
61
|
+
const seen = new Map();
|
|
62
|
+
for (const rows of runs) {
|
|
63
|
+
for (const r of rows) {
|
|
64
|
+
const prev = seen.get(r.sha);
|
|
65
|
+
if (!prev || (r.rank || 0) < (prev.rank || 0)) seen.set(r.sha, r);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const merged = Array.from(seen.values());
|
|
69
|
+
if (!merged.length) return rerank(searchLike(dbc, q, limit * 6), query, o).slice(0, limit);
|
|
70
|
+
return rerank(merged, query, o).slice(0, limit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function noisePenalty(r) {
|
|
74
|
+
let p = 1;
|
|
75
|
+
const paths = String(r.paths || '').toLowerCase();
|
|
76
|
+
const list = paths.split(' ').filter(Boolean);
|
|
77
|
+
if (list.length && list.every(function (x) { return /(^|\/)(vendor|third_party|thirdparty|packages|node_modules|dist|build|target|external)\//.test(x); })) p *= 0.4;
|
|
78
|
+
if (list.length && list.every(function (x) { return /\.(rst|md|mdx|txt)$/.test(x) || /(^|\/)(docs?|documentation)\//.test(x); })) p *= 0.75;
|
|
79
|
+
const title = String(r.title || '').trim();
|
|
80
|
+
if (/^(bump|update\s+\S+\s+to\s+v?\d|release\s+\d|v?\d+\.\d+\.\d+$)/i.test(title)) p *= 0.45;
|
|
81
|
+
if (/^(revert|revert:)/i.test(title)) p *= 0.7;
|
|
82
|
+
if (/^merge (pull request|branch|remote)/i.test(title)) p *= 0.5;
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function rerank(rows, query, opts) {
|
|
87
|
+
const o = opts || {};
|
|
88
|
+
const toks = tokenize(query);
|
|
89
|
+
const now = Date.now() / 1000;
|
|
90
|
+
const best = rows.reduce(function (m, r) { return Math.min(m, r.rank || 0); }, 0);
|
|
91
|
+
const scored = rows.map(function (r) {
|
|
92
|
+
const gap = Math.max(0, (r.rank || 0) - best);
|
|
93
|
+
let s = 1 / (1 + gap / 3);
|
|
94
|
+
s *= 0.5 + Math.min(1, r.confidence || 0.2);
|
|
95
|
+
if (r.is_bot) s *= 0.2;
|
|
96
|
+
if (r.kind === 'fix') s *= 1.2;
|
|
97
|
+
if (r.kind === 'chore') s *= 0.7;
|
|
98
|
+
const title = String(r.title || '').toLowerCase();
|
|
99
|
+
const rat = String(r.rationale || '').toLowerCase();
|
|
100
|
+
const paths = String(r.paths || '').toLowerCase();
|
|
101
|
+
let hit = 0;
|
|
102
|
+
for (const t of toks) {
|
|
103
|
+
if (title.includes(t)) { s *= 1.25; hit++; }
|
|
104
|
+
else if (rat.includes(t)) { s *= 1.15; hit++; }
|
|
105
|
+
else if (paths.includes(t)) { s *= 1.08; hit++; }
|
|
106
|
+
}
|
|
107
|
+
const coverage = toks.length ? hit / toks.length : 0;
|
|
108
|
+
s *= 0.35 + 0.9 * coverage;
|
|
109
|
+
if (!r.rationale) s *= 0.75;
|
|
110
|
+
s *= noisePenalty(r);
|
|
111
|
+
const age = Math.max(0, (now - (r.ts || now)) / 86400);
|
|
112
|
+
s *= 0.85 + 0.15 / (1 + age / 900);
|
|
113
|
+
return Object.assign({}, r, { score: s });
|
|
114
|
+
});
|
|
115
|
+
scored.sort(function (a, b) { return b.score - a.score; });
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const r of scored) {
|
|
119
|
+
if (seen.has(r.sha)) continue;
|
|
120
|
+
seen.add(r.sha);
|
|
121
|
+
out.push(r);
|
|
122
|
+
}
|
|
123
|
+
const top = out.length ? out[0].score : 1;
|
|
124
|
+
for (const r of out) r.match = Math.max(1, Math.round((r.score / (top || 1)) * 100));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function decisionFor(dbc, sha) {
|
|
129
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.subject FROM decisions d JOIN commits c ON c.sha = d.sha WHERE d.sha = ?').get(sha) || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decisionsForPath(dbc, p, limit) {
|
|
133
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence FROM decisions d JOIN commit_files cf ON cf.sha = d.sha WHERE cf.path = ? ORDER BY d.ts DESC LIMIT ?').all(p, limit || 8);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function recentDecisions(dbc, limit) {
|
|
137
|
+
return dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author FROM decisions d JOIN commits c ON c.sha = d.sha WHERE c.is_bot = 0 AND d.rationale != \'\' ORDER BY d.ts DESC LIMIT ?').all(limit || 10);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Best-of index: highest-confidence, highest-impact decisions. */
|
|
141
|
+
export function topDecisions(dbc, limit) {
|
|
142
|
+
const rows = dbc.prepare('SELECT d.sha, d.ts, d.title, d.rationale, d.paths, d.kind, d.confidence, c.author, c.added, c.removed, c.files_changed FROM decisions d JOIN commits c ON c.sha = d.sha WHERE c.is_bot = 0').all();
|
|
143
|
+
const scored = rows.map(function (r) {
|
|
144
|
+
const churn = Math.log10(2 + (r.added || 0) + (r.removed || 0));
|
|
145
|
+
const spread = 1 + Math.min(r.files_changed || 1, 20) / 40;
|
|
146
|
+
let s = (r.confidence || 0) * churn * spread;
|
|
147
|
+
if (r.kind === 'fix') s *= 1.3;
|
|
148
|
+
if (r.kind === 'chore') s *= 0.5;
|
|
149
|
+
if (!r.rationale) s *= 0.55;
|
|
150
|
+
const years = Math.max(0, (Date.now() / 1000 - (r.ts || 0)) / 31536000);
|
|
151
|
+
s *= 1 / (1 + years / 10);
|
|
152
|
+
s *= noisePenalty(r);
|
|
153
|
+
return Object.assign({}, r, { score: s });
|
|
154
|
+
});
|
|
155
|
+
scored.sort(function (a, b) { return b.score - a.score; });
|
|
156
|
+
return scored.slice(0, limit || 20);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function openFor(cwd, gd) {
|
|
160
|
+
return openIndex(gd, { create: false });
|
|
161
|
+
}
|
package/src/signals.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Turn raw commit text into a structured why-decision. */
|
|
2
|
+
|
|
3
|
+
const BOT_RE = /(\[bot\]|-bot@|^bot$|dependabot|renovate|github-actions|pre-commit-ci|greenkeeper|snyk-bot|imgbot|allcontributors|semantic-release|autofix)/i;
|
|
4
|
+
|
|
5
|
+
const NOISE_RE = [
|
|
6
|
+
/^signed-off-by:/i,
|
|
7
|
+
/^co-authored-by:/i,
|
|
8
|
+
/^reviewed-by:/i,
|
|
9
|
+
/^acked-by:/i,
|
|
10
|
+
/^tested-by:/i,
|
|
11
|
+
/^change-id:/i,
|
|
12
|
+
/^bug:/i,
|
|
13
|
+
/^see also:/i,
|
|
14
|
+
/^refs?:\s*#?\d+/i,
|
|
15
|
+
/^fix(?:e[sd])?:?\s*#\d+$/i,
|
|
16
|
+
/^closes?:?\s*#\d+$/i,
|
|
17
|
+
/^cc\s+@/i,
|
|
18
|
+
/^generated by /i,
|
|
19
|
+
/^this (?:pr|commit|change) was /i,
|
|
20
|
+
/^\[skip ci\]/i,
|
|
21
|
+
/^conflicts?:/i,
|
|
22
|
+
/^-{3,}$/,
|
|
23
|
+
/^={3,}$/,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const CAUSAL = [
|
|
27
|
+
/\b(because|since|so that|in order to|to avoid|to prevent|otherwise|which caused|due to|the reason|this ensures|so we|so it|as a result|which means|that way|to make sure|to fix|workaround|root cause|regression|edge case|race condition|memory leak|deadlock|timeout|overflow|deprecated|backwards?[- ]compat)/i,
|
|
28
|
+
/(因为|由于|所以|因此|导致|避免|防止|否则|为了|兼容|回滚|修复|临时方案|根因|边界情况|竞态|内存泄漏|死锁|超时|溢出|已废弃)/,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const FIX_RE = /\b(fix|fixes|fixed|revert|reverts|hotfix|regression|bug|broken|crash|oops|workaround|patch)\b|修复|回滚|修复|bug/i;
|
|
32
|
+
|
|
33
|
+
const BULK_RE = /^(bump|update deps|update dependencies|release|chore\(deps\)|version bump|v?\d+\.\d+\.\d+$)/i;
|
|
34
|
+
|
|
35
|
+
const COMMENT_RE = /^\s*(?:\/\/|#|\/\*|\*|--|;)\s*(?:NOTE|WHY|HACK|IMPORTANT|XXX|WARNING|TODO|FIXME)[:\s-]+(.{8,240})/i;
|
|
36
|
+
|
|
37
|
+
export function isBot(author, email) {
|
|
38
|
+
return BOT_RE.test(String(author || '')) || BOT_RE.test(String(email || ''));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isTrivial(subject) {
|
|
42
|
+
return BULK_RE.test(String(subject || '').trim());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function stripNoise(body) {
|
|
46
|
+
const lines = String(body || '').split(/\r?\n/);
|
|
47
|
+
const kept = [];
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const t = line.trim();
|
|
50
|
+
if (!t) { kept.push(''); continue; }
|
|
51
|
+
if (NOISE_RE.some(function (re) { return re.test(t); })) continue;
|
|
52
|
+
if (/^https?:\/\/\S+$/.test(t)) continue;
|
|
53
|
+
kept.push(t);
|
|
54
|
+
}
|
|
55
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function splitSentences(text) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const para of String(text || '').split(/\n\s*\n|\n(?=\s*[-*\d]\s)/)) {
|
|
61
|
+
const clean = para.replace(/\s+/g, ' ').trim();
|
|
62
|
+
if (!clean) continue;
|
|
63
|
+
const parts = clean.split(/(?<=[.!?;])\s+(?=[A-Z0-9])|(?<=[。!?;])/);
|
|
64
|
+
for (const p of parts) {
|
|
65
|
+
const s = p.trim();
|
|
66
|
+
if (s) out.push(s);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Remove the subject if the body repeats it (squash-merge artefact). */
|
|
73
|
+
export function dedupeSubject(body, subject) {
|
|
74
|
+
const b = String(body || '').trim();
|
|
75
|
+
const s = String(subject || '').trim();
|
|
76
|
+
if (!b || !s) return b;
|
|
77
|
+
const lines = b.split(/\r?\n/);
|
|
78
|
+
const first = lines[0].trim();
|
|
79
|
+
const norm = function (x) { return x.toLowerCase().replace(/[.:;,]+$/, '').trim(); };
|
|
80
|
+
const a = norm(first);
|
|
81
|
+
const t = norm(s);
|
|
82
|
+
if (a === t || (t.length >= 12 && a.startsWith(t.slice(0, 40)))) {
|
|
83
|
+
return lines.slice(1).join('\n').trim();
|
|
84
|
+
}
|
|
85
|
+
return b;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function scoreSentence(sentence) {
|
|
89
|
+
const s = String(sentence || '');
|
|
90
|
+
if (s.length < 25 || s.length > 400) return 0;
|
|
91
|
+
let score = 0;
|
|
92
|
+
for (const re of CAUSAL) if (re.test(s)) { score += 3; break; }
|
|
93
|
+
if (/\b(?:instead of|rather than|previously|used to|before this)\b/i.test(s)) score += 1.2;
|
|
94
|
+
if (/`[^`]+`|\b[A-Z][A-Za-z0-9]+\.[A-Za-z0-9]+\b/.test(s)) score += 0.6;
|
|
95
|
+
if (/^[-*]\s/.test(s)) score -= 0.5;
|
|
96
|
+
if (/^(see|ref|link)\b/i.test(s)) score -= 2;
|
|
97
|
+
return score;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function extractRationale(subject, body) {
|
|
101
|
+
const cleanBody = dedupeSubject(stripNoise(body), subject);
|
|
102
|
+
const sentences = splitSentences(cleanBody).filter(function (s) { return s.length >= 20; });
|
|
103
|
+
let best = null;
|
|
104
|
+
let bestScore = 0;
|
|
105
|
+
for (const s of sentences) {
|
|
106
|
+
const sc = scoreSentence(s);
|
|
107
|
+
if (sc > bestScore) { bestScore = sc; best = s; }
|
|
108
|
+
}
|
|
109
|
+
if (best && bestScore >= 1) {
|
|
110
|
+
const r = trimSentence(best);
|
|
111
|
+
if (sameText(r, subject)) return { rationale: '', confidence: 0.3, source: 'subject-only' };
|
|
112
|
+
return { rationale: r, confidence: bestScore >= 3 ? 0.9 : 0.6, source: 'body' };
|
|
113
|
+
}
|
|
114
|
+
if (sentences.length) {
|
|
115
|
+
const r = trimSentence(sentences[0]);
|
|
116
|
+
if (sameText(r, subject)) return { rationale: '', confidence: 0.3, source: 'subject-only' };
|
|
117
|
+
return { rationale: r, confidence: 0.4, source: 'body-weak' };
|
|
118
|
+
}
|
|
119
|
+
return { rationale: '', confidence: best ? 0.3 : 0.15, source: 'subject-only' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sameText(a, b) {
|
|
123
|
+
const norm = function (x) { return String(x || '').toLowerCase().replace(/[^a-z0-9\u3400-\u9fff]+/g, ' ').trim(); };
|
|
124
|
+
const x = norm(a);
|
|
125
|
+
const y = norm(b);
|
|
126
|
+
if (!x || !y) return false;
|
|
127
|
+
return x === y || (y.length >= 16 && x.startsWith(y));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function trimSentence(s) {
|
|
131
|
+
let t = String(s).trim().replace(/^[-*\d.]+\s+/, '').replace(/\s+/g, ' ');
|
|
132
|
+
if (t.length > 320) t = t.slice(0, 317).trimEnd() + '...';
|
|
133
|
+
return t;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Mine intent-bearing comments from added lines of a diff. */
|
|
137
|
+
export function mineComments(files) {
|
|
138
|
+
const found = [];
|
|
139
|
+
for (const f of files || []) {
|
|
140
|
+
for (const h of f.hunks || []) {
|
|
141
|
+
for (const line of h.added || []) {
|
|
142
|
+
const m = line.match(COMMENT_RE);
|
|
143
|
+
if (m && m[1]) found.push({ path: f.path, text: trimSentence(m[1]) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return found.slice(0, 5);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function hasFixWord(text) {
|
|
151
|
+
return FIX_RE.test(String(text || ''));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function cleanSubject(subject) {
|
|
155
|
+
return String(subject || '').replace(/\s+/g, ' ').trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function classify(kind) {
|
|
159
|
+
return kind || 'change';
|
|
160
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Terminal rendering helpers. */
|
|
2
|
+
import { c, width, padEnd, truncate, wrapText, isoDate, relativeTime, plural, stripAnsi } from './util.js';
|
|
3
|
+
|
|
4
|
+
export function termWidth() {
|
|
5
|
+
return Math.max(60, Math.min(100, (process.stdout.columns || 90) - 2));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function header(title, subtitle) {
|
|
9
|
+
const w = termWidth();
|
|
10
|
+
const line = c.gray('\u2500'.repeat(Math.min(w, Math.max(28, width(stripAnsi(title)) + 30))));
|
|
11
|
+
const parts = [c.bold(title)];
|
|
12
|
+
if (subtitle) parts.push(c.dim(subtitle));
|
|
13
|
+
return '\n ' + parts.join(' ') + '\n ' + line + '\n';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function badge(kind) {
|
|
17
|
+
if (kind === 'fix') return c.red('fix');
|
|
18
|
+
if (kind === 'chore') return c.gray('chore');
|
|
19
|
+
if (kind === 'revert') return c.magenta('revert');
|
|
20
|
+
return c.blue('change');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bar(value, max, size) {
|
|
24
|
+
const n = Math.max(0, Math.min(size || 12, Math.round(((value || 0) / (max || 1)) * (size || 12))));
|
|
25
|
+
return c.cyan('\u2588'.repeat(n)) + c.gray('\u00b7'.repeat((size || 12) - n));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sparkline(values) {
|
|
29
|
+
const chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
|
30
|
+
const max = Math.max.apply(null, values.concat([1]));
|
|
31
|
+
return values.map(function (v) { return chars[Math.min(7, Math.round((v / max) * 7))]; }).join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One decision, rendered for humans. */
|
|
35
|
+
export function renderDecision(d, opts) {
|
|
36
|
+
const o = opts || {};
|
|
37
|
+
const w = termWidth();
|
|
38
|
+
const indent = o.indent == null ? ' ' : o.indent;
|
|
39
|
+
const lines = [];
|
|
40
|
+
const num = o.index != null ? c.gray(String(o.index).padStart(2) + '. ') : '';
|
|
41
|
+
lines.push('');
|
|
42
|
+
lines.push(indent + num + c.bold(truncate(d.title || '(untitled)', w - 6)));
|
|
43
|
+
const meta = [];
|
|
44
|
+
if (d.ts) meta.push(isoDate(d.ts));
|
|
45
|
+
if (d.sha) meta.push(c.gray(d.sha.slice(0, 8)));
|
|
46
|
+
if (d.author) meta.push(d.author);
|
|
47
|
+
if (d.kind) meta.push(badge(d.kind));
|
|
48
|
+
if (d.match != null) meta.push(c.gray(d.match + '% match'));
|
|
49
|
+
else if (d.score != null) meta.push(c.gray('score ' + (d.score).toFixed(2)));
|
|
50
|
+
lines.push(indent + ' ' + meta.join(c.gray(' \u00b7 ')));
|
|
51
|
+
if (d.rationale) {
|
|
52
|
+
const body = wrapText(d.rationale, w - 6, indent + ' ');
|
|
53
|
+
lines.push('');
|
|
54
|
+
for (const l of body) lines.push(c.white(l));
|
|
55
|
+
}
|
|
56
|
+
if (d.paths) {
|
|
57
|
+
const list = String(d.paths).split(/\s+/).filter(Boolean).slice(0, o.maxPaths || 3);
|
|
58
|
+
if (list.length) {
|
|
59
|
+
lines.push('');
|
|
60
|
+
lines.push(indent + ' ' + c.cyan(list.join(' ')));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (o.extra) for (const e of o.extra) lines.push(indent + ' ' + e);
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderNote(text, kind) {
|
|
68
|
+
const w = termWidth();
|
|
69
|
+
const icon = kind === 'warn' ? c.yellow('!') : kind === 'ok' ? c.green('\u2713') : c.gray('\u00b7');
|
|
70
|
+
return ' ' + icon + ' ' + wrapText(text, w - 6, ' ').join('\n').trimStart();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderTable(rows, cols, opts) {
|
|
74
|
+
const o = opts || {};
|
|
75
|
+
const widths = cols.map(function (col) {
|
|
76
|
+
return Math.max(col.label.length, ...rows.map(function (r) { return width(String(col.value(r))); }));
|
|
77
|
+
});
|
|
78
|
+
const out = [];
|
|
79
|
+
const head = cols.map(function (col, i) { return padEnd(c.gray(col.label.toUpperCase()), widths[i] + (i ? 2 : 0)); }).join(' ');
|
|
80
|
+
out.push(' ' + head);
|
|
81
|
+
out.push(' ' + c.gray('\u2500'.repeat(Math.min(termWidth(), head.length))));
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
const line = cols.map(function (col, i) {
|
|
84
|
+
const v = String(col.value(r));
|
|
85
|
+
return padEnd(i === 0 ? truncate(v, widths[i]) : v, widths[i] + (i ? 2 : 0));
|
|
86
|
+
}).join(' ');
|
|
87
|
+
out.push(' ' + line);
|
|
88
|
+
}
|
|
89
|
+
return out.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function banner() {
|
|
93
|
+
return [
|
|
94
|
+
'',
|
|
95
|
+
' ' + c.bold(c.cyan('whygit')) + c.gray(' \u2014 git blame tells you who. whygit tells you why.'),
|
|
96
|
+
'',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function kv(pairs) {
|
|
101
|
+
const w = Math.max.apply(null, pairs.map(function (p) { return p[0].length; }));
|
|
102
|
+
return pairs.map(function (p) { return ' ' + c.gray(padEnd(p[0], w)) + ' ' + p[1]; }).join('\n');
|
|
103
|
+
}
|