@memberjunction/codegen-lib 4.4.0 → 5.1.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.
- package/README.md +163 -0
- package/dist/Angular/angular-codegen.d.ts +12 -0
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +78 -12
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/related-entity-components.d.ts.map +1 -1
- package/dist/Angular/related-entity-components.js +10 -3
- package/dist/Angular/related-entity-components.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +40 -0
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +103 -13
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/sql_codegen.d.ts +10 -3
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +79 -15
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/EntityNameScanner/EntityNameScanner.d.ts +166 -0
- package/dist/EntityNameScanner/EntityNameScanner.d.ts.map +1 -0
- package/dist/EntityNameScanner/EntityNameScanner.js +758 -0
- package/dist/EntityNameScanner/EntityNameScanner.js.map +1 -0
- package/dist/EntityNameScanner/HtmlEntityNameScanner.d.ts +86 -0
- package/dist/EntityNameScanner/HtmlEntityNameScanner.d.ts.map +1 -0
- package/dist/EntityNameScanner/HtmlEntityNameScanner.js +262 -0
- package/dist/EntityNameScanner/HtmlEntityNameScanner.js.map +1 -0
- package/dist/EntityNameScanner/MetadataNameScanner.d.ts +90 -0
- package/dist/EntityNameScanner/MetadataNameScanner.d.ts.map +1 -0
- package/dist/EntityNameScanner/MetadataNameScanner.js +426 -0
- package/dist/EntityNameScanner/MetadataNameScanner.js.map +1 -0
- package/dist/EntityNameScanner/entity-rename-map.d.ts +31 -0
- package/dist/EntityNameScanner/entity-rename-map.d.ts.map +1 -0
- package/dist/EntityNameScanner/entity-rename-map.js +3012 -0
- package/dist/EntityNameScanner/entity-rename-map.js.map +1 -0
- package/dist/Misc/action_subclasses_codegen.d.ts +2 -2
- package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/createNewUser.js +6 -6
- package/dist/Misc/createNewUser.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +2 -2
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/runCodeGen.d.ts.map +1 -1
- package/dist/runCodeGen.js +24 -3
- package/dist/runCodeGen.js.map +1 -1
- package/package.json +15 -15
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-based scanner/fixer for hardcoded entity names AND class name references
|
|
3
|
+
* in TypeScript source code, for the MemberJunction v5.0 migration.
|
|
4
|
+
*
|
|
5
|
+
* Three replacement strategies (ported from tools/migrate-entity-refs.js):
|
|
6
|
+
*
|
|
7
|
+
* 1. **Class names** (regex with word boundaries):
|
|
8
|
+
* `ActionEntity` → `MJActionEntity`, `ActionSchema` → `MJActionSchema`,
|
|
9
|
+
* `ActionEntityType` → `MJActionEntityType`
|
|
10
|
+
*
|
|
11
|
+
* 2. **Multi-word entity names** (regex with quote boundaries):
|
|
12
|
+
* `'Action Categories'` → `'MJ: Action Categories'`
|
|
13
|
+
*
|
|
14
|
+
* 3. **Single-word entity names** (TypeScript AST):
|
|
15
|
+
* Only replaces string literals in confirmed entity-name contexts
|
|
16
|
+
* (EntityName properties, GetEntityObject args, RegisterClass decorators, etc.)
|
|
17
|
+
*
|
|
18
|
+
* Usage (via MJCLI):
|
|
19
|
+
* mj codegen 5-0-fix-entity-names --path packages/Angular
|
|
20
|
+
* mj codegen 5-0-fix-entity-names --path packages/Angular --fix
|
|
21
|
+
*
|
|
22
|
+
* Or programmatically:
|
|
23
|
+
* import { scanEntityNames } from '@memberjunction/codegen-lib';
|
|
24
|
+
* const result = await scanEntityNames({ TargetPath: './packages' });
|
|
25
|
+
*/
|
|
26
|
+
import ts from 'typescript';
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
import { glob } from 'glob';
|
|
30
|
+
import { ENTITY_RENAME_MAP } from './entity-rename-map.js';
|
|
31
|
+
export { ENTITY_RENAME_MAP } from './entity-rename-map.js';
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Default Configuration
|
|
34
|
+
// ============================================================================
|
|
35
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
36
|
+
'**/node_modules/**',
|
|
37
|
+
'**/dist/**',
|
|
38
|
+
'**/build/**',
|
|
39
|
+
'**/.git/**',
|
|
40
|
+
'**/__tests__/**',
|
|
41
|
+
'**/*.d.ts',
|
|
42
|
+
'**/*.spec.ts',
|
|
43
|
+
'**/*.test.ts',
|
|
44
|
+
'**/generated/**',
|
|
45
|
+
'**/Demos/**',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Method names whose first string-literal argument is treated as an entity name.
|
|
49
|
+
*/
|
|
50
|
+
const ENTITY_NAME_METHODS = new Set([
|
|
51
|
+
'GetEntityObject',
|
|
52
|
+
'GetEntityObjectByRecord',
|
|
53
|
+
'GetEntityByName',
|
|
54
|
+
'EntityByName',
|
|
55
|
+
'OpenEntityRecord',
|
|
56
|
+
'navigateToEntity',
|
|
57
|
+
'BuildRelationshipViewParamsByEntityName',
|
|
58
|
+
'NewRecordValues',
|
|
59
|
+
'IsCurrentTab',
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* Property names that hold entity name strings in property assignments.
|
|
63
|
+
* Matches the original migrate-entity-refs.js ENTITY_NAME_PROPERTIES set.
|
|
64
|
+
*/
|
|
65
|
+
const ENTITY_NAME_ASSIGNMENT_PROPS = new Set([
|
|
66
|
+
'EntityName',
|
|
67
|
+
'entityName',
|
|
68
|
+
'Entity',
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Property names that, when used in a `=== 'OldName'` or `!== 'OldName'`
|
|
72
|
+
* comparison, unambiguously indicate the string literal is an entity name.
|
|
73
|
+
*
|
|
74
|
+
* NOTE: `Name` is intentionally excluded — it's too generic and produces
|
|
75
|
+
* false positives. Instead, `.Name` comparisons are handled by targeted
|
|
76
|
+
* AST checks in classifyParentContext (Cases 5 and 6).
|
|
77
|
+
*/
|
|
78
|
+
const ENTITY_NAME_COMPARISON_PROPS = new Set([
|
|
79
|
+
'Entity',
|
|
80
|
+
'EntityName',
|
|
81
|
+
'LinkedEntity',
|
|
82
|
+
]);
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Rename Map Construction
|
|
85
|
+
// ============================================================================
|
|
86
|
+
/** Escapes special regex characters in a string. */
|
|
87
|
+
function escapeRegExp(str) {
|
|
88
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Builds class rename rules from the rename map entries.
|
|
92
|
+
* For each entry with classNameChanged=true, creates regex rules for:
|
|
93
|
+
* - OldClassNameEntityType → NewClassNameEntityType (zod inferred type)
|
|
94
|
+
* - OldClassNameSchema → NewClassNameSchema (zod schema constant)
|
|
95
|
+
* - OldClassNameEntity → NewClassNameEntity (class name itself)
|
|
96
|
+
*
|
|
97
|
+
* Uses negative lookbehind for / and . to avoid matching inside file paths
|
|
98
|
+
* (e.g., `import { Foo } from './custom/OldClassNameEntity'` should NOT rename the path).
|
|
99
|
+
*/
|
|
100
|
+
export function buildClassRenameRules(entries) {
|
|
101
|
+
const rules = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (!entry.classNameChanged)
|
|
104
|
+
continue;
|
|
105
|
+
// Suffixes in longest-first order to avoid partial matches
|
|
106
|
+
const suffixes = ['EntityType', 'Schema', 'Entity'];
|
|
107
|
+
for (const suffix of suffixes) {
|
|
108
|
+
const oldName = entry.oldClassName + suffix;
|
|
109
|
+
const newName = entry.newClassName + suffix;
|
|
110
|
+
rules.push({
|
|
111
|
+
old: oldName,
|
|
112
|
+
new: newName,
|
|
113
|
+
pattern: new RegExp(`(?<![/.])\\b${escapeRegExp(oldName)}\\b`, 'g'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Sort longest-first to prevent shorter patterns from matching inside longer names
|
|
118
|
+
rules.sort((a, b) => b.old.length - a.old.length);
|
|
119
|
+
return rules;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Builds multi-word entity name regex rules.
|
|
123
|
+
* For each entry with nameChanged=true AND a multi-word old name,
|
|
124
|
+
* creates patterns that match the old name inside quotes.
|
|
125
|
+
*/
|
|
126
|
+
export function buildMultiWordNameRules(entries) {
|
|
127
|
+
const rules = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.nameChanged)
|
|
130
|
+
continue;
|
|
131
|
+
if (!entry.oldName.includes(' '))
|
|
132
|
+
continue;
|
|
133
|
+
const escaped = escapeRegExp(entry.oldName);
|
|
134
|
+
rules.push({
|
|
135
|
+
old: entry.oldName,
|
|
136
|
+
new: entry.newName,
|
|
137
|
+
singleQuotePattern: new RegExp(`(?<=')${escaped}(?=')`, 'g'),
|
|
138
|
+
doubleQuotePattern: new RegExp(`(?<=")${escaped}(?=")`, 'g'),
|
|
139
|
+
backtickPattern: new RegExp(`(?<=\`)${escaped}(?=\`)`, 'g'),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Sort longest-first
|
|
143
|
+
rules.sort((a, b) => b.old.length - a.old.length);
|
|
144
|
+
return rules;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Builds an entity name rename map (old → new) from the embedded data.
|
|
148
|
+
* This is the simple entity-name-only map used by the HTML and metadata scanners.
|
|
149
|
+
*/
|
|
150
|
+
export function loadEmbeddedRenameMap() {
|
|
151
|
+
const map = new Map();
|
|
152
|
+
for (const entry of ENTITY_RENAME_MAP) {
|
|
153
|
+
if (entry.nameChanged) {
|
|
154
|
+
map.set(entry.oldName, entry.newName);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return map;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Parses entity_subclasses.ts to build a map of old entity names to new
|
|
161
|
+
* (MJ:-prefixed) entity names.
|
|
162
|
+
*
|
|
163
|
+
* Scans for `@RegisterClass(BaseEntity, 'MJ: SomeName')` decorators and
|
|
164
|
+
* creates a mapping: `'SomeName' -> 'MJ: SomeName'`.
|
|
165
|
+
*/
|
|
166
|
+
export function buildEntityNameMap(entitySubclassesPath) {
|
|
167
|
+
const renameMap = new Map();
|
|
168
|
+
if (!fs.existsSync(entitySubclassesPath)) {
|
|
169
|
+
throw new Error(`Entity subclasses file not found: ${entitySubclassesPath}`);
|
|
170
|
+
}
|
|
171
|
+
const sourceText = fs.readFileSync(entitySubclassesPath, 'utf-8');
|
|
172
|
+
// Quick check
|
|
173
|
+
if (!sourceText.includes('RegisterClass')) {
|
|
174
|
+
return renameMap;
|
|
175
|
+
}
|
|
176
|
+
const sourceFile = ts.createSourceFile(entitySubclassesPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
177
|
+
function visit(node) {
|
|
178
|
+
if (ts.isClassDeclaration(node)) {
|
|
179
|
+
const decorators = ts.getDecorators(node);
|
|
180
|
+
if (decorators) {
|
|
181
|
+
for (const decorator of decorators) {
|
|
182
|
+
if (!ts.isCallExpression(decorator.expression))
|
|
183
|
+
continue;
|
|
184
|
+
const callExpr = decorator.expression;
|
|
185
|
+
if (!ts.isIdentifier(callExpr.expression))
|
|
186
|
+
continue;
|
|
187
|
+
if (callExpr.expression.text !== 'RegisterClass')
|
|
188
|
+
continue;
|
|
189
|
+
const args = callExpr.arguments;
|
|
190
|
+
if (args.length < 2)
|
|
191
|
+
continue;
|
|
192
|
+
if (!ts.isIdentifier(args[0]) || args[0].text !== 'BaseEntity')
|
|
193
|
+
continue;
|
|
194
|
+
if (!ts.isStringLiteral(args[1]))
|
|
195
|
+
continue;
|
|
196
|
+
const newName = args[1].text;
|
|
197
|
+
if (!newName.startsWith('MJ: '))
|
|
198
|
+
continue;
|
|
199
|
+
const oldName = newName.substring(4); // Strip 'MJ: '
|
|
200
|
+
renameMap.set(oldName, newName);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(node, visit);
|
|
205
|
+
}
|
|
206
|
+
visit(sourceFile);
|
|
207
|
+
// Filter to only entries that actually changed names (some entities always
|
|
208
|
+
// had the MJ: prefix). Cross-reference against the embedded rename map.
|
|
209
|
+
const knownRenames = new Set();
|
|
210
|
+
for (const entry of ENTITY_RENAME_MAP) {
|
|
211
|
+
if (entry.nameChanged) {
|
|
212
|
+
knownRenames.add(entry.oldName);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const oldName of [...renameMap.keys()]) {
|
|
216
|
+
if (!knownRenames.has(oldName)) {
|
|
217
|
+
renameMap.delete(oldName);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return renameMap;
|
|
221
|
+
}
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Rename Map Resolution
|
|
224
|
+
// ============================================================================
|
|
225
|
+
/**
|
|
226
|
+
* Resolves the path to entity_subclasses.ts, trying common locations.
|
|
227
|
+
* Returns null if not found (caller should fall back to embedded data).
|
|
228
|
+
*/
|
|
229
|
+
function resolveEntitySubclassesPath(basePath, explicitPath) {
|
|
230
|
+
if (explicitPath) {
|
|
231
|
+
const resolved = path.resolve(explicitPath);
|
|
232
|
+
if (fs.existsSync(resolved))
|
|
233
|
+
return resolved;
|
|
234
|
+
throw new Error(`Specified entity subclasses path does not exist: ${explicitPath}`);
|
|
235
|
+
}
|
|
236
|
+
// Try common locations relative to the target path
|
|
237
|
+
const candidates = [
|
|
238
|
+
// Monorepo layout (running from repo root or subdir)
|
|
239
|
+
path.resolve(basePath, 'packages/MJCoreEntities/src/generated/entity_subclasses.ts'),
|
|
240
|
+
path.resolve(basePath, '../packages/MJCoreEntities/src/generated/entity_subclasses.ts'),
|
|
241
|
+
path.resolve(basePath, '../../packages/MJCoreEntities/src/generated/entity_subclasses.ts'),
|
|
242
|
+
// npm consumer layout (node_modules)
|
|
243
|
+
path.resolve(basePath, 'node_modules/@memberjunction/core-entities/src/generated/entity_subclasses.ts'),
|
|
244
|
+
path.resolve(basePath, '../node_modules/@memberjunction/core-entities/src/generated/entity_subclasses.ts'),
|
|
245
|
+
];
|
|
246
|
+
for (const candidate of candidates) {
|
|
247
|
+
if (fs.existsSync(candidate))
|
|
248
|
+
return candidate;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Builds the entity name rename map, trying entity_subclasses.ts first and
|
|
254
|
+
* falling back to the embedded rename map compiled into this package.
|
|
255
|
+
*/
|
|
256
|
+
export function resolveEntityNameMap(basePath, explicitPath, verbose) {
|
|
257
|
+
const entitySubclassesPath = resolveEntitySubclassesPath(basePath, explicitPath);
|
|
258
|
+
if (entitySubclassesPath) {
|
|
259
|
+
if (verbose) {
|
|
260
|
+
console.log(`Building rename map from: ${entitySubclassesPath}`);
|
|
261
|
+
}
|
|
262
|
+
const renameMap = buildEntityNameMap(entitySubclassesPath);
|
|
263
|
+
if (verbose) {
|
|
264
|
+
console.log(`Loaded ${renameMap.size} entity name mappings`);
|
|
265
|
+
}
|
|
266
|
+
return renameMap;
|
|
267
|
+
}
|
|
268
|
+
// Fall back to the embedded data compiled into this package
|
|
269
|
+
if (verbose) {
|
|
270
|
+
console.log(`entity_subclasses.ts not found on disk, using embedded rename map`);
|
|
271
|
+
}
|
|
272
|
+
const renameMap = loadEmbeddedRenameMap();
|
|
273
|
+
if (verbose) {
|
|
274
|
+
console.log(`Loaded ${renameMap.size} entity name mappings from embedded data`);
|
|
275
|
+
}
|
|
276
|
+
return renameMap;
|
|
277
|
+
}
|
|
278
|
+
// ============================================================================
|
|
279
|
+
// AST Scanner (Strategy 3: Single-word entity names)
|
|
280
|
+
// ============================================================================
|
|
281
|
+
/**
|
|
282
|
+
* Extracts the method name from a call expression's callee.
|
|
283
|
+
* Handles both direct calls `GetEntityObject(...)` and member access
|
|
284
|
+
* `md.GetEntityObject(...)` or `this.service.OpenEntityRecord(...)`.
|
|
285
|
+
*/
|
|
286
|
+
function getMethodName(expression) {
|
|
287
|
+
if (ts.isIdentifier(expression))
|
|
288
|
+
return expression.text;
|
|
289
|
+
if (ts.isPropertyAccessExpression(expression))
|
|
290
|
+
return expression.name.text;
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Determines if a string literal node is in a relevant AST context
|
|
295
|
+
* (i.e., an argument to a known method, an EntityName property assignment,
|
|
296
|
+
* or a comparison against a known entity-name property).
|
|
297
|
+
*/
|
|
298
|
+
function classifyParentContext(node) {
|
|
299
|
+
const parent = node.parent;
|
|
300
|
+
if (!parent)
|
|
301
|
+
return null;
|
|
302
|
+
// Case 1: Argument to a method call like GetEntityObject('Name') or OpenEntityRecord('Name')
|
|
303
|
+
if (ts.isCallExpression(parent)) {
|
|
304
|
+
const methodName = getMethodName(parent.expression);
|
|
305
|
+
if (methodName && ENTITY_NAME_METHODS.has(methodName)) {
|
|
306
|
+
if (methodName === 'GetEntityObject')
|
|
307
|
+
return 'GetEntityObject';
|
|
308
|
+
if (methodName === 'OpenEntityRecord')
|
|
309
|
+
return 'OpenEntityRecord';
|
|
310
|
+
return 'EntityNameMethod';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Case 2: Property assignment like EntityName: 'Name' or entityName: 'Name' or Entity: 'Name'
|
|
314
|
+
if (ts.isPropertyAssignment(parent)) {
|
|
315
|
+
const propName = parent.name;
|
|
316
|
+
if (ts.isIdentifier(propName) && ENTITY_NAME_ASSIGNMENT_PROPS.has(propName.text)) {
|
|
317
|
+
return 'EntityNameProperty';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Case 2b: Binary assignment like item.EntityName = 'Users' or item.entityName = 'Users'
|
|
321
|
+
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
322
|
+
if (parent.right === node && ts.isPropertyAccessExpression(parent.left)) {
|
|
323
|
+
const propName = parent.left.name.text;
|
|
324
|
+
if (ENTITY_NAME_ASSIGNMENT_PROPS.has(propName)) {
|
|
325
|
+
return 'EntityNameProperty';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Case 3: Argument to @RegisterClass decorator
|
|
330
|
+
if (ts.isCallExpression(parent) && parent.parent && ts.isDecorator(parent.parent)) {
|
|
331
|
+
const callee = parent.expression;
|
|
332
|
+
if (ts.isIdentifier(callee) && callee.text === 'RegisterClass') {
|
|
333
|
+
return 'RegisterClass';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Case 4: Comparison like .Entity === 'OldName' or .LinkedEntity === 'OldName'
|
|
337
|
+
if (ts.isBinaryExpression(parent)) {
|
|
338
|
+
const op = parent.operatorToken.kind;
|
|
339
|
+
if (op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsEqualsToken) {
|
|
340
|
+
const otherSide = parent.left === node ? parent.right : parent.left;
|
|
341
|
+
const propName = getTrailingPropertyName(otherSide);
|
|
342
|
+
if (propName && ENTITY_NAME_COMPARISON_PROPS.has(propName)) {
|
|
343
|
+
return 'NameComparison';
|
|
344
|
+
}
|
|
345
|
+
// Case 5: .EntityInfo.Name === 'OldName'
|
|
346
|
+
if (propName === 'Name' && isEntityInfoNameChain(otherSide)) {
|
|
347
|
+
return 'NameComparison';
|
|
348
|
+
}
|
|
349
|
+
// Case 6: .Entities.find(e => e.Name === 'OldName')
|
|
350
|
+
if (propName === 'Name' && isEntitiesArrayCallback(parent)) {
|
|
351
|
+
return 'NameComparison';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Extracts the trailing property name from an expression.
|
|
359
|
+
* For `foo.bar.Name` returns 'Name', for `e.Name` returns 'Name'.
|
|
360
|
+
*/
|
|
361
|
+
function getTrailingPropertyName(expr) {
|
|
362
|
+
if (ts.isPropertyAccessExpression(expr)) {
|
|
363
|
+
return expr.name.text;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Checks if an expression is a `.EntityInfo.Name` property chain.
|
|
369
|
+
*/
|
|
370
|
+
function isEntityInfoNameChain(expr) {
|
|
371
|
+
if (!ts.isPropertyAccessExpression(expr))
|
|
372
|
+
return false;
|
|
373
|
+
if (expr.name.text !== 'Name')
|
|
374
|
+
return false;
|
|
375
|
+
const parent = expr.expression;
|
|
376
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name.text === 'EntityInfo') {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Checks if a binary expression is inside a `.find()` or `.filter()` callback
|
|
383
|
+
* on an array property named `Entities`.
|
|
384
|
+
*/
|
|
385
|
+
function isEntitiesArrayCallback(binaryExpr) {
|
|
386
|
+
let current = binaryExpr;
|
|
387
|
+
while (current.parent) {
|
|
388
|
+
current = current.parent;
|
|
389
|
+
if (ts.isArrowFunction(current)) {
|
|
390
|
+
const callParent = current.parent;
|
|
391
|
+
if (ts.isCallExpression(callParent)) {
|
|
392
|
+
const callee = callParent.expression;
|
|
393
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
394
|
+
const methodName = callee.name.text;
|
|
395
|
+
if (methodName === 'find' || methodName === 'filter') {
|
|
396
|
+
const obj = callee.expression;
|
|
397
|
+
const objProp = getTrailingPropertyName(obj);
|
|
398
|
+
if (objProp === 'Entities') {
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (ts.isBlock(current) || ts.isSourceFile(current)) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// File Scanning
|
|
414
|
+
// ============================================================================
|
|
415
|
+
/**
|
|
416
|
+
* Scans a single TypeScript file for entity name and class name references
|
|
417
|
+
* that need updating for the v5.0 migration.
|
|
418
|
+
*
|
|
419
|
+
* Applies all three strategies:
|
|
420
|
+
* 1. Regex-based class name scanning
|
|
421
|
+
* 2. Regex-based multi-word entity name scanning
|
|
422
|
+
* 3. AST-based single-word entity name scanning
|
|
423
|
+
*/
|
|
424
|
+
export function scanFile(filePath, sourceText, renameMap, classRules, multiWordRules) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
// Quick check: does this file contain any old entity name or class name?
|
|
427
|
+
let hasRelevantContent = false;
|
|
428
|
+
for (const oldName of renameMap.keys()) {
|
|
429
|
+
if (sourceText.includes(oldName)) {
|
|
430
|
+
hasRelevantContent = true;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Also check for class names if rules are provided
|
|
435
|
+
if (!hasRelevantContent && classRules) {
|
|
436
|
+
for (const rule of classRules) {
|
|
437
|
+
if (sourceText.includes(rule.old)) {
|
|
438
|
+
hasRelevantContent = true;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Also check for multi-word entity names if rules are provided
|
|
444
|
+
if (!hasRelevantContent && multiWordRules) {
|
|
445
|
+
for (const rule of multiWordRules) {
|
|
446
|
+
if (sourceText.includes(rule.old)) {
|
|
447
|
+
hasRelevantContent = true;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!hasRelevantContent)
|
|
453
|
+
return findings;
|
|
454
|
+
// ── Strategy 1: Regex-based class name scanning ──────────────────────────
|
|
455
|
+
if (classRules) {
|
|
456
|
+
for (const rule of classRules) {
|
|
457
|
+
let match;
|
|
458
|
+
// Reset lastIndex for each file
|
|
459
|
+
rule.pattern.lastIndex = 0;
|
|
460
|
+
while ((match = rule.pattern.exec(sourceText)) !== null) {
|
|
461
|
+
const startPos = match.index;
|
|
462
|
+
const endPos = startPos + match[0].length;
|
|
463
|
+
const line = getLineNumber(sourceText, startPos);
|
|
464
|
+
const column = getColumnNumber(sourceText, startPos);
|
|
465
|
+
findings.push({
|
|
466
|
+
FilePath: filePath,
|
|
467
|
+
Line: line,
|
|
468
|
+
Column: column,
|
|
469
|
+
OldName: rule.old,
|
|
470
|
+
NewName: rule.new,
|
|
471
|
+
QuoteChar: '',
|
|
472
|
+
StartPos: startPos,
|
|
473
|
+
EndPos: endPos,
|
|
474
|
+
PatternKind: 'ClassName',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// ── Strategy 2: Regex-based multi-word entity name scanning ──────────────
|
|
480
|
+
if (multiWordRules) {
|
|
481
|
+
for (const rule of multiWordRules) {
|
|
482
|
+
const quotePatterns = [
|
|
483
|
+
{ quoteChar: "'", pattern: rule.singleQuotePattern },
|
|
484
|
+
{ quoteChar: '"', pattern: rule.doubleQuotePattern },
|
|
485
|
+
{ quoteChar: '`', pattern: rule.backtickPattern },
|
|
486
|
+
];
|
|
487
|
+
for (const { quoteChar, pattern } of quotePatterns) {
|
|
488
|
+
let match;
|
|
489
|
+
pattern.lastIndex = 0;
|
|
490
|
+
while ((match = pattern.exec(sourceText)) !== null) {
|
|
491
|
+
const startPos = match.index;
|
|
492
|
+
const endPos = startPos + match[0].length;
|
|
493
|
+
const line = getLineNumber(sourceText, startPos);
|
|
494
|
+
const column = getColumnNumber(sourceText, startPos);
|
|
495
|
+
findings.push({
|
|
496
|
+
FilePath: filePath,
|
|
497
|
+
Line: line,
|
|
498
|
+
Column: column,
|
|
499
|
+
OldName: rule.old,
|
|
500
|
+
NewName: rule.new,
|
|
501
|
+
QuoteChar: quoteChar,
|
|
502
|
+
StartPos: startPos,
|
|
503
|
+
EndPos: endPos,
|
|
504
|
+
PatternKind: 'MultiWordEntityName',
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// ── Strategy 3: AST-based single-word entity name scanning ───────────────
|
|
511
|
+
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, // setParentNodes
|
|
512
|
+
ts.ScriptKind.TS);
|
|
513
|
+
function visit(node) {
|
|
514
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
515
|
+
const text = node.text;
|
|
516
|
+
// Direct match: the entire string literal is an old entity name
|
|
517
|
+
if (renameMap.has(text)) {
|
|
518
|
+
const patternKind = classifyParentContext(node);
|
|
519
|
+
if (patternKind) {
|
|
520
|
+
const { line, character } = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile));
|
|
521
|
+
const startPos = node.getStart(sourceFile);
|
|
522
|
+
const endPos = node.getEnd();
|
|
523
|
+
const quoteChar = sourceText[startPos];
|
|
524
|
+
findings.push({
|
|
525
|
+
FilePath: filePath,
|
|
526
|
+
Line: line + 1,
|
|
527
|
+
Column: character,
|
|
528
|
+
OldName: text,
|
|
529
|
+
NewName: renameMap.get(text),
|
|
530
|
+
QuoteChar: quoteChar,
|
|
531
|
+
StartPos: startPos,
|
|
532
|
+
EndPos: endPos,
|
|
533
|
+
PatternKind: patternKind,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
ts.forEachChild(node, visit);
|
|
539
|
+
}
|
|
540
|
+
visit(sourceFile);
|
|
541
|
+
return findings;
|
|
542
|
+
}
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// Fixer
|
|
545
|
+
// ============================================================================
|
|
546
|
+
/** Priority map: AST-based findings are more precise than regex-based ones. */
|
|
547
|
+
const PATTERN_PRIORITY = {
|
|
548
|
+
'ClassName': 1,
|
|
549
|
+
'MultiWordEntityName': 2,
|
|
550
|
+
};
|
|
551
|
+
/**
|
|
552
|
+
* Removes overlapping findings to prevent output corruption when multiple
|
|
553
|
+
* strategies match the same text region. Keeps the highest-priority finding
|
|
554
|
+
* (AST-based patterns win over regex-based ones since they have proper
|
|
555
|
+
* context validation).
|
|
556
|
+
*/
|
|
557
|
+
function deduplicateFindings(findings) {
|
|
558
|
+
if (findings.length <= 1)
|
|
559
|
+
return findings;
|
|
560
|
+
// Sort by StartPos ascending, then by priority descending (higher = better)
|
|
561
|
+
const sorted = [...findings].sort((a, b) => {
|
|
562
|
+
if (a.StartPos !== b.StartPos)
|
|
563
|
+
return a.StartPos - b.StartPos;
|
|
564
|
+
const aPri = PATTERN_PRIORITY[a.PatternKind] ?? 10;
|
|
565
|
+
const bPri = PATTERN_PRIORITY[b.PatternKind] ?? 10;
|
|
566
|
+
return bPri - aPri; // higher priority first
|
|
567
|
+
});
|
|
568
|
+
const result = [sorted[0]];
|
|
569
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
570
|
+
const current = sorted[i];
|
|
571
|
+
const last = result[result.length - 1];
|
|
572
|
+
// If this finding overlaps with the previous kept finding, skip it
|
|
573
|
+
if (current.StartPos < last.EndPos)
|
|
574
|
+
continue;
|
|
575
|
+
result.push(current);
|
|
576
|
+
}
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Applies fixes to a file by replacing old names with new names
|
|
581
|
+
* at the exact positions identified by the scanner.
|
|
582
|
+
*
|
|
583
|
+
* Processes findings from end to start to preserve byte offsets.
|
|
584
|
+
*/
|
|
585
|
+
export function fixFile(sourceText, findings) {
|
|
586
|
+
if (findings.length === 0)
|
|
587
|
+
return sourceText;
|
|
588
|
+
// Deduplicate overlapping findings to prevent output corruption.
|
|
589
|
+
// When multiple strategies find the same entity name at overlapping positions,
|
|
590
|
+
// keep only the most specific one (AST > MultiWordEntityName > ClassName).
|
|
591
|
+
const deduped = deduplicateFindings(findings);
|
|
592
|
+
// Sort by StartPos descending so we fix from end to start
|
|
593
|
+
const sorted = [...deduped].sort((a, b) => b.StartPos - a.StartPos);
|
|
594
|
+
let result = sourceText;
|
|
595
|
+
for (const finding of sorted) {
|
|
596
|
+
if (finding.PatternKind === 'ClassName') {
|
|
597
|
+
// Class names: replace the identifier directly (no quotes)
|
|
598
|
+
const before = result.substring(0, finding.StartPos);
|
|
599
|
+
const after = result.substring(finding.EndPos);
|
|
600
|
+
result = before + finding.NewName + after;
|
|
601
|
+
}
|
|
602
|
+
else if (finding.PatternKind === 'MultiWordEntityName') {
|
|
603
|
+
// Multi-word names: replace just the entity name text (inside quotes)
|
|
604
|
+
const before = result.substring(0, finding.StartPos);
|
|
605
|
+
const after = result.substring(finding.EndPos);
|
|
606
|
+
result = before + finding.NewName + after;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// AST-based: the StartPos/EndPos include quotes
|
|
610
|
+
const newLiteral = finding.QuoteChar + finding.NewName + finding.QuoteChar;
|
|
611
|
+
const before = result.substring(0, finding.StartPos);
|
|
612
|
+
const after = result.substring(finding.EndPos);
|
|
613
|
+
result = before + newLiteral + after;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// Utility Functions
|
|
620
|
+
// ============================================================================
|
|
621
|
+
/** Gets the 1-based line number for a character position in text. */
|
|
622
|
+
function getLineNumber(text, pos) {
|
|
623
|
+
let line = 1;
|
|
624
|
+
for (let i = 0; i < pos && i < text.length; i++) {
|
|
625
|
+
if (text[i] === '\n')
|
|
626
|
+
line++;
|
|
627
|
+
}
|
|
628
|
+
return line;
|
|
629
|
+
}
|
|
630
|
+
/** Gets the 0-based column number for a character position in text. */
|
|
631
|
+
function getColumnNumber(text, pos) {
|
|
632
|
+
let lastNewline = -1;
|
|
633
|
+
for (let i = 0; i < pos && i < text.length; i++) {
|
|
634
|
+
if (text[i] === '\n')
|
|
635
|
+
lastNewline = i;
|
|
636
|
+
}
|
|
637
|
+
return pos - lastNewline - 1;
|
|
638
|
+
}
|
|
639
|
+
// ============================================================================
|
|
640
|
+
// Main Entry Point
|
|
641
|
+
// ============================================================================
|
|
642
|
+
/**
|
|
643
|
+
* Scans TypeScript files for hardcoded entity names and class name references
|
|
644
|
+
* that need updating for the MemberJunction v5.0 migration, and optionally
|
|
645
|
+
* fixes them in place.
|
|
646
|
+
*
|
|
647
|
+
* Three strategies are applied:
|
|
648
|
+
* 1. Class name renames (regex): ActionEntity → MJActionEntity
|
|
649
|
+
* 2. Multi-word entity name renames (regex): 'AI Models' → 'MJ: AI Models'
|
|
650
|
+
* 3. Single-word entity name renames (AST): 'Actions' → 'MJ: Actions' (context-verified)
|
|
651
|
+
*/
|
|
652
|
+
export async function scanEntityNames(options) {
|
|
653
|
+
const errors = [];
|
|
654
|
+
const verbose = options.Verbose !== false;
|
|
655
|
+
// Resolve target path
|
|
656
|
+
const targetPath = path.resolve(options.TargetPath);
|
|
657
|
+
if (!fs.existsSync(targetPath)) {
|
|
658
|
+
return {
|
|
659
|
+
Success: false,
|
|
660
|
+
Findings: [],
|
|
661
|
+
FixedFiles: [],
|
|
662
|
+
FilesScanned: 0,
|
|
663
|
+
RenameMapSize: 0,
|
|
664
|
+
Errors: [`Target path does not exist: ${targetPath}`],
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// Build rename maps from embedded data
|
|
668
|
+
const renameEntries = ENTITY_RENAME_MAP;
|
|
669
|
+
const classRules = buildClassRenameRules(renameEntries);
|
|
670
|
+
const multiWordRules = buildMultiWordNameRules(renameEntries);
|
|
671
|
+
// The entity name map for AST scanning — SINGLE-WORD ONLY to avoid duplicate
|
|
672
|
+
// findings with Strategy 2 (multi-word names are handled exclusively by regex).
|
|
673
|
+
// When entity_subclasses.ts is available, buildEntityNameMap returns all entries
|
|
674
|
+
// (including multi-word). We filter to single-word to prevent overlapping matches.
|
|
675
|
+
let entityNameMap;
|
|
676
|
+
try {
|
|
677
|
+
const fullMap = resolveEntityNameMap(targetPath, options.EntitySubclassesPath, verbose);
|
|
678
|
+
// Filter to single-word names only for AST scanning
|
|
679
|
+
entityNameMap = new Map();
|
|
680
|
+
for (const [oldName, newName] of fullMap) {
|
|
681
|
+
if (!oldName.includes(' ')) {
|
|
682
|
+
entityNameMap.set(oldName, newName);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
return {
|
|
688
|
+
Success: false,
|
|
689
|
+
Findings: [],
|
|
690
|
+
FixedFiles: [],
|
|
691
|
+
FilesScanned: 0,
|
|
692
|
+
RenameMapSize: 0,
|
|
693
|
+
Errors: [err.message],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
if (verbose) {
|
|
697
|
+
console.log(`Loaded ${classRules.length} class rename rules, ${multiWordRules.length} multi-word entity name rules, and ${entityNameMap.size} single-word AST rules`);
|
|
698
|
+
}
|
|
699
|
+
// Find TypeScript files
|
|
700
|
+
const isFile = fs.statSync(targetPath).isFile();
|
|
701
|
+
let tsFiles;
|
|
702
|
+
if (isFile) {
|
|
703
|
+
tsFiles = [targetPath];
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const excludePatterns = [
|
|
707
|
+
...DEFAULT_EXCLUDE_PATTERNS,
|
|
708
|
+
...(options.ExcludePatterns ?? []),
|
|
709
|
+
];
|
|
710
|
+
tsFiles = await glob('**/*.ts', {
|
|
711
|
+
cwd: targetPath,
|
|
712
|
+
absolute: true,
|
|
713
|
+
ignore: excludePatterns,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (verbose) {
|
|
717
|
+
console.log(`Scanning ${tsFiles.length} TypeScript files...`);
|
|
718
|
+
}
|
|
719
|
+
// Scan files
|
|
720
|
+
const allFindings = [];
|
|
721
|
+
const fixedFiles = [];
|
|
722
|
+
for (const filePath of tsFiles) {
|
|
723
|
+
try {
|
|
724
|
+
const sourceText = fs.readFileSync(filePath, 'utf-8');
|
|
725
|
+
const findings = scanFile(filePath, sourceText, entityNameMap, classRules, multiWordRules);
|
|
726
|
+
if (findings.length > 0) {
|
|
727
|
+
allFindings.push(...findings);
|
|
728
|
+
if (options.Fix) {
|
|
729
|
+
const fixedText = fixFile(sourceText, findings);
|
|
730
|
+
fs.writeFileSync(filePath, fixedText, 'utf-8');
|
|
731
|
+
fixedFiles.push(filePath);
|
|
732
|
+
if (verbose) {
|
|
733
|
+
console.log(` Fixed ${findings.length} reference(s) in ${filePath}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else if (verbose) {
|
|
737
|
+
console.log(` Found ${findings.length} reference(s) in ${filePath}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
const message = `Error scanning ${filePath}: ${err.message}`;
|
|
743
|
+
errors.push(message);
|
|
744
|
+
if (verbose) {
|
|
745
|
+
console.error(` ${message}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
Success: errors.length === 0,
|
|
751
|
+
Findings: allFindings,
|
|
752
|
+
FixedFiles: fixedFiles,
|
|
753
|
+
FilesScanned: tsFiles.length,
|
|
754
|
+
RenameMapSize: renameEntries.length,
|
|
755
|
+
Errors: errors,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
//# sourceMappingURL=EntityNameScanner.js.map
|