diffsplain 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/README.md +64 -0
- package/dist/assets/geist-latin-BGnTDqni.woff2 +0 -0
- package/dist/assets/geist-mono-latin-pyAoGB9p.woff2 +0 -0
- package/dist/assets/index-CLtrIbvS.css +2 -0
- package/dist/assets/index-d0QMfM_f.js +10 -0
- package/dist/demo-diff-data.json +264 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +19 -0
- package/package.json +62 -0
- package/scripts/build-diff-data.mjs +1003 -0
- package/scripts/clean-built-live-data.mjs +9 -0
- package/scripts/cli-args.mjs +293 -0
- package/scripts/generate-summaries.mjs +735 -0
- package/scripts/present.mjs +228 -0
- package/scripts/serve-built.mjs +135 -0
- package/scripts/summary-path.mjs +35 -0
- package/scripts/write-todo-demo.mjs +60 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { helpText, parseCliArgs } from './cli-args.mjs';
|
|
10
|
+
|
|
11
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const callerDirectory = process.cwd();
|
|
13
|
+
let cli;
|
|
14
|
+
try {
|
|
15
|
+
cli = parseCliArgs(process.argv.slice(2), { callerDirectory });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(error.message);
|
|
18
|
+
console.error('Run diffsplain --help for usage.');
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (cli.help) {
|
|
23
|
+
console.log(helpText);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { agentEnabled, port } = cli;
|
|
28
|
+
const feedArgs = [...cli.feedArgs];
|
|
29
|
+
const agentArgs = [...cli.agentArgs];
|
|
30
|
+
const outputIndex = feedArgs.indexOf('--output');
|
|
31
|
+
|
|
32
|
+
if (outputIndex === -1) {
|
|
33
|
+
feedArgs.push('--output', resolve(root, '.cache/diff-data.json'));
|
|
34
|
+
agentArgs.push('--output', resolve(root, '.cache/diff-data.json'));
|
|
35
|
+
}
|
|
36
|
+
if (!feedArgs.includes('--watch')) feedArgs.push('--watch');
|
|
37
|
+
const outputPath = resolve(
|
|
38
|
+
callerDirectory,
|
|
39
|
+
feedArgs[feedArgs.indexOf('--output') + 1],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const builtPage = resolve(root, 'dist/index.html');
|
|
43
|
+
if (!existsSync(builtPage)) {
|
|
44
|
+
const result = spawnSync(
|
|
45
|
+
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
46
|
+
['run', 'build'],
|
|
47
|
+
{ cwd: root, stdio: 'inherit' },
|
|
48
|
+
);
|
|
49
|
+
if (result.error) {
|
|
50
|
+
console.error(`Could not build the local page: ${result.error.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (result.status !== 0) process.exit(result.status || 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const feed = spawn(
|
|
57
|
+
process.execPath,
|
|
58
|
+
[resolve(root, 'scripts/build-diff-data.mjs'), ...feedArgs],
|
|
59
|
+
{
|
|
60
|
+
cwd: callerDirectory,
|
|
61
|
+
stdio: agentEnabled
|
|
62
|
+
? ['inherit', 'pipe', 'inherit']
|
|
63
|
+
: ['inherit', 'inherit', 'inherit'],
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
const site = spawn(
|
|
67
|
+
process.execPath,
|
|
68
|
+
[
|
|
69
|
+
resolve(root, 'scripts/serve-built.mjs'),
|
|
70
|
+
'--output',
|
|
71
|
+
outputPath,
|
|
72
|
+
'--port',
|
|
73
|
+
String(port),
|
|
74
|
+
],
|
|
75
|
+
{ cwd: root, stdio: 'inherit' },
|
|
76
|
+
);
|
|
77
|
+
let closing = false;
|
|
78
|
+
let agent;
|
|
79
|
+
let agentTimer;
|
|
80
|
+
let agentFingerprint;
|
|
81
|
+
let queuedFingerprint;
|
|
82
|
+
|
|
83
|
+
function snapshotState() {
|
|
84
|
+
try {
|
|
85
|
+
const snapshot = JSON.parse(readFileSync(outputPath, 'utf8'));
|
|
86
|
+
if (snapshot.notes?.reviewFingerprint) {
|
|
87
|
+
const fingerprint = snapshot.notes.reviewFingerprint;
|
|
88
|
+
return {
|
|
89
|
+
fingerprint,
|
|
90
|
+
hasCurrentAgentNotes:
|
|
91
|
+
snapshot.notes.complete &&
|
|
92
|
+
snapshot.notes.fresh &&
|
|
93
|
+
snapshot.notes.generatedFor === fingerprint,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const reviewData = {
|
|
97
|
+
repo: {
|
|
98
|
+
name: snapshot.repo.name,
|
|
99
|
+
base: snapshot.repo.base,
|
|
100
|
+
head: snapshot.repo.head,
|
|
101
|
+
branch: snapshot.repo.branch,
|
|
102
|
+
baseBranch: snapshot.repo.baseBranch,
|
|
103
|
+
remote: snapshot.repo.remote,
|
|
104
|
+
targetKind: snapshot.repo.target?.kind,
|
|
105
|
+
},
|
|
106
|
+
change: {
|
|
107
|
+
title: snapshot.change.title,
|
|
108
|
+
number: snapshot.change.number,
|
|
109
|
+
},
|
|
110
|
+
files: snapshot.files.map((file) => ({
|
|
111
|
+
path: file.path,
|
|
112
|
+
oldPath: file.oldPath,
|
|
113
|
+
status: file.status,
|
|
114
|
+
additions: file.additions,
|
|
115
|
+
deletions: file.deletions,
|
|
116
|
+
isBinary: file.isBinary,
|
|
117
|
+
patch: file.patch,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
fingerprint: createHash('sha256')
|
|
122
|
+
.update(JSON.stringify(reviewData))
|
|
123
|
+
.digest('hex'),
|
|
124
|
+
hasCurrentAgentNotes: false,
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function snapshotFingerprint() {
|
|
132
|
+
return snapshotState()?.fingerprint;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runAgent(fingerprint) {
|
|
136
|
+
if (closing || !agentEnabled) return;
|
|
137
|
+
if (agent) {
|
|
138
|
+
queuedFingerprint = fingerprint;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
agentFingerprint = fingerprint;
|
|
142
|
+
const child = spawn(
|
|
143
|
+
process.execPath,
|
|
144
|
+
[resolve(root, 'scripts/generate-summaries.mjs'), ...agentArgs],
|
|
145
|
+
{ cwd: callerDirectory, stdio: 'inherit' },
|
|
146
|
+
);
|
|
147
|
+
agent = child;
|
|
148
|
+
let settled = false;
|
|
149
|
+
const finish = (code, signal, error) => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
settled = true;
|
|
152
|
+
const finishedFingerprint = agentFingerprint;
|
|
153
|
+
if (agent === child) agent = undefined;
|
|
154
|
+
agentFingerprint = finishedFingerprint;
|
|
155
|
+
if (!closing && (error || code || signal)) {
|
|
156
|
+
if (error) console.error(error.message);
|
|
157
|
+
console.error(
|
|
158
|
+
'The coding agent could not write notes. The diff page will stay open.',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const latest = queuedFingerprint || snapshotFingerprint();
|
|
162
|
+
queuedFingerprint = undefined;
|
|
163
|
+
if (latest && latest !== finishedFingerprint) scheduleAgent(latest);
|
|
164
|
+
};
|
|
165
|
+
child.on('error', (error) => finish(1, undefined, error));
|
|
166
|
+
child.on('exit', (code, signal) => finish(code, signal));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scheduleAgent(fingerprint) {
|
|
170
|
+
const state = snapshotState();
|
|
171
|
+
const selectedFingerprint = fingerprint || state?.fingerprint;
|
|
172
|
+
if (
|
|
173
|
+
!agentFingerprint &&
|
|
174
|
+
state?.hasCurrentAgentNotes &&
|
|
175
|
+
selectedFingerprint === state.fingerprint
|
|
176
|
+
) {
|
|
177
|
+
agentFingerprint = selectedFingerprint;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!selectedFingerprint || selectedFingerprint === agentFingerprint) return;
|
|
181
|
+
if (agent) {
|
|
182
|
+
queuedFingerprint = selectedFingerprint;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
clearTimeout(agentTimer);
|
|
186
|
+
agentTimer = setTimeout(() => runAgent(selectedFingerprint), 2_500);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (agentEnabled && feed.stdout) {
|
|
190
|
+
const feedLines = createInterface({ input: feed.stdout });
|
|
191
|
+
feedLines.on('line', (line) => {
|
|
192
|
+
if (line.startsWith('Wrote ') || line === 'No diff-data changes') {
|
|
193
|
+
scheduleAgent();
|
|
194
|
+
}
|
|
195
|
+
if (line !== 'No diff-data changes') console.log(line);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function stop(code = 0) {
|
|
200
|
+
if (closing) return;
|
|
201
|
+
closing = true;
|
|
202
|
+
clearTimeout(agentTimer);
|
|
203
|
+
if (!feed.killed) feed.kill('SIGTERM');
|
|
204
|
+
if (!site.killed) site.kill('SIGTERM');
|
|
205
|
+
if (agent && !agent.killed) agent.kill('SIGTERM');
|
|
206
|
+
process.exitCode = code;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
feed.on('exit', (code, signal) => {
|
|
210
|
+
if (!closing && (code || signal)) stop(code || 1);
|
|
211
|
+
});
|
|
212
|
+
feed.on('error', (error) => {
|
|
213
|
+
if (!closing) {
|
|
214
|
+
console.error(`Could not start the diff watcher: ${error.message}`);
|
|
215
|
+
stop(1);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
site.on('exit', (code, signal) => {
|
|
219
|
+
if (!closing) stop(code || (signal ? 1 : 0));
|
|
220
|
+
});
|
|
221
|
+
site.on('error', (error) => {
|
|
222
|
+
if (!closing) {
|
|
223
|
+
console.error(`Could not start the local page: ${error.message}`);
|
|
224
|
+
stop(1);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
process.on('SIGINT', () => stop(0));
|
|
228
|
+
process.on('SIGTERM', () => stop(0));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { dirname, extname, resolve, sep } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const clientRoot = resolve(root, 'dist');
|
|
11
|
+
const rawArgs = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function option(name, fallback) {
|
|
14
|
+
const index = rawArgs.indexOf(name);
|
|
15
|
+
if (index === -1) return fallback;
|
|
16
|
+
const value = rawArgs[index + 1];
|
|
17
|
+
if (!value || value.startsWith('--')) {
|
|
18
|
+
throw new Error(`${name} needs a value`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const output = resolve(option('--output', resolve(root, '.cache/diff-data.json')));
|
|
24
|
+
const portValue = option('--port', '3000');
|
|
25
|
+
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
|
|
26
|
+
throw new Error('--port must be a number from 0 to 65535');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const contentTypes = {
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.html': 'text/html; charset=utf-8',
|
|
32
|
+
'.ico': 'image/x-icon',
|
|
33
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
34
|
+
'.json': 'application/json; charset=utf-8',
|
|
35
|
+
'.svg': 'image/svg+xml',
|
|
36
|
+
'.woff': 'font/woff',
|
|
37
|
+
'.woff2': 'font/woff2',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function notFound() {
|
|
41
|
+
return new Response('Not found', { status: 404 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fileResponse(file, { live = false } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const info = await stat(file);
|
|
47
|
+
if (!info.isFile()) return notFound();
|
|
48
|
+
return new Response(await readFile(file), {
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type':
|
|
51
|
+
contentTypes[extname(file).toLowerCase()] ||
|
|
52
|
+
'application/octet-stream',
|
|
53
|
+
'cache-control': live
|
|
54
|
+
? 'no-store'
|
|
55
|
+
: 'public, max-age=31536000, immutable',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
return notFound();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchAsset(request) {
|
|
64
|
+
const url = new URL(request.url);
|
|
65
|
+
if (url.pathname === '/diff-data.json') {
|
|
66
|
+
return fileResponse(output, { live: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let pathname;
|
|
70
|
+
try {
|
|
71
|
+
pathname = decodeURIComponent(url.pathname);
|
|
72
|
+
} catch {
|
|
73
|
+
return notFound();
|
|
74
|
+
}
|
|
75
|
+
const file = resolve(
|
|
76
|
+
clientRoot,
|
|
77
|
+
pathname === '/' ? 'index.html' : `.${pathname}`,
|
|
78
|
+
);
|
|
79
|
+
if (file !== clientRoot && !file.startsWith(`${clientRoot}${sep}`)) {
|
|
80
|
+
return notFound();
|
|
81
|
+
}
|
|
82
|
+
return fileResponse(file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nodeRequest(request) {
|
|
86
|
+
const host = request.headers.host || '127.0.0.1';
|
|
87
|
+
const init = {
|
|
88
|
+
method: request.method,
|
|
89
|
+
headers: request.headers,
|
|
90
|
+
};
|
|
91
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
92
|
+
init.body = Readable.toWeb(request);
|
|
93
|
+
init.duplex = 'half';
|
|
94
|
+
}
|
|
95
|
+
return new Request(`http://${host}${request.url}`, init);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function send(nodeResponse, response) {
|
|
99
|
+
nodeResponse.writeHead(response.status, Object.fromEntries(response.headers));
|
|
100
|
+
if (!response.body) {
|
|
101
|
+
nodeResponse.end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
Readable.fromWeb(response.body).pipe(nodeResponse);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const server = createServer(async (request, response) => {
|
|
108
|
+
try {
|
|
109
|
+
const webRequest = nodeRequest(request);
|
|
110
|
+
await send(response, await fetchAsset(webRequest));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(error);
|
|
113
|
+
response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
114
|
+
response.end('Diffsplain could not load this page.');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
server.listen(Number(portValue), '127.0.0.1', () => {
|
|
119
|
+
const address = server.address();
|
|
120
|
+
const selectedPort =
|
|
121
|
+
address && typeof address === 'object' ? address.port : Number(portValue);
|
|
122
|
+
console.log(`Diffsplain: http://127.0.0.1:${selectedPort}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let closing = false;
|
|
126
|
+
function close() {
|
|
127
|
+
if (closing) return;
|
|
128
|
+
closing = true;
|
|
129
|
+
server.close(() => {
|
|
130
|
+
process.exitCode = 0;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
process.on('SIGINT', close);
|
|
135
|
+
process.on('SIGTERM', close);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function summaryPath({
|
|
5
|
+
projectRoot,
|
|
6
|
+
callerDirectory,
|
|
7
|
+
repo,
|
|
8
|
+
explicit,
|
|
9
|
+
pr,
|
|
10
|
+
branch,
|
|
11
|
+
checkout = false,
|
|
12
|
+
base,
|
|
13
|
+
head,
|
|
14
|
+
remote = 'origin',
|
|
15
|
+
}) {
|
|
16
|
+
if (explicit) return resolve(callerDirectory, explicit);
|
|
17
|
+
if (!pr && !branch && !checkout && !(base && head)) {
|
|
18
|
+
return resolve(repo, '.diffsplain/summaries.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pullRequest =
|
|
22
|
+
pr?.match(/\/pull\/(\d+)(?:\/|$)/)?.[1] || pr || undefined;
|
|
23
|
+
const target = pr
|
|
24
|
+
? { kind: 'pr', pullRequest, remote }
|
|
25
|
+
: branch
|
|
26
|
+
? { kind: 'branch', branch, base: base || 'default', remote }
|
|
27
|
+
: checkout
|
|
28
|
+
? { kind: 'checkout', base: base || 'default', remote }
|
|
29
|
+
: { kind: 'range', base, head };
|
|
30
|
+
const key = createHash('sha256')
|
|
31
|
+
.update(JSON.stringify({ repo, target }))
|
|
32
|
+
.digest('hex')
|
|
33
|
+
.slice(0, 24);
|
|
34
|
+
return resolve(projectRoot, '.cache/summaries', `${target.kind}-${key}.json`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
todoDemoChange,
|
|
9
|
+
todoDemoFiles,
|
|
10
|
+
} from "../site/todo-demo.js";
|
|
11
|
+
|
|
12
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const generatedAt = "2026-07-29T06:30:00.000Z";
|
|
14
|
+
const fingerprint = createHash("sha256")
|
|
15
|
+
.update(JSON.stringify({ change: todoDemoChange, files: todoDemoFiles }))
|
|
16
|
+
.digest("hex");
|
|
17
|
+
|
|
18
|
+
const snapshot = {
|
|
19
|
+
version: fingerprint.slice(0, 12),
|
|
20
|
+
generatedAt,
|
|
21
|
+
repo: {
|
|
22
|
+
name: "todo-list-demo",
|
|
23
|
+
root: "/demo/todo-list",
|
|
24
|
+
base: "main",
|
|
25
|
+
head: "feature/todo-filters",
|
|
26
|
+
branch: "feature/todo-filters",
|
|
27
|
+
baseBranch: "main",
|
|
28
|
+
target: {
|
|
29
|
+
kind: "pull-request",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
change: todoDemoChange,
|
|
33
|
+
notes: {
|
|
34
|
+
reviewFingerprint: fingerprint,
|
|
35
|
+
generatedFor: fingerprint,
|
|
36
|
+
fresh: true,
|
|
37
|
+
complete: true,
|
|
38
|
+
},
|
|
39
|
+
files: todoDemoFiles,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const summaries = {
|
|
43
|
+
change: todoDemoChange,
|
|
44
|
+
files: Object.fromEntries(
|
|
45
|
+
todoDemoFiles.map((file) => [file.path, file.summary]),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
await Promise.all([
|
|
50
|
+
writeFile(
|
|
51
|
+
resolve(root, "public/demo-diff-data.json"),
|
|
52
|
+
`${JSON.stringify(snapshot, null, 2)}\n`,
|
|
53
|
+
),
|
|
54
|
+
writeFile(
|
|
55
|
+
resolve(root, "data/todo-demo-summaries.json"),
|
|
56
|
+
`${JSON.stringify(summaries, null, 2)}\n`,
|
|
57
|
+
),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
console.log(`Wrote todo demo with ${todoDemoFiles.length} files`);
|