devkit-quality 0.1.0 → 0.1.1

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/terminal.js CHANGED
@@ -3,24 +3,38 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.Spinner = exports.color = void 0;
6
+ exports.ProgressRenderer = exports.color = exports.glyph = void 0;
7
+ exports.terminalWidth = terminalWidth;
7
8
  exports.severityColor = severityColor;
9
+ exports.severityBadge = severityBadge;
8
10
  exports.scoreColor = scoreColor;
9
11
  exports.scoreLabel = scoreLabel;
12
+ exports.scoreGrade = scoreGrade;
10
13
  exports.renderScoreBar = renderScoreBar;
14
+ exports.renderStackedBar = renderStackedBar;
11
15
  exports.pad = pad;
12
16
  exports.padStart = padStart;
17
+ exports.truncateMiddle = truncateMiddle;
18
+ exports.renderBox = renderBox;
19
+ exports.sectionHeading = sectionHeading;
20
+ exports.formatDuration = formatDuration;
13
21
  exports.renderCodeFrame = renderCodeFrame;
14
- exports.waitForNextTick = waitForNextTick;
15
22
  const node_fs_1 = __importDefault(require("node:fs"));
16
23
  const node_path_1 = __importDefault(require("node:path"));
17
24
  const colorEnabled = Boolean(process.env.FORCE_COLOR) || (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
25
+ /** Box-drawing and block glyphs, with a plain-ASCII fallback for dumb terminals. */
26
+ const ascii = process.env.TERM === 'dumb' || Boolean(process.env.DEVKIT_ASCII);
27
+ exports.glyph = ascii
28
+ ? { full: '#', empty: '.', tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', dot: '*', bullet: '-', arrow: '->', check: 'ok', warn: '!', rail: '|', sep: '-' }
29
+ : { full: '█', empty: '░', tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', dot: '●', bullet: '•', arrow: '→', check: '✔', warn: '▲', rail: '┃', sep: '─' };
18
30
  function wrap(open, close) {
19
31
  return (text) => (colorEnabled ? `\x1b[${open}m${text}\x1b[${close}m` : text);
20
32
  }
21
33
  exports.color = {
22
34
  bold: wrap('1', '22'),
23
35
  dim: wrap('2', '22'),
36
+ italic: wrap('3', '23'),
37
+ inverse: wrap('7', '27'),
24
38
  red: wrap('31', '39'),
25
39
  green: wrap('32', '39'),
26
40
  yellow: wrap('33', '39'),
@@ -28,11 +42,27 @@ exports.color = {
28
42
  magenta: wrap('35', '39'),
29
43
  cyan: wrap('36', '39'),
30
44
  gray: wrap('90', '39'),
31
- white: wrap('37', '39')
45
+ white: wrap('37', '39'),
46
+ brightRed: wrap('91', '39'),
47
+ bgRed: wrap('41;97', '49;39'),
48
+ bgYellow: wrap('43;30', '49;39'),
49
+ bgGreen: wrap('42;30', '49;39'),
50
+ bgCyan: wrap('46;30', '49;39'),
51
+ bgGray: wrap('100;97', '49;39')
32
52
  };
53
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
54
+ /** Printable width of a string, ignoring ANSI escape codes. */
55
+ function visibleLength(text) {
56
+ return [...text.replace(ANSI_PATTERN, '')].length;
57
+ }
58
+ function terminalWidth() {
59
+ const columns = process.stdout.columns || 100;
60
+ return Math.max(60, Math.min(columns, 100));
61
+ }
33
62
  function severityColor(severity, text) {
34
63
  switch (severity) {
35
64
  case 'CRITICAL':
65
+ return exports.color.brightRed(exports.color.bold(text));
36
66
  case 'HIGH':
37
67
  return exports.color.red(text);
38
68
  case 'MEDIUM':
@@ -43,6 +73,22 @@ function severityColor(severity, text) {
43
73
  return exports.color.gray(text);
44
74
  }
45
75
  }
76
+ function severityBadge(severity) {
77
+ const label = ` ${severity.padEnd(8)}`;
78
+ if (!colorEnabled)
79
+ return `[${severity}]`.padEnd(10);
80
+ switch (severity) {
81
+ case 'CRITICAL':
82
+ case 'HIGH':
83
+ return exports.color.bgRed(label);
84
+ case 'MEDIUM':
85
+ return exports.color.bgYellow(label);
86
+ case 'LOW':
87
+ return exports.color.bgCyan(label);
88
+ default:
89
+ return exports.color.bgGray(label);
90
+ }
91
+ }
46
92
  function scoreColor(score, text) {
47
93
  if (score >= 8)
48
94
  return exports.color.green(text);
@@ -57,18 +103,74 @@ function scoreLabel(score) {
57
103
  return 'Good';
58
104
  if (score >= 5)
59
105
  return 'Needs work';
60
- return 'Poor';
106
+ if (score >= 3)
107
+ return 'Poor';
108
+ return 'Critical';
109
+ }
110
+ function scoreGrade(score) {
111
+ if (score >= 9)
112
+ return 'A';
113
+ if (score >= 7)
114
+ return 'B';
115
+ if (score >= 5)
116
+ return 'C';
117
+ if (score >= 3)
118
+ return 'D';
119
+ return 'F';
61
120
  }
62
121
  function renderScoreBar(score, width = 28) {
63
122
  const filled = Math.round((Math.max(0, Math.min(10, score)) / 10) * width);
64
- const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
65
- return scoreColor(score, bar);
123
+ return scoreColor(score, exports.glyph.full.repeat(filled)) + exports.color.dim(exports.glyph.empty.repeat(width - filled));
124
+ }
125
+ /** A single horizontal bar split into colored segments, e.g. a severity distribution. */
126
+ function renderStackedBar(segments, width) {
127
+ const total = segments.reduce((sum, segment) => sum + segment.value, 0);
128
+ if (total === 0)
129
+ return exports.color.dim(exports.glyph.empty.repeat(width));
130
+ let used = 0;
131
+ return segments
132
+ .map((segment, index) => {
133
+ const isLast = index === segments.length - 1;
134
+ const cells = isLast ? width - used : Math.round((segment.value / total) * width);
135
+ const size = segment.value > 0 ? Math.max(1, Math.min(cells, width - used)) : 0;
136
+ used += size;
137
+ return segment.paint(exports.glyph.full.repeat(size));
138
+ })
139
+ .join('');
66
140
  }
67
141
  function pad(text, width) {
68
- return text.length >= width ? text : text + ' '.repeat(width - text.length);
142
+ const length = visibleLength(text);
143
+ return length >= width ? text : text + ' '.repeat(width - length);
69
144
  }
70
145
  function padStart(text, width) {
71
- return text.length >= width ? text : ' '.repeat(width - text.length) + text;
146
+ const length = visibleLength(text);
147
+ return length >= width ? text : ' '.repeat(width - length) + text;
148
+ }
149
+ function truncateMiddle(text, width) {
150
+ if (text.length <= width)
151
+ return text;
152
+ const keep = width - 1;
153
+ return `${text.slice(0, Math.ceil(keep / 3))}…${text.slice(text.length - Math.floor((keep * 2) / 3))}`;
154
+ }
155
+ /** A rounded box around pre-rendered lines. */
156
+ function renderBox(content, width) {
157
+ const inner = width - 4;
158
+ return [
159
+ exports.color.dim(`${exports.glyph.tl}${exports.glyph.h.repeat(width - 2)}${exports.glyph.tr}`),
160
+ ...content.map((line) => `${exports.color.dim(exports.glyph.v)} ${pad(line, inner)} ${exports.color.dim(exports.glyph.v)}`),
161
+ exports.color.dim(`${exports.glyph.bl}${exports.glyph.h.repeat(width - 2)}${exports.glyph.br}`)
162
+ ];
163
+ }
164
+ function sectionHeading(title, width, detail = '') {
165
+ const label = ` ${exports.color.bold(title.toUpperCase())}${detail ? ` ${exports.color.dim(detail)}` : ''} `;
166
+ return `${exports.color.dim(exports.glyph.h.repeat(2))}${label}${exports.color.dim(exports.glyph.h.repeat(Math.max(0, width - visibleLength(label) - 2)))}`;
167
+ }
168
+ function formatDuration(ms) {
169
+ if (ms < 1000)
170
+ return `${ms}ms`;
171
+ if (ms < 60_000)
172
+ return `${(ms / 1000).toFixed(1)}s`;
173
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
72
174
  }
73
175
  function renderCodeFrame(repository, relativeFile, line, column, severity) {
74
176
  const absolutePath = node_path_1.default.join(repository, relativeFile);
@@ -83,53 +185,65 @@ function renderCodeFrame(repository, relativeFile, line, column, severity) {
83
185
  const endLine = Math.min(lines.length, line + 1);
84
186
  const gutterWidth = String(endLine).length;
85
187
  const output = [];
86
- const maxWidth = 100;
188
+ const maxWidth = terminalWidth() - gutterWidth - 12;
87
189
  const truncate = (text) => (text.length > maxWidth ? `${text.slice(0, maxWidth)}…` : text);
88
190
  for (let current = startLine; current <= endLine; current += 1) {
89
- const rawText = lines[current - 1] ?? '';
191
+ const rawText = (lines[current - 1] ?? '').replace(/\t/g, ' ');
90
192
  const text = truncate(rawText);
91
193
  const gutter = padStart(String(current), gutterWidth);
92
194
  const marker = current === line ? severityColor(severity, '>') : ' ';
93
195
  if (current === line) {
94
- output.push(` ${marker} ${exports.color.bold(gutter)} ${exports.color.dim('|')} ${text}`);
196
+ output.push(` ${marker} ${exports.color.bold(gutter)} ${exports.color.dim(exports.glyph.v)} ${text}`);
95
197
  if (column > 0 && column - 1 < maxWidth) {
96
- output.push(` ${' '.repeat(gutterWidth + 2)} ${exports.color.dim('|')} ${' '.repeat(Math.max(0, column - 1))}${severityColor(severity, '^')}`);
198
+ output.push(` ${' '.repeat(gutterWidth)} ${exports.color.dim(exports.glyph.v)} ${' '.repeat(Math.max(0, column - 1))}${severityColor(severity, '^')}`);
97
199
  }
98
200
  }
99
201
  else {
100
- output.push(` ${marker} ${exports.color.dim(gutter)} ${exports.color.dim('|')} ${exports.color.dim(text)}`);
202
+ output.push(` ${marker} ${exports.color.dim(gutter)} ${exports.color.dim(exports.glyph.v)} ${exports.color.dim(text)}`);
101
203
  }
102
204
  }
103
205
  return output;
104
206
  }
105
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
106
- class Spinner {
107
- frameIndex = 0;
108
- timer;
109
- text;
110
- constructor(text) {
111
- this.text = text;
207
+ const SPINNER_FRAMES = ascii ? ['-', '\\', '|', '/'] : ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
208
+ /**
209
+ * Live scan progress on stderr. The scan itself is synchronous, so a timer-driven spinner would
210
+ * never get to draw; instead every progress update redraws the line directly (throttled).
211
+ * Writes nothing when stderr is not a TTY, so piped/CI output stays clean.
212
+ */
213
+ class ProgressRenderer {
214
+ frame = 0;
215
+ lastDraw = 0;
216
+ lastStep = 0;
217
+ enabled = Boolean(process.stderr.isTTY);
218
+ startedAt = Date.now();
219
+ update(progress) {
220
+ if (!this.enabled)
221
+ return;
222
+ const now = Date.now();
223
+ const isNewStep = progress.step !== this.lastStep;
224
+ if (!isNewStep && now - this.lastDraw < 60)
225
+ return;
226
+ this.lastDraw = now;
227
+ this.lastStep = progress.step;
228
+ this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
229
+ const width = 20;
230
+ const fraction = (progress.step - 1 + (progress.total ? (progress.current ?? 0) / progress.total : 0)) / progress.totalSteps;
231
+ const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width);
232
+ const bar = exports.color.cyan(exports.glyph.full.repeat(filled)) + exports.color.dim(exports.glyph.empty.repeat(width - filled));
233
+ const counter = progress.total ? exports.color.dim(` ${progress.current}/${progress.total}`) : '';
234
+ const percent = exports.color.dim(`${String(Math.round(fraction * 100)).padStart(3)}%`);
235
+ const elapsed = exports.color.dim(formatDuration(now - this.startedAt));
236
+ process.stderr.write(`\r\x1b[K${exports.color.cyan(SPINNER_FRAMES[this.frame])} ${bar} ${percent} ${progress.label}${counter} ${elapsed}`);
112
237
  }
113
- start() {
114
- // Written to stderr so it never pollutes piped/redirected stdout (--format json, etc.).
115
- if (!process.stderr.isTTY) {
116
- process.stderr.write(`${this.text}\n`);
238
+ finish(message) {
239
+ if (!this.enabled)
117
240
  return;
118
- }
119
- this.timer = setInterval(() => {
120
- process.stderr.write(`\r${exports.color.cyan(SPINNER_FRAMES[this.frameIndex])} ${this.text}`);
121
- this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
122
- }, 80);
241
+ process.stderr.write(`\r\x1b[K${exports.color.green(exports.glyph.check)} ${message} ${exports.color.dim(`in ${formatDuration(Date.now() - this.startedAt)}`)}\n`);
123
242
  }
124
- stop() {
125
- if (!this.timer)
243
+ fail() {
244
+ if (!this.enabled)
126
245
  return;
127
- clearInterval(this.timer);
128
- this.timer = undefined;
129
246
  process.stderr.write('\r\x1b[K');
130
247
  }
131
248
  }
132
- exports.Spinner = Spinner;
133
- function waitForNextTick() {
134
- return new Promise((resolve) => setImmediate(resolve));
135
- }
249
+ exports.ProgressRenderer = ProgressRenderer;
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "devkit-quality",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Deterministic repository quality scanner for JavaScript and TypeScript projects",
5
5
  "bin": {
6
- "devkit-quality": "bin/devkit.js"
6
+ "devkit": "bin/devkit.js"
7
7
  },
8
8
  "main": "dist/cli.js",
9
9
  "files": [
10
10
  "dist",
11
11
  "bin",
12
12
  "README.md",
13
- "LICENSE"
13
+ "LICENSE",
14
+ "CHANGELOG.md"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsc -p tsconfig.json",
@@ -24,6 +25,10 @@
24
25
  "type": "git",
25
26
  "url": "git+https://github.com/Karan071/devkit-cli.git"
26
27
  },
28
+ "homepage": "https://github.com/Karan071/devkit-cli#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/Karan071/devkit-cli/issues"
31
+ },
27
32
  "keywords": [
28
33
  "cli",
29
34
  "typescript",
@@ -34,6 +39,9 @@
34
39
  "license": "MIT",
35
40
  "author": "Karan Chourasia",
36
41
  "type": "commonjs",
42
+ "engines": {
43
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
44
+ },
37
45
  "dependencies": {
38
46
  "commander": "^12.1.0",
39
47
  "typescript": "^5.7.2"