diff --git a/.gitignore b/.gitignore index d7c53d50..04b0e983 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ vendor/ .bundle out .vscode/ +*~ +.#* +\#*# diff --git a/docs/rules.md b/docs/rules.md index e2148da0..4aba429d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -20,6 +20,7 @@ Below is a complete list of rules that Repolinter can run, along with their conf - [`git-grep-commits`](#git-grep-commits) - [`git-grep-log`](#git-grep-log) - [`git-list-tree`](#git-list-tree) + - [`git-regex-tag-names`](#git-regex-tag-names) - [`git-working-tree`](#git-working-tree) - [`json-schema-passes`](#json-schema-passes) - [`large-file`](#large-file) @@ -189,6 +190,19 @@ Check for blacklisted filepaths in Git. | `denylist` | **Yes** | `string[]` | | A list of patterns to search against all paths in the git history. | | `ignoreCase` | No | `boolean` | `false` | Set to true to make `denylist` case insensitive. | + +### `git-regex-tag-names` + +Check for permitted or denied Git tag names using JavaScript regular expressions. + +| Input | Required | Type | Default | Description | +|--------------|----------|------------|---------|------------------------------------------------------------------| +| `allowlist` | **Yes*** | `string[]` | | A list of permitted patterns to search against all git tag names | +| `denylist` | **Yes*** | `string[]` | | A list of denied patterns to search against all git tag names | +| `ignoreCase` | No | `boolean` | `false` | Set to true to make `denylist` case insensitive. | + +*`allowlist` and `denylist` cannot be both used within the same rule. + ### `git-working-tree` Checks whether the directory is managed with Git. diff --git a/lib/git_helper.js b/lib/git_helper.js index a3dfbeaf..6249c54f 100644 --- a/lib/git_helper.js +++ b/lib/git_helper.js @@ -9,6 +9,18 @@ function gitAllCommits(targetDir) { return spawnSync('git', args).stdout.toString().split('\n') } +/** + * @param targetDir + * @ignore + */ +function gitAllTagNames(targetDir) { + const args = ['-C', targetDir, 'tag', '-l'] + const tagNames = spawnSync('git', args).stdout.toString().split('\n') + tagNames.pop() + return tagNames +} + module.exports = { - gitAllCommits + gitAllCommits, + gitAllTagNames } diff --git a/rules/git-regex-tag-names-config.json b/rules/git-regex-tag-names-config.json new file mode 100644 index 00000000..ad47233e --- /dev/null +++ b/rules/git-regex-tag-names-config.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://raw.githubusercontent.com/todogroup/repolinter/master/rules/git-regex-tag-names-config.json", + "type": "object", + "oneOf": [ + { + "properties": { + "allowlist": { + "type": "array", + "items": { "type": "string" } + }, + "ignoreCase": { + "type": "boolean", + "default": false + } + }, + "required": ["allowlist"] + }, + { + "properties": { + "denylist": { + "type": "array", + "items": { "type": "string" } + }, + "ignoreCase": { + "type": "boolean", + "default": false + } + }, + "required": ["denylist"] + } + ] +} diff --git a/rules/git-regex-tag-names.js b/rules/git-regex-tag-names.js new file mode 100644 index 00000000..9132dc47 --- /dev/null +++ b/rules/git-regex-tag-names.js @@ -0,0 +1,142 @@ +// Copyright 2024 TODO Group. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const Result = require('../lib/result') +// eslint-disable-next-line no-unused-vars +const FileSystem = require('../lib/file_system') +const GitHelper = require('../lib/git_helper') + +/** + * @param {string} flags + * @returns {regexMatchFactory~regexFn} + * @ignore + */ +function regexMatchFactory(flags) { + /** + * @param {string} value + * @param {string} pattern + * @returns {object} + * @ignore + */ + const regexFn = function (value, pattern) { + return value.match(new RegExp(pattern, flags)) + } + return regexFn +} + +/** + * @param {string[]} tagNames + * @param {object} options The rule configuration + * @param {string[]=} options.allowlist + * @param {string[]=} options.denylist + * @param {boolean=} options.ignoreCase + * @returns {Result} + * @ignore + */ +function validateAgainstAllowlist(tagNames, options) { + const targets = [] + const allowlist = options.allowlist + console.log(options.ignoreCase) + const regexMatch = regexMatchFactory(options.ignoreCase ? 'i' : '') + + for (const tagName of tagNames) { + let matched = false + for (const allowRegex of allowlist) { + if (regexMatch(tagName, allowRegex) !== null) { + matched = true + break // tag name passed at least one allowlist entry. + } + } + if (!matched) { + // Tag name did not pass any allowlist entries + const message = [ + `The tag name for tag "${tagName}" does not match any regex in allowlist.\n`, + `\tAllowlist: ${allowlist.join(', ')}` + ].join('\n') + + targets.push({ + passed: false, + message, + path: tagName + }) + } + } + + if (targets.length <= 0) { + const message = [ + `Tag names comply with regex allowlist.\n`, + `\tAllowlist: ${allowlist.join(', ')}` + ].join('\n') + return new Result(message, [], true) + } + return new Result('', targets, false) +} + +/** + * @param {string[]} tagNames + * @param {object} options The rule configuration + * @param {string[]=} options.allowlist + * @param {string[]=} options.denylist + * @param {boolean=} options.ignoreCase + * @returns {Result} + * @ignore + */ +function validateAgainstDenylist(tagNames, options) { + const targets = [] + const denylist = options.denylist + const regexMatch = regexMatchFactory(options.ignoreCase ? 'i' : '') + + for (const tagName of tagNames) { + for (const denyRegex of denylist) { + if (regexMatch(tagName, denyRegex) !== null) { + // Tag name matches a denylist entry + const message = [ + `The tag name for tag "${tagName}" matched a regex in denylist.\n`, + `\tDenylist: ${denylist.join(', ')}` + ].join('\n') + + targets.push({ + passed: false, + message, + path: tagName + }) + } + } + } + if (targets.length <= 0) { + const message = [ + `No denylisted regex found in any tag names.\n`, + `\tDenylist: ${denylist.join(', ')}` + ].join('\n') + return new Result(message, [], true) + } + return new Result('', targets, false) +} + +/** + * + * @param {FileSystem} fs A filesystem object configured with filter paths and target directories + * @param {object} options The rule configuration + * @param {string[]=} options.allowlist + * @param {string[]=} options.denylist + * @param {boolean=} options.ignoreCase + * @returns {Result} The lint rule result + * @ignore + */ +function gitRegexTagNames(fs, options) { + if (options.allowlist && options.denylist) { + throw new Error('"allowlist" and "denylist" cannot be both set.') + } else if (!options.allowlist && !options.denylist) { + throw new Error('missing "allowlist" or "denylist".') + } + const tagNames = GitHelper.gitAllTagNames(fs.targetDir) + + // Allowlist + if (options.allowlist) { + return validateAgainstAllowlist(tagNames, options) + } else if (options.denylist) { + return validateAgainstDenylist(tagNames, options) + } +} + +module.exports = gitRegexTagNames diff --git a/rules/rules.js b/rules/rules.js index 0acb0134..a6e25f05 100644 --- a/rules/rules.js +++ b/rules/rules.js @@ -17,6 +17,7 @@ module.exports = { 'git-grep-commits': require('./git-grep-commits'), 'git-grep-log': require('./git-grep-log'), 'git-list-tree': require('./git-list-tree'), + 'git-regex-tag-names': require('./git-regex-tag-names'), 'git-working-tree': require('./git-working-tree'), 'large-file': require('./large-file'), 'license-detectable-by-licensee': require('./license-detectable-by-licensee'), diff --git a/rulesets/schema.json b/rulesets/schema.json index 8c469454..a0757fc4 100644 --- a/rulesets/schema.json +++ b/rulesets/schema.json @@ -214,6 +214,18 @@ } } }, + { + "if": { + "properties": { "type": { "const": "git-regex-tag-names" } } + }, + "then": { + "properties": { + "options": { + "$ref": "../rules/git-regex-tag-names-config.json" + } + } + } + }, { "if": { "properties": { "type": { "const": "git-working-tree" } } diff --git a/tests/rules/git_regex_tag_names_tests.js b/tests/rules/git_regex_tag_names_tests.js new file mode 100644 index 00000000..802076fe --- /dev/null +++ b/tests/rules/git_regex_tag_names_tests.js @@ -0,0 +1,192 @@ +// Copyright 2024 TODO Group. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const expect = require('chai').expect +const sinon = require('sinon') +const FileSystem = require('../../lib/file_system') +const gitRegexTagNames = require('../../rules/git-regex-tag-names') +const GitHelper = require('../../lib/git_helper') + +describe('rule', function () { + describe('git_regex_tag_names', function () { + const sandbox = sinon.createSandbox() + + function resetStubIfNeeded() { + sandbox.restore() + } + + function givenStub(stubVal) { + stubVal = stubVal || ['v1', 'v0.1', 'v3.0.0', 'v10.1.1', 'dev'] + + resetStubIfNeeded() + sandbox.stub(GitHelper, 'gitAllTagNames').returns(stubVal) + } + + beforeEach(function () { + givenStub() + }) + + after(function () { + resetStubIfNeeded() + }) + + it('allowlist passes with no tags', function () { + givenStub([]) + const ruleAllowlist = { + allowlist: ['foo', 'bar'] + } + const expected = { + message: + 'Tag names comply with regex allowlist.\n\n\tAllowlist: foo, bar', + targets: [], + passed: true + } + const actual = gitRegexTagNames(new FileSystem(), ruleAllowlist) + expect(actual).to.deep.equal(expected) + }) + + it('denylist passes with no tags', function () { + givenStub([]) + const ruleopt = { + denylist: ['foo', 'bar'] + } + const expected = { + message: + 'No denylisted regex found in any tag names.\n\n\tDenylist: foo, bar', + targets: [], + passed: true + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.equal(expected) + }) + + it('passes if all tag names passes at least one case-sensitive allowlist pattern', function () { + const ruleopt = { + allowlist: [ + '^v((0|([1-9][0-9]*)))(.(0|([1-9][0-9]*))){0,2}$', // Regex test for v0.0.0 format + 'dev' // Simple match + ] + } + const expected = { + message: + 'Tag names comply with regex allowlist.\n\n\tAllowlist: ^v((0|([1-9][0-9]*)))(.(0|([1-9][0-9]*))){0,2}$, dev', + targets: [], + passed: true + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.equal(expected) + }) + + it('passes if all tag names passes at least one case-sensitive allowlist pattern', function () { + const ruleopt = { + allowlist: [ + '^v((0|([1-9][0-9]*)))(.(0|([1-9][0-9]*))){0,2}$', // Regex test for v0.0.0 format + 'DEV' // Simple match + ], + ignoreCase: true + } + const expected = { + message: + 'Tag names comply with regex allowlist.\n\n\tAllowlist: ^v((0|([1-9][0-9]*)))(.(0|([1-9][0-9]*))){0,2}$, DEV', + targets: [], + passed: true + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.equal(expected) + }) + + it('passes if the case-sensitive denylist does not match any tag name', function () { + const ruleopt = { + denylist: [ + 'DEV' // Simple match; Test for default case-sensitivity + ] + } + const expectedPartial = { + targets: [], + passed: true + } + + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.own.include(expectedPartial) + }) + + it('fails if neither allowlist nor denylist are set', function () { + const ruleopt = {} + function call() { + gitRegexTagNames(new FileSystem(), ruleopt) + } + expect(call).to.throw('missing "allowlist" or "denylist".') + }) + + it('fails if allowlist and denylist are both set', function () { + const ruleopt = { + allowlist: ['foo', 'bar'], + denylist: ['foo', 'bar'] + } + function call() { + gitRegexTagNames(new FileSystem(), ruleopt) + } + expect(call).to.throw('"allowlist" and "denylist" cannot be both set.') + }) + + it('fails if tag name does not pass any case-sensitive allowlist pattern', function () { + const ruleopt = { + allowlist: [ + 'DEV' // Simple match; Test for default case-sensitivity + ] + } + const expectedPartial = { + passed: false + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.own.include(expectedPartial) + expect(actual).to.not.have.property('message') + expect(actual.targets.length).to.be.above(0) + }) + + it('fails if case-insensitive denylist pattern matches a tag name', function () { + const ruleopt = { + denylist: [ + 'dev' // Simple match; Test for default case-sensitivity + ] + } + const expectedPartial = { + targets: [ + { + passed: false, + message: + 'The tag name for tag "dev" matched a regex in denylist.\n\n\tDenylist: dev', + path: 'dev' + } + ], + passed: false + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.equal(expectedPartial) + expect(actual).to.not.have.property('message') + }) + + it('fails if case-sensitive denylist pattern matches a tag name', function () { + const ruleopt = { + denylist: [ + 'DEV' // Simple match; Test for default case-sensitivity + ], + ignoreCase: true + } + const expectedPartial = { + targets: [ + { + passed: false, + message: + 'The tag name for tag "dev" matched a regex in denylist.\n\n\tDenylist: DEV', + path: 'dev' + } + ], + passed: false + } + const actual = gitRegexTagNames(new FileSystem(), ruleopt) + expect(actual).to.deep.equal(expectedPartial) + expect(actual).to.not.have.property('message') + }) + }) +})