@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
487 lines (436 loc) • 18.8 kB
JavaScript
/**
* Property Comparison Tool
*
* Compares two property JSON files and generates a detailed report of:
* - New properties added
* - Properties with changed defaults
* - Properties with changed descriptions
* - Properties with changed types
* - Deprecated properties
* - Removed deprecated properties (deprecated in old version, deleted from source in new)
* - Removed properties
* - Properties with empty descriptions (excluding deprecated)
*/
const fs = require('fs');
const path = require('path');
const semver = require('semver');
/**
* The minimum version where Redpanda removes deprecated properties from the
* C++ source code instead of keeping them tagged as deprecated_property.
*/
const DEPRECATED_REMOVAL_MIN_VERSION = '26.1.0';
/**
* Check if the new version deletes deprecated properties from source.
*
* Before v26.1, deprecated properties were kept in the C++ source tagged as
* deprecated_property. Starting from v26.1, they are deleted entirely. When
* a property exists in the old version but is absent in the new version, and
* the new version is >= v26.1, we treat it as a deprecation (the property was
* removed because it was deprecated).
*
* Returns false only when the new version is explicitly below v26.1.
* For unparseable versions (e.g. "dev", "main"), assumes the latest behavior.
*
* @param {string} newVersion - The new version string.
* @returns {boolean} True if the new version deletes deprecated properties.
*/
function isCrossDeprecatedRemovalBoundary(newVersion) {
// Only skip if the new version is explicitly below the boundary
const newCleaned = semver.coerce(newVersion);
if (newCleaned && semver.lt(newCleaned, DEPRECATED_REMOVAL_MIN_VERSION)) {
return false;
}
// New version is either >= 26.1 or unparseable (e.g. "dev", "main").
// In both cases, assume the new version has the removal behavior.
return true;
}
/**
* Recursively compares two values for structural deep equality.
*
* - Returns true if values are strictly equal (`===`).
* - Returns false if types differ or either is `null`/`undefined` while the other is not.
* - Arrays: ensures same length and recursively compares each element.
* - Objects: compares own enumerable keys (order-insensitive) and recursively compares corresponding values.
*
* @param {*} a - First value to compare.
* @param {*} b - Second value to compare.
* @returns {boolean} True if the two values are deeply equal.
*/
function deepEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, i) => deepEqual(val, b[i]));
}
if (typeof a === 'object' && typeof b === 'object') {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => keysB.includes(key) && deepEqual(a[key], b[key]));
}
return false;
}
/**
* Format a value for concise human-readable display in comparison reports.
*
* Converts various JavaScript values into short string representations used
* in the report output:
* - null/undefined → `'null'`
* - Array:
* - empty → `'[]'`
* - single item → `[<formatted item>]` (recursively formatted)
* - multiple items → `'[<n> items]'`
* - Object → JSON string via `JSON.stringify`
* - String → quoted
* - Other primitives → `String(value)`
*
* @param {*} value - The value to format for display.
* @return {string} A concise string suitable for report output.
*/
function formatValue(value) {
if (value === null || value === undefined) {
return 'null';
}
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
if (value.length === 1) return `[${formatValue(value[0])}]`;
return `[${value.length} items]`;
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
if (typeof value === 'string') {
return `"${value}"`;
}
return String(value);
}
/**
* Extracts a flat map of property definitions from a parsed JSON schema or similar object.
*
* If the input contains a top-level `properties` object, that object is returned directly.
* Otherwise, the function scans the root keys (excluding `definitions`) and returns any
* entries that look like property definitions (an object with at least one of `type`,
* `description`, or `default`).
*
* @param {Object} data - Parsed JSON data to scan for property definitions.
* @returns {Object} A map of property definitions (key → property object). Returns an empty object if none found.
*/
function extractProperties(data) {
// Properties are nested under a 'properties' key in the JSON structure
if (data.properties && typeof data.properties === 'object') {
return data.properties;
}
// Fallback: look for properties at root level
const properties = {};
for (const [key, value] of Object.entries(data)) {
if (key !== 'definitions' && typeof value === 'object' && value !== null) {
// Check if this looks like a property definition
if (value.hasOwnProperty('type') || value.hasOwnProperty('description') || value.hasOwnProperty('default')) {
properties[key] = value;
}
}
}
return properties;
}
/**
* Compare two property JSON structures and produce a detailed change report.
*
* Compares properties extracted from oldData and newData and classifies differences
* into newProperties, changedDefaults, changedDescriptions, changedTypes,
* deprecatedProperties (newly deprecated in newData), removedProperties, and
* emptyDescriptions (non-deprecated properties missing descriptions in newData).
* Default equality is determined by a deep structural comparison.
*
* @param {Object} oldData - Parsed JSON of the older property file.
* @param {Object} newData - Parsed JSON of the newer property file.
* @param {string} oldVersion - Version string corresponding to oldData.
* @param {string} newVersion - Version string corresponding to newData.
* @return {Object} Report object with arrays: newProperties, changedDefaults,
* changedDescriptions, changedTypes, deprecatedProperties, removedProperties, emptyDescriptions.
*/
function compareProperties(oldData, newData, oldVersion, newVersion) {
const oldProps = extractProperties(oldData);
const newProps = extractProperties(newData);
const removesDeprecated = isCrossDeprecatedRemovalBoundary(newVersion);
const report = {
newProperties: [],
changedDefaults: [],
changedDescriptions: [],
changedTypes: [],
deprecatedProperties: [],
removedDeprecatedProperties: [],
removedProperties: [],
emptyDescriptions: []
};
// Find new properties
for (const [name, prop] of Object.entries(newProps)) {
if (!oldProps.hasOwnProperty(name)) {
report.newProperties.push({
name,
type: prop.type,
default: prop.default,
description: prop.description || 'No description'
});
}
}
// Find changed properties
for (const [name, oldProp] of Object.entries(oldProps)) {
if (newProps.hasOwnProperty(name)) {
const newProp = newProps[name];
// Check for deprecation first (using is_deprecated field only)
const isNewlyDeprecated = newProp.is_deprecated === true &&
oldProp.is_deprecated !== true;
if (isNewlyDeprecated) {
report.deprecatedProperties.push({
name,
reason: newProp.deprecatedReason || 'Property marked as deprecated'
});
// Skip other change detection for deprecated properties
continue;
}
// Only check other changes if property is not newly deprecated
// Check for default value changes
if (!deepEqual(oldProp.default, newProp.default)) {
report.changedDefaults.push({
name,
oldDefault: oldProp.default,
newDefault: newProp.default
});
}
// Check for description changes
if (oldProp.description !== newProp.description) {
report.changedDescriptions.push({
name,
oldDescription: oldProp.description || 'No description',
newDescription: newProp.description || 'No description'
});
}
// Check for type changes
if (oldProp.type !== newProp.type) {
report.changedTypes.push({
name,
oldType: oldProp.type,
newType: newProp.type
});
}
} else {
// Property was removed - skip experimental properties
// Check both the is_experimental_property field and development_ prefix
const isExperimental = oldProp.is_experimental_property || name.startsWith('development_');
if (!isExperimental) {
// In v26.1+, deprecated properties are deleted from source instead of
// being tagged. If a property existed before and is gone now, it was
// deprecated.
if (removesDeprecated) {
report.removedDeprecatedProperties.push({
name,
type: oldProp.type,
description: oldProp.description || 'No description'
});
} else {
report.removedProperties.push({
name,
type: oldProp.type,
description: oldProp.description || 'No description'
});
}
}
}
}
// Find properties with empty descriptions in the new version (excluding deprecated)
for (const [name, prop] of Object.entries(newProps)) {
const hasEmptyDescription = !prop.description ||
(typeof prop.description === 'string' && prop.description.trim().length === 0) ||
prop.description === 'No description';
if (hasEmptyDescription && !prop.is_deprecated) {
report.emptyDescriptions.push({
name,
type: prop.type
});
}
}
return report;
}
/**
* Print a human-readable console report summarizing property differences between two versions.
*
* The report includes sections for new properties, properties with changed defaults,
* changed types, updated descriptions, newly deprecated properties (with reason), and removed properties.
*
* @param {Object} report - Comparison report object returned by compareProperties().
* @param {string} oldVersion - Label for the old version (displayed as the "from" version).
* @param {string} newVersion - Label for the new version (displayed as the "to" version).
*/
function generateConsoleReport(report, oldVersion, newVersion) {
console.log('\n' + '='.repeat(60));
console.log(`📋 Property Changes Report (${oldVersion} → ${newVersion})`);
console.log('='.repeat(60));
if (report.newProperties.length > 0) {
console.log(`\n➤ New properties (${report.newProperties.length}):`);
report.newProperties.forEach(prop => {
console.log(` • ${prop.name} (${prop.type}) — default: ${formatValue(prop.default)}`);
});
} else {
console.log('\n➤ No new properties.');
}
if (report.changedDefaults.length > 0) {
console.log(`\n➤ Properties with changed defaults (${report.changedDefaults.length}):`);
report.changedDefaults.forEach(prop => {
console.log(` • ${prop.name}:`);
console.log(` - Old: ${formatValue(prop.oldDefault)}`);
console.log(` - New: ${formatValue(prop.newDefault)}`);
});
} else {
console.log('\n➤ No default value changes.');
}
if (report.changedTypes.length > 0) {
console.log(`\n➤ Properties with changed types (${report.changedTypes.length}):`);
report.changedTypes.forEach(prop => {
console.log(` • ${prop.name}: ${prop.oldType} → ${prop.newType}`);
});
}
if (report.changedDescriptions.length > 0) {
console.log(`\n➤ Properties with updated descriptions (${report.changedDescriptions.length}):`);
report.changedDescriptions.forEach(prop => {
console.log(` • ${prop.name} — description updated`);
});
}
if (report.deprecatedProperties.length > 0) {
console.log(`\n➤ Newly deprecated properties (${report.deprecatedProperties.length}):`);
report.deprecatedProperties.forEach(prop => {
console.log(` • ${prop.name} — ${prop.reason}`);
});
}
if (report.removedDeprecatedProperties.length > 0) {
console.log(`\n➤ Removed deprecated properties (${report.removedDeprecatedProperties.length}):`);
console.log(` These were deprecated in ${oldVersion} and removed from source in ${newVersion}.`);
report.removedDeprecatedProperties.forEach(prop => {
console.log(` • ${prop.name} (${prop.type})`);
});
}
if (report.removedProperties.length > 0) {
console.log(`\n➤ Removed properties (${report.removedProperties.length}):`);
report.removedProperties.forEach(prop => {
console.log(` • ${prop.name} (${prop.type})`);
});
}
if (report.emptyDescriptions.length > 0) {
console.log(`\nWarning: Properties with empty descriptions (${report.emptyDescriptions.length}):`);
report.emptyDescriptions.forEach(prop => {
console.log(` • ${prop.name} (${prop.type})`);
});
}
console.log('\n' + '='.repeat(60));
}
/**
* Write a structured JSON comparison report to disk.
*
* Produces a JSON file containing a comparison header (old/new versions and timestamp),
* a summary with counts for each change category, and the full details object passed as `report`.
*
* @param {Object} report - Comparison details object produced by compareProperties; expected to contain arrays: `newProperties`, `changedDefaults`, `changedDescriptions`, `changedTypes`, `deprecatedProperties`, `removedProperties`, and `emptyDescriptions`.
* @param {string} oldVersion - The previous version identifier included in the comparison header.
* @param {string} newVersion - The new version identifier included in the comparison header.
* @param {string} outputPath - Filesystem path where the JSON report will be written.
*/
function generateJsonReport(report, oldVersion, newVersion, outputPath) {
const jsonReport = {
comparison: {
oldVersion,
newVersion,
timestamp: new Date().toISOString()
},
summary: {
newProperties: report.newProperties.length,
changedDefaults: report.changedDefaults.length,
changedDescriptions: report.changedDescriptions.length,
changedTypes: report.changedTypes.length,
deprecatedProperties: report.deprecatedProperties.length,
removedDeprecatedProperties: report.removedDeprecatedProperties.length,
removedProperties: report.removedProperties.length,
emptyDescriptions: report.emptyDescriptions.length
},
details: report
};
fs.writeFileSync(outputPath, JSON.stringify(jsonReport, null, 2));
console.log(`📄 Detailed JSON report saved to: ${outputPath}`);
}
/**
* Compare two property JSON files and produce a change report.
*
* Reads and parses the two JSON files at oldFilePath and newFilePath, compares their properties
* using compareProperties, prints a human-readable console report, and optionally writes a
* structured JSON report to outputDir/filename.
*
* Side effects:
* - Synchronously reads the two input files.
* - Writes a JSON report file when outputDir is provided (creates the directory if needed).
* - Logs progress and results to the console.
* - On error, logs the error and exits the process with code 1.
*
* @param {string} oldFilePath - Path to the old property JSON file.
* @param {string} newFilePath - Path to the new property JSON file.
* @param {string} oldVersion - Version label for the old file (used in reports).
* @param {string} newVersion - Version label for the new file (used in reports).
* @param {string|undefined} outputDir - Optional directory to write the JSON report; if falsy, no file is written.
* @param {string} [filename='property-changes.json'] - Name of the JSON report file to write inside outputDir.
* @returns {Object} The comparison report object produced by compareProperties.
*/
function comparePropertyFiles(oldFilePath, newFilePath, oldVersion, newVersion, outputDir, filename = 'property-changes.json') {
try {
console.log(`Comparing property files:`);
console.log(` Old: ${oldFilePath}`);
console.log(` New: ${newFilePath}`);
const oldData = JSON.parse(fs.readFileSync(oldFilePath, 'utf8'));
const newData = JSON.parse(fs.readFileSync(newFilePath, 'utf8'));
const report = compareProperties(oldData, newData, oldVersion, newVersion);
// Merge removed deprecated properties back into the new JSON so they
// remain in generated documentation with is_deprecated + removed_deprecated flags
if (report.removedDeprecatedProperties.length > 0) {
const oldProps = extractProperties(oldData);
const newProps = extractProperties(newData);
report.removedDeprecatedProperties.forEach(({ name }) => {
if (oldProps[name] && !newProps[name]) {
newProps[name] = {
...oldProps[name],
is_deprecated: true,
removed_deprecated: true,
visibility: 'deprecated'
};
}
});
newData.properties = newProps;
fs.writeFileSync(newFilePath, JSON.stringify(newData, null, 2), 'utf8');
console.log(`\nMerged ${report.removedDeprecatedProperties.length} removed deprecated properties back into ${newVersion} JSON`);
}
// Generate console report
generateConsoleReport(report, oldVersion, newVersion);
// Generate JSON report if output directory provided
if (outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const jsonReportPath = path.join(outputDir, filename);
generateJsonReport(report, oldVersion, newVersion, jsonReportPath);
}
return report;
} catch (error) {
console.error(`Error: Error comparing properties: ${error.message}`);
process.exit(1);
}
}
// CLI usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 4) {
console.log('Usage: node compare-properties.js <old-file> <new-file> <old-version> <new-version> [output-dir] [filename]');
console.log('');
console.log('Example:');
console.log(' node compare-properties.js gen/v25.1.1-properties.json gen/v25.2.2-properties.json v25.1.1 v25.2.2 modules/reference property-changes-v25.1.1-to-v25.2.2.json');
process.exit(1);
}
const [oldFile, newFile, oldVersion, newVersion, outputDir, filename] = args;
comparePropertyFiles(oldFile, newFile, oldVersion, newVersion, outputDir, filename);
}
module.exports = { comparePropertyFiles, compareProperties };