Skip to content

Commit

Permalink
Add new attributes to the report
Browse files Browse the repository at this point in the history
* license version (for licenses that are versioned, like Apache-1.0 vs
  Apache-2.0),
* SPDX ID without the version suffix,
* link to license file,
* copyright holder,
* copyright year

All mentioned attributes are computed in a best effort, that is, there
is no guarantee that the implementation will find the respective values
even if it might be available in the package metadata or license file.

Also:
- add safeguard around read-package-tree
- sort packages by name and version before printing the report
  • Loading branch information
basti1302 committed Sep 22, 2020
1 parent 1847a36 commit e9097b4
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 18 deletions.
2 changes: 1 addition & 1 deletion cli-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
include: {
description: 'List of properties to include',
type: 'array',
choices: ['id', 'name', 'version', 'license', 'licenseId', 'licenseFullName', 'licenseFilePath', 'path', 'repository', 'author', 'homepage', 'dependencyLevel', 'description'],
choices: ['id', 'name', 'version', 'license', 'licenseId', 'licenseIdWithoutVersion', 'licenseFullName', 'licenseVersion', 'licenseFilePath', 'licenseLink', 'copyrightYear', 'copyrightHolder', 'path', 'repository', 'author', 'homepage', 'dependencyLevel', 'description'],
default: ['id', 'name', 'version', 'license', 'repository', 'author', 'homepage', 'dependencyLevel']
},
production: {
Expand Down
5 changes: 5 additions & 0 deletions formatters/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ module.exports = function ({data, header}) {
name: 'Package Name',
version: 'Version',
licenseId: 'SPDX ID',
licenseIdWithoutVersion: 'SPDX ID (without version)',
licenseVersion: 'License Version',
licenseFullName: 'SPDX Full Name',
licenseFilePath: 'Path to license file',
license: 'License',
licenseLink: 'License Link',
copyrightYear: 'Copyright Year',
copyrightHolder: 'Copyright Holder',
homepage: 'Homepage',
repository: 'Repository',
author: 'Author',
Expand Down
60 changes: 60 additions & 0 deletions helpers/extract-copyright.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const fsPromises = require('fs').promises

const { isString, isObject, isArray, compact } = require('lodash')

/**
* Inpsects the license file and tries to heuristically determine the copyright holder and the copyright year from it.
*
* @param {string} the path to the license file
* @returns {{copyrightYear: string, copyrightHolder: string}} the copyright information parsed from the license file
*/
module.exports = async function extractCopyright(licenseFilePaths) {
if (!licenseFilePaths || licenseFilePaths.length === 0) {
return {}
}
const licenseFilePath = licenseFilePaths[0]
let handle
try {
handle = await fsPromises.open(licenseFilePath, 'r')
const fullFile = await handle.readFile({ encoding: 'utf-8' })
const lines = fullFile.split('\n')
// The copyright line should be somewhere at the start, inspect the first few lines.
for (let i = 0; i < Math.min(lines.length, 5); i++) {
const line = lines[i]
const matchWithRange = /copyright(?:.*)(\d{4}\s*-\s*\d{4})(?:[,;.]?)\s+(.*)$/i.exec(line)
if (matchWithRange) {
return cleanUp({ copyrightYear: matchWithRange[1], copyrightHolder: matchWithRange[2] })
}
const matchWithYear = /copyright(?:.*)(\d{4})(?:[,;.]?)\s+(.*)$/i.exec(line)
if (matchWithYear) {
return cleanUp({ copyrightYear: matchWithYear[1], copyrightHolder: matchWithYear[2] })
}
const matchWithoutYear = /copyright\s+(.*)$/i.exec(line)
if (matchWithoutYear) {
return cleanUp({ copyrightYear: null, copyrightHolder: matchWithoutYear[1] })
}
}
} catch (e) {
console.warn('Could not open license file to parse copyright information.', e)
} finally {
if (handle) {
await handle.close()
}
}
return {}
}

function cleanUp(copyright) {
const patterns = [
/\s*All rights reserved.\s*/ig,
/\s*\([^\s]+@[^\s]+\)/ig, // matches "([email protected])"
/\s*<[^\s]+@[^\s]+>/ig, // matches "<[email protected]>"
/\s*<http[^\s]+>/ig, // matches "<http(s)://domain.tld>"
/\s*\([cC]\)/ig
]
patterns.forEach(p => {
copyright.copyrightHolder = copyright.copyrightHolder.replace(p, '')
})
copyright.copyrightHolder = copyright.copyrightHolder.trim()
return copyright
}
2 changes: 1 addition & 1 deletion helpers/extract-license-id.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { isString, isObject, isArray, compact } = require('lodash')

/**
* Deal with all the crazy stuff the "license" field in package.json can have and return only the SPDX ID (if any)
* Deal with all the wild stuff the "license" field in package.json can have and return only the SPDX ID (if any).
*
* @param {*} license
* @returns {string}
Expand Down
23 changes: 23 additions & 0 deletions helpers/extract-license-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const { isString, isObject, isArray, compact } = require('lodash')

/**
* Takes an SPDX identifier like Apache-1.0 and splits it into "Apache" and "1.0".
*
* @param {string} an SPDX identifier
* @returns {{licenseIdWithoutVersion: string, licenseVersion: string}} the SPDX ID parsed into individual parts. For
* unversioned licenses, licenseIdWithoutVersion without version will contain the input and licenseVersion will be
* null.
*/
module.exports = function extractLicenseText(spdxId) {
const match = /^(.*?)-(\d[\d\.]+)$/.exec(spdxId)
if (match) {
return {
licenseIdWithoutVersion: match[1],
licenseVersion: match[2]
}
}
return {
licenseIdWithoutVersion: spdxId,
licenseVersion: null
}
}
12 changes: 8 additions & 4 deletions helpers/get-package-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ module.exports = async function (path) {
if (!path) {
throw new Error('You must specify a path')
}
const raw = await readPackageTree(path)
return raw.package

}
try {
const raw = await readPackageTree(path)
return raw.package
} catch (e) {
console.error(`Reading package tree failed for ${path}.`, e);
return null;
}
}
2 changes: 1 addition & 1 deletion helpers/npm-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const optionsToArgv = require('./options-to-args')
module.exports = function (opts = {}) {
const blackListOpts = ['format']
const options = optionsToArgv(opts, blackListOpts)

return new Promise((resolve, reject) => {

debug('Got these options: %s', JSON.stringify(options, null, 2))
Expand Down
48 changes: 40 additions & 8 deletions lib.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const { chain, compact, sortBy } = require('lodash')
const promisify = require('util').promisify
const npmLs = require('./helpers/npm-list')
const getPackageDetails = require('./helpers/get-package-details')
const getExpandedLicName = require('./helpers/get-spdx-full-name')
const extractLicenseText = require('./helpers/extract-license-id')
const extractLicenseId = require('./helpers/extract-license-id')
const extractLicenseVersion = require('./helpers/extract-license-version')
const extractCopyright = require('./helpers/extract-copyright')
const glob = promisify(require('glob'))

/**
Expand All @@ -12,30 +15,59 @@ const glob = promisify(require('glob'))
*/
module.exports = async function (options = {}) {
const pathList = await npmLs(options)
return await Promise.all(pathList.map(async (path, index) => {
const results = await Promise.all(pathList.map(async (path, index) => {
const pkg = await getPackageDetails(path)
const licShortName = extractLicenseText(pkg.license || pkg.licenses || pkg.licence || pkg.licences)
if (!pkg) {
return null;
}
const repository = (pkg.repository || {}).url
const licShortName = extractLicenseId(pkg.license || pkg.licenses || pkg.licence || pkg.licences)
const licLongName = getExpandedLicName(licShortName) || 'unknown'
const { licenseIdWithoutVersion, licenseVersion } = extractLicenseVersion(licShortName)

// find any local licences files and build a path to them
const licFilePath = await glob('+(license**|licence**)', {cwd: path, nocase: true, nodir: true})
.then(files => files.map(file => `${path}/${file}`))
const allLicenseFiles = await glob('+(license**|licence**)', {cwd: path, nocase: true, nodir: true})
const licenseFilePaths = allLicenseFiles.map(file => `${path}/${file}`)
const licenseLink =
repository && allLicenseFiles.length > 0 ?
`${repositoryToHttp(repository)}/${allLicenseFiles[0]}` :
''

const { copyrightYear, copyrightHolder } = await extractCopyright(licenseFilePaths)
return {
id: index,
name: pkg.name,
version: pkg.version,
licenseId: licShortName,
licenseIdWithoutVersion,
licenseVersion,
licenseFullName: licLongName,
licenseFilePath: licFilePath || [],
licenseFilePath: licenseFilePaths || [],
license: `${licLongName} (${licShortName || '?'})`,
repository: (pkg.repository || {}).url,
licenseLink,
copyrightYear,
copyrightHolder,
repository,
author: (pkg.author || {}).name,
homepage: pkg.homepage,
path,
dependencyLevel: pkg._development ? 'development' : 'production',
description: pkg.description
}
}))
return chain(results).compact().sortBy(['name', 'version']).value()
}

}
function repositoryToHttp(repositoryUrl) {
if (repositoryUrl) {
// The branch "master" might not be actually the default branch of the project but
// the link will still resolve. If there is no master branch, Github will pick the correct default branch and show:
// "Branch not found, redirected to default branch."
// Naturally, for projects not hosted on Github we might be out of luck.
return repositoryUrl
.replace(/^git\+/, '')
.replace(/^ssh:\/\/git@/, 'https://')
.replace(/^git:\/\//, 'https://')
.replace(/\.git/, '/blob/master')
}
}
6 changes: 3 additions & 3 deletions tests/get-package-details.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ test('Returns details for a package at a given path', async (t) => {
t.is(actual.description, expected.description)
})

test('Should fail if the path does not exist', async (t) => {
test('Should return null if the path does not exist', async (t) => {
const path = './some-fake-path'
try {
await getPackageDetails(path)
t.fail('Expected an exception')
const actual = await getPackageDetails(path)
t.is(actual, null)
} catch (err) {
t.pass()
}
Expand Down

0 comments on commit e9097b4

Please sign in to comment.