@craft-ng/dev-tools 0.5.0-beta.4 → 0.5.1-beta.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 +132 -23
- package/package.json +6 -2
- package/src/bin/craft-migrate-primitives.d.ts +2 -0
- package/src/bin/craft-migrate-primitives.js +71 -0
- package/src/bin/craft-migrate-primitives.js.map +1 -0
- package/src/bin/craft-migrate-routes.d.ts +2 -0
- package/src/bin/craft-migrate-routes.js +77 -0
- package/src/bin/craft-migrate-routes.js.map +1 -0
- package/src/bin/craft-migrate-services.d.ts +2 -0
- package/src/bin/craft-migrate-services.js +75 -0
- package/src/bin/craft-migrate-services.js.map +1 -0
- package/src/bin/craft-migrate.d.ts +2 -0
- package/src/bin/craft-migrate.js +90 -0
- package/src/bin/craft-migrate.js.map +1 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +4 -4
- package/src/eslint-rules/index.cjs +6 -0
- package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
- package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
- package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +3 -3
- package/src/index.d.ts +8 -0
- package/src/index.js +8 -0
- package/src/index.js.map +1 -1
- package/src/scripts/angular-brand-codemod.d.ts +17 -0
- package/src/scripts/angular-brand-codemod.js +44 -8
- package/src/scripts/angular-brand-codemod.js.map +1 -1
- package/src/scripts/angular-brand-codemod.spec.ts +3 -3
- package/src/scripts/angular-brand-codemod.ts +78 -7
- package/src/scripts/migrate.d.ts +32 -0
- package/src/scripts/migrate.js +67 -0
- package/src/scripts/migrate.js.map +1 -0
- package/src/scripts/migrate.ts +128 -0
- package/src/scripts/migration-workspace.d.ts +2 -0
- package/src/scripts/migration-workspace.js +72 -0
- package/src/scripts/migration-workspace.js.map +1 -0
- package/src/scripts/migration-workspace.ts +93 -0
- package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitives.js +354 -0
- package/src/scripts/primitives/migrate-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-primitives.spec.ts +279 -0
- package/src/scripts/primitives/migrate-primitives.ts +543 -0
- package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
- package/src/scripts/primitives/migration-diagnostic.js +2 -0
- package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
- package/src/scripts/primitives/migration-diagnostic.ts +13 -0
- package/src/scripts/routes/migrate-routes.d.ts +34 -0
- package/src/scripts/routes/migrate-routes.js +669 -0
- package/src/scripts/routes/migrate-routes.js.map +1 -0
- package/src/scripts/routes/migrate-routes.spec.ts +264 -0
- package/src/scripts/routes/migrate-routes.ts +897 -0
- package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
- package/src/scripts/routes/migration-diagnostic.js +2 -0
- package/src/scripts/routes/migration-diagnostic.js.map +1 -0
- package/src/scripts/routes/migration-diagnostic.ts +25 -0
- package/src/scripts/services/config.d.ts +2 -0
- package/src/scripts/services/config.js +39 -0
- package/src/scripts/services/config.js.map +1 -0
- package/src/scripts/services/config.ts +60 -0
- package/src/scripts/services/migrate-services.d.ts +29 -0
- package/src/scripts/services/migrate-services.js +895 -0
- package/src/scripts/services/migrate-services.js.map +1 -0
- package/src/scripts/services/migrate-services.spec.ts +719 -0
- package/src/scripts/services/migrate-services.ts +1282 -0
- package/src/scripts/services/migration-diagnostic.d.ts +8 -0
- package/src/scripts/services/migration-diagnostic.js +2 -0
- package/src/scripts/services/migration-diagnostic.js.map +1 -0
- package/src/scripts/services/migration-diagnostic.ts +27 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import {
|
|
7
|
+
CallExpression,
|
|
8
|
+
Node,
|
|
9
|
+
Project,
|
|
10
|
+
QuoteKind,
|
|
11
|
+
SourceFile,
|
|
12
|
+
SyntaxKind,
|
|
13
|
+
} from 'ts-morph';
|
|
14
|
+
import type {
|
|
15
|
+
PrimitiveMigrationDiagnostic,
|
|
16
|
+
PrimitiveMigrationDiagnosticCode,
|
|
17
|
+
} from './migration-diagnostic.js';
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
export type MigratePrimitivesOptions = {
|
|
22
|
+
rootDir?: string;
|
|
23
|
+
tsConfigFilePath?: string;
|
|
24
|
+
files?: readonly string[];
|
|
25
|
+
write?: boolean;
|
|
26
|
+
check?: boolean;
|
|
27
|
+
json?: boolean;
|
|
28
|
+
jsonFilePath?: string;
|
|
29
|
+
failOnManual?: boolean;
|
|
30
|
+
eslint?: boolean;
|
|
31
|
+
log?: (message: string) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type MigratedPrimitiveFile = {
|
|
35
|
+
filePath: string;
|
|
36
|
+
changed: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MigratePrimitivesResult = {
|
|
40
|
+
changedFiles: string[];
|
|
41
|
+
files: MigratedPrimitiveFile[];
|
|
42
|
+
diagnostics: PrimitiveMigrationDiagnostic[];
|
|
43
|
+
remainingAngularSignals: number;
|
|
44
|
+
remainingSignalForms: number;
|
|
45
|
+
eslintRan: boolean;
|
|
46
|
+
exitCode: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function runPrimitivesMigration(
|
|
50
|
+
options: MigratePrimitivesOptions = {},
|
|
51
|
+
): Promise<MigratePrimitivesResult> {
|
|
52
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
53
|
+
const tsConfigFilePath = options.tsConfigFilePath
|
|
54
|
+
? resolve(options.tsConfigFilePath)
|
|
55
|
+
: defaultTsConfig(rootDir);
|
|
56
|
+
const project = new Project({
|
|
57
|
+
...(existsSync(tsConfigFilePath) ? { tsConfigFilePath } : {}),
|
|
58
|
+
manipulationSettings: { quoteKind: QuoteKind.Single },
|
|
59
|
+
skipAddingFilesFromTsConfig: false,
|
|
60
|
+
});
|
|
61
|
+
project.addSourceFilesAtPaths([
|
|
62
|
+
join(rootDir, '**/*.ts'),
|
|
63
|
+
`!${join(rootDir, '**/node_modules/**')}`,
|
|
64
|
+
`!${join(rootDir, '**/dist/**')}`,
|
|
65
|
+
`!${join(rootDir, '**/.angular/**')}`,
|
|
66
|
+
`!${join(rootDir, '**/*.d.ts')}`,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const selected = options.files?.length
|
|
70
|
+
? new Set(options.files.map((file) => resolve(rootDir, file)))
|
|
71
|
+
: undefined;
|
|
72
|
+
const sourceFiles = project.getSourceFiles().filter((file) => {
|
|
73
|
+
const path = resolve(file.getFilePath());
|
|
74
|
+
return isInside(path, rootDir) && (!selected || selected.has(path));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const files = new Map<string, MigratedPrimitiveFile>();
|
|
78
|
+
const touched = new Set<SourceFile>();
|
|
79
|
+
const diagnostics: PrimitiveMigrationDiagnostic[] = [];
|
|
80
|
+
|
|
81
|
+
for (const sourceFile of sourceFiles) {
|
|
82
|
+
const changedSignals = migrateSignalsToState(sourceFile);
|
|
83
|
+
const changedResources = migrateSingleEmissionRxResources(
|
|
84
|
+
sourceFile,
|
|
85
|
+
diagnostics,
|
|
86
|
+
);
|
|
87
|
+
const changedWorkflows = annotateImperativeWorkflows(
|
|
88
|
+
sourceFile,
|
|
89
|
+
diagnostics,
|
|
90
|
+
);
|
|
91
|
+
diagnoseSignalForms(sourceFile, diagnostics);
|
|
92
|
+
if (changedSignals || changedResources || changedWorkflows)
|
|
93
|
+
touched.add(sourceFile);
|
|
94
|
+
if (changedSignals || changedResources || changedWorkflows)
|
|
95
|
+
getFileReport(files, sourceFile.getFilePath()).changed = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const file of touched) file.organizeImports();
|
|
99
|
+
|
|
100
|
+
if (options.write) {
|
|
101
|
+
await Promise.all([...touched].map((file) => file.save()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let eslintRan = false;
|
|
105
|
+
if (options.write && options.eslint !== false && touched.size > 0) {
|
|
106
|
+
await runEslint(
|
|
107
|
+
[...touched].map((file) => file.getFilePath()),
|
|
108
|
+
rootDir,
|
|
109
|
+
);
|
|
110
|
+
eslintRan = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const remainingAngularSignals = countAngularSignalImports(project, rootDir);
|
|
114
|
+
const remainingSignalForms = countSignalFormImports(project, rootDir);
|
|
115
|
+
const result: MigratePrimitivesResult = {
|
|
116
|
+
changedFiles: [...touched].map((file) => file.getFilePath()),
|
|
117
|
+
files: [...files.values()],
|
|
118
|
+
diagnostics,
|
|
119
|
+
remainingAngularSignals,
|
|
120
|
+
remainingSignalForms,
|
|
121
|
+
eslintRan,
|
|
122
|
+
exitCode:
|
|
123
|
+
(options.check &&
|
|
124
|
+
(remainingAngularSignals > 0 || remainingSignalForms > 0)) ||
|
|
125
|
+
(options.failOnManual && diagnostics.length > 0)
|
|
126
|
+
? 1
|
|
127
|
+
: 0,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (options.jsonFilePath) {
|
|
131
|
+
const path = resolve(options.jsonFilePath);
|
|
132
|
+
await mkdir(dirname(path), { recursive: true });
|
|
133
|
+
await writeFile(path, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
134
|
+
}
|
|
135
|
+
const log = options.log ?? console.log;
|
|
136
|
+
if (options.json) log(JSON.stringify(result, null, 2));
|
|
137
|
+
else logSummary(result, log, options.write === true);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function migrateSignalsToState(sourceFile: SourceFile): boolean {
|
|
142
|
+
const angularCore = sourceFile.getImportDeclaration('@angular/core');
|
|
143
|
+
const signalImport = angularCore
|
|
144
|
+
?.getNamedImports()
|
|
145
|
+
.find((item) => item.getName() === 'signal');
|
|
146
|
+
if (!signalImport) return false;
|
|
147
|
+
|
|
148
|
+
let changed = false;
|
|
149
|
+
const declarationsToAnnotate = new Set<Node>();
|
|
150
|
+
for (const call of [
|
|
151
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression),
|
|
152
|
+
]) {
|
|
153
|
+
if (call.wasForgotten()) continue;
|
|
154
|
+
if (call.getExpression().getText() !== 'signal') continue;
|
|
155
|
+
const typeArgument = call.getTypeArguments()[0];
|
|
156
|
+
const initialValue = call.getArguments()[0];
|
|
157
|
+
if (!initialValue) continue;
|
|
158
|
+
const value = typeArgument
|
|
159
|
+
? `${initialValue.getText()} as ${typeArgument.getText()} satisfies ${typeArgument.getText()}`
|
|
160
|
+
: initialValue.getText();
|
|
161
|
+
const declaration = findImperativeStateDeclaration(call);
|
|
162
|
+
if (declaration) declarationsToAnnotate.add(declaration);
|
|
163
|
+
call.replaceWithText(
|
|
164
|
+
`state(${value}, ({ set, update }) => ({ set, update }))`,
|
|
165
|
+
);
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
for (const declaration of [...declarationsToAnnotate].sort(
|
|
169
|
+
(left, right) => right.getStart() - left.getStart(),
|
|
170
|
+
)) {
|
|
171
|
+
if (declaration.wasForgotten()) continue;
|
|
172
|
+
declaration.replaceWithText(
|
|
173
|
+
`${IMPERATIVE_STATE_COMMENT}\n${declaration.getText()}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!changed) return false;
|
|
178
|
+
signalImport.remove();
|
|
179
|
+
if (
|
|
180
|
+
angularCore &&
|
|
181
|
+
!angularCore.getDefaultImport() &&
|
|
182
|
+
!angularCore.getNamespaceImport() &&
|
|
183
|
+
angularCore.getNamedImports().length === 0
|
|
184
|
+
)
|
|
185
|
+
angularCore.remove();
|
|
186
|
+
ensureCoreImports(sourceFile, ['state']);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const IMPERATIVE_STATE_COMMENT =
|
|
191
|
+
'// CRAFT_IMPERATIVE_CODE_DETECTED: imperative code detected, prefer a declarative approach.';
|
|
192
|
+
|
|
193
|
+
function findImperativeStateDeclaration(
|
|
194
|
+
call: CallExpression,
|
|
195
|
+
): Node | undefined {
|
|
196
|
+
const declaration = call.getFirstAncestor(
|
|
197
|
+
(ancestor) =>
|
|
198
|
+
Node.isPropertyDeclaration(ancestor) ||
|
|
199
|
+
Node.isVariableStatement(ancestor),
|
|
200
|
+
);
|
|
201
|
+
if (
|
|
202
|
+
!declaration ||
|
|
203
|
+
declaration.getFullText().includes('CRAFT_IMPERATIVE_CODE_DETECTED')
|
|
204
|
+
)
|
|
205
|
+
return undefined;
|
|
206
|
+
return declaration;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const REACTIVE_WORKFLOW_COMMENT =
|
|
210
|
+
'// CRAFT_REACTIVE_WORKFLOW_RECOMMENDED: workflow impératif détecté...';
|
|
211
|
+
const FIRST_VALUE_FROM_REVIEW_COMMENT =
|
|
212
|
+
'// CRAFT_FIRST_VALUE_FROM_REVIEW: firstValueFrom bridges an Observable temporarily; prefer a Promise-native Craft API when possible.';
|
|
213
|
+
|
|
214
|
+
function migrateSingleEmissionRxResources(
|
|
215
|
+
sourceFile: SourceFile,
|
|
216
|
+
diagnostics: PrimitiveMigrationDiagnostic[],
|
|
217
|
+
): boolean {
|
|
218
|
+
const rxInterop = sourceFile.getImportDeclaration(
|
|
219
|
+
'@angular/core/rxjs-interop',
|
|
220
|
+
);
|
|
221
|
+
if (
|
|
222
|
+
!rxInterop
|
|
223
|
+
?.getNamedImports()
|
|
224
|
+
.some((item) => item.getName() === 'rxResource')
|
|
225
|
+
)
|
|
226
|
+
return false;
|
|
227
|
+
|
|
228
|
+
let changed = false;
|
|
229
|
+
for (const call of sourceFile.getDescendantsOfKind(
|
|
230
|
+
SyntaxKind.CallExpression,
|
|
231
|
+
)) {
|
|
232
|
+
if (call.wasForgotten() || call.getExpression().getText() !== 'rxResource')
|
|
233
|
+
continue;
|
|
234
|
+
const config = call.getArguments()[0];
|
|
235
|
+
if (!config || !Node.isObjectLiteralExpression(config)) continue;
|
|
236
|
+
const stream = config.getProperty('stream');
|
|
237
|
+
if (!stream || !Node.isPropertyAssignment(stream)) continue;
|
|
238
|
+
|
|
239
|
+
const converted = convertSingleEmissionStream(
|
|
240
|
+
stream.getInitializer()?.getText() ?? '',
|
|
241
|
+
);
|
|
242
|
+
if (!converted) continue;
|
|
243
|
+
|
|
244
|
+
call.getExpression().replaceWithText('query');
|
|
245
|
+
stream.replaceWithText(`loader: ${converted}`);
|
|
246
|
+
changed = true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!changed) return false;
|
|
250
|
+
ensureCoreImports(sourceFile, ['query']);
|
|
251
|
+
ensureNamedImport(sourceFile, 'rxjs', 'firstValueFrom');
|
|
252
|
+
removeNamedImportIfUnused(sourceFile, '@angular/core/rxjs-interop', 'rxResource');
|
|
253
|
+
removeNamedImportIfUnused(sourceFile, 'rxjs', 'from');
|
|
254
|
+
removeNamedImportIfUnused(sourceFile, 'rxjs', 'of');
|
|
255
|
+
removeNamedImportIfUnused(sourceFile, 'rxjs/operators', 'switchMap');
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
!sourceFile
|
|
259
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
260
|
+
.some((call) => call.getExpression().getText() === 'rxResource')
|
|
261
|
+
) {
|
|
262
|
+
removeDiagnostic(diagnostics, sourceFile, 'RX_RESOURCE_REQUIRES_QUERY');
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function convertSingleEmissionStream(initializer: string): string | undefined {
|
|
268
|
+
const match = initializer.match(
|
|
269
|
+
/^\s*\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/,
|
|
270
|
+
);
|
|
271
|
+
if (!match) return undefined;
|
|
272
|
+
let body = match[2];
|
|
273
|
+
const bridge = body.match(
|
|
274
|
+
/return\s+from\(([\s\S]*?)\)\.pipe\(\s*switchMap\(\s*\((\w+)\)\s*=>\s*([\s\S]*?)\s*\)\s*\)\s*;/,
|
|
275
|
+
);
|
|
276
|
+
if (!bridge) return undefined;
|
|
277
|
+
|
|
278
|
+
body = body.replace(/return\s+of\(([\s\S]*?)\)\s*;/g, 'return $1;');
|
|
279
|
+
body = body.replace(
|
|
280
|
+
bridge[0],
|
|
281
|
+
`${FIRST_VALUE_FROM_REVIEW_COMMENT}\nreturn firstValueFrom((await ${bridge[1]}).${stripReceiver(bridge[3], bridge[2])});`,
|
|
282
|
+
);
|
|
283
|
+
return `async (${match[1]}) => {${body}}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stripReceiver(expression: string, receiver: string): string {
|
|
287
|
+
const trimmed = expression.trim();
|
|
288
|
+
const prefix = `${receiver}.`;
|
|
289
|
+
return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ensureNamedImport(
|
|
293
|
+
sourceFile: SourceFile,
|
|
294
|
+
moduleSpecifier: string,
|
|
295
|
+
name: string,
|
|
296
|
+
): void {
|
|
297
|
+
let declaration = sourceFile.getImportDeclaration(moduleSpecifier);
|
|
298
|
+
if (!declaration)
|
|
299
|
+
declaration = sourceFile.addImportDeclaration({ moduleSpecifier });
|
|
300
|
+
if (
|
|
301
|
+
!declaration
|
|
302
|
+
.getNamedImports()
|
|
303
|
+
.some((namedImport) => namedImport.getName() === name)
|
|
304
|
+
)
|
|
305
|
+
declaration.addNamedImport(name);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function removeNamedImportIfUnused(
|
|
309
|
+
sourceFile: SourceFile,
|
|
310
|
+
moduleSpecifier: string,
|
|
311
|
+
name: string,
|
|
312
|
+
): void {
|
|
313
|
+
const declaration = sourceFile.getImportDeclaration(moduleSpecifier);
|
|
314
|
+
const namedImport = declaration
|
|
315
|
+
?.getNamedImports()
|
|
316
|
+
.find((item) => item.getName() === name);
|
|
317
|
+
if (!namedImport) return;
|
|
318
|
+
const stillUsed = sourceFile
|
|
319
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
320
|
+
.some(
|
|
321
|
+
(identifier) =>
|
|
322
|
+
identifier.getText() === name &&
|
|
323
|
+
identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) !==
|
|
324
|
+
declaration,
|
|
325
|
+
);
|
|
326
|
+
if (stillUsed) return;
|
|
327
|
+
namedImport.remove();
|
|
328
|
+
if (
|
|
329
|
+
declaration &&
|
|
330
|
+
!declaration.getDefaultImport() &&
|
|
331
|
+
!declaration.getNamespaceImport() &&
|
|
332
|
+
declaration.getNamedImports().length === 0
|
|
333
|
+
)
|
|
334
|
+
declaration.remove();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function removeDiagnostic(
|
|
338
|
+
diagnostics: PrimitiveMigrationDiagnostic[],
|
|
339
|
+
sourceFile: SourceFile,
|
|
340
|
+
code: PrimitiveMigrationDiagnosticCode,
|
|
341
|
+
): void {
|
|
342
|
+
const index = diagnostics.findIndex(
|
|
343
|
+
(diagnostic) =>
|
|
344
|
+
diagnostic.code === code &&
|
|
345
|
+
diagnostic.filePath === sourceFile.getFilePath(),
|
|
346
|
+
);
|
|
347
|
+
if (index >= 0) diagnostics.splice(index, 1);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function annotateImperativeWorkflows(
|
|
351
|
+
sourceFile: SourceFile,
|
|
352
|
+
diagnostics: PrimitiveMigrationDiagnostic[],
|
|
353
|
+
): boolean {
|
|
354
|
+
let changed = false;
|
|
355
|
+
const functions = [
|
|
356
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration),
|
|
357
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration),
|
|
358
|
+
];
|
|
359
|
+
for (const functionLike of functions) {
|
|
360
|
+
if (
|
|
361
|
+
functionLike.getFullText().includes('CRAFT_REACTIVE_WORKFLOW_RECOMMENDED')
|
|
362
|
+
)
|
|
363
|
+
continue;
|
|
364
|
+
const calls = functionLike.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
365
|
+
const hasSubmit = calls.some((call) => {
|
|
366
|
+
const expression = call.getExpression();
|
|
367
|
+
return (
|
|
368
|
+
expression.getText() === 'submit' ||
|
|
369
|
+
(Node.isPropertyAccessExpression(expression) &&
|
|
370
|
+
expression.getName() === 'submit')
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
if (!hasSubmit) continue;
|
|
374
|
+
const stateWrites = calls.filter((call) => {
|
|
375
|
+
const expression = call.getExpression();
|
|
376
|
+
return (
|
|
377
|
+
Node.isPropertyAccessExpression(expression) &&
|
|
378
|
+
['set', 'update'].includes(expression.getName())
|
|
379
|
+
);
|
|
380
|
+
}).length;
|
|
381
|
+
const hasNavigation = calls.some((call) => {
|
|
382
|
+
const expression = call.getExpression();
|
|
383
|
+
return (
|
|
384
|
+
Node.isPropertyAccessExpression(expression) &&
|
|
385
|
+
['navigate', 'navigateByUrl'].includes(expression.getName())
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
if (stateWrites < 2 && !(stateWrites >= 1 && hasNavigation)) continue;
|
|
389
|
+
|
|
390
|
+
functionLike.replaceWithText(
|
|
391
|
+
`${REACTIVE_WORKFLOW_COMMENT}\n${functionLike.getText()}`,
|
|
392
|
+
);
|
|
393
|
+
diagnose(
|
|
394
|
+
diagnostics,
|
|
395
|
+
'IMPERATIVE_WORKFLOW_REQUIRES_REVIEW',
|
|
396
|
+
sourceFile,
|
|
397
|
+
'Workflow impératif détecté: préférer insertFormSubmit avec une réaction au statut du formulaire, ou source$ avec on$/effect.',
|
|
398
|
+
);
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
return changed;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function diagnoseSignalForms(
|
|
405
|
+
sourceFile: SourceFile,
|
|
406
|
+
diagnostics: PrimitiveMigrationDiagnostic[],
|
|
407
|
+
): void {
|
|
408
|
+
const formsImport = sourceFile.getImportDeclaration('@angular/forms/signals');
|
|
409
|
+
if (!formsImport) return;
|
|
410
|
+
const names = new Set(formsImport.getNamedImports().map((item) => item.getName()));
|
|
411
|
+
if (names.has('form')) {
|
|
412
|
+
diagnose(
|
|
413
|
+
diagnostics,
|
|
414
|
+
'SIGNAL_FORM_REQUIRES_INSERT_FORM',
|
|
415
|
+
sourceFile,
|
|
416
|
+
'Angular signal form `form(...)` doit être migré vers `state(..., insertForm(...))`; les chemins de champs doivent être remappés vers le form tree craft.',
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
if (names.has('validateAsync')) {
|
|
420
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
421
|
+
if (!isValidateAsyncRxResourceCall(call)) continue;
|
|
422
|
+
diagnose(
|
|
423
|
+
diagnostics,
|
|
424
|
+
'ASYNC_VALIDATOR_REQUIRES_QUERY',
|
|
425
|
+
sourceFile,
|
|
426
|
+
'`validateAsync(...)` basé sur `rxResource(...)` doit devenir une `query(...)` locale déclenchée par la valeur du champ, puis `cAsyncValidate(queryRef, ...)`. Dans un craftService, créer la query avec `yield* track(query(...))`.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (sourceFile.getImportDeclaration('@angular/core/rxjs-interop')?.getNamedImports().some((item) => item.getName() === 'rxResource')) {
|
|
431
|
+
diagnose(
|
|
432
|
+
diagnostics,
|
|
433
|
+
'RX_RESOURCE_REQUIRES_QUERY',
|
|
434
|
+
sourceFile,
|
|
435
|
+
'`rxResource(...)` doit être remplacé par `query(...)` ou `mutation(...)` selon l’intention; dans un validateur async, préférer `query(...) + cAsyncValidate(...)`. Dans un craftService, englober toute primitive dépendante avec `yield* track(...)`.',
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isValidateAsyncRxResourceCall(call: CallExpression): boolean {
|
|
441
|
+
if (call.getExpression().getText() !== 'validateAsync') return false;
|
|
442
|
+
return call
|
|
443
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
444
|
+
.some((inner) => inner.getExpression().getText() === 'rxResource');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function ensureCoreImports(file: SourceFile, names: string[]): void {
|
|
448
|
+
let declaration = file.getImportDeclaration('@craft-ng/core');
|
|
449
|
+
if (!declaration)
|
|
450
|
+
declaration = file.addImportDeclaration({
|
|
451
|
+
moduleSpecifier: '@craft-ng/core',
|
|
452
|
+
});
|
|
453
|
+
const existing = new Set(
|
|
454
|
+
declaration.getNamedImports().map((item) => item.getName()),
|
|
455
|
+
);
|
|
456
|
+
declaration.addNamedImports(names.filter((name) => !existing.has(name)));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function diagnose(
|
|
460
|
+
diagnostics: PrimitiveMigrationDiagnostic[],
|
|
461
|
+
code: PrimitiveMigrationDiagnosticCode,
|
|
462
|
+
sourceFile: SourceFile,
|
|
463
|
+
message: string,
|
|
464
|
+
): void {
|
|
465
|
+
if (
|
|
466
|
+
diagnostics.some(
|
|
467
|
+
(item) => item.code === code && item.filePath === sourceFile.getFilePath(),
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
return;
|
|
471
|
+
diagnostics.push({
|
|
472
|
+
code,
|
|
473
|
+
filePath: sourceFile.getFilePath(),
|
|
474
|
+
message,
|
|
475
|
+
manual: true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function countAngularSignalImports(project: Project, rootDir: string): number {
|
|
480
|
+
return project
|
|
481
|
+
.getSourceFiles()
|
|
482
|
+
.filter((file) => isInside(resolve(file.getFilePath()), rootDir))
|
|
483
|
+
.filter((file) =>
|
|
484
|
+
file
|
|
485
|
+
.getImportDeclaration('@angular/core')
|
|
486
|
+
?.getNamedImports()
|
|
487
|
+
.some((item) => item.getName() === 'signal'),
|
|
488
|
+
).length;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function countSignalFormImports(project: Project, rootDir: string): number {
|
|
492
|
+
return project
|
|
493
|
+
.getSourceFiles()
|
|
494
|
+
.filter((file) => isInside(resolve(file.getFilePath()), rootDir))
|
|
495
|
+
.filter((file) => Boolean(file.getImportDeclaration('@angular/forms/signals')))
|
|
496
|
+
.length;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function getFileReport(
|
|
500
|
+
map: Map<string, MigratedPrimitiveFile>,
|
|
501
|
+
filePath: string,
|
|
502
|
+
): MigratedPrimitiveFile {
|
|
503
|
+
let report = map.get(filePath);
|
|
504
|
+
if (!report) {
|
|
505
|
+
report = { filePath, changed: false };
|
|
506
|
+
map.set(filePath, report);
|
|
507
|
+
}
|
|
508
|
+
return report;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isInside(filePath: string, rootDir: string): boolean {
|
|
512
|
+
const path = relative(rootDir, filePath);
|
|
513
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function defaultTsConfig(rootDir: string): string {
|
|
517
|
+
for (const name of ['tsconfig.app.json', 'tsconfig.json']) {
|
|
518
|
+
const candidate = join(rootDir, name);
|
|
519
|
+
if (existsSync(candidate)) return candidate;
|
|
520
|
+
}
|
|
521
|
+
return join(rootDir, 'tsconfig.json');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function runEslint(files: string[], cwd: string): Promise<void> {
|
|
525
|
+
const local = join(cwd, 'node_modules', '.bin', 'eslint');
|
|
526
|
+
await execFileAsync(
|
|
527
|
+
existsSync(local) ? local : 'eslint',
|
|
528
|
+
['--fix', ...files],
|
|
529
|
+
{ cwd },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function logSummary(
|
|
534
|
+
result: MigratePrimitivesResult,
|
|
535
|
+
log: (message: string) => void,
|
|
536
|
+
wrote: boolean,
|
|
537
|
+
): void {
|
|
538
|
+
log(
|
|
539
|
+
`${wrote ? 'Migrated' : 'Would migrate'} ${result.changedFiles.length} file(s); ${result.diagnostics.length} manual diagnostic(s).`,
|
|
540
|
+
);
|
|
541
|
+
for (const diagnostic of result.diagnostics)
|
|
542
|
+
log(`[${diagnostic.code}] ${diagnostic.filePath}: ${diagnostic.message}`);
|
|
543
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type PrimitiveMigrationDiagnosticCode = 'SIGNAL_FORM_REQUIRES_INSERT_FORM' | 'ASYNC_VALIDATOR_REQUIRES_QUERY' | 'RX_RESOURCE_REQUIRES_QUERY' | 'IMPERATIVE_WORKFLOW_REQUIRES_REVIEW';
|
|
2
|
+
export type PrimitiveMigrationDiagnostic = {
|
|
3
|
+
code: PrimitiveMigrationDiagnosticCode;
|
|
4
|
+
filePath: string;
|
|
5
|
+
symbol?: string;
|
|
6
|
+
message: string;
|
|
7
|
+
manual: true;
|
|
8
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migration-diagnostic.js","sourceRoot":"","sources":["../../../../../../libs/dev-tools/src/scripts/primitives/migration-diagnostic.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PrimitiveMigrationDiagnosticCode =
|
|
2
|
+
| 'SIGNAL_FORM_REQUIRES_INSERT_FORM'
|
|
3
|
+
| 'ASYNC_VALIDATOR_REQUIRES_QUERY'
|
|
4
|
+
| 'RX_RESOURCE_REQUIRES_QUERY'
|
|
5
|
+
| 'IMPERATIVE_WORKFLOW_REQUIRES_REVIEW';
|
|
6
|
+
|
|
7
|
+
export type PrimitiveMigrationDiagnostic = {
|
|
8
|
+
code: PrimitiveMigrationDiagnosticCode;
|
|
9
|
+
filePath: string;
|
|
10
|
+
symbol?: string;
|
|
11
|
+
message: string;
|
|
12
|
+
manual: true;
|
|
13
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph';
|
|
2
|
+
import { RouteMigrationDiagnostic } from './migration-diagnostic.js';
|
|
3
|
+
export type MigrateRoutesOptions = {
|
|
4
|
+
rootDir?: string;
|
|
5
|
+
tsConfigFilePath?: string;
|
|
6
|
+
files?: readonly string[];
|
|
7
|
+
write?: boolean;
|
|
8
|
+
check?: boolean;
|
|
9
|
+
collectionName?: string;
|
|
10
|
+
parentMount?: string;
|
|
11
|
+
parentNames?: readonly string[];
|
|
12
|
+
jsonFilePath?: string;
|
|
13
|
+
failOnManual?: boolean;
|
|
14
|
+
log?: (message: string) => void;
|
|
15
|
+
};
|
|
16
|
+
export type MigratedRoutesFile = {
|
|
17
|
+
filePath: string;
|
|
18
|
+
changed: boolean;
|
|
19
|
+
collections: string[];
|
|
20
|
+
};
|
|
21
|
+
export type MigrateRoutesResult = {
|
|
22
|
+
changedFiles: string[];
|
|
23
|
+
files: MigratedRoutesFile[];
|
|
24
|
+
diagnostics: RouteMigrationDiagnostic[];
|
|
25
|
+
remainingLegacyCollections: number;
|
|
26
|
+
exitCode: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function runRoutesMigration(options?: MigrateRoutesOptions): Promise<MigrateRoutesResult>;
|
|
29
|
+
export declare function migrateApplicationConfigSourceFile(sourceFile: SourceFile): boolean;
|
|
30
|
+
export declare function migrateBootstrapSourceFile(sourceFile: SourceFile): boolean;
|
|
31
|
+
export declare function migrateRoutesSourceFile(sourceFile: SourceFile, options?: Pick<MigrateRoutesOptions, 'collectionName' | 'parentMount' | 'parentNames'> & {
|
|
32
|
+
diagnostics?: RouteMigrationDiagnostic[];
|
|
33
|
+
generatedComponentFiles?: Set<SourceFile>;
|
|
34
|
+
}): string[];
|