@saptools/service-flow 0.1.44 → 0.1.46
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 +14 -0
- package/README.md +9 -1
- package/dist/{chunk-UKNPHTUS.js → chunk-EGY2A4AT.js} +1853 -1462
- package/dist/chunk-EGY2A4AT.js.map +1 -0
- package/dist/cli.js +101 -31
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +67 -13
- package/src/cli.ts +10 -2
- package/src/index.ts +2 -0
- package/src/linker/cross-repo-linker.ts +27 -3
- package/src/linker/odata-path-normalizer.ts +4 -1
- package/src/output/table-output.ts +16 -1
- package/src/parsers/imported-wrapper-parser.ts +262 -0
- package/src/parsers/outbound-call-parser.ts +5 -1
- package/src/parsers/symbol-parser.ts +25 -10
- package/src/trace/evidence.ts +6 -1
- package/src/trace/implementation-hints.ts +223 -0
- package/src/trace/trace-engine.ts +52 -45
- package/src/types.ts +8 -0
- package/dist/chunk-UKNPHTUS.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -121,6 +121,14 @@ interface TraceStart {
|
|
|
121
121
|
operationPath?: string;
|
|
122
122
|
handler?: string;
|
|
123
123
|
}
|
|
124
|
+
interface ImplementationHint {
|
|
125
|
+
servicePath?: string;
|
|
126
|
+
operationPath?: string;
|
|
127
|
+
packageName?: string;
|
|
128
|
+
repositoryName?: string;
|
|
129
|
+
candidateFamily?: string;
|
|
130
|
+
implementationRepo: string;
|
|
131
|
+
}
|
|
124
132
|
interface TraceEdge {
|
|
125
133
|
step: number;
|
|
126
134
|
type: string;
|
|
@@ -206,9 +214,12 @@ declare function trace(db: Db, start: TraceStart, options: {
|
|
|
206
214
|
includeDb?: boolean;
|
|
207
215
|
includeAsync?: boolean;
|
|
208
216
|
implementationRepo?: string;
|
|
217
|
+
implementationHints?: ImplementationHint[];
|
|
209
218
|
}): TraceResult;
|
|
210
219
|
|
|
220
|
+
declare function parseImplementationHint(value: string): ImplementationHint;
|
|
221
|
+
|
|
211
222
|
declare function redactText(text: string): string;
|
|
212
223
|
declare function redactValue(value: unknown): unknown;
|
|
213
224
|
|
|
214
|
-
export { type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
|
|
225
|
+
export { type ImplementationHint, type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseCdsFile,
|
|
9
9
|
parseDecorators,
|
|
10
10
|
parseHandlerRegistrations,
|
|
11
|
+
parseImplementationHint,
|
|
11
12
|
parseOutboundCalls,
|
|
12
13
|
parsePackageJson,
|
|
13
14
|
parseServiceBindings,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
stripQuotes,
|
|
17
18
|
substituteVariables,
|
|
18
19
|
trace
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-EGY2A4AT.js";
|
|
20
21
|
|
|
21
22
|
// src/parsers/generated-constants-parser.ts
|
|
22
23
|
import fs from "fs/promises";
|
|
@@ -46,6 +47,7 @@ export {
|
|
|
46
47
|
parseDecorators,
|
|
47
48
|
parseGeneratedConstants,
|
|
48
49
|
parseHandlerRegistrations,
|
|
50
|
+
parseImplementationHint,
|
|
49
51
|
parseOutboundCalls,
|
|
50
52
|
parsePackageJson,
|
|
51
53
|
parseServiceBindings,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAGjB,SAAS,OAAO,MAAc,KAAqB;AACjD,SAAO,KAAK,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,EAAE;AACxC;AACA,eAAsB,wBACpB,UACA,UACkC;AAClC,QAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,UAAU,QAAQ,GAAG,MAAM;AACpE,SAAO;AAAA,IACL,GAAG,KAAK;AAAA,MACN;AAAA,IACF;AAAA,EACF,EAAE,IAAI,CAAC,OAAO;AAAA,IACZ,MAAM,EAAE,CAAC,KAAK;AAAA,IACd,OAAO,YAAY,EAAE,CAAC,KAAK,EAAE;AAAA,IAC7B,YAAY,cAAc,QAAQ;AAAA,IAClC,YAAY,OAAO,MAAM,EAAE,SAAS,CAAC;AAAA,EACvC,EAAE;AACJ;","names":[]}
|
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { Db } from '../db/connection.js';
|
|
2
2
|
import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
|
|
3
|
+
import { implementationHintSuggestions } from '../trace/implementation-hints.js';
|
|
3
4
|
import { ANALYZER_VERSION } from '../version.js';
|
|
4
5
|
|
|
5
6
|
type Diagnostic = Record<string, unknown>;
|
|
7
|
+
interface DoctorOptions {
|
|
8
|
+
detail?: boolean;
|
|
9
|
+
}
|
|
6
10
|
|
|
7
11
|
export function linkUpgradeWarnings(db: Db): Diagnostic[] {
|
|
8
12
|
return [...schemaDriftDiagnostics(db, true), ...analyzerVersionDiagnostics(db, true)]
|
|
9
13
|
.filter((item) => ['schema_legacy_columns_present', 'external_target_columns_missing_data', 'reindex_required_after_upgrade', 'reindex_required_after_analyzer_upgrade'].includes(String(item.code)));
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
16
|
+
export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOptions = {}): Diagnostic[] {
|
|
13
17
|
const diagnostics = db.prepare('SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id').all() as Diagnostic[];
|
|
14
18
|
return [
|
|
15
19
|
...diagnostics,
|
|
@@ -17,7 +21,7 @@ export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
17
21
|
...localServiceDiagnostics(db, strict),
|
|
18
22
|
...schemaDriftDiagnostics(db, strict),
|
|
19
23
|
...analyzerVersionDiagnostics(db, strict),
|
|
20
|
-
...parserQualityDiagnostics(db, strict),
|
|
24
|
+
...parserQualityDiagnostics(db, strict, options),
|
|
21
25
|
];
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -99,7 +103,7 @@ function localServiceDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
99
103
|
return out;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
106
|
+
function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOptions): Diagnostic[] {
|
|
103
107
|
if (!strict) return [];
|
|
104
108
|
const symbol = symbolCallQuality(db);
|
|
105
109
|
const dbq = dbQueryQuality(db);
|
|
@@ -108,7 +112,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
108
112
|
identityAliasBindingQuality(db),
|
|
109
113
|
remoteActionNoBindingQuality(db),
|
|
110
114
|
contextualImplementationQuality(db),
|
|
111
|
-
implementationCandidateQuality(db),
|
|
115
|
+
implementationCandidateQuality(db, Boolean(options.detail)),
|
|
112
116
|
classInstanceNoiseQuality(db),
|
|
113
117
|
contextualBindingPropagationQuality(db),
|
|
114
118
|
nestedThisReceiverQuality(db),
|
|
@@ -178,20 +182,27 @@ function graphDynamicFlagQuality(db: Db): Diagnostic {
|
|
|
178
182
|
return { severity: Number(row.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_graph_dynamic_flag_consistency', message: 'Graph dynamic flag consistency aggregate', dynamicTerminalEdges: Number(row.count ?? 0) };
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
function implementationCandidateQuality(db: Db): Diagnostic {
|
|
182
|
-
const categories = [...implementationEdgeCategories(db), missingParameterMetadataCategory(db)].filter((item) => item.count > 0);
|
|
185
|
+
function implementationCandidateQuality(db: Db, detail: boolean): Diagnostic {
|
|
186
|
+
const categories = [...implementationEdgeCategories(db, detail), missingParameterMetadataCategory(db, detail), dynamicWrapperCategory(db, detail)].filter((item) => item.count > 0);
|
|
183
187
|
const total = categories.reduce((sum, item) => sum + item.count, 0);
|
|
184
|
-
return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, categories };
|
|
188
|
+
return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, summary: implementationSummary(categories), categories };
|
|
185
189
|
}
|
|
186
190
|
|
|
187
|
-
function implementationEdgeCategories(db: Db): Array<Diagnostic & { count: number }> {
|
|
191
|
+
function implementationEdgeCategories(db: Db, detail: boolean): Array<Diagnostic & { count: number }> {
|
|
188
192
|
const rows = db.prepare(`SELECT e.status,e.unresolved_reason unresolvedReason,e.evidence_json evidenceJson,o.operation_name operationName,base.operation_name baseOperation,s.service_path servicePath
|
|
189
193
|
FROM graph_edges e JOIN cds_operations o ON o.id=CAST(e.from_id AS INTEGER)
|
|
190
194
|
JOIN cds_services s ON s.id=o.service_id LEFT JOIN cds_operations base ON base.id=o.base_operation_id
|
|
191
195
|
WHERE e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status IN ('ambiguous','unresolved') ORDER BY s.service_path,o.operation_name,e.id`).all() as Diagnostic[];
|
|
192
196
|
const grouped = new Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>();
|
|
193
197
|
for (const row of rows) addImplementationCategory(grouped, row);
|
|
194
|
-
return [...grouped.values()].map(({ servicePaths, ...item }) => ({
|
|
198
|
+
return [...grouped.values()].map(({ servicePaths, ...item }) => ({
|
|
199
|
+
...item,
|
|
200
|
+
servicePathPattern: pathPattern(servicePaths),
|
|
201
|
+
suggestedAction: categoryAction(String(item.category)),
|
|
202
|
+
suggestedHints: suggestedHints(item.examples),
|
|
203
|
+
examples: item.examples.slice(0, 3),
|
|
204
|
+
expandedExamples: detail ? item.examples : undefined,
|
|
205
|
+
}));
|
|
195
206
|
}
|
|
196
207
|
|
|
197
208
|
function addImplementationCategory(grouped: Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>, row: Diagnostic): void {
|
|
@@ -202,22 +213,65 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
|
|
|
202
213
|
const baseOperation = String(row.baseOperation ?? row.operationName ?? evidence.operationName ?? 'unknown');
|
|
203
214
|
const key = [category, baseOperation, reason, family].join('\0');
|
|
204
215
|
const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
|
|
216
|
+
const hintSuggestions = implementationSuggestions(evidence);
|
|
205
217
|
current.count += 1;
|
|
206
218
|
current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
|
|
207
|
-
current.examples.push({ servicePath: row.servicePath, operation: row.operationName, status: row.status, reason: row.unresolvedReason });
|
|
219
|
+
current.examples.push({ servicePath: row.servicePath, operation: row.operationName, status: row.status, reason: row.unresolvedReason, implementationHintSuggestions: hintSuggestions });
|
|
208
220
|
grouped.set(key, current);
|
|
209
221
|
}
|
|
210
222
|
|
|
211
|
-
function
|
|
223
|
+
function implementationSuggestions(evidence: Diagnostic): Diagnostic[] | undefined {
|
|
224
|
+
const persisted = asRecords(evidence.implementationHintSuggestions);
|
|
225
|
+
const suggestions = persisted.length ? persisted : implementationHintSuggestions(evidence);
|
|
226
|
+
return suggestions.length ? suggestions : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function suggestedHints(examples: Diagnostic[]): string[] | undefined {
|
|
230
|
+
const hints = examples.flatMap((example) =>
|
|
231
|
+
asRecords(example.implementationHintSuggestions)
|
|
232
|
+
.flatMap((suggestion) => typeof suggestion.cli === 'string' ? [String(suggestion.cli)] : []));
|
|
233
|
+
const unique = [...new Set(hints)].slice(0, 3);
|
|
234
|
+
return unique.length ? unique : undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function missingParameterMetadataCategory(db: Db, detail = false): Diagnostic & { count: number } {
|
|
212
238
|
const examples = db.prepare(`SELECT sc.source_file sourceFile,sc.source_line sourceLine,sc.callee_expression calleeExpression
|
|
213
239
|
FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
|
|
214
240
|
WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
|
|
215
241
|
AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)
|
|
216
|
-
ORDER BY sc.source_file,sc.source_line
|
|
242
|
+
ORDER BY sc.source_file,sc.source_line`).all() as Diagnostic[];
|
|
217
243
|
const row = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
|
|
218
244
|
WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
|
|
219
245
|
AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)`).get() as { count?: number };
|
|
220
|
-
return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), examples };
|
|
246
|
+
return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), suggestedAction: categoryAction('missing_parameter_metadata'), examples: examples.slice(0, 3), expandedExamples: detail ? examples : undefined };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function dynamicWrapperCategory(db: Db, detail: boolean): Diagnostic & { count: number } {
|
|
250
|
+
const rows = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,
|
|
251
|
+
json_extract(evidence_json,'$.receiver') receiverName,
|
|
252
|
+
COALESCE(json_extract(evidence_json,'$.missingPathIdentifier'),json_extract(evidence_json,'$.operationPathExpression')) pathIdentifier
|
|
253
|
+
FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier'
|
|
254
|
+
ORDER BY source_file,source_line`).all() as Diagnostic[];
|
|
255
|
+
return { category: 'dynamic_wrapper_paths', reason: 'wrapper path cannot be proven statically', candidateFamily: 'wrapper_path', count: rows.length, suggestedAction: categoryAction('dynamic_wrapper_paths'), examples: rows.slice(0, 3), expandedExamples: detail ? rows : undefined };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function implementationSummary(categories: Array<Diagnostic & { count: number }>): Diagnostic[] {
|
|
259
|
+
const grouped = new Map<string, Diagnostic & { count: number }>();
|
|
260
|
+
for (const category of categories) {
|
|
261
|
+
const key = [category.category, category.candidateFamily, category.reason].join('\0');
|
|
262
|
+
const current = grouped.get(key) ?? { category: category.category, candidateFamily: category.candidateFamily, reason: category.reason, severity: 'warning', suggestedAction: category.suggestedAction, count: 0 };
|
|
263
|
+
current.count += category.count;
|
|
264
|
+
grouped.set(key, current);
|
|
265
|
+
}
|
|
266
|
+
return [...grouped.values()].sort((left, right) => String(left.category).localeCompare(String(right.category)) || String(left.candidateFamily).localeCompare(String(right.candidateFamily)));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function categoryAction(category: string): string {
|
|
270
|
+
if (category === 'duplicate_package_name_candidates') return 'Use scoped --implementation-hint fields to select one repository for each ambiguous hop.';
|
|
271
|
+
if (category === 'missing_strong_ownership_evidence') return 'Add an explicit package dependency, local service-path ownership, or registration ownership evidence.';
|
|
272
|
+
if (category === 'missing_parameter_metadata') return 'Export a statically analyzable helper with named or destructured parameters.';
|
|
273
|
+
if (category === 'dynamic_wrapper_paths') return 'Pass a literal path or provide the reported runtime identifier with --var key=value.';
|
|
274
|
+
return 'Inspect the capped examples and add stronger implementation ownership evidence.';
|
|
221
275
|
}
|
|
222
276
|
|
|
223
277
|
function implementationCategory(row: Diagnostic, evidence: Diagnostic): string {
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { linkWorkspace } from './linker/cross-repo-linker.js';
|
|
|
24
24
|
import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
|
|
25
25
|
import { trace } from './trace/trace-engine.js';
|
|
26
26
|
import { parseVars } from './trace/selectors.js';
|
|
27
|
+
import { parseImplementationHint } from './trace/implementation-hints.js';
|
|
27
28
|
import { renderTraceTable } from './output/table-output.js';
|
|
28
29
|
import { renderTraceJson, renderJson } from './output/json-output.js';
|
|
29
30
|
import { renderMermaid } from './output/mermaid-output.js';
|
|
@@ -152,6 +153,7 @@ export function createProgram(): Command {
|
|
|
152
153
|
.option('--include-db')
|
|
153
154
|
.option('--include-async')
|
|
154
155
|
.option('--implementation-repo <name>')
|
|
156
|
+
.option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
|
|
155
157
|
.option('--var <key=value>', 'dynamic variable', collect, [])
|
|
156
158
|
.action(
|
|
157
159
|
(opts: {
|
|
@@ -167,6 +169,7 @@ export function createProgram(): Command {
|
|
|
167
169
|
includeDb?: boolean;
|
|
168
170
|
includeAsync?: boolean;
|
|
169
171
|
implementationRepo?: string;
|
|
172
|
+
implementationHint: string[];
|
|
170
173
|
var: string[];
|
|
171
174
|
}) =>
|
|
172
175
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
@@ -186,6 +189,7 @@ export function createProgram(): Command {
|
|
|
186
189
|
includeDb: Boolean(opts.includeDb),
|
|
187
190
|
includeAsync: Boolean(opts.includeAsync),
|
|
188
191
|
implementationRepo: opts.implementationRepo,
|
|
192
|
+
implementationHints: opts.implementationHint.map(parseImplementationHint),
|
|
189
193
|
},
|
|
190
194
|
);
|
|
191
195
|
process.stdout.write(
|
|
@@ -293,6 +297,7 @@ export function createProgram(): Command {
|
|
|
293
297
|
.option('--path <operationPath>')
|
|
294
298
|
.option('--format <format>', 'mermaid|json', 'mermaid')
|
|
295
299
|
.option('--implementation-repo <name>')
|
|
300
|
+
.option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
|
|
296
301
|
.option('--var <key=value>', 'dynamic variable', collect, [])
|
|
297
302
|
.action(
|
|
298
303
|
(opts: {
|
|
@@ -303,6 +308,7 @@ export function createProgram(): Command {
|
|
|
303
308
|
path?: string;
|
|
304
309
|
format: string;
|
|
305
310
|
implementationRepo?: string;
|
|
311
|
+
implementationHint: string[];
|
|
306
312
|
var: string[];
|
|
307
313
|
}) =>
|
|
308
314
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
@@ -321,6 +327,7 @@ export function createProgram(): Command {
|
|
|
321
327
|
includeExternal: true,
|
|
322
328
|
vars: parseVars(opts.var),
|
|
323
329
|
implementationRepo: opts.implementationRepo,
|
|
330
|
+
implementationHints: opts.implementationHint.map(parseImplementationHint),
|
|
324
331
|
},
|
|
325
332
|
);
|
|
326
333
|
process.stdout.write(
|
|
@@ -362,10 +369,11 @@ export function createProgram(): Command {
|
|
|
362
369
|
.command('doctor')
|
|
363
370
|
.option('--workspace <path>')
|
|
364
371
|
.option('--strict')
|
|
372
|
+
.option('--detail')
|
|
365
373
|
.action(
|
|
366
|
-
(opts: { workspace?: string; strict?: boolean }) =>
|
|
374
|
+
(opts: { workspace?: string; strict?: boolean; detail?: boolean }) =>
|
|
367
375
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
368
|
-
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict));
|
|
376
|
+
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
|
|
369
377
|
process.stdout.write(
|
|
370
378
|
allDiagnostics.length
|
|
371
379
|
? renderJson(allDiagnostics)
|
package/src/index.ts
CHANGED
|
@@ -10,4 +10,6 @@ export { linkWorkspace } from './linker/cross-repo-linker.js';
|
|
|
10
10
|
export { applyVariables, extractPlaceholders, substituteVariables } from './linker/dynamic-edge-resolver.js';
|
|
11
11
|
export type { RuntimeSubstitution } from './linker/dynamic-edge-resolver.js';
|
|
12
12
|
export { trace } from './trace/trace-engine.js';
|
|
13
|
+
export { parseImplementationHint } from './trace/implementation-hints.js';
|
|
14
|
+
export type { ImplementationHint } from './types.js';
|
|
13
15
|
export { redactValue, redactText } from './utils/redaction.js';
|
|
@@ -6,6 +6,7 @@ import { resolveOperation } from './service-resolver.js';
|
|
|
6
6
|
import { linkHelperPackages } from './helper-package-linker.js';
|
|
7
7
|
import { normalizeDecoratorOperationSignal, normalizedOperationName } from './operation-decorator-normalizer.js';
|
|
8
8
|
import { externalHttpTarget } from './external-http-target.js';
|
|
9
|
+
import { implementationHintSuggestions } from '../trace/implementation-hints.js';
|
|
9
10
|
export interface LinkWorkspaceResult {
|
|
10
11
|
edgeCount: number;
|
|
11
12
|
unresolvedCount: number;
|
|
@@ -146,7 +147,29 @@ function objectJson(value: unknown): Record<string, unknown> | undefined {
|
|
|
146
147
|
}
|
|
147
148
|
function callEvidence(call: Record<string, unknown>, resolution: { target?: { repoName?: string; servicePath?: string; operationPath?: string; operationName?: string }; candidates: unknown[]; status: string; reasons: string[] }, servicePath: string | undefined, op: string | undefined, destination: string | undefined, normalized?: NormalizedODataOperationPath, odataPathIntent?: ReturnType<typeof classifyODataPathIntent>): Record<string, unknown> {
|
|
148
149
|
const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
|
|
149
|
-
|
|
150
|
+
const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
|
|
151
|
+
return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArguments: normalized?.wasInvocation ? normalized.invocationArguments : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, routingPlaceholderKeys: routingPlaceholderKeys.length ? routingPlaceholderKeys : undefined, odataOperationNormalizationReason: normalized?.normalizationReason, odataOperationNormalizationRejectedReason: normalized?.normalizationRejectedReason, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === 'local_service_call' ? 'local' : undefined, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateScores: compactCandidateScores(resolution.candidates), candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || undefined, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : undefined, bindingHasDynamicExpression: bindingHasDynamicExpression || undefined, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? 'partial' : 'complete', parserWarning: call.unresolved_reason ? { code: 'parser_warning', message: call.unresolved_reason } : undefined };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
|
|
155
|
+
return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
|
|
156
|
+
const row = objectValue(candidate);
|
|
157
|
+
if (!row) return [];
|
|
158
|
+
return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function placeholderKeys(values: Array<string | undefined>): string[] {
|
|
163
|
+
const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
|
|
164
|
+
return [...new Set(keys)].sort();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
168
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stringValue(value: unknown): string | undefined {
|
|
172
|
+
return typeof value === 'string' ? value : undefined;
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
@@ -179,13 +202,14 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
179
202
|
candidateFamilies: duplicateFamilies,
|
|
180
203
|
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
181
204
|
};
|
|
205
|
+
const evidenceWithHints = unique ? evidence : { ...evidence, implementationHintSuggestions: implementationHintSuggestions(evidence) };
|
|
182
206
|
if (accepted.length === 0) {
|
|
183
|
-
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', 'unresolved', 'operation', graphId(operation.operationId), 'handler_method_candidates', candidates.map((row) => graphId(row.methodId)).join(','), 0, JSON.stringify(
|
|
207
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', 'unresolved', 'operation', graphId(operation.operationId), 'handler_method_candidates', candidates.map((row) => graphId(row.methodId)).join(','), 0, JSON.stringify(evidenceWithHints), 0, 'No implementation candidate passed policy', generation);
|
|
184
208
|
edgeCount += 1;
|
|
185
209
|
unresolvedCount += 1;
|
|
186
210
|
continue;
|
|
187
211
|
}
|
|
188
|
-
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) : selected.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(
|
|
212
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) : selected.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidenceWithHints), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
|
|
189
213
|
edgeCount += 1;
|
|
190
214
|
if (unique) resolvedCount += 1;
|
|
191
215
|
else ambiguousCount += 1;
|
|
@@ -2,6 +2,7 @@ export interface NormalizedODataOperationPath {
|
|
|
2
2
|
rawOperationPath: string;
|
|
3
3
|
normalizedOperationPath: string;
|
|
4
4
|
wasInvocation: boolean;
|
|
5
|
+
invocationArguments?: string;
|
|
5
6
|
invocationArgumentPlaceholderKeys: string[];
|
|
6
7
|
normalizationReason?: string;
|
|
7
8
|
normalizationRejectedReason?: string;
|
|
@@ -20,6 +21,7 @@ export interface ODataPathIntent {
|
|
|
20
21
|
placeholderKeys: string[];
|
|
21
22
|
keyPredicatePlaceholderKeys: string[];
|
|
22
23
|
invocationArgumentPlaceholderKeys: string[];
|
|
24
|
+
invocationArguments?: string;
|
|
23
25
|
navigationSuffix?: string;
|
|
24
26
|
mediaOrPropertySuffix?: string;
|
|
25
27
|
hasEntityKeyPredicate: boolean;
|
|
@@ -51,6 +53,7 @@ export function normalizeODataOperationInvocationPath(path: string | undefined):
|
|
|
51
53
|
rawOperationPath: raw,
|
|
52
54
|
normalizedOperationPath: operationSegment,
|
|
53
55
|
wasInvocation: true,
|
|
56
|
+
invocationArguments: raw.slice(open + 1, close),
|
|
54
57
|
invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
|
|
55
58
|
normalizationReason: 'balanced_top_level_operation_invocation',
|
|
56
59
|
};
|
|
@@ -81,7 +84,7 @@ export function classifyODataPathIntent(path: string | undefined, method: string
|
|
|
81
84
|
const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
|
|
82
85
|
const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
|
|
83
86
|
const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes('('));
|
|
84
|
-
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
87
|
+
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArguments: invocation?.invocationArguments, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
85
88
|
if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
|
|
86
89
|
const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
|
|
87
90
|
const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
|
|
@@ -15,7 +15,22 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
15
15
|
const lines = ['Step Type From To Evidence'];
|
|
16
16
|
for (const e of result.edges) {
|
|
17
17
|
lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
|
|
18
|
+
const hint = firstHint(e.evidence);
|
|
19
|
+
if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
|
|
18
20
|
}
|
|
19
|
-
if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.
|
|
21
|
+
if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.flatMap(diagnosticLines));
|
|
20
22
|
return `${lines.join('\n')}\n`;
|
|
21
23
|
}
|
|
24
|
+
|
|
25
|
+
function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
|
|
26
|
+
const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
|
|
27
|
+
const hint = firstHint(diagnostic);
|
|
28
|
+
return hint ? [first, ` try ${hint}`] : [first];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function firstHint(evidence: Record<string, unknown>): string | undefined {
|
|
32
|
+
const suggestions = evidence.implementationHintSuggestions;
|
|
33
|
+
if (!Array.isArray(suggestions)) return undefined;
|
|
34
|
+
const first = suggestions.find((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object');
|
|
35
|
+
return typeof first?.cli === 'string' ? first.cli : undefined;
|
|
36
|
+
}
|