@saptools/service-flow 0.1.48 → 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/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  parsePackageJson,
16
16
  parseServiceBindings,
17
17
  trace
18
- } from "./chunk-EGY2A4AT.js";
18
+ } from "./chunk-XOROZHW4.js";
19
19
 
20
20
  // src/cli.ts
21
21
  import { Command } from "commander";
@@ -210,7 +210,7 @@ function migrate(db) {
210
210
  // package.json
211
211
  var package_default = {
212
212
  name: "@saptools/service-flow",
213
- version: "0.1.48",
213
+ version: "0.1.49",
214
214
  description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
215
215
  type: "module",
216
216
  publishConfig: {
@@ -554,11 +554,15 @@ function insertRegistrations(db, repoId, rows) {
554
554
  }
555
555
  function insertBindings(db, repoId, rows) {
556
556
  const stmt = db.prepare(
557
- "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),?,?,?,?,?,?,?,?,?,?)"
558
558
  );
559
559
  for (const r of rows)
560
560
  stmt.run(
561
561
  repoId,
562
+ repoId,
563
+ r.sourceFile,
564
+ r.sourceLine,
565
+ r.sourceLine,
562
566
  r.variableName,
563
567
  r.alias,
564
568
  r.aliasExpr,
@@ -612,9 +616,14 @@ function resolveSymbolCallTarget(db, repoId, r) {
612
616
  }
613
617
  function insertCalls(db, repoId, rows) {
614
618
  const stmt = db.prepare(
615
- "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)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,(SELECT id FROM service_bindings WHERE repo_id=? AND variable_name=? AND source_file=? ORDER BY CASE WHEN source_line<=? THEN 0 ELSE 1 END, ABS(source_line-?) ASC, id DESC 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)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
616
620
  );
617
- 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
+ };
618
627
  stmt.run(
619
628
  repoId,
620
629
  repoId,
@@ -633,21 +642,146 @@ function insertCalls(db, repoId, rows) {
633
642
  r.sourceFile,
634
643
  r.sourceLine,
635
644
  r.confidence,
636
- r.unresolvedReason,
645
+ r.unresolvedReason ?? binding.unresolvedReason,
637
646
  r.localServiceName,
638
647
  r.localServiceLookup,
639
648
  r.aliasChain ? JSON.stringify(r.aliasChain) : null,
640
- r.evidence ? JSON.stringify(r.evidence) : null,
649
+ JSON.stringify(evidence),
641
650
  r.externalTarget?.kind ?? null,
642
651
  r.externalTarget?.stableId ?? null,
643
652
  r.externalTarget?.label ?? null,
644
653
  r.externalTarget?.dynamic ? 1 : 0,
645
- repoId,
646
- r.serviceVariableName,
647
- r.sourceFile,
648
- r.sourceLine,
649
- r.sourceLine
654
+ binding.bindingId
650
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
+ }
651
785
  }
652
786
 
653
787
  // src/discovery/classify-repository.ts
@@ -1434,6 +1568,7 @@ function parserQualityDiagnostics(db, strict, options) {
1434
1568
  implementationCandidateQuality(db, Boolean(options.detail)),
1435
1569
  classInstanceNoiseQuality(db),
1436
1570
  contextualBindingPropagationQuality(db),
1571
+ serviceBindingQuality(db, Boolean(options.detail)),
1437
1572
  nestedThisReceiverQuality(db),
1438
1573
  wrapperPathPropagationQuality(db),
1439
1574
  remoteQueryTargetQuality(db),
@@ -1628,6 +1763,83 @@ function contextualBindingPropagationQuality(db) {
1628
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();
1629
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 };
1630
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
+ }
1631
1843
  function wrapperPathPropagationQuality(db) {
1632
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();
1633
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 };
@@ -1699,10 +1911,33 @@ function externalHttpTargetQuality(db) {
1699
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) };
1700
1912
  }
1701
1913
  function odataInvocationResolutionQuality(db) {
1702
- const rows = db.prepare("SELECT c.operation_path_expr operationPathExpr,e.status status FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'").all();
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();
1703
1921
  const unresolved = rows.filter((row) => row.status === "unresolved" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
1704
1922
  const ambiguous = rows.filter((row) => row.status === "ambiguous" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
1705
- 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 };
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
+ };
1706
1941
  }
1707
1942
  function identityAliasBindingQuality(db) {
1708
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();
@@ -1755,8 +1990,8 @@ function renderTraceTable(result) {
1755
1990
  const lines = ["Step Type From To Evidence"];
1756
1991
  for (const e of result.edges) {
1757
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)}`);
1758
- const hint = firstHint(e.evidence);
1759
- if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
1993
+ if (e.unresolvedReason)
1994
+ lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
1760
1995
  }
1761
1996
  if (result.diagnostics.length > 0) lines.push("", "Diagnostics:", ...result.diagnostics.flatMap(diagnosticLines));
1762
1997
  return `${lines.join("\n")}
@@ -1764,14 +1999,20 @@ function renderTraceTable(result) {
1764
1999
  }
1765
2000
  function diagnosticLines(diagnostic) {
1766
2001
  const first = `${String(diagnostic.severity ?? "info")} ${String(diagnostic.code ?? "diagnostic")} ${String(diagnostic.message ?? "")}`;
1767
- const hint = firstHint(diagnostic);
1768
- return hint ? [first, ` try ${hint}`] : [first];
2002
+ return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
1769
2003
  }
1770
- function firstHint(evidence) {
2004
+ function hintLines(evidence) {
1771
2005
  const suggestions = evidence.implementationHintSuggestions;
1772
- if (!Array.isArray(suggestions)) return void 0;
1773
- const first = suggestions.find((item) => Boolean(item) && typeof item === "object");
1774
- return typeof first?.cli === "string" ? first.cli : void 0;
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));
1775
2016
  }
1776
2017
 
1777
2018
  // src/output/json-output.ts
@@ -1843,11 +2084,11 @@ function cliHints(value) {
1843
2084
  function cliHintsFromSuggestions(value) {
1844
2085
  if (!Array.isArray(value)) return [];
1845
2086
  return value.flatMap((item) => {
1846
- if (!isRecord(item)) return [];
2087
+ if (!isRecord2(item)) return [];
1847
2088
  return typeof item.cli === "string" ? [item.cli] : [];
1848
2089
  });
1849
2090
  }
1850
- function isRecord(value) {
2091
+ function isRecord2(value) {
1851
2092
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
1852
2093
  }
1853
2094
  function cappedHints(hints) {