1
0
mirror of https://github.com/sasjs/lint.git synced 2025-12-10 17:34:36 +00:00

feat(lint): implement v1 with 3 rules - trailing spaces, encoded passwords and Doxygen header

This commit is contained in:
Krishna Acondy
2021-03-22 20:23:10 +00:00
parent f3d7d38984
commit bf23963127
31 changed files with 5603 additions and 0 deletions

9
.github/reviewer-lottery.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
groups:
- name: SASjs Devs # name of the group
reviewers: 1 # how many reviewers do you want to assign?
usernames: # github usernames of the reviewers
- krishna-acondy
- YuryShkoda
- saadjutt01
- medjedovicm
- allanbowe

13
.github/workflows/assign-reviewer.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Assign Reviewer
on:
pull_request:
types: [opened]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: uesteibar/reviewer-lottery@v1
with:
repo-token: ${{ secrets.GH_TOKEN }}

33
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SASjs Lint Build
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: npm ci
- name: Check Code Style
run: npm run lint
- name: Run Unit Tests
run: npm test
- name: Build Package
run: npm run package:lib
env:
CI: true

27
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
name: SASjs Lint Build and Publish
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Dependencies
run: npm ci
- name: Check Code Style
run: npm run lint
- name: Build Project
run: npm run build
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -102,3 +102,6 @@ dist
# TernJS port file # TernJS port file
.tern-port .tern-port
# Build output
build

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

5
.sasjslint Normal file
View File

@@ -0,0 +1,5 @@
{
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true
}

13
jest.config.js Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: -10
}
},
collectCoverageFrom: ['src/**/{!(index|example),}.ts']
}

4905
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "@sasjs/lint",
"description": "Linting and formatting for SAS code",
"scripts": {
"test": "jest --coverage",
"build": "rimraf build && tsc",
"prepublishOnly": "cp -r ./build/* . && rm -rf ./build && rm -rf ./src && rm tsconfig.json",
"postpublish": "git clean -fd",
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
"lint:fix": "npx prettier --write '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'",
"lint": "npx prettier --check '{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}'"
},
"publishConfig": {
"access": "public"
},
"release": {
"branches": [
"main"
]
},
"repository": {
"type": "git",
"url": "git+https://github.com/sasjs/lint.git"
},
"keywords": [
"sas",
"SASjs",
"lint",
"formatting"
],
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/sasjs/lint/issues"
},
"homepage": "https://github.com/sasjs/lint#readme",
"devDependencies": {
"@types/jest": "^26.0.21",
"@types/node": "^14.14.35",
"jest": "^26.6.3",
"rimraf": "^3.0.2",
"ts-jest": "^26.5.4",
"typescript": "^4.2.3"
},
"dependencies": {
"@sasjs/utils": "^2.9.0"
}
}

45
src/example.ts Normal file
View File

@@ -0,0 +1,45 @@
import { lint } from './lint'
const text = `/**
@file
@brief Returns an unused libref
@details Use as follows:
libname mclib0 (work);
libname mclib1 (work);
libname mclib2 (work);
%let libref=%mf_getuniquelibref({SAS001});
%put &=libref;
which returns:
> mclib3
@param prefix= first part of libref. Remember that librefs can only be 8 characters,
so a 7 letter prefix would mean that maxtries should be 10.
@param maxtries= the last part of the libref. Provide an integer value.
@version 9.2
@author Allan Bowe
**/
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;
%if %sysfunc(libref(&prefix&x)) ne 0 %then %do;
%let libref=&prefix&x;
%let rc=%sysfunc(libname(&libref,%sysfunc(pathname(work))));
%if &rc %then %put %sysfunc(sysmsg());
&prefix&x
%*put &sysmacroname: Libref &libref assigned as WORK and returned;
%return;
%end;
%end;
%put unable to find available libref in range &prefix.0-&maxtries;
%mend;
`
lint(text).then((diagnostics) => console.table(diagnostics))

3
src/format.ts Normal file
View File

@@ -0,0 +1,3 @@
export const format = (text: string) => {
}

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export { lint } from './lint'

84
src/lint.spec.ts Normal file
View File

@@ -0,0 +1,84 @@
import { lint, splitText } from './lint'
describe('lint', () => {
it('should identify trailing spaces', async () => {
const text = `/**
@file
**/
%put 'hello';
%put 'world'; `
const results = await lint(text)
expect(results.length).toEqual(2)
expect(results[0]).toEqual({
warning: 'Line contains trailing spaces',
lineNumber: 4,
columnNumber: 18
})
expect(results[1]).toEqual({
warning: 'Line contains trailing spaces',
lineNumber: 5,
columnNumber: 22
})
})
it('should identify encoded passwords', async () => {
const text = `/**
@file
**/
%put '{SAS001}';`
const results = await lint(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
warning: 'Line contains encoded password',
lineNumber: 4,
columnNumber: 11
})
})
it('should identify missing doxygen header', async () => {
const text = `%put 'hello';`
const results = await lint(text)
expect(results.length).toEqual(1)
expect(results[0]).toEqual({
warning: 'File missing Doxygen header',
lineNumber: 1,
columnNumber: 1
})
})
it('should return an empty list with an empty file', async () => {
const text = `/**
@file
**/`
const results = await lint(text)
expect(results.length).toEqual(0)
})
})
describe('splitText', () => {
it('should return an empty array when text is falsy', () => {
const lines = splitText('')
expect(lines.length).toEqual(0)
})
it('should return an array of lines from text', () => {
const lines = splitText(`line 1\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
it('should work with CRLF line endings', () => {
const lines = splitText(`line 1\r\nline 2`)
expect(lines.length).toEqual(2)
expect(lines[0]).toEqual('line 1')
expect(lines[1]).toEqual('line 2')
})
})

46
src/lint.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Diagnostic } from './types/Diagnostic'
import { LintConfig } from './types/LintConfig'
import { getLintConfig } from './utils/getLintConfig'
export const lint = async (text: string) => {
const config = await getLintConfig()
return processText(text, config)
}
export const splitText = (text: string): string[] => {
if (!text) return []
return text.replace(/\r\n/g, '\n').split('\n')
}
const processText = (text: string, config: LintConfig) => {
const lines = splitText(text)
const diagnostics: Diagnostic[] = []
diagnostics.push(...processFile(config, text))
lines.forEach((line, index) => {
diagnostics.push(...processLine(config, line, index + 1))
})
return diagnostics
}
const processFile = (config: LintConfig, fileContent: string): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.fileLintRules.forEach((rule) => {
diagnostics.push(...rule.test(fileContent))
})
return diagnostics
}
const processLine = (
config: LintConfig,
line: string,
lineNumber: number
): Diagnostic[] => {
const diagnostics: Diagnostic[] = []
config.lineLintRules.forEach((rule) => {
diagnostics.push(...rule.test(line, lineNumber))
})
return diagnostics
}

View File

@@ -0,0 +1,37 @@
import { hasDoxygenHeader } from './hasDoxygenHeader'
describe('hasDoxygenHeader', () => {
it('should return an empty array when the file starts with a doxygen header', () => {
const content = `/**
@file
@brief Returns an unused libref
**/
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;`
expect(hasDoxygenHeader.test(content)).toEqual([])
})
it('should return an array with a single diagnostic when the file has no header', () => {
const content = `
%macro mf_getuniquelibref(prefix=mclib,maxtries=1000);
%local x libref;
%let x={SAS002};
%do x=0 %to &maxtries;`
expect(hasDoxygenHeader.test(content)).toEqual([
{ warning: 'File missing Doxygen header', lineNumber: 1, columnNumber: 1 }
])
})
it('should return an array with a single diagnostic when the file is undefined', () => {
const content = undefined
expect(hasDoxygenHeader.test((content as unknown) as string)).toEqual([
{ warning: 'File missing Doxygen header', lineNumber: 1, columnNumber: 1 }
])
})
})

View File

@@ -0,0 +1,24 @@
import { FileLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
const name = 'hasDoxygenHeader'
const description =
'Enforce the presence of a Doxygen header at the start of each file.'
const warning = 'File missing Doxygen header'
const test = (value: string) => {
try {
const hasFileHeader = value.split('/**')[0] !== value
if (hasFileHeader) return []
return [{ warning, lineNumber: 1, columnNumber: 1 }]
} catch (e) {
return [{ warning, lineNumber: 1, columnNumber: 1 }]
}
}
export const hasDoxygenHeader: FileLintRule = {
type: LintRuleType.File,
name,
description,
warning,
test
}

View File

@@ -0,0 +1,35 @@
import { noEncodedPasswords } from './noEncodedPasswords'
describe('noEncodedPasswords', () => {
it('should return an empty array when the line has no encoded passwords', () => {
const line = "%put 'hello';"
expect(noEncodedPasswords.test(line, 1)).toEqual([])
})
it('should return an array with a single diagnostic when the line has an encoded password', () => {
const line = "%put '{SAS001}'; "
expect(noEncodedPasswords.test(line, 1)).toEqual([
{
warning: 'Line contains encoded password',
lineNumber: 1,
columnNumber: 7
}
])
})
it('should return an array with multiple diagnostics when the line has encoded passwords', () => {
const line = "%put '{SAS001} {SAS002}'; "
expect(noEncodedPasswords.test(line, 1)).toEqual([
{
warning: 'Line contains encoded password',
lineNumber: 1,
columnNumber: 7
},
{
warning: 'Line contains encoded password',
lineNumber: 1,
columnNumber: 16
}
])
})
})

View File

@@ -0,0 +1,24 @@
import { LineLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
const name = 'noEncodedPasswords'
const description = 'Disallow encoded passwords in SAS code.'
const warning = 'Line contains encoded password'
const test = (value: string, lineNumber: number) => {
const regex = new RegExp(/{sas\d{2,4}}[^;"'\s]*/, 'gi')
const matches = value.match(regex)
if (!matches || !matches.length) return []
return matches.map((match) => ({
warning,
lineNumber,
columnNumber: value.indexOf(match) + 1
}))
}
export const noEncodedPasswords: LineLintRule = {
type: LintRuleType.Line,
name,
description,
warning,
test
}

View File

@@ -0,0 +1,19 @@
import { noTrailingSpaces } from './noTrailingSpaces'
describe('noTrailingSpaces', () => {
it('should return an empty array when the line has no trailing spaces', () => {
const line = "%put 'hello';"
expect(noTrailingSpaces.test(line, 1)).toEqual([])
})
it('should return an array with a single diagnostic when the line has trailing spaces', () => {
const line = "%put 'hello'; "
expect(noTrailingSpaces.test(line, 1)).toEqual([
{
warning: 'Line contains trailing spaces',
lineNumber: 1,
columnNumber: 14
}
])
})
})

View File

@@ -0,0 +1,18 @@
import { LineLintRule } from '../types/LintRule'
import { LintRuleType } from '../types/LintRuleType'
const name = 'noTrailingSpaces'
const description = 'Disallow trailing spaces on lines.'
const warning = 'Line contains trailing spaces'
const test = (value: string, lineNumber: number) =>
value.trimEnd() === value
? []
: [{ warning, lineNumber, columnNumber: value.trimEnd().length + 1 }]
export const noTrailingSpaces: LineLintRule = {
type: LintRuleType.Line,
name,
description,
warning,
test
}

5
src/types/Diagnostic.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface Diagnostic {
lineNumber: number
columnNumber: number
warning: string
}

View File

@@ -0,0 +1,61 @@
import { LintConfig } from './LintConfig'
import { LintRuleType } from './LintRuleType'
describe('LintConfig', () => {
it('should create an empty instance', () => {
const config = new LintConfig()
expect(config).toBeTruthy()
expect(config.fileLintRules.length).toEqual(0)
expect(config.lineLintRules.length).toEqual(0)
})
it('should create an instance with the noTrailingSpaces flag set', () => {
const config = new LintConfig({ noTrailingSpaces: true })
expect(config).toBeTruthy()
expect(config.lineLintRules.length).toEqual(1)
expect(config.lineLintRules[0].name).toEqual('noTrailingSpaces')
expect(config.lineLintRules[0].type).toEqual(LintRuleType.Line)
expect(config.fileLintRules.length).toEqual(0)
})
it('should create an instance with the noEncodedPasswords flag set', () => {
const config = new LintConfig({ noEncodedPasswords: true })
expect(config).toBeTruthy()
expect(config.lineLintRules.length).toEqual(1)
expect(config.lineLintRules[0].name).toEqual('noEncodedPasswords')
expect(config.lineLintRules[0].type).toEqual(LintRuleType.Line)
expect(config.fileLintRules.length).toEqual(0)
})
it('should create an instance with the hasDoxygenHeader flag set', () => {
const config = new LintConfig({ hasDoxygenHeader: true })
expect(config).toBeTruthy()
expect(config.lineLintRules.length).toEqual(0)
expect(config.fileLintRules.length).toEqual(1)
expect(config.fileLintRules[0].name).toEqual('hasDoxygenHeader')
expect(config.fileLintRules[0].type).toEqual(LintRuleType.File)
})
it('should create an instance with all flags set', () => {
const config = new LintConfig({
noTrailingSpaces: true,
noEncodedPasswords: true,
hasDoxygenHeader: true
})
expect(config).toBeTruthy()
expect(config.lineLintRules.length).toEqual(2)
expect(config.lineLintRules[0].name).toEqual('noTrailingSpaces')
expect(config.lineLintRules[0].type).toEqual(LintRuleType.Line)
expect(config.lineLintRules[1].name).toEqual('noEncodedPasswords')
expect(config.lineLintRules[1].type).toEqual(LintRuleType.Line)
expect(config.fileLintRules.length).toEqual(1)
expect(config.fileLintRules[0].name).toEqual('hasDoxygenHeader')
expect(config.fileLintRules[0].type).toEqual(LintRuleType.File)
})
})

23
src/types/LintConfig.ts Normal file
View File

@@ -0,0 +1,23 @@
import { hasDoxygenHeader } from '../rules/hasDoxygenHeader'
import { noEncodedPasswords } from '../rules/noEncodedPasswords'
import { noTrailingSpaces } from '../rules/noTrailingSpaces'
import { FileLintRule, LineLintRule } from './LintRule'
export class LintConfig {
readonly lineLintRules: LineLintRule[] = []
readonly fileLintRules: FileLintRule[] = []
constructor(json?: any) {
if (json?.noTrailingSpaces) {
this.lineLintRules.push(noTrailingSpaces)
}
if (json?.noEncodedPasswords) {
this.lineLintRules.push(noEncodedPasswords)
}
if (json?.hasDoxygenHeader) {
this.fileLintRules.push(hasDoxygenHeader)
}
}
}

19
src/types/LintRule.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Diagnostic } from './Diagnostic'
import { LintRuleType } from './LintRuleType'
export interface LintRule {
type: LintRuleType
name: string
description: string
warning: string
test: (value: string, lineNumber: number) => Diagnostic[]
}
export interface LineLintRule extends LintRule {
type: LintRuleType.Line
}
export interface FileLintRule extends LintRule {
type: LintRuleType.File
test: (value: string) => Diagnostic[]
}

View File

@@ -0,0 +1,4 @@
export enum LintRuleType {
Line,
File
}

View File

@@ -0,0 +1,10 @@
import { LintConfig } from '../types/LintConfig'
import { getLintConfig } from './getLintConfig'
describe('getLintConfig', () => {
it('should get the lint config', async () => {
const config = await getLintConfig()
expect(config).toBeInstanceOf(LintConfig)
})
})

View File

@@ -0,0 +1,10 @@
import path from 'path'
import { LintConfig } from '../types/LintConfig'
import { readFile } from '@sasjs/utils/file'
import { getProjectRoot } from './getProjectRoot'
export async function getLintConfig(): Promise<LintConfig> {
const projectRoot = await getProjectRoot()
const configuration = await readFile(path.join(projectRoot, '.sasjslint'))
return new LintConfig(JSON.parse(configuration))
}

View File

@@ -0,0 +1,22 @@
import { getProjectRoot } from './getProjectRoot'
import path from 'path'
describe('getProjectRoot', () => {
it('should return the current location if it contains the lint config file', async () => {
const projectRoot = await getProjectRoot()
expect(projectRoot).toEqual(process.cwd())
})
it('should return the parent folder if it contains the lint config file', async () => {
const currentLocation = process.cwd()
jest
.spyOn(process, 'cwd')
.mockImplementationOnce(() =>
path.join(currentLocation, 'folder', 'subfolder')
)
const projectRoot = await getProjectRoot()
expect(projectRoot).toEqual(currentLocation)
})
})

View File

@@ -0,0 +1,27 @@
import path from 'path'
import { fileExists } from '@sasjs/utils/file'
export async function getProjectRoot() {
let root = ''
let rootFound = false
let i = 1
let currentLocation = process.cwd()
const maxLevels = currentLocation.split(path.sep).length
while (i <= maxLevels && !rootFound) {
const isRoot = await fileExists(path.join(currentLocation, '.sasjslint'))
if (isRoot) {
rootFound = true
root = currentLocation
break
} else {
currentLocation = path.join(currentLocation, '..')
i++
}
}
return root
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"lib": [
"ES2018",
"DOM",
"ES2019.String"
],
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true,
"outDir": "./build",
"strict": true,
"sourceMap": true
},
"include": [
"src"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}