oxlint-tui 1.0.7 → 1.0.8

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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/package.json +4 -1
  3. package/tui.js +338 -186
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # oxlint-tui
2
2
 
3
- A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing, toggling and visualizing [oxlint](https://github.com/oxc-project/oxc) rules.
3
+ A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing, toggling and visualizing [oxlint](https://github.com/oxc-project/oxc) rules and the number of warnings/errors they produce when toggled ON.
4
4
 
5
5
  It automatically loads your local configuration to show you the status of the rules toggled in your project and allows you to toggle them by selecting a rule in the Rules pane and pressing <kbd>1</kbd>, <kbd>2</kbd>, or <kbd>3</kbd>. The config file is modified in real-time.
6
6
 
7
+ Pressing <kbd>r</kbd> will lint the project using "oxlint". Pressing <kbd>t</kbd> will lint include the oxlint-tsgolint package and lint the project using "oxlint --type-aware". The results are presented directly in the interface.
8
+
7
9
  **NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
8
10
 
9
11
  ![screenshot](https://raw.githubusercontent.com/holoflash/oxlint-tui/refs/heads/main/screenshot.png)
@@ -58,6 +60,8 @@ oxlint-tui
58
60
  | **1** | Set selected rule to "off" |
59
61
  | **2** | Set selected rule to "warn" |
60
62
  | **3** | Set selected rule to "error" |
63
+ | **r** | Run the linter with "oxlint" |
64
+ | **t** | Run the linter with "-p oxlint-tsgolint@latest oxlint --type-aware" |
61
65
  | **Enter** | Open Rule Documentation in Browser |
62
66
  | **q** / **Esc** | Quit |
63
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-tui",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "A Node TUI Oxlint rules and configuration browser",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,9 @@
19
19
  "tui",
20
20
  "cli"
21
21
  ],
22
+ "dependencies": {
23
+ "oxlint-tsgolint": "0.11.1"
24
+ },
22
25
  "author": "holoflash",
23
26
  "license": "MIT",
24
27
  "engines": {
package/tui.js CHANGED
@@ -1,150 +1,208 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execSync, exec } from 'node:child_process';
4
- import { stdout, stdin, exit, platform, argv } from 'node:process';
5
- import readline from 'readline';
6
- import fs from 'node:fs';
3
+ import { execSync, exec } from "node:child_process";
4
+ import { stdout, stdin, exit, platform, argv } from "node:process";
5
+ import readline from "readline";
6
+ import fs from "node:fs";
7
7
 
8
- const OXLINT_VERSION = "1.41.0"
8
+ const OXLINT_VERSION = "1.41.0";
9
9
 
10
10
  const COLORS = {
11
- reset: '\x1b[0m',
12
- dim: '\x1b[90m',
13
- highlight: '\x1b[38;5;110m',
14
- selectedBg: '\x1b[47m\x1b[30m',
15
- borderActive: '\x1b[36m',
16
- borderInactive: '\x1b[90m',
17
- error: '\x1b[31m',
18
- warn: '\x1b[33m',
19
- success: '\x1b[32m',
11
+ reset: "\x1b[0m",
12
+ dim: "\x1b[90m",
13
+ highlight: "\x1b[38;5;110m",
14
+ selectedBg: "\x1b[47m\x1b[30m",
15
+ borderActive: "\x1b[36m",
16
+ borderInactive: "\x1b[90m",
17
+ error: "\x1b[31m",
18
+ warn: "\x1b[33m",
19
+ success: "\x1b[32m",
20
+ info: "\x1b[34m",
20
21
  };
21
22
 
22
23
  const KEY_MAP = {
23
- 'k': { type: 'MOVE_UP' },
24
- 'up': { type: 'MOVE_UP' },
25
- 'down': { type: 'MOVE_DOWN' },
26
- 'j': { type: 'MOVE_DOWN' },
27
- 'left': { type: 'MOVE_LEFT' },
28
- 'h': { type: 'MOVE_LEFT' },
29
- 'right': { type: 'MOVE_RIGHT' },
30
- 'l': { type: 'MOVE_RIGHT' },
31
- 'return': { type: 'OPEN_DOCS' },
32
- 'enter': { type: 'OPEN_DOCS' },
33
- '1': { type: 'SET_STATUS', value: 'off' },
34
- '2': { type: 'SET_STATUS', value: 'warn' },
35
- '3': { type: 'SET_STATUS', value: 'error' },
36
- 'q': { type: 'EXIT' },
37
- 'escape': { type: 'EXIT' }
24
+ k: { type: "MOVE_UP" },
25
+ up: { type: "MOVE_UP" },
26
+ down: { type: "MOVE_DOWN" },
27
+ j: { type: "MOVE_DOWN" },
28
+ left: { type: "MOVE_LEFT" },
29
+ h: { type: "MOVE_LEFT" },
30
+ right: { type: "MOVE_RIGHT" },
31
+ l: { type: "MOVE_RIGHT" },
32
+ return: { type: "OPEN_DOCS" },
33
+ enter: { type: "OPEN_DOCS" },
34
+ 1: { type: "SET_STATUS", value: "off" },
35
+ 2: { type: "SET_STATUS", value: "warn" },
36
+ 3: { type: "SET_STATUS", value: "error" },
37
+ q: { type: "EXIT" },
38
+ r: { type: "RUN_LINT" },
39
+ t: { type: "RUN_TYPE_AWARE_LINT" },
40
+ escape: { type: "EXIT" },
38
41
  };
39
42
 
40
- // TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserveing formatting
43
+ let state = {
44
+ activePane: 0,
45
+ selectedCatIdx: 0,
46
+ selectedRuleIdx: 0,
47
+ scrollCat: 0,
48
+ scrollRule: 0,
49
+ isLinting: false,
50
+ message: "Press R to lint; T to lint using --type-aware",
51
+ messageType: "dim",
52
+ ...loadRules(),
53
+ };
54
+
55
+ // TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserving formatting
41
56
  function updateConfig(rule, newStatus) {
42
57
  if (!state.configPath) return;
43
-
44
58
  try {
45
59
  if (!state.config.rules) state.config.rules = {};
46
60
  const ruleName = rule.value;
47
- const scope = rule.scope;
48
- const canonicalKey = scope === 'oxc' ? ruleName : `${scope}/${ruleName}`;
49
-
50
- const existingKey = Object.keys(state.config.rules).find(key =>
51
- key === canonicalKey ||
52
- key === ruleName ||
53
- key.endsWith(`/${ruleName}`)
61
+ const canonicalKey =
62
+ rule.scope === "oxc2" || rule.scope === "eslint"
63
+ ? ruleName
64
+ : `${rule.scope}/${ruleName}`;
65
+ const existingKey = Object.keys(state.config.rules).find(
66
+ (key) =>
67
+ key === canonicalKey ||
68
+ key === ruleName ||
69
+ key.endsWith(`/${ruleName}`),
54
70
  );
55
-
56
71
  const targetKey = existingKey || canonicalKey;
57
72
  state.config.rules[targetKey] = newStatus;
58
-
59
- fs.writeFileSync(state.configPath, JSON.stringify(state.config, null, 2), 'utf8');
60
- } catch {
61
- // TODO: Show errors in the tui
73
+ fs.writeFileSync(
74
+ state.configPath,
75
+ JSON.stringify(state.config, null, 2),
76
+ "utf8",
77
+ );
78
+ } catch (e) {
79
+ state.message = "Failed to write config file";
80
+ state.messageType = "error";
62
81
  }
63
82
  }
64
83
 
84
+ function runLint(type_aware) {
85
+ if (state.isLinting) return;
86
+
87
+ state.isLinting = true;
88
+ state.message = type_aware
89
+ ? "Running linter with --type-aware..."
90
+ : "Running linter...";
91
+ state.messageType = "info";
92
+ render();
93
+
94
+ const cmd = `npx -p oxlint@${OXLINT_VERSION} ${type_aware ? "-p oxlint-tsgolint@latest oxlint --type-aware" : "oxlint"}`;
95
+
96
+ exec(cmd, (error, stdout, stderr) => {
97
+ const fullOutput = stdout + stderr;
98
+ const summaryMatch = fullOutput.match(
99
+ /Found (\d+) warnings? and (\d+) errors?/,
100
+ );
101
+
102
+ state.isLinting = false;
103
+ if (summaryMatch) {
104
+ state.message = summaryMatch[0];
105
+ const errors = parseInt(summaryMatch[2]);
106
+ state.messageType = errors > 0 ? "error" : "warn";
107
+ } else if (
108
+ fullOutput.includes("Finished") ||
109
+ (!error && fullOutput.length === 0)
110
+ ) {
111
+ state.message = "Linting passed! 0 issues found.";
112
+ state.messageType = "success";
113
+ } else {
114
+ state.message = "Something went wrong";
115
+ state.messageType = "error";
116
+ }
117
+ render();
118
+ });
119
+ }
120
+
65
121
  function reducer(state, action) {
66
- const { categories, rulesByCategory, selectedCatIdx, selectedRuleIdx, activePane } = state;
122
+ const {
123
+ categories,
124
+ rulesByCategory,
125
+ selectedCatIdx,
126
+ selectedRuleIdx,
127
+ activePane,
128
+ } = state;
67
129
  const currentCat = categories[selectedCatIdx];
68
130
  const currentRules = rulesByCategory[currentCat] || [];
69
- const viewHeight = stdout.rows - 6;
70
-
131
+ const viewHeight = stdout.rows - 8;
71
132
  const statsHeight = 7;
72
133
  const catViewHeight = viewHeight - statsHeight;
73
134
 
74
135
  switch (action.type) {
75
- case 'SET_STATUS': {
136
+ case "SET_STATUS": {
76
137
  if (activePane !== 1) return state;
77
138
  const rule = currentRules[selectedRuleIdx];
78
139
  if (!rule) return state;
79
-
80
140
  updateConfig(rule, action.value);
81
-
82
141
  const updatedRules = [...currentRules];
83
142
  updatedRules[selectedRuleIdx] = {
84
143
  ...rule,
85
144
  configStatus: action.value,
86
- isActive: action.value === 'error' || action.value === 'warn'
145
+ isActive: action.value === "error" || action.value === "warn",
87
146
  };
88
-
89
147
  return {
90
148
  ...state,
149
+ message: `Rule '${rule.value}' set to: ${action.value}`,
150
+ messageType: "info",
91
151
  rulesByCategory: {
92
152
  ...rulesByCategory,
93
- [currentCat]: updatedRules
94
- }
153
+ [currentCat]: updatedRules,
154
+ },
95
155
  };
96
156
  }
97
-
98
- case 'MOVE_RIGHT':
99
- if (activePane !== 1)
100
- return { ...state, activePane: activePane + 1 };
157
+ case "MOVE_RIGHT":
158
+ if (activePane !== 1) return { ...state, activePane: activePane + 1 };
101
159
  return state;
102
160
 
103
- case 'MOVE_LEFT':
104
- if (activePane !== 0)
105
- return { ...state, activePane: activePane - 1 };
161
+ case "MOVE_LEFT":
162
+ if (activePane !== 0) return { ...state, activePane: activePane - 1 };
106
163
  return state;
107
-
108
- case 'MOVE_UP':
164
+ case "MOVE_UP":
109
165
  if (activePane === 0) {
110
- const nextIdx = selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
166
+ const nextIdx =
167
+ selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
111
168
  return {
112
169
  ...state,
113
170
  selectedCatIdx: nextIdx,
114
171
  selectedRuleIdx: 0,
115
172
  scrollRule: 0,
116
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
173
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
117
174
  };
118
175
  } else if (activePane === 1) {
119
- const nextIdx = selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
176
+ const nextIdx =
177
+ selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
120
178
  return {
121
179
  ...state,
122
180
  selectedRuleIdx: nextIdx,
123
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
181
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
124
182
  };
125
183
  }
126
184
  return state;
127
-
128
- case 'MOVE_DOWN':
185
+ case "MOVE_DOWN":
129
186
  if (activePane === 0) {
130
- const nextIdx = selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
187
+ const nextIdx =
188
+ selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
131
189
  return {
132
190
  ...state,
133
191
  selectedCatIdx: nextIdx,
134
192
  selectedRuleIdx: 0,
135
193
  scrollRule: 0,
136
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
194
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
137
195
  };
138
196
  } else if (activePane === 1) {
139
- const nextIdx = selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
197
+ const nextIdx =
198
+ selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
140
199
  return {
141
200
  ...state,
142
201
  selectedRuleIdx: nextIdx,
143
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
202
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
144
203
  };
145
204
  }
146
205
  return state;
147
-
148
206
  default:
149
207
  return state;
150
208
  }
@@ -153,83 +211,82 @@ function reducer(state, action) {
153
211
  function getRuleStatus(ruleName, category, config) {
154
212
  if (config.rules) {
155
213
  let val = config.rules[ruleName];
156
-
157
214
  if (val === undefined) {
158
- const foundKey = Object.keys(config.rules).find(key => key.endsWith(`/${ruleName}`));
159
- if (foundKey) {
160
- val = config.rules[foundKey];
161
- }
162
- }
163
-
164
- if (val !== undefined) {
165
- if (Array.isArray(val)) return val[0];
166
- return val;
215
+ const foundKey = Object.keys(config.rules).find((key) =>
216
+ key.endsWith(`/${ruleName}`),
217
+ );
218
+ if (foundKey) val = config.rules[foundKey];
167
219
  }
220
+ if (val !== undefined) return Array.isArray(val) ? val[0] : val;
168
221
  }
169
-
170
- if (config.categories && config.categories[category]) {
171
- return config.categories[category];
172
- }
173
- return 'off';
222
+ return (config.categories && config.categories[category]) || "off";
174
223
  }
175
224
 
176
225
  function stripJsonComments(json) {
177
- return json.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => g ? "" : m);
226
+ return json.replace(
227
+ /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
228
+ (m, g) => (g ? "" : m),
229
+ );
178
230
  }
179
231
 
180
232
  function loadRules() {
181
233
  let rulesData;
182
- let config = { rules: {}, categories: {} };
234
+ let config = {
235
+ rules: {},
236
+ categories: {},
237
+ };
183
238
  let configPath = null;
184
239
 
185
240
  try {
186
- const raw = execSync(`npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
241
+ const raw = execSync(
242
+ `npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
243
+ {
244
+ encoding: "utf8",
245
+ stdio: ["ignore", "pipe", "ignore"],
246
+ },
247
+ );
187
248
  rulesData = JSON.parse(raw);
188
249
  } catch (e) {
189
- console.error(`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`);
250
+ console.error(
251
+ `${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
252
+ );
190
253
  exit(1);
191
254
  }
192
255
 
193
256
  const userConfigPath = argv[2];
194
- if (userConfigPath) {
195
- if (!fs.existsSync(userConfigPath)) {
196
- console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
197
- exit(1);
198
- }
257
+ if (userConfigPath && fs.existsSync(userConfigPath)) {
199
258
  configPath = userConfigPath;
200
- } else if (fs.existsSync('.oxlintrc.json')) {
201
- configPath = '.oxlintrc.json';
259
+ } else if (fs.existsSync(".oxlintrc.json")) {
260
+ configPath = ".oxlintrc.json";
202
261
  }
203
262
 
204
263
  if (configPath) {
205
264
  try {
206
- const configFile = fs.readFileSync(configPath, 'utf8');
207
- const cleanConfig = stripJsonComments(configFile);
208
- config = JSON.parse(cleanConfig);
209
- } catch (e) {
210
- console.error(`${COLORS.error}Error: Failed to parse '${configPath}'.${COLORS.reset}`);
211
- exit(1);
212
- }
265
+ config = JSON.parse(
266
+ stripJsonComments(fs.readFileSync(configPath, "utf8")),
267
+ );
268
+ } catch (e) { }
213
269
  }
214
270
 
215
271
  const map = {};
216
- rulesData.forEach(rule => {
217
- const cat = rule.category || 'Uncategorized';
272
+ rulesData.forEach((rule) => {
273
+ const cat = rule.category || "Uncategorized";
218
274
  if (!map[cat]) map[cat] = [];
219
275
  const status = getRuleStatus(rule.value, cat, config);
220
- map[cat].push({ ...rule, configStatus: status, isActive: status === 'error' || status === 'warn' });
221
- });
222
-
223
- const categories = Object.keys(map).sort();
224
- categories.forEach(c => {
225
- map[c].sort((a, b) => {
226
- if (a.isActive && !b.isActive) return -1;
227
- if (!a.isActive && b.isActive) return 1;
228
- return a.value.localeCompare(b.value);
276
+ map[cat].push({
277
+ ...rule,
278
+ configStatus: status,
279
+ isActive: status === "error" || status === "warn",
229
280
  });
230
281
  });
231
282
 
232
- return { categories, rulesByCategory: map, config, configPath };
283
+ const categories = Object.keys(map).sort();
284
+ return {
285
+ categories,
286
+ rulesByCategory: map,
287
+ config,
288
+ configPath,
289
+ };
233
290
  }
234
291
 
235
292
  function updateScroll(idx, currentScroll, viewHeight) {
@@ -240,47 +297,77 @@ function updateScroll(idx, currentScroll, viewHeight) {
240
297
 
241
298
  function openUrl(url) {
242
299
  if (!url) return;
243
- const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
300
+ const cmd =
301
+ platform === "darwin"
302
+ ? "open"
303
+ : platform === "win32"
304
+ ? "start"
305
+ : "xdg-open";
244
306
  exec(`${cmd} "${url}"`);
245
307
  }
246
308
 
247
309
  function chunkString(str, len) {
310
+ if (!str) return [];
248
311
  const size = Math.ceil(str.length / len);
249
312
  const r = Array(size);
250
- for (let i = 0; i < size; i++) {
251
- r[i] = str.substring(i * len, (i + 1) * len);
252
- }
313
+ for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
253
314
  return r;
254
315
  }
255
316
 
256
317
  const write = (str) => stdout.write(str);
257
- const enterAltScreen = () => write('\x1b[?1049h\x1b[?25l');
258
- const exitAltScreen = () => write('\x1b[?1049l\x1b[?25h');
259
-
260
- function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollOffset, isActive) {
318
+ const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
319
+ const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
320
+
321
+ function drawBox(
322
+ buffer,
323
+ x,
324
+ y,
325
+ width,
326
+ height,
327
+ title,
328
+ items,
329
+ selectedIdx,
330
+ scrollOffset,
331
+ isActive,
332
+ ) {
261
333
  const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
262
- const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + '…' : title;
263
- const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, '─');
334
+ const titleClean =
335
+ title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
336
+ const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
337
+ width + borderColor.length - 1,
338
+ "─",
339
+ );
264
340
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
265
341
 
266
342
  for (let i = 1; i < height - 1; i++) {
267
- buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
343
+ buffer.push(
344
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
345
+ );
268
346
  }
269
- buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
347
+ buffer.push(
348
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
349
+ );
270
350
 
271
351
  const innerHeight = height - 2;
272
352
  items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
273
353
  const absIdx = scrollOffset + i;
274
354
  const rawText = (item.value || item).toString();
275
- let display = rawText.length > width - 4 ? rawText.substring(0, width - 5) + '…' : rawText.padEnd(width - 4);
355
+ let display =
356
+ rawText.length > width - 4
357
+ ? rawText.substring(0, width - 5) + "…"
358
+ : rawText.padEnd(width - 4);
276
359
  let itemColor = COLORS.dim;
277
- if (item.configStatus === 'error') itemColor = COLORS.error;
278
- else if (item.configStatus === 'warn') itemColor = COLORS.warn;
360
+ if (item.configStatus === "error") itemColor = COLORS.error;
361
+ else if (item.configStatus === "warn") itemColor = COLORS.warn;
279
362
  else if (item.isActive) itemColor = COLORS.success;
280
363
 
281
364
  buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
282
365
  if (absIdx === selectedIdx) {
283
- buffer.push(isActive ? `${COLORS.selectedBg}${display}${COLORS.reset}` : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
366
+ buffer.push(
367
+ isActive
368
+ ? `${COLORS.selectedBg}${display}${COLORS.reset}`
369
+ : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
370
+ );
284
371
  } else {
285
372
  buffer.push(`${itemColor}${display}${COLORS.reset}`);
286
373
  }
@@ -289,69 +376,93 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
289
376
 
290
377
  function drawStats(buffer, x, y, width, height, rules) {
291
378
  const borderColor = COLORS.borderInactive;
292
- const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, '─');
379
+ const topBorder = `${borderColor}┌─ STATS `.padEnd(
380
+ width + borderColor.length - 1,
381
+ "─",
382
+ );
293
383
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
294
- for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
295
- buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
384
+ for (let i = 1; i < height - 1; i++)
385
+ buffer.push(
386
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
387
+ );
388
+ buffer.push(
389
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
390
+ );
296
391
 
297
392
  let counts = { error: 0, warn: 0, off: 0 };
298
- rules.forEach(r => {
299
- if (r.configStatus === 'error') counts.error++;
300
- else if (r.configStatus === 'warn') counts.warn++;
393
+ rules.forEach((r) => {
394
+ if (r.configStatus === "error") counts.error++;
395
+ else if (r.configStatus === "warn") counts.warn++;
301
396
  else counts.off++;
302
397
  });
303
398
 
304
399
  const lines = [
305
- { label: 'Error', count: counts.error, color: COLORS.error },
306
- { label: 'Warn', count: counts.warn, color: COLORS.warn },
307
- { label: 'Off', count: counts.off, color: COLORS.dim }
400
+ { label: "Error", count: counts.error, color: COLORS.error },
401
+ { label: "Warn", count: counts.warn, color: COLORS.warn },
402
+ { label: "Off", count: counts.off, color: COLORS.dim },
308
403
  ];
309
404
 
310
405
  lines.forEach((line, i) => {
311
406
  if (i < height - 2) {
312
407
  const numStr = String(line.count).padStart(3);
313
408
  const labelStr = line.label.padEnd(width - 8);
314
- buffer.push(`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`);
409
+ buffer.push(
410
+ `\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
411
+ );
315
412
  }
316
413
  });
317
414
  }
318
415
 
319
416
  function drawDetails(buffer, x, y, width, height, rule, isActive) {
320
417
  const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
321
- const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, '─');
418
+ const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
419
+ width + borderColor.length - 1,
420
+ "─",
421
+ );
322
422
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
323
- for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
324
- buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
423
+ for (let i = 1; i < height - 1; i++)
424
+ buffer.push(
425
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
426
+ );
427
+ buffer.push(
428
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
429
+ );
325
430
 
326
431
  if (!rule) return;
327
432
 
328
433
  let statusDisplay = rule.configStatus.toUpperCase();
329
- if (rule.configStatus === 'error') statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
330
- else if (rule.configStatus === 'warn') statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
434
+ if (rule.configStatus === "error")
435
+ statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
436
+ else if (rule.configStatus === "warn")
437
+ statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
331
438
  else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
332
439
 
333
440
  const labels = [
334
- ['Name', rule.value],
335
- ['Status', statusDisplay],
336
- ['Category', rule.category],
337
- ['Scope', rule.scope],
338
- ['Fix', rule.fix],
339
- ['Default', rule.default ? 'Yes' : 'No'],
340
- ['Type-aware', rule.type_aware ? 'Yes' : 'No'],
341
- ['Docs', `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`]
441
+ ["Name", rule.value],
442
+ ["Status", statusDisplay],
443
+ ["Category", rule.category],
444
+ ["Scope", rule.scope],
445
+ ["Fix", rule.fix],
446
+ ["Default", rule.default ? "Yes" : "No"],
447
+ ["Type-aware", rule.type_aware ? "Yes" : "No"],
448
+ ["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
342
449
  ];
343
450
 
344
451
  let line = 0;
345
452
  labels.forEach(([lbl, val]) => {
346
- if (lbl === 'Status' && line < height - 2) {
347
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
453
+ if (lbl === "Status" && line < height - 2) {
454
+ buffer.push(
455
+ `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
456
+ );
348
457
  line++;
349
458
  return;
350
459
  }
351
- const chunks = chunkString(String(val || 'N/A'), width - 15);
460
+ const chunks = chunkString(String(val || "N/A"), width - 15);
352
461
  chunks.forEach((chunk) => {
353
462
  if (line < height - 2) {
354
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`);
463
+ buffer.push(
464
+ `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
465
+ );
355
466
  line++;
356
467
  }
357
468
  });
@@ -363,46 +474,87 @@ function render() {
363
474
  const currentCat = state.categories[state.selectedCatIdx];
364
475
  const rules = state.rulesByCategory[currentCat] || [];
365
476
  const rule = rules[state.selectedRuleIdx];
366
- const boxHeight = rows - 4;
477
+ const boxHeight = rows - 5;
367
478
  const col1W = Math.floor(columns * 0.2);
368
479
  const col2W = Math.floor(columns * 0.3);
369
480
  const col3W = columns - col1W - col2W - 2;
370
481
  const statsHeight = 6;
371
482
  const catListHeight = boxHeight - statsHeight;
372
483
 
373
- const buffer = ['\x1b[H\x1b[J'];
374
- drawBox(buffer, 1, 1, col1W, catListHeight, 'CATEGORIES', state.categories, state.selectedCatIdx, state.scrollCat, state.activePane === 0);
484
+ const buffer = ["\x1b[H\x1b[J"];
485
+ drawBox(
486
+ buffer,
487
+ 1,
488
+ 1,
489
+ col1W,
490
+ catListHeight,
491
+ "CATEGORIES",
492
+ state.categories,
493
+ state.selectedCatIdx,
494
+ state.scrollCat,
495
+ state.activePane === 0,
496
+ );
375
497
  drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
376
- drawBox(buffer, col1W + 1, 1, col2W, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIdx, state.scrollRule, state.activePane === 1);
377
- drawDetails(buffer, col1W + col2W + 1, 1, col3W, boxHeight, rule, state.activePane === 2);
378
-
379
- const footerConfig = state.configPath ? `Config: ${state.configPath}` : 'No config loaded';
380
- buffer.push(`\x1b[${rows - 2};2H${COLORS.dim}Nav: Arrows/HJKL | 1:Off 2:Warn 3:Err | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`);
381
- write(buffer.join(''));
498
+ drawBox(
499
+ buffer,
500
+ col1W + 1,
501
+ 1,
502
+ col2W,
503
+ boxHeight,
504
+ `RULES (${rules.length})`,
505
+ rules,
506
+ state.selectedRuleIdx,
507
+ state.scrollRule,
508
+ state.activePane === 1,
509
+ );
510
+ drawDetails(
511
+ buffer,
512
+ col1W + col2W + 1,
513
+ 1,
514
+ col3W,
515
+ boxHeight,
516
+ rule,
517
+ state.activePane === 2,
518
+ );
519
+
520
+ const msgColor = COLORS[state.messageType] || COLORS.reset;
521
+ buffer.push(
522
+ `\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
523
+ );
524
+
525
+ const footerConfig = state.configPath
526
+ ? `Config: ${state.configPath}`
527
+ : "No config loaded";
528
+ buffer.push(
529
+ `\x1b[${rows - 1};2H${COLORS.dim}Arrows: Nav | 1-3: Status | Space: Lint | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
530
+ );
531
+ write(buffer.join(""));
382
532
  }
383
533
 
384
- let state = {
385
- activePane: 0,
386
- selectedCatIdx: 0,
387
- selectedRuleIdx: 0,
388
- scrollCat: 0,
389
- scrollRule: 0,
390
- ...loadRules()
391
- };
392
-
393
534
  readline.emitKeypressEvents(stdin);
394
535
  if (stdin.isTTY) stdin.setRawMode(true);
395
536
 
396
- stdin.on('keypress', (_, key) => {
397
- const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : (KEY_MAP[key.sequence] || null));
537
+ stdin.on("keypress", (_, key) => {
538
+ const action =
539
+ KEY_MAP[key.name] ||
540
+ (key.ctrl && key.name === "c"
541
+ ? { type: "EXIT" }
542
+ : KEY_MAP[key.sequence] || null);
398
543
  if (!action) return;
399
544
 
400
- if (action.type === 'EXIT') {
545
+ if (action.type === "EXIT") {
401
546
  exitAltScreen();
402
547
  exit(0);
403
548
  }
404
-
405
- if (action.type === 'OPEN_DOCS' && state.activePane === 1) {
549
+ if (action.type === "RUN_LINT") {
550
+ runLint();
551
+ return;
552
+ }
553
+ if (action.type === "RUN_TYPE_AWARE_LINT") {
554
+ runLint(true);
555
+ return;
556
+ }
557
+ if (action.type === "OPEN_DOCS" && state.activePane === 1) {
406
558
  const currentCat = state.categories[state.selectedCatIdx];
407
559
  const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
408
560
  if (rule) openUrl(rule.docs_url || rule.url);
@@ -413,6 +565,6 @@ stdin.on('keypress', (_, key) => {
413
565
  render();
414
566
  });
415
567
 
416
- stdout.on('resize', render);
568
+ stdout.on("resize", render);
417
569
  enterAltScreen();
418
- render();
570
+ render();