Command-Line Tool Reference
pb-diff-cli.exe is the headless half of pb-diff:
everything the desktop application computes, without a window. It never
creates a user interface, never prompts and never waits on standard
input, so it is safe in a build step, a hook or an agent loop. Results
go to standard output, as JSON when asked. Progress and errors go to
standard error, and the outcome is reported as an exit code.
This document has two parts. The first, Using pb-diff-cli from a script or an AI agent, is for whoever writes a build step, a CI gate or a hook, or sets up an agent: the typical workflows, and how to read what comes back. The second is the complete reference, command by command, from Global options on. The workflows link to the reference rather than repeat it.
pb-diff-ci.exe is its sibling for build machines: the
same commands where it has them, no licence, no network, and answers
that stop at the level of the object. It has a part of its own below, pb-diff-ci: the tool for
build machines, and every command section says whether the command
is also in pb-diff-ci.
The desktop application is pb-diff.exe and takes a
separate switch set, for source-control clients and the PowerBuilder
IDE; see The desktop command
line in pb-diff Help. This document covers
pb-diff-cli.exe and pb-diff-ci.exe.
pb-diff-cli <command> [arguments] [options]
Using pb-diff-cli from a script or an AI agent
pb-diff-cli reads PowerBuilder libraries
(.pbl, .pbd, and libraries appended to an
.exe or .dll), exported objects and folders of
sources, compares them section by section rather than line by line, and
merges three versions of an object. It needs no PowerBuilder
installation and no ORCA.
The one rule
Always pass --format json. Text output
is written for people and may be reworded between releases; only the
JSON is a contract. Every JSON payload declares
schemaVersion: 1 and has a published schema, and
report honours --format json like every other
command. Parse the JSON; never scrape the text.
Then read the exit code before the payload, and
branch on the specific code: 0 is success or no differences, 1 is
differences found, 2 is unusable input, 3 is a merge that left
conflicts, 4 is a bug and 5 is no valid licence. Exit code 1 is an
answer, not a failure: treating "non-zero" as "something went wrong"
turns "these two releases differ" into a broken build. On exit 2
standard output is normally empty and the error object is on standard
error; branch on its code. validate is the
exception: when a library it was given is damaged, unreadable or
missing, it exits 2 with its payload on standard output and no error
envelope on standard error. See Exit codes and
Errors.
Prefer --out <file> to a pipe when another
program reads the JSON. In Git Bash on Windows, a pipe from a
.NET program such as pb-diff-cli into the Windows Python
interpreter can arrive empty:
pb-diff-cli help --format json | python -c "import json, sys; json.load(sys.stdin)"
fails at line 1, column 1, and nothing says why. Microsoft's own
dotnet command does the same, while the same pipe works in
PowerShell and cmd.exe, and cat,
wc and node read it whole in Git Bash. A file
written with --out reads the same in every shell. Nothing
else changes: read the exit code first, because on an input error the
file is empty and the error object is still on standard error, which
2> error.json keeps in a file too. For
validate's exception, --out keeps the payload
in the named file even on exit 2.
The commands
pb-diff-cli info <container> # flavour, format version, comment, counts
pb-diff-cli list <container>[!<glob>] # enumerate entries
pb-diff-cli export <container>[!<glob>] # write importable .sr* files
pb-diff-cli extract <container>[!<glob>] # write raw payloads (compiled objects, resources)
pb-diff-cli validate <path>... # libraries checked for damage; one state each
pb-diff-cli parse <file|container!entry> # the object's hierarchy: functions, events, controls
pb-diff-cli compare <old> <new> # two-way compare, section by section
pb-diff-cli compare-lib <old> <new> # summary compare of libraries, folders or files
pb-diff-cli merge3 <file1> <base> <file2> # three-way auto-merge; conflicts reported, not written
pb-diff-cli report <kind> <old> <new> # pdf, html, csv, json, rtf or markdown
pb-diff-cli check <path>... # sources left broken: conflict markers, unclosed blocks
pb-diff-cli tokens <file> | --line "<text>" --lang <lang> # the lexer behind the colouring
pb-diff-cli license [status|activate <key>|deactivate] # this machine's licence
pb-diff-cli schema [name] # the published names, or one schema
pb-diff-cli help [command] # usage; add --format json for the full catalogue
An entry inside a library is addressed as
container!entry, such as ui.pbl!w_main.srw;
see Addressing entries. The tool
describes itself, so a script or an agent can discover the surface
without reading this document:
pb-diff-cli help --format json is the whole catalogue as
data, the only machine-readable help (there is no --json
alias), and pb-diff-cli schema <name> prints the JSON
Schema of a payload.
Inventory a library before touching it
pb-diff-cli info ui.pbl --format json
pb-diff-cli list ui.pbl --kind source --format json
pb-diff-cli validate ui.pbl --format json
info gives the flavour, the format version and the
counts. list gives every entry with its kind
and a finer type; only source entries can be
exported or compared in detail, and compiled entries are
P-code that pb-diff does not decompile. validate walks the
structure: exit 0 is clean, 1 is warnings only, 2 means a library is
damaged or could not be verified. Run it before trusting a library you
did not build, and over a whole application -
pb-diff-cli validate app.pbw - to find the damaged ones
among them.
To read the code itself, export it:
pb-diff-cli export ui.pbl --out-dir ./src --format json # every source object
pb-diff-cli export "ui.pbl!*.srw" --out-dir ./windows # just the windows
pb-diff-cli export "ui.pbl!w_main.srw" --stdout # one object to standard output
Understand an object before changing it
pb-diff-cli parse w_main.srw --format json
pb-diff-cli parse "ui.pbl!w_main.srw" --lines --format json # straight out of the library
pb-diff-cli parse d_customer.srd --format json # bands, controls, database columns, properties
You get every function, event, control and script, each with a stable
key and a contentHash of its comparable text,
the cheap way to ask whether something changed. A file whose name a
source-control tool mangled is read with --detect:
pb-diff-cli parse 3f9a2c.tmp --detect H --format json # read its $PBExportHeader$, or what its content opens as
pb-diff-cli parse d_test.srd.tmp --detect F --format json # trust the first extension
See parse for what the document
holds, and what usedFallback and diagnostics
mean.
See what changed between two releases
pb-diff-cli compare-lib old/ui.pbl new/ui.pbl --changed-only --format json
pb-diff-cli compare-lib release-1.5/ui.pbl src/ws_objects --format json # a library against its source
pb-diff-cli compare "old/ui.pbl!w_main.srw" "new/ui.pbl!w_main.srw" --format json
pb-diff-cli compare old/w_main.srw new/w_main.srw --unified # a unified diff, for reading
compare-lib takes a library, a folder or a single file
on each side, and compares them as the desktop application's Compare
does: a ws_objects folder, a PowerBuilder 2025 Solution or
one of its PBL folders, a folder of exported .sr* files or
a folder holding libraries, against another or against a library. A name
two inputs on one side share is not paired by guessing; both rows are
listed, under their addresses, and notes says why.
compare-lib lists the entries with a
status, one of six: unchanged,
new, modified, deleted,
renamed-unchanged and renamed-modified. The
last two arrive only for entries you paired with
--pair old=new, because renames are never inferred;
counts folds renamed-modified into
modified, so the per-entry status is the only place a
rename reads as a rename, and a script that branches on four of the six
loses a renamed object. compare returns the object's
hierarchy with a status per node, and only the changed
nodes unless --all is given, so walking
children gives exactly what changed. See compare-lib and compare.
Gate a build on what changed
compare-lib --changed-only is the whole gate: exit 0
nothing changed, exit 1 something did, exit 2 the input was unusable.
Branch on the three, and do not collapse 1 and 2 into "failed". Either
side may be a folder, so a repository's ws_objects or
Solution is gated the same way as a library. On a build machine the same
gate runs in pb-diff-ci, which needs no licence; see pb-diff-ci. In
PowerShell, which works in Windows PowerShell 5.1 and in PowerShell
7:
$raw = pb-diff-cli compare-lib baseline/ui.pbl build/ui.pbl --changed-only --format json | Out-String
switch ($LASTEXITCODE) {
0 { 'No object changed.' }
1 { ($raw | ConvertFrom-Json).entries | ForEach-Object { '{0,-9} {1}' -f $_.status, $_.name } }
default { throw "Comparing the libraries failed with exit code $LASTEXITCODE." }
}
The same gate in a POSIX shell with jq:
out=$(pb-diff-cli compare-lib baseline/ui.pbl build/ui.pbl --changed-only --format json)
case $? in
0) echo "No object changed." ;;
1) printf '%s\n' "$out" | jq -r '.entries[] | "\(.status)\t\(.name)"' ;;
*) echo "Comparing the libraries failed." >&2; exit 2 ;;
esac
Run from the Order Desk examples installed with pb-diff
(docs/examples/order-desk beside the tool), the gate over
libraries/release-1.4/orders.pbl and
libraries/release-1.5/orders.pbl prints eleven lines, from
modified d_order_lines.srd to
deleted w_print_preview.srw. The examples folder holds
the published result of every command pb-diff Help runs, under
results/, each produced by this version of the tool from
the files beside it.
Merge three versions and check the result
pb-diff-cli merge3 mine.srw base.srw theirs.srw --output merged.srw --format json
The order is file1, base, file2: the common ancestor
is in the middle, and all three must be the same object type. Exit 0
means the merge was clean and merged.srw was written. Exit
3 means conflicts remain and nothing was written;
conflicts carries one entry per unresolved region, with the
three versions of it, so there is everything needed to decide without
reading the files again. The merge of three-way/mine,
three-way/base and three-way/theirs in the
Order Desk examples exits 3 with this entry:
{
"key": "function:of_discount(decimal)",
"name": "of_discount (decimal ad_total)",
"baseStart": 5,
"baseLength": 1,
"file1Start": 5,
"file1Length": 1,
"file2Start": 5,
"file2Length": 1,
"baseLines": [
"\treturn 0.05"
],
"file1Lines": [
"\treturn 0.06"
],
"file2Lines": [
"\treturn 0.07"
]
}
A script that stops for a decision, and shows the three versions of each region:
$raw = pb-diff-cli merge3 mine.srw base.srw theirs.srw --output merged.srw --format json | Out-String
switch ($LASTEXITCODE) {
0 { 'Merged cleanly into merged.srw.' }
3 {
foreach ($conflict in ($raw | ConvertFrom-Json).conflicts) {
"$($conflict.name), base line $($conflict.baseStart)"
" base: $($conflict.baseLines -join ' / ')"
" file1: $($conflict.file1Lines -join ' / ')"
" file2: $($conflict.file2Lines -join ' / ')"
}
}
default { throw "merge3 failed (exit code $LASTEXITCODE)." }
}
Resolving the conflicts is the caller's part, in two steps. Decide
each one from the three versions, and write the whole merged object;
never leave a conflict marker in it. Then check what was written:
pb-diff-cli compare base.srw merged.srw --format json, read
twice over. inputDiagnostics names what a parser found
wrong with either file, with side saying which and a stable
code: parse.conflict-markers if a marker was
left behind, parse.unterminated-block if a paste landed
short. The hunks are where anything else that was not meant to be
written shows up, whatever caused it.
pb-diff-cli parse merged.srw --format json gives the same
codes for one file with nothing to compare it against, and
pb-diff-cli check merged.srw --format json the codes alone,
for a whole folder at once: exit code 1 when something was found. A
diagnostic is a signal in one direction: an entry means the file is
damaged, an empty list means nothing was noticed.
--force-partial writes a file despite conflicts, taking
the base for each one; use it only when a partial result is genuinely
useful downstream. See merge3 for
what the written file is and the warnings a merge reports.
Write a report for a person
--report-format chooses the rendition a reader gets;
--format json still describes, on standard output, the file
that was written:
pb-diff-cli report summary-changes old.pbl new.pbl --output changes.html --format json
pb-diff-cli report changes old/w_main.srw new/w_main.srw --output w_main.pdf --format json
pb-diff-cli report summary-changes old.pbl src/ws_objects --report-format markdown >> summary.md
The Markdown rendition of the two summary reports is written for a CI
job summary or a pull-request comment. See report.
Run it on a build server or in CI
A CI job uses pb-diff-ci: copy
pb-diff-ci.exe to the build machine, hosted runners
included, and run it. It needs no licence and no network, and it has the
gate, the summary reports and check; see pb-diff-ci, and
Run pb-diff in CI in pb-diff Help for complete pipelines.
pb-diff-cli needs a valid licence for every command but
help, schema and license, and
without one exits 5 with LICENSE_REQUIRED before it reads
an input or writes an output. A build server that needs a command only
pb-diff-cli has - compare,
merge3, export - activates its service account
on a named subscription seat, as a person does:
pb-diff-cli license activate <key> --label "build server"
once, run as that account. The machine then verifies its licence online
at most once a day, and keeps working for 7 days without the licensing
service.
pb-diff-cli license status --format json says which
licence is in use and why a command would be refused; it exits 0 when
licensed and 5 when not. See license.
What pb-diff-cli will not do
- A workspace (
.pbw) or target (.pbt) is compared as a file, not expanded into the libraries it lists. To compare the objects, name the libraries, or the exported objects. - Libraries are read-only. No command writes into a library; a merged object goes back through PowerBuilder's own Import.
- Compiled entries are not decompiled. A
.pbd, or a library inside a built.exe, usually holds only P-code: you can learn that an object differs, never what differs. - Renames are never inferred, and pb-diff
never writes a conflict marker. One an input already carried is
copied with the rest of that input, and
merge3then calls the merge unclean. - Nothing is compiled. A merged file is structurally consistent with the merge that produced it; whether the code is correct is not a question pb-diff can answer. That PowerBuilder imports one is claimed for PowerBuilder 19 and later; 10 to 18 is expected and unverified. Reading, comparing and exporting libraries is a separate scope, certified from PowerBuilder 10 onwards.
Setting up an AI agent
An agent follows the same rules as a script. The package installs an
agent skill beside the tool, in skills/pb-diff/: operating
rules an agent loads, pointing into this document. To use it with Claude
Code, copy that folder into .claude/skills/pb-diff/ inside
the repository being worked on, or into
~/.claude/skills/pb-diff/ for every repository at once. The
skill drives the pb-diff-cli already on the machine and
finds the rest of the surface through
pb-diff-cli help --format json and
pb-diff-cli schema.
pb-diff-ci: the tool for build machines
pb-diff-ci.exe is pb-diff for a build machine. It is one
file, installed beside pb-diff-cli.exe and shipped in the
ZIP, and it runs copied on its own to any Windows machine, a hosted CI
runner included, with nothing else installed: no .NET, no PowerBuilder,
no pb-diff.
- No licence. It never checks one: it does not read
PBDIFF_LICENSE,PBDIFF_LICENSE_FILEor a machine activation, and it never answers exit code 5. - No network. It makes no network call of any kind: no licence check, no update check, no telemetry. Its single-file bundle carries no networking assembly at all.
- Never the content of an object. Its answers stop at
the object: name, type, status, sizes, dates, counts, and the codes and
line numbers of
check. No output carries a line of source, a script or the detail of a section; what changed inside an object is what pb-diff is for.
pb-diff-ci info <container> # flavour, format version, comment, counts
pb-diff-ci list <container>[!<glob>] # enumerate entries
pb-diff-ci validate <path>... # libraries checked for damage; one state each
pb-diff-ci compare-lib <old> <new> # summary compare of libraries, folders or files
pb-diff-ci report <summary|summary-changes> <old> <new> # pdf, html, csv, json, rtf or markdown
pb-diff-ci check <path>... # sources left broken
pb-diff-ci schema [name] # the published names, or one schema
pb-diff-ci help [command] # usage; add --format json for its catalogue
The same commands. Each of these is the
pb-diff-cli command of the same name, with the same
arguments, options, defaults, payload and exit codes, so a pipeline
moves from one tool to the other by changing the executable's name. The
sections under Commands describe both and say
which commands pb-diff-ci has. Two differences, both
because of what pb-diff-ci never shows: report
renders the summary and summary-changes kinds
only and has no --node, and schema publishes
the schemas of its own commands and the error envelope. Naming a command
it does not have - compare, merge3,
parse, export, extract,
tokens, license - exits 2 with
UNKNOWN_COMMAND, and the hint says that the command is in
pb-diff-cli.
Exit codes. The codes of Exit
codes with the same meanings, less the two it can never return: no
merge leaves conflicts, since it has none, and no licence is ever
missing. pb-diff-ci help --format json lists the four it
has.
The footer. Every report pb-diff-ci
writes, in every rendition but JSON, ends with this line, linked where
the rendition has links:
Generated by pb-diff-ci version. To see what changed inside each object, open the comparison in pb-diff: https://pbdiff.com
In CSV it is the last row, alone in the first column after an empty
row, so a script that reads the file by column count stops at the empty
row. pb-diff-cli reports carry no footer.
Usage terms. pb-diff-ci may be used
under the terms on the licence
page, which pb-diff-ci help names.
Global options
Accepted by every command.
| Option | Meaning |
|---|---|
--format <text|json> |
Output format. text is for humans and may be reworded
between releases; json is the contract. |
--out, -o <path> |
Write the payload to a file instead of standard output. The file is
written, in one replacement, only when the command has run to its end,
whatever its exit code; a command that fails with an error leaves an
existing file as it was. A path naming a file stream
(app.pbl:notes) is refused. |
--quiet |
Suppress successful human-readable text written to standard output.
JSON, raw payloads, errors and files named with --out are
unchanged. Overrides --verbose. |
--verbose |
Report progress on standard error. Never on standard output.
--quiet takes precedence. |
--help, -h, -? |
Show help for the command. |
@<file> |
Read further arguments from a command file. Not the value of an
option that takes one: --line "@x" is the text
@x. |
-- |
Ends the options: every argument after it is a positional taken as
written, so a file whose name starts with - or
@ can be named. |
Options that take a value accept a separate token or =,
for example --format json or --format=json.
The separate token is the canonical spelling, and the one every example
in this reference uses; = is an accepted alternative. A
flag (an option declared with takesValue: false in the
catalogue) never consumes the following token:
check --quiet broken.srw clean.srw checks both files. To
set a flag explicitly, use =true or =false,
for example --quiet=true or --quiet=false
(also with its short spelling, where available). Only true
and false, case-insensitively, are accepted after
=; another value is BAD_ARGUMENTS (exit 2).
The old --quiet false form no longer sets the flag's value:
false is a positional argument.
An empty argument, or an option given an empty or blank value - what
an unset variable in a script produces - is BAD_ARGUMENTS
(exit 2).
Unknown options are rejected rather than ignored: a typo fails with
exit code 2 instead of silently changing what the command does. Excess
positional arguments are BAD_ARGUMENTS (exit 2), unless the
command declares a repeatable positional argument, such as the paths of
check.
Addressing entries
A path with no ! is a plain file.
container!entry addresses one entry inside a PowerBuilder
library, which may be a .pbl, a .pbd, or a
library appended to an .exe or .dll:
pb-diff-cli parse "ui.pbl!w_main.srw"
Entry names are compared case-insensitively. Where a list of entries
is meaningful, the entry part accepts the * and
? wildcards:
pb-diff-cli export "ui.pbl!*.srw" --out-dir .\windows
pb-diff-cli list "ui.pbl!d_cust*"
Quote the argument in a shell that would otherwise expand
! or * itself.
PowerBuilder 2025 Solutions
pb-diff compares, reports on and merges the object sources of
PowerBuilder 2025 Solutions, as PowerBuilder 2025 and later write them,
and a source it merges builds in the Solution. In a Solution each
library is a PBL folder named like the library file it
replaced (app.pbl\), holding one UTF-8 source per object
with no $PBExportHeader$ line and the object comment as a
first line //objectcomments <text>. Name those files
wherever a file is asked for:
pb-diff-cli compare old\app.pbl\w_main.srw new\app.pbl\w_main.srw --format json
pb-diff-cli merge3 mine\w_main.srw base\app.pbl\w_main.srw theirs\w_main.srw --output base\app.pbl\w_main.srw --format json
- A PBL folder is a folder.
compare-liband thesummaryandsummary-changesreports compare a Solution, or one of its PBL folders, object by object, against another or against a library, as the desktop application does. Every other command refuses one with exit code 2 and a message naming it as a Solution library folder:info,list,export,extractandvalidatewithNOT_A_CONTAINER, andcompare,parse,merge3and the object reports withBAD_ARGUMENTS. - A merged Solution source stays UTF-8. See
merge3. - The header is framing. Comparing a library export
with the Solution source of the same object reports no difference for
the missing
$PBExportHeader$line or for the two spellings of the comment. The binary data section of a window with an OLE control is still a difference, because a Solution keeps it in a separate.bin. .bincompanions are containers. The.binbeside such a window is a one-entry library:comparerefuses it as a library, andcompare-libcompares its entry by payload. A folder compare lists it as a row of its own, so a change to an OLE control's data shows.--detect Hreads the export header, or, for a Solution source that has none, how the file opens. An object inherited from another object of the application cannot be typed from its content; keep its extension in the name, or use--detect F..pbslnand.pbprojare compared as build files (see Project and build files), and.pblmetaand.praas text. The last two are outside the certified scope.
Project and build files
The files PowerBuilder and its build tools keep beside the libraries
are compared by what they hold, with compare,
parse, report and merge3, exactly
as an object is:
pb-diff-cli compare release-1.4\src\app.pbr release-1.5\src\app.pbr --format json
| File | fileType |
Nodes |
|---|---|---|
Resource file .pbr |
Resource |
One Resource node per line, in the groups
group:datawindows, group:pictures,
group:icons, group:pointers and
group:other, keyed resource:<name> in
lower case. The order of the lines is not a difference; the case of a
name is. |
Workspace .pbw |
Workspace |
A Setting per setting
(setting:defaulttarget, setting:save-format,
...), and a Target per target under
block:targets, keyed target:<path> and
not by its number. |
Solution .pbsln |
Solution |
A ProjectReference per project under
block:projects, keyed project:<path>;
the Projects line itself names the default project. |
Project .pbproj |
SolutionProject |
A Setting per one-line element
(setting:type, setting:application, ...), a
ProjectReference per deploy configuration under
block:deployconfigurations, keyed
deploy:<name>, and setting:libraries,
the whole <Libraries> element, with
setting:appentry and a Library per library
beneath it. A library moved is setting:libraries modified
with no library added or removed. |
PowerGen project .gen |
PowerGen |
Best-effort, outside the certified scope. It must open with
$PowerGenVersion=; a .pbt holding one is an
invalid Target, not a PowerGen. A
Setting per header tag
(setting:powergenversion, ...), an Application
per application keyed application:<name>, and beneath
it a Setting per tag and setting:libraries,
the run of $Library lines, with a Library
beneath it for each; every key inside an application begins
application:<name>/. |
Target .pbt |
Target |
A Setting per setting (setting:appname,
setting:applib, setting:type, ...), a
ProjectReference per project under
block:projects, keyed project:<name>,
and setting:liblist, the library search path, with a
Library beneath it for each library, keyed
library:<path>. A library moved in the list is
setting:liblist modified with no library added or
removed. |
A file is the format its extension names. A build
file or an object export whose content is not that format is not read as
another one: it is compared as text, and inputDiagnostics
carries a parse.fallback entry saying which format it is
not and why
(Not a PowerBuilder target: it does not open with a Save Format line; compared as text.).
A .pbl or .pbd that is not a PowerBuilder
library is refused by every command with exit code 2 and
NOT_A_CONTAINER. Only a file whose name says nothing, such
as Git's temporary files under --detect H, is typed from
its content.
merge3 writes a merged build file - and any other file
that is not a PowerBuilder object export, an INI, XML or text file - in
the encoding its Base was read in, with its own line endings, rather
than the UTF-16LE of an object export: an ASCII file stays ASCII, an
ANSI one stays ANSI, and a Solution's UTF-8 keeps its byte-order mark or
its absence. What each file is for, and what a change to it does to an
application, is in pb-diff Help under Project and build
files.
Argument files
Any argument may be replaced by @<file>, a text
file holding further arguments. It is the way round a command line too
long for the shell or too awkward to quote:
pb-diff-cli compare @nightly-args.txt
A workspace or a target may not be one:
pb-diff-cli @app.pbw is refused with exit code 2 and
BAD_ARGUMENTS, and its text is never tokenised into
arguments. Named without the @, the same file is
compared.
Expansion happens before the rest of the command line is read, and a
failure in it — a file that is not there, one that nests too deeply, or
a workspace — still answers in the format that was asked for:
--format json on the command line gives the usual error
envelope. A --format written inside a command file
cannot apply to a failure reading that same file.
The GUI accepts command files too, and the two are
not interchangeable. This tool rejects a missing file
rather than ignoring it, because a script that names a file it did not
write should stop rather than run with fewer arguments than its author
intended; it takes # as its only comment marker; and it
does not consult the command-file folder from the application's Options,
because it has no settings file to read. The GUI differs on two of the
three counts: it also takes ; as a comment marker, and it
looks in the command-file folder from its Options. A missing command
file it reports and refuses, as this tool does (CLI-9).
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success; for compares, no differences; for check, nothing noticed |
| 1 | Compares: differences found. Validate: warnings only. Check: findings |
| 2 | Invalid input, missing file or entry, or bad arguments |
| 3 | Three-way merge left conflicts |
| 4 | Internal error |
| 5 | No valid licence |
Exit code 1 is not an error. compare,
compare-lib, validate and check
use it to report a finding, so a script that treats any non-zero code as
a failure will misread a perfectly good compare. pb-diff-ci
returns the same codes with the same meanings, except 3 and 5, which it
never returns.
Errors
In --format json mode an error is a single JSON object
on standard error, and nothing is written to standard output:
{ "schemaVersion": 1, "error": { "code": "ENTRY_NOT_FOUND", "message": "…", "hint": "…" } }
Branch on code, never on message. The
message is prose and may be reworded.
| Code | Cause |
|---|---|
BAD_ARGUMENTS |
The arguments do not form a valid command, or name a
.pbw/.pbt as a command file
(@app.pbw), or give a Solution PBL folder where an object
was expected, or name an output (--out,
--output) that is a PowerBuilder library or a file holding
one, or an --out or a report --output that is
one of the inputs. |
FILE_NOT_FOUND |
A path does not exist. A folder given where a file was expected is
not this: a command that reads a library answers
NOT_A_CONTAINER, and one that reads one object
BAD_ARGUMENTS, with a hint naming
compare-lib. |
NOT_A_CONTAINER |
The file is not a readable PowerBuilder library, or is a folder - a Solution PBL folder or any other - where a library file was expected. |
NO_SOURCES |
check: an input holds no object source or build file to
read, so it could not pass by checking nothing. |
NO_LIBRARIES |
validate: an input stands for no library - an empty
folder, a folder of sources, a Solution, or a list that could not be
read - so it could not pass by verifying nothing. |
ENTRY_NOT_FOUND |
The container has no entry of that name. |
FILE_EXISTS |
An output file exists and --overwrite was not
given. |
UNKNOWN_COMMAND |
No such command. |
UNKNOWN_OPTION |
No such option for this command. |
UNKNOWN_SCHEMA |
No output schema is published for that command; the hint lists the ones that are. |
IO_ERROR |
A read or write failed, or was refused - including
export and extract given an entry name that is
not a plain file name, two entries that would be written to the same
file, or an entry whose chain holds fewer bytes than it declares, which
would be written short. |
INTERNAL_ERROR |
A bug. The exit code is 4 and the message is worth reporting. |
merge3 additionally reports TYPE_MISMATCH
when the three inputs are not the same object type, and
MERGE_REFUSED when the comparison cannot produce a merge at
all - among others when one or two of the three could not be read as
their format and were read as text (their parse.fallback
diagnostic says why), since a file read as text has nothing in common
with the structure of the others.
With exit code 5, the licence codes:
| Code | Cause |
|---|---|
LICENSE_REQUIRED |
No valid licence: none, expired, suspended, moved to another
machine, not valid for this build, or a trial ended. The message says
which, and the hint how to activate - with none at all, also that a
trial is started from the desktop application and where to buy.
license activate also uses it when the key is refused. |
LICENSE_ACTIVE_ELSEWHERE |
license activate: the licence is active on another
machine, described in the message. Run it again with
--take-over to move it. |
LICENSING_UNREACHABLE |
license activate or license deactivate:
the licensing service could not be reached, gave no answer this build
can verify, or did not confirm the deactivation. Nothing was
changed. |
TERMS_APPROVAL_REQUIRED |
license activate: the licence terms in force have not
been accepted, and their clauses specifically approved, on this machine.
Only the desktop application asks for them: activate the key there, in
Help > Licence. Nothing was changed. |
Output contract
- Determinism. The same inputs and options produce
byte-identical output — on another machine as much as on another run,
and for a report file as much as for standard output. Entries are always
sorted case-insensitively by name, hunks are in document order, and no
wall-clock timestamp is injected into a payload, so output can be
cached, committed and diffed. Nothing is read from the machine:
pb-diff-clihas no settings file, and the highlight colours and tab size areportrendition is drawn with are fixed defaults rather than whatever the desktop application has been configured with.reportused to read that file, and the change is recorded inCHANGELOG.md: until then the PDF, HTML and RTF renditions of one command line could differ between two machines. - Encoding. UTF-8 without a byte-order mark, LF line
endings, in JSON mode. A file written to disk is different and follows
the PowerBuilder convention instead.
extractwrites the library's own bytes and nothing else.exportcopies the stored object payload verbatim — no decoding, no normalisation, no re-encoding — but the file around it is composed: the byte-order mark and the$PBExportHeader$/$PBExportComments$lines are written in the container's own encoding, and a.bincompanion is hex-encoded into a binary data section. Amerge3 --outputfile is neither: for an object it is UTF-16LE with a byte-order mark and CRLF line endings, whatever its inputs used, unless the Base is a UTF-8 PowerBuilder 2025 Solution source, which stays UTF-8; any other file keeps the encoding its Base was read in. - Streams. Standard output carries the payload and
nothing else. Progress (
--verbose) and errors go to standard error. - Versioning. Every JSON payload carries
schemaVersion, the error envelope included, and every one of them is described by a published schema — seeschema. Two outputs do not carry it, because neither is a command payload:pb-diff-cli schema <name>prints a JSON Schema, andreport --report-format jsonwrites a report document, the artefact rather than the answer.
Size and performance
There is no size limit on an input. A file larger than anything below is accepted and processed on a best-effort basis rather than refused, with no promise about how long it takes or how much memory it needs.
What is certified is an envelope of three loads, and only those:
| Load | Envelope |
|---|---|
| Open and enumerate a library | 50 MB |
Two-way parsed compare of a .srw |
500 KB a side |
| Library-to-library summary | two libraries of 2,000 entries |
Two things follow from how the container is read, and they are worth
knowing before pointing this tool at a large library. Opening one and
listing its entries reads the header and the directory nodes and no
entry's payload at all, so what pb-diff-cli list costs
follows the number of entries and not what the file
weighs — about 64 bytes an entry, so the 400-entry fixture the tests use
is enumerated by reading about 25 KB whether the file is 0.2 MB or 51
MB. Ten thousand entries cost more than four hundred, at any size. An
object you then ask for, by contrast, is read into memory whole:
parse, compare and export
materialise the object they are given, and that is deliberate rather
than a limitation to work around.
The published targets are objectives, not
guarantees: under a second for the parsed compare and under ten
seconds for the 2,000-entry library summary. On the reference runner
they are held to as ceilings - a release candidate whose median is above
one of them fails - and nowhere else, because a median belongs to the
machine it was taken on: the benchmark declares that runner in
build/bench/profiles.json and stamps a measurement taken
anywhere else as exploratory. No median is quoted here for the same
reason. The objectives and the loads are both declared in that one file
rather than described in prose, so what is measured cannot drift from
what is stated.
Commands
info
pb-diff-cli info <container>
Container metadata: flavour (ANSI or Unicode), format version, header
comment and entry counts. Also in pb-diff-ci. A
source-control block a library still carries is opaque (S04): it is
neither decoded nor reported, and there is no option to skip a decoding
that does not happen.
list
pb-diff-cli list <container>[!<glob>]
Enumerate the entries of a container: name, kind, type, size and
modification time. Also in pb-diff-ci.
| Option | Meaning |
|---|---|
--kind <source|compiled|resource> |
Only list entries of this kind. |
--comments |
Include entry comments in JSON and in a text table column. This reads every entry's data chain, so it is slower. |
Every entry has a kind (source,
compiled or resource) and a finer
type such as source/window or
compiled/datawindow. Only source entries can
be exported as .sr*. size is the payload
without the entry comment; the comment's length is reported
separately.
An exact entry selection that matches nothing is
ENTRY_NOT_FOUND (exit 2), before the kind filter is
applied. A wildcard selection that matches nothing succeeds with zero
entries.
export
pb-diff-cli export <container>[!<glob>] [extra entry...]
Write source entries as importable .sr* files:
$PBExportHeader$, the entry's comments, the right encoding,
and, where the object has a companion .bin, its binary data
section. Not in pb-diff-ci.
Entry names after the address are added to what the address
already selected. A bare container already selects every entry, so
naming entries after one narrows nothing - put the glob or the entry in
the address, after the !, when you mean to select.
Each exact entry selection, including extra entries, must exist or
the command fails with ENTRY_NOT_FOUND (exit 2) before
writing any files. An empty wildcard match is allowed and adds no
entries; if nothing is selected, the command succeeds with zero
files.
| Option | Meaning |
|---|---|
--out-dir, -d <dir> |
Directory to write into. Default: the current directory. |
--stdout |
Write a single entry's bytes to standard output instead of to a
file. Bytes, not JSON: it cannot be combined with
--format json or with --out. |
--overwrite |
Overwrite existing files. Without it, an existing file is
FILE_EXISTS. |
Every entry is written under its own name, unchanged. An entry whose
name is not a plain file name, or two entries that would land on the
same file (names compare without regard to case), refuse the whole
command with IO_ERROR before anything is written.
extract
pb-diff-cli extract <container>[!<glob>] [extra entry...]
Write the raw payload bytes of any entry, including compiled objects
and resources. This is not a decompiler: a compiled entry's bytes are
P-code, and pb-diff does not interpret them. Not in
pb-diff-ci.
Selections are added together as in export. Each exact
entry selection must exist or the command fails with
ENTRY_NOT_FOUND (exit 2) before writing any files. An empty
wildcard match is allowed; if nothing is selected, the command succeeds
with zero files.
| Option | Meaning |
|---|---|
--out-dir, -d <dir> |
Directory to write into. Default: the current directory. |
--stdout |
Write a single entry's bytes to standard output instead of to a
file. Bytes, not JSON: it cannot be combined with
--format json or with --out. |
--overwrite |
Overwrite existing files. Without it, an existing file is
FILE_EXISTS. |
validate
pb-diff-cli validate <path>...
Check libraries for damage: walk each one's structure and report what
it finds. Also in pb-diff-ci. Each path is one of:
- a library - a
.pbl, a.pbd, or an.exe/.dllwith a library appended - which stands for itself, whatever it holds: one too damaged to open is a result, not an error; - a folder, which stands for the
.pbland.pbdfiles beneath it, walked ascompare-libwalks one: aws_objectsfolder is not walked into, and a folder that cannot be read is a note; - a workspace (
.pbw), which stands for the libraries of its targets, a target (.pbt) for its library list, a Solution (.pbsln) for the libraries of its projects and a project (.pbproj) for its library list - each in search-path order, resolved against the folder of the file that lists it. This is the only command that expands them; a compare takes them as the build files they are. A Solution's libraries are folders of sources, whichcheckreads; only the.pbdfiles a project lists are verified here.
A library reached twice is verified once. Each library ends in one
state: valid, warnings, damaged,
unreadable or missing. Damaged means at least
one error; unreadable, that the file is there and cannot be read as a
PowerBuilder library; missing, that a library list names it and it is
not there. A workspace naming a target that is not there, a target or
project whose library list cannot be read, and a folder that cannot be
read to the bottom are notes, and each leaves libraries unverified: the
run is not valid, and summary.unverified
counts them. A note's detail is why, for an
unreadable-list or unreadable-folder, and the
list that names the path, for a missing-list or
source-library-folder. The results keep the order of the
inputs, so a series of builds of one library -
validate build-768\arst.pbl build-769\arst.pbl ... - reads
as its history.
Each finding has a severity (info,
warning, error) and a stable code
such as NODE_FREE_SPACE, CHAIN_LENGTH (an
entry declaring more than its chain holds), CHAIN_SHARED (a
data block two entries' chains reach) or
BITMAP_UNMARKED_BLOCK. Branch on the code; the message is
prose. A .pbd whose bitmap still marks only an empty
library's blocks gets the warning BITMAP_UNMAINTAINED
rather than that error: a compiled library is written whole by a build,
and Appeon's own pb_sdk.pbd ships that way.
| Option | Meaning |
|---|---|
--no-chains |
Skip walking every entry's data chain. |
--no-bitmap |
Skip cross-checking the free bitmap. |
--output <path> |
Also write the result as a report to this file: the counts, a row per library and every finding. The payload or the text still goes to standard output. |
--report-format <fmt> |
The report's rendition: Markdown, HTML or CSV
(--report-format markdown). Default: from the
--output file name. Without --output the
rendition replaces the text on standard output, which is how a job
summary takes it; under --format json it needs
--output. |
The report is a result, not one of the five reports of
report: it has no sides and compares nothing.
pb-diff-ci ends it with its footer, as it ends every report
it writes.
The worst library is the exit code: 0 when every library is valid, 1
when the worst has warnings, 2 when any is damaged, unreadable or
missing, or a list or folder the inputs stand for could not be read. A
path that is not there is FILE_NOT_FOUND, a file that is
neither a library nor a list of them is NOT_A_CONTAINER,
and an input that stands for no library is NO_LIBRARIES;
each ends the command before any result, with exit code 2 and the error
object on standard error.
compare
pb-diff-cli compare <old> <new>
Compare two objects section by section. Either side may be a file or
a container!entry. Not in pb-diff-ci, which
never shows what changed inside an object.
compare refuses a library named on its own, with exit
code 2 and BAD_ARGUMENTS: a .pbl or a
.pbd holds objects and is not one of them. Use
compare-lib for two libraries, or address the object you
mean (ui.pbl!w_main.srw). An .exe or a
.dll is judged on whether it really carries an appended
library. merge3, parse and the file reports
draw the same line.
| Option | Meaning |
|---|---|
--context, -c <n> |
Non-negative context rows around each change. Default 3; values larger than the available row count are clamped to that count. |
--unified |
Emit a unified diff of the pretty-printed sources instead of the
structured result. Its line numbers are the pretty-print's - sections in
a fixed order, continuation lines joined - so it reads like any
diff -u but is not a patch to apply to the files. It is
text: it cannot be combined with --format json. |
--all |
Include unchanged nodes, not only the changed ones. |
--ignore-whitespace, -w |
Ignore all whitespace when comparing lines. |
--ignore-case |
Ignore letter case when comparing lines. |
--resync-threshold <0..1> |
How alike two lines must be to count as modified rather than replaced. Default 0.5; 0 pairs everything. |
--inline <token|character|none> |
Which engine highlights what changed inside a line. |
--no-sync-names |
Do not pair a function whose signature changed but whose name did not. |
--detect <F|H> |
Identify a mangled filename by its first extension (F)
or by its export header (H), which for a PowerBuilder 2025
Solution source is how the file opens. |
--no-normalise-case |
Keep DataWindow attribute and value case as written. |
--ignore-xml-attribute-order |
Sort XML attributes before comparing. |
--ignore-xml-namespaces |
Drop XML namespace declarations and prefixes. |
--ignore-xml-comments |
Drop XML comments. |
The result is the parsed hierarchy with a status per
node (unchanged, new, modified or
deleted) plus hasChanges, true when anything
beneath a node changed. Unchanged nodes are omitted unless
--all is given, so walking children gives
exactly the changed items. Each changed node carries hunks.
A hunk has oldStart, oldLength,
newStart, newLength and a rows
array; each row carries kind,
oldLine/newLine, the text of both sides, and,
on a modified row, oldRanges and newRanges -
the character spans that actually differ within the line.
Exit code 1 means differences were found. It does not mean anything went wrong.
Two lists come back beside the tree, and they answer different questions.
diagnosticsis about the comparison: two sides of different types, a side that had to be compared as text, two siblings that share a key. Prose written for a reader, not a contract.inputDiagnosticsis about one of the two filescomparewas given. Each entry carriesside—oldornew— and the samecodeandmessageparsereports for that file, so a merged object left with<<<<<<<in it is named here rather than merely shown as changed lines. Branch oncode; the codes are the ones tabulated underparse. Empty for two sound objects.
Ignore options affect the comparison only. The text reported for each side is always the text as it is in the file.
compare-lib
pb-diff-cli compare-lib <old> <new>
The compare summary: two libraries, folders or files compared object
by object, on the same model and the same engine as the desktop
application's Compare, with a status per object and aggregate counts.
Also in pb-diff-ci.
Each side is one path:
- a library - a
.pbl, a.pbd, or a library appended to an.exeor.dll- whose objects are compared from their exports, so a change to an object's comment, or to the OLE data of its.bincompanion, is a change; - a folder, which stands for every supported file
beneath it: a
ws_objectsfolder, a PowerBuilder 2025 Solution or one of its PBL folders, a folder of exported.sr*files, or a folder holding libraries. The build files beside the objects are compared too. A Solution's build output (.pbandbuildbeside a.pbproj) and_BackupFilesare not walked, and neither is aws_objectsfolder met inside the folder named, whose objects would otherwise all appear twice; a.pbdbeside the.pblit was built from is that library's output and is left out; - a single file.
The two sides are matched by name without regard to case, an object
against its own name whatever form it is in, so a library compares with
a folder of its exports and with the Solution converted from it. A name
two inputs on one side share is never paired by guessing: each of them
is listed, named by its address, and notes says why.
| Option | Meaning |
|---|---|
--mode <parsed|binary|metadata> |
How thoroughly each object is checked. Default
parsed. |
--changed-only |
List only new, modified, deleted and renamed-modified objects. |
--pair <old=new> |
Pair two differently named rows as a rename: by object name, or by address when two inputs share the name. Repeatable. |
--ignore-whitespace, -w |
Ignore all whitespace when comparing lines. |
--ignore-case |
Ignore letter case when comparing lines. |
--no-normalise-case |
Keep DataWindow attribute and value case as written. |
--ignore-xml-attribute-order |
Sort XML attributes before comparing. |
--ignore-xml-namespaces |
Drop XML namespace declarations and prefixes. |
--ignore-xml-comments |
Drop XML comments. |
parsed compares the parsed objects, so a reordered but
unchanged export, the framing that differs between a library export and
a Solution source, and the case of a DataWindow attribute do not show up
as modified. binary compares the bytes.
metadata compares size and modification time, a fast survey
that reports an object as changed when only its timestamp moved; for a
folder checked out by Git or SVN the time is the checkout's, so use
parsed there. The options are the desktop application's
Options, as switches, with its defaults; pb-diff-cli reads
no settings file, so it agrees with a desktop whose Options are at their
defaults or set to match the switches given.
The payload names each side as old and new,
with the path given and its kind:
library, folder or file. Each
entry carries name, status, type,
oldSize and newSize, the modification times,
oldName and newName, and oldPath
and newPath, the address of each side - a file's path, or
container!entry - which tells two namesakes apart.
notes lists what had to be said about the inputs, each with
a kind, a side, the name, the
paths concerned, a message and, where there is
one, a detail: ambiguous-name,
compiled-set-aside, compiled-beside-source,
source-control-copy, not-a-library,
not-its-format, unreadable-folder and
missing-input. The set may grow; ignore a kind you do not
know.
Renames are never detected automatically: content similarity cannot
distinguish a renamed object from a copied-and-edited one. Pair them
yourself with --pair.
A pairing has to be one the two sides can carry out, and an
unambiguous one. --pair naming a row neither side holds, or
a name two inputs share without its address, is
ENTRY_NOT_FOUND; naming the same row on the same side of
two pairs is BAD_ARGUMENTS. A pair takes the two rows it
names and nothing else: a row whose counterpart a pair claimed is
reported as new or deleted, so every object of both sides appears
exactly once whatever pairing you ask for.
A path that does not exist is FILE_NOT_FOUND; a library
named outright that is not one - a .pbl another tool wrote,
an .exe with no library in it - is
NOT_A_CONTAINER, while the same file found in a folder is
left out and noted; and container!entry is
BAD_ARGUMENTS, since one entry is not a set.
--verbose reports on standard error how far a long compare
has got.
merge3
pb-diff-cli merge3 <file1> <base> <file2> [--output <merged>]
Three-way merge. A change is auto-mergeable when only one side made it, or when the two sides changed the same item without overlapping. Every auto-mergeable change is applied automatically; what is left is reported as a conflict list with the node, the base line range and the text each side wanted.
| Option | Meaning |
|---|---|
--output <path> |
Where to write the merged result: written whole to a temporary file
beside it and then moved into place, so a failed write leaves an
existing file as it was. It may be one of the inputs, for a merge in
place; a library or a file holding one is refused with
BAD_ARGUMENTS. |
--force-partial |
Write even with conflicts, taking the base for each one. |
--no-sync-names |
Do not pair a routine whose signature changed but whose name did not. |
--ignore-whitespace, -w |
Ignore all whitespace when comparing lines: a change only in whitespace is not a change, and the base's text is kept. |
--ignore-case |
Ignore letter case when comparing lines: a change only in case is not a change, and the base's text is kept. |
--detect <F|H> |
Identify a mangled filename by its first extension, or by its export
header or its content: a Solution source, or a build file other than a
.pbr. |
The two ignore options decide what counts as a change, not only what is shown. A side that only re-indented a line, or only changed its case, has not changed it under them, so the merge keeps the base's line, as it keeps every region it did not decide, and a real change the other side made to the same item is taken whole. Leave them off to merge a re-indentation.
All three inputs must be the same object type, or the command fails
with TYPE_MISMATCH.
Each of the three is a file or a library entry, written the usual way, so a merge can start from the library as it shipped without exporting it first:
pb-diff-cli merge3 mine.srw "release-1.4\ui.pbl!w_main.srw" theirs.srw --output merged.srw
An address that selects more than one entry is refused with
BAD_ARGUMENTS: a merge has exactly three sides.
merge3 reports what a parser found wrong with one of the
three files in inputDiagnostics, each entry naming which —
side is file1, base or
file2 — with the same code and
message parse gives for that file. A merge
started from a file that still carries conflict markers is named there
before anything is decided. diagnostics remains what it
was: prose about the merge itself, not about any one file.
Exit code 0 means the merge was clean. With --output,
the merged file is written; without it, only the merge summary is
emitted (JSON or text), not the merged source text. JSON
written is true only when a merged file was actually
written, and false when no --output was requested.
--out redirects the summary, not the merged source. Exit
code 3 means conflicts remain, and in that case no output file
is written unless both --force-partial and
--output are given. pb-diff never writes a conflict marker:
markers are not valid PowerScript, and a file containing them fails to
import. A merge copies what it did not decide, though, so markers one of
the three files already carried can reach the result. Then the merge is
not clean either: exit code 3, a merge.conflict-markers
warning, and no output file unless --force-partial is
given.
The output file is written in the canonical PowerBuilder form —
UTF-16LE with a byte-order mark and CRLF line endings — whatever File1
and File2 used, and it is the same file the desktop application's Save
produces. A file that is not an object export - a build file such as a
.pbr, an INI, XML or text file - is written in its Base's
own encoding instead (see Project and
build files). The other exception is a UTF-8 Base, which is what a
PowerBuilder 2025 Solution holds: the output stays UTF-8, with a
byte-order mark exactly when the Base had one, gains no
$PBExportHeader$ line, and keeps every byte the merge did
not decide, bare LF line endings included. The Solution compiler reads
UTF-8 only, and a UTF-16LE file in a PBL folder fails the build. The
JSON on standard output is unaffected.
The output is the base file rewritten: only the regions a merge
action changed are composed again, and everything else — trailing
whitespace, wrapped continuation lines, the order the file wrote its
sections in — is copied from the base. merge3 f f f
therefore returns f, in that encoding.
A control the merge carries into the base is made consistent with the
object around it: it is instantiated in the owning object's
.create script, released in its .destroy
script and put top-most in the control list, and a control the merge
takes out loses exactly those three lines. An object no merge action
moved a control in or out of is not touched at all. What is
not invented is the control's property block, its entry
in the object's own type block or its line in the forward
block: a control that arrives without them is reported in
warnings under merge.undeclared-control, and
the object will not compile until they are merged too.
warnings also carries
merge.missing-create-script, for an owner with no
.create script to rewrite,
merge.unresolved-conflict for each conflict left unresolved
- the base is what a partial result keeps for it, and without
--force-partial it is listed although nothing is written -
and merge.conflict-markers when the result carries markers
an input already had.
A change one side made inside a control or an XML element the other
side deleted - an event added or edited in a button the other side
removed - is a conflict, because merged on its own it has nowhere to go.
A partial result leaves it out with its container rather than writing
the base version where it would read as part of something else, and its
merge.unresolved-conflict message says so.
That the file is well formed is measured on every build. That the PowerBuilder IDE accepts a merged file rests on D26's two frozen results, which were imported through the Library painter and regenerated in PowerBuilder 19 and 25 on 23 August 2026; the claim covers PowerBuilder 19 and later, and 10 to 18 is expected and unverified. Reading, comparing and exporting libraries is a separate scope and is certified from PowerBuilder 10 onwards.
report
pb-diff-cli report <summary|summary-changes|full|changes|script> <old> <new>
Render a comparison as a report. pb-diff-ci has the
command with the summary and summary-changes
kinds only, and without --node.
| Kind | Inputs | Contents |
|---|---|---|
summary |
two libraries, folders or files | Every object of the compare compare-lib runs, and its
notes. |
summary-changes |
two libraries, folders or files | New, modified, deleted and renamed-modified objects, and the notes. |
full |
two objects | The whole object, changed or not, item by item. |
changes |
two objects | The changed items only, each complete, with its changes highlighted. |
script |
two objects | One item, named by --node. |
Object report bodies use the same exact decoded-source projection as the desktop compare panes. PowerScript continuation lines, original whitespace, JavaScript fragments and raw XML element text are reported as written; semantic normalisation decides whether something changed but is not printed in place of either input.
The inputs column is enforced. The summary kinds take what
compare-lib takes, with the same rules; a kind that takes
two objects refuses a library named on its own and points at the summary
kind that does take one. An address naming an entry the container does
not hold is ENTRY_NOT_FOUND, and one selecting several
entries is BAD_ARGUMENTS - a report covers one
comparison.
| Option | Meaning |
|---|---|
--output <path> |
Write to a file. Without it, the text formats go to standard output. |
--report-format <fmt> |
pdf, html, csv,
json, rtf or markdown;
markdown for the summary kinds only. Default: taken from
the output file name, and HTML when its extension names no format;
without --output, JSON under --format json and
HTML otherwise. |
--node <key> |
Which item a script report covers. |
--mode <parsed|binary|metadata> |
The summary kinds: how thoroughly each object is checked, as for
compare-lib. Default parsed. |
--pair <old=new> |
The summary kinds: pair two rows as a rename, as for
compare-lib. Repeatable. |
--ignore-whitespace, -w |
Ignore all whitespace when comparing lines. |
--ignore-case |
Ignore letter case when comparing lines. |
--no-normalise-case |
Keep DataWindow attribute and value case as written. |
--ignore-xml-attribute-order |
Sort XML attributes before comparing. |
--ignore-xml-namespaces |
Drop XML namespace declarations and prefixes. |
--ignore-xml-comments |
Drop XML comments. |
The keys --node takes are the ones
pb-diff-cli parse reports for the object; naming one the
compare does not hold is exit code 2 and BAD_ARGUMENTS,
like any other bad argument. --mode and --pair
with an object kind, and --node with a summary kind, are
BAD_ARGUMENTS too.
PDF must be written to a file. The formats are PDF, HTML, CSV, JSON,
RTF and Markdown, not legacy binary .xls or
.doc files; those need a large third-party library, and
pb-diff ships with no third-party runtime dependencies.
Markdown is GitHub-flavoured, written for a GitHub
Actions job summary, an Azure DevOps build summary or a pull-request
comment: the two inputs, the counts, one table row per object with its
status, type and sizes, and the notes. Every name is escaped, so a name
cannot add a column or start a tag. It is the summary kinds' rendition
only - an object report asked for it is BAD_ARGUMENTS - and
an --output file ending in .md chooses it.
Without --output it goes to standard output, which is how a
pipeline appends it to a job summary
(>> $env:GITHUB_STEP_SUMMARY). A job summary holds 1
MiB and a pull-request comment 65,536 characters;
summary-changes keeps a large application within them where
summary may not.
Reports pb-diff-ci writes end with its footer, in every
rendition but JSON; see pb-diff-ci.
--format json and
--report-format json are different questions. The
first is how the CLI answers — the same switch every other command takes
— and the second is which rendition a reader gets.
--format json --output review.htmlwrites the HTML file and prints a payload describing it: the kind, the two sides, the format, the path, the size and the SHA-256 of what was written.--format jsonwith no--outputprints the report itself, embedded in that payload asreport. It is the same document--report-format jsonwrites to a file.--format json --report-format htmlwith no--outputis refused withBAD_ARGUMENTS: a rendition for people and a payload for a program cannot both be standard output, and dropping one of them silently is worse than saying so.- Without
--format jsonnothing changes: the text renditions go to standard output as before.
check
pb-diff-cli check <path>...
Report sources left broken: to sources what validate is
to libraries. Also in pb-diff-ci. Each path is a file or a
folder, and a folder is walked as compare-lib walks it;
every object source and build file it holds is read as
parse reads it, and what the parsers noticed is reported -
never the hierarchy, and never a line of the file.
pb-diff-cli check src\ws_objects --format json
pb-diff-cli check merged\w_main.srw app.pbl\ --format json
Each finding names the file, carries one of the codes parse tabulates and its message, and,
where the parser knows it, line, the first line
concerned:
| Code | What was found |
|---|---|
parse.conflict-markers |
Merge conflict markers left in the file; line is the
first of them. |
parse.unterminated-block |
A PowerScript block opened and never closed; line is
where it opened. |
parse.unterminated-statement |
A DataWindow statement opened and never closed. |
parse.fallback |
The file is not the format its extension names, or could not be read as one. |
parse.unrecognised-line |
A DataWindow line the parser does not recognise; it is kept as text
and compared, and line is where it is. |
The set of codes may grow; ignore a code you do not know. The
check payload also carries inputs, each with
the number of files it stood for, fileCount, each file
counted once however it was reached, clean,
findingCount and the notes of the walk.
Exit code 0 means nothing was noticed, 1 that there are findings, 2
that an input is unusable: a path that does not exist is
FILE_NOT_FOUND, a library is BAD_ARGUMENTS
with a hint naming validate, and an input that holds no
source to read is NO_SOURCES, so that a mistyped folder
cannot pass a gate.
check works in one direction, as parse
does: a finding means the file is damaged; no finding means nothing was
noticed, not that the file compiles.
parse
pb-diff-cli parse <file|container!entry>
Show how pb-diff parses an object: the hierarchy of sections and
scripts that every compare is built on. Not in pb-diff-ci.
parse refuses a library named on its own: a library has no
hierarchy of its own, and list is what shows what one
holds.
| Option | Meaning |
|---|---|
--lines |
Include each node's text, not just its structure. |
--detect <F|H> |
Identify a mangled filename by its first extension, or by its export
header or its content: a Solution source, or a build file other than a
.pbr. |
--no-normalise-case |
Keep DataWindow attribute and value case as written. |
--no-key-features |
Omit the derived DataWindow Overview. The spelling is kept as published. |
--ignore-xml-attribute-order |
Sort XML attributes before comparing. |
--ignore-xml-namespaces |
Drop XML namespace declarations and prefixes. |
--ignore-xml-comments |
Drop XML comments. |
Each node has a kind (Function,
Event, Control, Band,
TypeDefinition, …), a display name, a
key (the stable identity used to pair a node with its
counterpart on the other side of a compare) and a
contentHash of its comparable text, which is the cheap way
to ask whether something changed.
Two things parse does deliberately. Sections
come out in a fixed order, not the order the file used, which
is why a reorganised but unchanged object parses identically. And
a file that will not parse still returns a document,
with usedFallback true and fallbackReason set,
compared as plain text.
usedFallback marks a parser that gave up altogether: a
DataWindow in which no statement was recognised, XML that is not well
formed, a .sr* file in which no PowerScript declaration was
recognised — no forward, no type block, no variables block,
no routine, no event — and any parser that threw. A file rejected that
way is not a damaged object but something else under an object's name: a
note left in place of an export, a fragment, a file of the wrong
kind.
diagnostics reports what a parser noticed without giving
up. Each entry carries a stable code and a
message, and the code is what to branch on; the message is
prose and may be reworded.
| Code | What it means |
|---|---|
parse.conflict-markers |
Unresolved merge conflict markers. The file is not importable. |
parse.unterminated-block |
A PowerScript block was opened and never closed — a truncated file. |
parse.unterminated-statement |
A DataWindow statement was opened and never closed. |
parse.fallback |
The parser gave up; usedFallback and
fallbackReason say the same thing. |
parse.unrecognised-line |
A DataWindow line no statement accounts for, kept as a text node so a change to it is still a difference. |
The marker check runs for every file type, not only for PowerScript.
A marker is a run of seven or more <, = or
> at column 0, followed by end of line or by a space —
the shape every merge tool writes. A row of equals signs on its own is
also how a plain-text file underlines a heading, so it counts only in a
file that carries an angle-bracket marker too. The file is still parsed,
not degraded: the hierarchy is what shows where a marker landed.
The same five codes reach compare and
merge3, in their own inputDiagnostics member,
each entry naming the side it belongs to. parse is the
narrower question — one file, no counterpart — and not a step a caller
has to remember before the damage has a name.
Both members work in one direction only. An entry in
diagnostics, or usedFallback true, means
something is wrong; an empty list and a false flag mean nothing was
noticed, not that nothing is wrong. A truncated object in particular
does not set usedFallback: it is still
mostly parseable, and its hierarchy is more use than a text compare
would be, so it is reported by its diagnostic instead.
tokens
pb-diff-cli tokens <file>
pb-diff-cli tokens --line "<text>" --lang <language>
Tokenise a file or a single line with the lexer behind syntax
colouring and the token diff. A debugging aid, not part of a normal
workflow. Not in pb-diff-ci.
A plain .pbl or .pbd path that is not a
PowerBuilder library is refused with NOT_A_CONTAINER (exit
2), rather than tokenised as text, and a whole PowerBuilder library with
BAD_ARGUMENTS: address one entry as
<library>!<entry>. A file and
--line together are BAD_ARGUMENTS.
| Option | Meaning |
|---|---|
--line <text> |
Tokenise this text instead of a file. |
--lang <language> |
powerscript, datawindow, xml,
html, javascript, ini,
text. |
--detect <F|H> |
Identify a mangled filename by its first extension, or by its export
header or its content: a Solution source, or a build file other than a
.pbr. |
license
pb-diff-cli license [status] # this machine's licence, or the one PBDIFF_LICENSE names
pb-diff-cli license activate <key> [--label <text>] [--take-over]
pb-diff-cli license deactivate
Needs no licence itself, and is not in pb-diff-ci, which
has no licence to show. status, the default, reports the
licence: its state, the holder it was issued to, its kind, when the
subscription ends, and for a machine activation the label of this
machine, when it was last verified online and until when it stays
licensed without verifying again. It exits 0 when the licence is valid
and 5 when it is not, with the same payload either way. When the machine
file is more than a day old it verifies it online first, for a few
seconds at most; a verification that cannot be completed or saved in
that time leaves the licence as it was.
activate licenses this machine - this computer and this
Windows user - with a licence key, and needs the licensing service. A
30-day trial is not started here but in the desktop application's
Licence window (Help > Licence), with an e-mail address; once started
it licenses pb-diff-cli on the same machine too, and its
kind is trial. A key bought at
https://pbdiff.com/buy replaces it with
activate. A key is activated once the licence terms in
force are accepted, and their clauses specifically approved, on this
machine, which only the desktop application's Licence window asks for:
without that, activate refuses with
TERMS_APPROVAL_REQUIRED, and changes nothing. A machine
that approved them - with a trial, or a key activated in the desktop
application - is not asked again; when the terms cannot be read,
activate goes ahead and the desktop application asks at its
next start. When the licence is already active on another machine, it
refuses with LICENSE_ACTIVE_ELSEWHERE, naming that machine
by its label or its dates; --take-over moves the licence
here, and the other machine stops being licensed at its next online
verification. deactivate frees the licence from this
machine in the service and deletes the machine file; it too needs the
service. The machine file is deleted only when the service confirms the
deletion, or answers that the machine is already gone; with any other
answer it is kept and the command fails with
LICENSING_UNREACHABLE.
| Option | Meaning |
|---|---|
--label <text> |
A name of your choosing for this machine, shown to whoever the licence is moved from instead of dates. Nothing else about the machine is sent: not its name, not the user's. |
--take-over |
Move the licence here when it is active on another machine. |
The payload's state is valid,
none, invalid, expired,
suspended, moved or unverified
(not verified online for 7 days), and source says whether
the licence is this machine's activation, a licence file from the
environment, or nothing at all. No output of any command ever contains
the licence key or any part of a licence file. The activation is kept
per Windows user under %LOCALAPPDATA%\pb-diff\licence\.
The environment variables PBDIFF_LICENSE (a licence
file's content) and PBDIFF_LICENSE_FILE (its path) give
pb-diff-cli a licence file bound to no machine, verified
offline. They exist for the licence files RESTORE uses in its own build
pipelines, and no such file is issued to customers: a CI job uses
pb-diff-ci, and a build server activates its service
account. They are described here because a variable that is set changes
what pb-diff-cli does: while either is set it is the only
licence used, and a file that is damaged, expired or suspended fails the
command rather than falling back to the machine's activation.
schema
pb-diff-cli schema # the published names
pb-diff-cli schema <name> # one schema
With no argument, the published names, one per line, or as
{ "schemaVersion": 1, "schemas": [ … ] } under
--format json. Listing them is an answer of its own, not
something to be read out of an error message — and being an answer, it
is a payload like any other, with a schema of its own under the name
schema.
With a name, that schema. Sixteen are published, one per command that
emits JSON plus the error envelope: check,
compare, compare-lib, error,
export, extract, help,
info, license, list,
merge3, parse, report,
schema, tokens, validate. Names
are matched case-insensitively; asking for anything else fails with
UNKNOWN_SCHEMA and a hint listing the ones that exist. Also
in pb-diff-ci, which publishes the eight of its own
commands and the error envelope.
The schemas are Draft 2020-12 documents. They live in the repository
at docs/cli/schemas/v1/, and
pb-diff-cli embeds and serves those very files, so the
schema you read and the schema the tool prints are the same bytes. Every
payload they describe carries schemaVersion: 1. Within v1
they may gain optional members; removing a member, changing its type or
changing what it means requires schemaVersion: 2.
For that reason none of them sets
additionalProperties: false. A member added within v1 is a
compatible change, and a schema that forbade unknown members would turn
every such addition into a breaking one for readers holding an older
copy. Validate what you rely on; ignore what you do not recognise.
Two keywords of their own say how a schema may grow within v1.
x-open-set marks a set of values that may gain members,
such as compare-lib's note kinds and check's
codes: ignore a value you do not know. x-grows-with marks a
value that echoes an option, such as report's
reportFormat, which may gain a value only together with a
new value of report --report-format, so that a caller who
never asks for the new rendition never receives it.
What they do not cover, by decision: text output,
the unified diff of compare --unified, the raw bytes of
export --stdout and extract --stdout, the PDF,
HTML, CSV, RTF and Markdown report renditions, and what
pb-diff-cli schema <name> prints — a JSON Schema, and
validated as one rather than against a schema of its own.
help
pb-diff-cli help [command]
Usage, in both tools; pb-diff-ci help also names its
usage terms. pb-diff-cli help --format json emits the whole
command catalogue as JSON, with its commands, options and exit codes, so
a program can discover the surface without parsing this document. That
is the only machine-readable help; there is no --json
alias.
help is validated like every other command, and the
format never decides whether an invocation succeeded:
- an unknown topic —
pb-diff-cli help nosuchcmd— exits 2 withUNKNOWN_COMMAND, in text and in JSON alike; - an unknown option —
pb-diff-cli help --bogus— exits 2 withUNKNOWN_OPTION; pb-diff-cli <command> --helpchecks the option names first, sopb-diff-cli compare --bogus --helpexits 2 rather than answering with the help;pb-diff-cli <command> --helpthen renders the help without running the command and without needing its mandatory inputs, sopb-diff-cli merge3 --helpworks with no files at hand.
In JSON the answer is always the complete catalogue: a topic narrows the prose, not the contract.
See also
- pb-diff Help: the desktop
application, its workflows and its source-control integration. Press
F1in the desktop application for the page of the tab in front. - Automate with pb-diff-cli: the short introduction in pb-diff Help.
- Run pb-diff in CI:
pb-diff-cion a build machine, with pipelines for GitHub Actions, Azure DevOps and Jenkins.