v0.1.0 · lossless, redaction-safe

This JSON changed. Could it break a client?

Compare two JSON documents and see what moved, how much it matters, and get a diff you can paste into a pull request without leaking a secret.

npm install --save-dev m2jsonlens
baseline.jsoncandidate.jsonbreaking
  • data/order/auth/token

    changed: string [REDACTED] → string [REDACTED]

  • additive/order/currency

    added: absent → string "AED"

  • data/order/customer/email

    changed: string [REDACTED] → string [REDACTED]

  • breaking/order/customer/phone

    type changed: null [REDACTED] → string [REDACTED]

Real output from compare. 9 differences, secrets redacted.

0runtime dependencies
Node ≥18ESM and CommonJS, ships its own types
3impact levels: breaking, additive, data
0 · 1 · 2equal, differences, bad input

Impact levels

A diff tells you what changed. This tells you what matters.

Every change carries a kind and an impact, decided from the shape of the data, so you can fail a build on breaking changes and still see everything else.

Breaking

A client can fail.

An object field was removed, or a value changed type, including to or from null.

price: 5 → "5.00"

Additive

Existing clients are fine.

A new object field appeared. Anything written against the baseline keeps working.

added currency

Data

Same shape, new content.

A scalar changed, or an array element was added, removed or edited. Often expected, still worth seeing.

Structural only: it can't know what is semantic for you.

What you get

The diff your reviewer and your pipeline both trust.

  • Missing, null and a type change are different things

    A side that doesn't exist is absent, not null. Every difference is classed breaking, additive or data, so a reviewer knows what to look at first.

  • Exact numbers

    Integers beyond 2^53 stay exact, and 1, 1.0 and 1e0 are equal. JSON.parse would round the first and can't tell the others apart.

  • Redaction that holds

    Listed paths never appear in text, JSON, added subtrees or errors. No hashes either, since hashes of weak secrets can be brute-forced.

  • Array order is your call

    Ordered by default. Switch a path to unordered, or key it by a field like sku, and reordering stops being noise.

  • Schema diagnostics

    Optional Ajv-backed validation that explains what's wrong without echoing the document's values.

  • Built for CI

    Exit codes 0, 1 and 2, stable sorted JSON, stdin support and a plain-JSON config that rejects typos. Zero runtime dependencies.

Workflow

Compare, redact, gate.

Point it at a saved golden response and a fresh one. It reads both into memory, so it is built for API responses and configs, not multi-gigabyte streams.

Compare

Parse both documents losslessly and walk them together.

  1. 1
    m2jsonlens diff a.json b.json

Redact

Hide only what you listed, so the output is safe to paste.

  1. 2
    --redact /auth/token

Gate

Let the exit code decide whether the pull request can merge.

  1. 3
    --fail-on breaking

Paths are not redacted: object keys appear in them. Read the redaction guarantees and limits →

How it works

From the terminal, from a config file, from code.

  1. 1

    Diff two files

    Use - to read one side from stdin. Exit codes tell your pipeline what happened.

    sh
    m2jsonlens diff baseline.json candidate.json --config m2jsonlens.config.json
    curl -s $URL | m2jsonlens diff golden.json -      # "-" reads one side from stdin
    m2jsonlens validate response.json --schema orders.schema.json
  2. 2

    Keep the rules in a config

    Plain JSON, never code. Unknown keys are errors, so typos are caught.

    json
    {
      "ignore": ["/requestId", "**/updatedAt"],
      "redact": ["/order/auth/token", "/order/customer/email"],
      "arrays": {
        "default": "ordered",
        "rules": [
          { "path": "/order/items", "mode": "keyed", "key": "sku" },
          { "path": "/order/tags", "mode": "unordered" }
        ]
      },
      "failOn": "breaking"
    }
  3. 3

    Or call the library

    The result is plain data you can filter and test. ESM and CommonJS are both shipped.

    ts
    import { compare, formatText, parseJson } from "m2jsonlens";
    
    declare const baselineText: string;
    declare const candidateText: string;
    
    const result = compare(parseJson(baselineText), parseJson(candidateText), {
      ignore: ["/requestId"],
      redact: ["/auth/token"],
      arrays: { rules: [{ path: "/items", mode: "keyed", key: "sku" }] },
    });
    
    if (result.summary.breaking > 0) console.error(formatText(result));
    yaml
    - run: npm ci
    - run: npx m2jsonlens diff golden.json <(curl -s $URL) --config m2jsonlens.config.json --fail-on breaking

Playground

Edit either document. The diff follows as you type.

This runs the m2jsonlens source from this repository in your browser. Nothing is mocked and nothing is uploaded.

Breaking changes3 breaking · 1 additive · 5 data
  • datachanged/order/auth/token

    string [REDACTED] → string [REDACTED]

  • additiveadded/order/currency

    (absent) → string "AED"

  • datachanged/order/customer/email

    string [REDACTED] → string [REDACTED]

  • breakingtype changed/order/customer/phone

    null [REDACTED] → string [REDACTED]

  • breakingremoved/order/discount

    null null → (absent)

  • datachanged/order/id

    number "9007199254740993" → number 9007199254740992

  • datachanged/order/items/sku=A-1/qty

    number 2 → number 3

  • breakingtype changed/order/items/sku=B-7/price

    number 5 → string "5.00"

  • dataadded/order/items/sku=C-3

    (absent) → object {"sku":"C-3","qty":1,"price":1}

A field disappeared, a number became a string and a phone went from null to a value. Secrets are listed in redact, so their values never appear, even though the token changed. Items are matched by sku, so reordering is not a difference. How the comparison works →

The code behind this panel
ts
import { compare, formatText, parseJson, type CompareOptions, type CompareResult } from "m2jsonlens";

export type Input = {
  baseline: string;
  candidate: string;
  options: CompareOptions;
};

export type Outcome = { result: CompareResult; text: string };

/** The same functions the CLI uses. Both documents are parsed losslessly, so large numbers stay exact. */
export function diff({ baseline, candidate, options }: Input): Outcome {
  const result = compare(parseJson(baseline), parseJson(candidate), options);
  return { result, text: formatText(result) };
}

Options

A handful of rules decide what counts as a difference.

The defaults assume nothing: array order matters, and no field is treated as a secret until you say so.

  • ignorePaths excluded from comparison. * matches one segment, ** matches any number.
  • redactPaths whose values never appear anywhere in the output. Nothing is guessed.
  • arrays: orderedThe default. Array order is significant unless a rule says otherwise.
  • arrays: unorderedElements match as a multiset, duplicates counted.
  • arrays: keyedElements match by a field such as sku, and diff at field level.
  • --fail-onany, breaking or none: decides which differences make the exit code non-zero.

See the full semantics →

Documentation

Everything is written down.

More from M2Toolkit