blob: d7312fc32d8abb3fd63fb6f0ae2d238c999b6b83 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Summarises what changed between two Fineract OpenAPI specs, as Markdown.
*
* The point is the *removed* operations. ADR-0001 records a spec bump that broke
* 137 call sites across 63 components; the breakage is invisible in a 140k-line
* generated diff but obvious in a list of methods that no longer exist. Because
* the generator derives method names from method+path through
* preprocess-spec.mjs, this can name the exact TypeScript methods that disappear
* and count their call sites — before anyone merges.
*
* node scripts/spec-diff-summary.mjs <old-spec.json> <new-spec.json>
*
* Writes Markdown to stdout and, like scripts/e2e-summary.mjs, never exits
* non-zero: it is a reporting aid, and a crash here must not fail a sync that
* otherwise succeeded.
*/
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { deterministicName } from './preprocess-spec.mjs';
const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options']);
function read(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** `Map<"GET /loans/{id}", operation>` for every operation in the document. */
function operations(spec) {
const found = new Map();
for (const [path, item] of Object.entries(spec?.paths ?? {})) {
for (const [method, operation] of Object.entries(item ?? {})) {
if (HTTP_METHODS.has(method)) {
found.set(`${method.toUpperCase()} ${path}`, operation);
}
}
}
return found;
}
/** Files referencing `.<name>(`, so a removed operation's blast radius is visible. */
function callSites(methodName) {
try {
const out = execFileSync('grep', ['-rn', '--include=*.ts', `\\.${methodName}(`, 'src'], {
encoding: 'utf8',
});
return out.trim().split('\n').filter(Boolean);
} catch {
// grep exits 1 when nothing matches, which is the common and healthy case.
return [];
}
}
function table(rows) {
return ['| | added | removed | changed |', '|---|---|---|---|', ...rows].join('\n');
}
function main() {
const [oldFile, newFile] = process.argv.slice(2);
const oldSpec = read(oldFile);
const newSpec = read(newFile);
if (!oldSpec || !newSpec) {
console.log('_Could not read both specs, so no diff summary was produced._');
return;
}
const before = operations(oldSpec);
const after = operations(newSpec);
const added = [...after.keys()].filter((k) => !before.has(k)).sort();
const removed = [...before.keys()].filter((k) => !after.has(k)).sort();
const changed = [...after.keys()].filter(
(k) => before.has(k) && JSON.stringify(before.get(k)) !== JSON.stringify(after.get(k)),
);
const oldSchemas = Object.keys(oldSpec.components?.schemas ?? {});
const newSchemas = Object.keys(newSpec.components?.schemas ?? {});
const addedSchemas = newSchemas.filter((s) => !oldSchemas.includes(s));
const removedSchemas = oldSchemas.filter((s) => !newSchemas.includes(s));
const lines = ['### API surface diff', ''];
lines.push(
table([
`| paths | ${Object.keys(newSpec.paths ?? {}).length - Object.keys(oldSpec.paths ?? {}).length >= 0 ? '+' : ''}${Object.keys(newSpec.paths ?? {}).length - Object.keys(oldSpec.paths ?? {}).length} | | |`,
`| operations | ${added.length} | ${removed.length} | ${changed.length} |`,
`| schemas | ${addedSchemas.length} | ${removedSchemas.length} | |`,
]),
);
lines.push('');
if (removed.length) {
lines.push('#### ⚠️ Removed operations — these generated methods disappear', '');
lines.push('| operation | generated method | call sites in `src/` |');
lines.push('|---|---|---|');
const details = [];
for (const key of removed) {
const [method, path] = key.split(' ');
const name = deterministicName(method.toLowerCase(), path);
const sites = callSites(name);
const count = sites.length ? `**${sites.length}**` : '0';
lines.push(`| \`${key}\` | \`${name}\` | ${count} |`);
if (sites.length) details.push(...sites);
}
lines.push('');
if (details.length) {
lines.push('<details><summary>Exact call sites</summary>', '', '```');
lines.push(...details.slice(0, 100));
if (details.length > 100) lines.push(`…and ${details.length - 100} more`);
lines.push('```', '</details>', '');
}
} else {
lines.push('No operations were removed, so no existing call site can break.', '');
}
if (added.length) {
lines.push('<details><summary>Added operations</summary>', '', '```');
lines.push(...added.slice(0, 100));
if (added.length > 100) lines.push(`…and ${added.length - 100} more`);
lines.push('```', '</details>', '');
}
console.log(lines.join('\n'));
}
try {
main();
} catch (error) {
console.log(`_Diff summary failed: ${error.message}_`);
}