@emeryld/manager 1.4.0 → 1.4.2

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
@@ -15,7 +15,7 @@ Dev dependency built for Codex agents: scan a pnpm workspace, scaffold RRRoutes
15
15
  - **Action menu** for the selection:
16
16
  - `update dependencies` → runs `pnpm -r update` (or filtered update), stages only dependency files, prompts for a commit message, commits, and pushes.
17
17
  - `test` → `pnpm test` (filtered to the package when possible).
18
- - `format checker` → prompts for limits, then scans each source file for long functions, deep indentation, too many components/functions, and repeated/similar snippets before reporting offenders.
18
+ - `format checker` → prompts for limits, then scans each source file for long functions and class methods, deep indentation, too many components/functions, and repeated/similar snippets before reporting offenders.
19
19
  - `build` → `pnpm build` (filtered when a single package is selected).
20
20
  - `publish` → ensures the working tree is committed, checks registry auth, prompts a version strategy, commits the bump, tags, publishes with pnpm, and pushes tags.
21
21
  - `full` → update → test → build → publish in order.
@@ -27,7 +27,8 @@ Dev dependency built for Codex agents: scan a pnpm workspace, scaffold RRRoutes
27
27
 
28
28
  | setting | description |
29
29
  | --- | --- |
30
- | `maxFunctionLength` | allowed lines per function |
30
+ | `maxFunctionLength` | allowed lines per function (default 150) |
31
+ | `maxMethodLength` | allowed lines per class method (default 150) |
31
32
  | `maxIndentationDepth` | indentation spaces before warning |
32
33
  | `maxFunctionsPerFile` | functions allowed in a file |
33
34
  | `maxComponentsPerFile` | component abstractions allowed |
@@ -38,9 +39,19 @@ Dev dependency built for Codex agents: scan a pnpm workspace, scaffold RRRoutes
38
39
  | `indentationWidth` | spaces to count per tab when evaluating indentation depth |
39
40
 
40
41
  Each run also prompts for overrides, so you can tweak targets without editing the file.
42
+ Long methods inside classes now obey the `maxMethodLength` limit, so class members are scanned even when they aren't individually exported.
41
43
  The override prompt now lists every limit at once; you can move with ↑/↓, type to replace the highlighted value, Backspace to erase characters, and press Enter when the values validate before confirming.
42
44
  You can also decide where the report lands: the manager will ask whether to stream the violations to the console or dump them into a temporary file that is opened in your editor (no repo files are modified).
43
45
 
46
+ ```json
47
+ {
48
+ "manager.formatChecker": {
49
+ "maxFunctionLength": 150,
50
+ "maxMethodLength": 200
51
+ }
52
+ }
53
+ ```
54
+
44
55
  ## Format checker scan CLI
45
56
  - **Usage**: `pnpm manager-cli scan [flags]` scans the workspace without interactive prompts while honoring defaults in `.vscode/settings.json` under `manager.formatChecker`.
46
57
  - **Flags**: override any limit via the table below or run `pnpm manager-cli scan --help`/`-h` to print the same guidance.
@@ -48,6 +59,7 @@ You can also decide where the report lands: the manager will ask whether to stre
48
59
  | Flag | Description |
49
60
  | --- | --- |
50
61
  | `--max-function-length <number>` | Maximum lines per function before a violation is reported. |
62
+ | `--max-method-length <number>` | Maximum lines per class/instance method before a violation is reported. |
51
63
  | `--max-indentation-depth <number>` | Maximum indentation depth (spaces) that the scanner tolerates. |
52
64
  | `--max-functions-per-file <number>` | Maximum number of functions allowed in a source file. |
53
65
  | `--max-components-per-file <number>` | Maximum component abstractions permitted per file. |
@@ -8,6 +8,12 @@ export const SCAN_FLAG_DEFINITIONS = [
8
8
  key: 'maxFunctionLength',
9
9
  parser: (value) => parseNumberFlag(value, '--max-function-length'),
10
10
  },
11
+ {
12
+ flag: '--max-method-length',
13
+ description: 'Maximum method length (lines).',
14
+ key: 'maxMethodLength',
15
+ parser: (value) => parseNumberFlag(value, '--max-method-length'),
16
+ },
11
17
  {
12
18
  flag: '--max-indentation-depth',
13
19
  description: 'Maximum indentation depth (spaces).',
@@ -35,6 +35,7 @@ async function promptLimitsSequential(defaults) {
35
35
  console.log(colors.dim('Enter a number to override a limit or press Enter to keep the default.'));
36
36
  return {
37
37
  maxFunctionLength: await promptNumber('Max function length', defaults.maxFunctionLength, 'lines'),
38
+ maxMethodLength: await promptNumber('Max method length', defaults.maxMethodLength, 'lines'),
38
39
  maxIndentationDepth: await promptNumber('Max indentation depth', defaults.maxIndentationDepth, 'spaces'),
39
40
  maxFunctionsPerFile: await promptNumber('Max functions per file', defaults.maxFunctionsPerFile, 'count'),
40
41
  maxComponentsPerFile: await promptNumber('Max components per file', defaults.maxComponentsPerFile, 'count'),
@@ -6,6 +6,12 @@ export const SETTING_DESCRIPTORS = [
6
6
  unit: 'lines',
7
7
  type: 'number',
8
8
  },
9
+ {
10
+ key: 'maxMethodLength',
11
+ label: 'Max method length',
12
+ unit: 'lines',
13
+ type: 'number',
14
+ },
9
15
  {
10
16
  key: 'maxIndentationDepth',
11
17
  label: 'Max indentation depth',
@@ -4,9 +4,10 @@ import path from 'node:path';
4
4
  import { rootDir } from '../helper-cli/env.js';
5
5
  export const REPORTING_MODES = ['group', 'file'];
6
6
  export const DEFAULT_LIMITS = {
7
- maxFunctionLength: 120,
7
+ maxFunctionLength: 150,
8
+ maxMethodLength: 150,
8
9
  maxIndentationDepth: 6,
9
- maxFunctionsPerFile: 16,
10
+ maxFunctionsPerFile: 25,
10
11
  maxComponentsPerFile: 8,
11
12
  maxFileLength: 500,
12
13
  maxDuplicateLineOccurrences: 3,
@@ -38,6 +39,7 @@ export async function loadFormatLimits() {
38
39
  const settingsRecord = settings;
39
40
  return {
40
41
  maxFunctionLength: coerceNumber(settingsRecord.maxFunctionLength, DEFAULT_LIMITS.maxFunctionLength),
42
+ maxMethodLength: coerceNumber(settingsRecord.maxMethodLength, DEFAULT_LIMITS.maxMethodLength),
41
43
  maxIndentationDepth: coerceNumber(settingsRecord.maxIndentationDepth, DEFAULT_LIMITS.maxIndentationDepth),
42
44
  maxFunctionsPerFile: coerceNumber(settingsRecord.maxFunctionsPerFile, DEFAULT_LIMITS.maxFunctionsPerFile),
43
45
  maxComponentsPerFile: coerceNumber(settingsRecord.maxComponentsPerFile, DEFAULT_LIMITS.maxComponentsPerFile),
@@ -8,13 +8,14 @@ import * as ts from 'typescript';
8
8
  export async function analyzeFiles(files, limits) {
9
9
  const violations = [];
10
10
  const duplicateMap = new Map();
11
+ const fileSnapshots = new Map();
11
12
  for (const file of files) {
12
- violations.push(...(await analyzeSingleFile(file, limits, duplicateMap)));
13
+ violations.push(...(await analyzeSingleFile(file, limits, duplicateMap, fileSnapshots)));
13
14
  }
14
- violations.push(...collectDuplicateViolations(duplicateMap, limits));
15
+ violations.push(...collectDuplicateViolations(duplicateMap, limits, fileSnapshots));
15
16
  return violations;
16
17
  }
17
- async function analyzeSingleFile(filePath, limits, duplicates) {
18
+ async function analyzeSingleFile(filePath, limits, duplicates, fileSnapshots) {
18
19
  let content;
19
20
  try {
20
21
  content = await readFile(filePath, 'utf-8');
@@ -48,7 +49,7 @@ async function analyzeSingleFile(filePath, limits, duplicates) {
48
49
  }
49
50
  });
50
51
  const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.ESNext, true, resolveScriptKind(filePath));
51
- recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines);
52
+ recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines, fileSnapshots);
52
53
  const indentationGroups = groupIndentationViolations(indentationViolations, lines, limits.maxIndentationDepth);
53
54
  indentationGroups
54
55
  .sort((a, b) => b.severity - a.severity)
@@ -95,13 +96,17 @@ async function analyzeSingleFile(filePath, limits, duplicates) {
95
96
  });
96
97
  }
97
98
  functions.forEach((record) => {
98
- if (record.length > limits.maxFunctionLength) {
99
+ const limit = record.isMethod
100
+ ? limits.maxMethodLength
101
+ : limits.maxFunctionLength;
102
+ if (record.length > limit) {
103
+ const descriptor = record.isMethod ? 'Method' : 'Function';
99
104
  violations.push({
100
105
  type: 'functionLength',
101
106
  file: filePath,
102
107
  line: record.startLine,
103
- severity: record.length - limits.maxFunctionLength,
104
- message: `Function ${record.name ?? '<anonymous>'} spans ${record.length} lines (max ${limits.maxFunctionLength})`,
108
+ severity: record.length - limit,
109
+ message: `${descriptor} ${record.name ?? '<anonymous>'} spans ${record.length} lines (max ${limit})`,
105
110
  });
106
111
  }
107
112
  });
@@ -1,8 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import { getLineColumnRange } from './utils.js';
3
3
  import { collectRequiredVariables } from './variables.js';
4
- export function recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines) {
4
+ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicates, sourceFile, lines, fileSnapshots) {
5
5
  const minLines = Math.max(1, limits.minDuplicateLines);
6
+ const registerSnapshot = () => {
7
+ ensureFileSnapshot(fileSnapshots, filePath, normalizedLines, lines, sourceFile);
8
+ };
6
9
  if (limits.minDuplicateLines <= 0 || minLines === 1) {
7
10
  normalizedLines.forEach((entry, index) => {
8
11
  if (!entry.normalized)
@@ -13,6 +16,7 @@ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicat
13
16
  if (entry.isImport)
14
17
  return;
15
18
  const { startColumn, endColumn } = getLineColumnRange(entry.raw);
19
+ registerSnapshot();
16
20
  addDuplicateOccurrence(duplicates, entry.normalized, filePath, index + 1, startColumn, index + 1, endColumn, snippet, sourceFile, lines);
17
21
  });
18
22
  return;
@@ -48,6 +52,7 @@ export function recordDuplicateLines(normalizedLines, filePath, limits, duplicat
48
52
  const endLine = i + 1 + lastNonEmptyIndex;
49
53
  const { startColumn } = getLineColumnRange(slice[firstNonEmptyIndex].raw);
50
54
  const { endColumn } = getLineColumnRange(slice[lastNonEmptyIndex].raw);
55
+ registerSnapshot();
51
56
  addDuplicateOccurrence(duplicates, key, filePath, startLine, startColumn, endLine, endColumn, snippet, sourceFile, lines);
52
57
  }
53
58
  }
@@ -88,28 +93,140 @@ function addDuplicateOccurrence(duplicates, key, filePath, startLine, startColum
88
93
  ],
89
94
  });
90
95
  }
91
- export function collectDuplicateViolations(duplicates, limits) {
96
+ function ensureFileSnapshot(fileSnapshots, filePath, normalizedLines, lines, sourceFile) {
97
+ if (fileSnapshots.has(filePath))
98
+ return;
99
+ fileSnapshots.set(filePath, {
100
+ sourceFile,
101
+ normalizedLines,
102
+ lines,
103
+ });
104
+ }
105
+ function fingerprintLine(entry) {
106
+ if (entry.normalized)
107
+ return entry.normalized;
108
+ const collapsed = entry.trimmed.replace(/\s+/g, ' ');
109
+ if (!collapsed)
110
+ return undefined;
111
+ if (!/[A-Za-z0-9]/.test(collapsed))
112
+ return undefined;
113
+ return collapsed.toLowerCase();
114
+ }
115
+ function buildOccurrenceInfo(occurrence, fileSnapshots) {
116
+ const snapshot = fileSnapshots.get(occurrence.file);
117
+ if (!snapshot)
118
+ return undefined;
119
+ const totalLines = snapshot.normalizedLines.length;
120
+ if (totalLines === 0)
121
+ return undefined;
122
+ const clampIndex = (value) => Math.max(0, Math.min(totalLines - 1, value));
123
+ const rawStart = clampIndex(occurrence.line - 1);
124
+ const rawEnd = clampIndex(occurrence.endLine - 1);
125
+ return {
126
+ file: occurrence.file,
127
+ snapshot,
128
+ startIndex: Math.min(rawStart, rawEnd),
129
+ endIndex: Math.max(rawStart, rawEnd),
130
+ };
131
+ }
132
+ function expandDuplicateTracker(tracker, fileSnapshots) {
133
+ const infos = tracker.occurrences
134
+ .map((occurrence) => buildOccurrenceInfo(occurrence, fileSnapshots))
135
+ .filter((info) => Boolean(info));
136
+ if (infos.length < 2)
137
+ return undefined;
138
+ expandRangeAcrossOccurrences(infos);
139
+ const primary = infos[0];
140
+ const lines = primary.snapshot.normalizedLines
141
+ .slice(primary.startIndex, primary.endIndex + 1)
142
+ .map((entry) => entry.trimmed)
143
+ .filter(Boolean);
144
+ if (!lines.length)
145
+ return undefined;
146
+ const snippet = lines.map((line) => line.replace(/\s+/g, ' ')).join('\n');
147
+ const startLine = primary.startIndex + 1;
148
+ const endLine = primary.endIndex + 1;
149
+ const startColumn = getLineColumnRange(primary.snapshot.normalizedLines[primary.startIndex].raw).startColumn;
150
+ const endColumn = getLineColumnRange(primary.snapshot.normalizedLines[primary.endIndex].raw).endColumn;
151
+ const requiredVariables = collectRequiredVariables(primary.snapshot.sourceFile, primary.snapshot.lines, {
152
+ startLine,
153
+ startColumn,
154
+ endLine,
155
+ endColumn,
156
+ });
157
+ return {
158
+ snippet,
159
+ detail: infos.map(formatOccurrenceDetail).join('\n'),
160
+ requiredVariables,
161
+ };
162
+ }
163
+ function expandRangeAcrossOccurrences(infos) {
164
+ while (tryExpandRange(infos, 'left')) { }
165
+ while (tryExpandRange(infos, 'right')) { }
166
+ }
167
+ function tryExpandRange(infos, direction) {
168
+ const fingerprints = [];
169
+ for (const info of infos) {
170
+ const nextIndex = direction === 'left' ? info.startIndex - 1 : info.endIndex + 1;
171
+ if (nextIndex < 0 || nextIndex >= info.snapshot.normalizedLines.length) {
172
+ return false;
173
+ }
174
+ const fingerprint = fingerprintLine(info.snapshot.normalizedLines[nextIndex]);
175
+ if (!fingerprint)
176
+ return false;
177
+ fingerprints.push(fingerprint);
178
+ }
179
+ if (fingerprints.some((value) => value !== fingerprints[0])) {
180
+ return false;
181
+ }
182
+ for (const info of infos) {
183
+ if (direction === 'left') {
184
+ info.startIndex -= 1;
185
+ }
186
+ else {
187
+ info.endIndex += 1;
188
+ }
189
+ }
190
+ return true;
191
+ }
192
+ function formatOccurrenceDetail(info) {
193
+ const startLine = info.startIndex + 1;
194
+ const endLine = info.endIndex + 1;
195
+ const startEntry = info.snapshot.normalizedLines[info.startIndex];
196
+ const endEntry = info.snapshot.normalizedLines[info.endIndex];
197
+ const startColumn = startEntry
198
+ ? getLineColumnRange(startEntry.raw).startColumn
199
+ : 1;
200
+ const endColumn = endEntry
201
+ ? getLineColumnRange(endEntry.raw).endColumn
202
+ : 1;
203
+ const relativePath = path.relative(process.cwd(), info.file);
204
+ return `${relativePath}:${startLine}:${startColumn}/${endLine}:${endColumn}`;
205
+ }
206
+ export function collectDuplicateViolations(duplicates, limits, fileSnapshots) {
92
207
  const violations = [];
93
208
  for (const tracker of duplicates.values()) {
94
209
  if (tracker.count <= limits.maxDuplicateLineOccurrences)
95
210
  continue;
96
211
  if (tracker.occurrences.length === 0)
97
212
  continue;
213
+ const expanded = expandDuplicateTracker(tracker, fileSnapshots);
98
214
  violations.push({
99
215
  type: 'duplicateLine',
100
216
  file: tracker.occurrences[0].file,
101
217
  line: tracker.occurrences[0].line,
102
218
  severity: tracker.count - limits.maxDuplicateLineOccurrences,
103
219
  message: `Repeated ${tracker.count} times (max ${limits.maxDuplicateLineOccurrences})`,
104
- detail: tracker.occurrences
105
- .map((occurrence) => {
106
- const relativePath = path.relative(process.cwd(), occurrence.file);
107
- return `${relativePath}:${occurrence.line}:${occurrence.startColumn}/${occurrence.endLine}:${occurrence.endColumn}`;
108
- })
109
- .join('\n'),
110
- snippet: tracker.snippet,
220
+ detail: expanded?.detail ??
221
+ tracker.occurrences
222
+ .map((occurrence) => {
223
+ const relativePath = path.relative(process.cwd(), occurrence.file);
224
+ return `${relativePath}:${occurrence.line}:${occurrence.startColumn}/${occurrence.endLine}:${occurrence.endColumn}`;
225
+ })
226
+ .join('\n'),
227
+ snippet: expanded?.snippet ?? tracker.snippet,
111
228
  repeatCount: tracker.count,
112
- requiredVariables: tracker.requiredVariables,
229
+ requiredVariables: expanded?.requiredVariables ?? tracker.requiredVariables,
113
230
  });
114
231
  }
115
232
  return violations;
@@ -12,13 +12,15 @@ export function collectFunctionRecords(sourceFile) {
12
12
  const length = end.line - start.line + 1;
13
13
  const name = resolveFunctionName(node);
14
14
  const isComponent = typeof name === 'string' && isComponentFunction(node, name);
15
- const isExported = ts.isExportDeclaration(node);
15
+ const isMethod = ts.isMethodDeclaration(node);
16
+ const isExported = hasExportModifier(node) || (isMethod && isParentClassExported(node));
16
17
  records.push({
17
18
  name,
18
19
  startLine: start.line + 1,
19
20
  length,
20
21
  isComponent,
21
22
  isExported,
23
+ isMethod,
22
24
  });
23
25
  }
24
26
  ts.forEachChild(node, visit);
@@ -66,3 +68,18 @@ function containsJsx(node) {
66
68
  ts.forEachChild(node, walk);
67
69
  return found;
68
70
  }
71
+ function hasExportModifier(node) {
72
+ const candidate = node;
73
+ if (!candidate.modifiers)
74
+ return false;
75
+ return candidate.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
76
+ }
77
+ function isParentClassExported(node) {
78
+ const parent = node.parent;
79
+ if (!parent)
80
+ return false;
81
+ if (ts.isClassDeclaration(parent) || ts.isClassExpression(parent)) {
82
+ return hasExportModifier(parent);
83
+ }
84
+ return false;
85
+ }
@@ -2,9 +2,11 @@ import path from 'node:path';
2
2
  import * as ts from 'typescript';
3
3
  export function normalizeLine(line) {
4
4
  const trimmed = line.trim();
5
- if (trimmed.length < 12)
5
+ if (!trimmed)
6
6
  return undefined;
7
7
  const collapse = trimmed.replace(/\s+/g, ' ');
8
+ const letterCount = (collapse.match(/[A-Za-z]/g) ?? []).length;
9
+ // if (collapse.length < 12 && letterCount < 3) return undefined
8
10
  const noNumbers = collapse.replace(/\d+/g, '#');
9
11
  return noNumbers.toLowerCase();
10
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",