@emeryld/manager 1.1.0 → 1.2.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,4 +1,5 @@
1
1
  import { colors } from '../utils/log.js';
2
+ import { captureConsoleOutput, exportReportLines, promptExportMode, } from '../utils/export.js';
2
3
  import { rootDir } from '../helper-cli/env.js';
3
4
  import { loadFormatLimits, } from './config.js';
4
5
  import { collectSourceFiles, analyzeFiles } from './scan/index.js';
@@ -9,10 +10,11 @@ export async function runFormatChecker() {
9
10
  console.log(colors.cyan('Gathering defaults from .vscode/settings.json (manager.formatChecker)'));
10
11
  const defaults = await loadFormatLimits();
11
12
  const limits = await promptLimits(defaults);
12
- await executeFormatCheck(limits);
13
+ const exportMode = await promptExportMode();
14
+ await executeFormatCheck(limits, exportMode);
13
15
  }
14
16
  export async function runFormatCheckerScanCli(argv) {
15
- const { overrides, help } = parseScanCliArgs(argv);
17
+ const { overrides, help, exportMode } = parseScanCliArgs(argv);
16
18
  if (help) {
17
19
  printScanUsage();
18
20
  return;
@@ -20,9 +22,9 @@ export async function runFormatCheckerScanCli(argv) {
20
22
  const defaults = await loadFormatLimits();
21
23
  const limits = { ...defaults, ...overrides };
22
24
  console.log(colors.cyan('Running format checker scan (machine-friendly)'));
23
- await executeFormatCheck(limits);
25
+ await executeFormatCheck(limits, exportMode);
24
26
  }
25
- async function executeFormatCheck(limits) {
27
+ async function executeFormatCheck(limits, exportMode) {
26
28
  console.log(colors.magenta('Scanning workspace for source files...'));
27
29
  const files = await collectSourceFiles(rootDir);
28
30
  if (files.length === 0) {
@@ -31,8 +33,16 @@ async function executeFormatCheck(limits) {
31
33
  }
32
34
  console.log(colors.magenta(`Analyzing ${files.length} files`));
33
35
  const violations = await analyzeFiles(files, limits);
34
- const hadViolations = reportViolations(violations, limits.reportingMode);
36
+ const hadViolations = await handleViolations(violations, limits.reportingMode, exportMode);
35
37
  if (!hadViolations) {
36
38
  console.log(colors.green('Workspace meets the configured format limits.'));
37
39
  }
38
40
  }
41
+ async function handleViolations(violations, reportingMode, exportMode) {
42
+ if (exportMode === 'console') {
43
+ return reportViolations(violations, reportingMode);
44
+ }
45
+ const { result, lines } = captureConsoleOutput(() => reportViolations(violations, reportingMode));
46
+ await exportReportLines('format-checker', 'txt', lines);
47
+ return result;
48
+ }
@@ -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) ||
@@ -0,0 +1,246 @@
1
+ import * as ts from 'typescript';
2
+ const RANGE_CLAMP_MIN = 0;
3
+ export const DEFAULT_REQUIRED_VARIABLE_EXCLUSIONS = new Set(['Object', 'console', 'Boolean', 'Number', 'String', 'Array', 'Math', 'Date', 'Set', 'Map', 'WeakSet', 'WeakMap', 'Symbol', 'Promise', 'Error', 'RegExp', 'JSON', 'Intl', 'Reflect', 'Proxy', 'globalThis']);
4
+ export function collectRequiredVariables(sourceFile, lines, range, exclusions) {
5
+ if (!lines.length)
6
+ return [];
7
+ const lastLineIndex = Math.max(0, lines.length - 1);
8
+ let startLineIndex = clamp(range.startLine - 1, RANGE_CLAMP_MIN, lastLineIndex);
9
+ let endLineIndex = clamp(range.endLine - 1, RANGE_CLAMP_MIN, lastLineIndex);
10
+ if (startLineIndex > endLineIndex) {
11
+ const temp = startLineIndex;
12
+ startLineIndex = endLineIndex;
13
+ endLineIndex = temp;
14
+ }
15
+ const startLineText = lines[startLineIndex] ?? '';
16
+ const endLineText = lines[endLineIndex] ?? '';
17
+ const startColumnIndex = clamp(range.startColumn - 1, RANGE_CLAMP_MIN, startLineText.length);
18
+ let endColumnIndex = clamp(range.endColumn - 1, RANGE_CLAMP_MIN, endLineText.length);
19
+ if (startLineIndex === endLineIndex &&
20
+ endColumnIndex < startColumnIndex) {
21
+ endColumnIndex = startLineText.length;
22
+ }
23
+ const rangeStart = sourceFile.getPositionOfLineAndCharacter(startLineIndex, startColumnIndex);
24
+ const rangeEnd = sourceFile.getPositionOfLineAndCharacter(endLineIndex, endColumnIndex);
25
+ const declared = new Set();
26
+ const referenced = new Set();
27
+ const exclusionSet = new Set(DEFAULT_REQUIRED_VARIABLE_EXCLUSIONS);
28
+ if (exclusions) {
29
+ for (const value of exclusions) {
30
+ exclusionSet.add(value);
31
+ }
32
+ }
33
+ function declareSymbol(name) {
34
+ if (!name)
35
+ return;
36
+ declared.add(name);
37
+ referenced.delete(name);
38
+ }
39
+ function isIdentifierLike(node) {
40
+ return ts.isIdentifier(node);
41
+ }
42
+ function addDeclaration(node) {
43
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && node.name) {
44
+ collectBindingNames(node.name, declareSymbol);
45
+ return;
46
+ }
47
+ if (ts.isBindingElement(node) && node.name) {
48
+ collectBindingNames(node.name, declareSymbol);
49
+ return;
50
+ }
51
+ if ((ts.isFunctionDeclaration(node) ||
52
+ ts.isFunctionExpression(node) ||
53
+ ts.isArrowFunction(node)) &&
54
+ node.name) {
55
+ declareSymbol(node.name.text);
56
+ return;
57
+ }
58
+ if ((ts.isClassDeclaration(node) || ts.isClassExpression(node)) && node.name) {
59
+ declareSymbol(node.name.text);
60
+ return;
61
+ }
62
+ if (ts.isEnumDeclaration(node) && node.name) {
63
+ declareSymbol(node.name.text);
64
+ return;
65
+ }
66
+ if (ts.isInterfaceDeclaration(node) && node.name) {
67
+ declareSymbol(node.name.text);
68
+ return;
69
+ }
70
+ if (ts.isTypeAliasDeclaration(node) && node.name) {
71
+ declareSymbol(node.name.text);
72
+ return;
73
+ }
74
+ if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) {
75
+ declareSymbol(node.name.text);
76
+ return;
77
+ }
78
+ if (ts.isImportClause(node) && node.name) {
79
+ declareSymbol(node.name.text);
80
+ return;
81
+ }
82
+ if (ts.isNamespaceImport(node) && node.name) {
83
+ declareSymbol(node.name.text);
84
+ return;
85
+ }
86
+ if (ts.isImportSpecifier(node) && node.name) {
87
+ declareSymbol(node.name.text);
88
+ }
89
+ }
90
+ function collectBindingNames(name, collector) {
91
+ if (ts.isIdentifier(name)) {
92
+ if (name.text) {
93
+ collector(name.text);
94
+ }
95
+ return;
96
+ }
97
+ if (ts.isObjectBindingPattern(name)) {
98
+ for (const element of name.elements) {
99
+ if (element.name) {
100
+ collectBindingNames(element.name, collector);
101
+ }
102
+ }
103
+ return;
104
+ }
105
+ if (ts.isArrayBindingPattern(name)) {
106
+ for (const element of name.elements) {
107
+ if (ts.isBindingElement(element) && element.name) {
108
+ collectBindingNames(element.name, collector);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ function getIdentifierText(node) {
114
+ const escaped = node.escapedText;
115
+ if (typeof escaped === 'string')
116
+ return escaped;
117
+ if (escaped !== undefined)
118
+ return String(escaped);
119
+ return node.getText(sourceFile);
120
+ }
121
+ function shouldSkipIdentifier(node) {
122
+ const parent = node.parent;
123
+ if (!parent)
124
+ return true;
125
+ if ((ts.isPropertyAccessExpression(parent) ||
126
+ ts.isPropertyAccessChain(parent)) &&
127
+ parent.name === node) {
128
+ return true;
129
+ }
130
+ if ((ts.isPropertyAssignment(parent) ||
131
+ ts.isPropertySignature(parent) ||
132
+ ts.isPropertyDeclaration(parent) ||
133
+ ts.isMethodDeclaration(parent) ||
134
+ ts.isMethodSignature(parent) ||
135
+ ts.isGetAccessorDeclaration(parent) ||
136
+ ts.isSetAccessorDeclaration(parent)) &&
137
+ parent.name === node) {
138
+ return true;
139
+ }
140
+ if (ts.isIndexSignatureDeclaration(parent))
141
+ return true;
142
+ if (ts.isEnumMember(parent) && parent.name === node)
143
+ return true;
144
+ if (ts.isBindingElement(parent) && parent.propertyName === node)
145
+ return true;
146
+ if (ts.isTypeReferenceNode(parent))
147
+ return true;
148
+ if (ts.isTypePredicateNode(parent))
149
+ return true;
150
+ if (ts.isTypeQueryNode(parent))
151
+ return true;
152
+ if (ts.isImportClause(parent) && parent.name === node)
153
+ return true;
154
+ if (ts.isNamespaceImport(parent) && parent.name === node)
155
+ return true;
156
+ if (ts.isImportSpecifier(parent)) {
157
+ if (parent.propertyName === node)
158
+ return true;
159
+ if (parent.name === node)
160
+ return true;
161
+ }
162
+ if (ts.isJsxAttribute(parent) && parent.name === node)
163
+ return true;
164
+ if (ts.isJsxNamespacedName(parent) && parent.name === node)
165
+ return true;
166
+ if (ts.isLabeledStatement(parent) && parent.label === node)
167
+ return true;
168
+ if ((ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) &&
169
+ parent.label === node) {
170
+ return true;
171
+ }
172
+ if (ts.isMetaProperty(parent))
173
+ return true;
174
+ if (ts.isTypeAliasDeclaration(parent) && parent.name === node)
175
+ return true;
176
+ if (ts.isInterfaceDeclaration(parent) && parent.name === node)
177
+ return true;
178
+ if (ts.isClassDeclaration(parent) && parent.name === node)
179
+ return true;
180
+ return false;
181
+ }
182
+ function addReference(node) {
183
+ const name = getIdentifierText(node);
184
+ if (!name)
185
+ return;
186
+ if (shouldSkipIdentifier(node))
187
+ return;
188
+ if (isDeclarationIdentifier(node))
189
+ return;
190
+ if (declared.has(name))
191
+ return;
192
+ if (exclusionSet.has(name))
193
+ return;
194
+ referenced.add(name);
195
+ }
196
+ function isDeclarationIdentifier(node) {
197
+ const parent = node.parent;
198
+ if (!parent)
199
+ return false;
200
+ if ((ts.isVariableDeclaration(parent) ||
201
+ ts.isParameter(parent) ||
202
+ ts.isBindingElement(parent)) &&
203
+ parent.name === node) {
204
+ return true;
205
+ }
206
+ if ((ts.isFunctionDeclaration(parent) ||
207
+ ts.isFunctionExpression(parent) ||
208
+ ts.isArrowFunction(parent) ||
209
+ ts.isClassDeclaration(parent) ||
210
+ ts.isClassExpression(parent) ||
211
+ ts.isEnumDeclaration(parent) ||
212
+ ts.isInterfaceDeclaration(parent) ||
213
+ ts.isTypeAliasDeclaration(parent)) &&
214
+ parent.name === node) {
215
+ return true;
216
+ }
217
+ if ((ts.isImportClause(parent) ||
218
+ ts.isNamespaceImport(parent) ||
219
+ ts.isImportSpecifier(parent)) &&
220
+ parent.name === node) {
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+ function overlapsRange(node) {
226
+ const nodeStart = node.getStart(sourceFile);
227
+ const nodeEnd = node.end;
228
+ return nodeEnd >= rangeStart && nodeStart <= rangeEnd;
229
+ }
230
+ function visit(node) {
231
+ if (!overlapsRange(node))
232
+ return;
233
+ addDeclaration(node);
234
+ if (isIdentifierLike(node)) {
235
+ addReference(node);
236
+ }
237
+ ts.forEachChild(node, visit);
238
+ }
239
+ visit(sourceFile);
240
+ return [...referenced].sort();
241
+ }
242
+ function clamp(value, min, max) {
243
+ if (min > max)
244
+ return min;
245
+ return Math.min(Math.max(value, min), max);
246
+ }
package/dist/menu.js CHANGED
@@ -10,6 +10,7 @@ import { openDockerHelper } from './docker.js';
10
10
  import { run } from './utils/run.js';
11
11
  import { makeBaseScriptEntries, getPackageMarker, getPackageModule } from './menu/script-helpers.js';
12
12
  import { runFormatChecker } from './format-checker/index.js';
13
+ import { runRobot } from './robot/index.js';
13
14
  import { describeVersionControlScope, makeVersionControlEntries, } from './version-control.js';
14
15
  function formatKindLabel(kind) {
15
16
  if (kind === 'cli')
@@ -77,6 +78,14 @@ function makeManagerStepEntries(targets, packages, state) {
77
78
  await runFormatChecker();
78
79
  },
79
80
  },
81
+ {
82
+ name: 'robot metadata',
83
+ emoji: '🤖',
84
+ description: 'Collect functions, components, types, consts, and classes across the workspace',
85
+ handler: async () => {
86
+ await runRobot();
87
+ },
88
+ },
80
89
  {
81
90
  name: 'build',
82
91
  emoji: '🏗️',
@@ -0,0 +1,102 @@
1
+ import { colors } from '../../utils/log.js';
2
+ import { askLine } from '../../prompts.js';
3
+ import { formatValue, parseInteractiveValue, parseKindsInput, SETTING_DESCRIPTORS, validateRobotSettings, } from './settings.js';
4
+ import { promptInteractiveSettings, } from '../../cli/interactive-settings.js';
5
+ import { stdin as input } from 'node:process';
6
+ const READY_PROMPT = colors.dim('Settings retained. Use the keys above to adjust and confirm again.');
7
+ export async function promptRobotSettings(defaults) {
8
+ const supportsInteractive = typeof input.setRawMode === 'function' && input.isTTY;
9
+ let currentSettings = defaults;
10
+ while (true) {
11
+ const chosen = supportsInteractive
12
+ ? await promptRobotSettingsInteractive(currentSettings)
13
+ : await promptRobotSettingsSequential(currentSettings);
14
+ const confirmed = await confirmExecution();
15
+ if (confirmed)
16
+ return chosen;
17
+ currentSettings = chosen;
18
+ console.log(READY_PROMPT);
19
+ }
20
+ }
21
+ async function confirmExecution() {
22
+ const question = colors.cyan('Run the robot extractor with these settings? (Y/n): ');
23
+ while (true) {
24
+ const answer = (await askLine(question)).trim().toLowerCase();
25
+ if (!answer || answer === 'y' || answer === 'yes')
26
+ return true;
27
+ if (answer === 'n' || answer === 'no')
28
+ return false;
29
+ console.log(colors.yellow('Answer "yes" or "no", or press Enter to proceed.'));
30
+ }
31
+ }
32
+ async function promptRobotSettingsSequential(defaults) {
33
+ console.log(colors.dim('Enter values to override defaults or press Enter to keep the current setting.'));
34
+ return {
35
+ includeKinds: await promptKinds(defaults.includeKinds),
36
+ exportedOnly: await promptBoolean('Only consider exported symbols', defaults.exportedOnly),
37
+ maxColumns: await promptNumber('Maximum columns', defaults.maxColumns),
38
+ };
39
+ }
40
+ async function promptKinds(fallback) {
41
+ const descriptor = SETTING_DESCRIPTORS.find((entry) => entry.key === 'includeKinds');
42
+ if (!descriptor)
43
+ return fallback;
44
+ const question = colors.cyan(`Kinds to include [default ${formatValue(fallback, descriptor)}]: `);
45
+ while (true) {
46
+ const answer = await askLine(question);
47
+ if (!answer)
48
+ return fallback;
49
+ const parsed = parseKindsInput(answer);
50
+ if (parsed.error) {
51
+ console.log(colors.yellow(parsed.error));
52
+ continue;
53
+ }
54
+ if (parsed.value)
55
+ return parsed.value;
56
+ }
57
+ }
58
+ async function promptBoolean(label, fallback) {
59
+ const question = colors.cyan(`${label} [default ${fallback ? 'yes' : 'no'}]: `);
60
+ while (true) {
61
+ const answer = (await askLine(question)).trim().toLowerCase();
62
+ if (!answer)
63
+ return fallback;
64
+ if (['yes', 'y', 'true', '1'].includes(answer))
65
+ return true;
66
+ if (['no', 'n', 'false', '0'].includes(answer))
67
+ return false;
68
+ console.log(colors.yellow('Please answer "yes" or "no", or leave blank to keep the default.'));
69
+ }
70
+ }
71
+ async function promptNumber(label, fallback) {
72
+ const question = colors.cyan(`${label} [default ${fallback}]: `);
73
+ while (true) {
74
+ const answer = await askLine(question);
75
+ if (!answer)
76
+ return fallback;
77
+ const parsed = Number(answer);
78
+ if (!Number.isNaN(parsed) && parsed > 0) {
79
+ return Math.floor(parsed);
80
+ }
81
+ console.log(colors.yellow('Provide a positive integer or leave blank to keep the default.'));
82
+ }
83
+ }
84
+ async function promptRobotSettingsInteractive(defaults) {
85
+ const descriptors = SETTING_DESCRIPTORS.map((descriptor) => ({
86
+ key: descriptor.key,
87
+ label: descriptor.label,
88
+ unit: descriptor.unit,
89
+ format: (value) => formatValue(value, descriptor),
90
+ parse: (buffer) => parseInteractiveValue(descriptor, buffer),
91
+ }));
92
+ return promptInteractiveSettings({
93
+ title: 'Robot settings (type to edit values)',
94
+ descriptors,
95
+ initial: defaults,
96
+ instructions: [
97
+ 'Use ↑/↓ to change rows, type comma separated values for kinds, Backspace to clear characters, and Enter to validate and confirm the selection.',
98
+ 'Press Esc/Ctrl+C to abort, or hit Enter again after reviewing the summary to continue.',
99
+ ],
100
+ validate: validateRobotSettings,
101
+ });
102
+ }
@@ -0,0 +1,120 @@
1
+ import { ROBOT_KINDS } from '../types.js';
2
+ const KNOWN_KINDS = new Set(ROBOT_KINDS);
3
+ export const SETTING_DESCRIPTORS = [
4
+ {
5
+ key: 'includeKinds',
6
+ label: 'Kinds to include',
7
+ unit: 'comma-separated (function, component, type, const, class)',
8
+ type: 'list',
9
+ },
10
+ {
11
+ key: 'exportedOnly',
12
+ label: 'Only exported symbols',
13
+ type: 'boolean',
14
+ },
15
+ {
16
+ key: 'maxColumns',
17
+ label: 'Maximum columns',
18
+ unit: 'columns',
19
+ type: 'number',
20
+ },
21
+ ];
22
+ export function formatValue(value, descriptor) {
23
+ if (descriptor.type === 'boolean') {
24
+ return value ? 'true' : 'false';
25
+ }
26
+ if (descriptor.type === 'list') {
27
+ const kinds = value;
28
+ if (kinds.length === ROBOT_KINDS.length)
29
+ return 'all';
30
+ return kinds.join(', ');
31
+ }
32
+ return `${value}`;
33
+ }
34
+ export function parseInteractiveValue(descriptor, buffer) {
35
+ const trimmed = buffer.trim();
36
+ if (!trimmed) {
37
+ return { value: undefined };
38
+ }
39
+ if (descriptor.type === 'number') {
40
+ const parsed = parsePositiveInteger(trimmed, { allowZero: descriptor.allowZero });
41
+ if (parsed === undefined) {
42
+ return {
43
+ error: `${descriptor.label} requires a ${descriptor.allowZero ? 'non-negative' : 'positive'} integer.`,
44
+ };
45
+ }
46
+ return { value: parsed };
47
+ }
48
+ if (descriptor.type === 'boolean') {
49
+ const parsed = parseBooleanInput(trimmed);
50
+ if (parsed.error)
51
+ return { error: parsed.error };
52
+ return { value: parsed.value };
53
+ }
54
+ if (descriptor.type === 'list') {
55
+ return parseKindsInput(trimmed);
56
+ }
57
+ return { value: undefined };
58
+ }
59
+ export function validateRobotSettings(settings) {
60
+ if (!settings.includeKinds.length) {
61
+ return 'Select at least one kind to include.';
62
+ }
63
+ if (!Number.isFinite(settings.maxColumns) || settings.maxColumns <= 0) {
64
+ return 'Maximum columns must be a positive number.';
65
+ }
66
+ return undefined;
67
+ }
68
+ function parsePositiveInteger(inputValue, options) {
69
+ const num = Number(inputValue);
70
+ if (!Number.isFinite(num))
71
+ return undefined;
72
+ const floored = Math.floor(num);
73
+ const min = options?.allowZero ? 0 : 1;
74
+ if (floored < min)
75
+ return undefined;
76
+ return floored;
77
+ }
78
+ function parseBooleanInput(raw) {
79
+ const normalized = raw.trim().toLowerCase();
80
+ if (['yes', 'y', 'true', '1'].includes(normalized))
81
+ return { value: true };
82
+ if (['no', 'n', 'false', '0'].includes(normalized))
83
+ return { value: false };
84
+ return { error: 'Provide "true" or "false".' };
85
+ }
86
+ export function parseKindsInput(raw) {
87
+ const normalized = raw
88
+ .split(',')
89
+ .map((chunk) => chunk.trim().toLowerCase())
90
+ .filter(Boolean);
91
+ if (normalized.length === 0) {
92
+ return { error: 'Enter at least one kind or "all".' };
93
+ }
94
+ if (normalized.length === 1 && normalized[0] === 'all') {
95
+ return { value: [...ROBOT_KINDS] };
96
+ }
97
+ const invalid = [];
98
+ const unique = [];
99
+ const seen = new Set();
100
+ for (const entry of normalized) {
101
+ if (!KNOWN_KINDS.has(entry)) {
102
+ invalid.push(entry);
103
+ continue;
104
+ }
105
+ const kind = entry;
106
+ if (!seen.has(kind)) {
107
+ seen.add(kind);
108
+ unique.push(kind);
109
+ }
110
+ }
111
+ if (invalid.length > 0) {
112
+ return {
113
+ error: `Unknown kinds: ${invalid.join(', ')}. Valid kinds are ${ROBOT_KINDS.join(', ')}.`,
114
+ };
115
+ }
116
+ if (unique.length === 0) {
117
+ return { error: 'Provide at least one valid kind.' };
118
+ }
119
+ return { value: unique };
120
+ }