Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ It does two things:
| `object-reference/<code>/<version>/` | The published, committed documentation builds (the archive). |
| `iron-products.json` | Product catalog — package type, package name / Maven coordinates, domain, and URL path per product. |
| `scaffolds/` | DocFX templates, `docfx.<code>.json` configs, homepages, and the `tools/` toolchain (DocFX, JDK — Git LFS). |
| `docs/` | Internal operator notes (e.g. the Windows/DocFX limitation). |
| `docs/` | Internal operator notes (e.g. the Windows/DocFX limitation) and generated API diffs under `docs/api-diffs/`. |
| `apidiff/` | Modules behind `diff-apidocs`, in both ports. |
| `*.py` / `*.mjs` | The tooling, in two interchangeable ports (see below). |

## Tooling
Expand All @@ -33,6 +34,7 @@ Both versions read from the same `iron-products.json` and write to the same `obj
| --- | --- | --- |
| Inspect a product's version / build status | `check-apidocs.py` | `check-apidocs.mjs` |
| Generate any missing documentation | `update-apidocs.py` | `update-apidocs.mjs` |
| Diff the API surface between two versions | `diff-apidocs.py` | `diff-apidocs.mjs` |
| Shared version/path helpers | `apidocs.py` | `apidocs.mjs` |
| Colorized status output | `statuslogger.py` | `statuslogger.mjs` |

Expand Down Expand Up @@ -88,6 +90,46 @@ node update-apidocs.mjs
> [`docs/running-generation-in-wsl.md`](docs/running-generation-in-wsl.md).
> The version-inspection tooling (`check-apidocs`) runs fine on any platform.

### `diff-apidocs` — compare two archived versions

Reports what changed in a product's public API between two builds already in `object-reference/`,
classified as breaking, additive, or cosmetic.

```bash
# Python — newest vs previous archived version
python diff-apidocs.py -p ironzip
# Node — an explicit pair, written out as JSON + Markdown
node diff-apidocs.mjs -p ironzip --from 2024.1.1 --to 2026.6.2 --json --markdown
```

| Flag | Meaning |
| --- | --- |
| `-p, --product-code` | Product code (e.g. `ironzip`). |
| `-n, --product-name` | Product display name (alternative to the code). |
| `--from` / `--to` | The versions to compare. Omit both for newest-vs-previous; omit one and the newest is used for that end. |
| `--namespace GLOB` | Only report types matching the glob; repeatable. |
| `--exclude GLOB` | Skip types matching the glob; repeatable. |
| `--include-internal` | Include vendored/internal namespaces (`.Internal`, `Interop`, `grpc`, `Pdfium`, `BouncyCastle`, `GrpcLayer`), which are excluded by default. |
| `--all-visibility` | Include non-public declarations (public/protected only by default). |
| `--json [PATH]` / `--markdown [PATH]` | Write an artifact; defaults to `docs/api-diffs/<code>/<from>..<to>.{json,md}`. |
| `--quiet` / `--no-warnings` | Suppress the terminal report / parser warnings. |
| `--fail-on-breaking` | Exit `2` when breaking changes are found (for release gating). |
| `--list-versions` | List the product's archived versions and exit. |

Exit codes: `0` success, `1` tool error (unknown product, missing version), `2` breaking changes
found with `--fail-on-breaking`.

This tool is **entirely offline** — it reads only the committed archive, never a package registry, so
it needs no network and triggers no build. Two sources are combined per version: `xrefmap.yml` for
member identity (uid, kind, parameter types) and the DocFX `api/*.html` pages for the signature
detail xrefmap lacks (modifiers, return types, base types, default parameter values, and property
accessors). Each declaration is located by the `data-uid` DocFX writes on its heading, which is
byte-identical to the xrefmap uid.

Because a uid encodes parameter *types*, a parameter-type change or a new overload appears as a
removal plus an addition rather than a modification; the report notes the pairing. Only DocFX (.NET)
products are supported — `ironpdfjava` is JavaDoc output with no xrefmap and exits with a message.

### Archetype-N API-overview enhancement

After DocFX generates a .NET product's `…/object-reference/api/` pages, `update-apidocs` injects a short, task-led SEO overview into each class-reference page (prose + three meta-title/description variants + `TechArticle`/`FAQPage` JSON-LD), placed below the class summary and above the member tables, wrapped in `<!-- archetype-N:start … -->` / `<!-- archetype-N:end -->` sentinels (idempotent). This is on by default; pass `--no-enhancement` to skip it.
Expand All @@ -106,7 +148,7 @@ The per-product cache (committed, with a `_manifest.json`) makes steady-state re

## Dependencies

- **Python**: `requests`, `colorama` (`pip install requests colorama`).
- **Node.js**: `adm-zip` (`npm install`) — only needed by `update-apidocs.mjs` for nupkg extraction; `check-apidocs.mjs` and `apidocs.mjs` are dependency-free.
- **Python**: `requests`, `colorama` (`pip install requests colorama`). `diff-apidocs.py` is stdlib-only apart from `colorama` (via `statuslogger.py`) — it does not use `requests`.
- **Node.js**: `adm-zip` (`npm install`) — only needed by `update-apidocs.mjs` for nupkg extraction; `check-apidocs.mjs`, `diff-apidocs.mjs`, and `apidocs.mjs` are dependency-free.
- **Generation only**: the DocFX + JDK toolchain under `scaffolds/tools/` (Git LFS), plus `mono` on Linux to run DocFX.
- **Archetype-N enhancement**: stdlib-only (Python) / native (Node `>=18`, global `fetch`); no extra packages. An LLM API key is only needed to author pages not already in the cache.
3 changes: 3 additions & 0 deletions _config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ exclude:
- package-lock.json
- node_modules
- __pycache__
# The `*.py`/`*.mjs` globs above already cover its files; excluding the directory outright means
# nothing added under it can ever be published by accident.
- apidiff
- scaffolds
- docs
- .vscode
Expand Down
13 changes: 13 additions & 0 deletions apidiff/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""apidiff — API surface diffing for the object-reference archive.

Reads two built version directories under ``object-reference/<code>/`` and reports what changed in
the public API surface between them, classified as breaking, additive, or cosmetic.

Two committed sources are combined:

* ``xrefmap.yml`` supplies member *identity* (uid + kind + parameter types) for the whole product.
* ``api/<Type>.html`` supplies the *signature* detail xrefmap lacks — modifiers, return types,
base types, default parameter values, and property accessors.

Nothing is downloaded; the archive is the only input. See ``docs/api-surface-diff-plan.md``.
"""
147 changes: 147 additions & 0 deletions apidiff/archive.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* archive.mjs — resolve a product and a pair of archived versions, entirely offline
* (Node port of archive.py).
*
* Directory existence under `object-reference/<code>/` is the whole version index — there is no
* manifest — so every lookup here is a filesystem read. Nothing contacts a package registry, which
* is what lets the tool run on a machine with no network and no credentials.
*/

import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

import { PRODUCTS_CATALOG, getApidocPath, listArchivedVersions, compareVersions, versionSortKey } from "../apidocs.mjs";

/** Only DocFX-generated (.NET) products carry the xrefmap.yml + api/*.html pair this tool reads. */
export const SUPPORTED_PACKAGE_TYPES = new Set(["nuget"]);

/** A resolution failure with a message already written for the operator. */
export class ArchiveError extends Error {}

function loadCatalog() {
return JSON.parse(readFileSync(PRODUCTS_CATALOG, "utf-8"));
}

/** Return a product's catalog entry from iron-products.json. */
export function loadProduct(productCode = null, productName = null) {
if (!productCode && !productName) {
throw new ArchiveError("Please specify product code or product name (-p, --product-code | -n, --product-name)");
}
const catalog = loadCatalog();
for (const product of catalog.libraries) {
if ((productCode && product.code === productCode) || (productName && product.name === productName)) return product;
}
const known = catalog.libraries.map((product) => product.code).sort().join(", ");
throw new ArchiveError(`Specified product does not exist. Known product codes: ${known}`);
}

/** Product codes this tool can diff, in catalog order. */
export function supportedProductCodes() {
return loadCatalog().libraries
.filter((product) => SUPPORTED_PACKAGE_TYPES.has(product.packageType) && listArchivedVersions(product.code).length)
.map((product) => product.code);
}

/** Reject products whose docs are not DocFX output (JavaDoc, pip, npm). */
export function requireSupported(product) {
const packageType = product.packageType;
if (!SUPPORTED_PACKAGE_TYPES.has(packageType)) {
throw new ArchiveError(
`${product.code} is a ${packageType === "maven" ? "JavaDoc" : packageType} product `
+ `(packageType: ${packageType}).\n`
+ `Signature diffing is not yet supported. Supported: ${supportedProductCodes().join(", ")}`,
);
}
}

/** Distance stand-in for a component that cannot be compared numerically, so it always ranks last. */
const INCOMPARABLE = 10 ** 9;

/**
* Archived versions closest to the requested one, nearest first.
*
* Candidates are ranked first by *how deep* the first differing component is — sharing a major
* version matters more than a small difference in it, so 2026.5.9 suggests 2026.5.2 ahead of
* 2025.1.1 — and only then by the size of that difference.
*/
function nearest(version, available, limit = 5) {
const target = versionSortKey(version);

const distance = (candidate) => {
const key = versionSortKey(candidate);
const shared = Math.min(target.length, key.length);
for (let index = 0; index < shared; index++) {
const [leftKind, leftValue, leftText] = target[index];
const [rightKind, rightValue, rightText] = key[index];
if (leftKind !== rightKind || leftValue !== rightValue || leftText !== rightText) {
const magnitude = leftKind === 0 && rightKind === 0 ? Math.abs(leftValue - rightValue) : INCOMPARABLE;
// Negated so a difference appearing later (a longer shared prefix) sorts first.
return [-index, magnitude, candidate];
}
}
return [-target.length, 0, candidate];
};

return [...available]
.map((candidate) => [distance(candidate), candidate])
.sort(([a], [b]) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0))
.slice(0, limit)
.map(([, candidate]) => candidate);
}

/** Throw unless the version has a built directory in the archive. */
export function requireVersion(product, version, available) {
if (available.includes(version)) return;
const suggestions = available.length ? nearest(version, available).join(", ") : "(none archived)";
throw new ArchiveError(
`${product.code} ${version} is not in the archive.\n`
+ `Nearest archived: ${suggestions}\n`
+ "Run update-apidocs to build it.",
);
}

/**
* Resolve the pair of versions to diff. With neither given, the two newest archived versions are
* used; with only one, the other end is the newest archived version.
*
* @returns {[string, string, string[]]} `[versionFrom, versionTo, available]`, oldest end first.
*/
export function resolveVersions(product, versionFrom = null, versionTo = null) {
const available = listArchivedVersions(product.code);
if (!available.length) {
throw new ArchiveError(`No archived versions found for ${product.code} under object-reference/.`);
}

if (!versionFrom && !versionTo) {
if (available.length < 2) {
throw new ArchiveError(
`${product.code} has only one archived version (${available[0]}); nothing to diff against.`,
);
}
return [available[available.length - 2], available[available.length - 1], available];
}

if (versionFrom && !versionTo) versionTo = available[available.length - 1];
else if (versionTo && !versionFrom) versionFrom = available[available.length - 1];

requireVersion(product, versionFrom, available);
requireVersion(product, versionTo, available);

if (versionFrom === versionTo) {
throw new ArchiveError(`--from and --to are both ${versionFrom}; nothing to diff.`);
}

// Report oldest to newest regardless of the order the operator supplied them.
if (compareVersions(versionFrom, versionTo) > 0) [versionFrom, versionTo] = [versionTo, versionFrom];
return [versionFrom, versionTo, available];
}

/** Path to one archived version directory. */
export function versionDirectory(product, version) {
const path = getApidocPath(product, version);
const xrefmap = join(path, "xrefmap.yml");
if (!existsSync(xrefmap) || !statSync(xrefmap).isFile()) {
throw new ArchiveError(`${product.code} ${version} has no xrefmap.yml at ${path}; the build may be incomplete.`);
}
return path;
}
Loading