@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 +14 -2
- package/dist/format-checker/cli/options.js +6 -0
- package/dist/format-checker/cli/prompts.js +1 -0
- package/dist/format-checker/cli/settings.js +6 -0
- package/dist/format-checker/config.js +4 -2
- package/dist/format-checker/scan/analysis.js +12 -7
- package/dist/format-checker/scan/duplicates.js +127 -10
- package/dist/format-checker/scan/functions.js +18 -1
- package/dist/format-checker/scan/utils.js +3 -1
- package/package.json +1 -1
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'),
|
|
@@ -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:
|
|
7
|
+
maxFunctionLength: 150,
|
|
8
|
+
maxMethodLength: 150,
|
|
8
9
|
maxIndentationDepth: 6,
|
|
9
|
-
maxFunctionsPerFile:
|
|
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
|
-
|
|
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 -
|
|
104
|
-
message:
|
|
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
|
-
|
|
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:
|
|
105
|
-
.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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
|
}
|