whygit 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/LICENSE +21 -0
- package/README.md +275 -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/util.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Small, dependency-free helpers shared across whygit. */
|
|
2
|
+
|
|
3
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
4
|
+
const noColor = 'NO_COLOR' in process.env || process.env.TERM === 'dumb';
|
|
5
|
+
const useColor = isTTY && !noColor;
|
|
6
|
+
|
|
7
|
+
function wrap(code) {
|
|
8
|
+
return function (s) {
|
|
9
|
+
if (!useColor) return String(s);
|
|
10
|
+
return '\x1b[' + code + 'm' + s + '\x1b[0m';
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const c = {
|
|
15
|
+
dim: wrap('2'),
|
|
16
|
+
bold: wrap('1'),
|
|
17
|
+
italic: wrap('3'),
|
|
18
|
+
underline: wrap('4'),
|
|
19
|
+
red: wrap('31'),
|
|
20
|
+
green: wrap('32'),
|
|
21
|
+
yellow: wrap('33'),
|
|
22
|
+
blue: wrap('34'),
|
|
23
|
+
magenta: wrap('35'),
|
|
24
|
+
cyan: wrap('36'),
|
|
25
|
+
gray: wrap('90'),
|
|
26
|
+
white: wrap('97'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const supportsColor = useColor;
|
|
30
|
+
|
|
31
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
32
|
+
|
|
33
|
+
export function stripAnsi(s) {
|
|
34
|
+
return String(s).replace(ANSI_RE, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Display width that treats CJK / wide characters as two columns. */
|
|
38
|
+
export function width(s) {
|
|
39
|
+
const str = stripAnsi(s);
|
|
40
|
+
let w = 0;
|
|
41
|
+
for (const ch of str) {
|
|
42
|
+
const cp = ch.codePointAt(0);
|
|
43
|
+
if (cp === 0x200b || (cp >= 0x0300 && cp <= 0x036f)) continue;
|
|
44
|
+
if (
|
|
45
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
46
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
47
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
48
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
49
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
50
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
51
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
52
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
53
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
54
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
55
|
+
) {
|
|
56
|
+
w += 2;
|
|
57
|
+
} else {
|
|
58
|
+
w += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return w;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function padEnd(s, n) {
|
|
65
|
+
const d = n - width(s);
|
|
66
|
+
return d > 0 ? String(s) + ' '.repeat(d) : String(s);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function padStart(s, n) {
|
|
70
|
+
const d = n - width(s);
|
|
71
|
+
return d > 0 ? ' '.repeat(d) + String(s) : String(s);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function truncate(s, max) {
|
|
75
|
+
const str = String(s);
|
|
76
|
+
if (width(str) <= max) return str;
|
|
77
|
+
let out = '';
|
|
78
|
+
let w = 0;
|
|
79
|
+
for (const ch of str) {
|
|
80
|
+
const cw = width(ch);
|
|
81
|
+
if (w + cw > max - 1) break;
|
|
82
|
+
out += ch;
|
|
83
|
+
w += cw;
|
|
84
|
+
}
|
|
85
|
+
return out + '\u2026';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function wrapText(s, maxWidth, indent) {
|
|
89
|
+
const pre = indent || '';
|
|
90
|
+
const words = String(s).split(/\s+/).filter(Boolean);
|
|
91
|
+
const lines = [];
|
|
92
|
+
let line = '';
|
|
93
|
+
for (const w of words) {
|
|
94
|
+
if (line && width(line + ' ' + w) > maxWidth) {
|
|
95
|
+
lines.push(pre + line);
|
|
96
|
+
line = w;
|
|
97
|
+
} else {
|
|
98
|
+
line = line ? line + ' ' + w : w;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (line) lines.push(pre + line);
|
|
102
|
+
return lines.length ? lines : [pre];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function plural(n, one, many) {
|
|
106
|
+
return n + ' ' + (n === 1 ? one : many || one + 's');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isoDate(ts) {
|
|
110
|
+
if (!ts) return '';
|
|
111
|
+
const d = new Date(Number(ts) * 1000);
|
|
112
|
+
return d.toISOString().slice(0, 10);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function relativeTime(ts, now) {
|
|
116
|
+
const t = Number(ts) * 1000;
|
|
117
|
+
const diff = (now || Date.now()) - t;
|
|
118
|
+
const sec = Math.round(diff / 1000);
|
|
119
|
+
if (sec < 60) return 'just now';
|
|
120
|
+
const min = Math.round(sec / 60);
|
|
121
|
+
if (min < 60) return min + 'm ago';
|
|
122
|
+
const hr = Math.round(min / 60);
|
|
123
|
+
if (hr < 24) return hr + 'h ago';
|
|
124
|
+
const day = Math.round(hr / 24);
|
|
125
|
+
if (day < 30) return day + 'd ago';
|
|
126
|
+
const mo = Math.round(day / 30.44);
|
|
127
|
+
if (mo < 12) return mo + 'mo ago';
|
|
128
|
+
const yr = (day / 365.25).toFixed(1).replace(/\.0$/, '');
|
|
129
|
+
return yr + 'y ago';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function slug(s, max) {
|
|
133
|
+
return String(s)
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
136
|
+
.replace(/^-+|-+$/g, '')
|
|
137
|
+
.slice(0, max || 60);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function clamp(n, lo, hi) {
|
|
141
|
+
return Math.max(lo, Math.min(hi, n));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function pct(n, d) {
|
|
145
|
+
if (!d) return '0%';
|
|
146
|
+
return Math.round((n / d) * 100) + '%';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function unique(arr) {
|
|
150
|
+
return Array.from(new Set(arr));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function groupBy(arr, key) {
|
|
154
|
+
const map = new Map();
|
|
155
|
+
for (const item of arr) {
|
|
156
|
+
const k = typeof key === 'function' ? key(item) : item[key];
|
|
157
|
+
if (!map.has(k)) map.set(k, []);
|
|
158
|
+
map.get(k).push(item);
|
|
159
|
+
}
|
|
160
|
+
return map;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Parse 'src/a.js:12' or 'src/a.js:12-20' (Windows drive letters preserved). */
|
|
164
|
+
export function parsePathLine(input) {
|
|
165
|
+
const m = String(input).match(/^(.*?):(\d+)(?:-(\d+))?$/);
|
|
166
|
+
if (!m) return { path: String(input), line: null, endLine: null };
|
|
167
|
+
return { path: m[1], line: Number(m[2]), endLine: Number(m[3] || m[2]) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function jsonOut(value, pretty) {
|
|
171
|
+
return JSON.stringify(value, null, pretty ? 2 : 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function warn(msg) {
|
|
175
|
+
process.stderr.write(c.yellow('whygit: ') + msg + '\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function die(msg, code) {
|
|
179
|
+
process.stderr.write(c.red('whygit: ') + msg + '\n');
|
|
180
|
+
process.exit(code == null ? 1 : code);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function log(msg) {
|
|
184
|
+
process.stdout.write(msg + '\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isBinaryName(p) {
|
|
188
|
+
return /\.(png|jpe?g|gif|webp|ico|bmp|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|class|exe|dll|so|dylib|bin|woff2?|ttf|otf|eot|mp3|mp4|mov|avi|webm|wav|flac|sqlite|db|pyc|o|a|lib|node)$/i.test(p);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const CODE_EXT = new Set(['.js','.jsx','.mjs','.cjs','.ts','.tsx','.py','.rb','.go','.rs','.java','.kt','.kts','.scala','.c','.h','.cc','.cpp','.hpp','.cs','.php','.swift','.m','.mm','.sh','.bash','.zsh','.fish','.ps1','.lua','.pl','.r','.jl','.dart','.ex','.exs','.erl','.clj','.cljs','.fs','.vb','.sql','.vue','.svelte','.html','.css','.scss','.sass','.less','.yml','.yaml','.toml','.json','.md','.mdx','.txt','.rst']);
|
|
192
|
+
|
|
193
|
+
export function extOf(p) {
|
|
194
|
+
const m = String(p).toLowerCase().match(/\.[a-z0-9]+$/);
|
|
195
|
+
return m ? m[0] : '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function isCodeFile(p) {
|
|
199
|
+
return CODE_EXT.has(extOf(p));
|
|
200
|
+
}
|
package/src/whylog.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** WHYLOG.md - the changelog's wiser sibling. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isoDate } from './util.js';
|
|
5
|
+
import { topDecisions } from './search.js';
|
|
6
|
+
import { topRisk } from './risk.js';
|
|
7
|
+
import { lsFiles } from './git.js';
|
|
8
|
+
|
|
9
|
+
export function buildWhylog(ctx, opts) {
|
|
10
|
+
const o = opts || {};
|
|
11
|
+
const limit = o.limit || 40;
|
|
12
|
+
const label = o.label || 'this repository';
|
|
13
|
+
const alive = new Set(lsFiles(ctx.cwd));
|
|
14
|
+
const rows = topDecisions(ctx.db, limit * 4).filter(function (r) { return r.rationale; }).slice(0, limit);
|
|
15
|
+
const risk = topRisk(ctx.db, 60).filter(function (r) { return alive.has(r.path); }).slice(0, 8);
|
|
16
|
+
const pickPaths = function (r) {
|
|
17
|
+
const all = String(r.paths || '').split(/\s+/).filter(Boolean);
|
|
18
|
+
const live = all.filter(function (p) { return alive.has(p); });
|
|
19
|
+
return (live.length ? live : all).slice(0, 4);
|
|
20
|
+
};
|
|
21
|
+
const head = (ctx.head || '').slice(0, 8);
|
|
22
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
23
|
+
const out = [];
|
|
24
|
+
out.push('# WHYLOG');
|
|
25
|
+
out.push('');
|
|
26
|
+
out.push('> Git blame tells you *who*. This file tells you *why*.');
|
|
27
|
+
out.push('>');
|
|
28
|
+
out.push('> Every non-obvious decision this repository had to make, distilled from its own history.');
|
|
29
|
+
out.push('> Generated by [whygit](https://github.com/whygit/whygit) - rebuilt from git, never hand-edited.');
|
|
30
|
+
out.push('');
|
|
31
|
+
out.push('`' + label + '` \u00b7 ' + ctx.stats.commits + ' commits indexed \u00b7 HEAD `' + head + '` \u00b7 ' + date);
|
|
32
|
+
out.push('');
|
|
33
|
+
out.push('## How to use this file');
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push('- **Before changing code**, search this file for the file you are about to touch: `grep -n "src/your/file.py" WHYLOG.md`.');
|
|
36
|
+
out.push('- **If a decision below contradicts what you were about to do**, trust the decision until you can explain why it no longer applies.');
|
|
37
|
+
out.push('- **When you change something non-obvious**, write the reason in the commit body - whygit picks it up on the next rebuild.');
|
|
38
|
+
out.push('- The same index is available to agents over MCP: `whygit mcp`.');
|
|
39
|
+
out.push('');
|
|
40
|
+
out.push('## Load-bearing decisions');
|
|
41
|
+
out.push('');
|
|
42
|
+
rows.forEach(function (r, i) {
|
|
43
|
+
const paths = pickPaths(r);
|
|
44
|
+
out.push('### ' + (i + 1) + '. ' + (r.title || '(untitled)'));
|
|
45
|
+
out.push('');
|
|
46
|
+
out.push('`' + isoDate(r.ts) + '` \u00b7 `' + String(r.sha).slice(0, 8) + '` \u00b7 ' + (r.author || 'unknown') + ' \u00b7 ' + (r.kind || 'change'));
|
|
47
|
+
out.push('');
|
|
48
|
+
if (r.rationale) out.push('> ' + String(r.rationale).replace(/\n+/g, ' '));
|
|
49
|
+
if (paths.length) {
|
|
50
|
+
out.push('');
|
|
51
|
+
out.push('Touches: ' + paths.map(function (p) { return '`' + p + '`'; }).join(', '));
|
|
52
|
+
}
|
|
53
|
+
out.push('');
|
|
54
|
+
});
|
|
55
|
+
if (risk.length) {
|
|
56
|
+
out.push('## The cursed files');
|
|
57
|
+
out.push('');
|
|
58
|
+
out.push('Most churn, most fixes, most authors. Read this list before refactoring - the history here is load-bearing.');
|
|
59
|
+
out.push('');
|
|
60
|
+
out.push('| File | Score | Commits | Fixes | Authors |');
|
|
61
|
+
out.push('| --- | ---: | ---: | ---: | ---: |');
|
|
62
|
+
for (const r of risk) {
|
|
63
|
+
out.push('| `' + r.path + '` | ' + (Math.round(r.score * 10) / 10) + ' | ' + r.commits + ' | ' + r.fixes + ' | ' + r.authors + ' |');
|
|
64
|
+
}
|
|
65
|
+
out.push('');
|
|
66
|
+
}
|
|
67
|
+
out.push('## House rule');
|
|
68
|
+
out.push('');
|
|
69
|
+
out.push('```');
|
|
70
|
+
out.push('WHY: <the reason, in one sentence>');
|
|
71
|
+
out.push('```');
|
|
72
|
+
out.push('');
|
|
73
|
+
out.push('Put that line in any commit body that changes behaviour for a non-obvious reason. Nothing else to configure.');
|
|
74
|
+
out.push('');
|
|
75
|
+
return out.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeWhylog(ctx, opts) {
|
|
79
|
+
const o = opts || {};
|
|
80
|
+
const file = path.join(ctx.cwd, o.file || 'WHYLOG.md');
|
|
81
|
+
const body = buildWhylog(ctx, o);
|
|
82
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
83
|
+
return { file: file, bytes: Buffer.byteLength(body), decisions: (body.match(/^### /gm) || []).length };
|
|
84
|
+
}
|