@devds1989/t-rush 1.0.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/package.json +39 -0
- package/src/commands/history.ts +107 -0
- package/src/commands/start.ts +145 -0
- package/src/commands/stats.ts +109 -0
- package/src/editor.ts +65 -0
- package/src/index.ts +47 -0
- package/src/scanner.ts +136 -0
- package/src/store.ts +66 -0
- package/src/streak.ts +32 -0
- package/src/timer.ts +33 -0
- package/src/types/types.ts +70 -0
- package/src/ui.ts +124 -0
- package/src/validator.ts +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devesh Chandra Srivastava
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devds1989/t-rush",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Speedrun your TODOs, FIXMEs, and BUGs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"trush": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"todo",
|
|
17
|
+
"fixme",
|
|
18
|
+
"cli",
|
|
19
|
+
"productivity",
|
|
20
|
+
"speedrun",
|
|
21
|
+
"developer-tools"
|
|
22
|
+
],
|
|
23
|
+
"author": "Devesh",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@inquirer/confirm": "^5.1.9",
|
|
27
|
+
"@inquirer/search": "^4.1.9",
|
|
28
|
+
"chalk": "^5.6.2",
|
|
29
|
+
"commander": "^14.0.3",
|
|
30
|
+
"fast-glob": "^3.3.3",
|
|
31
|
+
"ignore": "^7.0.5",
|
|
32
|
+
"nanospinner": "^1.2.2"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^25.8.0",
|
|
36
|
+
"tsx": "^4.22.1",
|
|
37
|
+
"typescript": "^6.0.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadData } from "../store.js";
|
|
3
|
+
import { badge } from "../ui.js";
|
|
4
|
+
import type { RunRecord } from "../types/types.js";
|
|
5
|
+
|
|
6
|
+
function formatDate(iso: string): string {
|
|
7
|
+
const d = new Date(iso);
|
|
8
|
+
return d.toLocaleDateString("en-GB", {
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
month: "short",
|
|
11
|
+
year: "numeric",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatDuration(seconds: number): string {
|
|
16
|
+
const m = Math.floor(seconds / 60);
|
|
17
|
+
const s = seconds % 60;
|
|
18
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
19
|
+
return `${s}s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function statusIcon(completed: boolean): string {
|
|
23
|
+
return completed ? chalk.green("✔") : chalk.red("✘");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printRow(run: RunRecord): void {
|
|
27
|
+
const date = chalk.dim(formatDate(run.startedAt));
|
|
28
|
+
const status = statusIcon(run.completed);
|
|
29
|
+
const type = badge(run.type);
|
|
30
|
+
const file = chalk.dim(run.file + ":" + run.line);
|
|
31
|
+
const text = chalk.white(
|
|
32
|
+
run.text.length > 40 ? run.text.slice(0, 40) + "…" : run.text,
|
|
33
|
+
);
|
|
34
|
+
const duration = chalk.cyan(formatDuration(run.duration));
|
|
35
|
+
const repo = chalk.gray(`[${run.repo}]`);
|
|
36
|
+
|
|
37
|
+
console.log(` ${status} ${date} ${type} ${duration} ${file}`);
|
|
38
|
+
console.log(` ${text} ${repo}`);
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type HistoryOptions = {
|
|
43
|
+
limit?: number;
|
|
44
|
+
type?: string;
|
|
45
|
+
repo?: string;
|
|
46
|
+
completed?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function filterRuns(runs: RunRecord[], opts: HistoryOptions): RunRecord[] {
|
|
50
|
+
let filtered = [...runs].reverse(); // most recent first
|
|
51
|
+
|
|
52
|
+
if (opts.type) {
|
|
53
|
+
const t = opts.type.toUpperCase();
|
|
54
|
+
filtered = filtered.filter((r) => r.type === t);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (opts.repo) {
|
|
58
|
+
filtered = filtered.filter((r) => r.repo.includes(opts.repo!));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (opts.completed !== undefined) {
|
|
62
|
+
filtered = filtered.filter((r) => r.completed === opts.completed);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (opts.limit) {
|
|
66
|
+
filtered = filtered.slice(0, opts.limit);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return filtered;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function history(opts: HistoryOptions = {}): void {
|
|
73
|
+
const data = loadData();
|
|
74
|
+
|
|
75
|
+
if (data.runs.length === 0) {
|
|
76
|
+
console.log();
|
|
77
|
+
console.log(chalk.gray(" No runs yet. Start one with: trush start"));
|
|
78
|
+
console.log();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const runs = filterRuns(data.runs, { limit: 20, ...opts });
|
|
83
|
+
|
|
84
|
+
if (runs.length === 0) {
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.gray(" No runs match that filter."));
|
|
87
|
+
console.log();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const width = 50;
|
|
92
|
+
const line = chalk.gray("─".repeat(width));
|
|
93
|
+
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.white.bold(" Run History"));
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.gray(` showing ${runs.length} of ${data.runs.length} total`),
|
|
98
|
+
);
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(line);
|
|
101
|
+
console.log();
|
|
102
|
+
|
|
103
|
+
runs.forEach(printRow);
|
|
104
|
+
|
|
105
|
+
console.log(line);
|
|
106
|
+
console.log();
|
|
107
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createSpinner } from "nanospinner";
|
|
2
|
+
import search from "@inquirer/search";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
import { scanRepo } from "../scanner.js";
|
|
7
|
+
import { openEditor } from "../editor.js";
|
|
8
|
+
import { validateTodo } from "../validator.js";
|
|
9
|
+
import { createTimer } from "../timer.js";
|
|
10
|
+
import { addRun, generateId, getRepoName } from "../store.js";
|
|
11
|
+
import { incrementStreak, resetStreak, getStats } from "../streak.js";
|
|
12
|
+
import {
|
|
13
|
+
badge,
|
|
14
|
+
formatTodoChoice,
|
|
15
|
+
formatValidation,
|
|
16
|
+
printResult,
|
|
17
|
+
printNoTodos,
|
|
18
|
+
warn,
|
|
19
|
+
} from "../ui.js";
|
|
20
|
+
import type { TodoItem } from "../types/types.js";
|
|
21
|
+
|
|
22
|
+
async function pickTodo(items: TodoItem[]): Promise<TodoItem> {
|
|
23
|
+
const answer = await search<TodoItem>({
|
|
24
|
+
message: "Pick a TODO, FIXME, or BUG to fix",
|
|
25
|
+
source: async (input) => {
|
|
26
|
+
const term = (input ?? "").toLowerCase();
|
|
27
|
+
return items
|
|
28
|
+
.filter(
|
|
29
|
+
(item) =>
|
|
30
|
+
item.text.toLowerCase().includes(term) ||
|
|
31
|
+
item.file.toLowerCase().includes(term) ||
|
|
32
|
+
item.type.toLowerCase().includes(term),
|
|
33
|
+
)
|
|
34
|
+
.map((item) => ({
|
|
35
|
+
name: formatTodoChoice(item),
|
|
36
|
+
value: item,
|
|
37
|
+
}));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return answer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function confirmComplete(): Promise<boolean> {
|
|
45
|
+
const confirm = (await import("@inquirer/confirm")).default;
|
|
46
|
+
return confirm({ message: "Did you complete it?" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function confirmAnyway(): Promise<boolean> {
|
|
50
|
+
const confirm = (await import("@inquirer/confirm")).default;
|
|
51
|
+
return confirm({
|
|
52
|
+
message: chalk.yellow("TODO still present. Mark as complete anyway?"),
|
|
53
|
+
default: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function start(cwd: string = process.cwd()): Promise<void> {
|
|
58
|
+
// 1. scan
|
|
59
|
+
const scanSpinner = createSpinner("Scanning repo...").start();
|
|
60
|
+
const items = await scanRepo(cwd);
|
|
61
|
+
|
|
62
|
+
if (items.length === 0) {
|
|
63
|
+
scanSpinner.success({ text: "Scan complete" });
|
|
64
|
+
printNoTodos();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
scanSpinner.success({
|
|
69
|
+
text: `Found ${chalk.white.bold(items.length)} items`,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 2. pick
|
|
73
|
+
const picked = await pickTodo(items);
|
|
74
|
+
const filePath = path.resolve(cwd, picked.file);
|
|
75
|
+
|
|
76
|
+
console.log();
|
|
77
|
+
console.log(
|
|
78
|
+
` ${badge(picked.type)} ${chalk.dim(picked.file)}${chalk.dim(":") + chalk.cyan(picked.line)}`,
|
|
79
|
+
);
|
|
80
|
+
console.log(` ${chalk.gray('"' + picked.text + '"')}`);
|
|
81
|
+
console.log();
|
|
82
|
+
|
|
83
|
+
// 3. open editor + timer
|
|
84
|
+
const timerSpinner = createSpinner(
|
|
85
|
+
"Timer running — close editor to stop",
|
|
86
|
+
).start();
|
|
87
|
+
const timer = createTimer();
|
|
88
|
+
openEditor(filePath, picked.line);
|
|
89
|
+
const timerResult = timer.stop();
|
|
90
|
+
timerSpinner.success({ text: chalk.white(timerResult.formatted) });
|
|
91
|
+
|
|
92
|
+
// 4. validate
|
|
93
|
+
const validateSpinner = createSpinner("Validating...").start();
|
|
94
|
+
await new Promise((res) => setTimeout(res, 600)); // brief pause so it feels intentional
|
|
95
|
+
const validation = validateTodo(
|
|
96
|
+
filePath,
|
|
97
|
+
picked.line,
|
|
98
|
+
picked.rawLine,
|
|
99
|
+
picked.type,
|
|
100
|
+
);
|
|
101
|
+
validateSpinner.stop();
|
|
102
|
+
|
|
103
|
+
console.log(` ${formatValidation(validation)}`);
|
|
104
|
+
console.log();
|
|
105
|
+
|
|
106
|
+
// 5. confirm
|
|
107
|
+
let completed = false;
|
|
108
|
+
|
|
109
|
+
if (validation.status === "done") {
|
|
110
|
+
completed = await confirmComplete();
|
|
111
|
+
} else if (validation.status === "unchanged") {
|
|
112
|
+
console.log(` ${warn("Still seeing the " + picked.type)}`);
|
|
113
|
+
completed = await confirmAnyway();
|
|
114
|
+
} else if (validation.status === "missing") {
|
|
115
|
+
completed = await confirmComplete();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 6. update streak + stats
|
|
119
|
+
const streak = completed ? incrementStreak(picked.type) : resetStreak();
|
|
120
|
+
|
|
121
|
+
const stats = getStats();
|
|
122
|
+
|
|
123
|
+
// 7. save run
|
|
124
|
+
addRun({
|
|
125
|
+
id: generateId(),
|
|
126
|
+
repo: getRepoName(cwd),
|
|
127
|
+
file: picked.file,
|
|
128
|
+
line: picked.line,
|
|
129
|
+
text: picked.text,
|
|
130
|
+
type: picked.type,
|
|
131
|
+
startedAt: timerResult.startedAt,
|
|
132
|
+
finishedAt: timerResult.finishedAt,
|
|
133
|
+
duration: timerResult.duration,
|
|
134
|
+
completed,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 8. result card
|
|
138
|
+
printResult({
|
|
139
|
+
item: picked,
|
|
140
|
+
duration: timerResult.formatted,
|
|
141
|
+
streak,
|
|
142
|
+
stats,
|
|
143
|
+
completed,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadData } from "../store.js";
|
|
3
|
+
import { getStreak, getStats } from "../streak.js";
|
|
4
|
+
import { CommentType } from "../types/types.js";
|
|
5
|
+
import type { RunRecord } from "../types/types.js";
|
|
6
|
+
|
|
7
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function avgDuration(runs: RunRecord[]): string {
|
|
10
|
+
const completed = runs.filter((r) => r.completed && r.duration > 0);
|
|
11
|
+
if (completed.length === 0) return "N/A";
|
|
12
|
+
|
|
13
|
+
const avg = Math.floor(
|
|
14
|
+
completed.reduce((acc, r) => acc + r.duration, 0) / completed.length,
|
|
15
|
+
);
|
|
16
|
+
const m = Math.floor(avg / 60);
|
|
17
|
+
const s = avg % 60;
|
|
18
|
+
|
|
19
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
20
|
+
return `${s}s`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fastestRun(runs: RunRecord[]): string {
|
|
24
|
+
const completed = runs.filter((r) => r.completed && r.duration > 0);
|
|
25
|
+
if (completed.length === 0) return "N/A";
|
|
26
|
+
|
|
27
|
+
const fastest = completed.reduce((a, b) => (a.duration < b.duration ? a : b));
|
|
28
|
+
const m = Math.floor(fastest.duration / 60);
|
|
29
|
+
const s = fastest.duration % 60;
|
|
30
|
+
|
|
31
|
+
const time = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
32
|
+
return `${time} ${chalk.dim(`(${fastest.file}:${fastest.line})`)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function completionRate(runs: RunRecord[]): string {
|
|
36
|
+
if (runs.length === 0) return "N/A";
|
|
37
|
+
const rate = Math.round(
|
|
38
|
+
(runs.filter((r) => r.completed).length / runs.length) * 100,
|
|
39
|
+
);
|
|
40
|
+
const color =
|
|
41
|
+
rate >= 80 ? chalk.green : rate >= 50 ? chalk.yellow : chalk.red;
|
|
42
|
+
return color(`${rate}%`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function topRepo(runs: RunRecord[]): string {
|
|
46
|
+
if (runs.length === 0) return "N/A";
|
|
47
|
+
|
|
48
|
+
const counts: Record<string, number> = {};
|
|
49
|
+
runs.forEach((r) => {
|
|
50
|
+
counts[r.repo] = (counts[r.repo] ?? 0) + 1;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
54
|
+
return `${chalk.white(top[0])} ${chalk.dim(`(${top[1]} runs)`)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function row(label: string, value: string): void {
|
|
58
|
+
const padded = label.padEnd(20, " ");
|
|
59
|
+
console.log(` ${chalk.dim(padded)} ${value}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function stats(): void {
|
|
65
|
+
const data = loadData();
|
|
66
|
+
const streak = getStreak();
|
|
67
|
+
const s = getStats();
|
|
68
|
+
const runs = data.runs;
|
|
69
|
+
|
|
70
|
+
const width = 50;
|
|
71
|
+
const line = chalk.gray("─".repeat(width));
|
|
72
|
+
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(chalk.white.bold(" Stats"));
|
|
75
|
+
console.log();
|
|
76
|
+
console.log(line);
|
|
77
|
+
console.log();
|
|
78
|
+
|
|
79
|
+
// streak
|
|
80
|
+
console.log(` ${chalk.yellow.bold("Streak")}`);
|
|
81
|
+
row("Current", chalk.white.bold(String(streak.current)));
|
|
82
|
+
row("Last", chalk.white(String(streak.last)));
|
|
83
|
+
row("Longest", chalk.white(String(streak.longest)));
|
|
84
|
+
console.log();
|
|
85
|
+
|
|
86
|
+
// runs
|
|
87
|
+
console.log(` ${chalk.cyan.bold("Runs")}`);
|
|
88
|
+
row("Completed", chalk.green(String(s.totalCompleted)));
|
|
89
|
+
row("Aborted", chalk.red(String(s.totalAborted)));
|
|
90
|
+
row("Completion rate", completionRate(runs));
|
|
91
|
+
console.log();
|
|
92
|
+
|
|
93
|
+
// by type
|
|
94
|
+
console.log(` ${chalk.white.bold("By Type")}`);
|
|
95
|
+
row("TODO", chalk.blue(String(s.byType[CommentType.TODO])));
|
|
96
|
+
row("FIXME", chalk.yellow(String(s.byType[CommentType.FIXME])));
|
|
97
|
+
row("BUG", chalk.red(String(s.byType[CommentType.BUG])));
|
|
98
|
+
console.log();
|
|
99
|
+
|
|
100
|
+
// performance
|
|
101
|
+
console.log(` ${chalk.white.bold("Performance")}`);
|
|
102
|
+
row("Avg time", chalk.white(avgDuration(runs)));
|
|
103
|
+
row("Fastest run", fastestRun(runs));
|
|
104
|
+
row("Top repo", topRepo(runs));
|
|
105
|
+
console.log();
|
|
106
|
+
|
|
107
|
+
console.log(line);
|
|
108
|
+
console.log();
|
|
109
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { SpawnSyncOptions } from "node:child_process";
|
|
3
|
+
import { EditorConfig, SupportedEditors } from "./types/types";
|
|
4
|
+
|
|
5
|
+
const EDITORS: Record<SupportedEditors, EditorConfig> = {
|
|
6
|
+
nvim: {
|
|
7
|
+
bin: {
|
|
8
|
+
linux: "nvim",
|
|
9
|
+
win32: "nvim.exe",
|
|
10
|
+
},
|
|
11
|
+
args: (file, line) => [`+${line}`, file],
|
|
12
|
+
},
|
|
13
|
+
code: {
|
|
14
|
+
bin: {
|
|
15
|
+
linux: "code",
|
|
16
|
+
win32: "code.cmd",
|
|
17
|
+
},
|
|
18
|
+
args: (file, line) => ["--goto", `${file}:${line}`],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
type Platform = "linux" | "win32";
|
|
22
|
+
|
|
23
|
+
function getPlatform(): Platform {
|
|
24
|
+
return process.platform === "win32" ? "win32" : "linux";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function detectEditor(): SupportedEditors {
|
|
28
|
+
const env = process.env.EDITOR ?? "";
|
|
29
|
+
|
|
30
|
+
if (env.includes("nvim")) return "nvim";
|
|
31
|
+
if (env.includes("code")) return "code";
|
|
32
|
+
|
|
33
|
+
// fallback: default per platform
|
|
34
|
+
return getPlatform() === "win32" ? "code" : "nvim";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getBin(editor: SupportedEditors): string {
|
|
38
|
+
return EDITORS[editor].bin[getPlatform()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getArgs(
|
|
42
|
+
editor: SupportedEditors,
|
|
43
|
+
file: string,
|
|
44
|
+
line: number,
|
|
45
|
+
): string[] {
|
|
46
|
+
return EDITORS[editor].args(file, line);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function openEditor(file: string, line: number): void {
|
|
50
|
+
const editor = detectEditor();
|
|
51
|
+
const bin = getBin(editor);
|
|
52
|
+
const args = getArgs(editor, file, line);
|
|
53
|
+
|
|
54
|
+
const options: SpawnSyncOptions = {
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
// code on windows needs a shell to resolve .cmd
|
|
57
|
+
shell: getPlatform() === "win32" && editor === "code",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const result = spawnSync(bin, args, options);
|
|
61
|
+
|
|
62
|
+
if (result.error) {
|
|
63
|
+
throw new Error(`Failed to open editor: ${result.error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { start } from "./commands/start.js";
|
|
5
|
+
import { history } from "./commands/history.js";
|
|
6
|
+
import { stats } from "./commands/stats.js";
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("t-rush")
|
|
12
|
+
.description("Speedrun your TODOs, FIXMEs, and BUGs")
|
|
13
|
+
.version("0.1.0");
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command("start")
|
|
17
|
+
.description("scan repo, pick a TODO and start a speedrun")
|
|
18
|
+
.argument("[dir]", "directory to scan", process.cwd())
|
|
19
|
+
.action(async (dir: string) => {
|
|
20
|
+
await start(dir);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("history")
|
|
25
|
+
.description("show past runs")
|
|
26
|
+
.option("-l, --limit <n>", "number of runs to show", "20")
|
|
27
|
+
.option("-t, --type <type>", "filter by type: TODO, FIXME, BUG")
|
|
28
|
+
.option("-r, --repo <name>", "filter by repo name")
|
|
29
|
+
.option("--completed", "show only completed runs")
|
|
30
|
+
.option("--aborted", "show only aborted runs")
|
|
31
|
+
.action((opts) => {
|
|
32
|
+
history({
|
|
33
|
+
limit: parseInt(opts.limit),
|
|
34
|
+
type: opts.type,
|
|
35
|
+
repo: opts.repo,
|
|
36
|
+
completed: opts.completed ? true : opts.aborted ? false : undefined,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command("stats")
|
|
42
|
+
.description("show streak, completion rate and performance")
|
|
43
|
+
.action(() => {
|
|
44
|
+
stats();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
program.parse();
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
import ignore from "ignore";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
export type TodoItem = {
|
|
7
|
+
type: "TODO" | "FIXME";
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
text: string;
|
|
11
|
+
rawLine: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ALWAYS_IGNORE = [
|
|
15
|
+
"**/node_modules/**",
|
|
16
|
+
"**/.git/**",
|
|
17
|
+
"**/dist/**",
|
|
18
|
+
"**/build/**",
|
|
19
|
+
"**/.next/**",
|
|
20
|
+
"**/coverage/**",
|
|
21
|
+
"**/*.min.js",
|
|
22
|
+
"**/*.lock",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const TODO_REGEX =
|
|
26
|
+
/(?:\/\/|#|--|%|;|\*)\s*(?:TODO|FIXME)(?:\(.*?\))?[:\s]+(.+)/i;
|
|
27
|
+
|
|
28
|
+
const TODO_TYPE_REGEX = /(?:TODO|FIXME)/i;
|
|
29
|
+
|
|
30
|
+
const BINARY_EXTENSIONS = new Set([
|
|
31
|
+
"png",
|
|
32
|
+
"jpg",
|
|
33
|
+
"jpeg",
|
|
34
|
+
"gif",
|
|
35
|
+
"webp",
|
|
36
|
+
"ico",
|
|
37
|
+
"svg",
|
|
38
|
+
"pdf",
|
|
39
|
+
"zip",
|
|
40
|
+
"tar",
|
|
41
|
+
"gz",
|
|
42
|
+
"rar",
|
|
43
|
+
"7z",
|
|
44
|
+
"exe",
|
|
45
|
+
"bin",
|
|
46
|
+
"dll",
|
|
47
|
+
"so",
|
|
48
|
+
"dylib",
|
|
49
|
+
"mp3",
|
|
50
|
+
"mp4",
|
|
51
|
+
"wav",
|
|
52
|
+
"mov",
|
|
53
|
+
"avi",
|
|
54
|
+
"ttf",
|
|
55
|
+
"woff",
|
|
56
|
+
"woff2",
|
|
57
|
+
"eot",
|
|
58
|
+
"lock",
|
|
59
|
+
"map",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function isBinary(filePath: string): boolean {
|
|
63
|
+
const ext = path.extname(filePath).replace(".", "").toLowerCase();
|
|
64
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadGitignore(cwd: string): ReturnType<typeof ignore> {
|
|
68
|
+
const ig = ignore();
|
|
69
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
70
|
+
|
|
71
|
+
if (fs.existsSync(gitignorePath)) {
|
|
72
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
73
|
+
ig.add(content);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return ig;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseTodos(filePath: string, cwd: string): TodoItem[] {
|
|
80
|
+
const items: TodoItem[] = [];
|
|
81
|
+
|
|
82
|
+
if (isBinary(filePath)) return items;
|
|
83
|
+
|
|
84
|
+
let content: string;
|
|
85
|
+
try {
|
|
86
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
87
|
+
} catch {
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// skip if file looks binary despite extension
|
|
92
|
+
if (content.includes("\0")) return items;
|
|
93
|
+
|
|
94
|
+
const lines = content.split("\n");
|
|
95
|
+
|
|
96
|
+
lines.forEach((rawLine, index) => {
|
|
97
|
+
const match = TODO_REGEX.exec(rawLine);
|
|
98
|
+
if (!match) return;
|
|
99
|
+
|
|
100
|
+
const typeMatch = TODO_TYPE_REGEX.exec(rawLine);
|
|
101
|
+
if (!typeMatch) return;
|
|
102
|
+
|
|
103
|
+
items.push({
|
|
104
|
+
type: typeMatch[0].toUpperCase() as "TODO" | "FIXME",
|
|
105
|
+
file: path.relative(cwd, filePath),
|
|
106
|
+
line: index + 1,
|
|
107
|
+
text: match[1].trim(),
|
|
108
|
+
rawLine: rawLine.trim(),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function scanRepo(
|
|
116
|
+
cwd: string = process.cwd(),
|
|
117
|
+
): Promise<TodoItem[]> {
|
|
118
|
+
const ig = loadGitignore(cwd);
|
|
119
|
+
|
|
120
|
+
const files = await fg("**/*", {
|
|
121
|
+
cwd,
|
|
122
|
+
absolute: true,
|
|
123
|
+
onlyFiles: true,
|
|
124
|
+
ignore: ALWAYS_IGNORE,
|
|
125
|
+
dot: false,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const filtered = files.filter((f) => {
|
|
129
|
+
const rel = path.relative(cwd, f);
|
|
130
|
+
return !ig.ignores(rel);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const todos = filtered.flatMap((f) => parseTodos(f, cwd));
|
|
134
|
+
|
|
135
|
+
return todos;
|
|
136
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { RunRecord, AppData } from "./types/types";
|
|
5
|
+
import { CommentType } from "./types/types";
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = path.join(os.homedir(), ".t-rush");
|
|
8
|
+
const DATA_FILE = path.join(DATA_DIR, "data.json");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DATA: AppData = {
|
|
11
|
+
runs: [],
|
|
12
|
+
streak: {
|
|
13
|
+
current: 0,
|
|
14
|
+
last: 0,
|
|
15
|
+
longest: 0,
|
|
16
|
+
},
|
|
17
|
+
stats: {
|
|
18
|
+
totalCompleted: 0,
|
|
19
|
+
totalAborted: 0,
|
|
20
|
+
byType: {
|
|
21
|
+
[CommentType.TODO]: 0,
|
|
22
|
+
[CommentType.FIXME]: 0,
|
|
23
|
+
[CommentType.BUG]: 0,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function loadData(): AppData {
|
|
29
|
+
if (!fs.existsSync(DATA_DIR)) return structuredClone(DEFAULT_DATA);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = fs.readFileSync(DATA_FILE, "utf-8");
|
|
33
|
+
return JSON.parse(content) as AppData;
|
|
34
|
+
} catch {
|
|
35
|
+
return structuredClone(DEFAULT_DATA);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function saveData(data: AppData): void {
|
|
40
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
41
|
+
|
|
42
|
+
const tmp = DATA_FILE + ".tmp";
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
46
|
+
fs.renameSync(tmp, DATA_FILE);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function addRun(record: RunRecord): void {
|
|
54
|
+
const data = loadData();
|
|
55
|
+
data.runs.push(record);
|
|
56
|
+
saveData(data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRepoName(cwd: string = process.cwd()): string {
|
|
60
|
+
const parts = cwd.split(path.sep);
|
|
61
|
+
return parts[parts.length - 1] || "unknown-repo";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function generateId(): string {
|
|
65
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
66
|
+
}
|
package/src/streak.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { loadData, saveData } from "./store";
|
|
2
|
+
import { CommentType } from "./types/types";
|
|
3
|
+
import { StreakInfo, StatsInfo } from "./types/types";
|
|
4
|
+
|
|
5
|
+
export function incrementStreak(type: CommentType): StreakInfo {
|
|
6
|
+
const data = loadData();
|
|
7
|
+
data.streak.current += 1;
|
|
8
|
+
data.streak.longest = Math.max(data.streak.longest, data.streak.current);
|
|
9
|
+
data.stats.totalCompleted += 1;
|
|
10
|
+
data.stats.byType[type] += 1;
|
|
11
|
+
saveData(data);
|
|
12
|
+
return { ...data.streak };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resetStreak(): StreakInfo {
|
|
16
|
+
const data = loadData();
|
|
17
|
+
data.streak.last = data.streak.current;
|
|
18
|
+
data.streak.current = 0;
|
|
19
|
+
data.stats.totalAborted += 1;
|
|
20
|
+
saveData(data);
|
|
21
|
+
return { ...data.streak };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getStreak(): StreakInfo {
|
|
25
|
+
const data = loadData();
|
|
26
|
+
return { ...data.streak };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getStats(): StatsInfo {
|
|
30
|
+
const data = loadData();
|
|
31
|
+
return { ...data.stats };
|
|
32
|
+
}
|
package/src/timer.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { TimerResult } from "./types/types";
|
|
2
|
+
|
|
3
|
+
export function formatDuration(seconds: number): string {
|
|
4
|
+
const h = Math.floor(seconds / 3600);
|
|
5
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
6
|
+
const s = seconds % 60;
|
|
7
|
+
|
|
8
|
+
if (h > 0) {
|
|
9
|
+
return `${h}h ${m}m ${s}s`;
|
|
10
|
+
} else if (m > 0) {
|
|
11
|
+
return `${m}m ${s}s`;
|
|
12
|
+
}
|
|
13
|
+
return `${s}s`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTimer() {
|
|
17
|
+
const startedAt = new Date();
|
|
18
|
+
const startMs = Date.now();
|
|
19
|
+
|
|
20
|
+
function stop(): TimerResult {
|
|
21
|
+
const finishedAt = new Date();
|
|
22
|
+
const duration = Math.floor((Date.now() - startMs) / 1000);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
startedAt: startedAt.toISOString(),
|
|
26
|
+
finishedAt: finishedAt.toISOString(),
|
|
27
|
+
duration,
|
|
28
|
+
formatted: formatDuration(duration),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { stop };
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const CommentType = {
|
|
2
|
+
TODO: "TODO",
|
|
3
|
+
FIXME: "FIXME",
|
|
4
|
+
BUG: "BUG",
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export type CommentType = (typeof CommentType)[keyof typeof CommentType];
|
|
8
|
+
|
|
9
|
+
export type TodoItem = {
|
|
10
|
+
type: CommentType;
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
text: string;
|
|
14
|
+
rawLine: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RunRecord = {
|
|
18
|
+
id: string;
|
|
19
|
+
repo: string;
|
|
20
|
+
file: string;
|
|
21
|
+
line: number;
|
|
22
|
+
text: string;
|
|
23
|
+
type: CommentType;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
finishedAt: string;
|
|
26
|
+
duration: number;
|
|
27
|
+
completed: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type StreakInfo = {
|
|
31
|
+
current: number;
|
|
32
|
+
last: number;
|
|
33
|
+
longest: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type StatsInfo = {
|
|
37
|
+
totalCompleted: number;
|
|
38
|
+
totalAborted: number;
|
|
39
|
+
byType: Record<CommentType, number>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type AppData = {
|
|
43
|
+
runs: RunRecord[];
|
|
44
|
+
streak: StreakInfo;
|
|
45
|
+
stats: StatsInfo;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type TimerResult = {
|
|
49
|
+
startedAt: string;
|
|
50
|
+
finishedAt: string;
|
|
51
|
+
duration: number;
|
|
52
|
+
formatted: string; // formatted like "1m 30s"
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SupportedEditors = "code" | "nvim";
|
|
56
|
+
|
|
57
|
+
export type EditorConfig = {
|
|
58
|
+
bin: {
|
|
59
|
+
linux: string;
|
|
60
|
+
win32: string;
|
|
61
|
+
};
|
|
62
|
+
args: (file: string, line: number) => string[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ValidationStatus = "done" | "unchanged" | "missing";
|
|
66
|
+
|
|
67
|
+
export type ValidationResult = {
|
|
68
|
+
status: ValidationStatus;
|
|
69
|
+
message: string;
|
|
70
|
+
};
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type {
|
|
3
|
+
StreakInfo,
|
|
4
|
+
StatsInfo,
|
|
5
|
+
TodoItem,
|
|
6
|
+
ValidationResult,
|
|
7
|
+
} from "./types/types.js";
|
|
8
|
+
import { CommentType } from "./types/types.js";
|
|
9
|
+
|
|
10
|
+
// colors
|
|
11
|
+
export function badge(type: TodoItem["type"]): string {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case CommentType.TODO:
|
|
14
|
+
return chalk.bgBlue.white.bold(` TODO `);
|
|
15
|
+
case CommentType.FIXME:
|
|
16
|
+
return chalk.bgYellow.black.bold(` FIXME `);
|
|
17
|
+
case CommentType.BUG:
|
|
18
|
+
return chalk.bgRed.white.bold(` BUG `);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatTodoChoice(item: TodoItem): string {
|
|
23
|
+
return `${badge(item.type)} ${chalk.dim(item.file)}${chalk.dim(":") + chalk.cyan(item.line)} ${chalk.white(item.text)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function label(text: string): string {
|
|
27
|
+
return chalk.gray(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function success(text: string): string {
|
|
31
|
+
return chalk.green(`✔ ${text}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function failure(text: string): string {
|
|
35
|
+
return chalk.red(`✘ ${text}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function warn(text: string): string {
|
|
39
|
+
return chalk.yellow(`⚠ ${text}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function info(text: string): string {
|
|
43
|
+
return chalk.cyan(`ℹ ${text}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatValidation(result: ValidationResult): string {
|
|
47
|
+
switch (result.status) {
|
|
48
|
+
case "done":
|
|
49
|
+
return success(result.message);
|
|
50
|
+
case "unchanged":
|
|
51
|
+
return warn(result.message);
|
|
52
|
+
case "missing":
|
|
53
|
+
return info(result.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ResultCardOptions = {
|
|
58
|
+
item: TodoItem;
|
|
59
|
+
duration: string;
|
|
60
|
+
streak: StreakInfo;
|
|
61
|
+
stats: StatsInfo;
|
|
62
|
+
completed: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function printResult(opts: ResultCardOptions): void {
|
|
66
|
+
const { item, duration, streak, stats, completed } = opts;
|
|
67
|
+
|
|
68
|
+
const width = 50;
|
|
69
|
+
const line = chalk.gray("─".repeat(width));
|
|
70
|
+
|
|
71
|
+
console.log();
|
|
72
|
+
console.log(line);
|
|
73
|
+
console.log();
|
|
74
|
+
|
|
75
|
+
// status
|
|
76
|
+
if (completed) {
|
|
77
|
+
console.log(
|
|
78
|
+
` ${chalk.green.bold("✔")} ${badge(item.type)} ${chalk.white.bold("resolved")}`,
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
console.log(
|
|
82
|
+
` ${chalk.red.bold("✘")} ${badge(item.type)} ${chalk.white.bold("aborted")}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// file + text
|
|
87
|
+
console.log(` ${chalk.dim(item.file + ":" + item.line)}`);
|
|
88
|
+
console.log(` ${chalk.gray('"' + item.text + '"')}`);
|
|
89
|
+
console.log();
|
|
90
|
+
|
|
91
|
+
// time
|
|
92
|
+
console.log(` ${chalk.cyan("⏱")} ${chalk.white(duration)}`);
|
|
93
|
+
console.log();
|
|
94
|
+
|
|
95
|
+
// streak
|
|
96
|
+
if (completed) {
|
|
97
|
+
console.log(
|
|
98
|
+
` ${chalk.yellow("")} Streak: ${chalk.white.bold(streak.current)} ${chalk.dim(`(longest: ${streak.longest})`)}`,
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(
|
|
102
|
+
` ${chalk.gray("")} Streak reset ${chalk.dim(`(last: ${streak.last} longest: ${streak.longest})`)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// stats
|
|
107
|
+
console.log();
|
|
108
|
+
console.log(
|
|
109
|
+
` ${chalk.dim("TODO:")} ${chalk.white(stats.byType[CommentType.TODO])} ${chalk.dim("FIXME:")} ${chalk.white(stats.byType[CommentType.FIXME])} ${chalk.dim("BUG:")} ${chalk.white(stats.byType[CommentType.BUG])}`,
|
|
110
|
+
);
|
|
111
|
+
console.log(
|
|
112
|
+
` ${chalk.dim("Completed:")} ${chalk.green(stats.totalCompleted)} ${chalk.dim("Aborted:")} ${chalk.red(stats.totalAborted)}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
console.log();
|
|
116
|
+
console.log(line);
|
|
117
|
+
console.log();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function printNoTodos(): void {
|
|
121
|
+
console.log();
|
|
122
|
+
console.log(success("No TODOs, FIXMEs, or BUGs found. Clean codebase!"));
|
|
123
|
+
console.log();
|
|
124
|
+
}
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { CommentType } from "./types/types.js";
|
|
3
|
+
import { ValidationResult } from "./types/types.js";
|
|
4
|
+
|
|
5
|
+
const COMMENT_REGEX = /(?:TODO|FIXME|BUG)(?:\(.*?\))?[:\s]/i;
|
|
6
|
+
|
|
7
|
+
function scanWindow(
|
|
8
|
+
lines: string[],
|
|
9
|
+
line: number,
|
|
10
|
+
originalText: string,
|
|
11
|
+
): boolean {
|
|
12
|
+
const start = Math.max(0, line - 10);
|
|
13
|
+
const end = Math.min(lines.length, line + 10);
|
|
14
|
+
const window = lines.slice(start, end);
|
|
15
|
+
|
|
16
|
+
return window.some((l) => l.includes(originalText.trim()));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateTodo(
|
|
20
|
+
file: string,
|
|
21
|
+
line: number,
|
|
22
|
+
originalText: string,
|
|
23
|
+
type: CommentType,
|
|
24
|
+
): ValidationResult {
|
|
25
|
+
// file missing or deleted
|
|
26
|
+
if (!fs.existsSync(file)) {
|
|
27
|
+
return {
|
|
28
|
+
status: "missing",
|
|
29
|
+
message: `${file} not found — was it deleted or moved?`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines = fs.readFileSync(file, "utf-8").split("\n");
|
|
34
|
+
const exactLine = lines[line - 1]; // 1-indexed
|
|
35
|
+
|
|
36
|
+
// exact line no longer exists (file got shorter)
|
|
37
|
+
if (!exactLine) {
|
|
38
|
+
return {
|
|
39
|
+
status: "done",
|
|
40
|
+
message: `Line ${line} no longer exists — looks done.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// exact line still has a TODO/FIXME/BUG comment
|
|
45
|
+
if (COMMENT_REGEX.test(exactLine)) {
|
|
46
|
+
// check ±10 window in case user edited around it but didn't remove it
|
|
47
|
+
const foundInWindow = scanWindow(lines, line - 1, originalText);
|
|
48
|
+
|
|
49
|
+
if (foundInWindow) {
|
|
50
|
+
return {
|
|
51
|
+
status: "unchanged",
|
|
52
|
+
message: `${type} still present at ${file}:${line}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
status: "done",
|
|
59
|
+
message: `${type} resolved in ${file}`,
|
|
60
|
+
};
|
|
61
|
+
}
|