@emeryld/manager 1.1.0 → 1.3.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.
@@ -1,7 +1,8 @@
1
1
  import { colors } from '../../utils/log.js';
2
- import { stdin as input, stdout as output } from 'node:process';
3
- import { askLine } from '../../prompts.js';
2
+ import { stdin as input } from 'node:process';
3
+ import { askLine, promptSingleKey } from '../../prompts.js';
4
4
  import { formatValue, parseInteractiveValue, SETTING_DESCRIPTORS, validateLimits, } from './settings.js';
5
+ import { promptInteractiveSettings, } from '../../cli/interactive-settings.js';
5
6
  const READY_PROMPT = colors.dim('Limits retained. Use the navigation above to adjust and confirm again.');
6
7
  export async function promptLimits(defaults) {
7
8
  const supportsInteractive = typeof input.setRawMode === 'function' && input.isTTY;
@@ -19,14 +20,16 @@ export async function promptLimits(defaults) {
19
20
  }
20
21
  async function confirmExecution() {
21
22
  const question = colors.cyan('Run the format checker with these limits? (Y/n): ');
22
- while (true) {
23
- const answer = (await askLine(question)).trim().toLowerCase();
24
- if (!answer || answer === 'y' || answer === 'yes')
23
+ return promptSingleKey(question, (key, raw) => {
24
+ if (!key)
25
+ return undefined;
26
+ if (key === 'y' || key === 'yes')
25
27
  return true;
26
- if (answer === 'n' || answer === 'no')
28
+ if (key === 'n' || key === 'no')
27
29
  return false;
28
- console.log(colors.yellow('Answer "yes" or "no", or press Enter to proceed.'));
29
- }
30
+ console.log(colors.yellow('Answer "yes" or "no".'));
31
+ return undefined;
32
+ });
30
33
  }
31
34
  async function promptLimitsSequential(defaults) {
32
35
  console.log(colors.dim('Enter a number to override a limit or press Enter to keep the default.'));
@@ -44,193 +47,24 @@ async function promptLimitsSequential(defaults) {
44
47
  };
45
48
  }
46
49
  async function promptLimitsInteractive(defaults) {
47
- if (typeof input.setRawMode !== 'function' || !input.isTTY) {
48
- return promptLimitsSequential(defaults);
49
- }
50
- return new Promise((resolve) => {
51
- const wasRaw = input.isRaw;
52
- if (!wasRaw) {
53
- input.setRawMode(true);
54
- input.resume();
55
- }
56
- output.write('\x1b[?25l');
57
- const state = {
58
- selectedIndex: 0,
59
- editingIndex: null,
60
- editBuffer: '',
61
- justFocused: false,
62
- errorMessage: '',
63
- renderedLines: 0,
64
- };
65
- const limits = { ...defaults };
66
- const writableLimits = limits;
67
- const cleanup = () => {
68
- if (state.renderedLines > 0) {
69
- output.write(`\x1b[${state.renderedLines}A`);
70
- output.write('\x1b[0J');
71
- state.renderedLines = 0;
72
- }
73
- output.write('\x1b[?25h');
74
- if (!wasRaw) {
75
- input.setRawMode(false);
76
- input.pause();
77
- }
78
- input.off('data', onData);
79
- };
80
- const finalize = () => {
81
- cleanup();
82
- console.log();
83
- resolve(limits);
84
- };
85
- const render = () => {
86
- const lines = buildInteractiveLines(limits, state.selectedIndex, state.editingIndex, state.editBuffer, state.errorMessage);
87
- if (state.renderedLines > 0) {
88
- output.write(`\x1b[${state.renderedLines}A`);
89
- output.write('\x1b[0J');
90
- }
91
- lines.forEach((line) => console.log(line));
92
- state.renderedLines = lines.length;
93
- };
94
- const startEditing = () => {
95
- state.editingIndex = state.selectedIndex;
96
- state.editBuffer = '';
97
- state.justFocused = true;
98
- state.errorMessage = '';
99
- };
100
- const moveSelection = (delta) => {
101
- state.selectedIndex =
102
- (state.selectedIndex + delta + SETTING_DESCRIPTORS.length) %
103
- SETTING_DESCRIPTORS.length;
104
- state.editingIndex = null;
105
- state.editBuffer = '';
106
- state.justFocused = false;
107
- state.errorMessage = '';
108
- render();
109
- };
110
- const commitEdit = () => {
111
- if (state.editingIndex === null)
112
- return;
113
- const descriptor = SETTING_DESCRIPTORS[state.editingIndex];
114
- const parsed = parseInteractiveValue(descriptor, state.editBuffer);
115
- if (parsed.error) {
116
- state.errorMessage = parsed.error;
117
- process.stdout.write('\x07');
118
- render();
119
- return;
120
- }
121
- if (parsed.value !== undefined) {
122
- writableLimits[descriptor.key] = parsed.value;
123
- }
124
- state.editingIndex = null;
125
- state.editBuffer = '';
126
- state.justFocused = false;
127
- state.errorMessage = '';
128
- render();
129
- };
130
- const handlePrintable = (typedChar) => {
131
- if (state.editingIndex !== state.selectedIndex) {
132
- startEditing();
133
- }
134
- if (state.justFocused) {
135
- state.editBuffer = typedChar;
136
- state.justFocused = false;
137
- }
138
- else {
139
- state.editBuffer += typedChar;
140
- }
141
- render();
142
- };
143
- const handleBackspace = () => {
144
- if (state.editingIndex !== state.selectedIndex) {
145
- startEditing();
146
- state.justFocused = false;
147
- }
148
- if (state.editBuffer.length > 0) {
149
- state.editBuffer = state.editBuffer.slice(0, -1);
150
- render();
151
- return;
152
- }
153
- process.stdout.write('\x07');
154
- };
155
- const onData = (buffer) => {
156
- const ascii = buffer.length === 1 ? buffer[0] : undefined;
157
- const isPrintable = ascii !== undefined && ascii >= 0x20 && ascii <= 0x7e;
158
- const typedChar = isPrintable && ascii !== undefined ? String.fromCharCode(ascii) : '';
159
- const isArrowUp = buffer.equals(Buffer.from([0x1b, 0x5b, 0x41]));
160
- const isArrowDown = buffer.equals(Buffer.from([0x1b, 0x5b, 0x42]));
161
- const isEnter = buffer.length === 1 && (buffer[0] === 0x0d || buffer[0] === 0x0a);
162
- const isEscape = buffer.length === 1 && buffer[0] === 0x1b;
163
- const isCtrlC = buffer.length === 1 && buffer[0] === 0x03;
164
- const isBackspace = buffer.length === 1 && (buffer[0] === 0x7f || buffer[0] === 0x08);
165
- if (isCtrlC || isEscape) {
166
- cleanup();
167
- process.exit(1);
168
- }
169
- if (isArrowUp ||
170
- (buffer.length === 1 && (buffer[0] === 0x6b || buffer[0] === 0x4b))) {
171
- moveSelection(-1);
172
- return;
173
- }
174
- if (isArrowDown ||
175
- (buffer.length === 1 && (buffer[0] === 0x6a || buffer[0] === 0x4a))) {
176
- moveSelection(1);
177
- return;
178
- }
179
- if (isEnter) {
180
- if (state.editingIndex === state.selectedIndex) {
181
- commitEdit();
182
- return;
183
- }
184
- const validation = validateLimits(limits);
185
- if (validation) {
186
- state.errorMessage = validation;
187
- process.stdout.write('\x07');
188
- render();
189
- return;
190
- }
191
- finalize();
192
- return;
193
- }
194
- if (isBackspace) {
195
- handleBackspace();
196
- return;
197
- }
198
- if (isPrintable && typedChar) {
199
- handlePrintable(typedChar);
200
- return;
201
- }
202
- process.stdout.write('\x07');
203
- };
204
- input.on('data', onData);
205
- render();
50
+ const descriptors = SETTING_DESCRIPTORS.map((descriptor) => ({
51
+ key: descriptor.key,
52
+ label: descriptor.label,
53
+ unit: descriptor.unit,
54
+ format: (value) => formatValue(value, descriptor),
55
+ parse: (buffer) => parseInteractiveValue(descriptor, buffer),
56
+ }));
57
+ return promptInteractiveSettings({
58
+ title: 'Format checker settings (type to edit values)',
59
+ descriptors,
60
+ initial: defaults,
61
+ instructions: [
62
+ 'Use ↑/↓ to change rows, type to replace the highlighted value, Backspace to clear characters, and Enter to validate and confirm the selection.',
63
+ 'Press Esc/Ctrl+C to abort, or hit Enter again after reviewing the summary to continue.',
64
+ ],
65
+ validate: validateLimits,
206
66
  });
207
67
  }
208
- function buildInteractiveLines(limits, selectedIndex, editingIndex, editBuffer, errorMessage) {
209
- const heading = colors.bold('Format checker settings (type to edit values)');
210
- const lines = [heading, ''];
211
- SETTING_DESCRIPTORS.forEach((descriptor, index) => {
212
- const isSelected = index === selectedIndex;
213
- const pointer = isSelected ? colors.green('➤') : ' ';
214
- const label = descriptor.unit
215
- ? `${descriptor.label} (${descriptor.unit})`
216
- : descriptor.label;
217
- const isActive = editingIndex === index;
218
- const baseValue = formatValue(limits[descriptor.key], descriptor);
219
- const displayValue = isActive
220
- ? colors.yellow(editBuffer.length > 0 ? editBuffer : baseValue)
221
- : colors.magenta(baseValue);
222
- const labelColor = isSelected ? colors.green : colors.cyan;
223
- const line = `${pointer} ${labelColor(label)}: ${displayValue}`;
224
- lines.push(line);
225
- });
226
- lines.push('');
227
- lines.push(colors.dim('Use ↑/↓ to change rows, type to replace the highlighted value, Backspace to clear characters, and Enter to validate and confirm the selection.'));
228
- lines.push(colors.dim('Press Esc/Ctrl+C to abort, or hit Enter again after reviewing the summary to continue.'));
229
- if (errorMessage) {
230
- lines.push(colors.red(`⚠ ${errorMessage}`));
231
- }
232
- return lines;
233
- }
234
68
  async function promptNumber(label, fallback, unit, options) {
235
69
  const question = colors.cyan(`${label} (${unit}) [default ${fallback}]: `);
236
70
  while (true) {
@@ -1,4 +1,7 @@
1
1
  import { colors } from '../utils/log.js';
2
+ import { captureConsoleOutput, exportReportLines, promptExportMode, } from '../utils/export.js';
3
+ import { promptDirectorySelection, describeDirectorySelection } from '../directory-picker.js';
4
+ import { runHelperCli } from '../helper-cli.js';
2
5
  import { rootDir } from '../helper-cli/env.js';
3
6
  import { loadFormatLimits, } from './config.js';
4
7
  import { collectSourceFiles, analyzeFiles } from './scan/index.js';
@@ -8,11 +11,57 @@ import { parseScanCliArgs, printScanUsage } from './cli/options.js';
8
11
  export async function runFormatChecker() {
9
12
  console.log(colors.cyan('Gathering defaults from .vscode/settings.json (manager.formatChecker)'));
10
13
  const defaults = await loadFormatLimits();
11
- const limits = await promptLimits(defaults);
12
- await executeFormatCheck(limits);
14
+ let limits = defaults;
15
+ let exportMode = 'console';
16
+ while (true) {
17
+ let lastAction;
18
+ const scripts = [
19
+ {
20
+ name: 'Run format checker',
21
+ emoji: '🧮',
22
+ description: `${formatLimitsSummary(limits)} · export=${exportMode}`,
23
+ handler: async () => {
24
+ const selection = await promptDirectorySelection({
25
+ title: 'Select directory for format scan',
26
+ });
27
+ if (!selection)
28
+ return;
29
+ lastAction = 'run';
30
+ await executeFormatCheck(limits, exportMode, selection.absolutePath, describeDirectorySelection(selection));
31
+ },
32
+ },
33
+ {
34
+ name: 'Adjust limits',
35
+ emoji: '⚙️',
36
+ description: formatLimitsSummary(limits),
37
+ handler: async () => {
38
+ limits = await promptLimits(limits);
39
+ lastAction = 'configure';
40
+ },
41
+ },
42
+ {
43
+ name: 'Change export mode',
44
+ emoji: '📤',
45
+ description: `Current: ${exportMode}`,
46
+ handler: async () => {
47
+ exportMode = await promptExportMode();
48
+ lastAction = 'export';
49
+ },
50
+ },
51
+ ];
52
+ const ran = await runHelperCli({
53
+ title: 'Format checker helper',
54
+ scripts,
55
+ argv: [],
56
+ });
57
+ if (!ran)
58
+ return;
59
+ if (lastAction === 'run')
60
+ return;
61
+ }
13
62
  }
14
63
  export async function runFormatCheckerScanCli(argv) {
15
- const { overrides, help } = parseScanCliArgs(argv);
64
+ const { overrides, help, exportMode } = parseScanCliArgs(argv);
16
65
  if (help) {
17
66
  printScanUsage();
18
67
  return;
@@ -20,19 +69,30 @@ export async function runFormatCheckerScanCli(argv) {
20
69
  const defaults = await loadFormatLimits();
21
70
  const limits = { ...defaults, ...overrides };
22
71
  console.log(colors.cyan('Running format checker scan (machine-friendly)'));
23
- await executeFormatCheck(limits);
72
+ await executeFormatCheck(limits, exportMode);
24
73
  }
25
- async function executeFormatCheck(limits) {
26
- console.log(colors.magenta('Scanning workspace for source files...'));
27
- const files = await collectSourceFiles(rootDir);
74
+ async function executeFormatCheck(limits, exportMode, scanRoot = rootDir, scopeLabel = 'workspace') {
75
+ console.log(colors.magenta(`Scanning ${scopeLabel} for source files...`));
76
+ const files = await collectSourceFiles(scanRoot);
28
77
  if (files.length === 0) {
29
78
  console.log(colors.yellow('No source files were found to analyze.'));
30
79
  return;
31
80
  }
32
81
  console.log(colors.magenta(`Analyzing ${files.length} files`));
33
82
  const violations = await analyzeFiles(files, limits);
34
- const hadViolations = reportViolations(violations, limits.reportingMode);
83
+ const hadViolations = await handleViolations(violations, limits.reportingMode, exportMode);
35
84
  if (!hadViolations) {
36
85
  console.log(colors.green('Workspace meets the configured format limits.'));
37
86
  }
38
87
  }
88
+ async function handleViolations(violations, reportingMode, exportMode) {
89
+ if (exportMode === 'console') {
90
+ return reportViolations(violations, reportingMode);
91
+ }
92
+ const { result, lines } = captureConsoleOutput(() => reportViolations(violations, reportingMode));
93
+ await exportReportLines('format-checker', 'txt', lines);
94
+ return result;
95
+ }
96
+ function formatLimitsSummary(limits) {
97
+ return `limits: fn≤${limits.maxFunctionLength} · indent≤${limits.maxIndentationDepth} · reporting=${limits.reportingMode} · exportOnly=${limits.exportOnly}`;
98
+ }
@@ -109,6 +109,9 @@ function logViolationEntry(entry, messagePrefix, locationDescriptor) {
109
109
  console.log(` ${colors.yellow(line)}`);
110
110
  });
111
111
  }
112
+ if (entry.requiredVariables?.length) {
113
+ console.log(` ${colors.dim('Required variables:')} ${entry.requiredVariables.join(', ')}`);
114
+ }
112
115
  if (locationDescriptor) {
113
116
  console.log(` ${colors.dim(locationDescriptor)}`);
114
117
  }
@@ -2,7 +2,9 @@ import { readFile } from 'node:fs/promises';
2
2
  import { collectDuplicateViolations, recordDuplicateLines, } from './duplicates.js';
3
3
  import { countIndentation, groupIndentationViolations, } from './indentation.js';
4
4
  import { collectFunctionRecords } from './functions.js';
5
- import { markImportLines, normalizeLine } from './utils.js';
5
+ import { markImportLines, normalizeLine, resolveScriptKind } from './utils.js';
6
+ import { collectRequiredVariables } from './variables.js';
7
+ import * as ts from 'typescript';
6
8
  export async function analyzeFiles(files, limits) {
7
9
  const violations = [];
8
10
  const duplicateMap = new Map();
@@ -45,12 +47,19 @@ async function analyzeSingleFile(filePath, limits, duplicates) {
45
47
  indentationViolations.push({ line: index + 1, indent });
46
48
  }
47
49
  });
48
- recordDuplicateLines(normalizedLines, filePath, limits, duplicates);
50
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.ESNext, true, resolveScriptKind(filePath));
51
+ recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines);
49
52
  const indentationGroups = groupIndentationViolations(indentationViolations, lines, limits.maxIndentationDepth);
50
53
  indentationGroups
51
54
  .sort((a, b) => b.severity - a.severity)
52
55
  .slice(0, 4)
53
56
  .forEach((group) => {
57
+ const requiredVariables = collectRequiredVariables(sourceFile, lines, {
58
+ startLine: group.startLine,
59
+ startColumn: group.startColumn,
60
+ endLine: group.endLine,
61
+ endColumn: group.endColumn,
62
+ });
54
63
  violations.push({
55
64
  type: 'indentation',
56
65
  file: filePath,
@@ -59,9 +68,10 @@ async function analyzeSingleFile(filePath, limits, duplicates) {
59
68
  message: `Indentation level ${group.maxIndent} exceeds max ${limits.maxIndentationDepth}`,
60
69
  snippet: group.snippet,
61
70
  detail: `${group.startLine}:${group.startColumn}/${group.endLine}:${group.endColumn}`,
71
+ requiredVariables,
62
72
  });
63
73
  });
64
- const allFunctions = collectFunctionRecords(content, filePath);
74
+ const allFunctions = collectFunctionRecords(sourceFile);
65
75
  const functions = limits.exportOnly
66
76
  ? allFunctions.filter((record) => record.isExported)
67
77
  : allFunctions;
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { getLineColumnRange } from './utils.js';
3
- export function recordDuplicateLines(normalizedLines, filePath, limits, duplicates) {
3
+ import { collectRequiredVariables } from './variables.js';
4
+ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines) {
4
5
  const minLines = Math.max(1, limits.minDuplicateLines);
5
6
  if (limits.minDuplicateLines <= 0 || minLines === 1) {
6
7
  normalizedLines.forEach((entry, index) => {
@@ -12,7 +13,7 @@ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicat
12
13
  if (entry.isImport)
13
14
  return;
14
15
  const { startColumn, endColumn } = getLineColumnRange(entry.raw);
15
- addDuplicateOccurrence(duplicates, entry.normalized, filePath, index + 1, startColumn, index + 1, endColumn, snippet);
16
+ addDuplicateOccurrence(duplicates, entry.normalized, filePath, index + 1, startColumn, index + 1, endColumn, snippet, sourceFile, lines);
16
17
  });
17
18
  return;
18
19
  }
@@ -47,10 +48,10 @@ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicat
47
48
  const endLine = i + 1 + lastNonEmptyIndex;
48
49
  const { startColumn } = getLineColumnRange(slice[firstNonEmptyIndex].raw);
49
50
  const { endColumn } = getLineColumnRange(slice[lastNonEmptyIndex].raw);
50
- addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet);
51
+ addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet, sourceFile, lines);
51
52
  }
52
53
  }
53
- function addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet) {
54
+ function addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet, sourceFile, lines) {
54
55
  const tracker = duplicates.get(key);
55
56
  if (tracker) {
56
57
  tracker.count += 1;
@@ -69,6 +70,12 @@ function addDuplicateOccurrence(duplicates, key, filePath, startLine, startColum
69
70
  duplicates.set(key, {
70
71
  count: 1,
71
72
  snippet,
73
+ requiredVariables: collectRequiredVariables(sourceFile, lines, {
74
+ startLine,
75
+ startColumn,
76
+ endLine,
77
+ endColumn,
78
+ }),
72
79
  occurrences: [
73
80
  {
74
81
  file: filePath,
@@ -102,6 +109,7 @@ export function collectDuplicateViolations(duplicates, limits) {
102
109
  .join('\n'),
103
110
  snippet: tracker.snippet,
104
111
  repeatCount: tracker.count,
112
+ requiredVariables: tracker.requiredVariables,
105
113
  });
106
114
  }
107
115
  return violations;
@@ -1,7 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { resolveScriptKind } from './utils.js';
3
- export function collectFunctionRecords(content, filePath) {
4
- const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.ESNext, true, resolveScriptKind(filePath));
2
+ export function collectFunctionRecords(sourceFile) {
5
3
  const records = [];
6
4
  function visit(node) {
7
5
  if ((ts.isFunctionDeclaration(node) ||