Semantic diff for DMN files. Compares two .dmn files and produces a structured representation of their differences — ID-based identity, severity classification, multiple output formats, and native CI/CD integration.
DMN 1.0 → 1.5 · MIT licensed · 1 runtime dependency (@veridtools/dmn-parser)
DMN files are XML, but diffing them as text is noisy. Reordering decision table rules, reformatting whitespace, moving DMNDI diagram coordinates — all of these produce textual diffs that carry zero semantic meaning. Code review tools can't tell which changes actually affect runtime behaviour and which are cosmetic edits.
dmn-diff understands the structure of DMN. It matches elements by their @id attribute, not position, so reordering rules produces zero diff. It classifies every field change by severity — breaking, non-breaking, or cosmetic — so reviewers can focus on what matters. It strips DMNDI, normalises encoding artefacts (BOM, CDATA, hitPolicy casing), and exposes a clean JSON result for tooling.
The result is a diff you can act on: block PRs on breaking changes, flag non-breaking changes for review, and auto-approve cosmetic-only commits.
- ID-based identity — elements matched by
@id, not position; reordering rules produces zero diff - Severity classification — every field change is
breaking,non-breaking, orcosmetic - Four output modes — semantic prose, row-diff table, JSON, raw XML diff
- CI/CD native — exit code
1on changes,0when clean; pipe-friendly output - Full normalization — CDATA vs entities, empty wildcards,
hitPolicydefaults, BOM, DMNDI exclusion
pnpm add @veridtools/dmn-diff
# or
npm install @veridtools/dmn-diffFor global CLI use:
npm install -g @veridtools/dmn-diffdmn-diff before.dmn after.dmn # semantic output (default)
dmn-diff before.dmn after.dmn --rows # or -r # row-diff table with +/-
dmn-diff before.dmn after.dmn --json # or -j # structured JSON
dmn-diff before.dmn after.dmn --xml # or -x # raw XML line diff (colored)
dmn-diff before.dmn after.dmn --no-color # or -n # disable ANSI colors
dmn-diff before.dmn after.dmn --skip-breaking # or -s # show diff, always exit 0
dmn-diff --help # or -h # show banner, version, optionsExit code 1 when changes are detected — works as a CI gate out of the box:
Clone the repo and run against the bundled examples:
git clone https://github.com/veridtools/dmn-diff
cd dmn-diff
npm install
npx tsx bin/dmn-diff.ts src/examples/a.dmn src/examples/b.dmn
npx tsx bin/dmn-diff.ts src/examples/a.dmn src/examples/b.dmn --rows
npx tsx bin/dmn-diff.ts src/examples/a.dmn src/examples/b.dmn --json
npx tsx bin/dmn-diff.ts src/examples/a.dmn src/examples/b.dmn --xml
npx tsx bin/dmn-diff.ts src/examples/a.dmn src/examples/b.dmn --skip-breakingdmn-diff main.dmn feature.dmn || echo "DMN changes detected"- name: Check DMN changes
run: |
dmn-diff main.dmn feature.dmn --json > diff.json
node -e "
const r = require('./diff.json');
const breaking = r.changes.flatMap(c => c.fieldChanges ?? []).filter(f => f.severity === 'breaking');
if (breaking.length) { console.error('Breaking DMN changes detected'); process.exit(1); }
"before.dmn → after.dmn
⚠ 1 breaking change(s) detected
~ outputClause "riskLevel" (oc1) modified
@name: level → riskLevel ⚠ BREAKING
~ inputEntry "r1ie1" (r1ie1) modified
text: > 700 → >= 700
0 added · 2 modified · 0 removed · 12 unchanged
import { diff, renderSemantic } from '@veridtools/dmn-diff'
import { readFileSync } from 'fs'
const from = readFileSync('before.dmn', 'utf-8')
const to = readFileSync('after.dmn', 'utf-8')
const result = diff(from, to, { fromFile: 'before.dmn', toFile: 'after.dmn' })
// Check for breaking changes
const hasBreaking = result.changes.some(
(c) => c.type === 'modified' && c.fieldChanges.some((f) => f.severity === 'breaking')
)
// Filter changes in a specific decision
const decisionChanges = result.changes.filter((c) => c.path.includes('decision[d1]'))
// Render
console.log(renderSemantic(result))interface DiffResult {
meta: {
fromFile: string
toFile: string
timestamp: string // ISO 8601
hasChanges: boolean
}
summary: {
added: number
removed: number
modified: number
unchanged: number
}
changes: Change[] // AddedChange | RemovedChange | ModifiedChange
}
interface ModifiedChange {
type: 'modified'
id: string
name: string // element name, falls back to id when the element has no name
elementType: ElementType
parentId?: string
path: string // e.g. "decision[d1].decisionTable[dt1].rule[r1].inputEntry[r1ie1]"
fieldChanges: FieldChange[]
}
interface FieldChange {
field: string // e.g. "text", "@name", "inputExpression.@typeRef"
from: unknown
to: unknown
severity: 'breaking' | 'non-breaking' | 'cosmetic'
}import { renderSemantic, renderRows, renderJson, renderXml, tokenize } from '@veridtools/dmn-diff'
renderSemantic(result) // human-readable prose (default CLI output)
renderRows(result) // row-diff table with +/- per field
renderJson(result) // JSON.stringify of DiffResult
renderXml(fromXml, toXml) // raw line diff of XML source (includes DMNDI)
tokenize(result) // DiffToken[] — structured intermediate representation
detectFormat(output) // 'json' | 'semantic' | 'rows' — identify a rendered string's formattokenize accepts either a DiffResult or the string output of any renderer, and returns a flat DiffToken[] stream. Use it to build custom formatters (HTML, React, Monaco) without re-implementing diff logic, or to parse dmn-diff CLI output back into structured tokens for downstream tooling. JSON output round-trips losslessly; semantic and rows formats recover element types, ids, field changes, and severities, but not path or parentId.
Severity is assigned per field, not per element. A single modification can carry changes of mixed severity — the summary calls out the worst one.
| Severity | Meaning | Examples |
|---|---|---|
breaking |
Changes runtime evaluation — expression, type, name used as reference | decision.@name, outputClause.@name, itemDefinition.@typeRef, functionDefinition.@kind |
non-breaking |
Changes behaviour but not in a way that breaks existing consumers | inputEntry.text, outputEntry.text, decisionTable.@hitPolicy, itemDefinition.allowedValues |
cosmetic |
No runtime impact | *.@label, *.@description, textAnnotation.text, decisionTable.@preferredOrientation |
The following differences produce a zero diff result — they are encoding or tooling artefacts, not semantic changes:
| Source difference | Normalized to |
|---|---|
hitPolicy absent |
"UNIQUE" |
hitPolicy="unique" |
"UNIQUE" (uppercase) |
preferredOrientation absent |
"Rule-as-Row" |
isCollection="false" |
treated same as absent |
isCollection="true" |
true (boolean) |
Empty <inputEntry> |
"-" (DMN wildcard) |
<![CDATA[> 5]]> |
> 5 (via entity escaping) |
| UTF-8 BOM | removed |
<DMNDI> section |
removed entirely |
<extensionElements> |
removed entirely |
<typeRef>string</typeRef> (child element) |
@typeRef="string" (attribute form) |
<description>…</description> (child element) |
@description="…" (attribute form) |
| Version | Namespace |
|---|---|
| 1.1 | http://www.omg.org/spec/DMN/20151101/dmn.xsd |
| 1.2 | http://www.omg.org/spec/DMN/20180521/MODEL/ |
| 1.3 | https://www.omg.org/spec/DMN/20191111/MODEL/ |
| 1.4 | https://www.omg.org/spec/DMN/20211108/MODEL/ |
| 1.5 | https://www.omg.org/spec/DMN/20230324/MODEL/ |
DMN 1.4 boxed expressions (conditional, filter, iterator) and DMN 1.5 specifics (typeConstraint, optional import.namespace, DMN15-74 BKM variable.typeRef removal) are fully handled.
The test suite is backed by @veridtools/dmn-fixtures — a curated set of 300+ real-world DMN files covering every element type, all five DMN versions, and edge cases (BOM, CDATA, DMNDI-only changes, empty wildcards, duplicate namespace declarations). Fixture-driven tests verify both that known-equivalent variants produce zero diff and that known-different variants produce the expected change type and severity.
See CONTRIBUTING.md.
MIT
