@saptools/service-flow 0.1.47 → 0.1.49
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 +15 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-XOROZHW4.js} +593 -242
- package/dist/chunk-XOROZHW4.js.map +1 -0
- package/dist/cli.js +387 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +167 -7
- package/src/cli.ts +4 -7
- package/src/db/repositories.ts +186 -10
- package/src/linker/cross-repo-linker.ts +70 -1
- package/src/output/doctor-output.ts +98 -0
- package/src/output/table-output.ts +18 -8
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/trace-engine.ts +118 -25
- package/dist/chunk-EGY2A4AT.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -15,13 +15,12 @@ import {
|
|
|
15
15
|
parsePackageJson,
|
|
16
16
|
parseServiceBindings,
|
|
17
17
|
trace
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-XOROZHW4.js";
|
|
19
19
|
|
|
20
20
|
// src/cli.ts
|
|
21
21
|
import { Command } from "commander";
|
|
22
22
|
import path6 from "path";
|
|
23
23
|
import fs6 from "fs/promises";
|
|
24
|
-
import pc from "picocolors";
|
|
25
24
|
|
|
26
25
|
// src/config/defaults.ts
|
|
27
26
|
var DEFAULT_IGNORES = [
|
|
@@ -211,7 +210,7 @@ function migrate(db) {
|
|
|
211
210
|
// package.json
|
|
212
211
|
var package_default = {
|
|
213
212
|
name: "@saptools/service-flow",
|
|
214
|
-
version: "0.1.
|
|
213
|
+
version: "0.1.49",
|
|
215
214
|
description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
|
|
216
215
|
type: "module",
|
|
217
216
|
publishConfig: {
|
|
@@ -555,11 +554,15 @@ function insertRegistrations(db, repoId, rows) {
|
|
|
555
554
|
}
|
|
556
555
|
function insertBindings(db, repoId, rows) {
|
|
557
556
|
const stmt = db.prepare(
|
|
558
|
-
"INSERT INTO service_bindings(repo_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(
|
|
557
|
+
"INSERT INTO service_bindings(repo_id,symbol_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(?,(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1),?,?,?,?,?,?,?,?,?,?)"
|
|
559
558
|
);
|
|
560
559
|
for (const r of rows)
|
|
561
560
|
stmt.run(
|
|
562
561
|
repoId,
|
|
562
|
+
repoId,
|
|
563
|
+
r.sourceFile,
|
|
564
|
+
r.sourceLine,
|
|
565
|
+
r.sourceLine,
|
|
563
566
|
r.variableName,
|
|
564
567
|
r.alias,
|
|
565
568
|
r.aliasExpr,
|
|
@@ -613,9 +616,14 @@ function resolveSymbolCallTarget(db, repoId, r) {
|
|
|
613
616
|
}
|
|
614
617
|
function insertCalls(db, repoId, rows) {
|
|
615
618
|
const stmt = db.prepare(
|
|
616
|
-
"INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1))
|
|
619
|
+
"INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
617
620
|
);
|
|
618
|
-
for (const r of rows)
|
|
621
|
+
for (const r of rows) {
|
|
622
|
+
const binding = resolvePersistedBinding(db, repoId, r);
|
|
623
|
+
const evidence = {
|
|
624
|
+
...r.evidence ?? {},
|
|
625
|
+
serviceBindingResolution: binding.evidence
|
|
626
|
+
};
|
|
619
627
|
stmt.run(
|
|
620
628
|
repoId,
|
|
621
629
|
repoId,
|
|
@@ -634,21 +642,146 @@ function insertCalls(db, repoId, rows) {
|
|
|
634
642
|
r.sourceFile,
|
|
635
643
|
r.sourceLine,
|
|
636
644
|
r.confidence,
|
|
637
|
-
r.unresolvedReason,
|
|
645
|
+
r.unresolvedReason ?? binding.unresolvedReason,
|
|
638
646
|
r.localServiceName,
|
|
639
647
|
r.localServiceLookup,
|
|
640
648
|
r.aliasChain ? JSON.stringify(r.aliasChain) : null,
|
|
641
|
-
|
|
649
|
+
JSON.stringify(evidence),
|
|
642
650
|
r.externalTarget?.kind ?? null,
|
|
643
651
|
r.externalTarget?.stableId ?? null,
|
|
644
652
|
r.externalTarget?.label ?? null,
|
|
645
653
|
r.externalTarget?.dynamic ? 1 : 0,
|
|
646
|
-
|
|
647
|
-
r.serviceVariableName,
|
|
648
|
-
r.sourceFile,
|
|
649
|
-
r.sourceLine,
|
|
650
|
-
r.sourceLine
|
|
654
|
+
binding.bindingId
|
|
651
655
|
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function resolvePersistedBinding(db, repoId, call) {
|
|
659
|
+
if (!call.serviceVariableName)
|
|
660
|
+
return { bindingId: null, evidence: { status: "not_applicable", candidateCount: 0 } };
|
|
661
|
+
const candidates = bindingCandidates(db, repoId, call);
|
|
662
|
+
const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
|
|
663
|
+
const families = new Set(prior.map(bindingSignature));
|
|
664
|
+
if (prior.length > 0 && families.size === 1) {
|
|
665
|
+
const selected = prior.at(-1);
|
|
666
|
+
return {
|
|
667
|
+
bindingId: selected?.id ?? null,
|
|
668
|
+
evidence: bindingEvidence("selected", prior, selected)
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
if (prior.length > 1) {
|
|
672
|
+
return {
|
|
673
|
+
bindingId: null,
|
|
674
|
+
unresolvedReason: "ambiguous_service_binding_candidates",
|
|
675
|
+
evidence: bindingEvidence("ambiguous", prior)
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (candidates.length > 0) {
|
|
679
|
+
return {
|
|
680
|
+
bindingId: null,
|
|
681
|
+
unresolvedReason: "service_binding_declared_after_call",
|
|
682
|
+
evidence: bindingEvidence("rejected_future_binding", candidates)
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
bindingId: null,
|
|
687
|
+
evidence: bindingEvidence("unrecoverable", [])
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function bindingCandidates(db, repoId, call) {
|
|
691
|
+
const ownerId = callSymbolId(db, repoId, call);
|
|
692
|
+
const rows = db.prepare(`
|
|
693
|
+
SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
|
|
694
|
+
destination_expr destinationExpr,service_path_expr servicePathExpr,
|
|
695
|
+
source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
|
|
696
|
+
FROM service_bindings
|
|
697
|
+
WHERE repo_id=? AND variable_name=? AND source_file=?
|
|
698
|
+
AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
|
|
699
|
+
ORDER BY source_line,id
|
|
700
|
+
`).all(
|
|
701
|
+
repoId,
|
|
702
|
+
call.serviceVariableName,
|
|
703
|
+
call.sourceFile,
|
|
704
|
+
ownerId,
|
|
705
|
+
ownerId
|
|
706
|
+
);
|
|
707
|
+
return rows.flatMap((row) => {
|
|
708
|
+
if (typeof row.id !== "number" || typeof row.variableName !== "string" || typeof row.sourceFile !== "string" || typeof row.sourceLine !== "number")
|
|
709
|
+
return [];
|
|
710
|
+
return [{
|
|
711
|
+
id: row.id,
|
|
712
|
+
symbolId: nullableNumber(row.symbolId),
|
|
713
|
+
variableName: row.variableName,
|
|
714
|
+
alias: nullableString(row.alias),
|
|
715
|
+
aliasExpr: nullableString(row.aliasExpr),
|
|
716
|
+
destinationExpr: nullableString(row.destinationExpr),
|
|
717
|
+
servicePathExpr: nullableString(row.servicePathExpr),
|
|
718
|
+
sourceFile: row.sourceFile,
|
|
719
|
+
sourceLine: row.sourceLine,
|
|
720
|
+
helperChainJson: nullableString(row.helperChainJson)
|
|
721
|
+
}];
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function nullableString(value) {
|
|
725
|
+
return value === null || typeof value === "string" ? value : void 0;
|
|
726
|
+
}
|
|
727
|
+
function nullableNumber(value) {
|
|
728
|
+
return value === null || typeof value === "number" ? value : void 0;
|
|
729
|
+
}
|
|
730
|
+
function callSymbolId(db, repoId, call) {
|
|
731
|
+
const row = db.prepare(`
|
|
732
|
+
SELECT id FROM symbols
|
|
733
|
+
WHERE repo_id=? AND source_file=?
|
|
734
|
+
AND ((? IS NOT NULL AND qualified_name=?)
|
|
735
|
+
OR (start_line<=? AND end_line>=?))
|
|
736
|
+
ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
|
|
737
|
+
(end_line-start_line),id
|
|
738
|
+
LIMIT 1
|
|
739
|
+
`).get(
|
|
740
|
+
repoId,
|
|
741
|
+
call.sourceFile,
|
|
742
|
+
call.sourceSymbolQualifiedName,
|
|
743
|
+
call.sourceSymbolQualifiedName,
|
|
744
|
+
call.sourceLine,
|
|
745
|
+
call.sourceLine,
|
|
746
|
+
call.sourceSymbolQualifiedName
|
|
747
|
+
);
|
|
748
|
+
return typeof row?.id === "number" ? row.id : void 0;
|
|
749
|
+
}
|
|
750
|
+
function bindingEvidence(status, candidates, selected) {
|
|
751
|
+
return {
|
|
752
|
+
status,
|
|
753
|
+
candidateCount: candidates.length,
|
|
754
|
+
selectedBindingId: selected?.id,
|
|
755
|
+
sourceOrderRule: "binding_source_line_must_not_follow_call",
|
|
756
|
+
candidates: candidates.map((candidate) => ({
|
|
757
|
+
bindingId: candidate.id,
|
|
758
|
+
symbolId: candidate.symbolId,
|
|
759
|
+
variableName: candidate.variableName,
|
|
760
|
+
alias: candidate.alias,
|
|
761
|
+
aliasExpr: candidate.aliasExpr,
|
|
762
|
+
destinationExpr: candidate.destinationExpr,
|
|
763
|
+
servicePathExpr: candidate.servicePathExpr,
|
|
764
|
+
sourceFile: candidate.sourceFile,
|
|
765
|
+
sourceLine: candidate.sourceLine,
|
|
766
|
+
helperChain: parseBindingChain(candidate.helperChainJson)
|
|
767
|
+
}))
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
function bindingSignature(candidate) {
|
|
771
|
+
return JSON.stringify([
|
|
772
|
+
candidate.alias,
|
|
773
|
+
candidate.aliasExpr,
|
|
774
|
+
candidate.destinationExpr,
|
|
775
|
+
candidate.servicePathExpr
|
|
776
|
+
]);
|
|
777
|
+
}
|
|
778
|
+
function parseBindingChain(value) {
|
|
779
|
+
if (!value) return void 0;
|
|
780
|
+
try {
|
|
781
|
+
return JSON.parse(value);
|
|
782
|
+
} catch {
|
|
783
|
+
return void 0;
|
|
784
|
+
}
|
|
652
785
|
}
|
|
653
786
|
|
|
654
787
|
// src/discovery/classify-repository.ts
|
|
@@ -1312,6 +1445,7 @@ function doctorDiagnostics(db, strict, options = {}) {
|
|
|
1312
1445
|
return [
|
|
1313
1446
|
...diagnostics,
|
|
1314
1447
|
...healthDiagnostics(db, strict),
|
|
1448
|
+
...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
|
|
1315
1449
|
...localServiceDiagnostics(db, strict),
|
|
1316
1450
|
...schemaDriftDiagnostics(db, strict),
|
|
1317
1451
|
...analyzerVersionDiagnostics(db, strict),
|
|
@@ -1339,10 +1473,6 @@ function healthDiagnostics(db, strict) {
|
|
|
1339
1473
|
SELECT 'warning','legacy_schema_weaker_foreign_keys','Legacy table lacks fresh-schema foreign-key metadata; rebuild the database or re-run init/index in a new database',NULL,NULL
|
|
1340
1474
|
WHERE (SELECT COUNT(*) FROM pragma_foreign_key_list('graph_edges'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('index_runs'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('diagnostics'))=0
|
|
1341
1475
|
UNION ALL
|
|
1342
|
-
SELECT 'warning','remote_target_without_implementation','Remote target operation has no implementation edge: ' || s.service_path || o.operation_path,o.source_file,o.source_line
|
|
1343
|
-
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
1344
|
-
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation' AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id) AND ?
|
|
1345
|
-
UNION ALL
|
|
1346
1476
|
SELECT CASE WHEN ? THEN 'warning' ELSE 'error' END,'local_service_calls_all_unresolved','All local service calls are unresolved; verify local service alias parsing and linking',NULL,NULL
|
|
1347
1477
|
WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE call_type='local_service_call') AND NOT EXISTS (SELECT 1 FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call' AND e.status='resolved')
|
|
1348
1478
|
UNION ALL
|
|
@@ -1360,7 +1490,41 @@ function healthDiagnostics(db, strict) {
|
|
|
1360
1490
|
UNION ALL
|
|
1361
1491
|
SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
|
|
1362
1492
|
FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`
|
|
1363
|
-
).all(strict, strict, strict, strict, strict, strict
|
|
1493
|
+
).all(strict, strict, strict, strict, strict, strict);
|
|
1494
|
+
}
|
|
1495
|
+
function remoteTargetWithoutImplementationDiagnostics(db, strict, detail) {
|
|
1496
|
+
if (!strict) return [];
|
|
1497
|
+
const groups = db.prepare(`SELECT s.service_path servicePath,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,COUNT(*) callSiteCount
|
|
1498
|
+
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
1499
|
+
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
|
|
1500
|
+
AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
|
|
1501
|
+
GROUP BY s.service_path,o.operation_path,o.source_file,o.source_line
|
|
1502
|
+
ORDER BY s.service_path,o.operation_path`).all();
|
|
1503
|
+
return groups.map((group) => {
|
|
1504
|
+
const examples = remoteTargetWithoutImplementationExamples(db, String(group.servicePath ?? ""), String(group.operationPath ?? ""));
|
|
1505
|
+
return {
|
|
1506
|
+
severity: "warning",
|
|
1507
|
+
code: "remote_target_without_implementation",
|
|
1508
|
+
message: `Remote target operation has no implementation edge: ${String(group.servicePath ?? "")}${String(group.operationPath ?? "")}`,
|
|
1509
|
+
sourceFile: group.sourceFile,
|
|
1510
|
+
sourceLine: group.sourceLine,
|
|
1511
|
+
servicePath: group.servicePath,
|
|
1512
|
+
operationPath: group.operationPath,
|
|
1513
|
+
callSiteCount: Number(group.callSiteCount ?? 0),
|
|
1514
|
+
examples: examples.slice(0, 3),
|
|
1515
|
+
expandedExamples: detail ? examples : void 0
|
|
1516
|
+
};
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
function remoteTargetWithoutImplementationExamples(db, servicePath, operationPath) {
|
|
1520
|
+
return db.prepare(`SELECT r.name repo,c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPathExpr
|
|
1521
|
+
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
1522
|
+
LEFT JOIN outbound_calls c ON remote.from_kind='call' AND c.id=CAST(remote.from_id AS INTEGER)
|
|
1523
|
+
LEFT JOIN repositories r ON r.id=c.repo_id
|
|
1524
|
+
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
|
|
1525
|
+
AND s.service_path=? AND o.operation_path=?
|
|
1526
|
+
AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
|
|
1527
|
+
ORDER BY r.name,c.source_file,c.source_line`).all(servicePath, operationPath);
|
|
1364
1528
|
}
|
|
1365
1529
|
function schemaDriftDiagnostics(db, strict) {
|
|
1366
1530
|
if (!strict) return [];
|
|
@@ -1404,6 +1568,7 @@ function parserQualityDiagnostics(db, strict, options) {
|
|
|
1404
1568
|
implementationCandidateQuality(db, Boolean(options.detail)),
|
|
1405
1569
|
classInstanceNoiseQuality(db),
|
|
1406
1570
|
contextualBindingPropagationQuality(db),
|
|
1571
|
+
serviceBindingQuality(db, Boolean(options.detail)),
|
|
1407
1572
|
nestedThisReceiverQuality(db),
|
|
1408
1573
|
wrapperPathPropagationQuality(db),
|
|
1409
1574
|
remoteQueryTargetQuality(db),
|
|
@@ -1598,6 +1763,83 @@ function contextualBindingPropagationQuality(db) {
|
|
|
1598
1763
|
const opportunities = db.prepare("SELECT c.source_file sourceFile,c.source_line sourceLine,json_extract(c.evidence_json,'$.receiver') receiverName,c.operation_path_expr operationPath FROM outbound_calls c WHERE c.call_type='remote_action' AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL AND c.service_binding_id IS NULL ORDER BY c.source_file,c.source_line LIMIT 8").all();
|
|
1599
1764
|
return { severity: missing.count + opportunities.length > 0 ? "warning" : "info", code: "strict_contextual_binding_propagation_quality", message: "Contextual service-client propagation opportunities for trace-time helper resolution", calleeSymbolsMissingParameterMetadata: missing.count, traceTimeContextualOpportunities: opportunities.length, examples: opportunities };
|
|
1600
1765
|
}
|
|
1766
|
+
function serviceBindingQuality(db, detail) {
|
|
1767
|
+
const rows = db.prepare(`
|
|
1768
|
+
SELECT c.source_file sourceFile,c.source_line sourceLine,
|
|
1769
|
+
c.unresolved_reason unresolvedReason,c.evidence_json evidenceJson,
|
|
1770
|
+
s.evidence_json symbolEvidenceJson
|
|
1771
|
+
FROM outbound_calls c
|
|
1772
|
+
LEFT JOIN symbols s ON s.id=c.source_symbol_id
|
|
1773
|
+
WHERE c.call_type='remote_action'
|
|
1774
|
+
AND c.operation_path_expr IS NOT NULL
|
|
1775
|
+
AND c.service_binding_id IS NULL
|
|
1776
|
+
ORDER BY c.source_file,c.source_line
|
|
1777
|
+
`).all();
|
|
1778
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1779
|
+
for (const row of rows) {
|
|
1780
|
+
const category = bindingCategory(row);
|
|
1781
|
+
groups.set(category, [...groups.get(category) ?? [], bindingExample(row)]);
|
|
1782
|
+
}
|
|
1783
|
+
const categories = [...groups.entries()].map(([category, examples]) => ({
|
|
1784
|
+
category,
|
|
1785
|
+
count: examples.length,
|
|
1786
|
+
severity: "warning",
|
|
1787
|
+
suggestedAction: bindingCategoryAction(category),
|
|
1788
|
+
examples: examples.slice(0, 3),
|
|
1789
|
+
expandedExamples: detail ? examples : void 0
|
|
1790
|
+
}));
|
|
1791
|
+
return {
|
|
1792
|
+
severity: rows.length > 0 ? "warning" : "info",
|
|
1793
|
+
code: "strict_service_binding_quality",
|
|
1794
|
+
message: "Remote service-client binding quality aggregate",
|
|
1795
|
+
total: rows.length,
|
|
1796
|
+
categories
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
function bindingCategory(row) {
|
|
1800
|
+
const evidence = parseObject(row.evidenceJson);
|
|
1801
|
+
const resolution = parseObject(evidence.serviceBindingResolution);
|
|
1802
|
+
if (resolution.status === "rejected_future_binding") return "direct_binding_missing";
|
|
1803
|
+
if (resolution.status === "ambiguous") return "ambiguous_binding_candidates";
|
|
1804
|
+
const receiver = evidence.receiver;
|
|
1805
|
+
const symbolEvidence = parseObject(row.symbolEvidenceJson);
|
|
1806
|
+
if (symbolHasReceiverParameter(symbolEvidence, receiver))
|
|
1807
|
+
return "contextual_binding_recoverable";
|
|
1808
|
+
if (!Array.isArray(symbolEvidence.parameterBindings))
|
|
1809
|
+
return "missing_symbol_parameter_metadata";
|
|
1810
|
+
return "unrecoverable_binding";
|
|
1811
|
+
}
|
|
1812
|
+
function symbolHasReceiverParameter(evidence, receiver) {
|
|
1813
|
+
if (typeof receiver !== "string" || !Array.isArray(evidence.parameterBindings))
|
|
1814
|
+
return false;
|
|
1815
|
+
return asRecords(evidence.parameterBindings).some((binding) => {
|
|
1816
|
+
if (binding.kind === "identifier") return binding.name === receiver;
|
|
1817
|
+
if (binding.kind === "object_pattern")
|
|
1818
|
+
return asRecords(binding.properties).some((property) => property.local === receiver);
|
|
1819
|
+
return asRecords(binding.elements).some((element) => element.local === receiver);
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
function bindingExample(row) {
|
|
1823
|
+
const evidence = parseObject(row.evidenceJson);
|
|
1824
|
+
return {
|
|
1825
|
+
sourceFile: row.sourceFile,
|
|
1826
|
+
sourceLine: row.sourceLine,
|
|
1827
|
+
receiver: evidence.receiver,
|
|
1828
|
+
unresolvedReason: row.unresolvedReason,
|
|
1829
|
+
bindingResolution: evidence.serviceBindingResolution
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
function bindingCategoryAction(category) {
|
|
1833
|
+
if (category === "direct_binding_missing")
|
|
1834
|
+
return "Move the binding before the call or bind the call to an earlier immutable client.";
|
|
1835
|
+
if (category === "contextual_binding_recoverable")
|
|
1836
|
+
return "Trace from the caller so parameter binding evidence can be applied.";
|
|
1837
|
+
if (category === "ambiguous_binding_candidates")
|
|
1838
|
+
return "Split mutable client alternatives or add a statically unique client assignment.";
|
|
1839
|
+
if (category === "missing_symbol_parameter_metadata")
|
|
1840
|
+
return "Use named or destructured parameters on an indexed helper symbol.";
|
|
1841
|
+
return "Add a direct CAP client binding or statically provable helper-return binding.";
|
|
1842
|
+
}
|
|
1601
1843
|
function wrapperPathPropagationQuality(db) {
|
|
1602
1844
|
const examples = db.prepare("SELECT source_file sourceFile,source_line sourceLine,json_extract(evidence_json,'$.receiver') receiverName,json_extract(evidence_json,'$.operationPathExpression') pathIdentifier FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier' ORDER BY source_file,source_line LIMIT 5").all();
|
|
1603
1845
|
return { severity: examples.length > 0 ? "warning" : "info", code: "strict_wrapper_path_propagation_quality", message: "Dynamic path sends where send({ path }) used a path identifier", dynamicPathIdentifierCalls: examples.length, examples };
|
|
@@ -1669,10 +1911,33 @@ function externalHttpTargetQuality(db) {
|
|
|
1669
1911
|
return { severity: bad > 0 ? "warning" : "info", code: "strict_external_http_target_quality", message: "External HTTP semantic target aggregate", totalExternalHttpCalls: Number(row.total ?? 0), numericTargetCount: Number(row.numericTargets ?? 0), invalidOrMissingExternalTargetEvidence: Number(row.invalidEvidence ?? 0) };
|
|
1670
1912
|
}
|
|
1671
1913
|
function odataInvocationResolutionQuality(db) {
|
|
1672
|
-
const rows = db.prepare(
|
|
1914
|
+
const rows = db.prepare(`SELECT c.operation_path_expr operationPathExpr,
|
|
1915
|
+
c.source_file sourceFile,c.source_line sourceLine,e.id graphEdgeId,
|
|
1916
|
+
e.status status,e.evidence_json evidenceJson
|
|
1917
|
+
FROM outbound_calls c JOIN graph_edges e
|
|
1918
|
+
ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
|
|
1919
|
+
WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'
|
|
1920
|
+
ORDER BY c.source_file,c.source_line`).all();
|
|
1673
1921
|
const unresolved = rows.filter((row) => row.status === "unresolved" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
1674
1922
|
const ambiguous = rows.filter((row) => row.status === "ambiguous" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
1675
|
-
|
|
1923
|
+
const examples = rows.filter((row) => row.status === "ambiguous" || row.status === "unresolved").map(odataInvocationExample).slice(0, 5);
|
|
1924
|
+
return { severity: unresolved + ambiguous > 0 ? "warning" : "info", code: "strict_odata_invocation_resolution_quality", message: "OData invocation-path resolution quality aggregate", totalInvocationRemoteActions: rows.length, resolvedInvocationCalls: rows.filter((row) => row.status === "resolved").length, dynamicInvocationCalls: rows.filter((row) => row.status === "dynamic").length, ambiguousInvocationCalls: rows.filter((row) => row.status === "ambiguous").length, unresolvedInvocationCalls: rows.filter((row) => row.status === "unresolved").length, ambiguousNormalizedCalls: ambiguous, unresolvedNormalizedCallsWithIndexedCandidates: unresolved, examples };
|
|
1925
|
+
}
|
|
1926
|
+
function odataInvocationExample(row) {
|
|
1927
|
+
const evidence = parseObject(row.evidenceJson);
|
|
1928
|
+
const normalized = normalizeODataOperationInvocationPath(row.operationPathExpr);
|
|
1929
|
+
return {
|
|
1930
|
+
sourceFile: row.sourceFile,
|
|
1931
|
+
sourceLine: row.sourceLine,
|
|
1932
|
+
graphEdgeId: row.graphEdgeId,
|
|
1933
|
+
status: row.status,
|
|
1934
|
+
rawPath: row.operationPathExpr,
|
|
1935
|
+
normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : void 0,
|
|
1936
|
+
indexedOperationCandidateCount: evidence.indexedOperationCandidateCount,
|
|
1937
|
+
candidateScores: evidence.candidateScores,
|
|
1938
|
+
entityOperationPrecedence: evidence.entityOperationPrecedence,
|
|
1939
|
+
resolutionReasons: evidence.resolutionReasons
|
|
1940
|
+
};
|
|
1676
1941
|
}
|
|
1677
1942
|
function identityAliasBindingQuality(db) {
|
|
1678
1943
|
const examples = db.prepare("SELECT c.source_file sourceFile,c.source_line sourceLine FROM outbound_calls c WHERE c.call_type='remote_action' AND c.service_binding_id IS NULL AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL ORDER BY c.source_file,c.source_line LIMIT 5").all();
|
|
@@ -1725,8 +1990,8 @@ function renderTraceTable(result) {
|
|
|
1725
1990
|
const lines = ["Step Type From To Evidence"];
|
|
1726
1991
|
for (const e of result.edges) {
|
|
1727
1992
|
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)}`);
|
|
1728
|
-
|
|
1729
|
-
|
|
1993
|
+
if (e.unresolvedReason)
|
|
1994
|
+
lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
|
|
1730
1995
|
}
|
|
1731
1996
|
if (result.diagnostics.length > 0) lines.push("", "Diagnostics:", ...result.diagnostics.flatMap(diagnosticLines));
|
|
1732
1997
|
return `${lines.join("\n")}
|
|
@@ -1734,14 +1999,20 @@ function renderTraceTable(result) {
|
|
|
1734
1999
|
}
|
|
1735
2000
|
function diagnosticLines(diagnostic) {
|
|
1736
2001
|
const first = `${String(diagnostic.severity ?? "info")} ${String(diagnostic.code ?? "diagnostic")} ${String(diagnostic.message ?? "")}`;
|
|
1737
|
-
|
|
1738
|
-
return hint ? [first, ` try ${hint}`] : [first];
|
|
2002
|
+
return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
|
|
1739
2003
|
}
|
|
1740
|
-
function
|
|
2004
|
+
function hintLines(evidence) {
|
|
1741
2005
|
const suggestions = evidence.implementationHintSuggestions;
|
|
1742
|
-
if (!Array.isArray(suggestions)) return
|
|
1743
|
-
const
|
|
1744
|
-
|
|
2006
|
+
if (!Array.isArray(suggestions)) return [];
|
|
2007
|
+
const hints = suggestions.flatMap((item) => isRecord(item) && typeof item.cli === "string" ? [item.cli] : []);
|
|
2008
|
+
const unique = [...new Set(hints)];
|
|
2009
|
+
const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
|
|
2010
|
+
if (unique.length > shown.length)
|
|
2011
|
+
shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
|
|
2012
|
+
return shown;
|
|
2013
|
+
}
|
|
2014
|
+
function isRecord(value) {
|
|
2015
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1745
2016
|
}
|
|
1746
2017
|
|
|
1747
2018
|
// src/output/json-output.ts
|
|
@@ -1753,6 +2024,92 @@ function renderTraceJson(trace2) {
|
|
|
1753
2024
|
return renderJson(trace2);
|
|
1754
2025
|
}
|
|
1755
2026
|
|
|
2027
|
+
// src/output/doctor-output.ts
|
|
2028
|
+
import pc from "picocolors";
|
|
2029
|
+
function renderDoctorDiagnostics(diagnostics, format) {
|
|
2030
|
+
if (format === "json") return renderJson(diagnostics);
|
|
2031
|
+
if (format === "table") return renderDoctorTable(diagnostics);
|
|
2032
|
+
if (format) throw new Error(`Unsupported doctor format: ${format}. Expected json or table.`);
|
|
2033
|
+
return renderLegacyDoctorOutput(diagnostics);
|
|
2034
|
+
}
|
|
2035
|
+
function renderLegacyDoctorOutput(diagnostics) {
|
|
2036
|
+
if (diagnostics.length === 0) return cleanDoctorMessage();
|
|
2037
|
+
return renderJson(diagnostics);
|
|
2038
|
+
}
|
|
2039
|
+
function renderDoctorTable(diagnostics) {
|
|
2040
|
+
if (diagnostics.length === 0) return cleanDoctorMessage();
|
|
2041
|
+
const rows = diagnostics.map((diagnostic) => ({
|
|
2042
|
+
severity: String(diagnostic.severity ?? "info"),
|
|
2043
|
+
code: String(diagnostic.code ?? "diagnostic"),
|
|
2044
|
+
location: diagnosticLocation(diagnostic),
|
|
2045
|
+
message: compactMessage(diagnostic),
|
|
2046
|
+
hints: suggestedHintLines(diagnostic)
|
|
2047
|
+
}));
|
|
2048
|
+
const widths = {
|
|
2049
|
+
severity: columnWidth("Severity", rows.map((row) => row.severity), 10),
|
|
2050
|
+
code: columnWidth("Code", rows.map((row) => row.code), 44),
|
|
2051
|
+
location: columnWidth("Location", rows.map((row) => row.location), 28)
|
|
2052
|
+
};
|
|
2053
|
+
const lines = [
|
|
2054
|
+
`${"Severity".padEnd(widths.severity)} ${"Code".padEnd(widths.code)} ${"Location".padEnd(widths.location)} Message`,
|
|
2055
|
+
`${"-".repeat(widths.severity)} ${"-".repeat(widths.code)} ${"-".repeat(widths.location)} ${"-".repeat(7)}`
|
|
2056
|
+
];
|
|
2057
|
+
for (const row of rows) {
|
|
2058
|
+
lines.push(`${truncate(row.severity, widths.severity).padEnd(widths.severity)} ${truncate(row.code, widths.code).padEnd(widths.code)} ${truncate(row.location, widths.location).padEnd(widths.location)} ${row.message}`);
|
|
2059
|
+
lines.push(...row.hints.map((hint) => ` try ${hint}`));
|
|
2060
|
+
}
|
|
2061
|
+
return `${lines.join("\n")}
|
|
2062
|
+
`;
|
|
2063
|
+
}
|
|
2064
|
+
function diagnosticLocation(diagnostic) {
|
|
2065
|
+
const file = diagnostic.sourceFile ?? diagnostic.file;
|
|
2066
|
+
const line = diagnostic.sourceLine ?? diagnostic.line;
|
|
2067
|
+
if (file || line) return `${String(file ?? "")}:${String(line ?? "")}`;
|
|
2068
|
+
return "-";
|
|
2069
|
+
}
|
|
2070
|
+
function compactMessage(diagnostic) {
|
|
2071
|
+
const message = String(diagnostic.message ?? "");
|
|
2072
|
+
const count = typeof diagnostic.count === "number" ? ` count=${diagnostic.count}` : "";
|
|
2073
|
+
const total = typeof diagnostic.total === "number" ? ` total=${diagnostic.total}` : "";
|
|
2074
|
+
return `${message}${count}${total}`.trim();
|
|
2075
|
+
}
|
|
2076
|
+
function suggestedHintLines(diagnostic) {
|
|
2077
|
+
const direct = cliHints(diagnostic.suggestedHints);
|
|
2078
|
+
if (direct.length > 0) return cappedHints(direct);
|
|
2079
|
+
return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
|
|
2080
|
+
}
|
|
2081
|
+
function cliHints(value) {
|
|
2082
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
2083
|
+
}
|
|
2084
|
+
function cliHintsFromSuggestions(value) {
|
|
2085
|
+
if (!Array.isArray(value)) return [];
|
|
2086
|
+
return value.flatMap((item) => {
|
|
2087
|
+
if (!isRecord2(item)) return [];
|
|
2088
|
+
return typeof item.cli === "string" ? [item.cli] : [];
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
function isRecord2(value) {
|
|
2092
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
2093
|
+
}
|
|
2094
|
+
function cappedHints(hints) {
|
|
2095
|
+
const unique = [...new Set(hints)];
|
|
2096
|
+
const shown = unique.slice(0, 3);
|
|
2097
|
+
if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
|
|
2098
|
+
return shown;
|
|
2099
|
+
}
|
|
2100
|
+
function cleanDoctorMessage() {
|
|
2101
|
+
return `${pc.green("No diagnostics recorded")}
|
|
2102
|
+
`;
|
|
2103
|
+
}
|
|
2104
|
+
function columnWidth(header, values, max) {
|
|
2105
|
+
return Math.min(max, Math.max(header.length, ...values.map((value) => value.length)));
|
|
2106
|
+
}
|
|
2107
|
+
function truncate(value, width) {
|
|
2108
|
+
if (value.length <= width) return value;
|
|
2109
|
+
if (width <= 1) return value.slice(0, width);
|
|
2110
|
+
return `${value.slice(0, width - 1)}\u2026`;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1756
2113
|
// src/output/mermaid-output.ts
|
|
1757
2114
|
function safe(value) {
|
|
1758
2115
|
return value.replace(/[^\w-]/g, "_").slice(0, 60);
|
|
@@ -1985,13 +2342,10 @@ function createProgram() {
|
|
|
1985
2342
|
process.stdout.write(renderJson(rows));
|
|
1986
2343
|
}).catch(fail)
|
|
1987
2344
|
);
|
|
1988
|
-
program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").action(
|
|
2345
|
+
program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").option("--format <format>", "json|table").action(
|
|
1989
2346
|
(opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
1990
2347
|
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
|
|
1991
|
-
process.stdout.write(
|
|
1992
|
-
allDiagnostics.length ? renderJson(allDiagnostics) : `${pc.green("No diagnostics recorded")}
|
|
1993
|
-
`
|
|
1994
|
-
);
|
|
2348
|
+
process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
|
|
1995
2349
|
}).catch(fail)
|
|
1996
2350
|
);
|
|
1997
2351
|
program.command("clean").option("--workspace <path>").option("--db-only").action(
|