@saptools/service-flow 0.1.51 → 0.1.53
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/CHANGELOG.md +13 -0
- package/README.md +16 -14
- package/TECHNICAL-NOTE.md +18 -6
- package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
- package/dist/chunk-LFH7C46B.js.map +1 -0
- package/dist/cli.js +435 -515
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +45 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/000-clean.ts +82 -0
- package/src/cli/001-doctor-projection.ts +140 -0
- package/src/cli/doctor.ts +20 -3
- package/src/cli.ts +75 -57
- package/src/db/connection.ts +1 -1
- package/src/db/migrations.ts +5 -3
- package/src/db/repositories.ts +130 -24
- package/src/db/schema.ts +7 -2
- package/src/indexer/repository-indexer.ts +57 -29
- package/src/indexer/workspace-indexer.ts +84 -6
- package/src/linker/000-implementation-candidates.ts +641 -0
- package/src/linker/001-implementation-evidence-projection.ts +119 -0
- package/src/linker/002-call-evidence.ts +226 -0
- package/src/linker/cross-repo-linker.ts +27 -441
- package/src/linker/dynamic-edge-resolver.ts +35 -0
- package/src/linker/external-http-target.ts +24 -3
- package/src/linker/helper-package-linker.ts +18 -2
- package/src/linker/service-resolver.ts +45 -2
- package/src/output/doctor-output.ts +13 -4
- package/src/output/table-output.ts +33 -5
- package/src/parsers/cds-parser.ts +8 -2
- package/src/parsers/decorator-parser.ts +382 -48
- package/src/parsers/handler-registration-parser.ts +38 -21
- package/src/parsers/imported-wrapper-parser.ts +18 -5
- package/src/parsers/outbound-call-parser.ts +25 -8
- package/src/parsers/package-json-parser.ts +36 -11
- package/src/parsers/service-binding-parser-helpers.ts +8 -1
- package/src/parsers/service-binding-parser.ts +6 -3
- package/src/parsers/symbol-parser.ts +13 -3
- package/src/parsers/ts-project.ts +54 -0
- package/src/trace/000-dynamic-target-types.ts +96 -0
- package/src/trace/001-dynamic-identity.ts +308 -0
- package/src/trace/002-trace-diagnostics.ts +54 -0
- package/src/trace/003-dynamic-references.ts +283 -0
- package/src/trace/004-dynamic-candidate-sources.ts +155 -0
- package/src/trace/005-implementation-selection.ts +187 -0
- package/src/trace/006-contextual-projection.ts +30 -0
- package/src/trace/007-implementation-start-diagnostic.ts +61 -0
- package/src/trace/dynamic-targets.ts +582 -306
- package/src/trace/evidence.ts +331 -46
- package/src/trace/implementation-hints.ts +148 -8
- package/src/trace/selectors.ts +551 -3
- package/src/trace/trace-engine.ts +129 -135
- package/src/types.ts +27 -1
- package/src/utils/000-bounded-projection.ts +161 -0
- package/dist/chunk-YZJKE5UX.js.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Db } from '../db/connection.js';
|
|
2
|
+
import { canonicalImplementationEvidence } from './000-implementation-candidates.js';
|
|
2
3
|
export interface OperationTarget {
|
|
3
4
|
operationId: number;
|
|
4
5
|
repoName: string;
|
|
@@ -209,8 +210,11 @@ function ownershipReason(db: Db, candidate: OperationTarget, callerRepoId: numbe
|
|
|
209
210
|
if (row?.repoId === callerRepoId) return { candidate, reason: 'resolved_implementation_handler_repo_matches_caller' };
|
|
210
211
|
}
|
|
211
212
|
if (edge?.evidence_json) {
|
|
212
|
-
const
|
|
213
|
-
const
|
|
213
|
+
const stored = parsedRecord(edge.evidence_json);
|
|
214
|
+
const evidence = canonicalImplementationEvidence(db, candidate.operationId) ?? stored;
|
|
215
|
+
const hit = implementationEvidenceCandidates(evidence).find((item) =>
|
|
216
|
+
item.accepted && (item.handlerRepoId === callerRepoId
|
|
217
|
+
|| item.applicationRepoId === callerRepoId));
|
|
214
218
|
if (hit) return { candidate, reason: edge.status === 'ambiguous' ? 'ambiguous_implementation_candidate_repo_matches_caller' : 'registration_package_matches_caller' };
|
|
215
219
|
}
|
|
216
220
|
const dep = db.prepare("SELECT 1 FROM graph_edges WHERE edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND status='resolved' AND from_kind='repo' AND from_id=? AND to_id=?").get(String(callerRepoId), String(candidate.repoId));
|
|
@@ -218,6 +222,45 @@ function ownershipReason(db: Db, candidate: OperationTarget, callerRepoId: numbe
|
|
|
218
222
|
return undefined;
|
|
219
223
|
}
|
|
220
224
|
|
|
225
|
+
function implementationEvidenceCandidates(
|
|
226
|
+
evidence: Record<string, unknown>,
|
|
227
|
+
): Array<{ accepted: boolean; handlerRepoId?: number; applicationRepoId?: number }> {
|
|
228
|
+
const candidates = evidence.candidates;
|
|
229
|
+
if (!Array.isArray(candidates)) return [];
|
|
230
|
+
return candidates.flatMap((candidate) => {
|
|
231
|
+
if (!isRecord(candidate)) return [];
|
|
232
|
+
const row = candidate;
|
|
233
|
+
const handler = recordValue(row.handlerPackage);
|
|
234
|
+
const application = recordValue(row.applicationPackage);
|
|
235
|
+
return [{
|
|
236
|
+
accepted: row.accepted === true,
|
|
237
|
+
handlerRepoId: numberValue(handler.id),
|
|
238
|
+
applicationRepoId: numberValue(application.id),
|
|
239
|
+
}];
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function recordValue(value: unknown): Record<string, unknown> {
|
|
244
|
+
return isRecord(value) ? value : {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parsedRecord(value: string): Record<string, unknown> {
|
|
248
|
+
try {
|
|
249
|
+
const parsed: unknown = JSON.parse(value);
|
|
250
|
+
return recordValue(parsed);
|
|
251
|
+
} catch {
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
257
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function numberValue(value: unknown): number | undefined {
|
|
261
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
221
264
|
function matchesLocalRepo(db: Db, operationId: number, repoId: number | undefined): boolean {
|
|
222
265
|
if (repoId === undefined) return true;
|
|
223
266
|
const row = db.prepare('SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?').get(operationId) as { repoId?: number } | undefined;
|
|
@@ -56,8 +56,11 @@ function compactMessage(diagnostic: Diagnostic): string {
|
|
|
56
56
|
|
|
57
57
|
function suggestedHintLines(diagnostic: Diagnostic): string[] {
|
|
58
58
|
const direct = cliHints(diagnostic.suggestedHints);
|
|
59
|
-
|
|
60
|
-
return cappedHints(
|
|
59
|
+
const omitted = numericValue(diagnostic.omittedImplementationHintSuggestionCount);
|
|
60
|
+
if (direct.length > 0) return cappedHints(direct, omitted);
|
|
61
|
+
return cappedHints(
|
|
62
|
+
cliHintsFromSuggestions(diagnostic.implementationHintSuggestions), omitted,
|
|
63
|
+
);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
function cliHints(value: unknown): string[] {
|
|
@@ -76,13 +79,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
76
79
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
function cappedHints(hints: string[]): string[] {
|
|
82
|
+
function cappedHints(hints: string[], omitted: number): string[] {
|
|
80
83
|
const unique = [...new Set(hints)];
|
|
81
84
|
const shown = unique.slice(0, 3);
|
|
82
|
-
|
|
85
|
+
const remaining = Math.max(0, unique.length - shown.length) + omitted;
|
|
86
|
+
if (remaining > 0)
|
|
87
|
+
shown.push(`... ${remaining} additional hint(s) omitted; use a scoped --implementation-hint`);
|
|
83
88
|
return shown;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
function numericValue(value: unknown): number {
|
|
92
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
function cleanDoctorMessage(): string {
|
|
87
96
|
return `${pc.green('No diagnostics recorded')}\n`;
|
|
88
97
|
}
|
|
@@ -24,7 +24,30 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
24
24
|
|
|
25
25
|
function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
|
|
26
26
|
const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
|
|
27
|
-
|
|
27
|
+
const details = diagnosticDetailLines(diagnostic);
|
|
28
|
+
return [first, ...[...details, ...hintLines(diagnostic)]
|
|
29
|
+
.map((hint) => ` ${hint}`)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function diagnosticDetailLines(diagnostic: Record<string, unknown>): string[] {
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
if (diagnostic.sourceFile || diagnostic.sourceLine)
|
|
35
|
+
lines.push(`at ${String(diagnostic.sourceFile ?? '')}:${String(diagnostic.sourceLine ?? '')}`);
|
|
36
|
+
const unsupported = stringList(diagnostic.unsupportedDecoratorNames);
|
|
37
|
+
const observed = stringList(diagnostic.observedDecoratorNames);
|
|
38
|
+
if (unsupported.length > 0)
|
|
39
|
+
lines.push(`unsupported decorators: ${unsupported.join(', ')}`);
|
|
40
|
+
else if (observed.length > 0)
|
|
41
|
+
lines.push(`observed decorators: ${observed.join(', ')}`);
|
|
42
|
+
if (typeof diagnostic.remediation === 'string')
|
|
43
|
+
lines.push(`hint: ${diagnostic.remediation}`);
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stringList(value: unknown): string[] {
|
|
48
|
+
return Array.isArray(value)
|
|
49
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
50
|
+
: [];
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
function hintLines(evidence: Record<string, unknown>): string[] {
|
|
@@ -37,8 +60,10 @@ function hintLines(evidence: Record<string, unknown>): string[] {
|
|
|
37
60
|
: []);
|
|
38
61
|
const unique = [...new Set(hints)];
|
|
39
62
|
const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
const omitted = numberValue(evidence.omittedImplementationHintSuggestionCount);
|
|
64
|
+
const remaining = Math.max(0, unique.length - shown.length) + omitted;
|
|
65
|
+
if (remaining > 0)
|
|
66
|
+
shown.push(`... ${remaining} additional hint(s) omitted; use a scoped --implementation-hint`);
|
|
42
67
|
return [...dynamicLines, ...shown];
|
|
43
68
|
}
|
|
44
69
|
|
|
@@ -50,9 +75,12 @@ function dynamicHintLines(evidence: Record<string, unknown>): string[] {
|
|
|
50
75
|
if (count === 0) return [];
|
|
51
76
|
const shown = numberValue(exploration.shownCandidateCount);
|
|
52
77
|
const omitted = numberValue(exploration.omittedCandidateCount);
|
|
53
|
-
const
|
|
78
|
+
const rejected = numberValue(exploration.rejectedCandidateCount);
|
|
79
|
+
const lines = [
|
|
80
|
+
`viable candidates: ${shown} shown, ${omitted} omitted; rejected: ${rejected}`,
|
|
81
|
+
];
|
|
54
82
|
lines.push(...varSetHints(exploration.suggestedVarSets));
|
|
55
|
-
if (omitted > 0 || shown < count)
|
|
83
|
+
if (omitted > 0 || rejected > 0 || shown < count)
|
|
56
84
|
lines.push('use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches');
|
|
57
85
|
return lines;
|
|
58
86
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import type { CdsOperationFact, CdsServiceFact } from '../types.js';
|
|
4
4
|
import { ensureLeadingSlash, normalizePath } from '../utils/path-utils.js';
|
|
5
|
+
import type { RepositorySourceContext } from './ts-project.js';
|
|
5
6
|
|
|
6
7
|
function lineOf(text: string, index: number): number {
|
|
7
8
|
return text.slice(0, index).split('\n').length;
|
|
@@ -136,9 +137,14 @@ function operationsFromBody(text: string, maskedBody: string, bodyOffset: number
|
|
|
136
137
|
}));
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
export async function parseCdsFile(
|
|
140
|
+
export async function parseCdsFile(
|
|
141
|
+
repoPath: string,
|
|
142
|
+
filePath: string,
|
|
143
|
+
context?: RepositorySourceContext,
|
|
144
|
+
): Promise<CdsServiceFact[]> {
|
|
140
145
|
const absolute = path.join(repoPath, filePath);
|
|
141
|
-
const text =
|
|
146
|
+
const text = context?.get(filePath)?.text
|
|
147
|
+
?? await fs.readFile(absolute, 'utf8');
|
|
142
148
|
const masked = maskCommentsAndStrings(text);
|
|
143
149
|
const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
|
|
144
150
|
const services: CdsServiceFact[] = [];
|
|
@@ -1,17 +1,65 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import ts from 'typescript';
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
HandlerClassFact,
|
|
6
|
+
HandlerLifecycleEvent,
|
|
7
|
+
HandlerLifecyclePhase,
|
|
8
|
+
HandlerMethodFact,
|
|
9
|
+
HandlerMethodKind,
|
|
10
|
+
} from '../types.js';
|
|
5
11
|
import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
|
|
6
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createSourceFile,
|
|
14
|
+
type RepositorySourceContext,
|
|
15
|
+
} from './ts-project.js';
|
|
7
16
|
import { normalizePath } from '../utils/path-utils.js';
|
|
8
17
|
|
|
9
18
|
type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
|
|
19
|
+
type ResolvedArgumentKind = Exclude<
|
|
20
|
+
DecoratorResolution['resolutionKind'],
|
|
21
|
+
'lifecycle_implicit' | 'unresolved'
|
|
22
|
+
>;
|
|
10
23
|
interface StringLookups {
|
|
11
24
|
identifiers: Map<string, string>;
|
|
12
25
|
enumMembers: Map<string, string>;
|
|
13
26
|
objectProperties: Map<string, string>;
|
|
27
|
+
capDecoratorNames: Map<string, string>;
|
|
28
|
+
capDecoratorNamespaces: Set<string>;
|
|
14
29
|
}
|
|
30
|
+
interface LifecycleMetadata {
|
|
31
|
+
phase: HandlerLifecyclePhase;
|
|
32
|
+
event: HandlerLifecycleEvent;
|
|
33
|
+
}
|
|
34
|
+
interface MethodClassification {
|
|
35
|
+
handlerKind: HandlerMethodKind;
|
|
36
|
+
executable: boolean;
|
|
37
|
+
lifecyclePhase?: HandlerLifecyclePhase;
|
|
38
|
+
lifecycleEvent?: HandlerLifecycleEvent;
|
|
39
|
+
}
|
|
40
|
+
interface ParsedMethodDecorator {
|
|
41
|
+
classification: MethodClassification;
|
|
42
|
+
resolution: DecoratorResolution;
|
|
43
|
+
decoratorKind: string;
|
|
44
|
+
importedKind?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OPERATION_DECORATORS = new Set(['Action', 'Func', 'On']);
|
|
48
|
+
const EVENT_DECORATORS = new Set(['Event']);
|
|
49
|
+
const LIFECYCLE_DECORATORS = new Map<string, LifecycleMetadata>([
|
|
50
|
+
['BeforeCreate', { phase: 'before', event: 'CREATE' }],
|
|
51
|
+
['OnCreate', { phase: 'on', event: 'CREATE' }],
|
|
52
|
+
['AfterCreate', { phase: 'after', event: 'CREATE' }],
|
|
53
|
+
['BeforeRead', { phase: 'before', event: 'READ' }],
|
|
54
|
+
['OnRead', { phase: 'on', event: 'READ' }],
|
|
55
|
+
['AfterRead', { phase: 'after', event: 'READ' }],
|
|
56
|
+
['BeforeUpdate', { phase: 'before', event: 'UPDATE' }],
|
|
57
|
+
['OnUpdate', { phase: 'on', event: 'UPDATE' }],
|
|
58
|
+
['AfterUpdate', { phase: 'after', event: 'UPDATE' }],
|
|
59
|
+
['BeforeDelete', { phase: 'before', event: 'DELETE' }],
|
|
60
|
+
['OnDelete', { phase: 'on', event: 'DELETE' }],
|
|
61
|
+
['AfterDelete', { phase: 'after', event: 'DELETE' }],
|
|
62
|
+
]);
|
|
15
63
|
|
|
16
64
|
function line(sf: ts.SourceFile, pos: number): number {
|
|
17
65
|
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
@@ -27,6 +75,15 @@ function firstArg(d: ts.Decorator): ts.Expression | undefined {
|
|
|
27
75
|
const e = d.expression;
|
|
28
76
|
return ts.isCallExpression(e) ? e.arguments[0] : undefined;
|
|
29
77
|
}
|
|
78
|
+
function decoratorArguments(d: ts.Decorator): readonly ts.Expression[] | undefined {
|
|
79
|
+
return ts.isCallExpression(d.expression) ? d.expression.arguments : undefined;
|
|
80
|
+
}
|
|
81
|
+
function methodName(name: ts.PropertyName): string {
|
|
82
|
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name)
|
|
83
|
+
|| ts.isNumericLiteral(name)
|
|
84
|
+
? name.text
|
|
85
|
+
: name.getText();
|
|
86
|
+
}
|
|
30
87
|
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
|
31
88
|
let current = expression;
|
|
32
89
|
while (
|
|
@@ -77,8 +134,11 @@ function collectStringLookups(source: ts.SourceFile): StringLookups {
|
|
|
77
134
|
identifiers: new Map(),
|
|
78
135
|
enumMembers: new Map(),
|
|
79
136
|
objectProperties: new Map(),
|
|
137
|
+
capDecoratorNames: new Map(),
|
|
138
|
+
capDecoratorNamespaces: new Set(),
|
|
80
139
|
};
|
|
81
140
|
for (const statement of source.statements) {
|
|
141
|
+
collectCapDecoratorImports(statement, lookups);
|
|
82
142
|
if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
|
|
83
143
|
if (!ts.isVariableStatement(statement)
|
|
84
144
|
|| !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
|
|
@@ -91,8 +151,62 @@ function collectStringLookups(source: ts.SourceFile): StringLookups {
|
|
|
91
151
|
}
|
|
92
152
|
return lookups;
|
|
93
153
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
154
|
+
function collectCapDecoratorImports(
|
|
155
|
+
statement: ts.Statement,
|
|
156
|
+
lookups: StringLookups,
|
|
157
|
+
): void {
|
|
158
|
+
if (!ts.isImportDeclaration(statement)
|
|
159
|
+
|| !ts.isStringLiteral(statement.moduleSpecifier)
|
|
160
|
+
|| statement.moduleSpecifier.text !== 'cds-routing-handlers') return;
|
|
161
|
+
const clause = statement.importClause;
|
|
162
|
+
if (!clause || clause.isTypeOnly) return;
|
|
163
|
+
const bindings = clause.namedBindings;
|
|
164
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
165
|
+
for (const element of bindings.elements) {
|
|
166
|
+
if (element.isTypeOnly) continue;
|
|
167
|
+
lookups.capDecoratorNames.set(
|
|
168
|
+
element.name.text,
|
|
169
|
+
element.propertyName?.text ?? element.name.text,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (bindings && ts.isNamespaceImport(bindings))
|
|
174
|
+
lookups.capDecoratorNamespaces.add(bindings.name.text);
|
|
175
|
+
}
|
|
176
|
+
function capDecoratorName(
|
|
177
|
+
decorator: ts.Decorator,
|
|
178
|
+
lookups: StringLookups,
|
|
179
|
+
): string | undefined {
|
|
180
|
+
const expression = ts.isCallExpression(decorator.expression)
|
|
181
|
+
? decorator.expression.expression
|
|
182
|
+
: decorator.expression;
|
|
183
|
+
if (ts.isIdentifier(expression))
|
|
184
|
+
return lookups.capDecoratorNames.get(expression.text);
|
|
185
|
+
if (ts.isPropertyAccessExpression(expression)
|
|
186
|
+
&& ts.isIdentifier(expression.expression)
|
|
187
|
+
&& lookups.capDecoratorNamespaces.has(expression.expression.text))
|
|
188
|
+
return expression.name.text;
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
function unresolved(
|
|
192
|
+
rawExpression: string,
|
|
193
|
+
reason: string,
|
|
194
|
+
argumentExpression?: string,
|
|
195
|
+
): DecoratorResolution {
|
|
196
|
+
return {
|
|
197
|
+
rawExpression,
|
|
198
|
+
argumentExpression,
|
|
199
|
+
resolutionKind: 'unresolved',
|
|
200
|
+
unresolvedReason: reason,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function resolved(
|
|
204
|
+
rawExpression: string,
|
|
205
|
+
argumentExpression: string,
|
|
206
|
+
resolvedValue: string,
|
|
207
|
+
resolutionKind: ResolvedArgumentKind,
|
|
208
|
+
): DecoratorResolution {
|
|
209
|
+
return { rawExpression, argumentExpression, resolvedValue, resolutionKind };
|
|
96
210
|
}
|
|
97
211
|
function generatedConstant(rawExpression: string): string | undefined {
|
|
98
212
|
const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
|
|
@@ -104,76 +218,296 @@ function generatedConstant(rawExpression: string): string | undefined {
|
|
|
104
218
|
function resolveDecoratorArgument(
|
|
105
219
|
argument: ts.Expression | undefined,
|
|
106
220
|
lookups: StringLookups,
|
|
221
|
+
rawExpression: string,
|
|
107
222
|
): DecoratorResolution {
|
|
108
|
-
if (!argument) return unresolved(
|
|
109
|
-
const
|
|
223
|
+
if (!argument) return unresolved(rawExpression, 'decorator_argument_missing');
|
|
224
|
+
const argumentExpression = argument.getText();
|
|
110
225
|
const expression = unwrapExpression(argument);
|
|
111
226
|
if (ts.isStringLiteralLike(expression))
|
|
112
|
-
return
|
|
227
|
+
return resolved(rawExpression, argumentExpression, expression.text, 'literal');
|
|
113
228
|
if (ts.isIdentifier(expression)) {
|
|
114
229
|
const value = lookups.identifiers.get(expression.text);
|
|
115
230
|
return value === undefined
|
|
116
|
-
? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
|
|
117
|
-
:
|
|
231
|
+
? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string', argumentExpression)
|
|
232
|
+
: resolved(rawExpression, argumentExpression, value, 'const_identifier');
|
|
118
233
|
}
|
|
119
234
|
if (ts.isPropertyAccessExpression(expression)
|
|
120
235
|
&& ts.isIdentifier(expression.expression)) {
|
|
121
236
|
const key = `${expression.expression.text}.${expression.name.text}`;
|
|
122
237
|
const enumValue = lookups.enumMembers.get(key);
|
|
123
238
|
if (enumValue !== undefined)
|
|
124
|
-
return
|
|
239
|
+
return resolved(rawExpression, argumentExpression, enumValue, 'enum_member');
|
|
125
240
|
const objectValue = lookups.objectProperties.get(key);
|
|
126
241
|
if (objectValue !== undefined)
|
|
127
|
-
return
|
|
242
|
+
return resolved(rawExpression, argumentExpression, objectValue, 'const_object_property');
|
|
128
243
|
}
|
|
129
|
-
const generatedValue = generatedConstant(
|
|
244
|
+
const generatedValue = generatedConstant(argumentExpression);
|
|
130
245
|
if (generatedValue !== undefined)
|
|
131
|
-
return
|
|
246
|
+
return resolved(
|
|
132
247
|
rawExpression,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
248
|
+
argumentExpression,
|
|
249
|
+
generatedValue,
|
|
250
|
+
'generated_constant_name',
|
|
251
|
+
);
|
|
136
252
|
if (ts.isPropertyAccessExpression(expression))
|
|
137
|
-
return unresolved(
|
|
138
|
-
|
|
253
|
+
return unresolved(
|
|
254
|
+
rawExpression,
|
|
255
|
+
'property_access_not_resolved_to_local_string',
|
|
256
|
+
argumentExpression,
|
|
257
|
+
);
|
|
258
|
+
return unresolved(rawExpression, 'unsupported_decorator_expression', argumentExpression);
|
|
259
|
+
}
|
|
260
|
+
function classificationFor(name: string): MethodClassification | undefined {
|
|
261
|
+
if (OPERATION_DECORATORS.has(name))
|
|
262
|
+
return { handlerKind: 'operation', executable: true };
|
|
263
|
+
if (EVENT_DECORATORS.has(name))
|
|
264
|
+
return { handlerKind: 'event', executable: true };
|
|
265
|
+
const lifecycle = LIFECYCLE_DECORATORS.get(name);
|
|
266
|
+
if (!lifecycle) return undefined;
|
|
267
|
+
return {
|
|
268
|
+
handlerKind: 'entity_lifecycle',
|
|
269
|
+
executable: true,
|
|
270
|
+
lifecyclePhase: lifecycle.phase,
|
|
271
|
+
lifecycleEvent: lifecycle.event,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function lifecycleLikePhase(name: string): HandlerLifecyclePhase | undefined {
|
|
275
|
+
if (/^On[A-Z]/.test(name)) return 'on';
|
|
276
|
+
if (/^Before[A-Z]/.test(name)) return 'before';
|
|
277
|
+
if (/^After[A-Z]/.test(name)) return 'after';
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
function withClassification(
|
|
281
|
+
resolution: DecoratorResolution,
|
|
282
|
+
classification: MethodClassification,
|
|
283
|
+
): DecoratorResolution {
|
|
284
|
+
return {
|
|
285
|
+
...resolution,
|
|
286
|
+
handlerKind: classification.handlerKind,
|
|
287
|
+
executable: classification.executable,
|
|
288
|
+
lifecyclePhase: classification.lifecyclePhase,
|
|
289
|
+
lifecycleEvent: classification.lifecycleEvent,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function withDecoratorEvidence(
|
|
293
|
+
resolution: DecoratorResolution,
|
|
294
|
+
decorator: ts.Decorator,
|
|
295
|
+
resolvedDecoratorKind: string | undefined,
|
|
296
|
+
): DecoratorResolution {
|
|
297
|
+
return {
|
|
298
|
+
...resolution,
|
|
299
|
+
decoratorExpression: decorator.expression.getText(),
|
|
300
|
+
resolvedDecoratorKind,
|
|
301
|
+
decoratorImportSource: resolvedDecoratorKind
|
|
302
|
+
? 'cds-routing-handlers'
|
|
303
|
+
: undefined,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function lifecycleDecoratorResolution(
|
|
307
|
+
decorator: ts.Decorator,
|
|
308
|
+
lookups: StringLookups,
|
|
309
|
+
classification: MethodClassification,
|
|
310
|
+
): { classification: MethodClassification; resolution: DecoratorResolution } {
|
|
311
|
+
const rawExpression = decorator.expression.getText();
|
|
312
|
+
const args = decoratorArguments(decorator);
|
|
313
|
+
if (args?.length === 0)
|
|
314
|
+
return {
|
|
315
|
+
classification,
|
|
316
|
+
resolution: withClassification({
|
|
317
|
+
rawExpression,
|
|
318
|
+
resolutionKind: 'lifecycle_implicit',
|
|
319
|
+
}, classification),
|
|
320
|
+
};
|
|
321
|
+
const resolution = args?.length === 1
|
|
322
|
+
? resolveDecoratorArgument(args[0], lookups, rawExpression)
|
|
323
|
+
: unresolved(rawExpression, args ? 'unsupported_lifecycle_argument_count' : 'lifecycle_decorator_call_required');
|
|
324
|
+
const unsupported = { ...classification, handlerKind: 'unsupported_lifecycle', executable: false } as const;
|
|
325
|
+
const unsupportedResolution = args?.length === 1
|
|
326
|
+
? { ...resolution, unresolvedReason: 'lifecycle_decorator_arguments_not_supported' }
|
|
327
|
+
: resolution;
|
|
328
|
+
return {
|
|
329
|
+
classification: unsupported,
|
|
330
|
+
resolution: withClassification(unsupportedResolution, unsupported),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function unsupportedLifecycleResolution(
|
|
334
|
+
decorator: ts.Decorator,
|
|
335
|
+
phase: HandlerLifecyclePhase,
|
|
336
|
+
): { classification: MethodClassification; resolution: DecoratorResolution } {
|
|
337
|
+
const classification: MethodClassification = {
|
|
338
|
+
handlerKind: 'unsupported_lifecycle',
|
|
339
|
+
executable: false,
|
|
340
|
+
lifecyclePhase: phase,
|
|
341
|
+
};
|
|
342
|
+
const resolution = unresolved(
|
|
343
|
+
decorator.expression.getText(),
|
|
344
|
+
'lifecycle_decorator_not_allowlisted',
|
|
345
|
+
);
|
|
346
|
+
return { classification, resolution: withClassification(resolution, classification) };
|
|
347
|
+
}
|
|
348
|
+
function unsupportedDecoratorResolution(
|
|
349
|
+
decorator: ts.Decorator,
|
|
350
|
+
reason: string,
|
|
351
|
+
phase?: HandlerLifecyclePhase,
|
|
352
|
+
): { classification: MethodClassification; resolution: DecoratorResolution } {
|
|
353
|
+
const classification: MethodClassification = {
|
|
354
|
+
handlerKind: phase ? 'unsupported_lifecycle' : 'unsupported_decorator',
|
|
355
|
+
executable: false,
|
|
356
|
+
lifecyclePhase: phase,
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
classification,
|
|
360
|
+
resolution: withClassification(
|
|
361
|
+
unresolved(decorator.expression.getText(), reason),
|
|
362
|
+
classification,
|
|
363
|
+
),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function parseMethodDecorator(
|
|
367
|
+
decorator: ts.Decorator,
|
|
368
|
+
lookups: StringLookups,
|
|
369
|
+
handlerClass: boolean,
|
|
370
|
+
allowLifecycle: boolean,
|
|
371
|
+
): ParsedMethodDecorator | undefined {
|
|
372
|
+
const decoratorKind = callName(decorator);
|
|
373
|
+
const importedKind = capDecoratorName(decorator, lookups);
|
|
374
|
+
const resolvedKind = importedKind ?? decoratorKind;
|
|
375
|
+
const base = classificationFor(resolvedKind);
|
|
376
|
+
const phase = lifecycleLikePhase(resolvedKind);
|
|
377
|
+
const isLifecycle = base?.handlerKind === 'entity_lifecycle' || Boolean(phase && !base);
|
|
378
|
+
if (!handlerClass && isLifecycle) return undefined;
|
|
379
|
+
const parsed = isLifecycle && (!allowLifecycle || !importedKind)
|
|
380
|
+
? unsupportedDecoratorResolution(
|
|
381
|
+
decorator, 'lifecycle_decorator_import_not_supported', phase,
|
|
382
|
+
)
|
|
383
|
+
: base?.handlerKind === 'entity_lifecycle'
|
|
384
|
+
? lifecycleDecoratorResolution(decorator, lookups, base)
|
|
385
|
+
: phase && !base
|
|
386
|
+
? unsupportedLifecycleResolution(decorator, phase)
|
|
387
|
+
: !base && handlerClass
|
|
388
|
+
? unsupportedDecoratorResolution(decorator, 'decorator_not_allowlisted')
|
|
389
|
+
: {
|
|
390
|
+
classification: base,
|
|
391
|
+
resolution: resolveDecoratorArgument(
|
|
392
|
+
firstArg(decorator),
|
|
393
|
+
lookups,
|
|
394
|
+
firstArg(decorator)?.getText() ?? decorator.expression.getText(),
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
if (!parsed.classification) return undefined;
|
|
398
|
+
return {
|
|
399
|
+
classification: parsed.classification,
|
|
400
|
+
resolution: parsed.resolution,
|
|
401
|
+
decoratorKind,
|
|
402
|
+
importedKind,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function methodDecoratorFact(
|
|
406
|
+
method: ts.MethodDeclaration,
|
|
407
|
+
decorator: ts.Decorator,
|
|
408
|
+
lookups: StringLookups,
|
|
409
|
+
source: ts.SourceFile,
|
|
410
|
+
filePath: string,
|
|
411
|
+
handlerClass: boolean,
|
|
412
|
+
allowLifecycle: boolean,
|
|
413
|
+
): HandlerMethodFact | undefined {
|
|
414
|
+
const parsed = parseMethodDecorator(decorator, lookups, handlerClass, allowLifecycle);
|
|
415
|
+
if (!parsed) return undefined;
|
|
416
|
+
const classification = method.body
|
|
417
|
+
? parsed.classification
|
|
418
|
+
: { ...parsed.classification, executable: false };
|
|
419
|
+
const resolution = method.body
|
|
420
|
+
? parsed.resolution
|
|
421
|
+
: { ...parsed.resolution, unresolvedReason: 'handler_method_body_missing' };
|
|
422
|
+
const argumentExpression = firstArg(decorator)?.getText();
|
|
423
|
+
return {
|
|
424
|
+
methodName: methodName(method.name),
|
|
425
|
+
decoratorKind: parsed.decoratorKind,
|
|
426
|
+
decoratorValue: parsed.resolution.resolvedValue,
|
|
427
|
+
decoratorRawExpression: argumentExpression ?? decorator.expression.getText(),
|
|
428
|
+
handlerKind: classification.handlerKind,
|
|
429
|
+
executable: classification.executable,
|
|
430
|
+
lifecyclePhase: classification.lifecyclePhase,
|
|
431
|
+
lifecycleEvent: classification.lifecycleEvent,
|
|
432
|
+
decoratorResolution: withDecoratorEvidence(
|
|
433
|
+
withClassification(resolution, classification),
|
|
434
|
+
decorator,
|
|
435
|
+
parsed.importedKind,
|
|
436
|
+
),
|
|
437
|
+
sourceFile: normalizePath(filePath),
|
|
438
|
+
sourceLine: line(source, method.getStart()),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function unique(values: string[]): string[] {
|
|
442
|
+
return [...new Set(values)];
|
|
443
|
+
}
|
|
444
|
+
function parseClassMethods(
|
|
445
|
+
node: ts.ClassDeclaration,
|
|
446
|
+
lookups: StringLookups,
|
|
447
|
+
source: ts.SourceFile,
|
|
448
|
+
filePath: string,
|
|
449
|
+
handlerClass: boolean,
|
|
450
|
+
allowLifecycle: boolean,
|
|
451
|
+
): Pick<HandlerClassFact, 'methods' | 'observedDecoratorNames' | 'unsupportedDecoratorNames'> {
|
|
452
|
+
const methods: HandlerMethodFact[] = [];
|
|
453
|
+
const observed: string[] = [];
|
|
454
|
+
const unsupported: string[] = [];
|
|
455
|
+
for (const method of node.members.filter(ts.isMethodDeclaration)) {
|
|
456
|
+
for (const decorator of decs(method)) {
|
|
457
|
+
observed.push(callName(decorator));
|
|
458
|
+
const fact = methodDecoratorFact(
|
|
459
|
+
method, decorator, lookups, source, filePath, handlerClass, allowLifecycle,
|
|
460
|
+
);
|
|
461
|
+
if (fact) methods.push(fact);
|
|
462
|
+
if (!fact?.executable) unsupported.push(callName(decorator));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
methods,
|
|
467
|
+
observedDecoratorNames: unique(observed),
|
|
468
|
+
unsupportedDecoratorNames: unique(unsupported),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function parseHandlerClass(
|
|
472
|
+
node: ts.ClassDeclaration,
|
|
473
|
+
lookups: StringLookups,
|
|
474
|
+
source: ts.SourceFile,
|
|
475
|
+
filePath: string,
|
|
476
|
+
): HandlerClassFact | undefined {
|
|
477
|
+
const classDecoratorNames = unique(decs(node).map(callName));
|
|
478
|
+
const classDecorators = decs(node);
|
|
479
|
+
const hasImportedHandler = classDecorators.some((decorator) =>
|
|
480
|
+
capDecoratorName(decorator, lookups) === 'Handler');
|
|
481
|
+
const hasHandlerDecorator = hasImportedHandler
|
|
482
|
+
|| classDecoratorNames.includes('Handler');
|
|
483
|
+
const parsed = parseClassMethods(
|
|
484
|
+
node, lookups, source, filePath, hasHandlerDecorator, hasImportedHandler,
|
|
485
|
+
);
|
|
486
|
+
if (!hasHandlerDecorator && parsed.methods.length === 0) return undefined;
|
|
487
|
+
return {
|
|
488
|
+
className: node.name?.text ?? 'AnonymousHandler',
|
|
489
|
+
sourceFile: normalizePath(filePath),
|
|
490
|
+
sourceLine: line(source, node.getStart()),
|
|
491
|
+
...parsed,
|
|
492
|
+
hasHandlerDecorator,
|
|
493
|
+
classDecoratorNames,
|
|
494
|
+
};
|
|
139
495
|
}
|
|
140
496
|
export async function parseDecorators(
|
|
141
497
|
repoPath: string,
|
|
142
|
-
filePath: string
|
|
498
|
+
filePath: string,
|
|
499
|
+
context?: RepositorySourceContext,
|
|
143
500
|
): Promise<HandlerClassFact[]> {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
501
|
+
const snapshot = context?.get(filePath);
|
|
502
|
+
const text = snapshot?.text
|
|
503
|
+
?? await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
504
|
+
const sf = snapshot?.sourceFile() ?? createSourceFile(filePath, text);
|
|
146
505
|
const lookups = collectStringLookups(sf);
|
|
147
506
|
const handlers: HandlerClassFact[] = [];
|
|
148
507
|
function visit(node: ts.Node): void {
|
|
149
508
|
if (ts.isClassDeclaration(node)) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
const methods = node.members.filter(ts.isMethodDeclaration).flatMap((m) =>
|
|
153
|
-
decs(m)
|
|
154
|
-
.filter((d) =>
|
|
155
|
-
['Func', 'Action', 'On', 'Event'].includes(callName(d))
|
|
156
|
-
)
|
|
157
|
-
.map((d) => {
|
|
158
|
-
const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
|
|
159
|
-
return {
|
|
160
|
-
methodName: m.name.getText(),
|
|
161
|
-
decoratorKind: callName(d),
|
|
162
|
-
decoratorValue: decoratorResolution.resolvedValue,
|
|
163
|
-
decoratorRawExpression: decoratorResolution.rawExpression,
|
|
164
|
-
decoratorResolution,
|
|
165
|
-
sourceFile: normalizePath(filePath),
|
|
166
|
-
sourceLine: line(sf, m.getStart())
|
|
167
|
-
};
|
|
168
|
-
})
|
|
169
|
-
);
|
|
170
|
-
if (hasHandler || methods.length > 0)
|
|
171
|
-
handlers.push({
|
|
172
|
-
className,
|
|
173
|
-
sourceFile: normalizePath(filePath),
|
|
174
|
-
sourceLine: line(sf, node.getStart()),
|
|
175
|
-
methods
|
|
176
|
-
});
|
|
509
|
+
const handler = parseHandlerClass(node, lookups, sf, filePath);
|
|
510
|
+
if (handler) handlers.push(handler);
|
|
177
511
|
}
|
|
178
512
|
ts.forEachChild(node, visit);
|
|
179
513
|
}
|