diff #
Compare two versions of a VI: added/removed terminals, changed operations, and
rewired connections. diff never requires primitive or vi.lib mappings, so it
works on any two VIs out of the box. Useful in code review and CI to see what
actually changed in a binary .vi file, not just that it changed.
Output is picked along two independent axes: --format selects the
serialization (text, json, or html), and -v/--verbose selects the
detail level within text. They compose independently — --format never
implies a detail level, and --verbose has no effect on json or html.
Both text tiers, and json/html, project the SAME UID-keyed change map —
diff matches nodes and structures by their stable LabVIEW UID (not by name),
so an added or removed instance of a repeated node name is never confused with
a rewire of an unrelated one, and a node merely wrapped in a new structure or
handed a fresh UID never shows up as a phantom change. -v/--verbose only
adds DEPTH to the same set of changes — it never shows a different diff.
Synopsis #
lvkit diff <vi_a> <vi_b> [options]Arguments #
| Argument | Description |
|---|---|
vi_a | Path to the first .vi file. |
vi_b | Path to the second .vi file. |
Options #
| Option | Description |
|---|---|
--format {text,json,html} | Output serialization. text (default) is a concise, logical change summary — node/structure add/remove, wire connectivity, constant value changes — printed to stdout. json serializes the diff engine's UID-correlated change map (ChangeMap.to_dict()) — for scripts, CI, or an AI agent reading the diff. html writes a self-contained interactive diff viewer to a file. |
-v, --verbose | Show the change summary in full depth: the VI's own connector-pane changes (a connector-pane terminal added/removed/retyped — verbose-only, unlike Properties, which always show), old→new detail on modified values, and a trailing unchanged-node tally. Both tiers already show the full recursive containment tree — --verbose adds depth, never a different shape. Only affects --format text — a detail level, not a format. |
--long | Back-compat alias for --verbose. |
-o FILE, --output FILE | Output file path. Used by --format html (default outputs/vi-diff/<stemA>__<stemB>.html when omitted) and --format json (prints to stdout instead when omitted). Has no effect on --format text, which always goes to stdout. |
--open | Render --format html and open it in a browser. With no explicit --format, --open resolves the format to html; combined with --format text or --format json it's an error (Error: --open requires --format html, exit 1). |
--load-mode {none,minimal,full} | How deep to load dependencies when resolving SubVI signatures. Default minimal — enough to compare the diagram accurately without walking the full SubVI tree. |
| SubVI resolution flags | --search-path, --project-root, --vilib, --userlib, --no-auto-vilib — see SubVI & vi.lib resolution. |
--format is becoming lvkit's house convention for output selection across
subcommands (a single flag for mutually exclusive output projections, rather
than one boolean per format); other commands' existing --json booleans will
migrate to it over time, with --json kept as an alias.
Example #
text output is a single recursive containment tree, rendered as a
netlist — the same node-first, named-port syntax describe --verbose's
## Netlist section uses (see Netlist). Every change reads
inline as a change tag (+/-/~) in column 0, followed by netlist syntax
at whatever depth its own containment puts it: a structure's own line reads
case (selector): (or while (...):/for (...):/sequence:), and a
node's own line reads name(port=net, ...) -> outNets — the node leads,
each wired input bound to its source net by port name, never by position. A
structure is a recursion point: its own scope line, then a quoted
sub-header per affected frame ("Default":), with that frame's changes
indented beneath it on their own lines, recursing into nested structures. An
unchanged structure that merely CONTAINS a change still prints its own scope
line (with a space gutter, not a change tag), so nested changes always show
their enclosing case (...): context. There are no
Operations:/Wiring:/Structures: sections. Siblings inside a container
print in the VI's own dataflow order (the order the current/head version
places them), not grouped by change kind — a genuinely new node can print
before a genuinely removed one, or the reverse, depending on where each one
falls.
lvkit diff "Convert File Extension (String)__ogtk.vi" "Convert File Extension (Path)__ogtk.vi" \
--search-path samples/OpenG/extracted+ Strip Path(path=file name) -> stripped path, name
+ Convert File Extension (String)__ogtk.vi(new ending (none)=new ending (none), file name=name) -> prev ending, new filename
+ Build Path(name or relative path=new filename, base path=stripped path) -> appended path
- Match Pattern#1(string=file name, regular expression='\\.[~\\.]*$') -> before substring, match substring, after substring, offset past match
- case (new ending (none)):
"Default":
- Match Pattern#2(string=new ending (none), regular expression='^\\.') -> before substring, match substring, after substring, offset past match
- Less Than 0?(x=Match Pattern#2.offset past match) -> result
- Select(f_value='%s', selector=Less Than 0?.result, t_value='.%s') -> result
- Format String(0=Select.result, 1=Match Pattern#1.before substring, 5=new ending (none)) -> Format String.2, Format String.4
- = (unnamed str) = "'\\\\.[~\\\\.]*$'"The renamed VI now calls the old one as a SubVI (+ Convert File Extension (String)__ogtk.vi(new ending (none)=new ending (none), file name=name) -> prev ending, new filename) instead of inlining its old case (new ending (none)): logic (- case (new ending (none)):) — everything the removed
case used to contain (Match Pattern#2, Less Than 0?, Select, Format String) nests under its "Default": frame, indented beneath the
structure's own line. The OTHER Match Pattern instance (Match Pattern#1)
— the one that lived outside the case and is unrelated to it — sits at the
top level instead, right alongside the genuinely new/removed nodes. Note the
ordering: the three ADDED nodes print first — that's where the renamed VI's
own dataflow places them — then the removed case and the removed Match Pattern#1, last because they exist only in the OLD version and have no
position in the new one's order.
Pass -v/--verbose (or its back-compat alias --long) for the SAME tree,
plus more depth:
lvkit diff "Convert File Extension (String)__ogtk.vi" "Convert File Extension (Path)__ogtk.vi" \
--search-path samples/OpenG/extracted --verbose Connector pane:
~ ▭ input file name: String -> Path
~ ▭ output new filename: String -> Path
Block Diagram:
+ Strip Path(path=file name) -> stripped path, name
+ Convert File Extension (String)__ogtk.vi(new ending (none)=new ending (none), file name=name) -> prev ending, new filename
+ Build Path(name or relative path=new filename, base path=stripped path) -> appended path
- Match Pattern#1(string=file name, regular expression='\\.[~\\.]*$') -> before substring, match substring, after substring, offset past match
- case (new ending (none)):
"Default":
- Match Pattern#2(string=new ending (none), regular expression='^\\.') -> before substring, match substring, after substring, offset past match
- Less Than 0?(x=Match Pattern#2.offset past match) -> result
- Select(f_value='%s', selector=Less Than 0?.result, t_value='.%s') -> result
- Format String(0=Select.result, 1=Match Pattern#1.before substring, 5=new ending (none)) -> Format String.2, Format String.4
- ○ String constant = '^\\.'
- ○ String constant = '.%s'
- ○ String constant = '%s'
~ ▭ indicator new filename: String -> Path
~ ▭ control file name: String -> Path
- ○ String constant = '\\.[~\\.]*$'When a VI has VI-level changes, the tree reads as three parallel group folders,
each present only if it has changes: Connector pane: (terminal
add/remove/retype), Properties: (VI Properties settings, ordered to match
the properties popover), then Block Diagram: (all the node/wire/constant/
structure changes). Their leaves indent one level under the header. A pure
block-diagram diff (no VI-level changes) skips the folders and renders its rows
flush, as before.
--verbose doesn't change WHICH changes are reported, or how they're
nested — it's the exact same containment tree either way. It only adds:
kind="connector_pane" rows for the VI's own connector-pane interface (a
distinct concern the change map doesn't cover otherwise) — here, the two
retyped connector-pane terminals under the Connector pane: folder LEADING
the tree, since the connector pane is the only kind gated to verbose-only
(Properties rows, when present, show in both tiers — see
Options; VI health is never a diff row at all, in either tier —
see JSON output); old→new detail on a modified constant's
value instead of just its name; and, when at least one other change is
present, a trailing (N unchanged nodes) tally AFTER the tree. This pair
happens to share no unchanged nodes at all, so no tally appears in either
tier.
The +/-/~ gutter (column 0) is orthogonal to WHAT changed — the same
tag prefixes a structure's case (...): line, a node's name(port=net, ...) -> outNets line, and a constant's = name = value line alike. A
frame/value-kind change (a whole case/sequence frame added, removed, or
reselected) has no line of its own: its tag folds straight onto the quoted
frame header it decorates (e.g. + "4": for a brand-new case value with
content added inside it) instead of appearing as a separate line — that line
already says everything a repeated one would. A wire whose sink is otherwise
unchanged but sits alongside an added/removed node at the same containment
depth is suppressed outright — that node's own name(port=net, ...) -> outNets line already shows the net inline, so a standalone wire line would
just repeat it. A wire change with no such sibling — an unchanged node
losing or gaining a connection, with no node add/remove alongside it — still
renders its own sink = source line, and a REMOVED wire always renders even
when nothing else at that containment depth changed; see Netlist: Diff
annotations for a worked example. (A constant
edited IN PLACE — same UID, new value — reads as its own instance line like
any other modified node, since it's tracked in the UID-keyed change map by
its stable UID; only an ADDED or REMOVED constant, which has no UID of its
own to key on, goes through the separate name/value-matched list and keeps
the dedicated = marker, e.g. + = name = value.)
If the two VIs are identical, --format text (both the default and
--verbose) prints a single line, No changes detected., instead of an empty
diff/report. --format json and --format html still produce output when the
VIs are identical — an empty changes list, or a viewer with nothing in the
change list to select.
JSON output #
lvkit diff "Convert File Extension (String)__ogtk.vi" "Convert File Extension (Path)__ogtk.vi" \
--search-path samples/OpenG/extracted --format jsonPrints the diff engine's ChangeMap as JSON: {"changes": [...], "common_nodes": N} — no other top-level keys. changes has one entry per added/removed/
modified node, wire, structure, constant, FP control/indicator (kind: "terminal"), or VI-level facet (kind: "property"/"connector_pane" — the
VI's own Properties/connector-pane interface, rendered as ordinary entries
here too, unlike the text report where the connector pane is verbose-only).
Each entry has uid, full_id, kind, change (added/removed/
modified), label, detail, and diagram geometry (bounds, path, and
for a modified element the prior-version bounds_before/path_before —
always null for a property/connector_pane entry, which has no diagram
element of its own). A property entry's uid is property:{raw_field} (the
underlying VIProperties/KindProps field name, e.g. property:lock_state
— not its curated label, e.g. "lock"); common_nodes is the count of
nodes matched unchanged across both VIs. VI health is never diffed — it's
an emergent characteristic, not an authored change (describe/the index
still surface it) — so no kind: "health" entry, and no health:-prefixed
uid, ever appears in this JSON. This is the same change map --format html
renders into the viewer below, and what an editor integration or AI agent
should parse instead of scraping text output.
Interactive diff viewer #
lvkit diff "Convert File Extension (String)__ogtk.vi" "Convert File Extension (Path)__ogtk.vi" \
--search-path samples/OpenG/extracted --openThis writes outputs/vi-diff/Convert File Extension (String)__ogtk__Convert File Extension (Path)__ogtk.html
and opens it in the browser. The file is a single self-contained HTML page —
no server, no external JS/CSS — so it works equally well opened locally, sent
in a review, or hosted as a static CI artifact.
- Four view modes, switchable from the toolbar: overlay (the default — overlays both VIs with an opacity slider), before, after, and split (two panes — before above, after below — that pan and zoom together).
- A numbered change list in the sidebar covers every added/removed/modified node or wire; each entry's number is drawn as a badge on the diagram next to the element it correlates with.
- Clicking a change in the list (or its badge on the diagram) jumps to it;
prev/next buttons, or the
p/nkeys, step through the list in order. - Selecting a change spotlights it — the rest of the diagram dims — and zooms/centers on it. If the change lives inside a case or sequence frame that isn't the one currently showing, the viewer switches that frame into view first, in both panes, so a change never hides behind an unselected case.
- Independent zoom (toolbar buttons,
+/-keys, or Ctrl/Cmd+scroll) is shared across all four view modes. - The URL fragment encodes the selected change, view mode, change-list mode,
and per-structure frame selection
(
#c=N&view=overlay&list=tree&frame=UID=VALUE), so a link into the viewer can deep-link straight to a specific change.
Flat vs. tree change list #
A Flat / Tree toggle sits above the change list. Flat (the default)
is the numbered list in engine order, one row per change. Tree regroups
the SAME list by containment — client-side, from the container_uid/
frame_path every entry already carries, no extra data and no second engine
call — into exactly the structure the text containment tree above prints:
a structure is a recursion point, its frames are quoted sub-headers, and
contained changes nest beneath them. Every row keeps its number badge and
click-to-select behavior in either mode — switching modes only changes how
the list is grouped, never which change a click selects or what it does.
Exit codes #
diff exits 0 whenever it successfully compares the two VIs — including
when they're identical. The exit code does not tell you whether the VIs
differ; a CI step that needs to gate on "did this VI change" has to check the
printed output (No changes detected. vs. a diff/report), or parse the
json/html output, rather than the exit code.
diff exits 1 when vi_a or vi_b doesn't exist, when --open is
combined with --format text/--format json, or when loading either VI
raises a ValueError, FileNotFoundError, or KeyError (for example, a path
that isn't a .vi file) — each of these prints a one-line Error: ...
message to stderr. A .vi that pylabview itself can't parse (corrupt or
truncated data) raises RuntimeError, which isn't one of the caught types:
diff still exits 1, but stderr gets a full Python traceback instead of a
clean Error: line.
Notes #
diffcompares two individual.vifiles. To cover a change set in CI, invoke it once per changed VI from your pipeline.diffruns without a.lvkit/resolution store — pass--project-rootif one exists, butdiffdoesn't requiresetupto have been run first.- If a SubVI referenced by either VI isn't found on
--search-path,diffdoesn't fail: that SubVI is recorded as an unresolved dependency and the comparison proceeds with what could be loaded. - Because
vi.libauto-detection uses whatever LabVIEW is installed on the machine (see SubVI & vi.lib resolution), pass--no-auto-vilibfor a diff that's identical across machines and in CI. - Also available one-click, with no CLI, as the VS Code extension's Open Visual Diff command.
See also #
- Netlist — the node-first, named-port grammar this page's
textoutput renders, documented in full (including the diff-specific gutter/suppression rules). - describe — a snapshot of a single VI's signature and operations.
- render — a faithful diagram of a single VI, for visual comparison.
- structure — the same terminal/operation/wiring breakdown for
.lvlib/.lvclassmembers. - SubVI & vi.lib resolution — the resolution flags
diffshares with the other commands.