diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7677470 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Node.js CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b729dcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* + +# Editors +.vscode/ + +# Dependency directories +node_modules/ + +# Optional test coverage reports +coverage/ + +# Optional npm cache directory +.npm + +# Output of 'npm pack' +*.tgz + +# OS files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +[Dd]esktop.ini +.DS_Store +.localized +__MACOSX/ +.AppleDouble +.LSOverride +Icon[] diff --git a/README.md b/README.md index f7e30c9..1317c31 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ # jsonkdiff -Compare multiple JSON files and find non-common keys. Generates a per-file report of unique keys to help identify schema drift. + +![Build status](https://github.com/grrtbrtr/jsonkdiff/actions/workflows/test.yml/badge.svg) +![NPM version](https://img.shields.io/npm/v/jsonkdiff) + +A lightweight, zero-dependency CLI tool to compare multiple JSON files and identify missing (non-common) keys. +It generates a per-file report of unique keys to help identify schema drift. + +Perfect for auditing translation files, config sets, or API mocks. + +## Features + +- **Deep comparison**: Recursively traverses objects to find missing keys at any nesting level. +- **Compare many files**: Compares 2 or more files, using the union of all keys as the "master" schema. +- **Per-file reports**: Aggregates all unique keys and tells you exactly what is missing from each file. +- **Zero dependencies**: Built with pure Node.js. + +## Installation & usage + +### Run without installing + +```bash +npx jsonkdiff file1.json file2.json file3.json +``` + +### Global installation + +```bash +npm install -g jsonkdiff +jsonkdiff file1.json file2.json file3.json +``` + +## Example output + +If `en.json` has `{"auth": {"login": "Log In"}}` and `es.json` is empty, `jsonkdiff` will report: +```JSON +{ + "es.json": [ + "auth", + "auth.login" + ] +} +``` + +## License +[GPL-3.0-only](./LICENSE) \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..76164d0 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { compareKeys } from './../src/index.js'; + +const STYLES = Object.freeze({ + reset: '\x1b[0m', + bold: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m' +}); + +const files = process.argv.slice(2); + +if (files.length < 2) { + console.log(`\n${STYLES.bold}Usage:${STYLES.reset} jsonkdiff ...`); + process.exit(0); +} + +async function main() { + try { + const report = await compareKeys(files); + + console.log(`\n${STYLES.blue}${STYLES.bold}--- JSON Key Diff Report ---${STYLES.reset}\n`); + + let hasIssues = false; + for (const [fileName, missing] of Object.entries(report)) { + if (missing.length > 0) { + hasIssues = true; + console.log(`${STYLES.yellow}File: ${fileName}${STYLES.reset}`); + missing.forEach(key => { + console.log(` ${STYLES.red}× Missing key:${STYLES.reset} ${key}`); + }); + console.log(''); + } + } + + if (!hasIssues) { + console.log(`${STYLES.green}✔ All files share the exact same keys!${STYLES.reset}\n`); + process.exit(0); + } else { + process.exit(1); + } + } catch (err) { + if (err.code === 'ENOENT') { + console.error(`${STYLES.red}${STYLES.bold}Error:${STYLES.reset} Could not find file "${err.path}"`); + } else if (err instanceof SyntaxError) { + console.error(`${STYLES.red}${STYLES.bold}Error:${STYLES.reset} Invalid JSON in one of the files.`); + } else if (err.code === 'EISDIR') { + console.error(`${STYLES.red}${STYLES.bold}Error:${STYLES.reset} One of the given paths is a directory, not a file.`); + } else { + console.error(`${STYLES.red}${STYLES.bold}Error:${STYLES.reset} ${err.message}`); + } + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..54202d8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "jsonkdiff", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jsonkdiff", + "version": "1.0.0", + "license": "GPL-3.0-only", + "bin": { + "jsonkdiff": "bin/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..afc1deb --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "jsonkdiff", + "version": "1.0.0", + "description": "Compare multiple JSON files and find non-common keys. Generates a per-file report of unique keys to help identify schema drift.", + "keywords": [ + "json", + "json-keys", + "json-comparison", + "diff", + "schema", + "schema-drift", + "comparison", + "cli", + "devtools" + ], + "homepage": "https://github.com/grrtbrtr/jsonkdiff#readme", + "bugs": { + "url": "https://github.com/grrtbrtr/jsonkdiff/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/grrtbrtr/jsonkdiff.git" + }, + "license": "GPL-3.0-only", + "author": "Gerrit Bertier ", + "type": "module", + "main": "src/index.js", + "bin": { + "jsonkdiff": "bin/cli.js" + }, + "files": [ + "src/", + "bin/", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "test": "node --test \"test/**/*.test.js\"", + "start": "node ./bin/cli.js", + "prepublishOnly": "npm test" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..80ca10b --- /dev/null +++ b/src/index.js @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { getDeepKeys } from './utils/object-utils.js'; + +/** + * Identify missing keys across multiple datasets. + * It aggregates all unique keys found across all objects and identifies + * which specific keys are absent from each individual object. + * + * @param {Array.<{name: string, object: object}>} datasets - An array of objects containing a name and the actual object. + * @returns {Object.} A report where keys are the object names and values are arrays of missing key paths. + */ +export function computeKeyDiff(datasets) { + const allKeys = new Set(); + const processedData = datasets.map(ds => { + const keys = getDeepKeys(ds.object); + keys.forEach(k => allKeys.add(k)); + return { name: ds.name, keys }; + }); + + const report = {}; + for (const item of processedData) { + report[item.name] = [...allKeys].filter(k => !item.keys.includes(k)); + } + return report; +} + +/** + * Reads multiple JSON files and performs a key-level diff. + * @async + * + * @param {string[]} filePaths - An array of relative or absolute paths to JSON files. + * @returns {Promise>} A promise that resolves to the diff report. + * @throws {Error} Throws an error if any file cannot be read or parsed as valid JSON. + */ +export async function compareKeys(filePaths) { + const datasets = await Promise.all(filePaths.map(async (filePath) => { + const absPath = path.resolve(filePath); + const file = await fs.readFile(absPath, 'utf-8'); + return { + name: filePath, + object: JSON.parse(file) + }; + })); + + return computeKeyDiff(datasets); +} \ No newline at end of file diff --git a/src/utils/object-utils.js b/src/utils/object-utils.js new file mode 100644 index 0000000..180dc3e --- /dev/null +++ b/src/utils/object-utils.js @@ -0,0 +1,20 @@ +/** + * Recursively flattens an object into a list of key paths. + * Example: { a: { b: 1 } } -> ["a", "a.b"] + * @param {object} obj - The object to traverse and flatten. + * @param {string} [prefix] - The prefix to append to the key (used for nesting). + * + * @returns {Array.} An array of keys. + */ +export function getDeepKeys(obj, prefix = '') { + let keys = []; + for (const key in obj) { + const fullPath = prefix ? `${prefix}.${key}` : key; + keys.push(fullPath); + + if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { + keys = keys.concat(getDeepKeys(obj[key], fullPath)); + } + } + return keys; +} \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..4fc267a --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert'; +import test from 'node:test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { compareKeys, computeKeyDiff } from './../src/index.js'; + +test('Unit: computeKeyDiff', async (t) => { + await t.test('identifies missing keys between two files', () => { + const datasets = [ + { name: 'A.json', object: { id: 1, name: 'test' } }, + { name: 'B.json', object: { id: 2 } } + ]; + const result = computeKeyDiff(datasets); + + assert.deepStrictEqual(result['A.json'], []); + assert.deepStrictEqual(result['B.json'], ['name']); + }); + + await t.test('identifies keys missing from both sides simultaneously', () => { + const datasets = [ + { name: 'A.json', object: { onlyA: 1 } }, + { name: 'B.json', object: { onlyB: 1 } } + ]; + const result = computeKeyDiff(datasets); + + assert.deepStrictEqual(result['A.json'], ['onlyB']); + assert.deepStrictEqual(result['B.json'], ['onlyA']); + }); +}); + +test('Integration: compareKeys', async (t) => { + const file1 = path.join(os.tmpdir(), `jsonkdiff_1_${Date.now()}.json`); + const file2 = path.join(os.tmpdir(), `jsonkdiff_2_${Date.now()}.json`); + + try { + await fs.writeFile(file1, JSON.stringify({ active: true, meta: { id: 1 } })); + await fs.writeFile(file2, JSON.stringify({ active: true })); + + await t.test('reads files and returns correct diff', async () => { + const report = await compareKeys([file1, file2]); + assert.ok(report[file2].includes('meta')); + assert.ok(report[file2].includes('meta.id')); + }); + + await t.test('throws error on invalid JSON', async () => { + const brokenFile = path.join(os.tmpdir(), `jsonkdiff_broken_${Date.now()}.json`); + await fs.writeFile(brokenFile, '{ invalid json }'); + + await assert.rejects( + async () => { + await compareKeys([brokenFile]); + }, + { + name: 'SyntaxError' + } + ); + + await fs.unlink(brokenFile); + }); + } finally { + await Promise.all([ + fs.unlink(file1).catch(() => {}), + fs.unlink(file2).catch(() => {}), + ]); + } +}); \ No newline at end of file diff --git a/test/utils/object-utils.test.js b/test/utils/object-utils.test.js new file mode 100644 index 0000000..a877069 --- /dev/null +++ b/test/utils/object-utils.test.js @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert'; + +import { getDeepKeys } from './../../src/utils/object-utils.js'; + +test('Unit: getDeepKeys', async (t) => { + await t.test('flattens nested objects', () => { + const input = { a: { b: { c: 1 } } }; + const expected = ['a', 'a.b', 'a.b.c']; + assert.deepStrictEqual(getDeepKeys(input), expected); + }); + + await t.test('handles empty objects', () => { + assert.deepStrictEqual(getDeepKeys({}), []); + }); + + await t.test('handles null values without crashing', () => { + const input = { a: null, b: { c: null } }; + const expected = ['a', 'b', 'b.c']; + assert.deepStrictEqual(getDeepKeys(input), expected); + }); + + await t.test('treats arrays as leaf nodes (values)', () => { + const input = { list: [1, 2, 3] }; + const expected = ['list']; + assert.deepStrictEqual(getDeepKeys(input), expected); + }); +}); \ No newline at end of file