main.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. // METADATA // {"ai-commented":{"service":"claude"}}
  2. /*
  3. * Copyright (C) 2024-present Puter Technologies Inc.
  4. *
  5. * This file is part of Puter.
  6. *
  7. * Puter is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU Affero General Public License as published
  9. * by the Free Software Foundation, either version 3 of the License, or
  10. * (at your option) any later version.
  11. *
  12. * This program is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. * GNU Affero General Public License for more details.
  16. *
  17. * You should have received a copy of the GNU Affero General Public License
  18. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. */
  20. const levenshtein = require('js-levenshtein');
  21. const DiffMatchPatch = require('diff-match-patch');
  22. const enq = require('enquirer');
  23. const dmp = new DiffMatchPatch();
  24. const dedent = require('dedent');
  25. const { walk, EXCLUDE_LISTS } = require('file-walker');
  26. const { CommentParser } = require('../comment-parser/main');
  27. const fs = require('fs');
  28. const path_ = require('path');
  29. /**
  30. * Compares two license headers and returns their Levenshtein distance and formatted diff
  31. * @param {Object} params - The parameters object
  32. * @param {string} params.header1 - First header text to compare
  33. * @param {string} params.header2 - Second header text to compare
  34. * @param {boolean} [params.distance_only=false] - If true, only return distance without diff
  35. * @returns {Object} Object containing distance and formatted terminal diff
  36. */
  37. const CompareFn = ({ header1, header2, distance_only = false }) => {
  38. // Calculate Levenshtein distance
  39. const distance = levenshtein(header1, header2);
  40. // console.log(`Levenshtein distance: ${distance}`);
  41. if ( distance_only ) return { distance };
  42. // Generate diffs using diff-match-patch
  43. const diffs = dmp.diff_main(header1, header2);
  44. dmp.diff_cleanupSemantic(diffs);
  45. let term_diff = '';
  46. // Manually format diffs for terminal display
  47. diffs.forEach(([type, text]) => {
  48. switch (type) {
  49. case DiffMatchPatch.DIFF_INSERT:
  50. term_diff += `\x1b[32m${text}\x1b[0m`; // Green for insertions
  51. break;
  52. case DiffMatchPatch.DIFF_DELETE:
  53. term_diff += `\x1b[31m${text}\x1b[0m`; // Red for deletions
  54. break;
  55. case DiffMatchPatch.DIFF_EQUAL:
  56. term_diff += text; // No color for equal parts
  57. break;
  58. }
  59. });
  60. return {
  61. distance,
  62. term_diff,
  63. };
  64. }
  65. /**
  66. * Creates a license checker instance that can compare and validate license headers
  67. * @param {Object} params - Configuration parameters
  68. * @param {Object} params.comment_parser - Comment parser instance to use
  69. * @param {string} params.desired_header - The expected license header text
  70. * @returns {Object} License checker instance with compare and supports methods
  71. */
  72. const LicenseChecker = ({
  73. comment_parser,
  74. desired_header,
  75. }) => {
  76. const supports = ({ filename }) => {
  77. return comment_parser.supports({ filename });
  78. };
  79. const compare = async ({ filename, source }) => {
  80. const headers = await comment_parser.extract_top_comments(
  81. { filename, source });
  82. const headers_lines = headers.map(h => h.lines);
  83. if ( headers.length < 1 ) {
  84. return {
  85. has_header: false,
  86. };
  87. }
  88. // console.log('headers', headers);
  89. let top = 0;
  90. let bottom = 0;
  91. let current_distance = Number.MAX_SAFE_INTEGER;
  92. // "wah"
  93. for ( let i=1 ; i <= headers.length ; i++ ) {
  94. const combined = headers_lines.slice(top, i).flat();
  95. const combined_txt = combined.join('\n');
  96. const { distance } =
  97. CompareFn({
  98. header1: desired_header,
  99. header2: combined_txt,
  100. distance_only: true,
  101. });
  102. if ( distance < current_distance ) {
  103. current_distance = distance;
  104. bottom = i;
  105. } else {
  106. break;
  107. }
  108. }
  109. // "woop"
  110. for ( let i=1 ; i < headers.length ; i++ ) {
  111. const combined = headers_lines.slice(i, bottom).flat();
  112. const combined_txt = combined.join('\n');
  113. const { distance } =
  114. CompareFn({
  115. header1: desired_header,
  116. header2: combined_txt,
  117. distance_only: true,
  118. });
  119. if ( distance < current_distance ) {
  120. current_distance = distance;
  121. top = i;
  122. } else {
  123. break;
  124. }
  125. }
  126. // console.log('headers', headers);
  127. const combined = headers_lines.slice(top, bottom).flat();
  128. const combined_txt = combined.join('\n');
  129. const diff_info = CompareFn({
  130. header1: desired_header,
  131. header2: combined_txt,
  132. })
  133. if ( diff_info.distance > 0.7*desired_header.length ) {
  134. return {
  135. has_header: false,
  136. };
  137. }
  138. diff_info.range = [
  139. headers[top].range[0],
  140. headers[bottom-1].range[1],
  141. ];
  142. diff_info.has_header = true;
  143. return diff_info;
  144. };
  145. return {
  146. compare,
  147. supports,
  148. };
  149. };
  150. const license_check_test = async ({ options }) => {
  151. const comment_parser = CommentParser();
  152. const license_checker = LicenseChecker({
  153. comment_parser,
  154. desired_header: fs.readFileSync(
  155. path_.join(__dirname, '../../doc/license_header.txt'),
  156. 'utf-8',
  157. ),
  158. });
  159. const walk_iterator = walk({
  160. excludes: EXCLUDE_LISTS.NOT_AGPL,
  161. }, path_.join(__dirname, '../..'));
  162. for await ( const value of walk_iterator ) {
  163. if ( value.is_dir ) continue;
  164. if ( value.name !== 'dev-console-ui-utils.js' ) continue;
  165. console.log(value.path);
  166. const source = fs.readFileSync(value.path, 'utf-8');
  167. const diff_info = await license_checker.compare({
  168. filename: value.name,
  169. source,
  170. })
  171. if ( diff_info ) {
  172. process.stdout.write('\x1B[36;1m=======\x1B[0m\n');
  173. process.stdout.write(diff_info.term_diff);
  174. process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n');
  175. // console.log('headers', headers);
  176. } else {
  177. console.log('NO COMMENT');
  178. }
  179. console.log('RANGE', diff_info.range)
  180. const new_comment = comment_parser.output_comment({
  181. filename: value.name,
  182. style: 'block',
  183. text: 'some text\nto display'
  184. });
  185. console.log('NEW COMMENT?', new_comment);
  186. }
  187. };
  188. /**
  189. * Executes the main command line interface for the license header tool.
  190. * Sets up Commander.js program with commands for checking and syncing license headers.
  191. * Handles configuration file loading and command execution.
  192. *
  193. * @async
  194. * @returns {Promise<void>} Resolves when command execution is complete
  195. */
  196. const cmd_check_fn = async () => {
  197. const comment_parser = CommentParser();
  198. const license_checker = LicenseChecker({
  199. comment_parser,
  200. desired_header: fs.readFileSync(
  201. path_.join(__dirname, '../../doc/license_header.txt'),
  202. 'utf-8',
  203. ),
  204. });
  205. const counts = {
  206. ok: 0,
  207. missing: 0,
  208. conflict: 0,
  209. error: 0,
  210. unsupported: 0,
  211. };
  212. const walk_iterator = walk({
  213. excludes: EXCLUDE_LISTS.NOT_AGPL,
  214. }, path_.join(__dirname, '../..'));
  215. for await ( const value of walk_iterator ) {
  216. if ( value.is_dir ) continue;
  217. process.stdout.write(value.path + ' ... ');
  218. if ( ! license_checker.supports({ filename: value.name }) ) {
  219. process.stdout.write(`\x1B[37;1mUNSUPPORTED\x1B[0m\n`);
  220. counts.unsupported++;
  221. continue;
  222. }
  223. const source = fs.readFileSync(value.path, 'utf-8');
  224. const diff_info = await license_checker.compare({
  225. filename: value.name,
  226. source,
  227. })
  228. if ( ! diff_info ) {
  229. counts.error++;
  230. continue;
  231. }
  232. if ( ! diff_info.has_header ) {
  233. counts.missing++;
  234. process.stdout.write(`\x1B[33;1mMISSING\x1B[0m\n`);
  235. continue;
  236. }
  237. if ( diff_info ) {
  238. if ( diff_info.distance !== 0 ) {
  239. counts.conflict++;
  240. process.stdout.write(`\x1B[31;1mCONFLICT\x1B[0m\n`);
  241. } else {
  242. counts.ok++;
  243. process.stdout.write(`\x1B[32;1mOK\x1B[0m\n`);
  244. }
  245. } else {
  246. console.log('NO COMMENT');
  247. }
  248. }
  249. const { Table } = require('console-table-printer');
  250. const t = new Table({
  251. columns: [
  252. {
  253. title: 'License Header',
  254. name: 'situation', alignment: 'left', color: 'white_bold' },
  255. {
  256. title: 'Number of Files',
  257. name: 'count', alignment: 'right' },
  258. ],
  259. colorMap: {
  260. green: '\x1B[32;1m',
  261. yellow: '\x1B[33;1m',
  262. red: '\x1B[31;1m',
  263. }
  264. });
  265. console.log('');
  266. if ( counts.error > 0 ) {
  267. console.log(`\x1B[31;1mTHERE WERE SOME ERRORS!\x1B[0m`);
  268. console.log('check the log above for the stack trace');
  269. console.log('');
  270. t.addRow({ situation: 'error', count: counts.error },
  271. { color: 'red' });
  272. }
  273. console.log(dedent(`
  274. \x1B[31;1mAny text below is mostly lies!\x1B[0m
  275. This tool is still being developed and most of what's
  276. described is "the plan" rather than a thing that will
  277. actually happen.
  278. \x1B[31;1m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1B[0m
  279. `));
  280. if ( counts.conflict ) {
  281. console.log(dedent(`
  282. \x1B[37;1mIt looks like you have some conflicts!\x1B[0m
  283. Run the following command to update license headers:
  284. \x1B[36;1maddlicense sync\x1B[0m
  285. This will begin an interactive license update.
  286. Any time the license doesn't quite match you will
  287. be given the option to replace it or skip the file.
  288. \x1B[90mSee \`addlicense help sync\` for other options.\x1B[0m
  289. You will also be able to choose
  290. "remember for headers matching this one"
  291. if you know the same issue will come up later.
  292. `));
  293. } else if ( counts.missing ) {
  294. console.log(dedent(`
  295. \x1B[37;1mSome missing license headers!\x1B[0m
  296. Run the following command to add the missing license headers:
  297. \x1B[36;1maddlicense sync\x1B[0m
  298. `));
  299. } else {
  300. console.log(dedent(`
  301. \x1B[37;1mNo action to perform!\x1B[0m
  302. Run the following command to do absolutely nothing:
  303. \x1B[36;1maddlicense sync\x1B[0m
  304. `));
  305. }
  306. console.log('');
  307. t.addRow({ situation: 'ok', count: counts.ok },
  308. { color: 'green' });
  309. t.addRow({ situation: 'missing', count: counts.missing },
  310. { color: 'yellow' });
  311. t.addRow({ situation: 'conflict', count: counts.conflict },
  312. { color: 'red' });
  313. t.addRow({ situation: 'unsupported', count: counts.unsupported });
  314. t.printTable();
  315. };
  316. /**
  317. * Synchronizes license headers in source files by adding missing headers and handling conflicts
  318. *
  319. * Walks through files, checks for license headers, and:
  320. * - Adds headers to files missing them
  321. * - Prompts user to resolve conflicts when headers don't match
  322. * - Handles duplicate headers by allowing removal
  323. * - Tracks counts of different header statuses (ok, missing, conflict, etc)
  324. *
  325. * @returns {Promise<void>} Resolves when synchronization is complete
  326. */
  327. const cmd_sync_fn = async () => {
  328. const comment_parser = CommentParser();
  329. const desired_header = fs.readFileSync(
  330. path_.join(__dirname, '../../doc/license_header.txt'),
  331. 'utf-8',
  332. );
  333. const license_checker = LicenseChecker({
  334. comment_parser,
  335. desired_header,
  336. });
  337. const counts = {
  338. ok: 0,
  339. missing: 0,
  340. conflict: 0,
  341. error: 0,
  342. unsupported: 0,
  343. };
  344. const walk_iterator = walk({
  345. excludes: EXCLUDE_LISTS.NOT_AGPL,
  346. }, '.');
  347. for await ( const value of walk_iterator ) {
  348. if ( value.is_dir ) continue;
  349. process.stdout.write(value.path + ' ... ');
  350. if ( ! license_checker.supports({ filename: value.name }) ) {
  351. process.stdout.write(`\x1B[37;1mUNSUPPORTED\x1B[0m\n`);
  352. counts.unsupported++;
  353. continue;
  354. }
  355. const source = fs.readFileSync(value.path, 'utf-8');
  356. const diff_info = await license_checker.compare({
  357. filename: value.name,
  358. source,
  359. })
  360. if ( ! diff_info ) {
  361. counts.error++;
  362. continue;
  363. }
  364. if ( ! diff_info.has_header ) {
  365. fs.writeFileSync(
  366. value.path,
  367. comment_parser.output_comment({
  368. style: 'block',
  369. filename: value.name,
  370. text: desired_header,
  371. }) +
  372. '\n' +
  373. source
  374. );
  375. continue;
  376. }
  377. if ( diff_info ) {
  378. if ( diff_info.distance !== 0 ) {
  379. counts.conflict++;
  380. process.stdout.write(`\x1B[31;1mCONFLICT\x1B[0m\n`);
  381. process.stdout.write('\x1B[36;1m=======\x1B[0m\n');
  382. process.stdout.write(diff_info.term_diff);
  383. process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n');
  384. const prompt = new enq.Select({
  385. message: 'Select Action',
  386. choices: [
  387. { name: 'skip', message: 'Skip' },
  388. { name: 'replace', message: 'Replace' },
  389. ]
  390. })
  391. const action = await prompt.run();
  392. if ( action === 'skip' ) continue;
  393. const before = source.slice(0, diff_info.range[0]);
  394. const after = source.slice(diff_info.range[1]);
  395. const new_source = before +
  396. comment_parser.output_comment({
  397. style: 'block',
  398. filename: value.name,
  399. text: desired_header,
  400. }) +
  401. after;
  402. fs.writeFileSync(value.path, new_source);
  403. } else {
  404. let cut_diff_info = diff_info;
  405. let cut_source = source;
  406. const cut_header = async () => {
  407. cut_source = cut_source.slice(cut_diff_info.range[1]);
  408. cut_diff_info = await license_checker.compare({
  409. filename: value.name,
  410. source: cut_source,
  411. });
  412. };
  413. await cut_header();
  414. const cut_range = [
  415. diff_info.range[1],
  416. diff_info.range[1],
  417. ];
  418. const cut_diff_infos = [];
  419. while ( cut_diff_info.has_header ) {
  420. cut_diff_infos.push(cut_diff_info);
  421. cut_range[1] += cut_diff_info.range[1];
  422. await cut_header();
  423. }
  424. if ( cut_range[0] !== cut_range[1] ) {
  425. process.stdout.write(`\x1B[31;1mDUPLICATE\x1B[0m\n`);
  426. process.stdout.write('\x1B[36;1m==== KEEP ====\x1B[0m\n');
  427. process.stdout.write(diff_info.term_diff + '\n');
  428. process.stdout.write('\x1B[36;1m==== REMOVE ====\x1B[0m\n');
  429. for ( const diff_info of cut_diff_infos ) {
  430. process.stdout.write(diff_info.term_diff);
  431. }
  432. process.stdout.write('\n\x1B[36;1m=======\x1B[0m\n');
  433. const prompt = new enq.Select({
  434. message: 'Select Action',
  435. choices: [
  436. { name: 'skip', message: 'Skip' },
  437. { name: 'remove', message: 'Remove' },
  438. ]
  439. })
  440. const action = await prompt.run();
  441. if ( action === 'skip' ) continue;
  442. const new_source =
  443. source.slice(0, cut_range[0]) +
  444. source.slice(cut_range[1]);
  445. fs.writeFileSync(value.path, new_source);
  446. }
  447. counts.ok++;
  448. process.stdout.write(`\x1B[32;1mOK\x1B[0m\n`);
  449. }
  450. } else {
  451. console.log('NO COMMENT');
  452. }
  453. }
  454. const { Table } = require('console-table-printer');
  455. const t = new Table({
  456. columns: [
  457. {
  458. title: 'License Header',
  459. name: 'situation', alignment: 'left', color: 'white_bold' },
  460. {
  461. title: 'Number of Files',
  462. name: 'count', alignment: 'right' },
  463. ],
  464. colorMap: {
  465. green: '\x1B[32;1m',
  466. yellow: '\x1B[33;1m',
  467. red: '\x1B[31;1m',
  468. }
  469. });
  470. console.log('');
  471. if ( counts.error > 0 ) {
  472. console.log(`\x1B[31;1mTHERE WERE SOME ERRORS!\x1B[0m`);
  473. console.log('check the log above for the stack trace');
  474. console.log('');
  475. t.addRow({ situation: 'error', count: counts.error },
  476. { color: 'red' });
  477. }
  478. console.log(dedent(`
  479. \x1B[31;1mAny text below is mostly lies!\x1B[0m
  480. This tool is still being developed and most of what's
  481. described is "the plan" rather than a thing that will
  482. actually happen.
  483. \x1B[31;1m^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\x1B[0m
  484. `));
  485. if ( counts.conflict ) {
  486. console.log(dedent(`
  487. \x1B[37;1mIt looks like you have some conflicts!\x1B[0m
  488. Run the following command to update license headers:
  489. \x1B[36;1maddlicense sync\x1B[0m
  490. This will begin an interactive license update.
  491. Any time the license doesn't quite match you will
  492. be given the option to replace it or skip the file.
  493. \x1B[90mSee \`addlicense help sync\` for other options.\x1B[0m
  494. You will also be able to choose
  495. "remember for headers matching this one"
  496. if you know the same issue will come up later.
  497. `));
  498. } else if ( counts.missing ) {
  499. console.log(dedent(`
  500. \x1B[37;1mSome missing license headers!\x1B[0m
  501. Run the following command to add the missing license headers:
  502. \x1B[36;1maddlicense sync\x1B[0m
  503. `));
  504. } else {
  505. console.log(dedent(`
  506. \x1B[37;1mNo action to perform!\x1B[0m
  507. Run the following command to do absolutely nothing:
  508. \x1B[36;1maddlicense sync\x1B[0m
  509. `));
  510. }
  511. console.log('');
  512. t.addRow({ situation: 'ok', count: counts.ok },
  513. { color: 'green' });
  514. t.addRow({ situation: 'missing', count: counts.missing },
  515. { color: 'yellow' });
  516. t.addRow({ situation: 'conflict', count: counts.conflict },
  517. { color: 'red' });
  518. t.addRow({ situation: 'unsupported', count: counts.unsupported });
  519. t.printTable();
  520. };
  521. /**
  522. * Main entry point for the license header tool.
  523. * Sets up command line interface using Commander and processes commands.
  524. * Handles 'check' and 'sync' commands for managing license headers in files.
  525. *
  526. * @returns {Promise<void>} Resolves when command processing is complete
  527. */
  528. const main = async () => {
  529. const { program } = require('commander');
  530. const helptext = dedent(`
  531. Usage: usage text
  532. `);
  533. const run_command = async ({ cmd, cmd_fn }) => {
  534. const options = {
  535. program: program.opts(),
  536. command: cmd.opts(),
  537. };
  538. console.log('options', options);
  539. if ( ! fs.existsSync(options.program.config) ) {
  540. // TODO: configuration wizard
  541. fs.writeFileSync(options.program.config, '');
  542. }
  543. await cmd_fn({ options });
  544. };
  545. program
  546. .name('addlicense')
  547. .option('-c, --config', 'configuration file', 'addlicense.yml')
  548. .addHelpText('before', helptext)
  549. ;
  550. const cmd_check = program.command('check')
  551. .description('check license headers')
  552. .option('-n, --non-interactive', 'disable prompting')
  553. .action(() => {
  554. run_command({ cmd: cmd_check, cmd_fn: cmd_check_fn });
  555. })
  556. const cmd_sync = program.command('sync')
  557. .description('synchronize files with license header rules')
  558. .option('-n, --non-interactive', 'disable prompting')
  559. .action(() => {
  560. run_command({ cmd: cmd_sync, cmd_fn: cmd_sync_fn })
  561. })
  562. program.parse(process.argv);
  563. };
  564. if ( require.main === module ) {
  565. main();
  566. }