Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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[]
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)
58 changes: 58 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -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 <file1.json> <file2.json> ...`);
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();
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 <gerrit.bertier@gmail.com>",
"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"
}
}
48 changes: 48 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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.<string, string[]>} 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<Object.<string, string[]>>} 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);
}
20 changes: 20 additions & 0 deletions src/utils/object-utils.js
Original file line number Diff line number Diff line change
@@ -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.<string>} 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;
}
68 changes: 68 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
@@ -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(() => {}),
]);
}
});
Loading