diff --git a/.github/workflows/openrpc-updater.yml b/.github/workflows/openrpc-updater.yml new file mode 100644 index 0000000000..9872df419b --- /dev/null +++ b/.github/workflows/openrpc-updater.yml @@ -0,0 +1,138 @@ + +name: OpenRPC JSON Updater + +on: + push: + branches: + - main + paths: + - 'docs/openrpc.json' + +jobs: + clone-and-build-execution-apis: + runs-on: ubuntu-latest + + steps: + - name: Checkout execution-apis repo + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + with: + ref: main + repository: 'ethereum/execution-apis' + path: 'execution-apis' + + - name: Use Node.js TLS 20 + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + working-directory: ./execution-apis + + - name: Build project + run: npm run build + working-directory: ./execution-apis + + - name: Upload openrpc.json as an artifact + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + with: + name: openrpc + path: ./execution-apis/refs-openrpc.json + + update-openrpc: + runs-on: ubuntu-latest + needs: clone-and-build-execution-apis + steps: + - name: Checkout repository + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + with: + ref: 'main' + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + + - name: Download openrpc.json artifact + uses: actions/download-artifact@v4 + with: + name: openrpc + path: ./downloaded-artifacts/ + + - name: Copy generated openrpc.json to scripts directory + run: | + mkdir -p scripts/openrpc-json-updater + cp ./downloaded-artifacts/refs-openrpc.json scripts/openrpc-json-updater/original-openrpc.json + + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: '22' + + - name: Install dependencies + run: | + cd scripts/openrpc-json-updater + npm install + + - name: Generate comparison report + id: generate-report + run: | + cd scripts/openrpc-json-updater + REPORT_OUTPUT=$(node cli.js) + echo "REPORT_OUTPUT<> $GITHUB_ENV + echo "$REPORT_OUTPUT" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + # This workflow automatically creates PRs when the OpenRPC JSON file differs from the upstream source. + # PRs are only created when actual changes are detected (SKIP_PR=false), ensuring that + # maintainers can review and approve schema updates before they're merged into the main branch. + # This provides a safety mechanism for tracking OpenRPC specification changes over time. + - name: Perform merge + id: merge + run: | + cd scripts/openrpc-json-updater + MERGE_OUTPUT=$(node cli.js --merge) + MERGE_EXIT_CODE=$? + echo "$MERGE_OUTPUT" + + if [ $MERGE_EXIT_CODE -eq 0 ]; then + if [[ "$MERGE_OUTPUT" =~ No\ differences\ found\ after\ merge ]]; then + echo "No differences found. Skipping PR creation." + echo "SKIP_PR=true" >> $GITHUB_ENV + exit 0 + elif [[ "$MERGE_OUTPUT" == *"Merge completed"* ]]; then + echo "Successfully updated openrpc.json" + echo "SKIP_PR=false" >> $GITHUB_ENV + else + echo "Unexpected output. Output was: $MERGE_OUTPUT" + exit 1 + fi + else + echo "Failed to update file. Output was: $MERGE_OUTPUT" + exit 1 + fi + + - name: Generate unique branch name + id: branch-name + run: | + TIMESTAMP=$(date +%Y%m%d%H%M%S) + UNIQUE_BRANCH="update-openrpc-${TIMESTAMP}" + echo "UNIQUE_BRANCH=${UNIQUE_BRANCH}" >> $GITHUB_ENV + echo "Generated unique branch name: ${UNIQUE_BRANCH}" + + - name: Create Pull Request + if: env.SKIP_PR != 'true' + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + commit-message: Update OpenRPC JSON + title: 'Update OpenRPC JSON' + body: | + # OpenRPC JSON Update + + This PR updates the OpenRPC JSON file with the latest changes. + + ## Comparison Report + ``` + ${{ env.REPORT_OUTPUT }} + ``` + branch: ${{ env.UNIQUE_BRANCH }} + base: 'main' + add-paths: docs/openrpc.json + delete-branch: true \ No newline at end of file diff --git a/scripts/openrpc-json-updater/README.md b/scripts/openrpc-json-updater/README.md new file mode 100644 index 0000000000..43cf8003b1 --- /dev/null +++ b/scripts/openrpc-json-updater/README.md @@ -0,0 +1,91 @@ +# OpenRPC Diff & Merge CLI + +A command-line tool for comparing and merging OpenRPC JSON specifications. + +## GitHub Actions Integration + +This tool is used with the `openrpc-updater.yml` workflow that automatically: + +- Fetches the latest OpenRPC spec from the ethereum/execution-apis repository +- Compares it with our local version +- Creates a PR with the changes if differences are found + +The GitHub Actions workflow requires a `PERSONAL_ACCESS_TOKEN` secret to be configured in your repository settings. +Add the token as a repository secret: + +- Go to your repository → Settings → Secrets and variables → Actions +- Click "New repository secret" +- Name: `PERSONAL_ACCESS_TOKEN` +- Value: Your generated token + +## Exclusions and Customizations (config.js) + +This tool applies several filters when processing OpenRPC specifications to align them with our implementation +requirements: + +### Skipped Methods + +**Discarded Methods:** + +- `engine_*` - Engine API methods are not supported in our implementation + +**Not Implemented Methods:** + +- `debug_getBadBlocks` +- `debug_getRawBlock` +- `debug_getRawHeader` +- `debug_getRawReceipts` +- `debug_getRawTransaction` + +These debug methods are excluded because they are not implemented in our current system. + +### Skipped Fields/Keys + +The following fields are excluded from all methods: + +- `examples` - Example values are omitted to reduce specification size +- `baseFeePerBlobGas` - Blob-related fields not applicable to our current implementation +- `blobGasUsedRatio` - Blob-related fields not applicable to our current implementation + +### Custom Field Overrides + +Certain fields are customized with our own descriptions and metadata for better integration with our system. This +includes custom summaries, descriptions, and schema titles for various `eth_*` methods like `eth_feeHistory`, +`eth_gasPrice`, `eth_getBalance`, etc. + +These customizations ensure the OpenRPC specification aligns with our implementation's specific requirements and +documentation standards. + +## Install + +```shell script +npm install +``` + +## Usage + +```shell script +node cli.js [options] + +Options: + -g, --merge merge original -> modified (writes a new dated file) +``` + +### Examples + +Full diff report: + +```shell script +node cli.js +``` + +Merge changes: + +```shell script +node cli.js --merge +``` + +## Important Note + +The script creates a file in the `docs/openrpc.json` path, so the application must be run with appropriate file system +permissions. diff --git a/scripts/openrpc-json-updater/cli.js b/scripts/openrpc-json-updater/cli.js new file mode 100644 index 0000000000..572e9d32cb --- /dev/null +++ b/scripts/openrpc-json-updater/cli.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mergeDocuments } from './operations/merge.js'; +import { compareIgnoringFormatting, prepareDocuments } from './operations/prepare.js'; +import { generateReport } from './operations/report.js'; +import { readJson, writeJson } from './utils/file.utils.js'; + +const originalFilePath = './original-openrpc.json'; +const modifiedFilePath = '../../docs/openrpc.json'; + +const { data: originalJson } = readJson(originalFilePath); +const { data: modifiedJson, originalContent: modifiedContent } = readJson(modifiedFilePath); + +function parseArgs() { + const argv = process.argv.slice(2); + const result = { mergeFlag: false }; + + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case '-g': + case '--merge': + result.mergeFlag = true; + break; + } + } + return result; +} + +function hasDifferences(original, merged) { + const differences = compareIgnoringFormatting(original, merged); + return differences && differences.length > 0; +} + +(async () => { + const { mergeFlag } = parseArgs(); + + const { normalizedOriginal, normalizedModified } = prepareDocuments(originalJson, modifiedJson); + + if (mergeFlag) { + const merged = mergeDocuments(normalizedOriginal, normalizedModified); + + if (!hasDifferences(normalizedModified, merged)) { + console.log(`\nNo differences found after merge. No changes needed.\n`); + process.exit(0); + } + + writeJson(modifiedFilePath, merged, modifiedContent); + console.log(`\nMerge completed. Updated file: '${modifiedFilePath}'.\n`); + return; + } + + await generateReport(normalizedOriginal, normalizedModified).catch((err) => { + console.error('Unexpected error while generating report:', err); + process.exit(1); + }); +})(); diff --git a/scripts/openrpc-json-updater/config.js b/scripts/openrpc-json-updater/config.js new file mode 100644 index 0000000000..5e7f310825 --- /dev/null +++ b/scripts/openrpc-json-updater/config.js @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const SKIPPED_KEYS = ['examples', 'baseFeePerBlobGas', 'blobGasUsedRatio']; + +export const CUSTOM_FIELDS = [ + 'eth_feeHistory.summary', + 'eth_feeHistory.description', + 'eth_feeHistory.params.2.description', + 'eth_feeHistory.result.schema.properties.gasUsedRatio.description', + 'eth_feeHistory.result.schema.properties.baseFeePerGas.title', + 'eth_feeHistory.result.schema.properties.baseFeePerGas.description', + 'eth_feeHistory.result.schema.properties.reward.title', + 'eth_getTransactionCount.summary', + 'eth_maxPriorityFeePerGas.result.schema.description', + 'eth_sendRawTransaction.summary', +]; + +export const DISCARDED_METHODS = ['engine_*']; + +export const NOT_IMPLEMENTED_METHODS = [ + 'debug_getBadBlocks', + 'debug_getRawBlock', + 'debug_getRawHeader', + 'debug_getRawReceipts', + 'debug_getRawTransaction', + 'eth_coinbase', + 'eth_blobBaseFee', + 'eth_syncing', + 'eth_getProof', +]; + +export const SKIPPED_METHODS = [...DISCARDED_METHODS, ...NOT_IMPLEMENTED_METHODS]; + +export function shouldSkipMethod(methodName, path) { + if (!methodName) return false; + + if (path) { + const fullPath = `${methodName}.${path}`; + if (CUSTOM_FIELDS.includes(fullPath)) return true; + } + + for (const pattern of SKIPPED_METHODS) { + if (pattern === methodName) return true; + + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + if (methodName.startsWith(prefix)) return true; + } + } + return false; +} + +export function shouldSkipKey(key) { + if (!key) return false; + for (const pattern of SKIPPED_KEYS) { + if (pattern === key) return true; + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + if (key.startsWith(prefix)) return true; + } + } + return false; +} + +export function shouldSkipPath(path) { + if (!path) return false; + const parts = path.split('.'); + for (const part of parts) { + if (shouldSkipKey(part)) return true; + } + return false; +} + +export function getSkippedMethodCategory(methodName) { + if (!methodName) return null; + + const matchesPattern = (pattern, method) => { + if (pattern === method) return true; + + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + return method.startsWith(prefix); + } + + return false; + }; + + if (DISCARDED_METHODS.some((pattern) => matchesPattern(pattern, methodName))) { + return 'discarded'; + } + + if (NOT_IMPLEMENTED_METHODS.some((pattern) => matchesPattern(pattern, methodName))) { + return 'not implemented'; + } + + return null; +} diff --git a/scripts/openrpc-json-updater/operations/merge.js b/scripts/openrpc-json-updater/operations/merge.js new file mode 100644 index 0000000000..1609405ddf --- /dev/null +++ b/scripts/openrpc-json-updater/operations/merge.js @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { shouldSkipKey, shouldSkipMethod, shouldSkipPath } from '../config.js'; +import { + filterSkippedMethods, + findRefPaths, + getNestedValue, + getObjectByPath, + handleRefField, + handleRefFieldsWithOriginal, + removeSkippedKeys, + setNestedValue, + setObjectByPath, +} from '../utils/merge.utils.js'; +import { getDifferingKeys, getMethodMap } from '../utils/openrpc.utils.js'; + +class MergeDocuments { + /** + * Merges two OpenRPC documents + * @param {Object} originalJson - The original OpenRPC document + * @param {Object} modifiedJson - The modified Hedera OpenRPC document + * @returns {Object} - The merged OpenRPC document + */ + mergeDocuments(originalJson, modifiedJson) { + if (!Array.isArray(originalJson.methods)) return modifiedJson; + if (!Array.isArray(modifiedJson.methods)) modifiedJson.methods = []; + + // Step 1: Filter methods that should be skipped + const filteredOriginal = this.filterDocument(originalJson); + const filteredModified = this.filterDocument(modifiedJson); + + // Step 2: Merge methods from original to hedera's modified file + this.mergeMethods(filteredOriginal, filteredModified); + + // Step 3: Merge components from original to hedera's modified file + this.mergeComponents(filteredOriginal, filteredModified); + + // Step 4: Process the final document + return this.processDocument(filteredModified, filteredOriginal); + } + + /** + * Filters a document to remove methods that should be skipped + * @param {Object} document - The document to filter + * @returns {Object} - The filtered document + */ + filterDocument(document) { + const filtered = JSON.parse(JSON.stringify(document)); + filtered.methods = filterSkippedMethods(filtered.methods); + return filtered; + } + + /** + * Merges methods from original to modified + * @param {Object} filteredOriginal - The filtered original document + * @param {Object} filteredModified - The filtered modified document + */ + mergeMethods(filteredOriginal, filteredModified) { + const modifiedMap = getMethodMap(filteredModified); + + for (const origMethod of filteredOriginal.methods) { + const name = origMethod.name; + if (!name) continue; + + if (!modifiedMap.has(name)) { + filteredModified.methods.push(origMethod); + continue; + } + const modMethod = modifiedMap.get(name); + + this.processRefFields(origMethod, modMethod); + this.processDifferingKeys(origMethod, modMethod); + } + } + + /** + * Processes $ref fields in a method + * @param {Object} origMethod - The original method + * @param {Object} modMethod - The modified method + */ + processRefFields(origMethod, modMethod) { + const refPaths = findRefPaths(origMethod); + + for (const { path } of refPaths) { + this.replaceObjectAtPath(origMethod, modMethod, path); + } + } + + /** + * Replaces an object at a specific path with the original object + * @param {Object} origMethod - The original method + * @param {Object} modMethod - The modified method + * @param {string} path - The path to the object to replace + * @private + */ + replaceObjectAtPath(origMethod, modMethod, path) { + const targetObj = this.getObjectAtPath(modMethod, path); + const origObj = this.getObjectAtPath(origMethod, path); + + if (!this.areValidObjects(targetObj, origObj)) { + return; + } + + if (path) { + setObjectByPath(modMethod, path, this.deepClone(origObj)); + } else { + this.replaceRootObject(modMethod, origObj); + } + } + + /** + * Gets an object at the specified path or returns the root object if no path + * @param {Object} obj - The object to navigate + * @param {string} path - The path to navigate to + * @returns {*} The object at the path or the root object + * @private + */ + getObjectAtPath(obj, path) { + return path ? getObjectByPath(obj, path) : obj; + } + + /** + * Validates that both objects are valid objects + * @param {*} targetObj - The target object + * @param {*} origObj - The original object + * @returns {boolean} True if both are valid objects + * @private + */ + areValidObjects(targetObj, origObj) { + return targetObj && typeof targetObj === 'object' && origObj && typeof origObj === 'object'; + } + + /** + * Creates a deep clone of an object + * @param {*} obj - The object to clone + * @returns {*} A deep clone of the object + * @private + */ + deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); + } + + /** + * Replaces the root object by clearing the target and copying from source + * @param {Object} targetObj - The object to clear and replace + * @param {Object} sourceObj - The source object to copy from + * @private + */ + replaceRootObject(targetObj, sourceObj) { + Object.keys(targetObj).forEach((key) => { + delete targetObj[key]; + }); + + Object.assign(targetObj, sourceObj); + } + + /** + * Processes differing keys between original and modified methods + * @param {Object} origMethod - The original method + * @param {Object} modMethod - The modified method + */ + processDifferingKeys(origMethod, modMethod) { + const differingKeys = getDifferingKeys(origMethod, modMethod); + + for (const path of differingKeys) { + if (shouldSkipPath(path)) continue; + + const methodName = origMethod.name; + if (shouldSkipMethod(methodName, path)) continue; + + const valueFromOriginal = getNestedValue(origMethod, path); + + if (this.shouldSkipDueToRef(modMethod, path)) continue; + + if (path.includes('.')) { + setNestedValue(modMethod, path, valueFromOriginal); + } else { + modMethod[path] = valueFromOriginal; + } + } + } + + /** + * Checks if a path should be skipped due to containing a $ref + * @param {Object} obj - The object to check + * @param {string} path - The path to check + * @returns {boolean} - Whether the path should be skipped + */ + shouldSkipDueToRef(obj, path) { + const parts = path.split('.'); + let checkPath = ''; + for (let i = 0; i < parts.length; i++) { + checkPath = checkPath ? `${checkPath}.${parts[i]}` : parts[i]; + const value = getNestedValue(obj, checkPath); + if (value && typeof value === 'object' && value['$ref'] !== undefined) { + return true; + } + } + return false; + } + + /** + * Merges components from original to modified + * @param {Object} filteredOriginal - The filtered original document + * @param {Object} filteredModified - The filtered modified document + */ + mergeComponents(filteredOriginal, filteredModified) { + if (!this.hasValidComponents(filteredOriginal)) { + return; + } + + this.ensureComponentsExist(filteredModified); + + const originalComponents = filteredOriginal.components; + const modifiedComponents = filteredModified.components; + + Object.keys(originalComponents).forEach((sectionName) => { + this.mergeSectionComponents(originalComponents[sectionName], modifiedComponents, sectionName); + }); + } + + /** + * Validates if the document has valid components + * @param {Object} document - The document to validate + * @returns {boolean} True if document has valid components + * @private + */ + hasValidComponents(document) { + return document?.components && typeof document.components === 'object'; + } + + /** + * Ensures the component object exists in the target document + * @param {Object} document - The document to ensure components exist in + * @private + */ + ensureComponentsExist(document) { + if (!document.components) { + document.components = {}; + } + } + + /** + * Merges components from a specific section + * @param {Object} originalSection - The original section components + * @param {Object} modifiedComponents - The modified document's components object + * @param {string} sectionName - The name of the section being merged + * @private + */ + mergeSectionComponents(originalSection, modifiedComponents, sectionName) { + if (!originalSection || typeof originalSection !== 'object') { + return; + } + + this.ensureSectionExists(modifiedComponents, sectionName); + + const validKeys = this.getValidKeysFromSection(originalSection); + + validKeys.forEach((key) => { + this.mergeComponentKey(originalSection[key], modifiedComponents[sectionName], key); + }); + } + + /** + * Ensures a specific section exists in the components object + * @param {Object} components - The components object + * @param {string} sectionName - The section name to ensure exists + * @private + */ + ensureSectionExists(components, sectionName) { + if (!components[sectionName]) { + components[sectionName] = {}; + } + } + + /** + * Gets valid keys from a section (excludes skipped keys) + * @param {Object} section - The section to get keys from + * @returns {string[]} Array of valid key names + * @private + */ + getValidKeysFromSection(section) { + return Object.keys(section).filter((key) => !shouldSkipKey(key)); + } + + /** + * Merges a specific component key if it doesn't already exist + * @param {*} originalValue - The original component value + * @param {Object} targetSection - The target section to merge into + * @param {string} key - The component key name + * @private + */ + mergeComponentKey(originalValue, targetSection, key) { + if (!targetSection[key]) { + targetSection[key] = removeSkippedKeys(originalValue); + } + } + + /** + * Processes a document to handle $ref fields and remove skipped keys + * @param {Object} document - The document to process + * @param {Object} originalDocument - The original document + * @returns {Object} - The processed document + */ + /** + * Processes a document to handle $ref fields and remove skipped keys + * @param {Object} document - The document to process + * @param {Object} originalDocument - The original document + * @returns {Object} - The processed document + */ + processDocument(document, originalDocument) { + if (!this.isValidDocument(document)) { + return document; + } + + const result = this.cloneDocument(document); + + this.filterAndCleanMethods(result); + + if (originalDocument) { + return this.processWithOriginalDocument(result, originalDocument); + } + + return handleRefField(result); + } + + /** + * Validates if the document has the required structure + * @param {Object} document - The document to validate + * @returns {boolean} True if a document is valid + * @private + */ + isValidDocument(document) { + return document && document.methods && Array.isArray(document.methods); + } + + /** + * Creates a deep clone of the document + * @param {Object} document - The document to clone + * @returns {Object} A deep clone of the document + * @private + */ + cloneDocument(document) { + return JSON.parse(JSON.stringify(document)); + } + + /** + * Filters out skipped methods and removes skipped keys from remaining methods + * @param {Object} document - The document to process + * @private + */ + filterAndCleanMethods(document) { + document.methods = filterSkippedMethods(document.methods); + document.methods = this.removeSkippedKeysFromMethods(document.methods); + } + + /** + * Removes skipped keys from all methods + * @param {Array} methods - Array of methods to clean + * @returns {Array} Array of methods with skipped keys removed + * @private + */ + removeSkippedKeysFromMethods(methods) { + return methods.map((method) => removeSkippedKeys(method)); + } + + /** + * Processes the document when an original document is available + * @param {Object} document - The document to process + * @param {Object} originalDocument - The original document for reference + * @returns {Object} The processed document + * @private + */ + processWithOriginalDocument(document, originalDocument) { + this.processMethodsWithOriginal(document, originalDocument); + this.processComponentsWithOriginal(document, originalDocument); + return document; + } + + /** + * Processes methods with reference to the original document + * @param {Object} document - The document to process + * @param {Object} originalDocument - The original document for reference + * @private + */ + processMethodsWithOriginal(document, originalDocument) { + if (!document.methods || !originalDocument.methods) { + return; + } + + const origMethodMap = getMethodMap(originalDocument); + + document.methods = document.methods.map((method) => { + const origMethod = origMethodMap.get(method.name); + return origMethod ? handleRefFieldsWithOriginal(method, origMethod) : method; + }); + } + + /** + * Processes components with reference to the original document + * @param {Object} document - The document to process + * @param {Object} originalDocument - The original document for reference + * @private + */ + processComponentsWithOriginal(document, originalDocument) { + if (!document.components || !originalDocument.components) { + return; + } + + document.components = handleRefFieldsWithOriginal(document.components, originalDocument.components, true); + } +} + +const mergeDocumentsInstance = new MergeDocuments(); + +/** + * Merges two OpenRPC documents + * @param {Object} originalJson - The original OpenRPC document + * @param {Object} modifiedJson - The modified OpenRPC document + * @returns {Object} - The merged OpenRPC document + */ +export function mergeDocuments(originalJson, modifiedJson) { + return mergeDocumentsInstance.mergeDocuments(originalJson, modifiedJson); +} diff --git a/scripts/openrpc-json-updater/operations/prepare.js b/scripts/openrpc-json-updater/operations/prepare.js new file mode 100644 index 0000000000..82c51694a8 --- /dev/null +++ b/scripts/openrpc-json-updater/operations/prepare.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 + +import diff from 'deep-diff'; + +export function prepareDocuments(originalJson, modifiedJson) { + return { + normalizedOriginal: normalizeDocument(originalJson), + normalizedModified: normalizeDocument(modifiedJson), + }; +} + +export function normalizeDocument(document) { + return JSON.parse(JSON.stringify(document)); +} + +export function compareIgnoringFormatting(obj1, obj2) { + const normalized1 = normalizeDocument(obj1); + const normalized2 = normalizeDocument(obj2); + + return diff(normalized1, normalized2); +} diff --git a/scripts/openrpc-json-updater/operations/report.js b/scripts/openrpc-json-updater/operations/report.js new file mode 100644 index 0000000000..e5d190fc50 --- /dev/null +++ b/scripts/openrpc-json-updater/operations/report.js @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { getSkippedMethodCategory, NOT_IMPLEMENTED_METHODS } from '../config.js'; +import { getDifferingKeysByCategory, getMethodMap, groupPaths } from '../utils/openrpc.utils.js'; + +export async function generateReport(originalJson, modifiedJson) { + const originalMethods = getMethodMap(originalJson); + const modifiedMethods = getMethodMap(modifiedJson); + + const missingMethods = []; + + for (const method of NOT_IMPLEMENTED_METHODS) { + missingMethods.push({ + missingMethod: method, + status: 'not implemented', + }); + } + for (const name of originalMethods.keys()) { + if (!modifiedMethods.has(name)) { + const alreadyReported = missingMethods.some(item => item.missingMethod === name); + if (!alreadyReported) { + const category = getSkippedMethodCategory(name); + missingMethods.push({ + missingMethod: name, + status: category ? `${category}` : 'a new method', + }); + } + } + } + + const changedMethods = []; + for (const [name, origMethod] of originalMethods) { + if (!modifiedMethods.has(name)) continue; + const category = getSkippedMethodCategory(name); + if (category === 'discarded' || category === 'not implemented') { + continue; + } + const modMethod = modifiedMethods.get(name); + + const { valueDiscrepancies } = getDifferingKeysByCategory(origMethod, modMethod); + if (valueDiscrepancies.length > 0) { + changedMethods.push({ + method: name, + valueDiscrepancies: groupPaths(valueDiscrepancies, 3), + }); + } + } + + if (missingMethods.length === 0 && changedMethods.length === 0) { + console.log('No differences detected.'); + return; + } + + if (missingMethods.length > 0) { + console.log('\nMethods present in the original document but missing from the modified document:\n'); + console.table(missingMethods); + console.log('\nStatus explanation:'); + console.log('- (discarded): Methods that have been intentionally removed'); + console.log('- (not implemented): Methods that have not been implemented yet'); + } + + if (changedMethods.length > 0) { + console.log('\nMethods with differences between documents:\n'); + console.table(changedMethods, ['method', 'valueDiscrepancies']); + console.log('\nExplanation:'); + console.log('- valueDiscrepancies: Fields that exist in both documents but have different values'); + console.log('- Entries with format "path (N diffs)" indicate N differences within that path'); + } +} diff --git a/scripts/openrpc-json-updater/package-lock.json b/scripts/openrpc-json-updater/package-lock.json new file mode 100644 index 0000000000..9212c7cc49 --- /dev/null +++ b/scripts/openrpc-json-updater/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "openrpc-json-updater", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "deep-diff": "^1.0.2" + } + }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "license": "MIT" + } + } +} diff --git a/scripts/openrpc-json-updater/package.json b/scripts/openrpc-json-updater/package.json new file mode 100644 index 0000000000..4fad7b75ed --- /dev/null +++ b/scripts/openrpc-json-updater/package.json @@ -0,0 +1,10 @@ +{ + "name": "openrpc-updater", + "description": "OpenRPC JSON Updater Tool", + "author": "Hedera Team", + "license": "Apache-2.0", + "type": "module", + "devDependencies": { + "deep-diff": "^1.0.2" + } +} diff --git a/scripts/openrpc-json-updater/utils/file.utils.js b/scripts/openrpc-json-updater/utils/file.utils.js new file mode 100644 index 0000000000..159a30782d --- /dev/null +++ b/scripts/openrpc-json-updater/utils/file.utils.js @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 + +import fs from 'fs'; + +export function readJson(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return { + data: JSON.parse(content), + originalContent: content, + }; + } catch (err) { + console.error(`Unable to read or parse "${filePath}":`, err); + process.exit(1); + } +} + +/** + * Formats a JSON object with custom indentation and compact array handling + * @param {*} obj - The object to format + * @param {string} indent - The indentation string (default: ' ') + * @param {number} level - The current nesting level (default: 0) + * @returns {string} Formatted JSON string + */ +function formatJson(obj, indent = ' ', level = 0) { + const formatter = new JsonFormatter(indent, level); + return formatter.format(obj); +} + +/** + * JSON formatter class that handles different data types and formatting rules + */ +class JsonFormatter { + constructor(indent, level) { + this.indent = indent; + this.level = level; + this.currentIndent = indent.repeat(level); + this.nextIndent = indent.repeat(level + 1); + } + + /** + * Main formatting method that delegates to specific type handlers + * @param {*} obj - The object to format + * @returns {string} Formatted string representation + */ + format(obj) { + if (this.isPrimitive(obj)) { + return this.formatPrimitive(obj); + } + + if (Array.isArray(obj)) { + return this.formatArray(obj); + } + + return this.formatObject(obj); + } + + /** + * Checks if a value is a primitive type + * @param {*} value - The value to check + * @returns {boolean} True if the value is primitive + */ + isPrimitive(value) { + return ( + value === null || + value === undefined || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'string' + ); + } + + /** + * Formats primitive values (null, undefined, numbers, booleans, strings) + * @param {*} value - The primitive value to format + * @returns {string} Formatted primitive value + */ + formatPrimitive(value) { + if (value === null || value === undefined) { + return 'null'; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (typeof value === 'string') { + return JSON.stringify(value); + } + + return String(value); + } + + /** + * Formats arrays with compact or expanded layout based on content + * @param {Array} array - The array to format + * @returns {string} Formatted array string + */ + formatArray(array) { + if (array.length === 0) { + return '[]'; + } + + if (this.shouldUseCompactArrayFormat(array)) { + return this.formatCompactArray(array); + } + + return this.formatExpandedArray(array); + } + + /** + * Determines if an array should use compact formatting + * @param {Array} array - The array to check + * @returns {boolean} True if compact formatting should be used + */ + shouldUseCompactArrayFormat(array) { + const maxCompactLength = 8; + const maxStringLength = 40; + + return array.length <= maxCompactLength && array.every((item) => this.isCompactableItem(item, maxStringLength)); + } + + /** + * Checks if an array item is suitable for compact formatting + * @param {*} item - The item to check + * @param {number} maxStringLength - Maximum string length for compact format + * @returns {boolean} True if item can be formatted compactly + */ + isCompactableItem(item, maxStringLength) { + return ( + (typeof item === 'string' && item.length < maxStringLength) || + typeof item === 'number' || + typeof item === 'boolean' + ); + } + + /** + * Formats an array in compact single-line format + * @param {Array} array - The array to format + * @returns {string} Compact formatted array + */ + formatCompactArray(array) { + const items = array.map((item) => this.createChildFormatter().format(item)).join(', '); + return `[${items}]`; + } + + /** + * Formats an array in expanded multi-line format + * @param {Array} array - The array to format + * @returns {string} Expanded formatted array + */ + formatExpandedArray(array) { + const childFormatter = this.createChildFormatter(); + const items = array.map((item) => this.nextIndent + childFormatter.format(item)).join(',\n'); + + return `[\n${items}\n${this.currentIndent}]`; + } + + /** + * Formats objects with proper indentation and key-value pairs + * @param {Object} obj - The object to format + * @returns {string} Formatted object string + */ + formatObject(obj) { + const entries = Object.entries(obj); + + if (entries.length === 0) { + return '{}'; + } + + const childFormatter = this.createChildFormatter(); + const props = entries.map(([key, value]) => this.formatObjectProperty(key, value, childFormatter)).join(',\n'); + + return `{\n${props}\n${this.currentIndent}}`; + } + + /** + * Formats a single object property (key-value pair) + * @param {string} key - The property key + * @param {*} value - The property value + * @param {JsonFormatter} childFormatter - Formatter for the child value + * @returns {string} Formatted property string + */ + formatObjectProperty(key, value, childFormatter) { + const formattedValue = childFormatter.format(value); + return `${this.nextIndent}"${key}": ${formattedValue}`; + } + + /** + * Creates a child formatter for the next nesting level + * @returns {JsonFormatter} A new formatter for child elements + */ + createChildFormatter() { + return new JsonFormatter(this.indent, this.level + 1); + } +} + +export function writeJson(filePath, data, originalContent) { + try { + if (!originalContent) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + return true; + } + + const eol = originalContent.includes('\r\n') ? '\r\n' : '\n'; + const formatted = formatJson(data); + const output = formatted.replace(/\n/g, eol); + fs.writeFileSync(filePath, output, 'utf-8'); + return true; + } catch (err) { + console.error(`Unable to write to "${filePath}":`, err); + } +} diff --git a/scripts/openrpc-json-updater/utils/merge.utils.js b/scripts/openrpc-json-updater/utils/merge.utils.js new file mode 100644 index 0000000000..c3932074d9 --- /dev/null +++ b/scripts/openrpc-json-updater/utils/merge.utils.js @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { getSkippedMethodCategory,shouldSkipKey } from '../config.js'; + +/** + * Sets a nested value in an object using a dot-notation path + */ +export function setNestedValue(obj, path, value) { + if (!path) return; + + const parts = path.split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + + if (current[part] === undefined || current[part] === null || typeof current[part] !== 'object') { + current[part] = {}; + } + + current = current[part]; + } + + const lastPart = parts[parts.length - 1]; + current[lastPart] = value; +} + +/** + * Gets a nested value from an object using a dot-notation path + */ +export function getNestedValue(obj, path) { + if (!path) return undefined; + + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current === undefined || current === null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + + return current; +} + +/** + * Removes keys that should be skipped from an object + */ +export function removeSkippedKeys(obj) { + if (!obj || typeof obj !== 'object') return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => removeSkippedKeys(item)); + } + + const result = {}; + for (const key in obj) { + if (shouldSkipKey(key)) continue; + + result[key] = removeSkippedKeys(obj[key]); + } + + return result; +} + +/** + * Handles $ref fields in an object + */ +export function handleRefField(obj) { + if (!obj || typeof obj !== 'object') return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => handleRefField(item)); + } + + if (obj['$ref'] !== undefined) { + return { $ref: obj['$ref'] }; + } + + const result = {}; + for (const key in obj) { + result[key] = handleRefField(obj[key]); + } + + return result; +} + +/** + * Gets an object by path from an object + */ +export function getObjectByPath(obj, path) { + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current === undefined || current === null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + + return current; +} + +/** + * Sets an object by path in an object + */ +export function setObjectByPath(obj, path, value) { + const parts = path.split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + + if (current[part] === undefined || current[part] === null || typeof current[part] !== 'object') { + current[part] = {}; + } + + current = current[part]; + } + + const lastPart = parts[parts.length - 1]; + current[lastPart] = value; +} + +/** + * Finds all paths in an object that contain $ref fields + * @param {*} obj - The object to search + * @param {string} currentPath - The current path (used internally for recursion) + * @param {Array} paths - The accumulated paths array (used internally for recursion) + * @returns {Array} Array of objects with path and ref properties + */ +export function findRefPaths(obj, currentPath = '', paths = []) { + const pathFinder = new RefPathFinder(); + return pathFinder.find(obj, currentPath, paths); +} + +/** + * Class responsible for finding $ref paths in objects + */ +class RefPathFinder { + /** + * Main method to find all $ref paths in an object + * @param {*} obj - The object to search + * @param {string} currentPath - The current path + * @param {Array} paths - The accumulated paths array + * @returns {Array} Array of ref path objects + */ + find(obj, currentPath = '', paths = []) { + if (!this.isValidObject(obj)) { + return paths; + } + + if (Array.isArray(obj)) { + this.processArray(obj, currentPath, paths); + } else { + this.processObject(obj, currentPath, paths); + } + + return paths; + } + + /** + * Validates if the object is suitable for processing + * @param {*} obj - The object to validate + * @returns {boolean} True if object can be processed + */ + isValidObject(obj) { + return obj && typeof obj === 'object'; + } + + /** + * Processes an array and recursively searches its elements + * @param {Array} array - The array to process + * @param {string} currentPath - The current path + * @param {Array} paths - The accumulated paths array + */ + processArray(array, currentPath, paths) { + array.forEach((item, index) => { + const itemPath = this.buildPath(currentPath, index); + this.find(item, itemPath, paths); + }); + } + + /** + * Processes an object and searches for $ref fields and nested objects + * @param {Object} obj - The object to process + * @param {string} currentPath - The current path + * @param {Array} paths - The accumulated paths array + */ + processObject(obj, currentPath, paths) { + if (this.hasRefField(obj)) { + this.addRefPath(currentPath, obj['$ref'], paths); + } + + this.processObjectProperties(obj, currentPath, paths); + } + + /** + * Checks if an object has a $ref field + * @param {Object} obj - The object to check + * @returns {boolean} True if object has $ref field + */ + hasRefField(obj) { + return obj['$ref'] !== undefined; + } + + /** + * Adds a ref path to the paths array + * @param {string} path - The path to the ref + * @param {string} ref - The ref value + * @param {Array} paths - The paths array to add to + */ + addRefPath(path, ref, paths) { + paths.push({ + path, + ref, + }); + } + + /** + * Processes all properties of an object recursively + * @param {Object} obj - The object whose properties to process + * @param {string} currentPath - The current path + * @param {Array} paths - The accumulated paths array + */ + processObjectProperties(obj, currentPath, paths) { + Object.keys(obj).forEach((key) => { + const value = obj[key]; + if (this.shouldProcessProperty(value)) { + const propertyPath = this.buildPath(currentPath, key); + this.find(value, propertyPath, paths); + } + }); + } + + /** + * Determines if a property value should be processed recursively + * @param {*} value - The property value + * @returns {boolean} True if value should be processed + */ + shouldProcessProperty(value) { + return typeof value === 'object' && value !== null; + } + + /** + * Builds a dot-notation path from current path and new segment + * @param {string} currentPath - The current path + * @param {string|number} segment - The new path segment + * @returns {string} The combined path + */ + buildPath(currentPath, segment) { + return currentPath ? `${currentPath}.${segment}` : String(segment); + } +} + +/** + * Handles $ref fields with an original object + * @param {*} obj - The object to process + * @param {*} origObj - The original object for reference + * @param {boolean} isComponent - Whether processing components + * @returns {*} The processed object + */ +export function handleRefFieldsWithOriginal(obj, origObj, isComponent = false) { + const refHandler = new RefFieldHandler(isComponent); + return refHandler.handle(obj, origObj); +} + +/** + * Class responsible for handling $ref fields with original object references + */ +class RefFieldHandler { + constructor(isComponent = false) { + this.isComponent = isComponent; + } + + /** + * Main method to handle ref fields + * @param {*} obj - The object to process + * @param {*} origObj - The original object for reference + * @returns {*} The processed object + */ + handle(obj, origObj) { + if (!this.isValidObject(obj)) { + return obj; + } + + if (!this.isValidObject(origObj)) { + return this.handleWithoutOriginal(obj); + } + + if (Array.isArray(obj)) { + return this.handleArray(obj, origObj); + } + + return this.handleObject(obj, origObj); + } + + /** + * Validates if an object is suitable for processing + * @param {*} obj - The object to validate + * @returns {boolean} True if object can be processed + */ + isValidObject(obj) { + return obj && typeof obj === 'object'; + } + + /** + * Handles processing when no valid original object is available + * @param {*} obj - The object to process + * @returns {*} The processed object + */ + handleWithoutOriginal(obj) { + return this.isComponent ? obj : handleRefField(obj); + } + + /** + * Handles array processing with original array reference + * @param {Array} array - The array to process + * @param {Array} origArray - The original array for reference + * @returns {Array} The processed array + */ + handleArray(array, origArray) { + return array.map((item, index) => { + const origItem = this.getArrayItemSafely(origArray, index); + return this.handle(item, origItem); + }); + } + + /** + * Safely gets an item from an array at the specified index + * @param {Array} array - The array to get item from + * @param {number} index - The index to get + * @returns {*} The item at index or undefined + */ + getArrayItemSafely(array, index) { + return Array.isArray(array) && index < array.length ? array[index] : undefined; + } + + /** + * Handles object processing with original object reference + * @param {Object} obj - The object to process + * @param {Object} origObj - The original object for reference + * @returns {Object} The processed object + */ + handleObject(obj, origObj) { + if (this.hasRefField(origObj)) { + return this.cloneObject(origObj); + } + + if (this.hasRefField(obj)) { + return this.handleObjectWithRef(obj); + } + + return this.processObjectProperties(obj, origObj); + } + + /** + * Checks if an object has a $ref field + * @param {Object} obj - The object to check + * @returns {boolean} True if object has $ref field + */ + hasRefField(obj) { + return obj && obj['$ref'] !== undefined; + } + + /** + * Creates a deep clone of an object + * @param {Object} obj - The object to clone + * @returns {Object} A deep clone of the object + */ + cloneObject(obj) { + return JSON.parse(JSON.stringify(obj)); + } + + /** + * Handles an object that contains a $ref field + * @param {Object} obj - The object with $ref field + * @returns {Object} The processed ref object + */ + handleObjectWithRef(obj) { + if (this.isComponent) { + return this.cloneObject(obj); + } + return this.createRefOnlyObject(obj['$ref']); + } + + /** + * Creates an object containing only the $ref field + * @param {string} refValue - The $ref value + * @returns {Object} Object with only $ref field + */ + createRefOnlyObject(refValue) { + return { $ref: refValue }; + } + + /** + * Processes all properties of an object recursively + * @param {Object} obj - The object to process + * @param {Object} origObj - The original object for reference + * @returns {Object} The processed object with all properties handled + */ + processObjectProperties(obj, origObj) { + const newObj = {}; + + Object.keys(obj).forEach((key) => { + const value = obj[key]; + const origValue = origObj[key]; + newObj[key] = this.handle(value, origValue); + }); + + return newObj; + } +} + +/** + * Filters methods that should be skipped + */ +export function filterSkippedMethods(methods) { + if (!Array.isArray(methods)) return []; + return methods.filter((method) => { + const methodName = method?.name; + if (!methodName) return true; + + const category = getSkippedMethodCategory(methodName); + return category !== 'discarded'; + }); +} diff --git a/scripts/openrpc-json-updater/utils/openrpc.utils.js b/scripts/openrpc-json-updater/utils/openrpc.utils.js new file mode 100644 index 0000000000..8cdc08c974 --- /dev/null +++ b/scripts/openrpc-json-updater/utils/openrpc.utils.js @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { shouldSkipKey, shouldSkipPath } from '../config.js'; +import { compareIgnoringFormatting } from '../operations/prepare.js'; + +export function getMethodMap(openrpcDoc) { + const map = new Map(); + if (Array.isArray(openrpcDoc.methods)) { + for (const m of openrpcDoc.methods) { + if (m?.name) map.set(m.name, m); + } + } + return map; +} + +function hasKey(obj, path) { + if (!path) return false; + const parts = path.split('.'); + let current = obj; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + if (current === undefined || current === null || typeof current !== 'object') { + return false; + } + + if (!(part in current)) { + return false; + } + + current = current[part]; + } + + return true; +} + +export function groupPaths(paths, minGroupSize = 3) { + if (!paths || paths.length === 0) return '-'; + if (paths.length === 1) return paths[0]; + + function getDepth(path) { + return path.split('.').length; + } + + function analyzePrefixes(paths) { + const prefixCounters = {}; + + for (const path of paths) { + const parts = path.split('.'); + let currentPrefix = ''; + + for (let i = 0; i < parts.length - 1; i++) { + currentPrefix = currentPrefix ? `${currentPrefix}.${parts[i]}` : parts[i]; + prefixCounters[currentPrefix] = (prefixCounters[currentPrefix] || 0) + 1; + } + } + + return Object.keys(prefixCounters) + .filter((prefix) => prefixCounters[prefix] >= minGroupSize) + .sort((a, b) => { + const countDiff = prefixCounters[b] - prefixCounters[a]; + if (countDiff !== 0) return countDiff; + + const depthA = getDepth(a); + const depthB = getDepth(b); + return depthB - depthA; + }); + } + + function getSubpaths(paths, prefix) { + return paths.filter((path) => path.startsWith(prefix + '.') || path === prefix); + } + + function groupPathsHierarchically(paths) { + const remainingPaths = [...paths]; + const result = []; + + const commonPrefixes = analyzePrefixes(paths); + + for (const prefix of commonPrefixes) { + const matchingPaths = getSubpaths(remainingPaths, prefix); + + if (matchingPaths.length >= minGroupSize) { + for (const path of matchingPaths) { + const index = remainingPaths.indexOf(path); + if (index !== -1) { + remainingPaths.splice(index, 1); + } + } + + result.push(`${prefix} (${matchingPaths.length} diffs)`); + } + } + + result.push(...remainingPaths); + + return result; + } + + const groupedPaths = groupPathsHierarchically(paths); + return groupedPaths.join(', '); +} + +export function getDifferingKeysByCategory(origMethod, modMethod) { + const result = { + valueDiscrepancies: [], + }; + + const differences = compareIgnoringFormatting(origMethod, modMethod) || []; + + // Process differences from comparison + processDifferences(differences, origMethod, modMethod, result); + + // Find missing keys in original method + findMissingKeys('', origMethod, modMethod, result); + + return result; +} + +function processDifferences(differences, origMethod, modMethod, result) { + for (const difference of differences) { + if (!difference.path) continue; + + const fullPath = difference.path.join('.'); + + if (shouldSkipDifferencePath(fullPath)) continue; + + categorizeDifference(fullPath, origMethod, modMethod, result); + } +} + +function shouldSkipDifferencePath(fullPath) { + return !fullPath || fullPath.startsWith('name') || shouldSkipPath(fullPath); +} + +function categorizeDifference(fullPath, origMethod, modMethod, result) { + const existsInOrig = hasKey(origMethod, fullPath); + const existsInMod = hasKey(modMethod, fullPath); + + if (existsInOrig && existsInMod) { + result.valueDiscrepancies.push(fullPath); + } +} + +function findMissingKeys(prefix, orig, mod, result) { + for (const key in orig) { + if (shouldSkipKey(key)) continue; + + const newPrefix = buildPath(prefix, key); + + if (shouldSkipMissingKeyPath(newPrefix)) continue; + + if (isKeyMissing(key, mod)) { + result.valueDiscrepancies.push(newPrefix); + } else if (shouldRecurseIntoObjects(orig[key], mod[key])) { + findMissingKeys(newPrefix, orig[key], mod[key], result); + } + } +} + +function buildPath(prefix, key) { + return prefix ? `${prefix}.${key}` : key; +} + +function shouldSkipMissingKeyPath(path) { + return path === 'name' || shouldSkipPath(path); +} + +function isKeyMissing(key, obj) { + return !(key in obj); +} + +function shouldRecurseIntoObjects(origValue, modValue) { + return isNonArrayObject(origValue) && isNonArrayObject(modValue); +} + +function isNonArrayObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function getDifferingKeys(origMethod, modMethod) { + const { valueDiscrepancies } = getDifferingKeysByCategory(origMethod, modMethod); + return [...new Set(valueDiscrepancies)]; +}