Skip to content

feat: add diff-apidocs, an API surface diff tool for the object-reference archive - #6

Open
irondarrius wants to merge 8 commits into
mainfrom
feature/api-surface-diff
Open

feat: add diff-apidocs, an API surface diff tool for the object-reference archive#6
irondarrius wants to merge 8 commits into
mainfrom
feature/api-surface-diff

Conversation

@irondarrius

Copy link
Copy Markdown
Collaborator

Adds diff-apidocs, a tool that answers "what changed in the public API between version X and Y?" for the 480+ archived .NET version directories, classifying every change as breaking, additive, or cosmetic. Ships as both ports per the dual-port rule. Also includes the 2026 release-chain diffs it produced, and the findings those surfaced.

python diff-apidocs.py -p ironpdf                                  # newest vs previous
node   diff-apidocs.mjs -p ironzip --from 2024.1.1 --to 2026.7.2 --json --markdown
python diff-apidocs.py -p ironword --namespace 'IronWord.Models.*' --fail-on-breaking

Exit codes: 0 success, 1 tool error, 2 breaking changes found with --fail-on-breaking.

How it works

Two committed sources are combined per version, so the tool is entirely offline — no registry calls, no build triggered:

Source Supplies
xrefmap.yml member identity — uid, kind, parameter types
api/*.html declarations modifiers, return types, base classes, default parameter values, accessors
api/*.html Implements section the interfaces a type implements

Declarations are located by the data-uid DocFX writes on each heading, which is byte-identical to the xrefmap uid. That anchoring is a correctness requirement, not a style choice: Archetype-N injects runnable code samples into the same pages, so a flat lang-csharp scan — the approach in scaffolds/tools/archetype-n/facts.py — reports using IronZip; as a member.

The {PackageName}.xml files inside each nupkg were evaluated and rejected: DocFX takes metadata from the assembly, and the .xml supplies only doc-comment prose with no representation of base types or interfaces. Using it would also have made the tool network-dependent.

Because a uid encodes parameter types, a parameter-type change or a new overload surfaces as a removal plus an addition rather than a modification; the report notes the pairing.

Findings from the 2026 release diffs

Running this across the year turned up four things worth acting on.

1. IronWord 2026.7.1 dropped 21 documented types, including Text, Image, Shape, Style and 13 enums. Verified independently of the tool against the raw archive: 200 → 179 pages, 3865 → 3320 xrefmap entries.

Two of them are a genuine documentation hole rather than a removal — TextAlignment and ShapeType are still used in surviving public signatures (Paragraph.Alignment { get; set; } returns TextAlignment; ShapeContent(ShapeType, RectangleF) takes ShapeType) but no longer have type pages. A consumer cannot look up a type the API requires. Worth raising with the IronWord team.

2. IronQR 2026.1.1 shipped an accidental breaking change. IQrInput and QrImageInput lost IDisposable, restored in 2026.1.2. The cumulative year diff correctly nets to zero — a sum-of-steps tool would have reported 2 breaking for the year. This is the concrete argument for wiring --fail-on-breaking into the release pipeline; it would have caught this pre-release.

3. Iron.Pdf.Extensions publishes obfuscator output to the docs site. The namespace contains only obfuscator-generated types, renamed randomly every build:

2025.12.2 2026.1.3 2026.6.1 2026.7.2
auxkyk auxkyl kjmakb kjmakc bnubqp bnubqq qdygyt qdygyu

scaffolds/filterConfig.yml gains a uidRegex exclude so future builds stop publishing them, alongside the existing ^IronPdfEngine.Proto$ entries. That only helps builds that pick the change up — the 2026.7 rebuild did not, and every already-archived version still contains them, so the tool carries a matching filter regardless.

4. DocFX's declaration line is not a reliable source for interfaces. Between 2026.6 and 2026.7 it stopped inlining them, while the Implements section stayed byte-identical:

2026.6.2  Implements: System.IDisposable
          decl:       public class IronZipArchive : IronBaseArchive, IDisposable
2026.7.2  Implements: System.IDisposable          <- unchanged
          decl:       public class IronZipArchive : IronBaseArchive

Nothing about the API changed. Read from the declaration this reported 183 breaking changes across 11 products; read from Implements it reports 21, all IronWord's removed types. Interface pages carry no Implements section, so for those an interface-shaped difference is reported as cosmetic with an explicit "unverifiable" note rather than counted as breaking — a deliberate trade against re-reporting a rendering change as ~160 false breakings.

2026 year-to-date, as committed

product breaking additive cosmetic
ironword 189 100 5
ironpdf 25 127 18
ironocr 21 29 6
ironbarcode 0 9 0
irondrawing 0 3 0
ironppt 0 1 0
ironprint 0 1 0
ironqr 0 0 1
ironwebscraper, ironxl, ironzip 0 0 0

85 diffs under docs/api-diffs/<code>/<from>..<to>.{json,md} — one per consecutive 2026 pair plus a cumulative year diff each. docs/ is already excluded from the Jekyll build, so these stay private to the repo.

IronPDF 2026.7.2 is purely additive: 27 new members including PdfDocument.ExtractTextFromLayer(int|string), AddHtmlHeaders/AddHtmlFooters overloads taking ContentOverlapBehavior, and the CssPageRulePolicy / ContentOverlapBehavior enums.

Verification

No test framework or CI exists in this repo, so this follows the documented golden-corpus procedure in docs/api-surface-diff-plan.md. All of it was run and passed.

  • 63/63 parity checks — 7 products × 3 version spans × 3 flag combinations, comparing exit codes, stdout, and byte-identical JSON between the two ports.
  • Zero unexplained classifications across all 11 products diffed oldest-to-newest; every reported change carries a specific stated reason.
  • Determinism — five consecutive identical runs, after fixing a bug where set iteration order let results vary with PYTHONHASHSEED.
  • Archetype-N leak guard — asserts no member declaration contains an injected code sample.
  • Cross-era parsing back to irondrawing/2022.9.8843 and ironxl/2019.3.2.1.
  • Filter completeness--namespace G and --exclude G summaries sum exactly to the unfiltered summary.
  • Performance — ironpdf at 341 pages per version diffs in 0.95 s, which is why there is no cache layer.

Notes for review

  • apidiff/ sits at the repo root beside check-apidocs, with both ports in one directory as scaffolds/tools/archetype-n/ does. Nothing was placed under scaffolds/tools/, where .gitattributes would silently make it Git LFS.
  • _config.yml excludes apidiff outright, so nothing under it can ever be published.
  • apidocs.py / .mjs gain list_archived_versions() and version_sort_key(), shared rather than duplicated. The sorter handles both numbering eras (2019.3.2.1, 2021.9.3650, 2026.7.2) and skips the _archetype-n-samples sibling directory.
  • Python is stdlib-only apart from colorama via StatusLogger; Node is dependency-free.
  • Known limitation: stdout is compared whitespace-normalised, not byte-for-byte, because statuslogger.py and statuslogger.mjs already differ in spacing — a pre-existing divergence affecting every tool in this repo. It was left alone rather than changing output the devops pipeline may parse. JSON, which this tool fully controls, is byte-identical.
  • Unrelated observation, not addressed here: __pycache__/*.pyc files are tracked in the repo despite .gitignore covering them. Worth a standalone cleanup.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ

irondarrius and others added 8 commits June 29, 2026 14:49
…ference

build_dotnet_apidoc computed the archive destination at runtime via
os.path.dirname(os.path.abspath(__file__)). Because os.path.abspath resolves
against the current working directory and a preceding build (e.g. the
IronPDF-for-Java JavaDoc step) chdir's into scaffolds/ without restoring cwd,
the destination resolved to scaffolds/object-reference/<code>/<version> instead
of the repo-root ./object-reference/<code>/<version>. The content was assembled
and copied correctly, just to the wrong tree, so it never reached the canonical
cache.

Use the frozen, import-time get_apidoc_path() helper (the same one the JavaDoc
path and update-apidocs.mjs already use), which anchors to apidocs.py's
APIDOCS_STORAGE_PATH and is independent of cwd.

Also gitignore scaffolds/object-reference/** as a safety net so a stray
wrong-location tree can never be committed.

Verified: with cwd forced to scaffolds/ (the staging condition), get_apidoc_path
returns <repo>/object-reference/ironzip/<version>, while the old inline
computation returned <repo>/scaffolds/object-reference/...

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…X output

The Iron.Pdf.Extensions namespace contains only obfuscator-generated types, and
the obfuscator emits new random names on every build:

  2025.12.2  auxkyk auxkyl
  2026.1.3   kjmakb kjmakc
  2026.6.1   bnubqp bnubqq

They are never stable API surface, yet they are published to the docs site and
show up as two removed plus two added types in every IronPDF release diff.

Excluded by uidRegex alongside the existing IronPdfEngine.Proto entries. This
only affects future builds; the 73 already-archived IronPDF versions still
contain them, so diff-apidocs carries a matching BLOCK_NS backstop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ
…ence archive

Answers "what changed in the public API between version X and Y?" for the 469
archived .NET version directories, classifying every change as breaking,
additive, or cosmetic. Ships as both ports per the dual-port rule.

Two committed sources are combined per version: xrefmap.yml supplies member
identity (uid, kind, parameter types) and the DocFX api/*.html pages supply the
signature detail xrefmap lacks (modifiers, return types, base types, default
parameter values, property accessors). The nupkg {PackageName}.xml files were
evaluated and rejected: their <member name="M:..."> keys are the same identity
as commentId, so they add prose rather than signatures, and would have made the
tool network-dependent. Nothing is downloaded and no build is triggered.

Declarations are located by the data-uid DocFX writes on each heading, which is
byte-identical to the xrefmap uid. This is a correctness requirement rather than
a style choice: Archetype-N injects runnable code samples into the same pages, so
a flat lang-csharp scan reports "using IronZip;" as a member.

Because a uid encodes parameter types, a parameter-type change or a new overload
appears as a removal plus an addition; the report notes the pairing. Two build
nondeterminism artifacts are normalised so they cannot produce permanent false
positives: DocFX <GUID> markers are stripped (745 uids in ironpdf, 663 in
ironword, 68 in ironocr, 2 in ironxl, which alone made IronXL report a phantom
2 breaking + 2 additive in every release), and obfuscated Iron.Pdf.Extensions
types are filtered as a backstop for versions built before the filterConfig fix.

Verified: 63/63 golden-corpus parity checks across 7 products, 3 version spans
and 3 flag combinations, comparing exit codes, stdout, and byte-identical JSON;
zero unexplained classifications across all 11 products diffed oldest to newest;
ironpdf at 341 pages per version diffs in 0.95s, so no cache layer is needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ
74 diffs generated with diff-apidocs: one per consecutive 2026 release pair,
using the last pre-2026 release as the entry baseline, plus a cumulative
year diff per product. JSON for machine consumption, Markdown for reading.

Highlights from the year:

  ironpdf   45 breaking / 106 additive cumulative, opening with an
            IBoundedDocumentObject -> IBoundedPdfDocumentObject rename that
            cascades through the object model
  ironword  218 breaking in 2026.1.4 alone: 13 members bool -> Nullable<bool>,
            10 lost their set accessor, 9 dropped IDocumentElement
  ironocr   22 breaking / 29 additive across six releases
  ironzip, ironprint, ironwebscraper, irondrawing, ironxl
            no public API changes all year

ironqr nets to zero cumulatively despite non-zero steps: IQrInput and
QrImageInput lost IDisposable in 2026.1.1 and had it restored in 2026.1.2, an
accidental breaking change that shipped and was hotfixed a release later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ
… line

Between the 2026.6 and 2026.7 builds DocFX stopped inlining interfaces in the
declaration line, while the Implements section stayed byte-identical:

  2026.6.2  Implements: System.IDisposable
            decl:       public class IronZipArchive : IronBaseArchive, IDisposable
  2026.7.2  Implements: System.IDisposable          <- unchanged
            decl:       public class IronZipArchive : IronBaseArchive

Nothing about the API changed, but reading interfaces from the declaration
reported 183 breaking changes across 11 products. Reading them from Implements
reports 21 — IronWord's genuinely removed types, corroborated independently
against the raw archive. IronPDF 2026.7 goes from 53 breaking to 0.

parse_implements() reads the section, _implements_reasons() compares it, and
_base_reasons() now compares only the base class from the declaration. Interface
pages carry no Implements section (nor do a few classes), so for those an
interface-shaped base-list difference is reported as cosmetic with an explicit
"unverifiable" note rather than counted as breaking. That can under-report a real
interface removal on an interface type, which is the better trade against
re-reporting a rendering change as ~160 breaking changes.

Also closes the remaining obfuscation routes and one determinism bug:

- Obfuscated Iron.Pdf.Extensions names reach a diff three ways, not one: as a
  type, as a fully-qualified parameter type inside a member uid, and as a bare
  simple name in a base list. BLOCK_NS is now unanchored and applied to whole
  member uids, and Surface.blocked_type_names carries the simple names.
- BLOCK_NS gains a word boundary on Interop; without it the alternative matched
  System.Runtime.InteropServices and wrongly dropped 134 of ironocr's 1522
  members. Blast radius is now 8 members across all products, all with genuinely
  internal parameter types.
- _without_blocked removes names longest-first. Iterating the set unsorted let
  IEnumerable strip out of IEnumerable<Cell> first, leaving a stray <Cell>, so
  results depended on PYTHONHASHSEED and varied run to run.

Verified: 63/63 parity checks, both ports agree across all 11 products, and five
consecutive identical runs confirm determinism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ
85 diffs across 11 products: one per consecutive 2026 release pair, using the
last pre-2026 release as the entry baseline, plus a cumulative year-to-date diff
each. Replaces the earlier set, which predated the Implements-section fix and the
obfuscation filtering and so overstated breaking changes.

Year to date:

  ironword  189 breaking / 100 additive — a large 2026.1.4 refactor, plus 21
            types removed in 2026.7.1 (Text, Image, Shape, Style and 13 enums)
  ironpdf    25 breaking / 127 additive — 2026.7.2 is purely additive: 27 new
            members including ExtractTextFromLayer and the ContentOverlapBehavior
            header/footer overloads
  ironocr    21 breaking /  29 additive
  ironbarcode, irondrawing, ironppt, ironprint    additions only
  ironqr, ironwebscraper, ironxl, ironzip         no public API change all year

Cosmetic entries on the 2026.7 pairs are the DocFX declaration-rendering change
on pages with no Implements section to verify against, not API changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMFBtpbUYSURA6Y911CvFJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant