svn-visualizer 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/.github/workflows/ci.yml +60 -0
- package/.gitignore +100 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.release-it.json +23 -0
- package/AGENTS.md +90 -0
- package/CHANGELOG.md +1 -0
- package/CONTRIBUTING.md +65 -0
- package/GENERATED.md +129 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/commitlint.config.js +5 -0
- package/dist/index.js +474 -0
- package/oxc.config.ts +198 -0
- package/oxfmt.config.ts +9 -0
- package/oxlint.config.ts +36 -0
- package/package.json +72 -0
- package/src/aggregation.test.ts +118 -0
- package/src/aggregation.ts +198 -0
- package/src/cli.test.ts +10 -0
- package/src/cli.ts +61 -0
- package/src/client/main.ts +117 -0
- package/src/gather.ts +93 -0
- package/src/index.ts +9 -0
- package/src/model.ts +32 -0
- package/src/report.test.ts +91 -0
- package/src/report.ts +88 -0
- package/src/store.test.ts +52 -0
- package/src/store.ts +48 -0
- package/src/svn-parser.test.ts +34 -0
- package/src/svn-parser.ts +78 -0
- package/tsconfig.json +43 -0
- package/vite.client.config.ts +15 -0
- package/vite.config.ts +26 -0
package/src/gather.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {access} from 'node:fs/promises';
|
|
3
|
+
import {z} from 'zod';
|
|
4
|
+
import {type Commit, type DataSet} from './model.js';
|
|
5
|
+
import {parseInfoRevision, parseInfoXml, parseLogXml} from './svn-parser.js';
|
|
6
|
+
import {assertSameSource, mergeCommits, readData, revisionCheckpoint, writeData} from './store.js';
|
|
7
|
+
|
|
8
|
+
const gatherOptionsSchema = z.object({
|
|
9
|
+
url: z.url(),
|
|
10
|
+
username: z.string().min(1).optional(),
|
|
11
|
+
passwordEnv: z.string().regex(/^[a-z_]\w*$/iu),
|
|
12
|
+
dataFile: z.string().min(1),
|
|
13
|
+
svnBinary: z.string().min(1),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type GatherOptions = z.input<typeof gatherOptionsSchema>;
|
|
17
|
+
|
|
18
|
+
type CommandResult = {readonly stdout: string; readonly stderr: string};
|
|
19
|
+
|
|
20
|
+
function runSvn(binary: string, arguments_: readonly string[], password?: string): Promise<CommandResult> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const child = spawn(binary, arguments_, {stdio: ['pipe', 'pipe', 'pipe']});
|
|
23
|
+
let stdout = '';
|
|
24
|
+
let stderr = '';
|
|
25
|
+
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
|
26
|
+
stdout += chunk;
|
|
27
|
+
});
|
|
28
|
+
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
|
29
|
+
stderr += chunk;
|
|
30
|
+
});
|
|
31
|
+
child.on('error', (error) => {
|
|
32
|
+
reject(new Error(`Unable to run ${binary}: ${error.message}`, {cause: error}));
|
|
33
|
+
});
|
|
34
|
+
child.on('close', (code) => {
|
|
35
|
+
if (code === 0) {
|
|
36
|
+
resolve({stdout, stderr});
|
|
37
|
+
} else {
|
|
38
|
+
reject(new Error(`${binary} exited with code ${String(code)}: ${stderr.trim()}`));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
child.stdin.end(password === undefined ? undefined : `${password}\n`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function authArguments(username: string | undefined, password: string | undefined): string[] {
|
|
46
|
+
const result = ['--non-interactive', '--trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other'];
|
|
47
|
+
if (username !== undefined) {
|
|
48
|
+
result.push('--username', username);
|
|
49
|
+
}
|
|
50
|
+
if (password !== undefined) {
|
|
51
|
+
result.push('--password-from-stdin', '--no-auth-cache');
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function existingData(filePath: string): Promise<DataSet | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
await access(filePath);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return readData(filePath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function gather(rawOptions: GatherOptions): Promise<{readonly added: number; readonly data: DataSet}> {
|
|
66
|
+
const options = gatherOptionsSchema.parse(rawOptions);
|
|
67
|
+
const password = process.env[options.passwordEnv];
|
|
68
|
+
const auth = authArguments(options.username, password);
|
|
69
|
+
const info = await runSvn(options.svnBinary, ['info', '--xml', ...auth, options.url], password);
|
|
70
|
+
const source = parseInfoXml(info.stdout, options.url);
|
|
71
|
+
const headRevision = parseInfoRevision(info.stdout);
|
|
72
|
+
const previous = await existingData(options.dataFile);
|
|
73
|
+
if (previous !== undefined) {
|
|
74
|
+
assertSameSource(previous.source, source);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lastRevision = previous?.lastRevision ?? 0;
|
|
78
|
+
let incoming: Commit[] = [];
|
|
79
|
+
if (lastRevision < headRevision) {
|
|
80
|
+
const log = await runSvn(options.svnBinary, ['log', '--xml', ...auth, '--revision', `${lastRevision + 1}:HEAD`, options.url], password);
|
|
81
|
+
incoming = parseLogXml(log.stdout);
|
|
82
|
+
}
|
|
83
|
+
const commits = mergeCommits(previous?.commits ?? [], incoming);
|
|
84
|
+
const data = {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
source,
|
|
87
|
+
lastRevision: revisionCheckpoint(lastRevision, headRevision),
|
|
88
|
+
gatheredAt: new Date().toISOString(),
|
|
89
|
+
commits,
|
|
90
|
+
} satisfies DataSet;
|
|
91
|
+
await writeData(options.dataFile, data);
|
|
92
|
+
return {added: incoming.length, data};
|
|
93
|
+
}
|
package/src/index.ts
ADDED
package/src/model.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {z} from 'zod';
|
|
2
|
+
|
|
3
|
+
export const commitSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
revision: z.number().int().positive(),
|
|
6
|
+
author: z.string().nullable(),
|
|
7
|
+
date: z.iso.datetime({offset: true}),
|
|
8
|
+
message: z.string(),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
|
|
12
|
+
export const sourceSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
requestedUrl: z.url(),
|
|
15
|
+
root: z.url(),
|
|
16
|
+
uuid: z.string().min(1),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
|
|
20
|
+
export const dataSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
schemaVersion: z.literal(1),
|
|
23
|
+
source: sourceSchema,
|
|
24
|
+
lastRevision: z.number().int().nonnegative(),
|
|
25
|
+
gatheredAt: z.iso.datetime({offset: true}),
|
|
26
|
+
commits: z.array(commitSchema),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
|
|
30
|
+
export type Commit = z.infer<typeof commitSchema>;
|
|
31
|
+
export type DataSet = z.infer<typeof dataSchema>;
|
|
32
|
+
export type Source = z.infer<typeof sourceSchema>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {mkdtemp, readFile} from 'node:fs/promises';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {describe, expect, it} from 'vitest';
|
|
5
|
+
import {type DataSet} from './model.js';
|
|
6
|
+
import {generate, renderHtml, safeJson} from './report.js';
|
|
7
|
+
import {writeData} from './store.js';
|
|
8
|
+
|
|
9
|
+
describe('HTML rendering', () => {
|
|
10
|
+
it('escapes markup and script payloads', () => {
|
|
11
|
+
const page = renderHtml(
|
|
12
|
+
{
|
|
13
|
+
title: '<img src=x onerror=alert(1)>',
|
|
14
|
+
sourceUrl: 'https://example.test/?x=<tag>&y=1',
|
|
15
|
+
generatedAt: '2026-01-01T00:00:00.000Z',
|
|
16
|
+
range: {from: '2026-01-01', to: '2026-01-01'},
|
|
17
|
+
total: 0,
|
|
18
|
+
users: {labels: ['</script><script>alert(1)</script>'], values: [1]},
|
|
19
|
+
weekdays: {labels: [], values: []},
|
|
20
|
+
hours: {labels: [], values: []},
|
|
21
|
+
days: {labels: [], values: []},
|
|
22
|
+
daysByUser: {labels: [], datasets: []},
|
|
23
|
+
months: {labels: [], values: []},
|
|
24
|
+
recent: [],
|
|
25
|
+
},
|
|
26
|
+
'console.log("</script>")',
|
|
27
|
+
);
|
|
28
|
+
expect(page).not.toContain('<img src=x');
|
|
29
|
+
expect(page).not.toContain('</script><script>alert');
|
|
30
|
+
expect(page).toContain('<img');
|
|
31
|
+
expect(page).toContain(String.raw`<\/script>`);
|
|
32
|
+
expect(page).toContain('<tbody id="commits">');
|
|
33
|
+
expect(safeJson({value: '\u2028<&'})).toBe(String.raw`{"value":"\u2028\u003c\u0026"}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('escapes commit messages embedded in the report data', () => {
|
|
37
|
+
const page = renderHtml(
|
|
38
|
+
{
|
|
39
|
+
title: 't',
|
|
40
|
+
sourceUrl: 'https://example.test/',
|
|
41
|
+
generatedAt: '2026-01-01T00:00:00.000Z',
|
|
42
|
+
range: {from: '2026-01-01', to: '2026-01-01'},
|
|
43
|
+
total: 1,
|
|
44
|
+
users: {labels: [], values: []},
|
|
45
|
+
weekdays: {labels: [], values: []},
|
|
46
|
+
hours: {labels: [], values: []},
|
|
47
|
+
days: {labels: [], values: []},
|
|
48
|
+
daysByUser: {labels: [], datasets: []},
|
|
49
|
+
months: {labels: [], values: []},
|
|
50
|
+
recent: [{revision: 1, author: 'ada', date: '2026-01-01T00:00:00.000Z', message: '<script>alert(1)</script>'}],
|
|
51
|
+
},
|
|
52
|
+
'',
|
|
53
|
+
);
|
|
54
|
+
expect(page).not.toContain('<script>alert(1)</script>');
|
|
55
|
+
expect(page).toContain(String.raw`\u003cscript\u003ealert(1)\u003c/script\u003e`);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('report generation', () => {
|
|
60
|
+
it('writes a self-contained HTML report from a data file', async () => {
|
|
61
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'svn-visualizer-'));
|
|
62
|
+
const dataFile = path.join(directory, 'data.json');
|
|
63
|
+
const data: DataSet = {
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
source: {requestedUrl: 'https://example.test/svn/project/trunk', root: 'https://example.test/svn/project', uuid: 'fixture'},
|
|
66
|
+
lastRevision: 2,
|
|
67
|
+
gatheredAt: '2026-01-03T00:00:00.000Z',
|
|
68
|
+
commits: [
|
|
69
|
+
{revision: 1, author: 'ada', date: '2026-01-01T12:00:00.000Z', message: 'Initial import'},
|
|
70
|
+
{revision: 2, author: 'lin', date: '2026-01-02T15:30:00.000Z', message: 'Ship report'},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
await writeData(dataFile, data);
|
|
74
|
+
|
|
75
|
+
const outputFile = await generate({dataFile, outputDir: path.join(directory, 'output'), title: 'Integration report'}, 'console.log("chart.js")');
|
|
76
|
+
|
|
77
|
+
expect(outputFile).toBe(path.join(directory, 'output', 'index.html'));
|
|
78
|
+
const html = await readFile(outputFile, 'utf8');
|
|
79
|
+
for (const expected of [
|
|
80
|
+
'Integration report',
|
|
81
|
+
'2</strong>',
|
|
82
|
+
'https://example.test/svn/project/trunk',
|
|
83
|
+
'console.log("chart.js")',
|
|
84
|
+
'Recent commits',
|
|
85
|
+
'Ship report',
|
|
86
|
+
]) {
|
|
87
|
+
expect(html).toContain(expected);
|
|
88
|
+
}
|
|
89
|
+
expect(/src=["']https?:/u.test(html)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {mkdir, readFile, writeFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {z} from 'zod';
|
|
5
|
+
import {aggregate, resolveRange, type ReportData} from './aggregation.js';
|
|
6
|
+
import {readData} from './store.js';
|
|
7
|
+
|
|
8
|
+
const generateOptionsSchema = z.object({
|
|
9
|
+
dataFile: z.string().min(1),
|
|
10
|
+
outputDir: z.string().min(1),
|
|
11
|
+
from: z.string().optional(),
|
|
12
|
+
to: z.string().optional(),
|
|
13
|
+
relativeDays: z.number().int().positive().optional(),
|
|
14
|
+
title: z.string().min(1).default('Subversion activity'),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type GenerateOptions = z.input<typeof generateOptionsSchema>;
|
|
18
|
+
|
|
19
|
+
type PageData = ReportData & {
|
|
20
|
+
readonly title: string;
|
|
21
|
+
readonly sourceUrl: string;
|
|
22
|
+
readonly generatedAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function escapeHtml(value: string): string {
|
|
26
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function safeJson(value: unknown): string {
|
|
30
|
+
return JSON.stringify(value)
|
|
31
|
+
.replaceAll('&', String.raw`\u0026`)
|
|
32
|
+
.replaceAll('<', String.raw`\u003c`)
|
|
33
|
+
.replaceAll('>', String.raw`\u003e`)
|
|
34
|
+
.replaceAll('\u2028', String.raw`\u2028`)
|
|
35
|
+
.replaceAll('\u2029', String.raw`\u2029`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderHtml(data: PageData, clientScript: string): string {
|
|
39
|
+
const title = escapeHtml(data.title);
|
|
40
|
+
const source = escapeHtml(data.sourceUrl);
|
|
41
|
+
return `<!doctype html>
|
|
42
|
+
<html lang="en">
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
46
|
+
<title>${title}</title>
|
|
47
|
+
<style>
|
|
48
|
+
:root{color-scheme:dark;--ink:#f4efe4;--muted:#aaa69d;--panel:#18201f;--line:#34413e;--accent:#f3b33d;--cool:#6dc8bf}*{box-sizing:border-box}body{margin:0;background:#0c1110;color:var(--ink);font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:radial-gradient(circle at 80% 0,#24423b 0,transparent 38%),linear-gradient(120deg,transparent 0 48%,#ffffff05 48% 49%,transparent 49%);z-index:-1}.wrap{width:min(1180px,calc(100% - 32px));margin:auto;padding:56px 0 72px}header{border-left:5px solid var(--accent);padding-left:24px;margin-bottom:36px}h1{font-family:Georgia,serif;font-size:clamp(2.25rem,6vw,5rem);font-weight:500;line-height:.95;letter-spacing:-.045em;margin:0 0 20px}.eyebrow{color:var(--accent);font-size:.75rem;font-weight:800;letter-spacing:.18em;text-transform:uppercase}.meta{display:flex;flex-wrap:wrap;gap:8px 24px;color:var(--muted);font-size:.875rem}.meta a{color:var(--cool);overflow-wrap:anywhere}.total{display:grid;grid-template-columns:auto 1fr;align-items:end;gap:18px;margin:28px 0}.total strong{font:500 clamp(4rem,14vw,9rem)/.8 Georgia,serif;color:var(--accent)}.total span{max-width:12rem;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-weight:700}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.card{min-width:0;background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:4px;padding:22px;box-shadow:0 16px 42px #0003}.card.wide{grid-column:1/-1}.card h2{font:500 1.25rem Georgia,serif;margin:0 0 18px}.chart{position:relative;height:280px}.wide .chart{height:330px}table.commits{width:100%;border-collapse:collapse;font-size:.875rem}table.commits th,table.commits td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}table.commits th{color:var(--muted);font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}table.commits td.rev{color:var(--accent);font-variant-numeric:tabular-nums;white-space:nowrap}table.commits td.date{white-space:nowrap;color:var(--muted)}table.commits .msg{overflow-wrap:anywhere}table.commits tbody tr:last-child td{border-bottom:none}footer{color:var(--muted);font-size:.75rem;margin-top:24px;text-align:right}@media(max-width:720px){.wrap{padding-top:32px}.grid{grid-template-columns:1fr}.card.wide{grid-column:auto}.chart,.wide .chart{height:260px}}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<main class="wrap">
|
|
53
|
+
<header><div class="eyebrow">Repository pulse / UTC</div><h1>${title}</h1><div class="meta"><span>${escapeHtml(data.range.from)} to ${escapeHtml(data.range.to)}</span><a href="${source}">${source}</a></div></header>
|
|
54
|
+
<section class="total" aria-label="Commit total"><strong>${String(data.total)}</strong><span>commits in selected period</span></section>
|
|
55
|
+
<section class="grid">
|
|
56
|
+
<article class="card wide"><h2>Last 30 days</h2><div class="chart"><canvas id="days" role="img" aria-label="Commits per day">Chart: commits per day.</canvas></div></article>
|
|
57
|
+
<article class="card wide"><h2>Commits per day and user</h2><div class="chart"><canvas id="days-by-user" role="img" aria-label="Commits per day and user">Chart: commits per day and user.</canvas></div></article>
|
|
58
|
+
<article class="card wide"><h2>Current and previous 11 months</h2><div class="chart"><canvas id="months" role="img" aria-label="Commits per month">Chart: commits per month.</canvas></div></article>
|
|
59
|
+
<article class="card"><h2>Contributors</h2><div class="chart"><canvas id="users" role="img" aria-label="Commits by contributor">Chart: commits by contributor.</canvas></div></article>
|
|
60
|
+
<article class="card"><h2>Weekday</h2><div class="chart"><canvas id="weekdays" role="img" aria-label="Commits by weekday in UTC">Chart: commits by weekday in UTC.</canvas></div></article>
|
|
61
|
+
<article class="card wide"><h2>Hour of day (UTC)</h2><div class="chart"><canvas id="hours" role="img" aria-label="Commits by hour in UTC">Chart: commits by hour in UTC.</canvas></div></article>
|
|
62
|
+
<article class="card wide"><h2>Recent commits</h2><table class="commits"><thead><tr><th>Revision</th><th>Author</th><th>Date (UTC)</th><th>Message</th></tr></thead><tbody id="commits"></tbody></table></article>
|
|
63
|
+
</section>
|
|
64
|
+
<footer>Generated ${escapeHtml(data.generatedAt)} · All dates and times UTC</footer>
|
|
65
|
+
</main>
|
|
66
|
+
<script id="report-data" type="application/json">${safeJson(data)}</script>
|
|
67
|
+
<script type="module">${clientScript.replaceAll('</script', String.raw`<\/script`)}</script>
|
|
68
|
+
</body>
|
|
69
|
+
</html>\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function generate(rawOptions: GenerateOptions, clientScript?: string): Promise<string> {
|
|
73
|
+
const options = generateOptionsSchema.parse(rawOptions);
|
|
74
|
+
const data = await readData(options.dataFile);
|
|
75
|
+
const range = resolveRange(data.commits, options);
|
|
76
|
+
const reportData: PageData = {
|
|
77
|
+
...aggregate(data.commits, range),
|
|
78
|
+
title: options.title,
|
|
79
|
+
sourceUrl: data.source.requestedUrl,
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
const clientUrl = new URL('client/main.js', import.meta.url);
|
|
83
|
+
const script = clientScript ?? (await readFile(fileURLToPath(clientUrl), 'utf8'));
|
|
84
|
+
const outputFile = path.resolve(options.outputDir, 'index.html');
|
|
85
|
+
await mkdir(path.dirname(outputFile), {recursive: true});
|
|
86
|
+
await writeFile(outputFile, renderHtml(reportData, script), 'utf8');
|
|
87
|
+
return outputFile;
|
|
88
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {mkdtemp, readFile, writeFile} from 'node:fs/promises';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {describe, expect, it} from 'vitest';
|
|
5
|
+
import {type Commit, type DataSet, type Source} from './model.js';
|
|
6
|
+
import {assertSameSource, mergeCommits, readData, revisionCheckpoint, writeData} from './store.js';
|
|
7
|
+
|
|
8
|
+
const source: Source = {requestedUrl: 'https://example.test/svn/trunk', root: 'https://example.test/svn', uuid: 'uuid'};
|
|
9
|
+
const commits: Commit[] = [
|
|
10
|
+
{revision: 2, author: 'ada', date: '2026-01-02T00:00:00.000Z', message: 'two'},
|
|
11
|
+
{revision: 1, author: null, date: '2026-01-01T00:00:00.000Z', message: 'one'},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('data store', () => {
|
|
15
|
+
it('round-trips validated data atomically', async () => {
|
|
16
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'svn-visualizer-'));
|
|
17
|
+
const file = path.join(directory, 'nested', 'data.json');
|
|
18
|
+
const data: DataSet = {schemaVersion: 1, source, lastRevision: 2, gatheredAt: '2026-01-03T00:00:00.000Z', commits};
|
|
19
|
+
await writeData(file, data);
|
|
20
|
+
await expect(readData(file)).resolves.toStrictEqual(data);
|
|
21
|
+
await expect(readFile(file, 'utf8')).resolves.toContain('\n\t"source"');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects invalid external state', async () => {
|
|
25
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'svn-visualizer-'));
|
|
26
|
+
const file = path.join(directory, 'data.json');
|
|
27
|
+
await writeFile(file, '{"schemaVersion":2}', 'utf8');
|
|
28
|
+
await expect(readData(file)).rejects.toThrow('Invalid data file');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects source mismatches', () => {
|
|
32
|
+
expect(() => {
|
|
33
|
+
assertSameSource(source, {...source, uuid: 'other'});
|
|
34
|
+
}).toThrow('does not match');
|
|
35
|
+
expect(() => {
|
|
36
|
+
assertSameSource(source, source);
|
|
37
|
+
}).not.toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('sorts, deduplicates, and checkpoints revisions', () => {
|
|
41
|
+
const [original] = commits;
|
|
42
|
+
if (original === undefined) {
|
|
43
|
+
throw new Error('Test fixture is missing a commit');
|
|
44
|
+
}
|
|
45
|
+
const replacement = {...original, message: 'updated'};
|
|
46
|
+
const merged = mergeCommits(commits, [replacement, {revision: 3, author: 'lin', date: '2026-01-03T00:00:00.000Z', message: 'three'}]);
|
|
47
|
+
expect(merged.map((commit) => commit.revision)).toStrictEqual([1, 2, 3]);
|
|
48
|
+
expect(merged[1]?.message).toBe('updated');
|
|
49
|
+
expect(revisionCheckpoint(0, 3)).toBe(3);
|
|
50
|
+
expect(revisionCheckpoint(9, 0)).toBe(9);
|
|
51
|
+
});
|
|
52
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {mkdir, readFile, rename, rm, writeFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {dataSchema, type Commit, type DataSet, type Source} from './model.js';
|
|
4
|
+
|
|
5
|
+
export async function readData(filePath: string): Promise<DataSet> {
|
|
6
|
+
let content: string;
|
|
7
|
+
try {
|
|
8
|
+
content = await readFile(filePath, 'utf8');
|
|
9
|
+
} catch (error: unknown) {
|
|
10
|
+
throw new Error(`Unable to read data file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, {cause: error});
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return dataSchema.parse(JSON.parse(content) as unknown);
|
|
14
|
+
} catch (error: unknown) {
|
|
15
|
+
throw new Error(`Invalid data file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, {cause: error});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function assertSameSource(existing: Source, current: Source): void {
|
|
20
|
+
if (existing.requestedUrl !== current.requestedUrl || existing.root !== current.root || existing.uuid !== current.uuid) {
|
|
21
|
+
throw new Error('Data file source does not match the requested SVN repository');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mergeCommits(existing: readonly Commit[], incoming: readonly Commit[]): Commit[] {
|
|
26
|
+
const byRevision = new Map<number, Commit>();
|
|
27
|
+
for (const commit of [...existing, ...incoming]) {
|
|
28
|
+
byRevision.set(commit.revision, commit);
|
|
29
|
+
}
|
|
30
|
+
return [...byRevision.values()].sort((left, right) => left.revision - right.revision);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function revisionCheckpoint(previous: number, repositoryHead: number): number {
|
|
34
|
+
return Math.max(previous, repositoryHead);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function writeData(filePath: string, data: DataSet): Promise<void> {
|
|
38
|
+
const validated = dataSchema.parse(data);
|
|
39
|
+
const directory = path.dirname(path.resolve(filePath));
|
|
40
|
+
await mkdir(directory, {recursive: true});
|
|
41
|
+
const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(temporary, `${JSON.stringify(validated, null, '\t')}\n`, {encoding: 'utf8', mode: 0o600});
|
|
44
|
+
await rename(temporary, path.resolve(filePath));
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(temporary, {force: true});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
import {parseInfoRevision, parseInfoXml, parseLogXml} from './svn-parser.js';
|
|
3
|
+
|
|
4
|
+
describe('SVN XML parser', () => {
|
|
5
|
+
it('parses entities and a singleton log entry', () => {
|
|
6
|
+
const commits = parseLogXml(
|
|
7
|
+
`<?xml version="1.0"?><log><logentry revision="7"><author>A & B</author><date>2026-02-03T04:05:06.000Z</date><msg>Fix <thing></msg></logentry></log>`,
|
|
8
|
+
);
|
|
9
|
+
expect(commits).toStrictEqual([{revision: 7, author: 'A & B', date: '2026-02-03T04:05:06.000Z', message: 'Fix <thing>'}]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('handles empty and missing optional text nodes', () => {
|
|
13
|
+
expect(parseLogXml('<?xml version="1.0"?><log></log>')).toStrictEqual([]);
|
|
14
|
+
expect(parseLogXml('<log><logentry revision="1"><date>2026-01-01T00:00:00Z</date><msg/></logentry></log>')).toStrictEqual([
|
|
15
|
+
{revision: 1, author: null, date: '2026-01-01T00:00:00Z', message: ''},
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects malformed XML', () => {
|
|
20
|
+
expect(() => parseLogXml('<log>')).toThrow(/Invalid SVN XML:/u);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('parses repository identity', () => {
|
|
24
|
+
const source = parseInfoXml(
|
|
25
|
+
'<info><entry revision="0"><repository><root>https://example.test/svn/root</root><uuid>abc-123</uuid></repository></entry></info>',
|
|
26
|
+
'https://example.test/svn/root/trunk',
|
|
27
|
+
);
|
|
28
|
+
expect(source.uuid).toBe('abc-123');
|
|
29
|
+
expect(source.requestedUrl).toContain('/trunk');
|
|
30
|
+
expect(
|
|
31
|
+
parseInfoRevision('<info><entry revision="0"><repository><root>https://example.test/svn/root</root><uuid>abc-123</uuid></repository></entry></info>'),
|
|
32
|
+
).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {SyntaxValidator} from 'fast-xml-validator';
|
|
3
|
+
import {z} from 'zod';
|
|
4
|
+
import {commitSchema, sourceSchema, type Commit, type Source} from './model.js';
|
|
5
|
+
|
|
6
|
+
const parser = new XMLParser({
|
|
7
|
+
ignoreAttributes: false,
|
|
8
|
+
attributeNamePrefix: '@_',
|
|
9
|
+
parseTagValue: false,
|
|
10
|
+
trimValues: false,
|
|
11
|
+
isArray: (_name, path): boolean => path === 'log.logentry',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const infoXmlSchema = z.object({
|
|
15
|
+
info: z.object({
|
|
16
|
+
entry: z.object({
|
|
17
|
+
'@_revision': z.coerce.number().int().nonnegative(),
|
|
18
|
+
repository: z.object({root: z.string().min(1), uuid: z.string().min(1)}),
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const logContentsSchema = z.object({
|
|
24
|
+
logentry: z
|
|
25
|
+
.array(
|
|
26
|
+
z.object({
|
|
27
|
+
'@_revision': z.coerce.number().int().positive(),
|
|
28
|
+
author: z.union([z.string(), z.record(z.string(), z.never())]).optional(),
|
|
29
|
+
date: z.string(),
|
|
30
|
+
msg: z.union([z.string(), z.record(z.string(), z.never())]).optional(),
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
.default([]),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const logXmlSchema = z.object({log: z.union([logContentsSchema, z.literal('')])});
|
|
37
|
+
|
|
38
|
+
function parseXml(xml: string): unknown {
|
|
39
|
+
try {
|
|
40
|
+
SyntaxValidator.validate(xml);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
throw new Error(`Invalid SVN XML: ${message}`, {cause: error});
|
|
44
|
+
}
|
|
45
|
+
return parser.parse(xml) as unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function text(value: string | Record<string, never> | undefined): string {
|
|
49
|
+
return typeof value === 'string' ? value : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseInfoXml(xml: string, requestedUrl: string): Source {
|
|
53
|
+
const parsed = infoXmlSchema.parse(parseXml(xml));
|
|
54
|
+
return sourceSchema.parse({
|
|
55
|
+
requestedUrl,
|
|
56
|
+
root: parsed.info.entry.repository.root,
|
|
57
|
+
uuid: parsed.info.entry.repository.uuid,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseInfoRevision(xml: string): number {
|
|
62
|
+
return infoXmlSchema.parse(parseXml(xml)).info.entry['@_revision'];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseLogXml(xml: string): Commit[] {
|
|
66
|
+
const parsed = logXmlSchema.parse(parseXml(xml));
|
|
67
|
+
if (parsed.log === '') {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return parsed.log.logentry.map((entry) =>
|
|
71
|
+
commitSchema.parse({
|
|
72
|
+
revision: entry['@_revision'],
|
|
73
|
+
author: entry.author === undefined ? null : text(entry.author),
|
|
74
|
+
date: entry.date,
|
|
75
|
+
message: text(entry.msg),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
/* Language and Environment */
|
|
4
|
+
"target": "ES2023" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
|
5
|
+
"lib": ["ES2023", "DOM"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
|
6
|
+
"module": "ESNext" /* Specify what module code is generated. */,
|
|
7
|
+
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
|
8
|
+
"types": ["node"],
|
|
9
|
+
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. */,
|
|
10
|
+
"resolveJsonModule": true /* Enable importing .json files. */,
|
|
11
|
+
"allowImportingTsExtensions": /* Allow imports to include TypeScript file extensions. */ true,
|
|
12
|
+
"noEmit": true /* Disable emitting files from a compilation. */,
|
|
13
|
+
|
|
14
|
+
/* Strict Type-Checking Options */
|
|
15
|
+
"strict": true /* Enable all strict type-checking options. */,
|
|
16
|
+
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
|
|
17
|
+
"strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */,
|
|
18
|
+
"strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */,
|
|
19
|
+
"strictBindCallApply": true /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */,
|
|
20
|
+
"strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */,
|
|
21
|
+
"noImplicitThis": true /* Enable error reporting when 'this' is given the type 'any'. */,
|
|
22
|
+
"useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */,
|
|
23
|
+
"alwaysStrict": true /* Ensure 'use strict' is always emitted. */,
|
|
24
|
+
"noUnusedLocals": true /* Enable error reporting when local variables aren't read. */,
|
|
25
|
+
"noUnusedParameters": true /* Raise an error when a function parameter isn't read. */,
|
|
26
|
+
"exactOptionalPropertyTypes": true /* Interpret optional property types as strictly typed, preventing assignment of 'undefined'. */,
|
|
27
|
+
"noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
|
|
28
|
+
"noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
|
|
29
|
+
"noUncheckedIndexedAccess": true /* Add 'undefined' to a type when accessed using an index. */,
|
|
30
|
+
"noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */,
|
|
31
|
+
"noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type. */,
|
|
32
|
+
"allowUnusedLabels": false /* Disable error reporting for unused labels. */,
|
|
33
|
+
"allowUnreachableCode": false /* Disable error reporting for unreachable code. */,
|
|
34
|
+
|
|
35
|
+
/* Emit */
|
|
36
|
+
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
|
37
|
+
|
|
38
|
+
/* Completeness */
|
|
39
|
+
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
|
40
|
+
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */
|
|
41
|
+
},
|
|
42
|
+
"include": ["src/**/*"]
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {defineConfig} from 'vite';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
build: {
|
|
6
|
+
lib: {
|
|
7
|
+
entry: path.resolve(import.meta.dirname, 'src/client/main.ts'),
|
|
8
|
+
formats: ['es'],
|
|
9
|
+
fileName: 'main',
|
|
10
|
+
},
|
|
11
|
+
outDir: 'dist/client',
|
|
12
|
+
emptyOutDir: true,
|
|
13
|
+
target: 'es2022',
|
|
14
|
+
},
|
|
15
|
+
});
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {defineConfig} from 'vitest/config';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import packageJson from './package.json' with {type: 'json'};
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
define: {
|
|
7
|
+
APP_VERSION: JSON.stringify(packageJson.version),
|
|
8
|
+
},
|
|
9
|
+
build: {
|
|
10
|
+
ssr: true,
|
|
11
|
+
lib: {
|
|
12
|
+
entry: path.resolve(import.meta.dirname, 'src/index.ts'),
|
|
13
|
+
formats: ['es'],
|
|
14
|
+
fileName: 'index',
|
|
15
|
+
},
|
|
16
|
+
outDir: 'dist',
|
|
17
|
+
emptyOutDir: false,
|
|
18
|
+
target: 'node22',
|
|
19
|
+
},
|
|
20
|
+
test: {
|
|
21
|
+
coverage: {
|
|
22
|
+
provider: 'v8',
|
|
23
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|