dev-utils/bitrot.js (365 lines of code) (raw):

#!/usr/bin/env node /* * Copyright Elasticsearch B.V. and other contributors where applicable. * Licensed under the BSD 2-Clause License; you may not use this file except in * compliance with the BSD 2-Clause License. */ 'use strict'; // Compare .tav.yml, docs/supported-technologies.asciidoc, and the current // releases of modules instrumented by the Elastic Node.js APM agent to: // - list inconsistencies between TAV-tested and "supported", and // - list new releases of modules that the agent doesn't yet support // // Usage: // node dev-utils/bitrot.js [MODULE-NAME...] const { execSync } = require('child_process'); const fs = require('fs'); const dashdash = require('dashdash'); const { ecsFormat } = require('@elastic/ecs-pino-format'); const pino = require('pino'); const semver = require('semver'); const yaml = require('js-yaml'); let log = null; let rotCount = 0; const EXCUSE_FROM_SUPPORTED_TECHNOLOGIES_DOC = { '@elastic/elasticsearch-canary': true, // we test this for advance warning for '@elastic/elasticsearch', but don't explicitly support the canary versions 'body-parser': true, // instrumented to support express finalhandler: true, // instrumented to support express got: true, // got@12 is pure ESM so we state support up to got@11 only 'mimic-response': true, // we instrument a single old version to indirectly support an old version of 'got' mongojs: true, // last release was in 2019, we aren't going to add effort to this module now '': null, }; const EXCUSE_FROM_TAV = { '@elastic/elasticsearch-canary': true, got: true, // got@12 is pure ESM so we state support up to got@11 only jade: true, // we deprecated 'jade' (in favour of 'pug') 'mimic-response': true, // we instrument a single old version to indirectly support an old version of 'got' mongojs: true, // last release was in 2019, we aren't going to add effort to this module now '': null, }; // ---- caching const gCachePath = '/tmp/apm-agent-nodejs-bitrot.cache.json'; let gCache = null; function ensureCacheLoaded(ns) { if (gCache === null) { try { gCache = JSON.parse(fs.readFileSync(gCachePath)); } catch (loadErr) { log.debug(loadErr, 'could not load cache'); gCache = {}; } } if (!(ns in gCache)) { gCache[ns] = {}; } return gCache[ns]; } function saveCache() { if (gCache !== null) { fs.writeFileSync(gCachePath, JSON.stringify(gCache, null, 2)); } } // ---- minimal ANSI styling support (from bunyan) // http://en.wikipedia.org/wiki/ANSI_escape_code#graphics // Suggested colors (some are unreadable in common cases): // - Good: cyan, yellow (limited use, poor visibility on white background), // bold, green, magenta, red // - Bad: blue (not visible on cmd.exe), grey (same color as background on // Solarized Dark theme from <https://github.com/altercation/solarized>, see // issue #160) var colors = { bold: [1, 22], italic: [3, 23], underline: [4, 24], inverse: [7, 27], white: [37, 39], grey: [90, 39], black: [30, 39], blue: [34, 39], cyan: [36, 39], green: [32, 39], magenta: [35, 39], red: [31, 39], yellow: [33, 39], }; function stylizeWithColor(str, color) { if (!str) { return ''; } var codes = colors[color]; if (codes) { return '\x1B[' + codes[0] + 'm' + str + '\x1B[' + codes[1] + 'm'; } else { return str; } } function stylizeWithoutColor(str, color) { return str; } let stylize = stylizeWithColor; // ---- support functions function rot(moduleName, s) { rotCount++; console.log(`${stylize(moduleName, 'bold')} bitrot: ${s}`); } // Process docs/supported-technologies.asciidoc into an array of: // {name: '<module name>', versions: '<version range>'} // // Note that the tables in this file don't seem to follow the AsciiDoc table // syntax described here: // https://docs.asciidoctor.org/asciidoc/latest/tables/build-a-basic-table/ // I don't know why the difference. This parsing supports just the limited form // I see in supported-technologies.asciidoc. function loadSupportedDoc() { const docPath = 'docs/supported-technologies.asciidoc'; var html = fs.readFileSync(docPath, 'utf8'); var rows = []; var state = null; // null | 'thead' | 'tbody' html.split(/\n/g).forEach(function (line) { if (!line.startsWith('|')) { // no op } else if (state === null) { if (line.startsWith('|===')) { state = 'thead'; } } else if (state === 'thead') { state = 'tbody'; } else if (state === 'tbody') { if (line.startsWith('|===')) { state = null; } else { // Examples: // |https://www.npmjs.com/package/generic-pool[generic-pool] | ^2.0.0 \|\| ^3.1.0 |Used by a lot of ... // |https://www.npmjs.com/package/bluebird[bluebird] |>=2.0.0 <4.0.0 | var escapePlaceholder = '6B1EC7E1-B273-40E9-94C4-197A59B55E24'; var cells = line .trim() .slice(1) // remove leading '|' .replace(/\\\|/g, escapePlaceholder) .split(/\s*\|\s*/g) .map((c) => c.replace(new RegExp(escapePlaceholder, 'g'), '|')) .filter((c) => c.length > 0); rows.push(cells); } } }); // log.trace({rows}, `${docPath} table rows`) // The tables in supported-technologies.asciidoc have the module // name in the first column, and version range in the second. There // are two forms of the first cell to parse: // [ '<<hapi,@hapi/hapi>>', '>=17.9.0 <20.0.0' ], // [ '<<koa,Koa>> via koa-router or @koa/router', '>=5.2.0 <10.0.0' ], // [ '<<restify,Restify>>', '>=5.2.0' ], // [ '<<lambda,AWS Lambda>>', 'N/A' ], // ['https://www.npmjs.com/package/jade[jade]', '>=0.5.6'] // // The entries in the "Frameworks" table use the names of internal links in // these docs. The anchor name is *sometimes* the same name as the npm // module, but sometimes not. var results = []; let match; rows.forEach(function (row) { if (row[1] === 'N/A') { // skip } else if (row[0].includes('<<')) { match = /^\s*<<([\w-]+),(.*?)>>/.exec(row[0]); if (!match) { throw new Error( `could not parse this table cell text from docs/supported-technologies.asciidoc: ${JSON.stringify( row[0], )}`, ); } var moduleNames; if (match[1] === 'nextjs') { moduleNames = ['next']; } else if (match[2] === '@hapi/hapi') { moduleNames = [match[2]]; } else if (match[2] === '@opentelemetry/api') { moduleNames = [match[2]]; } else if (match[1] === 'koa') { moduleNames = ['koa-router', '@koa/router']; } else if (match[1] === 'azure-functions') { moduleNames = []; // Azure Functions compat isn't about an NPM package version. } else { moduleNames = [match[1]]; } moduleNames.forEach((n) => { results.push({ name: n, versions: row[1] }); }); } else { match = /^https:\/\/.*\[(.*)\]$/.exec(row[0].trim()); if (!match) { throw new Error( `could not parse this table cell text from docs/supported-technologies.asciidoc: ${JSON.stringify( row[0], )}`, ); } results.push({ name: match[1], versions: row[1] }); } }); return results; } function getNpmInfo(name) { const CACHE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes const cache = ensureCacheLoaded('npmInfo'); const cacheEntry = cache[name]; if (cacheEntry) { if (cacheEntry.timestamp + CACHE_TIMEOUT_MS > Date.now()) { return cacheEntry.value; } else { delete cache[name]; } } // Limited security guard on exec'ing given `name`. const PKG_NAME_RE = /^(@[\w_.-]+\/)?([\w_.-]+)$/; if (!PKG_NAME_RE.test(name)) { throw new Error( `${JSON.stringify(name)} does not look like a valid npm package name`, ); } const stdout = execSync(`npm info -j "${name}"`); const npmInfo = JSON.parse(stdout); cache[name] = { timestamp: Date.now(), value: npmInfo, }; saveCache(); return npmInfo; } function bitrot(moduleNames) { log.debug({ moduleNames }, 'bitrot'); var tavYmls = [ yaml.load(fs.readFileSync('.tav.yml', 'utf8')), yaml.load(fs.readFileSync('./test/opentelemetry-bridge/.tav.yml', 'utf8')), yaml.load( fs.readFileSync('./test/opentelemetry-metrics/fixtures/.tav.yml', 'utf8'), ), yaml.load( fs.readFileSync( 'test/instrumentation/modules/next/a-nextjs-app/.tav.yml', 'utf8', ), ), ]; var supported = loadSupportedDoc(); // Merge into one data structure we can iterate through. var rangesFromName = {}; var ensureKey = (name) => { if (!(name in rangesFromName)) { rangesFromName[name] = { tavRanges: [], supRanges: [] }; } }; tavYmls.forEach((tavYml) => { for (const [label, tavInfo] of Object.entries(tavYml)) { var name = tavInfo.name || label; ensureKey(name); rangesFromName[name].tavRanges.push(tavInfo.versions); } }); for (const supInfo of supported) { ensureKey(supInfo.name); rangesFromName[supInfo.name].supRanges.push(supInfo.versions); } // Reduce to `moduleNames` if given. if (moduleNames && moduleNames.length > 0) { var allNames = Object.keys(rangesFromName); moduleNames.forEach((name) => { if (!(name in rangesFromName)) { throw new Error( `unknown module name: ${name} (known module names: ${allNames.join( ', ', )})`, ); } }); allNames.forEach((name) => { if (!moduleNames.includes(name)) { delete rangesFromName[name]; } }); } log.debug({ rangesFromName }, 'rangesFromName'); // Check each module name. var namesToCheck = Object.keys(rangesFromName).sort(); namesToCheck.forEach((name) => { var npmInfo = getNpmInfo(name); log.trace( { name, 'dist-tags': npmInfo['dist-tags'], time: npmInfo.time }, 'npmInfo', ); // If the current latest version is in the supported and // tav ranges, then all is good. var latest = npmInfo['dist-tags'].latest; var tavGood = false; if (EXCUSE_FROM_TAV[name]) { tavGood = true; } else { for (const range of rangesFromName[name].tavRanges) { if (semver.satisfies(latest, range, { includePrerelease: true })) { tavGood = true; break; } } } var supGood = false; if (EXCUSE_FROM_SUPPORTED_TECHNOLOGIES_DOC[name]) { supGood = true; } else { for (const range of rangesFromName[name].supRanges) { if (semver.satisfies(latest, range, { includePrerelease: true })) { supGood = true; break; } } } if (tavGood && supGood) { log.debug( `latest ${name}@${latest} is in tav and supported ranges (a good thing)`, ); return; } var issues = []; if (!tavGood) { issues.push( `is not in .tav.yml ranges (${rangesFromName[name].tavRanges.join( ', ', )})`, ); } if (!supGood) { issues.push( `is not in supported-technologies.asciidoc ranges (${rangesFromName[ name ].supRanges.join(', ')})`, ); } rot( name, `latest ${name}@${latest} (released ${ npmInfo.time[latest].split('T')[0] }): ${issues.join(', ')}`, ); }); } // ---- mainline const options = [ { names: ['verbose', 'v'], type: 'bool', help: 'Verbose log output. (Pipe to `ecslog` to format.)', }, { names: ['help', 'h'], type: 'bool', help: 'Print this help and exit.', }, ]; function main(argv) { var parser = dashdash.createParser({ options }); try { var opts = parser.parse(argv); } catch (e) { console.error('help: error: %s', e.message); process.exit(1); } if (opts.help) { var help = parser.help().trimRight(); process.stdout.write(`Synopsis: dev-utils/bitrot.js [OPTIONS] Description: Compare ".tav.yml", "docs/supported-technologies.asciidoc" and the current releases of instrumented modules to list new releases that are not (yet) supported by the APM agent. Options: ${help} Exit status: 0 No bitrot was found. 1 There was an unexpected error. 3 Bitrot was found. `); process.exit(0); } stylize = process.stdout.isTTY ? stylizeWithColor : stylizeWithoutColor; log = pino( { name: 'bitrot', base: {}, // Don't want pid and hostname fields. level: opts.verbose ? 'trace' : 'warn', serializers: { err: pino.stdSerializers.err, req: pino.stdSerializers.req, res: pino.stdSerializers.res, }, ...ecsFormat({ apmIntegration: false }), }, pino.destination(1), ); const moduleNames = opts._args; try { bitrot(moduleNames); } catch (err) { log.debug(err); console.error(`bitrot: error: ${err.message}`); process.exit(1); } if (rotCount > 0) { process.exit(3); } } if (require.main === module) { main(process.argv); }