lint.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. #!/usr/bin/env node
  2. const crypto = require('node:crypto');
  3. const { GitProcess } = require('dugite');
  4. const childProcess = require('node:child_process');
  5. const { ESLint } = require('eslint');
  6. const fs = require('node:fs');
  7. const minimist = require('minimist');
  8. const path = require('node:path');
  9. const { getCodeBlocks } = require('@electron/lint-roller/dist/lib/markdown');
  10. const { chunkFilenames, findMatchingFiles } = require('./lib/utils');
  11. const ELECTRON_ROOT = path.normalize(path.dirname(__dirname));
  12. const SOURCE_ROOT = path.resolve(ELECTRON_ROOT, '..');
  13. const DEPOT_TOOLS = path.resolve(SOURCE_ROOT, 'third_party', 'depot_tools');
  14. // Augment the PATH for this script so that we can find executables
  15. // in the depot_tools folder even if folks do not have an instance of
  16. // DEPOT_TOOLS in their path already
  17. process.env.PATH = `${process.env.PATH}${path.delimiter}${DEPOT_TOOLS}`;
  18. const IGNORELIST = new Set(
  19. [
  20. ['shell', 'browser', 'resources', 'win', 'resource.h'],
  21. ['shell', 'common', 'node_includes.h'],
  22. ['spec', 'fixtures', 'pages', 'jquery-3.6.0.min.js']
  23. ].map((tokens) => path.join(ELECTRON_ROOT, ...tokens))
  24. );
  25. const IS_WINDOWS = process.platform === 'win32';
  26. const CPPLINT_FILTERS = [
  27. // from presubmit_canned_checks.py OFF_BY_DEFAULT_LINT_FILTERS
  28. '-build/include',
  29. '-build/include_order',
  30. '-build/namespaces',
  31. '-readability/casting',
  32. '-runtime/int',
  33. '-whitespace/braces',
  34. // from presubmit_canned_checks.py OFF_UNLESS_MANUALLY_ENABLED_LINT_FILTERS
  35. '-build/c++11',
  36. '-build/header_guard',
  37. '-readability/todo',
  38. '-runtime/references',
  39. '-whitespace/braces',
  40. '-whitespace/comma',
  41. '-whitespace/end_of_line',
  42. '-whitespace/forcolon',
  43. '-whitespace/indent',
  44. '-whitespace/line_length',
  45. '-whitespace/newline',
  46. '-whitespace/operators',
  47. '-whitespace/parens',
  48. '-whitespace/semicolon',
  49. '-whitespace/tab'
  50. ];
  51. function spawnAndCheckExitCode (cmd, args, opts) {
  52. opts = { stdio: 'inherit', ...opts };
  53. const { error, status, signal } = childProcess.spawnSync(cmd, args, opts);
  54. if (error) {
  55. // the subprocess failed or timed out
  56. console.error(error);
  57. process.exit(1);
  58. }
  59. if (status === null) {
  60. // the subprocess terminated due to a signal
  61. console.error(signal);
  62. process.exit(1);
  63. }
  64. if (status !== 0) {
  65. // `status` is an exit code
  66. process.exit(status);
  67. }
  68. }
  69. function cpplint (args) {
  70. args.unshift(`--root=${SOURCE_ROOT}`);
  71. const result = childProcess.spawnSync(
  72. IS_WINDOWS ? 'cpplint.bat' : 'cpplint.py',
  73. args,
  74. { encoding: 'utf8', shell: true }
  75. );
  76. // cpplint.py writes EVERYTHING to stderr, including status messages
  77. if (result.stderr) {
  78. for (const line of result.stderr.split(/[\r\n]+/)) {
  79. if (
  80. line.length &&
  81. !line.startsWith('Done processing ') &&
  82. line !== 'Total errors found: 0'
  83. ) {
  84. console.warn(line);
  85. }
  86. }
  87. }
  88. if (result.status !== 0) {
  89. if (result.error) console.error(result.error);
  90. process.exit(result.status || 1);
  91. }
  92. }
  93. function isObjCHeader (filename) {
  94. return /\/(mac|cocoa)\//.test(filename);
  95. }
  96. const LINTERS = [
  97. {
  98. key: 'cpp',
  99. roots: ['shell'],
  100. test: (filename) =>
  101. filename.endsWith('.cc') ||
  102. (filename.endsWith('.h') && !isObjCHeader(filename)),
  103. run: (opts, filenames) => {
  104. const clangFormatFlags = opts.fix ? ['--fix'] : [];
  105. for (const chunk of chunkFilenames(filenames)) {
  106. spawnAndCheckExitCode('python3', [
  107. 'script/run-clang-format.py',
  108. ...clangFormatFlags,
  109. ...chunk
  110. ]);
  111. cpplint([`--filter=${CPPLINT_FILTERS.join(',')}`, ...chunk]);
  112. }
  113. }
  114. },
  115. {
  116. key: 'objc',
  117. roots: ['shell'],
  118. test: (filename) =>
  119. filename.endsWith('.mm') ||
  120. (filename.endsWith('.h') && isObjCHeader(filename)),
  121. run: (opts, filenames) => {
  122. const clangFormatFlags = opts.fix ? ['--fix'] : [];
  123. spawnAndCheckExitCode('python3', [
  124. 'script/run-clang-format.py',
  125. '-r',
  126. ...clangFormatFlags,
  127. ...filenames
  128. ]);
  129. const filter = [...CPPLINT_FILTERS, '-readability/braces'];
  130. cpplint([
  131. '--extensions=mm,h',
  132. `--filter=${filter.join(',')}`,
  133. ...filenames
  134. ]);
  135. }
  136. },
  137. {
  138. key: 'python',
  139. roots: ['script'],
  140. test: (filename) => filename.endsWith('.py'),
  141. run: (opts, filenames) => {
  142. const rcfile = path.join(DEPOT_TOOLS, 'pylintrc');
  143. const args = ['--rcfile=' + rcfile, ...filenames];
  144. const env = {
  145. PYTHONPATH: path.join(ELECTRON_ROOT, 'script'),
  146. ...process.env
  147. };
  148. spawnAndCheckExitCode('pylint-2.7', args, { env });
  149. }
  150. },
  151. {
  152. key: 'javascript',
  153. roots: ['build', 'default_app', 'lib', 'npm', 'script', 'spec'],
  154. ignoreRoots: ['spec/node_modules'],
  155. test: (filename) => filename.endsWith('.js') || filename.endsWith('.ts'),
  156. run: async (opts, filenames) => {
  157. const eslint = new ESLint({
  158. // Do not use the lint cache on CI builds
  159. cache: !process.env.CI,
  160. cacheLocation: `node_modules/.eslintcache.${crypto
  161. .createHash('md5')
  162. .update(fs.readFileSync(__filename))
  163. .digest('hex')}`,
  164. extensions: ['.js', '.ts'],
  165. fix: opts.fix,
  166. overrideConfigFile: path.join(ELECTRON_ROOT, '.eslintrc.json'),
  167. resolvePluginsRelativeTo: ELECTRON_ROOT
  168. });
  169. const formatter = await eslint.loadFormatter();
  170. let successCount = 0;
  171. const results = await eslint.lintFiles(filenames);
  172. for (const result of results) {
  173. successCount += result.errorCount === 0 ? 1 : 0;
  174. if (
  175. opts.verbose &&
  176. result.errorCount === 0 &&
  177. result.warningCount === 0
  178. ) {
  179. console.log(`${result.filePath}: no errors or warnings`);
  180. }
  181. }
  182. console.log(formatter.format(results));
  183. if (opts.fix) {
  184. await ESLint.outputFixes(results);
  185. }
  186. if (successCount !== filenames.length) {
  187. console.error('Linting had errors');
  188. process.exit(1);
  189. }
  190. }
  191. },
  192. {
  193. key: 'gn',
  194. roots: ['.'],
  195. test: (filename) => filename.endsWith('.gn') || filename.endsWith('.gni'),
  196. run: (opts, filenames) => {
  197. const allOk = filenames
  198. .map((filename) => {
  199. const env = {
  200. CHROMIUM_BUILDTOOLS_PATH: path.resolve(
  201. ELECTRON_ROOT,
  202. '..',
  203. 'buildtools'
  204. ),
  205. DEPOT_TOOLS_WIN_TOOLCHAIN: '0',
  206. ...process.env
  207. };
  208. // Users may not have depot_tools in PATH.
  209. env.PATH = `${env.PATH}${path.delimiter}${DEPOT_TOOLS}`;
  210. const args = ['format', filename];
  211. if (!opts.fix) args.push('--dry-run');
  212. const result = childProcess.spawnSync('gn', args, {
  213. env,
  214. stdio: 'inherit',
  215. shell: true
  216. });
  217. if (result.status === 0) {
  218. return true;
  219. } else if (result.status === 2) {
  220. console.log(
  221. `GN format errors in "${filename}". Run 'gn format "${filename}"' or rerun with --fix to fix them.`
  222. );
  223. return false;
  224. } else {
  225. console.log(
  226. `Error running 'gn format --dry-run "${filename}"': exit code ${result.status}`
  227. );
  228. return false;
  229. }
  230. })
  231. .every((x) => x);
  232. if (!allOk) {
  233. process.exit(1);
  234. }
  235. }
  236. },
  237. {
  238. key: 'patches',
  239. roots: ['patches'],
  240. test: (filename) => filename.endsWith('.patch'),
  241. run: (opts, filenames) => {
  242. const patchesDir = path.resolve(__dirname, '../patches');
  243. const patchesConfig = path.resolve(patchesDir, 'config.json');
  244. // If the config does not exist, that's a problem
  245. if (!fs.existsSync(patchesConfig)) {
  246. console.error(`Patches config file: "${patchesConfig}" does not exist`);
  247. process.exit(1);
  248. }
  249. for (const target of JSON.parse(fs.readFileSync(patchesConfig, 'utf8'))) {
  250. // The directory the config points to should exist
  251. const targetPatchesDir = path.resolve(__dirname, '../../..', target.patch_dir);
  252. if (!fs.existsSync(targetPatchesDir)) {
  253. console.error(
  254. `target patch directory: "${targetPatchesDir}" does not exist`
  255. );
  256. process.exit(1);
  257. }
  258. // We need a .patches file
  259. const dotPatchesPath = path.resolve(targetPatchesDir, '.patches');
  260. if (!fs.existsSync(dotPatchesPath)) {
  261. console.error(`.patches file: "${dotPatchesPath}" does not exist`);
  262. process.exit(1);
  263. }
  264. // Read the patch list
  265. const patchFileList = fs
  266. .readFileSync(dotPatchesPath, 'utf8')
  267. .trim()
  268. .split('\n');
  269. const patchFileSet = new Set(patchFileList);
  270. patchFileList.reduce((seen, file) => {
  271. if (seen.has(file)) {
  272. console.error(
  273. `'${file}' is listed in ${dotPatchesPath} more than once`
  274. );
  275. process.exit(1);
  276. }
  277. return seen.add(file);
  278. }, new Set());
  279. if (patchFileList.length !== patchFileSet.size) {
  280. console.error(
  281. 'Each patch file should only be in the .patches file once'
  282. );
  283. process.exit(1);
  284. }
  285. for (const file of fs.readdirSync(targetPatchesDir)) {
  286. // Ignore the .patches file and READMEs
  287. if (file === '.patches' || file === 'README.md') continue;
  288. if (!patchFileSet.has(file)) {
  289. console.error(
  290. `Expected the .patches file at "${dotPatchesPath}" to contain a patch file ("${file}") present in the directory but it did not`
  291. );
  292. process.exit(1);
  293. }
  294. patchFileSet.delete(file);
  295. }
  296. // If anything is left in this set, it means it did not exist on disk
  297. if (patchFileSet.size > 0) {
  298. console.error(
  299. `Expected all the patch files listed in the .patches file at "${dotPatchesPath}" to exist but some did not:\n${JSON.stringify(
  300. [...patchFileSet.values()],
  301. null,
  302. 2
  303. )}`
  304. );
  305. process.exit(1);
  306. }
  307. }
  308. const allOk =
  309. filenames.length > 0 &&
  310. filenames
  311. .map((f) => {
  312. const patchText = fs.readFileSync(f, 'utf8');
  313. const subjectAndDescription =
  314. /Subject: (.*?)\n\n([\s\S]*?)\s*(?=diff)/ms.exec(patchText);
  315. if (!subjectAndDescription[2]) {
  316. console.warn(
  317. `Patch file '${f}' has no description. Every patch must contain a justification for why the patch exists and the plan for its removal.`
  318. );
  319. return false;
  320. }
  321. const trailingWhitespaceLines = patchText
  322. .split(/\r?\n/)
  323. .map((line, index) => [line, index])
  324. .filter(([line]) => line.startsWith('+') && /\s+$/.test(line))
  325. .map(([, lineNumber]) => lineNumber + 1);
  326. if (trailingWhitespaceLines.length > 0) {
  327. console.warn(
  328. `Patch file '${f}' has trailing whitespace on some lines (${trailingWhitespaceLines.join(
  329. ','
  330. )}).`
  331. );
  332. return false;
  333. }
  334. return true;
  335. })
  336. .every((x) => x);
  337. if (!allOk) {
  338. process.exit(1);
  339. }
  340. }
  341. },
  342. {
  343. key: 'md',
  344. roots: ['.'],
  345. ignoreRoots: ['node_modules', 'spec/node_modules'],
  346. test: (filename) => filename.endsWith('.md'),
  347. run: async (opts, filenames) => {
  348. let errors = false;
  349. // Run markdownlint on all Markdown files
  350. for (const chunk of chunkFilenames(filenames)) {
  351. spawnAndCheckExitCode('electron-markdownlint', chunk);
  352. }
  353. // Run the remaining checks only in docs
  354. const docs = filenames.filter(
  355. (filename) => path.dirname(filename).split(path.sep)[0] === 'docs'
  356. );
  357. for (const filename of docs) {
  358. const contents = fs.readFileSync(filename, 'utf8');
  359. const codeBlocks = await getCodeBlocks(contents);
  360. for (const codeBlock of codeBlocks) {
  361. const line = codeBlock.position.start.line;
  362. if (codeBlock.lang) {
  363. // Enforce all lowercase language identifiers
  364. if (codeBlock.lang.toLowerCase() !== codeBlock.lang) {
  365. console.log(
  366. `${filename}:${line} Code block language identifiers should be all lowercase`
  367. );
  368. errors = true;
  369. }
  370. // Prefer js/ts to javascript/typescript as the language identifier
  371. if (codeBlock.lang === 'javascript') {
  372. console.log(
  373. `${filename}:${line} Use 'js' as code block language identifier instead of 'javascript'`
  374. );
  375. errors = true;
  376. }
  377. if (codeBlock.lang === 'typescript') {
  378. console.log(
  379. `${filename}:${line} Use 'typescript' as code block language identifier instead of 'ts'`
  380. );
  381. errors = true;
  382. }
  383. // Enforce latest fiddle code block syntax
  384. if (
  385. codeBlock.lang === 'javascript' &&
  386. codeBlock.meta &&
  387. codeBlock.meta.includes('fiddle=')
  388. ) {
  389. console.log(
  390. `${filename}:${line} Use 'fiddle' as code block language identifier instead of 'javascript fiddle='`
  391. );
  392. errors = true;
  393. }
  394. // Ensure non-empty content in fiddle code blocks matches the file content
  395. if (codeBlock.lang === 'fiddle' && codeBlock.value.trim() !== '') {
  396. // This is copied and adapted from the website repo:
  397. // https://github.com/electron/website/blob/62a55ca0dd14f97339e1a361b5418d2f11c34a75/src/transformers/fiddle-embedder.ts#L89C6-L101
  398. const parseFiddleEmbedOptions = (optStrings) => {
  399. // If there are optional parameters, parse them out to pass to the getFiddleAST method.
  400. return optStrings.reduce((opts, option) => {
  401. // Use indexOf to support bizarre combinations like `|key=Myvalue=2` (which will properly
  402. // parse to {'key': 'Myvalue=2'})
  403. const firstEqual = option.indexOf('=');
  404. const key = option.slice(0, firstEqual);
  405. const value = option.slice(firstEqual + 1);
  406. return { ...opts, [key]: value };
  407. }, {});
  408. };
  409. const [dir, ...others] = codeBlock.meta.split('|');
  410. const options = parseFiddleEmbedOptions(others);
  411. const fiddleFilename = path.join(dir, options.focus || 'main.js');
  412. try {
  413. const fiddleContent = fs
  414. .readFileSync(fiddleFilename, 'utf8')
  415. .trim();
  416. if (fiddleContent !== codeBlock.value.trim()) {
  417. console.log(
  418. `${filename}:${line} Content for fiddle code block differs from content in ${fiddleFilename}`
  419. );
  420. errors = true;
  421. }
  422. } catch (err) {
  423. console.error(
  424. `${filename}:${line} Error linting fiddle code block content`
  425. );
  426. if (err.stack) {
  427. console.error(err.stack);
  428. }
  429. errors = true;
  430. }
  431. }
  432. }
  433. }
  434. }
  435. if (errors) {
  436. process.exit(1);
  437. }
  438. }
  439. }
  440. ];
  441. function parseCommandLine () {
  442. let help;
  443. const langs = [
  444. 'cpp',
  445. 'objc',
  446. 'javascript',
  447. 'python',
  448. 'gn',
  449. 'patches',
  450. 'markdown'
  451. ];
  452. const langRoots = langs.map((lang) => lang + '-roots');
  453. const langIgnoreRoots = langs.map((lang) => lang + '-ignore-roots');
  454. const opts = minimist(process.argv.slice(2), {
  455. boolean: [...langs, 'help', 'changed', 'fix', 'verbose', 'only'],
  456. alias: {
  457. cpp: ['c++', 'cc', 'cxx'],
  458. javascript: ['js', 'es'],
  459. python: 'py',
  460. markdown: 'md',
  461. changed: 'c',
  462. help: 'h',
  463. verbose: 'v'
  464. },
  465. string: [...langRoots, ...langIgnoreRoots],
  466. unknown: () => {
  467. help = true;
  468. }
  469. });
  470. if (help || opts.help) {
  471. const langFlags = langs.map((lang) => `[--${lang}]`).join(' ');
  472. console.log(
  473. `Usage: script/lint.js ${langFlags} [-c|--changed] [-h|--help] [-v|--verbose] [--fix] [--only -- file1 file2]`
  474. );
  475. process.exit(0);
  476. }
  477. return opts;
  478. }
  479. function populateLinterWithArgs (linter, opts) {
  480. const extraRoots = opts[`${linter.key}-roots`];
  481. if (extraRoots) {
  482. linter.roots.push(...extraRoots.split(','));
  483. }
  484. const extraIgnoreRoots = opts[`${linter.key}-ignore-roots`];
  485. if (extraIgnoreRoots) {
  486. const list = extraIgnoreRoots.split(',');
  487. if (linter.ignoreRoots) {
  488. linter.ignoreRoots.push(...list);
  489. } else {
  490. linter.ignoreRoots = list;
  491. }
  492. }
  493. }
  494. async function findChangedFiles (top) {
  495. const result = await GitProcess.exec(
  496. ['diff', '--name-only', '--cached'],
  497. top
  498. );
  499. if (result.exitCode !== 0) {
  500. console.log(
  501. 'Failed to find changed files',
  502. GitProcess.parseError(result.stderr)
  503. );
  504. process.exit(1);
  505. }
  506. const relativePaths = result.stdout.split(/\r\n|\r|\n/g);
  507. const absolutePaths = relativePaths.map((x) => path.join(top, x));
  508. return new Set(absolutePaths);
  509. }
  510. async function findFiles (args, linter) {
  511. let filenames = [];
  512. let includelist = null;
  513. // build the includelist
  514. if (args.changed) {
  515. includelist = await findChangedFiles(ELECTRON_ROOT);
  516. if (!includelist.size) {
  517. return [];
  518. }
  519. } else if (args.only) {
  520. includelist = new Set(args._.map((p) => path.resolve(p)));
  521. }
  522. // accumulate the raw list of files
  523. for (const root of linter.roots) {
  524. const files = await findMatchingFiles(
  525. path.join(ELECTRON_ROOT, root),
  526. linter.test
  527. );
  528. filenames.push(...files);
  529. }
  530. for (const ignoreRoot of linter.ignoreRoots || []) {
  531. const ignorePath = path.join(ELECTRON_ROOT, ignoreRoot);
  532. if (!fs.existsSync(ignorePath)) continue;
  533. const ignoreFiles = new Set(
  534. await findMatchingFiles(ignorePath, linter.test)
  535. );
  536. filenames = filenames.filter((fileName) => !ignoreFiles.has(fileName));
  537. }
  538. // remove ignored files
  539. filenames = filenames.filter((x) => !IGNORELIST.has(x));
  540. // if a includelist exists, remove anything not in it
  541. if (includelist) {
  542. filenames = filenames.filter((x) => includelist.has(x));
  543. }
  544. // it's important that filenames be relative otherwise clang-format will
  545. // produce patches with absolute paths in them, which `git apply` will refuse
  546. // to apply.
  547. return filenames.map((x) => path.relative(ELECTRON_ROOT, x));
  548. }
  549. async function main () {
  550. const opts = parseCommandLine();
  551. // no mode specified? run 'em all
  552. if (
  553. !opts.cpp &&
  554. !opts.javascript &&
  555. !opts.objc &&
  556. !opts.python &&
  557. !opts.gn &&
  558. !opts.patches &&
  559. !opts.markdown
  560. ) {
  561. opts.cpp =
  562. opts.javascript =
  563. opts.objc =
  564. opts.python =
  565. opts.gn =
  566. opts.patches =
  567. opts.markdown =
  568. true;
  569. }
  570. const linters = LINTERS.filter((x) => opts[x.key]);
  571. for (const linter of linters) {
  572. populateLinterWithArgs(linter, opts);
  573. const filenames = await findFiles(opts, linter);
  574. if (filenames.length) {
  575. if (opts.verbose) {
  576. console.log(
  577. `linting ${filenames.length} ${linter.key} ${
  578. filenames.length === 1 ? 'file' : 'files'
  579. }`
  580. );
  581. }
  582. await linter.run(opts, filenames);
  583. }
  584. }
  585. }
  586. if (require.main === module) {
  587. main().catch((error) => {
  588. console.error(error);
  589. process.exit(1);
  590. });
  591. }