@emeryld/manager 1.2.0 → 1.3.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/README.md CHANGED
@@ -65,8 +65,73 @@ You can also decide where the report lands: the manager will ask whether to stre
65
65
 
66
66
  The `robot metadata` action now starts with the same interactive settings screen as the format checker: pick which kinds of symbols to include, decide whether to limit the scan to exported declarations, and adjust the column width. Use ↑/↓ to move between rows, type new values (comma-separated lists for the kinds), and press Enter once the validation message disappears.
67
67
 
68
+ The column width now controls how much context is emitted for each declaration. Symbols that start past the configured column keep their name/export status but drop heavyweight fields (IO signatures, docstrings, JSX locations, etc.), so you still see everything in the workspace but only the left-most definitions contribute rich metadata to your LLM prompt.
69
+
68
70
  Before the extraction runs you can also choose how to consume the results. The default `console` stream prints the JSON into your terminal, while `editor` writes the report to a temporary file (non-saved) and tries to open it in your editor via your configured `$EDITOR`, `code`, or the OS `open/xdg-open` helpers.
69
71
 
72
+ To keep the payload more token-friendly, the interactive settings screen now exposes `Condense output for compact JSON` and `Docstring length limit`. Enabling the condensed output replaces the verbose object format with a small `fields` + `rows` array layout, and the docstring limit trims comments to the first N characters (set the value to `0` for unlimited). Set your preferred defaults in `.vscode/settings.json` under `manager.robot.condenseOutput` and `manager.robot.maxDocStringLength`.
73
+
74
+ ### Running with arguments
75
+ Run `pnpm manager-cli`, pick the workspace (or “All packages”), then select the `robot metadata` action. The helper walks you through:
76
+
77
+ 1. Set the symbol kinds to capture (comma-separated list or `all`).
78
+ 2. Toggle `Only exported symbols`, adjust `Maximum columns`, `Condense output for compact JSON`, and `Docstring length limit`.
79
+ 3. Confirm the summary to trigger the run and choose whether to stream results to the console or editor.
80
+
81
+ The same knobs live in `.vscode/settings.json` under `manager.robot`, for example:
82
+
83
+ ```json
84
+ {
85
+ "manager.robot": {
86
+ "includeKinds": ["function", "type", "const"],
87
+ "exportedOnly": true,
88
+ "maxColumns": 120,
89
+ "condenseOutput": true,
90
+ "maxDocStringLength": 80
91
+ }
92
+ }
93
+ ```
94
+
95
+ ### Example output
96
+ `robot metadata` prints a summary followed by the parsed payload. With the default formatting you get readable JSON:
97
+
98
+ ```text
99
+ Robot extraction complete (functions=4 components=1 types=2 consts=0 classes=1)
100
+ Estimated tokens: 72
101
+ Detailed results:
102
+ {
103
+ "functions": [
104
+ {
105
+ "kind": "function",
106
+ "name": "buildManagerConfig",
107
+ "location": { "file": "src/menu.ts", "line": 45, "column": 3 },
108
+ "docString": "Builds the menu configuration for the helper CLI.",
109
+ "exported": true,
110
+ "inputs": ["packages: LoadedPackage[]"],
111
+ "output": "HelperScriptEntry[]"
112
+ }
113
+ ],
114
+ ...
115
+ }
116
+ ```
117
+
118
+ Enabling `Condense output for compact JSON` yields a compact payload that reduces tokens even when the detailed rows are long:
119
+
120
+ ```text
121
+ Robot extraction complete (functions=4 components=1 types=2 consts=0 classes=1)
122
+ Estimated tokens: 48
123
+ Detailed results (condensed):
124
+ {
125
+ "version": 1,
126
+ "summary": { "functions": 4, "components": 1, "types": 2, "consts": 0, "classes": 1 },
127
+ "fields": ["kind","file","line","column","name","signature","docString","exported"],
128
+ "rows": [
129
+ ["function","src/menu.ts",45,3,"buildManagerConfig","(packages: LoadedPackage[]) => HelperScriptEntry[]","Builds the menu configuration...",true],
130
+ ["component","src/ui/banner.ts",12,7,"Banner","(props: BannerProps) => JSX.Element","Lightweight banner used in the hero layout.",true]
131
+ ]
132
+ }
133
+ ```
134
+
70
135
  ## Non-interactive release (Codex/CI)
71
136
  - **Syntax**: `pnpm manager-cli <pkg|all> --non-interactive [publish flags]`
72
137
  - **Requirements**: provide the selection (`<pkg>` or `all`) and one of `--bump <type>`, `--sync <version>`, or `--noop` (skip the version change but still tag/publish). Use `--non-interactive`, `--ci`, `--yes`, or `-y` interchangeably to answer every prompt in the affirmative.
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { IGNORED_DIRECTORIES } from './format-checker/scan/constants.js';
4
+ import { normalizeScripts } from './helper-cli/scripts.js';
5
+ import { promptForScript } from './helper-cli/prompts.js';
6
+ import { rootDir } from './helper-cli/env.js';
7
+ import { loadPackages } from './packages.js';
8
+ const ROOT_ENTRY = {
9
+ label: 'Workspace root',
10
+ relativePath: '.',
11
+ description: 'Root directory',
12
+ color: 'brightBlue',
13
+ };
14
+ export async function promptDirectorySelection(options = {}) {
15
+ const candidates = await buildDirectoryCandidates(options.includeWorkspaceRoot ?? true);
16
+ const entries = normalizeScripts(candidates.map((candidate) => ({
17
+ name: candidate.label,
18
+ emoji: '📁',
19
+ color: candidate.color,
20
+ description: candidate.description ?? candidate.relativePath,
21
+ script: candidate.relativePath,
22
+ })));
23
+ const selection = await promptForScript(entries, options.title ?? 'Select a directory');
24
+ if (!selection)
25
+ return undefined;
26
+ const relativePath = selection.script ?? '.';
27
+ const absolutePath = path.resolve(rootDir, relativePath);
28
+ return { label: selection.displayName, relativePath, absolutePath };
29
+ }
30
+ export function describeDirectorySelection(selection) {
31
+ const normalized = selection.relativePath
32
+ .replace(/\\/g, '/')
33
+ .replace(/^\.\/+/, '');
34
+ if (!normalized || normalized === '.') {
35
+ return selection.label;
36
+ }
37
+ return `${selection.label} (${normalized})`;
38
+ }
39
+ async function buildDirectoryCandidates(includeRoot) {
40
+ const directories = await collectDirectories();
41
+ const packages = await loadPackageMetadata();
42
+ const packageMap = new Map(packages.map((pkg) => [pkg.relativeDir, pkg]));
43
+ const candidates = [];
44
+ const filtered = directories.filter((entry) => includeRoot || entry.relativePath !== '.');
45
+ for (const entry of filtered) {
46
+ const pkg = packageMap.get(entry.relativePath);
47
+ if (pkg) {
48
+ candidates.push({
49
+ label: pkg.name ?? pkg.dirName,
50
+ relativePath: entry.relativePath,
51
+ description: pkg.relativeDir,
52
+ color: pkg.color,
53
+ });
54
+ continue;
55
+ }
56
+ if (entry.relativePath === '.') {
57
+ candidates.push({
58
+ label: ROOT_ENTRY.label,
59
+ relativePath: ROOT_ENTRY.relativePath,
60
+ description: ROOT_ENTRY.description,
61
+ color: ROOT_ENTRY.color,
62
+ });
63
+ continue;
64
+ }
65
+ candidates.push({
66
+ label: path.basename(entry.relativePath) || ROOT_ENTRY.label,
67
+ relativePath: entry.relativePath,
68
+ description: entry.relativePath,
69
+ });
70
+ }
71
+ if (candidates.length === 0 && includeRoot) {
72
+ candidates.push(ROOT_ENTRY);
73
+ }
74
+ return candidates;
75
+ }
76
+ async function collectDirectories() {
77
+ const patterns = await collectIgnorePatterns();
78
+ const directories = [];
79
+ async function walk(current) {
80
+ const relativeRaw = path.relative(rootDir, current);
81
+ const relativePath = normalizeRelativePath(relativeRaw);
82
+ if (current !== rootDir && shouldIgnore(relativePath, patterns)) {
83
+ return;
84
+ }
85
+ directories.push({ absolutePath: current, relativePath });
86
+ let entries;
87
+ try {
88
+ entries = await readdir(current, { withFileTypes: true });
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ for (const entry of entries) {
94
+ if (!entry.isDirectory() || entry.isSymbolicLink())
95
+ continue;
96
+ await walk(path.join(current, entry.name));
97
+ }
98
+ }
99
+ await walk(rootDir);
100
+ directories.sort((a, b) => {
101
+ if (a.relativePath === '.')
102
+ return -1;
103
+ if (b.relativePath === '.')
104
+ return 1;
105
+ return a.relativePath.localeCompare(b.relativePath);
106
+ });
107
+ return directories;
108
+ }
109
+ async function collectIgnorePatterns() {
110
+ const patterns = new Set();
111
+ for (const entry of IGNORED_DIRECTORIES) {
112
+ patterns.add(normalizeRelativePath(entry));
113
+ }
114
+ try {
115
+ const contents = await readFile(path.join(rootDir, '.gitignore'), 'utf-8');
116
+ for (const rawLine of contents.split(/\r?\n/)) {
117
+ const trimmed = rawLine.trim();
118
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
119
+ continue;
120
+ patterns.add(normalizeRelativePath(trimmed));
121
+ }
122
+ }
123
+ catch {
124
+ // ignore failures (missing file)
125
+ }
126
+ return Array.from(patterns).filter(Boolean);
127
+ }
128
+ function normalizeRelativePath(value) {
129
+ const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
130
+ const trimmed = normalized.replace(/\/+$/, '');
131
+ return trimmed || '.';
132
+ }
133
+ function shouldIgnore(relativePath, patterns) {
134
+ if (relativePath === '.')
135
+ return false;
136
+ const segments = relativePath.split('/');
137
+ for (const pattern of patterns) {
138
+ if (!pattern)
139
+ continue;
140
+ if (relativePath === pattern)
141
+ return true;
142
+ if (relativePath.startsWith(`${pattern}/`))
143
+ return true;
144
+ const patternSegments = pattern.split('/');
145
+ if (patternSegments.length === 1 && segments.includes(pattern)) {
146
+ return true;
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+ async function loadPackageMetadata() {
152
+ try {
153
+ return await loadPackages();
154
+ }
155
+ catch {
156
+ return [];
157
+ }
158
+ }
@@ -1,6 +1,6 @@
1
1
  import { colors } from '../../utils/log.js';
2
2
  import { stdin as input } from 'node:process';
3
- import { askLine } from '../../prompts.js';
3
+ import { askLine, promptSingleKey } from '../../prompts.js';
4
4
  import { formatValue, parseInteractiveValue, SETTING_DESCRIPTORS, validateLimits, } from './settings.js';
5
5
  import { promptInteractiveSettings, } from '../../cli/interactive-settings.js';
6
6
  const READY_PROMPT = colors.dim('Limits retained. Use the navigation above to adjust and confirm again.');
@@ -20,14 +20,16 @@ export async function promptLimits(defaults) {
20
20
  }
21
21
  async function confirmExecution() {
22
22
  const question = colors.cyan('Run the format checker with these limits? (Y/n): ');
23
- while (true) {
24
- const answer = (await askLine(question)).trim().toLowerCase();
25
- if (!answer || answer === 'y' || answer === 'yes')
23
+ return promptSingleKey(question, (key, raw) => {
24
+ if (!key)
25
+ return undefined;
26
+ if (key === 'y' || key === 'yes')
26
27
  return true;
27
- if (answer === 'n' || answer === 'no')
28
+ if (key === 'n' || key === 'no')
28
29
  return false;
29
- console.log(colors.yellow('Answer "yes" or "no", or press Enter to proceed.'));
30
- }
30
+ console.log(colors.yellow('Answer "yes" or "no".'));
31
+ return undefined;
32
+ });
31
33
  }
32
34
  async function promptLimitsSequential(defaults) {
33
35
  console.log(colors.dim('Enter a number to override a limit or press Enter to keep the default.'));
@@ -1,5 +1,7 @@
1
1
  import { colors } from '../utils/log.js';
2
2
  import { captureConsoleOutput, exportReportLines, promptExportMode, } from '../utils/export.js';
3
+ import { promptDirectorySelection, describeDirectorySelection } from '../directory-picker.js';
4
+ import { runHelperCli } from '../helper-cli.js';
3
5
  import { rootDir } from '../helper-cli/env.js';
4
6
  import { loadFormatLimits, } from './config.js';
5
7
  import { collectSourceFiles, analyzeFiles } from './scan/index.js';
@@ -9,9 +11,54 @@ import { parseScanCliArgs, printScanUsage } from './cli/options.js';
9
11
  export async function runFormatChecker() {
10
12
  console.log(colors.cyan('Gathering defaults from .vscode/settings.json (manager.formatChecker)'));
11
13
  const defaults = await loadFormatLimits();
12
- const limits = await promptLimits(defaults);
13
- const exportMode = await promptExportMode();
14
- await executeFormatCheck(limits, exportMode);
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
+ }
15
62
  }
16
63
  export async function runFormatCheckerScanCli(argv) {
17
64
  const { overrides, help, exportMode } = parseScanCliArgs(argv);
@@ -24,9 +71,9 @@ export async function runFormatCheckerScanCli(argv) {
24
71
  console.log(colors.cyan('Running format checker scan (machine-friendly)'));
25
72
  await executeFormatCheck(limits, exportMode);
26
73
  }
27
- async function executeFormatCheck(limits, exportMode) {
28
- console.log(colors.magenta('Scanning workspace for source files...'));
29
- 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);
30
77
  if (files.length === 0) {
31
78
  console.log(colors.yellow('No source files were found to analyze.'));
32
79
  return;
@@ -46,3 +93,6 @@ async function handleViolations(violations, reportingMode, exportMode) {
46
93
  await exportReportLines('format-checker', 'txt', lines);
47
94
  return result;
48
95
  }
96
+ function formatLimitsSummary(limits) {
97
+ return `limits: fn≤${limits.maxFunctionLength} · indent≤${limits.maxIndentationDepth} · reporting=${limits.reportingMode} · exportOnly=${limits.exportOnly}`;
98
+ }
@@ -35,6 +35,8 @@ async function promptRobotSettingsSequential(defaults) {
35
35
  includeKinds: await promptKinds(defaults.includeKinds),
36
36
  exportedOnly: await promptBoolean('Only consider exported symbols', defaults.exportedOnly),
37
37
  maxColumns: await promptNumber('Maximum columns', defaults.maxColumns),
38
+ condenseOutput: await promptBoolean('Condense output for compact JSON', defaults.condenseOutput),
39
+ maxDocStringLength: await promptNumber('Docstring length limit (0 = unlimited)', defaults.maxDocStringLength, { allowZero: true }),
38
40
  };
39
41
  }
40
42
  async function promptKinds(fallback) {
@@ -68,17 +70,21 @@ async function promptBoolean(label, fallback) {
68
70
  console.log(colors.yellow('Please answer "yes" or "no", or leave blank to keep the default.'));
69
71
  }
70
72
  }
71
- async function promptNumber(label, fallback) {
73
+ async function promptNumber(label, fallback, options) {
72
74
  const question = colors.cyan(`${label} [default ${fallback}]: `);
75
+ const min = options?.allowZero ? 0 : 1;
76
+ const description = options?.allowZero ? 'non-negative integer' : 'positive integer';
73
77
  while (true) {
74
78
  const answer = await askLine(question);
75
79
  if (!answer)
76
80
  return fallback;
77
81
  const parsed = Number(answer);
78
- if (!Number.isNaN(parsed) && parsed > 0) {
79
- return Math.floor(parsed);
82
+ if (!Number.isNaN(parsed)) {
83
+ const floored = Math.floor(parsed);
84
+ if (floored >= min)
85
+ return floored;
80
86
  }
81
- console.log(colors.yellow('Provide a positive integer or leave blank to keep the default.'));
87
+ console.log(colors.yellow(`Provide a ${description} or leave blank to keep the default.`));
82
88
  }
83
89
  }
84
90
  async function promptRobotSettingsInteractive(defaults) {
@@ -18,6 +18,18 @@ export const SETTING_DESCRIPTORS = [
18
18
  unit: 'columns',
19
19
  type: 'number',
20
20
  },
21
+ {
22
+ key: 'condenseOutput',
23
+ label: 'Condense output for compact JSON',
24
+ type: 'boolean',
25
+ },
26
+ {
27
+ key: 'maxDocStringLength',
28
+ label: 'Docstring length limit (0=unlimited)',
29
+ unit: 'characters',
30
+ type: 'number',
31
+ allowZero: true,
32
+ },
21
33
  ];
22
34
  export function formatValue(value, descriptor) {
23
35
  if (descriptor.type === 'boolean') {
@@ -63,6 +75,10 @@ export function validateRobotSettings(settings) {
63
75
  if (!Number.isFinite(settings.maxColumns) || settings.maxColumns <= 0) {
64
76
  return 'Maximum columns must be a positive number.';
65
77
  }
78
+ if (!Number.isFinite(settings.maxDocStringLength) ||
79
+ settings.maxDocStringLength < 0) {
80
+ return 'Docstring length limit must be zero or greater.';
81
+ }
66
82
  return undefined;
67
83
  }
68
84
  function parsePositiveInteger(inputValue, options) {
@@ -9,6 +9,8 @@ export const DEFAULT_ROBOT_SETTINGS = {
9
9
  includeKinds: [...ROBOT_KINDS],
10
10
  exportedOnly: true,
11
11
  maxColumns: 160,
12
+ condenseOutput: true,
13
+ maxDocStringLength: 0,
12
14
  };
13
15
  function coerceNumber(value, fallback) {
14
16
  const num = Number(value);
@@ -28,6 +30,13 @@ function coerceBoolean(value, fallback) {
28
30
  }
29
31
  return fallback;
30
32
  }
33
+ function coerceNonNegativeNumber(value, fallback) {
34
+ const num = Number(value);
35
+ if (Number.isNaN(num) || num < 0) {
36
+ return fallback;
37
+ }
38
+ return Math.floor(num);
39
+ }
31
40
  function coerceKinds(value) {
32
41
  const knownKinds = new Set(ROBOT_KINDS);
33
42
  if (typeof value === 'string') {
@@ -75,6 +84,8 @@ export async function loadRobotSettings() {
75
84
  includeKinds: coerceKinds(record.includeKinds ?? record.kinds),
76
85
  exportedOnly: coerceBoolean(record.exportedOnly ?? DEFAULT_ROBOT_SETTINGS.exportedOnly, DEFAULT_ROBOT_SETTINGS.exportedOnly),
77
86
  maxColumns: coerceNumber(record.maxColumns ?? DEFAULT_ROBOT_SETTINGS.maxColumns, DEFAULT_ROBOT_SETTINGS.maxColumns),
87
+ condenseOutput: coerceBoolean(record.condenseOutput ?? DEFAULT_ROBOT_SETTINGS.condenseOutput, DEFAULT_ROBOT_SETTINGS.condenseOutput),
88
+ maxDocStringLength: coerceNonNegativeNumber(record.maxDocStringLength ?? DEFAULT_ROBOT_SETTINGS.maxDocStringLength, DEFAULT_ROBOT_SETTINGS.maxDocStringLength),
78
89
  };
79
90
  }
80
91
  catch {
@@ -4,7 +4,8 @@ import { colors } from '../utils/log.js';
4
4
  import { exportReportLines, promptExportMode, } from '../utils/export.js';
5
5
  import { collectSourceFiles } from '../format-checker/scan/collect.js';
6
6
  import { resolveScriptKind } from '../format-checker/scan/utils.js';
7
- import { rootDir } from '../helper-cli/env.js';
7
+ import { promptDirectorySelection, describeDirectorySelection } from '../directory-picker.js';
8
+ import { runHelperCli } from '../helper-cli.js';
8
9
  import { loadRobotSettings } from './config.js';
9
10
  import { promptRobotSettings } from './cli/prompts.js';
10
11
  import { collectFunctions } from './extractors/functions.js';
@@ -12,30 +13,70 @@ import { collectComponents } from './extractors/components.js';
12
13
  import { collectConstants } from './extractors/constants.js';
13
14
  import { collectTypes } from './extractors/types.js';
14
15
  import { collectClasses } from './extractors/classes.js';
16
+ import { serializeRobotResult } from './serializer.js';
15
17
  export async function runRobot() {
16
18
  const defaults = await loadRobotSettings();
17
- const settings = await promptRobotSettings(defaults);
18
- const exportMode = await promptExportMode();
19
+ let settings = defaults;
20
+ let exportMode = 'console';
19
21
  console.log(colors.magenta('Running robot analyzer (manager.robot settings)'));
20
- const files = await collectSourceFiles(rootDir);
22
+ while (true) {
23
+ let lastAction;
24
+ let runResult;
25
+ const scripts = [
26
+ {
27
+ name: 'Run robot extractor',
28
+ emoji: '🤖',
29
+ description: robotSettingsSummary(settings),
30
+ handler: async () => {
31
+ const selection = await promptDirectorySelection({
32
+ title: 'Select directory for robot extractor',
33
+ });
34
+ if (!selection)
35
+ return;
36
+ lastAction = 'run';
37
+ runResult = await executeRobotExtraction(settings, exportMode, selection.absolutePath, describeDirectorySelection(selection));
38
+ },
39
+ },
40
+ {
41
+ name: 'Adjust settings',
42
+ emoji: '⚙️',
43
+ description: robotSettingsSummary(settings),
44
+ handler: async () => {
45
+ settings = await promptRobotSettings(settings);
46
+ lastAction = 'configure';
47
+ },
48
+ },
49
+ {
50
+ name: 'Change export mode',
51
+ emoji: '📤',
52
+ description: `Current: ${exportMode}`,
53
+ handler: async () => {
54
+ exportMode = await promptExportMode();
55
+ lastAction = 'export';
56
+ },
57
+ },
58
+ ];
59
+ const ran = await runHelperCli({
60
+ title: 'Robot metadata helper',
61
+ scripts,
62
+ argv: [],
63
+ });
64
+ if (!ran)
65
+ return createEmptyRobotResult();
66
+ if (lastAction === 'run' && runResult) {
67
+ return runResult;
68
+ }
69
+ }
70
+ }
71
+ async function executeRobotExtraction(settings, exportMode, scanRoot, scanLabel) {
72
+ console.log(colors.magenta(`Scanning ${scanLabel} for metadata`));
73
+ const files = await collectSourceFiles(scanRoot);
21
74
  if (files.length === 0) {
22
75
  console.log(colors.yellow('No source files found for robot'));
23
- return {
24
- functions: [],
25
- components: [],
26
- types: [],
27
- consts: [],
28
- classes: [],
29
- };
76
+ return createEmptyRobotResult();
30
77
  }
31
78
  console.log(colors.magenta(`Analyzing ${files.length} files for metadata`));
32
- const result = {
33
- functions: [],
34
- components: [],
35
- types: [],
36
- consts: [],
37
- classes: [],
38
- };
79
+ const result = createEmptyRobotResult();
39
80
  for (const file of files) {
40
81
  let content;
41
82
  try {
@@ -71,27 +112,43 @@ export async function runRobot() {
71
112
  consts: result.consts.length,
72
113
  classes: result.classes.length,
73
114
  };
74
- const snapshot = JSON.stringify(result);
75
- const tokenEstimate = estimateTokenCount(snapshot);
115
+ const serializedResult = serializeRobotResult(result, settings);
116
+ const tokenEstimate = estimateTokenCount(serializedResult);
76
117
  result.tokenEstimate = tokenEstimate;
118
+ const detailLabel = settings.condenseOutput
119
+ ? 'Detailed results (condensed)'
120
+ : 'Detailed results';
77
121
  const summaryText = `Robot extraction complete (functions=${summary.functions} components=${summary.components} types=${summary.types} consts=${summary.consts} classes=${summary.classes})`;
78
122
  console.log(colors.green(summaryText));
79
123
  console.log(colors.dim(`Estimated tokens: ${tokenEstimate}`));
80
124
  if (exportMode === 'console') {
81
- console.log(colors.dim('Detailed results:'));
82
- console.log(JSON.stringify(result, null, 2));
125
+ console.log(colors.dim(detailLabel));
126
+ console.log(serializedResult);
83
127
  }
84
128
  else {
85
129
  await exportReportLines('robot-metadata', 'json', [
86
130
  summaryText,
87
131
  `Token estimate: ${tokenEstimate}`,
88
132
  '',
89
- 'Detailed results:',
90
- JSON.stringify(result, null, 2),
133
+ `${detailLabel}:`,
134
+ serializedResult,
91
135
  ]);
92
136
  }
93
137
  return result;
94
138
  }
139
+ function createEmptyRobotResult() {
140
+ return {
141
+ functions: [],
142
+ components: [],
143
+ types: [],
144
+ consts: [],
145
+ classes: [],
146
+ };
147
+ }
148
+ function robotSettingsSummary(settings) {
149
+ const kinds = settings.includeKinds.join(',');
150
+ return `kinds: ${kinds} · exportOnly=${settings.exportedOnly ? 'yes' : 'no'} · columns≤${settings.maxColumns}`;
151
+ }
95
152
  function estimateTokenCount(serialized) {
96
153
  const length = Math.max(1, serialized.length);
97
154
  return Math.ceil(length / 4);
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { getDocString, getLocation, isNodeExported, withinColumnLimit, } from './shared.js';
2
+ import { EMPTY_LOCATION, getDocString, getLocation, isNodeExported, shouldIncludeContext, } from './shared.js';
3
3
  export function collectClasses(options) {
4
4
  const { sourceFile } = options.context;
5
5
  const records = [];
@@ -10,26 +10,27 @@ export function collectClasses(options) {
10
10
  }
11
11
  const name = node.name?.text;
12
12
  const location = getLocation(node, sourceFile);
13
- if (!withinColumnLimit(location, options.settings.maxColumns)) {
14
- ts.forEachChild(node, visit);
15
- return;
16
- }
13
+ const includeContext = shouldIncludeContext(location, options.settings.maxColumns);
17
14
  const exported = isNodeExported(node);
18
15
  if (options.settings.exportedOnly && !exported) {
19
16
  ts.forEachChild(node, visit);
20
17
  return;
21
18
  }
22
- const extendsClause = node.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword);
23
- const implementsClause = node.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ImplementsKeyword);
24
- const extendsText = extendsClause?.types
25
- .map((expr) => expr.getText(sourceFile))
26
- .join(', ');
27
- const implementsList = implementsClause?.types.map((expr) => expr.getText(sourceFile));
19
+ let extendsText;
20
+ let implementsList;
21
+ if (includeContext) {
22
+ const extendsClause = node.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword);
23
+ const implementsClause = node.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ImplementsKeyword);
24
+ extendsText = extendsClause?.types
25
+ .map((expr) => expr.getText(sourceFile))
26
+ .join(', ');
27
+ implementsList = implementsClause?.types.map((expr) => expr.getText(sourceFile));
28
+ }
28
29
  records.push({
29
30
  kind: 'class',
30
31
  name,
31
- location,
32
- docString: getDocString(node),
32
+ location: includeContext ? location : EMPTY_LOCATION,
33
+ docString: includeContext ? getDocString(node) : undefined,
33
34
  exported,
34
35
  extends: extendsText,
35
36
  implements: implementsList,
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { describeParameter, getDocString, getFunctionName, getLocation, isComponentFunction, isNodeExported, withinColumnLimit, } from './shared.js';
2
+ import { describeParameter, EMPTY_LOCATION, getDocString, getFunctionName, getLocation, isComponentFunction, isNodeExported, shouldIncludeContext, } from './shared.js';
3
3
  export function collectComponents(options) {
4
4
  const { sourceFile } = options.context;
5
5
  const records = [];
@@ -14,24 +14,31 @@ export function collectComponents(options) {
14
14
  return;
15
15
  }
16
16
  const location = getLocation(node, sourceFile);
17
- if (!withinColumnLimit(location, options.settings.maxColumns)) {
18
- return;
19
- }
17
+ const includeContext = shouldIncludeContext(location, options.settings.maxColumns);
20
18
  const exported = isNodeExported(node);
21
19
  if (options.settings.exportedOnly && !exported)
22
20
  return;
23
- const inputs = node.parameters.map((param) => describeParameter(param, sourceFile));
24
- const output = node.type?.getText(sourceFile);
25
- const jsxNode = findFirstJsx(node);
21
+ const inputs = includeContext
22
+ ? node.parameters.map((param) => describeParameter(param, sourceFile))
23
+ : [];
24
+ const output = includeContext
25
+ ? node.type?.getText(sourceFile)
26
+ : undefined;
27
+ const docString = includeContext ? getDocString(node) : undefined;
28
+ let jsxLocation;
29
+ if (includeContext) {
30
+ const jsxNode = findFirstJsx(node);
31
+ jsxLocation = jsxNode ? getLocation(jsxNode, sourceFile) : location;
32
+ }
26
33
  records.push({
27
34
  kind: 'component',
28
35
  name,
29
- location,
30
- docString: getDocString(node),
36
+ location: includeContext ? location : EMPTY_LOCATION,
37
+ docString,
31
38
  exported,
32
39
  inputs,
33
40
  output,
34
- jsxLocation: jsxNode ? getLocation(jsxNode, sourceFile) : location,
41
+ jsxLocation,
35
42
  });
36
43
  }
37
44
  ts.forEachChild(node, visit);
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { getDocString, getLocation, isNodeExported, withinColumnLimit, } from './shared.js';
2
+ import { EMPTY_LOCATION, getDocString, getLocation, isNodeExported, shouldIncludeContext, } from './shared.js';
3
3
  export function collectConstants(options) {
4
4
  const { sourceFile } = options.context;
5
5
  const records = [];
@@ -23,15 +23,18 @@ export function collectConstants(options) {
23
23
  if (!ts.isIdentifier(declaration.name))
24
24
  return;
25
25
  const location = getLocation(declaration.name, sourceFile);
26
- if (!withinColumnLimit(location, options.settings.maxColumns))
27
- return;
26
+ const includeContext = shouldIncludeContext(location, options.settings.maxColumns);
27
+ const docString = includeContext ? getDocString(declaration) : undefined;
28
+ const value = includeContext
29
+ ? declaration.initializer?.getText(sourceFile) ?? 'undefined'
30
+ : '';
28
31
  records.push({
29
32
  kind: 'const',
30
33
  name: declaration.name.text,
31
- location,
32
- docString: getDocString(declaration),
34
+ location: includeContext ? location : EMPTY_LOCATION,
35
+ docString,
33
36
  exported,
34
- value: declaration.initializer?.getText(sourceFile) ?? 'undefined',
37
+ value,
35
38
  });
36
39
  });
37
40
  }
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { describeParameter, getDocString, getFunctionName, getLocation, isComponentFunction, isNodeExported, withinColumnLimit, } from './shared.js';
2
+ import { describeParameter, EMPTY_LOCATION, getDocString, getFunctionName, getLocation, isComponentFunction, isNodeExported, shouldIncludeContext, } from './shared.js';
3
3
  export function collectFunctions(options) {
4
4
  const { sourceFile } = options.context;
5
5
  const records = [];
@@ -15,19 +15,22 @@ export function collectFunctions(options) {
15
15
  return;
16
16
  }
17
17
  const location = getLocation(node, sourceFile);
18
- if (!withinColumnLimit(location, options.settings.maxColumns)) {
19
- return;
20
- }
18
+ const includeContext = shouldIncludeContext(location, options.settings.maxColumns);
21
19
  const exported = isNodeExported(node);
22
20
  if (options.settings.exportedOnly && !exported)
23
21
  return;
24
- const inputs = node.parameters.map((param) => describeParameter(param, sourceFile));
25
- const output = node.type?.getText(sourceFile);
22
+ const inputs = includeContext
23
+ ? node.parameters.map((param) => describeParameter(param, sourceFile))
24
+ : [];
25
+ const output = includeContext
26
+ ? node.type?.getText(sourceFile)
27
+ : undefined;
28
+ const docString = includeContext ? getDocString(node) : undefined;
26
29
  records.push({
27
30
  kind: 'function',
28
31
  name,
29
- location,
30
- docString: getDocString(node),
32
+ location: includeContext ? location : EMPTY_LOCATION,
33
+ docString,
31
34
  exported,
32
35
  inputs,
33
36
  output,
@@ -44,11 +44,16 @@ export function isNodeExported(node) {
44
44
  return true;
45
45
  return false;
46
46
  }
47
- export function withinColumnLimit(location, maxColumns) {
47
+ export function shouldIncludeContext(location, maxColumns) {
48
48
  if (maxColumns <= 0)
49
49
  return true;
50
50
  return location.column <= maxColumns;
51
51
  }
52
+ export const EMPTY_LOCATION = {
53
+ file: '',
54
+ line: 0,
55
+ column: 0,
56
+ };
52
57
  export function getFunctionName(node) {
53
58
  if ('name' in node && node.name && ts.isIdentifier(node.name)) {
54
59
  return node.name.text;
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { getDocString, getLocation, isNodeExported, withinColumnLimit, } from './shared.js';
2
+ import { EMPTY_LOCATION, getDocString, getLocation, isNodeExported, shouldIncludeContext, } from './shared.js';
3
3
  export function collectTypes(options) {
4
4
  const { sourceFile } = options.context;
5
5
  const records = [];
@@ -16,10 +16,7 @@ export function collectTypes(options) {
16
16
  }
17
17
  if (typeKind) {
18
18
  const location = getLocation(node, sourceFile);
19
- if (!withinColumnLimit(location, options.settings.maxColumns)) {
20
- ts.forEachChild(node, visit);
21
- return;
22
- }
19
+ const includeContext = shouldIncludeContext(location, options.settings.maxColumns);
23
20
  const exported = isNodeExported(node);
24
21
  if (options.settings.exportedOnly && !exported) {
25
22
  ts.forEachChild(node, visit);
@@ -29,11 +26,11 @@ export function collectTypes(options) {
29
26
  records.push({
30
27
  kind: 'type',
31
28
  name,
32
- location,
33
- docString: getDocString(node),
29
+ location: includeContext ? location : EMPTY_LOCATION,
30
+ docString: includeContext ? getDocString(node) : undefined,
34
31
  exported,
35
32
  typeKind,
36
- definition: node.getText(sourceFile),
33
+ definition: includeContext ? node.getText(sourceFile) : '',
37
34
  });
38
35
  }
39
36
  ts.forEachChild(node, visit);
@@ -0,0 +1,97 @@
1
+ const CONDENSED_FIELDS = [
2
+ 'kind',
3
+ 'file',
4
+ 'line',
5
+ 'column',
6
+ 'name',
7
+ 'signature',
8
+ 'docString',
9
+ 'exported',
10
+ ];
11
+ export function serializeRobotResult(result, settings) {
12
+ if (!settings.condenseOutput) {
13
+ return JSON.stringify(result, null, 2);
14
+ }
15
+ return JSON.stringify(buildCondensedPayload(result, settings));
16
+ }
17
+ function buildCondensedPayload(result, settings) {
18
+ const rows = [];
19
+ const limit = settings.maxDocStringLength;
20
+ for (const record of flattenRecords(result)) {
21
+ rows.push([
22
+ record.kind,
23
+ record.location.file,
24
+ record.location.line,
25
+ record.location.column,
26
+ record.name ?? '',
27
+ buildSignature(record),
28
+ trimDocString(record.docString, limit),
29
+ record.exported,
30
+ ]);
31
+ }
32
+ return {
33
+ version: 1,
34
+ summary: {
35
+ functions: result.functions.length,
36
+ components: result.components.length,
37
+ types: result.types.length,
38
+ consts: result.consts.length,
39
+ classes: result.classes.length,
40
+ },
41
+ fields: [...CONDENSED_FIELDS],
42
+ rows,
43
+ };
44
+ }
45
+ function flattenRecords(result) {
46
+ return [
47
+ ...result.functions,
48
+ ...result.components,
49
+ ...result.types,
50
+ ...result.consts,
51
+ ...result.classes,
52
+ ];
53
+ }
54
+ function buildSignature(record) {
55
+ switch (record.kind) {
56
+ case 'function':
57
+ case 'component': {
58
+ const params = record.inputs.join(', ');
59
+ const output = record.output ? ` => ${normalize(record.output)}` : '';
60
+ return `(${params})${output}`;
61
+ }
62
+ case 'type': {
63
+ const displayKind = record.typeKind === 'type-alias'
64
+ ? 'type alias'
65
+ : record.typeKind;
66
+ return normalize(`${displayKind} ${record.definition}`);
67
+ }
68
+ case 'const':
69
+ return normalize(record.value);
70
+ case 'class': {
71
+ const parts = [];
72
+ if (record.extends)
73
+ parts.push(`extends ${record.extends}`);
74
+ if (record.implements?.length) {
75
+ parts.push(`implements ${record.implements.join(', ')}`);
76
+ }
77
+ return normalize(parts.join(' '));
78
+ }
79
+ default:
80
+ return '';
81
+ }
82
+ }
83
+ function trimDocString(value, limit) {
84
+ if (!value) {
85
+ return '';
86
+ }
87
+ const normalized = normalize(value);
88
+ if (limit <= 0 || normalized.length <= limit) {
89
+ return normalized;
90
+ }
91
+ return `${normalized.slice(0, limit)}...`;
92
+ }
93
+ function normalize(value) {
94
+ return (value ?? '')
95
+ .replace(/\s+/g, ' ')
96
+ .trim();
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",