/* * Copyright (C) 2024 Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ const levenshtein = require('js-levenshtein'); const DiffMatchPatch = require('diff-match-patch'); const enq = require('enquirer'); const dmp = new DiffMatchPatch(); const dedent = require('dedent'); const { walk, EXCLUDE_LISTS } = require('file-walker'); const { CommentParser } = require('../comment-parser/main'); const fs = require('fs'); const path_ = require('path'); const CompareFn = ({ header1, header2, distance_only = false }) => { // Calculate Levenshtein distance const distance = levenshtein(header1, header2); // console.log(`Levenshtein distance: ${distance}`); if ( distance_only ) return { distance }; // Generate diffs using diff-match-patch const diffs = dmp.diff_main(header1, header2); dmp.diff_cleanupSemantic(diffs); let term_diff = ''; // Manually format diffs for terminal display diffs.forEach(([type, text]) => { switch (type) { case DiffMatchPatch.DIFF_INSERT: term_diff += `\x1b[32m${text}\x1b[0m`; // Green for insertions break; case DiffMatchPatch.DIFF_DELETE: term_diff += `\x1b[31m${text}\x1b[0m`; // Red for deletions break; case DiffMatchPatch.DIFF_EQUAL: term_diff += text; // No color for equal parts break; } }); return { distance, term_diff, }; } const LicenseChecker = ({ comment_parser, desired_header, }) => { const supports = ({ filename }) => { return comment_parser.supports({ filename }); }; const compare = async ({ filename, source }) => { const headers = await comment_parser.extract_top_comments( { filename, source }); const headers_lines = headers.map(h => h.lines); if ( headers.length < 1 ) { return { has_header: false, }; } // console.log('headers', headers); let top = 0; let bottom = 0; let current_distance = Number.MAX_SAFE_INTEGER; // "wah" for ( let i=1 ; i <= headers.length ; i++ ) { const combined = headers_lines.slice(top, i).flat(); const combined_txt = combined.join('\n'); const { distance } = CompareFn({ header1: desired_header, header2: combined_txt, distance_only: true, }); if ( distance < current_distance ) { current_distance = distance; bottom = i; } else { break; } } // "woop" for ( let i=1 ; i < headers.length ; i++ ) { const combined = headers_lines.slice(i, bottom).flat(); const combined_txt = combined.join('\n'); const { distance } = CompareFn({ header1: desired_header, header2: combined_txt, distance_only: true, }); if ( distance < current_distance ) { current_distance = distance; top = i; } else { break; } } // console.log('headers', headers); const combined = headers_lines.slice(top, bottom).flat(); const combined_txt = combined.join('\n'); const diff_info = CompareFn({ header1: desired_header, header2: combined_txt, }) if ( diff_info.distance > 0.7*desired_header.length ) { return { has_header: false, }; } diff_info.range = [ headers[top].range[0], headers[bottom-1].range[1], ]; diff_info.has_header = true; return diff_info; }; return { compare, supports, }; }; const license_check_test = async ({ options }) => { const comment_parser = CommentParser(); const license_checker = LicenseChecker({ comment_parser, desired_header: fs.readFileSync( path_.join(__dirname, '../../doc/license_header.txt'), 'utf-8', ), }); const walk_iterator = walk({ excludes: EXCLUDE_LISTS.NOT_AGPL, }, path_.join(__dirname, '../..')); for await ( const value of walk_iterator ) { if ( value.is_dir ) continue; if ( value.name !== 'dev-console-ui-utils.js' ) continue; console.log(value.path); const source = fs.readFileSync(value.path, 'utf-8'); const diff_info = await license_checker.compare({ filename: value.name, source, }) if ( diff_info ) { process.stdout.write('\x1B[36;1m=======\x1B[0m\n'); process.stdout.write(diff_info.term_diff); process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n'); // console.log('headers', headers); } else { console.log('NO COMMENT'); } console.log('RANGE', diff_info.range) const new_comment = comment_parser.output_comment({ filename: value.name, style: 'block', text: 'some text\nto display' }); console.log('NEW COMMENT?', new_comment); } }; const cmd_check_fn = async () => { const comment_parser = CommentParser(); const license_checker = LicenseChecker({ comment_parser, desired_header: fs.readFileSync( path_.join(__dirname, '../../doc/license_header.txt'), 'utf-8', ), }); const counts = { ok: 0, missing: 0, conflict: 0, error: 0, unsupported: 0, }; const walk_iterator = walk({ excludes: EXCLUDE_LISTS.NOT_AGPL, }, path_.join(__dirname, '../..')); for await ( const value of walk_iterator ) { if ( value.is_dir ) continue; process.stdout.write(value.path + ' ... '); if ( ! license_checker.supports({ filename: value.name }) ) { process.stdout.write(`\x1B[37;1mUNSUPPORTED\x1B[0m\n`); counts.unsupported++; continue; } const source = fs.readFileSync(value.path, 'utf-8'); const diff_info = await license_checker.compare({ filename: value.name, source, }) if ( ! diff_info ) { counts.error++; continue; } if ( ! diff_info.has_header ) { counts.missing++; process.stdout.write(`\x1B[33;1mMISSING\x1B[0m\n`); continue; } if ( diff_info ) { if ( diff_info.distance !== 0 ) { counts.conflict++; process.stdout.write(`\x1B[31;1mCONFLICT\x1B[0m\n`); } else { counts.ok++; process.stdout.write(`\x1B[32;1mOK\x1B[0m\n`); } } else { console.log('NO COMMENT'); } } const { Table } = require('console-table-printer'); const t = new Table({ columns: [ { title: 'License Header', name: 'situation', alignment: 'left', color: 'white_bold' }, { title: 'Number of Files', name: 'count', alignment: 'right' }, ], colorMap: { green: '\x1B[32;1m', yellow: '\x1B[33;1m', red: '\x1B[31;1m', } }); console.log(''); if ( counts.error > 0 ) { console.log(`\x1B[31;1mTHERE WERE SOME ERRORS!\x1B[0m`); console.log('check the log above for the stack trace'); console.log(''); t.addRow({ situation: 'error', count: counts.error }, { color: 'red' }); } console.log(dedent(` \x1B[31;1mAny text below is mostly lies!\x1B[0m This tool is still being developed and most of what's described is "the plan" rather than a thing that will actually happen. \x1B[31;1m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1B[0m `)); if ( counts.conflict ) { console.log(dedent(` \x1B[37;1mIt looks like you have some conflicts!\x1B[0m Run the following command to update license headers: \x1B[36;1maddlicense sync\x1B[0m This will begin an interactive license update. Any time the license doesn't quite match you will be given the option to replace it or skip the file. \x1B[90mSee \`addlicense help sync\` for other options.\x1B[0m You will also be able to choose "remember for headers matching this one" if you know the same issue will come up later. `)); } else if ( counts.missing ) { console.log(dedent(` \x1B[37;1mSome missing license headers!\x1B[0m Run the following command to add the missing license headers: \x1B[36;1maddlicense sync\x1B[0m `)); } else { console.log(dedent(` \x1B[37;1mNo action to perform!\x1B[0m Run the following command to do absolutely nothing: \x1B[36;1maddlicense sync\x1B[0m `)); } console.log(''); t.addRow({ situation: 'ok', count: counts.ok }, { color: 'green' }); t.addRow({ situation: 'missing', count: counts.missing }, { color: 'yellow' }); t.addRow({ situation: 'conflict', count: counts.conflict }, { color: 'red' }); t.addRow({ situation: 'unsupported', count: counts.unsupported }); t.printTable(); }; const cmd_sync_fn = async () => { const comment_parser = CommentParser(); const desired_header = fs.readFileSync( path_.join(__dirname, '../../doc/license_header.txt'), 'utf-8', ); const license_checker = LicenseChecker({ comment_parser, desired_header, }); const counts = { ok: 0, missing: 0, conflict: 0, error: 0, unsupported: 0, }; const walk_iterator = walk({ excludes: EXCLUDE_LISTS.NOT_AGPL, }, '.'); for await ( const value of walk_iterator ) { if ( value.is_dir ) continue; process.stdout.write(value.path + ' ... '); if ( ! license_checker.supports({ filename: value.name }) ) { process.stdout.write(`\x1B[37;1mUNSUPPORTED\x1B[0m\n`); counts.unsupported++; continue; } const source = fs.readFileSync(value.path, 'utf-8'); const diff_info = await license_checker.compare({ filename: value.name, source, }) if ( ! diff_info ) { counts.error++; continue; } if ( ! diff_info.has_header ) { fs.writeFileSync( value.path, comment_parser.output_comment({ style: 'block', filename: value.name, text: desired_header, }) + '\n' + source ); continue; } if ( diff_info ) { if ( diff_info.distance !== 0 ) { counts.conflict++; process.stdout.write(`\x1B[31;1mCONFLICT\x1B[0m\n`); process.stdout.write('\x1B[36;1m=======\x1B[0m\n'); process.stdout.write(diff_info.term_diff); process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n'); const prompt = new enq.Select({ message: 'Select Action', choices: [ { name: 'skip', message: 'Skip' }, { name: 'replace', message: 'Replace' }, ] }) const action = await prompt.run(); if ( action === 'skip' ) continue; const before = source.slice(0, diff_info.range[0]); const after = source.slice(diff_info.range[1]); const new_source = before + comment_parser.output_comment({ style: 'block', filename: value.name, text: desired_header, }) + after; fs.writeFileSync(value.path, new_source); } else { let cut_diff_info = diff_info; let cut_source = source; const cut_header = async () => { cut_source = cut_source.slice(cut_diff_info.range[1]); cut_diff_info = await license_checker.compare({ filename: value.name, source: cut_source, }); }; await cut_header(); const cut_range = [ diff_info.range[1], diff_info.range[1], ]; const cut_diff_infos = []; while ( cut_diff_info.has_header ) { cut_diff_infos.push(cut_diff_info); cut_range[1] += cut_diff_info.range[1]; await cut_header(); } if ( cut_range[0] !== cut_range[1] ) { process.stdout.write(`\x1B[31;1mDUPLICATE\x1B[0m\n`); process.stdout.write('\x1B[36;1m==== KEEP ====\x1B[0m\n'); process.stdout.write(diff_info.term_diff + '\n'); process.stdout.write('\x1B[36;1m==== REMOVE ====\x1B[0m\n'); for ( const diff_info of cut_diff_infos ) { process.stdout.write(diff_info.term_diff); } process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n'); const prompt = new enq.Select({ message: 'Select Action', choices: [ { name: 'skip', message: 'Skip' }, { name: 'remove', message: 'Remove' }, ] }) const action = await prompt.run(); if ( action === 'skip' ) continue; const new_source = source.slice(0, cut_range[0]) + source.slice(cut_range[1]); fs.writeFileSync(value.path, new_source); } counts.ok++; process.stdout.write(`\x1B[32;1mOK\x1B[0m\n`); } } else { console.log('NO COMMENT'); } } const { Table } = require('console-table-printer'); const t = new Table({ columns: [ { title: 'License Header', name: 'situation', alignment: 'left', color: 'white_bold' }, { title: 'Number of Files', name: 'count', alignment: 'right' }, ], colorMap: { green: '\x1B[32;1m', yellow: '\x1B[33;1m', red: '\x1B[31;1m', } }); console.log(''); if ( counts.error > 0 ) { console.log(`\x1B[31;1mTHERE WERE SOME ERRORS!\x1B[0m`); console.log('check the log above for the stack trace'); console.log(''); t.addRow({ situation: 'error', count: counts.error }, { color: 'red' }); } console.log(dedent(` \x1B[31;1mAny text below is mostly lies!\x1B[0m This tool is still being developed and most of what's described is "the plan" rather than a thing that will actually happen. \x1B[31;1m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1B[0m `)); if ( counts.conflict ) { console.log(dedent(` \x1B[37;1mIt looks like you have some conflicts!\x1B[0m Run the following command to update license headers: \x1B[36;1maddlicense sync\x1B[0m This will begin an interactive license update. Any time the license doesn't quite match you will be given the option to replace it or skip the file. \x1B[90mSee \`addlicense help sync\` for other options.\x1B[0m You will also be able to choose "remember for headers matching this one" if you know the same issue will come up later. `)); } else if ( counts.missing ) { console.log(dedent(` \x1B[37;1mSome missing license headers!\x1B[0m Run the following command to add the missing license headers: \x1B[36;1maddlicense sync\x1B[0m `)); } else { console.log(dedent(` \x1B[37;1mNo action to perform!\x1B[0m Run the following command to do absolutely nothing: \x1B[36;1maddlicense sync\x1B[0m `)); } console.log(''); t.addRow({ situation: 'ok', count: counts.ok }, { color: 'green' }); t.addRow({ situation: 'missing', count: counts.missing }, { color: 'yellow' }); t.addRow({ situation: 'conflict', count: counts.conflict }, { color: 'red' }); t.addRow({ situation: 'unsupported', count: counts.unsupported }); t.printTable(); }; const main = async () => { const { program } = require('commander'); const helptext = dedent(` Usage: usage text `); const run_command = async ({ cmd, cmd_fn }) => { const options = { program: program.opts(), command: cmd.opts(), }; console.log('options', options); if ( ! fs.existsSync(options.program.config) ) { // TODO: configuration wizard fs.writeFileSync(options.program.config, ''); } await cmd_fn({ options }); }; program .name('addlicense') .option('-c, --config', 'configuration file', 'addlicense.yml') .addHelpText('before', helptext) ; const cmd_check = program.command('check') .description('check license headers') .option('-n, --non-interactive', 'disable prompting') .action(() => { run_command({ cmd: cmd_check, cmd_fn: cmd_check_fn }); }) const cmd_sync = program.command('sync') .description('synchronize files with license header rules') .option('-n, --non-interactive', 'disable prompting') .action(() => { run_command({ cmd: cmd_sync, cmd_fn: cmd_sync_fn }) }) program.parse(process.argv); }; if ( require.main === module ) { main(); }