puter/tools/license-headers/main.js
2024-07-10 16:53:54 -04:00

594 lines
19 KiB
JavaScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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();
}