agg-changes
Version:
Utility to aggregate versioned CHANGES-*.md files.
403 lines (361 loc) • 17.3 kB
JavaScript
// --- ADD DEBUG ---
// console.log(">>> Raw process.argv:", JSON.stringify(process.argv));
// ---------------
import fs from 'fs/promises';
import path from 'path';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import semver from 'semver';
// --- Constants ---
const SOURCE_DIR_DEFAULT = './CHANGES'; // Default input/output dir for CHANGES files
const INDIVIDUAL_FILE_PREFIX = 'CHANGES-';
const INDIVIDUAL_FILE_SUFFIX = '.md';
const AGGREGATED_FILE_PREFIX = 'ALL_CHANGES--';
const AGGREGATED_FILE_SEPARATOR = '-to--';
const AGGREGATED_FILE_SUFFIX = '.md';
// Output directory for aggregated file now defaults to the source dir
const FILE_ENCODING = 'utf-8';
const HEADER_TEMPLATE = '# CHANGES-{ver}.md'; // {ver} will be replaced
const ENTRY_SEPARATOR = '\n---\n'; // Separator between entries in aggregated file
// --- Argument Parsing ---
const argsToParse = hideBin(process.argv); // Get args after node/script path
// --- ADD DEBUG ---
//console.log(">>> Args passed to yargs:", JSON.stringify(argsToParse));
// ---------------
const argv = yargs(argsToParse) // Pass the prepared args
.usage('Usage: $0 [options]')
.option('d', {
alias: 'directory',
describe: `Directory for individual and aggregated CHANGES files (input/output)`, // Clarified description
type: 'string',
default: SOURCE_DIR_DEFAULT,
})
.option('sv', {
alias: 'start-version',
describe: 'Minimum version (inclusive) to include in the output',
type: 'string',
})
.option('ev', {
alias: 'end-version',
describe: 'Maximum version (inclusive) to include in the output',
type: 'string',
})
.option('b', {
alias: 'build-from-files',
describe: 'Ignore existing aggregated files and build fresh only from individual files found',
type: 'boolean',
default: false,
})
.option('dr', {
alias: 'dry-run',
describe: 'Show what would be done without writing any files',
type: 'boolean',
default: false,
})
.help('h')
.alias('h', 'help')
.strict() // Report errors for unknown options
.wrap(yargs().terminalWidth()) // Adjust help message width
.argv;
// --- Helper Functions ---
/**
* Finds the path to the latest aggregated changes file in the specified directory. // Updated doc
* @param {string} dir - The directory to search.
* @returns {Promise<string|null>} Path to the latest file or null if none found.
*/
async function findLatestAggregatedFile(dir) {
let latestFile = null;
let latestVersion = null;
try {
const files = await fs.readdir(dir);
const aggFiles = files.filter(f =>
f.startsWith(AGGREGATED_FILE_PREFIX) && f.endsWith(AGGREGATED_FILE_SUFFIX)
);
for (const file of aggFiles) {
// --- Use a Stricter Regex for the End Version ---
// This regex ensures the end version part only contains typical version characters
// (digits, dots, hyphens for pre-releases, letters for pre-releases/build)
// It will NOT match if '-BACKUP-' or '-copy-' is part of the version string.
const match = file.match(new RegExp(
`^${AGGREGATED_FILE_PREFIX}(.+)${AGGREGATED_FILE_SEPARATOR}([0-9a-zA-Z.-]+)${AGGREGATED_FILE_SUFFIX}$`
));
// --- End of Regex Change ---
if (match && match[2]) { // Now only matches files with valid-looking end versions
const endVersionStr = match[2];
// Coercion might still be useful if the version is slightly non-standard but valid chars
const currentSemVer = semver.coerce(endVersionStr);
if (currentSemVer) {
if (!latestVersion || semver.gt(currentSemVer, latestVersion)) {
latestVersion = currentSemVer;
latestFile = path.join(dir, file);
} else if (semver.eq(currentSemVer, latestVersion)) {
// Handle cases where versions are equal (e.g., 1.0.0 and 1.0.0-beta)
// Prefer the release version over pre-release if coerced versions match.
// A simple heuristic: shorter original string is likely the release version.
const existingEndVersionStr = latestFile ? latestFile.match(new RegExp(`${AGGREGATED_FILE_SEPARATOR}([0-9a-zA-Z.-]+)${AGGREGATED_FILE_SUFFIX}$`))[1] : '';
if (endVersionStr.length < existingEndVersionStr.length) {
latestVersion = currentSemVer; // Keep existing latestVersion object
latestFile = path.join(dir, file); // Update to the shorter-named file
}
// Add more sophisticated tie-breaking if needed (e.g., check semver.prerelease)
}
} else {
console.warn(`[WARN] Could not coerce end version from potential aggregated file: ${file}`);
}
}
// Files like *-BACKUP-*.md or *-copy-*.md will simply not match the stricter regex
}
} catch (err) {
if (err.code === 'ENOENT') {
console.log(`[INFO] Directory ${dir} not found while searching for base aggregated file.`);
} else {
console.error(`[ERROR] Failed to read directory ${dir} while searching for base aggregated file:`, err);
}
}
return latestFile;
}
/**
* Parses an aggregated file into a map of version strings to content blocks.
* @param {string} filePath - Path to the aggregated file.
* @returns {Promise<Map<string, string>>} Map of version to content.
*/
async function parseAggregatedFile(filePath) { // No change needed here
const versionMap = new Map();
if (!filePath) return versionMap;
try {
const content = await fs.readFile(filePath, FILE_ENCODING);
// Split carefully, handling potential variations in line endings around separator
const entries = content.split(new RegExp(`\\s*${ENTRY_SEPARATOR.trim()}\\s*`, 'g'));
let currentVersion = null;
let currentContentList = []; // Collect lines for current version
for (const entry of entries) {
if (!entry.trim()) continue; // Skip empty parts
const headerMatch = entry.match(/^\s*#\s*CHANGES-(.+)\.md\s*/); // Find header
if (headerMatch && headerMatch[1]) {
// If we were tracking a previous version, save it
if (currentVersion) {
versionMap.set(currentVersion, currentContentList.join('\n').trim());
}
// Start tracking the new version
currentVersion = headerMatch[1].trim();
// Get content *after* the header line
currentContentList = [entry.substring(headerMatch[0].length).trimStart()];
} else if (currentVersion) {
// If it's not a new header, append to the current content list
currentContentList.push(entry);
}
// Ignore content before the first header
}
// Save the last entry
if (currentVersion) {
versionMap.set(currentVersion, currentContentList.join('\n').trim());
}
} catch (err) {
if (err.code === 'ENOENT') {
console.log(`[INFO] Base aggregated file ${filePath} not found, starting fresh.`);
} else {
console.error(`[ERROR] Failed to read or parse base aggregated file ${filePath}:`, err);
}
// Return empty map on error or if file not found
return new Map();
}
return versionMap;
}
/**
* Finds individual CHANGES-<ver>.md files in a directory.
* @param {string} dir - The directory to search.
* @returns {Promise<string[]>} Array of full paths to matching files.
*/
async function findIndividualFiles(dir) { // No change needed here
try {
const files = await fs.readdir(dir);
return files
.filter(f => f.startsWith(INDIVIDUAL_FILE_PREFIX) && f.endsWith(INDIVIDUAL_FILE_SUFFIX))
.map(f => path.join(dir, f));
} catch (err) {
if (err.code === 'ENOENT') {
console.error(`[ERROR] Source directory not found: ${dir}`);
} else {
console.error(`[ERROR] Failed to read source directory ${dir}:`, err);
}
return []; // Return empty array on error
}
}
/**
* Extracts the version string from an individual changes filename.
* @param {string} filename - The filename (e.g., CHANGES-1.2.3.md).
* @returns {string|null} The version string or null if not matched.
*/
function extractVersion(filename) { // No change needed here
const baseName = path.basename(filename);
const match = baseName.match(new RegExp(`^${INDIVIDUAL_FILE_PREFIX}(.+)${INDIVIDUAL_FILE_SUFFIX}$`));
return match ? match[1] : null;
}
/**
* Checks if a filename needs the -copy-# suffix and finds the next available one within the target directory. // Updated doc
* @param {string} dir - The target directory where the file will be written. // Added param
* @param {string} baseFilename - The desired base filename (e.g., ALL_CHANGES--1-to-2.md).
* @returns {Promise<string>} The unique filename (basename only) to use. // Returns basename
*/
async function findUniqueFilename(dir, baseFilename) { // Added dir param
let counter = 1;
let targetBasename = baseFilename;
const ext = AGGREGATED_FILE_SUFFIX;
const base = baseFilename.slice(0, -ext.length);
while (true) {
const targetPath = path.join(dir, targetBasename); // Check within target dir
try {
await fs.access(targetPath, fs.constants.F_OK);
// File exists, try next copy number
targetBasename = `${base}-copy-${counter}${ext}`;
counter++;
} catch (err) {
if (err.code === 'ENOENT') {
// File does not exist, this is our unique name
return targetBasename; // Return the basename
} else {
// Other error accessing file
console.error(`[ERROR] Could not check existence of ${targetPath}:`, err);
// Fallback to base name, hoping for the best or letting writeFile fail
return baseFilename;
}
}
}
}
// --- Main Logic ---
async function main() {
// We correctly resolve the path here using path.resolve() on the parsed argv.directory
const targetDir = path.resolve(argv.directory); // Use resolved absolute path for target directory
console.log(`[INFO] Target directory (Input/Output): ${targetDir}`);
console.log('[INFO] Starting changes aggregation...');
// Ensure target directory exists before writing
try {
await fs.mkdir(targetDir, { recursive: true });
} catch (err) {
console.error(`[ERROR] Could not create target directory ${targetDir}:`, err);
process.exit(1);
}
if (argv.dryRun) {
console.log('[INFO] Dry Run Mode: No files will be written.');
}
let versionMap = new Map();
// 1. Determine base map (unless -b is specified)
if (!argv.buildFromFiles) {
const latestAggFile = await findLatestAggregatedFile(targetDir); // Search in targetDir
if (latestAggFile) {
console.log(`[INFO] Using base aggregated file: ${latestAggFile}`);
versionMap = await parseAggregatedFile(latestAggFile);
console.log(`[INFO] Parsed ${versionMap.size} entries from base file.`);
} else {
console.log('[INFO] No existing aggregated file found in target directory.');
}
} else {
console.log('[INFO] Build-from-files mode (-b): Ignoring existing aggregated files.');
}
// 2. Scan directory for individual files
const individualFiles = await findIndividualFiles(targetDir); // Scan targetDir
if (individualFiles.length === 0 && versionMap.size === 0) {
console.log(`[INFO] No individual files found in ${targetDir} and no base map loaded. Nothing to do.`);
return;
}
console.log(`[INFO] Found ${individualFiles.length} individual files in ${targetDir}.`);
// 3. Update map with individual file contents
let readCount = 0;
for (const file of individualFiles) {
const version = extractVersion(file);
if (!version) {
console.warn(`[WARN] Could not extract version from: ${file}`);
continue;
}
try {
const content = await fs.readFile(file, FILE_ENCODING);
versionMap.set(version, content.trim());
readCount++;
} catch (err) {
console.error(`[ERROR] Failed to read individual file ${file}:`, err);
}
}
console.log(`[INFO] Read and updated/added ${readCount} entries from individual files.`);
console.log(`[INFO] Total entries in map before filtering: ${versionMap.size}`);
// 4. Filter map based on --sv / --ev
let filteredMap = versionMap;
let rangeString = '';
if (argv.startVersion) rangeString += `>=${argv.startVersion} `;
if (argv.endVersion) rangeString += `<=${argv.endVersion}`;
rangeString = rangeString.trim();
if (rangeString) {
console.log(`[INFO] Filtering versions by range: "${rangeString}"`);
filteredMap = new Map();
for (const [version, content] of versionMap.entries()) {
const sv = semver.coerce(version); // Allow flexible version strings
if (sv && semver.satisfies(sv, rangeString, { includePrerelease: true })) {
filteredMap.set(version, content);
}
}
console.log(`[INFO] Entries remaining after filtering: ${filteredMap.size}`);
}
// 5. Check if map is empty
if (filteredMap.size === 0) {
console.log('[INFO] No versions remaining after filtering (or none found initially). No output file will be generated.');
return;
}
// 6. Sort versions
const sortedVersions = Array.from(filteredMap.keys()).sort((a, b) => {
// Use coerce to handle potentially non-standard versions for comparison
const svA = semver.coerce(a);
const svB = semver.coerce(b);
if (svA && svB) return semver.compare(svA, svB);
if (svA && !svB) return -1; // Treat valid semver as less than invalid
if (!svA && svB) return 1; // Treat invalid semver as greater than valid
// Fallback basic string compare if coerce fails on both
return a.localeCompare(b);
});
// 7. Determine final range and generate output content
const finalMinVer = sortedVersions[0];
const finalMaxVer = sortedVersions[sortedVersions.length - 1];
let outputContent = '';
for (let i = 0; i < sortedVersions.length; i++) {
const version = sortedVersions[i];
const content = filteredMap.get(version);
const header = HEADER_TEMPLATE.replace('{ver}', version);
outputContent += `${header}\n\n${content}`;
if (i < sortedVersions.length - 1) {
outputContent += ENTRY_SEPARATOR;
}
}
// 8. Determine output filename (basename only first)
const baseOutputBasename = `${AGGREGATED_FILE_PREFIX}${finalMinVer}${AGGREGATED_FILE_SEPARATOR}${finalMaxVer}${AGGREGATED_FILE_SUFFIX}`;
let targetBasename = baseOutputBasename;
if (argv.buildFromFiles) {
// Check for existing file only in -b mode to add -copy-#
targetBasename = await findUniqueFilename(targetDir, baseOutputBasename); // Pass targetDir
}
// 9. Construct final target path
const targetFilename = path.join(targetDir, targetBasename); // Use targetDir
// 10. Write output (or show dry run info)
console.log('--- Aggregation Summary ---');
console.log(`Source/Output Directory: ${targetDir}`); // Unified directory
console.log(`Build Mode: ${argv.buildFromFiles ? 'Build From Files (-b)' : 'Merge/Update (Default)'}`);
if (rangeString) console.log(`Version Filter: ${rangeString}`);
console.log(`Versions Included: ${sortedVersions.length} (Range: ${finalMinVer} to ${finalMaxVer})`);
console.log(`Output File: ${targetFilename}`); // Show final resolved path
console.log('--------------------------');
if (argv.dryRun) {
console.log('[INFO] Dry Run complete. No file written.');
} else {
try {
// Ensure target directory exists again just before writing (in case it was deleted)
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(targetFilename, outputContent, { encoding: FILE_ENCODING });
console.log(`[SUCCESS] Successfully wrote aggregated changes to ${targetFilename}`);
} catch (err) {
console.error(`[ERROR] Failed to write output file ${targetFilename}:`, err);
}
}
}
// --- Execute Main ---
main().catch(err => {
process.exit(1); // Exit with error code on fatal error
console.error("[FATAL] An unexpected error occurred:", err); // Log after exit code set
});