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/dist/index.js ADDED
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import { spawn } from "node:child_process";
4
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { z } from "zod";
6
+ import { XMLParser } from "fast-xml-parser";
7
+ import { SyntaxValidator } from "fast-xml-validator";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ //#region src/model.ts
11
+ var commitSchema = z.object({
12
+ revision: z.number().int().positive(),
13
+ author: z.string().nullable(),
14
+ date: z.iso.datetime({ offset: true }),
15
+ message: z.string()
16
+ }).strict();
17
+ var sourceSchema = z.object({
18
+ requestedUrl: z.url(),
19
+ root: z.url(),
20
+ uuid: z.string().min(1)
21
+ }).strict();
22
+ var dataSchema = z.object({
23
+ schemaVersion: z.literal(1),
24
+ source: sourceSchema,
25
+ lastRevision: z.number().int().nonnegative(),
26
+ gatheredAt: z.iso.datetime({ offset: true }),
27
+ commits: z.array(commitSchema)
28
+ }).strict();
29
+ //#endregion
30
+ //#region src/svn-parser.ts
31
+ var parser = new XMLParser({
32
+ ignoreAttributes: false,
33
+ attributeNamePrefix: "@_",
34
+ parseTagValue: false,
35
+ trimValues: false,
36
+ isArray: (_name, path) => path === "log.logentry"
37
+ });
38
+ var infoXmlSchema = z.object({ info: z.object({ entry: z.object({
39
+ "@_revision": z.coerce.number().int().nonnegative(),
40
+ repository: z.object({
41
+ root: z.string().min(1),
42
+ uuid: z.string().min(1)
43
+ })
44
+ }) }) });
45
+ var logContentsSchema = z.object({ logentry: z.array(z.object({
46
+ "@_revision": z.coerce.number().int().positive(),
47
+ author: z.union([z.string(), z.record(z.string(), z.never())]).optional(),
48
+ date: z.string(),
49
+ msg: z.union([z.string(), z.record(z.string(), z.never())]).optional()
50
+ })).default([]) });
51
+ var logXmlSchema = z.object({ log: z.union([logContentsSchema, z.literal("")]) });
52
+ function parseXml(xml) {
53
+ try {
54
+ SyntaxValidator.validate(xml);
55
+ } catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ throw new Error(`Invalid SVN XML: ${message}`, { cause: error });
58
+ }
59
+ return parser.parse(xml);
60
+ }
61
+ function text(value) {
62
+ return typeof value === "string" ? value : "";
63
+ }
64
+ function parseInfoXml(xml, requestedUrl) {
65
+ const parsed = infoXmlSchema.parse(parseXml(xml));
66
+ return sourceSchema.parse({
67
+ requestedUrl,
68
+ root: parsed.info.entry.repository.root,
69
+ uuid: parsed.info.entry.repository.uuid
70
+ });
71
+ }
72
+ function parseInfoRevision(xml) {
73
+ return infoXmlSchema.parse(parseXml(xml)).info.entry["@_revision"];
74
+ }
75
+ function parseLogXml(xml) {
76
+ const parsed = logXmlSchema.parse(parseXml(xml));
77
+ if (parsed.log === "") return [];
78
+ return parsed.log.logentry.map((entry) => commitSchema.parse({
79
+ revision: entry["@_revision"],
80
+ author: entry.author === void 0 ? null : text(entry.author),
81
+ date: entry.date,
82
+ message: text(entry.msg)
83
+ }));
84
+ }
85
+ //#endregion
86
+ //#region src/store.ts
87
+ async function readData(filePath) {
88
+ let content;
89
+ try {
90
+ content = await readFile(filePath, "utf8");
91
+ } catch (error) {
92
+ throw new Error(`Unable to read data file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
93
+ }
94
+ try {
95
+ return dataSchema.parse(JSON.parse(content));
96
+ } catch (error) {
97
+ throw new Error(`Invalid data file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
98
+ }
99
+ }
100
+ function assertSameSource(existing, current) {
101
+ if (existing.requestedUrl !== current.requestedUrl || existing.root !== current.root || existing.uuid !== current.uuid) throw new Error("Data file source does not match the requested SVN repository");
102
+ }
103
+ function mergeCommits(existing, incoming) {
104
+ const byRevision = /* @__PURE__ */ new Map();
105
+ for (const commit of [...existing, ...incoming]) byRevision.set(commit.revision, commit);
106
+ return [...byRevision.values()].sort((left, right) => left.revision - right.revision);
107
+ }
108
+ function revisionCheckpoint(previous, repositoryHead) {
109
+ return Math.max(previous, repositoryHead);
110
+ }
111
+ async function writeData(filePath, data) {
112
+ const validated = dataSchema.parse(data);
113
+ const directory = path.dirname(path.resolve(filePath));
114
+ await mkdir(directory, { recursive: true });
115
+ const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
116
+ try {
117
+ await writeFile(temporary, `${JSON.stringify(validated, null, " ")}\n`, {
118
+ encoding: "utf8",
119
+ mode: 384
120
+ });
121
+ await rename(temporary, path.resolve(filePath));
122
+ } finally {
123
+ await rm(temporary, { force: true });
124
+ }
125
+ }
126
+ //#endregion
127
+ //#region src/gather.ts
128
+ var gatherOptionsSchema = z.object({
129
+ url: z.url(),
130
+ username: z.string().min(1).optional(),
131
+ passwordEnv: z.string().regex(/^[a-z_]\w*$/iu),
132
+ dataFile: z.string().min(1),
133
+ svnBinary: z.string().min(1)
134
+ });
135
+ function runSvn(binary, arguments_, password) {
136
+ return new Promise((resolve, reject) => {
137
+ const child = spawn(binary, arguments_, { stdio: [
138
+ "pipe",
139
+ "pipe",
140
+ "pipe"
141
+ ] });
142
+ let stdout = "";
143
+ let stderr = "";
144
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
145
+ stdout += chunk;
146
+ });
147
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
148
+ stderr += chunk;
149
+ });
150
+ child.on("error", (error) => {
151
+ reject(new Error(`Unable to run ${binary}: ${error.message}`, { cause: error }));
152
+ });
153
+ child.on("close", (code) => {
154
+ if (code === 0) resolve({
155
+ stdout,
156
+ stderr
157
+ });
158
+ else reject(/* @__PURE__ */ new Error(`${binary} exited with code ${String(code)}: ${stderr.trim()}`));
159
+ });
160
+ child.stdin.end(password === void 0 ? void 0 : `${password}\n`);
161
+ });
162
+ }
163
+ function authArguments(username, password) {
164
+ const result = ["--non-interactive", "--trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other"];
165
+ if (username !== void 0) result.push("--username", username);
166
+ if (password !== void 0) result.push("--password-from-stdin", "--no-auth-cache");
167
+ return result;
168
+ }
169
+ async function existingData(filePath) {
170
+ try {
171
+ await access(filePath);
172
+ } catch {
173
+ return;
174
+ }
175
+ return readData(filePath);
176
+ }
177
+ async function gather(rawOptions) {
178
+ const options = gatherOptionsSchema.parse(rawOptions);
179
+ const password = process.env[options.passwordEnv];
180
+ const auth = authArguments(options.username, password);
181
+ const info = await runSvn(options.svnBinary, [
182
+ "info",
183
+ "--xml",
184
+ ...auth,
185
+ options.url
186
+ ], password);
187
+ const source = parseInfoXml(info.stdout, options.url);
188
+ const headRevision = parseInfoRevision(info.stdout);
189
+ const previous = await existingData(options.dataFile);
190
+ if (previous !== void 0) assertSameSource(previous.source, source);
191
+ const lastRevision = previous?.lastRevision ?? 0;
192
+ let incoming = [];
193
+ if (lastRevision < headRevision) incoming = parseLogXml((await runSvn(options.svnBinary, [
194
+ "log",
195
+ "--xml",
196
+ ...auth,
197
+ "--revision",
198
+ `${lastRevision + 1}:HEAD`,
199
+ options.url
200
+ ], password)).stdout);
201
+ const commits = mergeCommits(previous?.commits ?? [], incoming);
202
+ const data = {
203
+ schemaVersion: 1,
204
+ source,
205
+ lastRevision: revisionCheckpoint(lastRevision, headRevision),
206
+ gatheredAt: (/* @__PURE__ */ new Date()).toISOString(),
207
+ commits
208
+ };
209
+ await writeData(options.dataFile, data);
210
+ return {
211
+ added: incoming.length,
212
+ data
213
+ };
214
+ }
215
+ //#endregion
216
+ //#region src/aggregation.ts
217
+ var DAY_MS = 864e5;
218
+ var dateTextSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected YYYY-MM-DD");
219
+ var OTHER_CONTRIBUTORS_LABEL = "(others)";
220
+ function parseDateText(value) {
221
+ dateTextSchema.parse(value);
222
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
223
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw new Error(`Invalid UTC date: ${value}`);
224
+ return date;
225
+ }
226
+ function dateText(date) {
227
+ return date.toISOString().slice(0, 10);
228
+ }
229
+ function addDays(value, amount) {
230
+ const date = parseDateText(value);
231
+ return dateText(new Date(date.getTime() + amount * DAY_MS));
232
+ }
233
+ function datasetBounds(commits, today) {
234
+ if (commits.length === 0) return {
235
+ from: today,
236
+ to: today
237
+ };
238
+ const dates = commits.map((commit) => commit.date.slice(0, 10)).sort();
239
+ const [first] = dates;
240
+ const last = dates.at(-1);
241
+ if (first === void 0 || last === void 0) return {
242
+ from: today,
243
+ to: today
244
+ };
245
+ return {
246
+ from: first,
247
+ to: last
248
+ };
249
+ }
250
+ function resolveRange(commits, options, now = /* @__PURE__ */ new Date()) {
251
+ const today = dateText(now);
252
+ parseDateText(today);
253
+ if (options.relativeDays !== void 0) {
254
+ if (options.from !== void 0 || options.to !== void 0) throw new Error("--from/--to cannot be used with --relative-days");
255
+ if (!Number.isInteger(options.relativeDays) || options.relativeDays <= 0) throw new Error("--relative-days must be a positive integer");
256
+ return {
257
+ from: addDays(today, 1 - options.relativeDays),
258
+ to: today
259
+ };
260
+ }
261
+ const bounds = datasetBounds(commits, today);
262
+ const from = options.from ?? bounds.from;
263
+ const to = options.to ?? bounds.to;
264
+ parseDateText(from);
265
+ parseDateText(to);
266
+ if (from > to) throw new Error(`Report start date ${from} is after end date ${to}`);
267
+ return {
268
+ from,
269
+ to
270
+ };
271
+ }
272
+ function countSeries(labels, keys) {
273
+ const counts = new Map(labels.map((label) => [label, 0]));
274
+ for (const key of keys) if (counts.has(key)) counts.set(key, (counts.get(key) ?? 0) + 1);
275
+ return {
276
+ labels: [...labels],
277
+ values: labels.map((label) => counts.get(label) ?? 0)
278
+ };
279
+ }
280
+ function countStackedSeries(labels, buckets, entries) {
281
+ const bucketValues = new Map(buckets.map((bucket) => [bucket, Array.from({ length: labels.length }, () => 0)]));
282
+ const indexByLabel = new Map(labels.map((label, index) => [label, index]));
283
+ for (const { key, bucket } of entries) {
284
+ const values = bucketValues.get(bucket);
285
+ const index = indexByLabel.get(key);
286
+ if (values === void 0 || index === void 0) continue;
287
+ values[index] = (values[index] ?? 0) + 1;
288
+ }
289
+ return {
290
+ labels: [...labels],
291
+ datasets: buckets.map((bucket) => ({
292
+ label: bucket,
293
+ values: [...bucketValues.get(bucket) ?? []]
294
+ }))
295
+ };
296
+ }
297
+ function monthLabels(end) {
298
+ const endDate = parseDateText(end);
299
+ const result = [];
300
+ for (let offset = 11; offset >= 0; offset--) result.push(dateText(new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - offset, 1))).slice(0, 7));
301
+ return result;
302
+ }
303
+ function aggregate(commits, range) {
304
+ parseDateText(range.from);
305
+ parseDateText(range.to);
306
+ if (range.from > range.to) throw new Error("Report date range is reversed");
307
+ const selected = commits.filter((commit) => {
308
+ const date = commit.date.slice(0, 10);
309
+ return date >= range.from && date <= range.to;
310
+ });
311
+ const userCounts = /* @__PURE__ */ new Map();
312
+ for (const commit of selected) {
313
+ const author = commit.author === null || commit.author === "" ? "(no author)" : commit.author;
314
+ userCounts.set(author, (userCounts.get(author) ?? 0) + 1);
315
+ }
316
+ const ranked = [...userCounts.entries()].sort(([leftName, leftCount], [rightName, rightCount]) => rightCount - leftCount || leftName.localeCompare(rightName));
317
+ let userLabels;
318
+ let userValues;
319
+ const bucketOf = /* @__PURE__ */ new Map();
320
+ if (ranked.length > 10) {
321
+ const top = ranked.slice(0, 10);
322
+ const others = ranked.slice(10).reduce((sum, [, count]) => sum + count, 0);
323
+ userLabels = [...top.map(([name]) => name), OTHER_CONTRIBUTORS_LABEL];
324
+ userValues = [...top.map(([, count]) => count), others];
325
+ for (const [name] of top) bucketOf.set(name, name);
326
+ for (const [name] of ranked.slice(10)) bucketOf.set(name, OTHER_CONTRIBUTORS_LABEL);
327
+ } else {
328
+ userLabels = ranked.map(([name]) => name);
329
+ userValues = ranked.map(([, count]) => count);
330
+ for (const [name] of ranked) bucketOf.set(name, name);
331
+ }
332
+ const weekdayLabels = [
333
+ "Monday",
334
+ "Tuesday",
335
+ "Wednesday",
336
+ "Thursday",
337
+ "Friday",
338
+ "Saturday",
339
+ "Sunday"
340
+ ];
341
+ const hourLabels = Array.from({ length: 24 }, (_, hour) => hour.toString().padStart(2, "0"));
342
+ const dayLabels = Array.from({ length: 30 }, (_, index) => addDays(range.to, index - 29));
343
+ const months = monthLabels(range.to);
344
+ const recent = [...selected].sort((left, right) => right.revision - left.revision).slice(0, 20);
345
+ const daysByUser = countStackedSeries(dayLabels, userLabels, selected.map((commit) => {
346
+ const author = commit.author === null || commit.author === "" ? "(no author)" : commit.author;
347
+ return {
348
+ key: commit.date.slice(0, 10),
349
+ bucket: bucketOf.get(author) ?? "(others)"
350
+ };
351
+ }));
352
+ return {
353
+ range,
354
+ total: selected.length,
355
+ users: {
356
+ labels: userLabels,
357
+ values: userValues
358
+ },
359
+ weekdays: countSeries(weekdayLabels, selected.map((commit) => weekdayLabels[(new Date(commit.date).getUTCDay() + 6) % 7] ?? "Monday")),
360
+ hours: countSeries(hourLabels, selected.map((commit) => new Date(commit.date).getUTCHours().toString().padStart(2, "0"))),
361
+ days: countSeries(dayLabels, selected.map((commit) => commit.date.slice(0, 10))),
362
+ daysByUser,
363
+ months: countSeries(months, selected.map((commit) => commit.date.slice(0, 7))),
364
+ recent
365
+ };
366
+ }
367
+ //#endregion
368
+ //#region src/report.ts
369
+ var generateOptionsSchema = z.object({
370
+ dataFile: z.string().min(1),
371
+ outputDir: z.string().min(1),
372
+ from: z.string().optional(),
373
+ to: z.string().optional(),
374
+ relativeDays: z.number().int().positive().optional(),
375
+ title: z.string().min(1).default("Subversion activity")
376
+ });
377
+ function escapeHtml(value) {
378
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
379
+ }
380
+ function safeJson(value) {
381
+ return JSON.stringify(value).replaceAll("&", String.raw`\u0026`).replaceAll("<", String.raw`\u003c`).replaceAll(">", String.raw`\u003e`).replaceAll("\u2028", String.raw`\u2028`).replaceAll("\u2029", String.raw`\u2029`);
382
+ }
383
+ function renderHtml(data, clientScript) {
384
+ const title = escapeHtml(data.title);
385
+ const source = escapeHtml(data.sourceUrl);
386
+ return `<!doctype html>
387
+ <html lang="en">
388
+ <head>
389
+ <meta charset="utf-8">
390
+ <meta name="viewport" content="width=device-width,initial-scale=1">
391
+ <title>${title}</title>
392
+ <style>
393
+ :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}}
394
+ </style>
395
+ </head>
396
+ <body>
397
+ <main class="wrap">
398
+ <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>
399
+ <section class="total" aria-label="Commit total"><strong>${String(data.total)}</strong><span>commits in selected period</span></section>
400
+ <section class="grid">
401
+ <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>
402
+ <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>
403
+ <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>
404
+ <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>
405
+ <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>
406
+ <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>
407
+ <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>
408
+ </section>
409
+ <footer>Generated ${escapeHtml(data.generatedAt)} · All dates and times UTC</footer>
410
+ </main>
411
+ <script id="report-data" type="application/json">${safeJson(data)}<\/script>
412
+ <script type="module">${clientScript.replaceAll("<\/script", String.raw`<\/script`)}<\/script>
413
+ </body>
414
+ </html>\n`;
415
+ }
416
+ async function generate(rawOptions, clientScript) {
417
+ const options = generateOptionsSchema.parse(rawOptions);
418
+ const data = await readData(options.dataFile);
419
+ const range = resolveRange(data.commits, options);
420
+ const reportData = {
421
+ ...aggregate(data.commits, range),
422
+ title: options.title,
423
+ sourceUrl: data.source.requestedUrl,
424
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
425
+ };
426
+ const clientUrl = new URL("client/main.js", import.meta.url);
427
+ const script = clientScript ?? await readFile(fileURLToPath(clientUrl), "utf8");
428
+ const outputFile = path.resolve(options.outputDir, "index.html");
429
+ await mkdir(path.dirname(outputFile), { recursive: true });
430
+ await writeFile(outputFile, renderHtml(reportData, script), "utf8");
431
+ return outputFile;
432
+ }
433
+ //#endregion
434
+ //#region src/cli.ts
435
+ function positiveInteger(value) {
436
+ const parsed = Number(value);
437
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Expected a positive integer");
438
+ return parsed;
439
+ }
440
+ function gatherOptions(command) {
441
+ return command.requiredOption("--url <url>", "SVN repository URL").option("--username <username>", "SVN username").option("--password-env <name>", "environment variable containing the SVN password", "SVN_PASSWORD").option("--data-file <path>", "JSON state file", "svn-data.json").option("--svn-binary <path>", "SVN executable", "svn");
442
+ }
443
+ function generateOptions(command, includeDataFile = true) {
444
+ if (includeDataFile) command.option("--data-file <path>", "JSON state file", "svn-data.json");
445
+ return command.option("--output-dir <path>", "report output directory", "output").option("--from <date>", "first UTC date (YYYY-MM-DD)").option("--to <date>", "last UTC date (YYYY-MM-DD)").addOption(new Option("--relative-days <days>", "rolling number of UTC days").argParser(positiveInteger)).option("--title <title>", "report title", "Subversion activity");
446
+ }
447
+ function createProgram() {
448
+ const program = new Command().name("svn-visualizer").description("Generate standalone HTML activity reports from Subversion history").version("0.1.0");
449
+ gatherOptions(program.command("gather").description("Incrementally gather SVN history")).action(async (options) => {
450
+ const result = await gather(options);
451
+ console.log(result.added === 0 ? "No new commits." : `Gathered ${String(result.added)} new commit(s).`);
452
+ });
453
+ generateOptions(program.command("generate").description("Generate a standalone HTML report")).action(async (options) => {
454
+ const output = await generate(options);
455
+ console.log(`Generated ${output}`);
456
+ });
457
+ generateOptions(gatherOptions(program.command("report").description("Gather history and generate a report")), false).action(async (options) => {
458
+ const result = await gather(options);
459
+ console.log(result.added === 0 ? "No new commits." : `Gathered ${String(result.added)} new commit(s).`);
460
+ const output = await generate(options);
461
+ console.log(`Generated ${output}`);
462
+ });
463
+ return program;
464
+ }
465
+ //#endregion
466
+ //#region src/index.ts
467
+ try {
468
+ await createProgram().parseAsync();
469
+ } catch (error) {
470
+ console.error(error instanceof Error ? error.message : String(error));
471
+ process.exitCode = 1;
472
+ }
473
+ //#endregion
474
+ export {};
package/oxc.config.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * DO NOT EDIT THIS FILE — IT IS MANAGED AND WILL BE OVERWRITTEN ON UPDATES.
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * This file is the source of truth for the default `oxlint` and `oxfmt`
7
+ * configuration. It is automatically regenerated when the template is
8
+ * updated — any manual changes will be lost.
9
+ *
10
+ * To customize linting rules, edit `oxlint.config.ts`.
11
+ * To customize formatting settings, edit `oxfmt.config.ts`.
12
+ *
13
+ * Those two files are preserved across template updates and are designed
14
+ * to receive user overrides.
15
+ */
16
+
17
+ import {defineConfig} from 'oxlint';
18
+ import {configs as regexpConfigs} from 'eslint-plugin-regexp';
19
+
20
+ /** Filter out core ESLint rules bundled into eslint-plugin-regexp recommended config */
21
+ const regexpPluginRules = Object.fromEntries(Object.entries(regexpConfigs.recommended.rules).filter(([key]) => key.startsWith('regexp/')));
22
+
23
+ const commonIgnore = ['**/.*', 'node_modules/**', 'dist/**', 'build/**', 'coverage/**', 'temp/**', 'public/**', '**/*.md'];
24
+
25
+ export const linter = defineConfig({
26
+ options: {
27
+ typeAware: true,
28
+ typeCheck: true,
29
+ },
30
+ plugins: ['unicorn', 'typescript', 'oxc', 'import', 'react', 'jsdoc', 'promise', 'vitest'],
31
+ jsPlugins: ['eslint-plugin-regexp'],
32
+ categories: {
33
+ correctness: 'error',
34
+ nursery: 'error',
35
+ pedantic: 'error',
36
+ perf: 'error',
37
+ restriction: 'error',
38
+ style: 'error',
39
+ suspicious: 'error',
40
+ },
41
+ rules: {
42
+ ...regexpPluginRules,
43
+ 'eslint/capitalized-comments': 'off', // TODO: consider enabling
44
+ 'eslint/complexity': 'off', // TODO: consider enabling
45
+ 'eslint/curly': ['error', 'all'],
46
+ 'eslint/id-length': 'off',
47
+ 'eslint/init-declarations': 'off', // TODO: consider enabling
48
+ 'eslint/max-depth': 'off', // TODO: consider enabling
49
+ 'eslint/max-lines': 'off', // TODO: consider enabling
50
+ 'eslint/max-lines-per-function': 'off', // TODO: consider enabling
51
+ 'eslint/max-params': 'off', // TODO: consider enabling
52
+ 'eslint/max-statements': 'off', // TODO: consider enabling
53
+ 'eslint/no-await-in-loop': 'warn',
54
+ 'eslint/no-console': 'off',
55
+ 'eslint/no-continue': 'off',
56
+ 'eslint/no-inline-comments': 'off',
57
+ 'eslint/no-magic-numbers': 'off',
58
+ 'eslint/no-negated-condition': 'off', // TODO: consider enabling
59
+ 'eslint/no-nested-ternary': 'off',
60
+ 'eslint/no-warning-comments': 'off',
61
+ 'eslint/no-undefined': 'off', // TODO: consider enabling
62
+ 'eslint/no-plusplus': 'off',
63
+ 'eslint/one-var': 'off',
64
+ 'eslint/sort-imports': 'off',
65
+ 'eslint/sort-keys': 'off',
66
+ 'eslint/no-ternary': 'off',
67
+ 'eslint/no-void': ['error', {allowAsStatement: true}],
68
+ 'typescript/consistent-type-definitions': ['error', 'type'],
69
+ 'typescript/dot-notation': ['error', {allowPattern: '^[a-zA-Z]+(_[a-zA-Z]+)+$'}],
70
+ 'typescript/no-import-type-side-effects': 'off',
71
+ 'typescript/no-unused-vars': [
72
+ 'error',
73
+ {
74
+ caughtErrors: 'none',
75
+ argsIgnorePattern: '^_',
76
+ },
77
+ ],
78
+ 'typescript/prefer-readonly-parameter-types': 'off',
79
+ 'import/consistent-type-specifier-style': ['error', 'prefer-inline'],
80
+ 'import/exports-last': 'off',
81
+ 'import/group-exports': 'off',
82
+ 'import/max-dependencies': 'off',
83
+ 'import/no-named-export': 'off',
84
+ 'import/no-namespace': 'off', // TODO: consider enabling
85
+ 'import/no-nodejs-modules': 'off',
86
+ 'import/no-unassigned-import': ['error', {allow: ['**/*.css']}],
87
+ 'import/prefer-default-export': 'off',
88
+ 'import/no-default-export': 'off',
89
+ 'jsdoc/require-param': 'error',
90
+ 'jsdoc/require-param-type': 'off',
91
+ 'jsdoc/require-returns': 'warn',
92
+ 'jsdoc/require-returns-type': 'off',
93
+ 'oxc/no-async-await': 'off',
94
+ 'oxc/no-map-spread': 'off', // TODO: consider enabling
95
+ 'oxc/no-optional-chaining': 'off',
96
+ 'oxc/no-rest-spread-properties': 'off',
97
+ 'unicorn/escape-case': 'off',
98
+ 'unicorn/filename-case': 'off', // TODO: consider enabling
99
+ 'unicorn/max-nested-calls': ['warn', {max: 5}],
100
+ 'unicorn/no-array-reduce': 'off', // TODO: consider enabling
101
+ 'unicorn/no-array-sort': 'off', // TODO: consider enabling
102
+ 'unicorn/no-hex-escape': 'off',
103
+ 'unicorn/no-immediate-mutation': 'off',
104
+ 'unicorn/no-negated-condition': 'off',
105
+ 'unicorn/no-nested-ternary': 'off',
106
+ 'unicorn/no-null': 'off', // TODO: consider enabling
107
+ 'unicorn/no-process-exit': 'off', // TODO: consider enabling
108
+ 'unicorn/no-typeof-undefined': 'off', // TODO: consider enabling
109
+ 'unicorn/prefer-module': 'off', // TODO: consider enabling
110
+ 'unicorn/prefer-number-coercion': 'off', // TODO: consider enabling
111
+ 'react/function-component-definition': 'off', // TODO: consider enabling
112
+ 'react/jsx-filename-extension': ['error', {extensions: ['.tsx']}],
113
+ 'react/jsx-max-depth': ['error', {max: 5}],
114
+ 'react/jsx-no-literals': 'off',
115
+ 'react/react-in-jsx-scope': 'off',
116
+ 'vitest/max-expects': 'off',
117
+ 'vitest/no-conditional-in-test': 'off',
118
+ 'vitest/no-hooks': 'off',
119
+ 'vitest/no-importing-vitest-globals': 'off',
120
+ 'vitest/prefer-describe-function-title': 'off',
121
+ 'vitest/prefer-expect-assertions': 'off',
122
+ 'vitest/prefer-lowercase-title': 'off',
123
+ 'vitest/prefer-to-be-falsy': 'off', // NOTE: Pick strictness: keep prefer-strict-boolean-matchers, disable truthy/falsy rules.
124
+ 'vitest/prefer-to-be-truthy': 'off', // NOTE: Pick strictness: keep prefer-strict-boolean-matchers, disable truthy/falsy rules.
125
+ 'vitest/require-hook': 'off',
126
+ 'vitest/require-test-timeout': 'off',
127
+ },
128
+ overrides: [
129
+ {
130
+ // Relax strict type rules for unit tests to allow easier mocking and test scaffolding
131
+ files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'],
132
+ rules: {
133
+ 'typescript/no-unsafe-type-assertion': 'off',
134
+ 'typescript/no-explicit-any': 'off',
135
+ 'typescript/no-unsafe-assignment': 'off',
136
+ 'typescript/no-unsafe-member-access': 'off',
137
+ 'no-useless-undefined': 'off',
138
+ },
139
+ },
140
+ {
141
+ files: ['tests/e2e/**/*.e2e-test.ts', '**/*.e2e-test.ts'],
142
+ rules: {
143
+ 'vitest/prefer-importing-vitest-globals': 'off',
144
+ },
145
+ },
146
+ ],
147
+ settings: {
148
+ 'jsx-a11y': {
149
+ polymorphicPropName: undefined,
150
+ components: {},
151
+ attributes: {},
152
+ },
153
+ next: {
154
+ rootDir: [],
155
+ },
156
+ react: {
157
+ formComponents: [],
158
+ linkComponents: [],
159
+ version: undefined,
160
+ },
161
+ jsdoc: {
162
+ ignorePrivate: false,
163
+ ignoreInternal: false,
164
+ ignoreReplacesDocs: true,
165
+ overrideReplacesDocs: true,
166
+ augmentsExtendsReplacesDocs: false,
167
+ implementsReplacesDocs: false,
168
+ exemptDestructuredRootsFromChecks: false,
169
+ tagNamePreference: {},
170
+ },
171
+ vitest: {
172
+ typecheck: false,
173
+ },
174
+ },
175
+ env: {
176
+ builtin: true,
177
+ node: true,
178
+ },
179
+ globals: {},
180
+ ignorePatterns: commonIgnore,
181
+ });
182
+
183
+ export const formatter = {
184
+ printWidth: 160,
185
+ embeddedLanguageFormatting: 'off',
186
+ useTabs: true,
187
+ singleQuote: true,
188
+ bracketSpacing: false,
189
+ ignorePatterns: commonIgnore,
190
+ overrides: [
191
+ {
192
+ files: ['src/**/*.{scss,css}'],
193
+ options: {
194
+ singleQuote: false,
195
+ },
196
+ },
197
+ ],
198
+ };
@@ -0,0 +1,9 @@
1
+ import {formatter as defaults} from './oxc.config.ts';
2
+
3
+ // Add custom oxfmt formatter overrides here.
4
+ // This file is preserved on template updates.
5
+ const formatter: Partial<typeof defaults> = {};
6
+
7
+ const config = {...defaults, ...formatter};
8
+
9
+ export default config;