├── .gitignore ├── cli ├── cli.json ├── version.ts ├── cmds │ ├── upgrade.ts │ ├── init.ts │ ├── estimate-billing.ts │ ├── secrets.ts │ ├── check.ts │ └── login.ts ├── currentFilePath.ts ├── findRoot.ts ├── ignore.test.ts ├── git.test.ts ├── config.ts ├── main.ts ├── walkTextFiles.ts ├── rules.ts ├── git.ts └── ignore.js ├── .vscode └── settings.json ├── rules └── no-bugs.md ├── .github └── workflows │ └── lintrule.yml ├── README.md ├── install.sh └── scripts └── release.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist -------------------------------------------------------------------------------- /cli/cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.43" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true 4 | } -------------------------------------------------------------------------------- /cli/version.ts: -------------------------------------------------------------------------------- 1 | import data from "./cli.json" assert { type: "json" }; 2 | 3 | export async function readVersion() { 4 | return data.version; 5 | } 6 | -------------------------------------------------------------------------------- /rules/no-bugs.md: -------------------------------------------------------------------------------- 1 | --- 2 | include: ["**/**.ts"] 3 | --- 4 | 5 | Fail if you see obvious instances of: 6 | 7 | - infinite loops 8 | - syntax errors 9 | -------------------------------------------------------------------------------- /cli/cmds/upgrade.ts: -------------------------------------------------------------------------------- 1 | export async function upgrade() { 2 | const cmd = new Deno.Command("bash", { 3 | args: ["-c", "sh <(curl -s https://www.lintrule.com/install.sh)"], 4 | stdout: "piped", 5 | stderr: "piped", 6 | }); 7 | 8 | const { code, stdout, stderr } = await cmd.output(); 9 | 10 | if (code !== 0) { 11 | throw new Error(new TextDecoder().decode(stderr)); 12 | } 13 | 14 | console.log(new TextDecoder().decode(stdout)); 15 | console.error(new TextDecoder().decode(stderr)); 16 | } 17 | -------------------------------------------------------------------------------- /cli/cmds/init.ts: -------------------------------------------------------------------------------- 1 | import { join } from "https://deno.land/std@0.185.0/path/mod.ts"; 2 | 3 | export async function initCmd() { 4 | // If there's not already a rules directory 5 | // add one 6 | await Deno.mkdir("rules", { recursive: true }); 7 | 8 | // Add a markdown file called "no-bugs.md" 9 | await Deno.writeTextFile( 10 | join("rules", "no-bugs.md"), 11 | "don't approve obvious bugs." 12 | ); 13 | 14 | // Print out an instruction to login and then run rules check 15 | console.log(`You're ready to go! Try running: 16 | rules login 17 | rules check`); 18 | } 19 | -------------------------------------------------------------------------------- /cli/currentFilePath.ts: -------------------------------------------------------------------------------- 1 | export function currentFilePath() { 2 | // Get the URL of the current module 3 | const url = new URL(import.meta.url); 4 | 5 | // Get the pathname (this will still have a leading / on Windows) 6 | let path = url.pathname; 7 | 8 | // If on Windows, remove leading / 9 | if (Deno.build.os === "windows" && path.charAt(0) === "/") { 10 | path = path.substr(1); 11 | } 12 | 13 | return path; 14 | } 15 | 16 | export function currentFolder() { 17 | const path = currentFilePath(); 18 | const parts = path.split("/"); 19 | parts.pop(); 20 | return parts.join("/"); 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/lintrule.yml: -------------------------------------------------------------------------------- 1 | name: Lint Rules Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | rules: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Install Lint Rules 22 | run: | 23 | curl -fsSL https://www.lintrule.com/install.sh | bash 24 | 25 | - name: Run Lint Rules Check 26 | run: | 27 | rules check --secret "${{ secrets.LINTRULE_SECRET }}" 28 | -------------------------------------------------------------------------------- /cli/findRoot.ts: -------------------------------------------------------------------------------- 1 | // The root folder is the one with the `rules` directory in it. 2 | import { join } from "https://deno.land/std@0.185.0/path/mod.ts"; 3 | 4 | const RULES_FOLDER_NAME = "rules"; 5 | 6 | async function isRoot(folder: string) { 7 | const entries = await Deno.readDir(folder); 8 | for await (const entry of entries) { 9 | if (entry.name === RULES_FOLDER_NAME) { 10 | return true; 11 | } 12 | if (entry.name === ".git") { 13 | return true; 14 | } 15 | } 16 | return false; 17 | } 18 | 19 | export async function findRoot() { 20 | // Check if we're in the root folder 21 | const root = Deno.cwd(); 22 | if (await isRoot(root)) { 23 | return root; 24 | } 25 | 26 | // Otherwise keep checking the parent directory until we find it 27 | // or we run out of parents 28 | let parent = root; 29 | while (true) { 30 | parent = join(parent, ".."); 31 | 32 | if (await isRoot(parent)) { 33 | return parent; 34 | } else if (parent === "/") { 35 | return null; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cli/cmds/estimate-billing.ts: -------------------------------------------------------------------------------- 1 | export async function estimateBillingCommand() { 2 | // git log with numstat 3 | // git log --since="$one_month_ago" --until="$current_date" --numstat --pretty=format:"" 4 | 5 | const log = Deno.run({ 6 | cmd: [ 7 | "git", 8 | "log", 9 | "--since=1.months", 10 | "--until=now", 11 | "--numstat", 12 | '--pretty=format:""', 13 | ], 14 | stdout: "piped", 15 | cwd: Deno.cwd(), 16 | }); 17 | 18 | const output = await log.output(); 19 | const changes = new TextDecoder() 20 | .decode(output) 21 | .split("\n") 22 | .filter((l) => l && l.trim() !== "" && l !== '""') 23 | .map((l) => l.split("\t")) 24 | .map(([additions, deletions, filename]) => { 25 | return { 26 | additions: parseInt(additions), 27 | deletions: parseInt(deletions), 28 | filename, 29 | }; 30 | }); 31 | 32 | const contextBufferSize = 50; 33 | const linesOfCode = changes.reduce((acc, change) => { 34 | if (!change.additions) { 35 | return acc; 36 | } 37 | 38 | return acc + change.additions + contextBufferSize; 39 | }, 0); 40 | 41 | console.log( 42 | `Lintrule would cost $${linesOfCode / 1000} in the last month per rule` 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /cli/ignore.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; 2 | import ignore from "./ignore.js"; 3 | 4 | // Define the ignore function 5 | function isPathIgnored(entryPath: string, gitignoreContent: string): boolean { 6 | const ignoreLines = gitignoreContent.split("\n"); 7 | const ig = ignore().add(ignoreLines); 8 | return ig.ignores(entryPath); 9 | } 10 | 11 | Deno.test("ignore", () => { 12 | const gitignoreContent = ` 13 | # Ignore node_modules 14 | node_modules 15 | 16 | # Ignore all .log files 17 | *.log 18 | 19 | # Exclude files 20 | !app.log 21 | `; 22 | 23 | const testCases = [ 24 | { entryPath: "node_modules/package.json", shouldBeIgnored: true }, 25 | { entryPath: "src/index.js", shouldBeIgnored: false }, 26 | { entryPath: "error.log", shouldBeIgnored: true }, 27 | { entryPath: "app.log", shouldBeIgnored: false }, 28 | ]; 29 | 30 | for (const testCase of testCases) { 31 | const { entryPath, shouldBeIgnored } = testCase; 32 | const isIgnored = isPathIgnored(entryPath, gitignoreContent); 33 | assertEquals( 34 | isIgnored, 35 | shouldBeIgnored, 36 | `The path '${entryPath}' should ${ 37 | shouldBeIgnored ? "be ignored" : "not be ignored" 38 | }` 39 | ); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /cli/git.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.117.0/testing/asserts.ts"; 2 | import { parseDiffToHunks, parseDiffToFiles } from "./git.ts"; // Assuming the function is in this module 3 | import { currentFolder } from "./currentFilePath.ts"; 4 | import { join } from "https://deno.land/std@0.107.0/path/mod.ts"; 5 | 6 | Deno.test("parseDiff function", () => { 7 | const diff = `+++ file1.js 8 | @@ -64,17 +82,19 @@ 9 | +++ file2.js 10 | @@ -10,5 +20,7 @@`; 11 | 12 | const expected = [ 13 | { file: "file1.js", x: 64, y: 17, z: 82, w: 19 }, 14 | { file: "file2.js", x: 10, y: 5, z: 20, w: 7 }, 15 | ]; 16 | 17 | const result = parseDiffToHunks(diff); 18 | 19 | assertEquals(result, expected); 20 | }); 21 | 22 | Deno.test("parseDiff function", () => { 23 | const diff = `+++ file1.js 24 | @@ -64,17 +82,19 @@`; 25 | 26 | const expected = [{ file: "file1.js", x: 64, y: 17, z: 82, w: 19 }]; 27 | 28 | const result = parseDiffToHunks(diff); 29 | 30 | assertEquals(result, expected); 31 | }); 32 | 33 | Deno.test("parseDiff when file deleted", () => { 34 | const diff = `diff --git a/README.md b/README.md 35 | deleted file mode 100644 36 | index 98584f5..0000000 37 | --- a/README.md 38 | +++ /dev/null 39 | @@ -1,3 +0,0 @@ 40 | -# Lintrule 41 | - 42 | -[Lintrule](https://lintrule.com) is a new kind of linter and test framework.`; 43 | 44 | const files = parseDiffToFiles(diff); 45 | 46 | assertEquals(files, []); 47 | }); 48 | -------------------------------------------------------------------------------- /cli/cmds/secrets.ts: -------------------------------------------------------------------------------- 1 | import * as colors from "https://deno.land/std@0.185.0/fmt/colors.ts"; 2 | import { readConfig } from "../config.ts"; 3 | 4 | export async function secretsCreateCmd(props: { 5 | host: string; 6 | label?: string; 7 | secret?: string; 8 | }) { 9 | const config = await readConfig(); 10 | const accessToken = props.secret || config.accessToken; 11 | if (!accessToken) { 12 | console.error("Please run 'rules login' first."); 13 | Deno.exit(1); 14 | } 15 | 16 | const result = await fetch(`${props.host}/api/secrets`, { 17 | method: "POST", 18 | headers: { 19 | "Content-Type": "application/json", 20 | Authorization: `Bearer ${config.accessToken}`, 21 | }, 22 | body: JSON.stringify({ 23 | label: props.label || "Created at " + new Date().toISOString(), 24 | }), 25 | }); 26 | 27 | if (!result.ok) { 28 | const text = await result.text(); 29 | throw new Error(`Failed to create secret: ${text} ${result.status}`); 30 | } 31 | 32 | const json = (await result.json()) as { 33 | object: "access_token"; 34 | token: string; 35 | label: string; 36 | }; 37 | 38 | console.log(` 39 | ${colors.brightGreen("Secret created!")} 40 | 41 | ${colors.bold("Label:")} ${json.label} 42 | ${colors.bold("Secret:")} ${json.token} 43 | 44 | ${colors.dim("# Usage with environment variables")} 45 | export LINTRULE_SECRET=${json.token} 46 | rules check 47 | 48 | ${colors.dim("# Usage with command line arguments")} 49 | rules check --secret ${json.token}\n`); 50 | } 51 | 52 | export async function secretsListCmd() {} 53 | -------------------------------------------------------------------------------- /cli/config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "https://deno.land/std@0.185.0/path/mod.ts"; 2 | 3 | export interface Config { 4 | accessToken?: string; 5 | } 6 | 7 | function getConfigPath() { 8 | // On linux this will be ~/.config/lintrule/config.json 9 | // On windows this will be %APPDATA%/lintrule/config.json 10 | // On mac this will be ~/.lintrule/config.json 11 | 12 | switch (Deno.build.os) { 13 | case "linux": 14 | return join(Deno.env.get("HOME") || "", ".config", "lintrule.json"); 15 | case "windows": 16 | return join(Deno.env.get("APPDATA") || "", "lintrule", "config.json"); 17 | case "darwin": 18 | return join(Deno.env.get("HOME") || "", ".config", "lintrule.json"); 19 | } 20 | 21 | // Even though this is technically unreachable from the types 22 | // if you don't have this here, `compile` doesn't work. 23 | throw new Error("Unsupported platform: " + Deno.build.os); 24 | } 25 | 26 | export async function ensureEntirePath() { 27 | const configPath = getConfigPath(); 28 | const configDir = join(configPath, ".."); 29 | await Deno.mkdir(configDir, { recursive: true }); 30 | } 31 | 32 | export async function readConfig(): Promise { 33 | await ensureEntirePath(); 34 | const configPath = getConfigPath(); 35 | try { 36 | const configText = await Deno.readTextFile(configPath); 37 | const config = JSON.parse(configText); 38 | 39 | const accessToken = Deno.env.get("LINTRULE_SECRET") || config.accessToken; 40 | return { 41 | ...config, 42 | accessToken, 43 | }; 44 | } catch (_) { 45 | const accessToken = Deno.env.get("LINTRULE_SECRET"); 46 | return { 47 | accessToken, 48 | }; 49 | } 50 | } 51 | 52 | export async function writeConfig(changes: Partial) { 53 | await ensureEntirePath(); 54 | const configPath = getConfigPath(); 55 | const config = readConfig(); 56 | const newConfig = { ...config, ...changes }; 57 | await Deno.writeTextFile(configPath, JSON.stringify(newConfig, null, 2)); 58 | } 59 | -------------------------------------------------------------------------------- /cli/main.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts"; 2 | import { loginCmd } from "./cmds/login.ts"; 3 | import { checkCmd } from "./cmds/check.ts"; 4 | import { readVersion } from "./version.ts"; 5 | import { initCmd } from "./cmds/init.ts"; 6 | import { estimateBillingCommand } from "./cmds/estimate-billing.ts"; 7 | import { upgrade } from "./cmds/upgrade.ts"; 8 | import { secretsCreateCmd, secretsListCmd } from "./cmds/secrets.ts"; 9 | 10 | const version = await readVersion(); 11 | 12 | const billingCommand: any = new Command() 13 | .description("Manage billing") 14 | .action(() => billingCommand.showHelp()) 15 | .command("estimate", "Estimate your invoice for this repository") 16 | .action(() => estimateBillingCommand()); 17 | 18 | const secretsCommand: any = new Command() 19 | .description("Manage secrets") 20 | .action(() => secretsCommand.showHelp()) 21 | .command("create", "List all secrets") 22 | .option("--host [host]", "A specific api deployment of lintrule") 23 | .action((options) => 24 | secretsCreateCmd({ 25 | host: options.host?.toString() || "https://lintrule.com", 26 | }) 27 | ); 28 | 29 | const cmd: any = new Command() 30 | .name("rules") 31 | .version(version) 32 | .description("The plain language test framework") 33 | .action(() => cmd.showHelp()) 34 | .command("init", "Add a rules folder with a demo rule") 35 | .action(() => initCmd()) 36 | .command("upgrade", "Upgrade lintrule to the latest version") 37 | .action(() => upgrade()) 38 | .command("check", "Check this repository against all rules") 39 | .option("--host [host]", "A specific api deployment of lintrule") 40 | .option( 41 | "--secret [secret]", 42 | "A secret. You can also use the LINTRULE_SECRET environment variable" 43 | ) 44 | .option( 45 | "--diff [diff]", 46 | "Run rules only on changes between two files. Ex: 'HEAD^' or 'main..feature'" 47 | ) 48 | .action((options, ..._args) => 49 | checkCmd({ 50 | host: options.host?.toString() || "https://lintrule.com", 51 | secret: options.secret?.toString(), 52 | diff: options.diff?.toString(), 53 | }) 54 | ) 55 | .command("login", "Login to lintrule") 56 | .option("--host [host]", "A specific api deployment of lintrule") 57 | .action((options, ..._args) => { 58 | loginCmd({ 59 | host: options.host?.toString() || "https://lintrule.com", 60 | }); 61 | }) 62 | .command("billing", billingCommand) 63 | .command("secrets", secretsCommand); 64 | 65 | await cmd.parse(Deno.args); 66 | -------------------------------------------------------------------------------- /cli/walkTextFiles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | walk, 3 | WalkEntry, 4 | WalkOptions, 5 | } from "https://deno.land/std@0.115.0/fs/mod.ts"; 6 | import ignore from "./ignore.js"; 7 | import { relative } from "https://deno.land/std@0.185.0/path/mod.ts"; 8 | 9 | async function isTooBig(filePath: string, kbs: number): Promise { 10 | const data = await Deno.readFile(filePath); 11 | 12 | return data.length > 1024 * kbs; 13 | } 14 | 15 | export const ignoredPatterns = [ 16 | "package-lock.json", // ignore package-lock.json 17 | "yarn.lock", // ignore yarn.lock 18 | "node_modules", // ignore node_modules 19 | "*.lock", // ignore lock files 20 | "*.log", // ignore log files 21 | "*.jpeg", // ignore jpegs 22 | "*.jpg", // ignore jpgs 23 | "*.png", // ignore pngs 24 | "*.gif", // ignore gifs 25 | "*.mp4", // ignore mp4s 26 | "*.mp3", // ignore mp3s 27 | "*.wav", // ignore wavs 28 | "*.ogg", // ignore oggs 29 | "*.webm", // ignore webms 30 | "*.mov", // ignore movs 31 | "*.avi", // ignore avis 32 | "*.mkv", // ignore mkvs 33 | "*.flv", // ignore flvs 34 | "*.wmv", // ignore wmvs 35 | "*.m4v", // ignore m4vs 36 | "*.m4a", // ignore m4as 37 | "*.flac", // ignore flacs 38 | "*.opus", // ignore opuses 39 | "*.zip", // ignore zips 40 | "*.tar", // ignore tars 41 | "*.gz", // ignore gzs 42 | "*.7z", // ignore 7zs 43 | "*.rar", // ignore rars 44 | "*.pdf", // ignore pdfs 45 | "*.doc", // ignore docs 46 | "*.docx", // ignore docxs 47 | "*.xls", // ignore xls 48 | "*.xlsx", // ignore xlsx 49 | "*.ppt", // ignore ppt 50 | "*.pptx", // ignore pptx 51 | "*.pyc", // ignore pycs 52 | "*.ipynb", // ignore ipynbs 53 | ]; 54 | 55 | export async function* walkTextFiles( 56 | root: string, 57 | gitignorePath: string 58 | ): AsyncGenerator { 59 | const gitignoreContent = await Deno.readTextFile(gitignorePath); 60 | 61 | const walkOptions: WalkOptions = {}; 62 | 63 | // gitignore content to lines 64 | const ignoreLines = gitignoreContent.split("\n"); 65 | const ig = ignore().add(ignoreLines).add(ignoredPatterns); 66 | 67 | for await (const entry of walk(root, walkOptions)) { 68 | // Ignore the .git folder 69 | if (entry.path.includes(".git")) { 70 | continue; 71 | } 72 | 73 | if (entry.isDirectory) { 74 | continue; 75 | } 76 | 77 | // Turn this into a relative path so it matches the gitignore 78 | // in deno 79 | if (ig.ignores(relative(root, entry.path))) { 80 | continue; 81 | } 82 | 83 | // This doesn't look like a code file! 84 | if (await isTooBig(entry.path, 100)) { 85 | console.warn("Skipping file because it is too big:", entry.path); 86 | continue; 87 | } 88 | 89 | // If it's in the rules directory, skip it 90 | if (relative(root, entry.path).startsWith("rules")) { 91 | continue; 92 | } 93 | 94 | yield entry; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cli/rules.ts: -------------------------------------------------------------------------------- 1 | // document_rule.ts 2 | export interface DocumentRule { 3 | document: string; 4 | rule: string; 5 | } 6 | 7 | export interface DocumentRuleResponse { 8 | pass: boolean; 9 | skipped?: { 10 | reason: "context_too_big"; 11 | }; 12 | message?: string; 13 | } 14 | 15 | export interface ErrorResponse { 16 | object: "error"; 17 | type: string; 18 | message: string; 19 | } 20 | 21 | async function sendRule({ 22 | url, 23 | accessToken, 24 | documentRule, 25 | retries = 1, 26 | }: { 27 | url: string; 28 | accessToken: string; 29 | documentRule: DocumentRule; 30 | retries: number; 31 | }): Promise { 32 | // Create a headers object with the content type 33 | const headers = new Headers(); 34 | headers.append("Content-Type", "application/json"); 35 | headers.append("Authorization", `Bearer ${accessToken}`); 36 | 37 | // Send the POST request to the given URL with document and rule as parameters 38 | const res = await fetch(url, { 39 | method: "POST", 40 | headers, 41 | body: JSON.stringify(documentRule), 42 | }); 43 | 44 | if (res.status === 401) { 45 | throw new Error( 46 | "Your access token is unauthorized! Consider logging in again or using a different secret." 47 | ); 48 | } 49 | 50 | // Payment required 51 | if (res.status === 402) { 52 | throw new Error( 53 | "Please setup your billing details! Please run `rules login` to setup your billing details." 54 | ); 55 | } 56 | 57 | // Check for 'context_too_big' 58 | if (res.status === 400) { 59 | const body = await res.json(); 60 | 61 | if (body.type === "context_too_big") { 62 | return { 63 | pass: false, 64 | skipped: { 65 | reason: "context_too_big", 66 | }, 67 | }; 68 | } 69 | } 70 | 71 | if (retries > 0) { 72 | if (res.status >= 500 || res.status === 429) { 73 | // retry after 1 second 74 | await new Promise((resolve) => setTimeout(resolve, 1000)); 75 | return sendRule({ 76 | url, 77 | accessToken, 78 | documentRule, 79 | retries: retries - 1, 80 | }); 81 | } 82 | } 83 | 84 | // Check if the response is ok 85 | if (!res.ok) { 86 | throw new Error(`HTTP error! status: ${res.status}`); 87 | } 88 | 89 | // Read the response body into a DocumentRuleResponse object 90 | const body: DocumentRuleResponse = await res.json(); 91 | 92 | return body; 93 | } 94 | 95 | export async function check({ 96 | change, 97 | host, 98 | rulePath, 99 | accessToken, 100 | }: { 101 | host: string; 102 | change: { 103 | file: string; 104 | snippet: string; 105 | }; 106 | rulePath: string; 107 | accessToken: string; 108 | }): Promise { 109 | // Read the rule 110 | let rule = await Deno.readTextFile(rulePath); 111 | 112 | // Remove the frontmatter in the rule 113 | rule = rule.replace(/---[\s\S]*---/, ""); 114 | 115 | const body = await sendRule({ 116 | url: `${host}/api/check`, 117 | accessToken, 118 | documentRule: { 119 | document: change.snippet, 120 | rule: rule.trim(), 121 | }, 122 | retries: 2, 123 | }); 124 | 125 | return body; 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lintrule 2 | 3 | [Lintrule](https://lintrule.com) is a new kind of linter and test framework. 4 | 5 | ## Install 6 | 7 | ``` 8 | curl -fsSL https://www.lintrule.com/install.sh | bash 9 | ``` 10 | 11 | ## Usage 12 | 13 | In your codebase, setup a `rules` folder with the init command. 14 | 15 | ``` 16 | rules init 17 | ``` 18 | 19 | Next, login to Lintrule. 20 | 21 | ``` 22 | rules login 23 | ``` 24 | 25 | This will create a file a `rules/no-bugs.md` with your first rule. It's just a markdown file that says "don't approve obvious bugs." Try running it with: 26 | 27 | ``` 28 | rules check 29 | ``` 30 | 31 | To save on costs, Lintrule runs on diffs. By default, it runs on the changes since the last commit, effectively `git diff HEAD^`. If you want it to run on other diffs, you can pass them in as arguments. 32 | 33 | ``` 34 | # Check against main and the a feature branch 35 | rules check --diff main..my-feature-branch 36 | 37 | # Run on the last 3 commits 38 | rules check --diff HEAD~3 39 | ``` 40 | 41 | --- 42 | 43 | ### In a GitHub Action 44 | 45 | Create a new secret and add it as an environment variable (`LINTRULE_SECRET`) to your GitHub Action. 46 | 47 | ``` 48 | 49 | rules secrets create 50 | 51 | ``` 52 | 53 | Then add the following to a workflow file in `.github/workflows/rules.yml`. 54 | 55 | ```yaml 56 | name: Rules Check 57 | 58 | on: 59 | push: 60 | branches: 61 | - main 62 | pull_request: 63 | branches: 64 | - main 65 | 66 | jobs: 67 | rules: 68 | runs-on: ubuntu-latest 69 | 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v2 73 | with: 74 | fetch-depth: 2 # this part is important! 75 | 76 | - name: Install Lint Rules 77 | run: | 78 | curl -fsSL https://www.lintrule.com/install.sh | bash 79 | 80 | - name: Run Lint Rules Check 81 | run: | 82 | rules check --secret "${{ secrets.LINTRULE_SECRET }}" 83 | ``` 84 | 85 | --- 86 | 87 | ### Configuring rules 88 | 89 | You can ensure rules only run on certain files by adding them to the frontmatter, like this: 90 | 91 | ``` 92 | --- 93 | include: ["**/**.sql"] 94 | --- 95 | 96 | We're running postgres 8 and have about 1m rows 97 | in the "users" table, please make sure our 98 | migrations don't cause problems. 99 | 100 | ``` 101 | 102 | --- 103 | 104 | ## FAQ 105 | 106 | ### Does Lintrule run on diffs? 107 | 108 | Yes. By default, Lintrule runs only on changes that come from `git diff HEAD^`. 109 | 110 | If you're in a GitHub Action, Lintrule smartly uses the `GITHUB_SHA` and `GITHUB_REF` environment variables to determine the diff. For PRs, Lintrule uses the `GITHUB_BASE_REF` and `GITHUB_HEAD_REF`. 111 | 112 | ### Does it have false positives? 113 | 114 | Yes. Just like a person, the more general the instructions, the more likely it will do something you don't want. To fix false positives, get specific. 115 | 116 | On the other hand, Lintrule tends to not be _flaky_. If a rule produces a false positive, it tends to produce the same false positive. If you fix it, it tends to stay fixed for the same type of code. 117 | 118 | ### That's a lot of money, how do I make it cheaper? 119 | 120 | - The estimator shows you how much it costs if you run Lintrule on _every commit_. Try running Lintrule only on _pull requests_. 121 | - Instead of using lots of rules, try fitting more details into one rule. But be warned, the more competing details you have in a rule, the more likely it is that you'll get false positives. 122 | - Use `include` to silo your rules to certain files. That makes it easier to add more rules without increasing your cost. 123 | 124 | As LLMs get cheaper to run, we expect the prices to go down significantly. 125 | 126 | ### Is it slow? 127 | 128 | Not really. Lintrules runs rules in parallel, so regardless of how many rules or files you have, it will complete in a few seconds. 129 | 130 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the GitHub repository and the name of the binary. 4 | GITHUB_REPO="Flaque/lintrule" 5 | BINARY_NAME="rules" 6 | 7 | # Check the operating system 8 | OS="$(uname)" 9 | 10 | # If the operating system is Linux, set the target directory to '/usr/local/bin' 11 | # If the operating system is Darwin (macOS), set the target directory to '${HOME}/.local/bin' 12 | if [[ "$OS" == "Linux" ]]; then 13 | TARGET_DIR="/usr/local/bin" 14 | elif [[ "$OS" == "Darwin" ]]; then 15 | TARGET_DIR="${HOME}/.local/bin" 16 | else 17 | echo "Unsupported operating system: $OS" 18 | exit 1 19 | fi 20 | 21 | command -v unzip >/dev/null || 22 | error 'unzip is required to install lintrule' 23 | 24 | 25 | # Make sure the target dir exists 26 | mkdir -p "${TARGET_DIR}" 27 | 28 | # Define the target file path for the 'rules' CLI binary. 29 | TARGET_FILE="${TARGET_DIR}/${BINARY_NAME}" 30 | 31 | case $(uname -ms) in 32 | 'Darwin x86_64') 33 | target=x86_64-apple-darwin 34 | ;; 35 | 'Darwin arm64') 36 | target=aarch64-apple-darwin 37 | ;; 38 | 'Linux x86_64' | *) 39 | target=x86_64-unknown-linux-gnu 40 | ;; 41 | esac 42 | 43 | if [[ $target = darwin-x64 ]]; then 44 | # Is this process running in Rosetta? 45 | # redirect stderr to devnull to avoid error message when not running in Rosetta 46 | if [[ $(sysctl -n sysctl.proc_translated 2>/dev/null) = 1 ]]; then 47 | target=darwin-aarch64 48 | info "Your shell is running in Rosetta 2. Downloading rules for $target instead" 49 | fi 50 | fi 51 | 52 | GITHUB=${GITHUB-"https://github.com"} 53 | 54 | github_repo="$GITHUB/$GITHUB_REPO" 55 | 56 | if [[ $# = 0 ]]; then 57 | RULES_BINARY_URL=$github_repo/releases/latest/download/rules-$target.zip 58 | else 59 | RULES_BINARY_URL=$github_repo/releases/download/$1/rules-$target.zip 60 | fi 61 | 62 | # Check if the download URL was found. 63 | if [ -z "${RULES_BINARY_URL}" ]; then 64 | echo "Failed to find the download URL for the '${BINARY_NAME}' binary." 65 | echo "Please check the GitHub repository and release information." 66 | exit 1 67 | fi 68 | 69 | # Download the 'rules' CLI binary from the specified URL. 70 | echo "Downloading '${BINARY_NAME}' CLI binary..." 71 | echo "curl -L -o \"${TARGET_FILE}.zip\" \"${RULES_BINARY_URL}\"" 72 | curl -L -o "${TARGET_FILE}.zip" "${RULES_BINARY_URL}" 73 | 74 | echo "unzip -o \"${TARGET_FILE}.zip\" -d \"${TARGET_DIR}/dist\"" 75 | unzip -o "$TARGET_FILE.zip" -d "$TARGET_DIR/dist" || 76 | error 'Failed to extract rules' 77 | 78 | # rename the binary to 'rules' 79 | mv "$TARGET_DIR/dist/rules-$target" "$TARGET_DIR/$BINARY_NAME" 80 | 81 | 82 | # Make the downloaded binary executable. 83 | chmod +x "${TARGET_FILE}" 84 | 85 | # remove the dist directory 86 | rm -rf "$TARGET_DIR/dist" 87 | 88 | # remove the downloaded zip file 89 | rm "$TARGET_FILE.zip" 90 | 91 | 92 | # Verify that the 'rules' CLI binary is successfully installed. 93 | if [ -f "${TARGET_FILE}" ]; then 94 | echo "Successfully installed '${BINARY_NAME}' CLI." 95 | echo "The binary is located at '${TARGET_FILE}'." 96 | 97 | # Provide instructions for adding the target directory to the PATH. 98 | echo -e "\033[0;32m" 99 | echo -e "To use the '${BINARY_NAME}' command, add '${TARGET_DIR}' to your PATH." 100 | echo -e "You can do this by running one of the following commands, depending on your shell:" 101 | echo -e "\033[0m" 102 | echo -e "\033[0;32mFor bash:" 103 | echo -e "\033[1m echo 'export PATH=\"${TARGET_DIR}:\$PATH\"' >> ~/.bashrc && source ~/.bashrc\033[0m" 104 | echo -e "\033[0;32m" 105 | echo -e "\033[0;32mFor zsh:" 106 | echo -e "\033[1m echo 'export PATH=\"${TARGET_DIR}:\$PATH\"' >> ~/.zshrc && source ~/.zshrc\033[0m" 107 | echo -e "\033[0;32m" 108 | echo -e "After running the appropriate command, you can use '${BINARY_NAME}'.\033[0m" 109 | 110 | 111 | else 112 | echo "Installation failed. '${BINARY_NAME}' CLI could not be installed." 113 | fi 114 | -------------------------------------------------------------------------------- /cli/cmds/check.ts: -------------------------------------------------------------------------------- 1 | import { findRoot } from "../findRoot.ts"; 2 | import { check } from "../rules.ts"; 3 | import { walkTextFiles } from "../walkTextFiles.ts"; 4 | import * as colors from "https://deno.land/std@0.185.0/fmt/colors.ts"; 5 | import { relative } from "https://deno.land/std@0.185.0/path/mod.ts"; 6 | import { readConfig } from "../config.ts"; 7 | import { getChangesAsFiles } from "../git.ts"; 8 | import * as frontmatter from "https://deno.land/x/frontmatter@v0.1.5/mod.ts"; 9 | import { globToRegExp } from "https://deno.land/std@0.36.0/path/glob.ts"; 10 | 11 | const rootDir = await findRoot(); 12 | 13 | const root = rootDir || Deno.cwd(); 14 | const rulesDir = `${root}/rules`; 15 | const gitignorePath = ".gitignore"; 16 | 17 | async function checkRuleAgainstEntry(props: { 18 | rulePath: string; 19 | change: { 20 | file: string; 21 | snippet: string; 22 | }; 23 | host: string; 24 | accessToken: string; 25 | }) { 26 | const now = Date.now(); 27 | const result = await check({ 28 | change: props.change, 29 | rulePath: props.rulePath, 30 | host: props.host, 31 | accessToken: props.accessToken, 32 | }); 33 | const totalTime = Date.now() - now; 34 | 35 | const relativeEntry = relative(root, props.change.file); 36 | const relativeRuleEntry = relative(root, props.rulePath); 37 | 38 | if (result.skipped) { 39 | console.log( 40 | ` ${colors.bgYellow(" ⚠️ SKIP ")} ${relativeEntry} ${colors.dim( 41 | "=>" 42 | )} ${relativeRuleEntry}\nThe diff is too big to check :( ${colors.dim( 43 | `(${totalTime}ms)` 44 | )}` 45 | ); 46 | return true; 47 | } 48 | 49 | if (result.pass) { 50 | console.log( 51 | ` ${colors.bgBrightGreen(" ✔️ PASS ")} ${relativeRuleEntry} ${colors.dim( 52 | "=>" 53 | )} ${relativeEntry} ${colors.dim(`(${totalTime}ms)`)}` 54 | ); 55 | return true; 56 | } else { 57 | console.log( 58 | ` ${colors.bgRed( 59 | colors.brightWhite(" FAIL ") 60 | )} ${relativeEntry} ${relativeRuleEntry}\n${result.message} ${colors.dim( 61 | `(${totalTime}ms)` 62 | )}` 63 | ); 64 | return false; 65 | } 66 | } 67 | 68 | export async function checkCmd(props: { 69 | host: string; 70 | secret?: string; 71 | diff?: string; 72 | }) { 73 | const config = await readConfig(); 74 | const accessToken = props.secret || config.accessToken; 75 | if (!accessToken) { 76 | console.log("Please run 'rules login' first."); 77 | Deno.exit(1); 78 | } 79 | if (!accessToken.startsWith("sk_")) { 80 | console.log( 81 | `Lintrule secret does not start with 'sk_'. Here's some details about it: 82 | 83 | ${colors.bold("Ends with:")}: ${accessToken.slice(-3)} 84 | ${colors.bold("Length:")}: ${accessToken.length}` 85 | ); 86 | Deno.exit(1); 87 | } 88 | 89 | const files = []; 90 | for await (const ruleEntry of walkTextFiles(rulesDir, gitignorePath)) { 91 | const file = await Deno.readTextFile(ruleEntry.path); 92 | const result: { data?: { include?: string[] }; content: string } = 93 | frontmatter.parse(file) as any; 94 | 95 | for await (const change of getChangesAsFiles(props.diff)) { 96 | if (result.data?.include) { 97 | const include = result.data.include; 98 | if (!Array.isArray(include)) { 99 | throw new Error("Include must be an array"); 100 | } 101 | const includeRegexes = include.map((i) => globToRegExp(i)); 102 | const shouldInclude = includeRegexes.some((r) => r.test(change.file)); 103 | if (!shouldInclude) { 104 | continue; 105 | } 106 | } 107 | 108 | files.push({ 109 | change, 110 | rulePath: ruleEntry.path, 111 | }); 112 | } 113 | } 114 | 115 | // Add a little sanity check for runaway files atm 116 | if (files.length > 100) { 117 | throw new Error("Too many files to check at once. Please check less files"); 118 | } 119 | 120 | console.log(colors.dim(`\nFound ${files.length} changed files...\n`)); 121 | 122 | const now = Date.now(); 123 | const promises = []; 124 | for (const file of files) { 125 | promises.push( 126 | checkRuleAgainstEntry({ 127 | host: props.host, 128 | rulePath: file.rulePath, 129 | change: file.change, 130 | accessToken: accessToken, 131 | }) 132 | ); 133 | } 134 | 135 | const results = await Promise.all(promises); 136 | const failed = results.filter((r) => !r); 137 | if (failed.length > 0) { 138 | console.log(colors.bgRed(` ${failed.length} rules failed. `)); 139 | Deno.exit(1); 140 | } 141 | console.log(colors.dim(`\nFinished. (${Date.now() - now}ms)\n`)); 142 | } 143 | -------------------------------------------------------------------------------- /cli/cmds/login.ts: -------------------------------------------------------------------------------- 1 | import * as colors from "https://deno.land/std@0.185.0/fmt/colors.ts"; 2 | import { 3 | SpinnerTypes, 4 | TerminalSpinner, 5 | } from "https://deno.land/x/spinners/mod.ts"; 6 | import { writeConfig } from "../config.ts"; 7 | 8 | export interface CompleteResponse { 9 | object: "challenge"; 10 | challenge: string; 11 | status: "complete" | "incomplete" | "expired"; 12 | access_token?: string; 13 | } 14 | 15 | export interface ChallengeExpiredResponse { 16 | object: "error"; 17 | type: "challenge_expired"; 18 | message: string; 19 | } 20 | 21 | export interface ChallengeIsAccessedResponse { 22 | object: "error"; 23 | type: "challenge_is_accessed"; 24 | message: string; 25 | } 26 | 27 | export interface ChallengeUnauthorizedResponse { 28 | object: "error"; 29 | type: "challenge_unauthorized"; 30 | message: string; 31 | } 32 | 33 | type Responses = 34 | | CompleteResponse 35 | | ChallengeExpiredResponse 36 | | ChallengeIsAccessedResponse 37 | | ChallengeUnauthorizedResponse; 38 | 39 | async function openBrowser(url: string) { 40 | let cmd: string[] = []; 41 | switch (Deno.build.os) { 42 | case "windows": 43 | cmd = ["cmd", "/c", "start", url]; 44 | break; 45 | case "darwin": 46 | cmd = ["open", url]; 47 | break; 48 | case "linux": 49 | cmd = ["xdg-open", url]; 50 | break; 51 | default: 52 | return; 53 | } 54 | const process = Deno.run({ cmd }); 55 | await process.status(); 56 | process.close(); 57 | } 58 | 59 | async function completeChallenge(props: { host: string; challenge: string }) { 60 | // Make a challenge request 61 | const challengeResponse = await fetch( 62 | `${props.host}/api/challenges/complete`, 63 | { 64 | method: "POST", 65 | headers: { 66 | "Content-Type": "application/json", 67 | }, 68 | body: JSON.stringify({ 69 | challenge: props.challenge, 70 | }), 71 | } 72 | ); 73 | 74 | const result = (await challengeResponse.json()) as Responses; 75 | if (result.object === "error") { 76 | switch (result.type) { 77 | case "challenge_expired": 78 | throw new Error("Login expired, please try again"); 79 | case "challenge_is_accessed": 80 | throw new Error("Please try logging in again"); 81 | case "challenge_unauthorized": 82 | throw new Error("Challenge unauthorized, please try again hacker"); 83 | } 84 | } 85 | 86 | return result; 87 | } 88 | 89 | export async function loginCmd(props: { accessToken?: string; host: string }) { 90 | // Make a challenge request 91 | const challengeResponse = await fetch(`${props.host}/api/challenges`, { 92 | method: "POST", 93 | headers: { 94 | "Content-Type": "application/json", 95 | }, 96 | }); 97 | if (!challengeResponse.ok) { 98 | throw new Error( 99 | `Unexpected response from challenge request: ${challengeResponse.status}` 100 | ); 101 | } 102 | 103 | const challenge = (await challengeResponse.json()) as CompleteResponse; 104 | 105 | // If the initial challenge is not incomplete, that's somewhat unexpected 106 | // so we should throw an error 107 | if (challenge.status !== "incomplete") { 108 | throw new Error(`Unexpected challenge status: ${challenge.status}`); 109 | } 110 | 111 | // Open the challenge url in the browser 112 | const challengeUrl = `${props.host}/auth/cli?challenge=${challenge.challenge}`; 113 | await openBrowser(challengeUrl); 114 | 115 | // Poll the challenge endpoint until it's complete 116 | const spinner = new TerminalSpinner({ 117 | text: "Click here: " + challengeUrl, 118 | color: "blue", // see colors in util.ts 119 | spinner: SpinnerTypes.arc, // check the SpinnerTypes - see import 120 | indent: 0, // The level of indentation of the spinner in spaces 121 | cursor: false, // Whether or not to display a cursor when the spinner is active 122 | writer: Deno.stdout, // anything using the Writer interface incl. stdout, stderr, and files 123 | }); 124 | 125 | spinner.start(); 126 | 127 | while (true) { 128 | const result = await completeChallenge({ 129 | host: props.host, 130 | challenge: challenge.challenge, 131 | }); 132 | 133 | if (result.status === "incomplete") { 134 | // Wait a little bit and try again 135 | await new Promise((resolve) => setTimeout(resolve, 200)); 136 | } else if (result.status === "complete") { 137 | // Store the access token and exit 138 | await writeConfig({ accessToken: result.access_token }); 139 | spinner.succeed(); 140 | console.log(colors.green("You're logged in!")); 141 | Deno.exit(0); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /scripts/release.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts"; 2 | import * as colors from "https://deno.land/std@0.185.0/fmt/colors.ts"; 3 | import { readVersion } from "../cli/version.ts"; 4 | 5 | function logAndError(msg: string) { 6 | console.log(colors.bgRed(" Error "), colors.red(msg)); 7 | Deno.exit(1); 8 | } 9 | 10 | // Check if the 'gh' command exists 11 | try { 12 | const gh = Deno.run({ 13 | cmd: ["gh", "--version"], 14 | stdout: "null", 15 | stderr: "null", 16 | }); 17 | await gh.status(); 18 | } catch (error) { 19 | logAndError("Please install the 'gh' command first: brew install gh"); 20 | } 21 | 22 | function bumpedVersion( 23 | version: string, 24 | type: "major" | "minor" | "patch" | "prerelease" 25 | ) { 26 | const [major, minor, patch] = version.split(".").map((v) => 27 | parseInt( 28 | // Remove everything after the - if there is one 29 | v.includes("-") ? v.split("-")[0] : v 30 | ) 31 | ); 32 | switch (type) { 33 | case "major": 34 | return `${major + 1}.0.0`; 35 | case "minor": 36 | return `${major}.${minor + 1}.0`; 37 | case "patch": 38 | return `${major}.${minor}.${patch + 1}`; 39 | case "prerelease": 40 | return `${major}.${minor}.${patch}-pre.${Date.now()}`; 41 | } 42 | } 43 | 44 | async function saveVersion(version: string) { 45 | // Save the version in the cli.json file 46 | const cliJsonPath = "cli/cli.json"; 47 | const cliJson = await Deno.readTextFile(cliJsonPath); 48 | const cli = JSON.parse(cliJson); 49 | cli.version = version; 50 | await Deno.writeTextFile(cliJsonPath, JSON.stringify(cli, null, 2)); 51 | } 52 | 53 | const COMPILE_TARGETS = [ 54 | "x86_64-unknown-linux-gnu", // x86 linux 55 | "x86_64-pc-windows-msvc", // PCs 56 | "x86_64-apple-darwin", // intel macs 57 | "aarch64-apple-darwin", // m1 macs / arm 58 | ]; 59 | 60 | async function compileDistribution() { 61 | for (const target of COMPILE_TARGETS) { 62 | const p = Deno.run({ 63 | cmd: [ 64 | "deno", 65 | "compile", 66 | "--allow-read", 67 | "--allow-write", 68 | "--allow-env", 69 | "--allow-net", 70 | "--allow-run", 71 | "--target", 72 | target, 73 | "--output", 74 | 75 | `dist/rules-${target}`, 76 | "cli/main.ts", 77 | ], 78 | }); 79 | const status = await p.status(); 80 | if (!status.success) { 81 | logAndError("Failed to compile the distribution"); 82 | } 83 | 84 | // Zip the binary 85 | const zip = Deno.run({ 86 | cmd: [ 87 | "zip", 88 | "-j", 89 | `dist/rules-${target}.zip`, 90 | `dist/rules-${target}`, 91 | "cli/cli.json", 92 | ], 93 | }); 94 | const zipStatus = await zip.status(); 95 | if (!zipStatus.success) { 96 | logAndError("Failed to zip the distribution"); 97 | } 98 | } 99 | } 100 | 101 | async function createRelease(version: string) { 102 | // Only take the zip files 103 | const distFiles = [...Deno.readDirSync("./dist")] 104 | .filter((entry) => entry.isFile) 105 | .filter((entry) => entry.name.endsWith(".zip")) 106 | .map((entry) => `./dist/${entry.name}`); 107 | 108 | console.log(distFiles); 109 | const cmds = [ 110 | "gh", 111 | "release", 112 | "create", 113 | version, 114 | ...distFiles, 115 | "--generate-notes", 116 | ]; 117 | 118 | if (!version.includes("pre")) { 119 | cmds.push("--latest"); 120 | } else { 121 | cmds.push("--prerelease"); 122 | } 123 | 124 | console.log(cmds.join(" ")); 125 | 126 | const p = Deno.run({ 127 | cmd: cmds, 128 | }); 129 | const status = await p.status(); 130 | if (!status.success) { 131 | logAndError("Failed to create the release"); 132 | } 133 | 134 | // git add cli/cli.json 135 | // and commit with the name `release: vVersion` 136 | const p2 = Deno.run({ 137 | cmd: ["git", "add", "cli/cli.json"], 138 | }); 139 | const status2 = await p2.status(); 140 | if (!status2.success) { 141 | logAndError("Failed to add cli/cli.json"); 142 | } 143 | 144 | const p3 = Deno.run({ 145 | cmd: ["git", "commit", "-m", `release: v${version}`], 146 | }); 147 | const status3 = await p3.status(); 148 | if (!status3.success) { 149 | logAndError("Failed to commit cli/cli.json"); 150 | } 151 | 152 | const p4 = Deno.run({ 153 | cmd: ["git", "push"], 154 | }); 155 | const status4 = await p4.status(); 156 | if (!status4.success) { 157 | logAndError("Failed to push cli/cli.json"); 158 | } 159 | } 160 | 161 | await new Command() 162 | .name("releaser") 163 | .version("0.0.1") 164 | .description("A github release tool for the cli") 165 | .arguments("[type]") 166 | .action(async (_, type) => { 167 | if (!type) { 168 | logAndError( 169 | "Please specify a release type: major, minor, patch, prerelease" 170 | ); 171 | return; 172 | } 173 | if ( 174 | type !== "major" && 175 | type !== "minor" && 176 | type !== "patch" && 177 | type !== "prerelease" 178 | ) { 179 | logAndError( 180 | "Argument must be 'major', 'minor' or 'patch' or 'prerelease'" 181 | ); 182 | return; 183 | } 184 | const version = await readVersion(); 185 | const bumped = bumpedVersion(version, type); 186 | await saveVersion(bumped); 187 | await compileDistribution(); 188 | await createRelease(bumped); 189 | }) 190 | .parse(Deno.args); 191 | -------------------------------------------------------------------------------- /cli/git.ts: -------------------------------------------------------------------------------- 1 | import * as colors from "https://deno.land/std@0.185.0/fmt/colors.ts"; 2 | 3 | export function parseDiffToHunks(diff: string) { 4 | const lines = diff.split("\n"); 5 | const hunks = []; 6 | let currentFile = ""; 7 | 8 | for (const line of lines) { 9 | if (line.startsWith("+++ ")) { 10 | // It's the file name line 11 | currentFile = line.slice(4); // Remove the '+++ ' prefix 12 | } else if (line.startsWith("@@ ")) { 13 | // It's a hunk header 14 | const hunkHeader = line.slice(2).trim(); // Remove the '@@ ' prefix 15 | const [oldFile, newFile] = hunkHeader.split(" "); 16 | 17 | const [x, y] = oldFile.slice(1).split(",").map(Number); // Remove the '-' prefix and convert to numbers 18 | const [z, w] = newFile.slice(1).split(",").map(Number); // Remove the '+' prefix and convert to numbers 19 | 20 | hunks.push({ 21 | file: currentFile.replace("b/", "").replace("a/", ""), 22 | x, 23 | y, 24 | z, 25 | w, 26 | }); 27 | } 28 | } 29 | 30 | return hunks; 31 | } 32 | 33 | export function parseDiffToFiles(diff: string) { 34 | const diffParts = diff.split("diff --git"); 35 | const result = []; 36 | 37 | for (const part of diffParts) { 38 | if (part.trim() === "") continue; 39 | 40 | const match = part.match(/ a\/(.*) b\/(.*)/); 41 | if (!match) continue; 42 | 43 | const filePath = match[1]; 44 | 45 | const diffContentStart = part.indexOf("---"); 46 | const nextLineStart = part.indexOf("+++"); 47 | if (diffContentStart === -1 || nextLineStart === -1) continue; 48 | 49 | // Ignore if the file is deleted (next line is +++ /dev/null) 50 | const nextLine = part.slice( 51 | nextLineStart, 52 | part.indexOf("\n", nextLineStart) 53 | ); 54 | if (nextLine.trim() === "+++ /dev/null") continue; 55 | 56 | const diffContent = part.slice(diffContentStart); 57 | 58 | result.push({ 59 | file: filePath, 60 | diff: diffContent, 61 | }); 62 | } 63 | 64 | return result; 65 | } 66 | 67 | export async function getDiffInGithubActionPullRequest() { 68 | const head = Deno.env.get("GITHUB_HEAD_REF"); 69 | if (!head) { 70 | throw new Error("GITHUB_HEAD_REF is not defined"); 71 | } 72 | const ref = Deno.env.get("GITHUB_BASE_REF"); 73 | if (!ref) { 74 | throw new Error("GITHUB_BASE_REF is not defined"); 75 | } 76 | 77 | await gitFetch(head); 78 | await gitFetch(ref); 79 | 80 | const p = new Deno.Command("git", { 81 | args: ["diff", `${head}..${ref}^`], 82 | stdout: "piped", 83 | }); 84 | 85 | console.log(colors.dim(`\n$ git diff ${head}..${ref}`)); 86 | 87 | const { code, stdout, stderr } = await p.output(); // "p.output()" returns a promise that resolves with the raw output 88 | 89 | if (code !== 0) { 90 | const err = new TextDecoder().decode(stderr); 91 | if (err.includes("fatal: ambiguous argument")) { 92 | console.error(`rules can't find previous code to compare against. Try checking that your checkout step has 'fetch-depth' of 2 or higher. For example: 93 | 94 | - uses: actions/checkout@v2 95 | with: 96 | fetch-depth: 2 97 | 98 | `); 99 | } 100 | 101 | throw new Error(err); 102 | } 103 | 104 | const text = new TextDecoder().decode(stdout); // Convert the raw output into a string 105 | 106 | return text; 107 | } 108 | 109 | export async function gitFetch(ref: string) { 110 | const p = new Deno.Command("git", { 111 | args: ["fetch", `origin`, `${ref}:${ref}`], 112 | stdout: "piped", 113 | }); 114 | 115 | const { code } = await p.output(); 116 | if (code !== 0) { 117 | throw new Error("git fetch failed"); 118 | } 119 | } 120 | 121 | export async function getDiffInGithubAction() { 122 | const head = Deno.env.get("GITHUB_SHA"); 123 | if (!head) { 124 | throw new Error("GITHUB_SHA is not defined"); 125 | } 126 | const ref = Deno.env.get("GITHUB_REF"); 127 | if (!ref) { 128 | throw new Error("GITHUB_REF is not defined"); 129 | } 130 | 131 | await gitFetch(head); 132 | await gitFetch(ref); 133 | 134 | const p = new Deno.Command("git", { 135 | args: ["diff", `${head}..${ref}^`], 136 | stdout: "piped", 137 | }); 138 | 139 | console.log(colors.dim(`\n$ git diff ${head}..${ref}`)); 140 | const { code, stdout, stderr } = await p.output(); // "p.output()" returns a promise that resolves with the raw output 141 | 142 | if (code !== 0) { 143 | const err = new TextDecoder().decode(stderr); 144 | if (err.includes("fatal: ambiguous argument")) { 145 | console.error(`rules can't find previous code to compare against. Try checking that your checkout step has 'fetch-depth' of 2 or higher. For example: 146 | 147 | - uses: actions/checkout@v2 148 | with: 149 | fetch-depth: 2 150 | 151 | `); 152 | } 153 | 154 | throw new Error(err); 155 | } 156 | 157 | const text = new TextDecoder().decode(stdout); // Convert the raw output into a string 158 | 159 | return text; 160 | } 161 | 162 | export async function getSpecificDiff(diff: string) { 163 | const p = new Deno.Command("git", { 164 | args: ["diff", diff], 165 | stdout: "piped", 166 | }); 167 | 168 | const { code, stdout, stderr } = await p.output(); // "p.output()" returns a promise that resolves with the raw output 169 | 170 | console.log(colors.dim(`\n$ git diff ${diff}`)); 171 | if (code !== 0) { 172 | throw new Error(new TextDecoder().decode(stderr)); 173 | } 174 | 175 | const text = new TextDecoder().decode(stdout); // Convert the raw output into a string 176 | 177 | return text; 178 | } 179 | 180 | export async function getDiff(diff?: string) { 181 | if (diff) { 182 | return getSpecificDiff(diff); 183 | } 184 | 185 | // If we're in a github action inside a PR, use that diff 186 | if (Deno.env.get("GITHUB_HEAD_REF")) { 187 | return getDiffInGithubActionPullRequest(); 188 | } 189 | 190 | // If we're in a github action, use the github action diff 191 | if (Deno.env.get("GITHUB_BASE_REF")) { 192 | return getDiffInGithubAction(); 193 | } 194 | 195 | const p = new Deno.Command("git", { 196 | args: ["diff", "HEAD^"], 197 | stdout: "piped", 198 | }); 199 | 200 | const { code, stdout, stderr } = await p.output(); // "p.output()" returns a promise that resolves with the raw output 201 | 202 | console.log(colors.dim(`\n$ git diff HEAD^`)); 203 | if (code !== 0) { 204 | throw new Error(new TextDecoder().decode(stderr)); 205 | } 206 | 207 | const text = new TextDecoder().decode(stdout); // Convert the raw output into a string 208 | 209 | return text; 210 | } 211 | 212 | export async function* getChangesAsFiles(diff?: string) { 213 | const text = await getDiff(diff); 214 | const files = parseDiffToFiles(text); 215 | 216 | for (const file of files) { 217 | // Read the file 218 | const p = await Deno.readFile(file.file); 219 | const text = new TextDecoder().decode(p); 220 | 221 | yield { 222 | file: file.file, 223 | snippet: text, 224 | }; 225 | } 226 | } 227 | 228 | export async function* getChangesAsHunks() { 229 | const text = await getDiff(); 230 | const hunks = parseDiffToHunks(text); 231 | 232 | for (const hunk of hunks) { 233 | // Read the file 234 | const p = await Deno.readFile(hunk.file); 235 | const text = new TextDecoder().decode(p); 236 | 237 | // Split the file into lines 238 | const lines = text.split("\n"); 239 | 240 | // get the lines that were added 241 | const paddingBefore = 20; 242 | const paddingAfter = 20; 243 | const start = Math.max(0, hunk.z - paddingBefore); 244 | const end = Math.min(lines.length, hunk.z + hunk.w + paddingAfter); 245 | const addedLines = lines.slice(start, end).join("\n"); 246 | 247 | yield { 248 | file: hunk.file, 249 | snippet: addedLines, 250 | }; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /cli/ignore.js: -------------------------------------------------------------------------------- 1 | // From https://raw.githubusercontent.com/kaelzhang/node-ignore/master/index.js 2 | 3 | // A simple implementation of make-array 4 | function makeArray (subject) { 5 | return Array.isArray(subject) 6 | ? subject 7 | : [subject] 8 | } 9 | 10 | const EMPTY = '' 11 | const SPACE = ' ' 12 | const ESCAPE = '\\' 13 | const REGEX_TEST_BLANK_LINE = /^\s+$/ 14 | const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/ 15 | const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/ 16 | const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/ 17 | const REGEX_SPLITALL_CRLF = /\r?\n/g 18 | // /foo, 19 | // ./foo, 20 | // ../foo, 21 | // . 22 | // .. 23 | const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/ 24 | 25 | const SLASH = '/' 26 | 27 | // Do not use ternary expression here, since "istanbul ignore next" is buggy 28 | let TMP_KEY_IGNORE = 'node-ignore' 29 | /* istanbul ignore else */ 30 | if (typeof Symbol !== 'undefined') { 31 | TMP_KEY_IGNORE = Symbol.for('node-ignore') 32 | } 33 | const KEY_IGNORE = TMP_KEY_IGNORE 34 | 35 | const define = (object, key, value) => 36 | Object.defineProperty(object, key, {value}) 37 | 38 | const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g 39 | 40 | const RETURN_FALSE = () => false 41 | 42 | // Sanitize the range of a regular expression 43 | // The cases are complicated, see test cases for details 44 | const sanitizeRange = range => range.replace( 45 | REGEX_REGEXP_RANGE, 46 | (match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0) 47 | ? match 48 | // Invalid range (out of order) which is ok for gitignore rules but 49 | // fatal for JavaScript regular expression, so eliminate it. 50 | : EMPTY 51 | ) 52 | 53 | // See fixtures #59 54 | const cleanRangeBackSlash = slashes => { 55 | const {length} = slashes 56 | return slashes.slice(0, length - length % 2) 57 | } 58 | 59 | // > If the pattern ends with a slash, 60 | // > it is removed for the purpose of the following description, 61 | // > but it would only find a match with a directory. 62 | // > In other words, foo/ will match a directory foo and paths underneath it, 63 | // > but will not match a regular file or a symbolic link foo 64 | // > (this is consistent with the way how pathspec works in general in Git). 65 | // '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`' 66 | // -> ignore-rules will not deal with it, because it costs extra `fs.stat` call 67 | // you could use option `mark: true` with `glob` 68 | 69 | // '`foo/`' should not continue with the '`..`' 70 | const REPLACERS = [ 71 | 72 | // > Trailing spaces are ignored unless they are quoted with backslash ("\") 73 | [ 74 | // (a\ ) -> (a ) 75 | // (a ) -> (a) 76 | // (a \ ) -> (a ) 77 | /\\?\s+$/, 78 | match => match.indexOf('\\') === 0 79 | ? SPACE 80 | : EMPTY 81 | ], 82 | 83 | // replace (\ ) with ' ' 84 | [ 85 | /\\\s/g, 86 | () => SPACE 87 | ], 88 | 89 | // Escape metacharacters 90 | // which is written down by users but means special for regular expressions. 91 | 92 | // > There are 12 characters with special meanings: 93 | // > - the backslash \, 94 | // > - the caret ^, 95 | // > - the dollar sign $, 96 | // > - the period or dot ., 97 | // > - the vertical bar or pipe symbol |, 98 | // > - the question mark ?, 99 | // > - the asterisk or star *, 100 | // > - the plus sign +, 101 | // > - the opening parenthesis (, 102 | // > - the closing parenthesis ), 103 | // > - and the opening square bracket [, 104 | // > - the opening curly brace {, 105 | // > These special characters are often called "metacharacters". 106 | [ 107 | /[\\$.|*+(){^]/g, 108 | match => `\\${match}` 109 | ], 110 | 111 | [ 112 | // > a question mark (?) matches a single character 113 | /(?!\\)\?/g, 114 | () => '[^/]' 115 | ], 116 | 117 | // leading slash 118 | [ 119 | 120 | // > A leading slash matches the beginning of the pathname. 121 | // > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c". 122 | // A leading slash matches the beginning of the pathname 123 | /^\//, 124 | () => '^' 125 | ], 126 | 127 | // replace special metacharacter slash after the leading slash 128 | [ 129 | /\//g, 130 | () => '\\/' 131 | ], 132 | 133 | [ 134 | // > A leading "**" followed by a slash means match in all directories. 135 | // > For example, "**/foo" matches file or directory "foo" anywhere, 136 | // > the same as pattern "foo". 137 | // > "**/foo/bar" matches file or directory "bar" anywhere that is directly 138 | // > under directory "foo". 139 | // Notice that the '*'s have been replaced as '\\*' 140 | /^\^*\\\*\\\*\\\//, 141 | 142 | // '**/foo' <-> 'foo' 143 | () => '^(?:.*\\/)?' 144 | ], 145 | 146 | // starting 147 | [ 148 | // there will be no leading '/' 149 | // (which has been replaced by section "leading slash") 150 | // If starts with '**', adding a '^' to the regular expression also works 151 | /^(?=[^^])/, 152 | function startingReplacer () { 153 | // If has a slash `/` at the beginning or middle 154 | return !/\/(?!$)/.test(this) 155 | // > Prior to 2.22.1 156 | // > If the pattern does not contain a slash /, 157 | // > Git treats it as a shell glob pattern 158 | // Actually, if there is only a trailing slash, 159 | // git also treats it as a shell glob pattern 160 | 161 | // After 2.22.1 (compatible but clearer) 162 | // > If there is a separator at the beginning or middle (or both) 163 | // > of the pattern, then the pattern is relative to the directory 164 | // > level of the particular .gitignore file itself. 165 | // > Otherwise the pattern may also match at any level below 166 | // > the .gitignore level. 167 | ? '(?:^|\\/)' 168 | 169 | // > Otherwise, Git treats the pattern as a shell glob suitable for 170 | // > consumption by fnmatch(3) 171 | : '^' 172 | } 173 | ], 174 | 175 | // two globstars 176 | [ 177 | // Use lookahead assertions so that we could match more than one `'/**'` 178 | /\\\/\\\*\\\*(?=\\\/|$)/g, 179 | 180 | // Zero, one or several directories 181 | // should not use '*', or it will be replaced by the next replacer 182 | 183 | // Check if it is not the last `'/**'` 184 | (_, index, str) => index + 6 < str.length 185 | 186 | // case: /**/ 187 | // > A slash followed by two consecutive asterisks then a slash matches 188 | // > zero or more directories. 189 | // > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on. 190 | // '/**/' 191 | ? '(?:\\/[^\\/]+)*' 192 | 193 | // case: /** 194 | // > A trailing `"/**"` matches everything inside. 195 | 196 | // #21: everything inside but it should not include the current folder 197 | : '\\/.+' 198 | ], 199 | 200 | // normal intermediate wildcards 201 | [ 202 | // Never replace escaped '*' 203 | // ignore rule '\*' will match the path '*' 204 | 205 | // 'abc.*/' -> go 206 | // 'abc.*' -> skip this rule, 207 | // coz trailing single wildcard will be handed by [trailing wildcard] 208 | /(^|[^\\]+)(\\\*)+(?=.+)/g, 209 | 210 | // '*.js' matches '.js' 211 | // '*.js' doesn't match 'abc' 212 | (_, p1, p2) => { 213 | // 1. 214 | // > An asterisk "*" matches anything except a slash. 215 | // 2. 216 | // > Other consecutive asterisks are considered regular asterisks 217 | // > and will match according to the previous rules. 218 | const unescaped = p2.replace(/\\\*/g, '[^\\/]*') 219 | return p1 + unescaped 220 | } 221 | ], 222 | 223 | [ 224 | // unescape, revert step 3 except for back slash 225 | // For example, if a user escape a '\\*', 226 | // after step 3, the result will be '\\\\\\*' 227 | /\\\\\\(?=[$.|*+(){^])/g, 228 | () => ESCAPE 229 | ], 230 | 231 | [ 232 | // '\\\\' -> '\\' 233 | /\\\\/g, 234 | () => ESCAPE 235 | ], 236 | 237 | [ 238 | // > The range notation, e.g. [a-zA-Z], 239 | // > can be used to match one of the characters in a range. 240 | 241 | // `\` is escaped by step 3 242 | /(\\)?\[([^\]/]*?)(\\*)($|\])/g, 243 | (match, leadEscape, range, endEscape, close) => leadEscape === ESCAPE 244 | // '\\[bar]' -> '\\\\[bar\\]' 245 | ? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` 246 | : close === ']' 247 | ? endEscape.length % 2 === 0 248 | // A normal case, and it is a range notation 249 | // '[bar]' 250 | // '[bar\\\\]' 251 | ? `[${sanitizeRange(range)}${endEscape}]` 252 | // Invalid range notaton 253 | // '[bar\\]' -> '[bar\\\\]' 254 | : '[]' 255 | : '[]' 256 | ], 257 | 258 | // ending 259 | [ 260 | // 'js' will not match 'js.' 261 | // 'ab' will not match 'abc' 262 | /(?:[^*])$/, 263 | 264 | // WTF! 265 | // https://git-scm.com/docs/gitignore 266 | // changes in [2.22.1](https://git-scm.com/docs/gitignore/2.22.1) 267 | // which re-fixes #24, #38 268 | 269 | // > If there is a separator at the end of the pattern then the pattern 270 | // > will only match directories, otherwise the pattern can match both 271 | // > files and directories. 272 | 273 | // 'js*' will not match 'a.js' 274 | // 'js/' will not match 'a.js' 275 | // 'js' will match 'a.js' and 'a.js/' 276 | match => /\/$/.test(match) 277 | // foo/ will not match 'foo' 278 | ? `${match}$` 279 | // foo matches 'foo' and 'foo/' 280 | : `${match}(?=$|\\/$)` 281 | ], 282 | 283 | // trailing wildcard 284 | [ 285 | /(\^|\\\/)?\\\*$/, 286 | (_, p1) => { 287 | const prefix = p1 288 | // '\^': 289 | // '/*' does not match EMPTY 290 | // '/*' does not match everything 291 | 292 | // '\\\/': 293 | // 'abc/*' does not match 'abc/' 294 | ? `${p1}[^/]+` 295 | 296 | // 'a*' matches 'a' 297 | // 'a*' matches 'aa' 298 | : '[^/]*' 299 | 300 | return `${prefix}(?=$|\\/$)` 301 | } 302 | ], 303 | ] 304 | 305 | // A simple cache, because an ignore rule only has only one certain meaning 306 | const regexCache = Object.create(null) 307 | 308 | // @param {pattern} 309 | const makeRegex = (pattern, ignoreCase) => { 310 | let source = regexCache[pattern] 311 | 312 | if (!source) { 313 | source = REPLACERS.reduce( 314 | (prev, current) => prev.replace(current[0], current[1].bind(pattern)), 315 | pattern 316 | ) 317 | regexCache[pattern] = source 318 | } 319 | 320 | return ignoreCase 321 | ? new RegExp(source, 'i') 322 | : new RegExp(source) 323 | } 324 | 325 | const isString = subject => typeof subject === 'string' 326 | 327 | // > A blank line matches no files, so it can serve as a separator for readability. 328 | const checkPattern = pattern => pattern 329 | && isString(pattern) 330 | && !REGEX_TEST_BLANK_LINE.test(pattern) 331 | && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) 332 | 333 | // > A line starting with # serves as a comment. 334 | && pattern.indexOf('#') !== 0 335 | 336 | const splitPattern = pattern => pattern.split(REGEX_SPLITALL_CRLF) 337 | 338 | class IgnoreRule { 339 | constructor ( 340 | origin, 341 | pattern, 342 | negative, 343 | regex 344 | ) { 345 | this.origin = origin 346 | this.pattern = pattern 347 | this.negative = negative 348 | this.regex = regex 349 | } 350 | } 351 | 352 | const createRule = (pattern, ignoreCase) => { 353 | const origin = pattern 354 | let negative = false 355 | 356 | // > An optional prefix "!" which negates the pattern; 357 | if (pattern.indexOf('!') === 0) { 358 | negative = true 359 | pattern = pattern.substr(1) 360 | } 361 | 362 | pattern = pattern 363 | // > Put a backslash ("\") in front of the first "!" for patterns that 364 | // > begin with a literal "!", for example, `"\!important!.txt"`. 365 | .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!') 366 | // > Put a backslash ("\") in front of the first hash for patterns that 367 | // > begin with a hash. 368 | .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#') 369 | 370 | const regex = makeRegex(pattern, ignoreCase) 371 | 372 | return new IgnoreRule( 373 | origin, 374 | pattern, 375 | negative, 376 | regex 377 | ) 378 | } 379 | 380 | const throwError = (message, Ctor) => { 381 | throw new Ctor(message) 382 | } 383 | 384 | const checkPath = (path, originalPath, doThrow) => { 385 | if (!isString(path)) { 386 | return doThrow( 387 | `path must be a string, but got \`${originalPath}\``, 388 | TypeError 389 | ) 390 | } 391 | 392 | // We don't know if we should ignore EMPTY, so throw 393 | if (!path) { 394 | return doThrow(`path must not be empty`, TypeError) 395 | } 396 | 397 | // Check if it is a relative path 398 | if (checkPath.isNotRelative(path)) { 399 | const r = '`path.relative()`d' 400 | return doThrow( 401 | `path should be a ${r} string, but got "${originalPath}"`, 402 | RangeError 403 | ) 404 | } 405 | 406 | return true 407 | } 408 | 409 | const isNotRelative = path => REGEX_TEST_INVALID_PATH.test(path) 410 | 411 | checkPath.isNotRelative = isNotRelative 412 | checkPath.convert = p => p 413 | 414 | class Ignore { 415 | constructor ({ 416 | ignorecase = true, 417 | ignoreCase = ignorecase, 418 | allowRelativePaths = false 419 | } = {}) { 420 | define(this, KEY_IGNORE, true) 421 | 422 | this._rules = [] 423 | this._ignoreCase = ignoreCase 424 | this._allowRelativePaths = allowRelativePaths 425 | this._initCache() 426 | } 427 | 428 | _initCache () { 429 | this._ignoreCache = Object.create(null) 430 | this._testCache = Object.create(null) 431 | } 432 | 433 | _addPattern (pattern) { 434 | // #32 435 | if (pattern && pattern[KEY_IGNORE]) { 436 | this._rules = this._rules.concat(pattern._rules) 437 | this._added = true 438 | return 439 | } 440 | 441 | if (checkPattern(pattern)) { 442 | const rule = createRule(pattern, this._ignoreCase) 443 | this._added = true 444 | this._rules.push(rule) 445 | } 446 | } 447 | 448 | // @param {Array | string | Ignore} pattern 449 | add (pattern) { 450 | this._added = false 451 | 452 | makeArray( 453 | isString(pattern) 454 | ? splitPattern(pattern) 455 | : pattern 456 | ).forEach(this._addPattern, this) 457 | 458 | // Some rules have just added to the ignore, 459 | // making the behavior changed. 460 | if (this._added) { 461 | this._initCache() 462 | } 463 | 464 | return this 465 | } 466 | 467 | // legacy 468 | addPattern (pattern) { 469 | return this.add(pattern) 470 | } 471 | 472 | // | ignored : unignored 473 | // negative | 0:0 | 0:1 | 1:0 | 1:1 474 | // -------- | ------- | ------- | ------- | -------- 475 | // 0 | TEST | TEST | SKIP | X 476 | // 1 | TESTIF | SKIP | TEST | X 477 | 478 | // - SKIP: always skip 479 | // - TEST: always test 480 | // - TESTIF: only test if checkUnignored 481 | // - X: that never happen 482 | 483 | // @param {boolean} whether should check if the path is unignored, 484 | // setting `checkUnignored` to `false` could reduce additional 485 | // path matching. 486 | 487 | // @returns {TestResult} true if a file is ignored 488 | _testOne (path, checkUnignored) { 489 | let ignored = false 490 | let unignored = false 491 | 492 | this._rules.forEach(rule => { 493 | const {negative} = rule 494 | if ( 495 | unignored === negative && ignored !== unignored 496 | || negative && !ignored && !unignored && !checkUnignored 497 | ) { 498 | return 499 | } 500 | 501 | const matched = rule.regex.test(path) 502 | 503 | if (matched) { 504 | ignored = !negative 505 | unignored = negative 506 | } 507 | }) 508 | 509 | return { 510 | ignored, 511 | unignored 512 | } 513 | } 514 | 515 | // @returns {TestResult} 516 | _test (originalPath, cache, checkUnignored, slices) { 517 | const path = originalPath 518 | // Supports nullable path 519 | && checkPath.convert(originalPath) 520 | 521 | checkPath( 522 | path, 523 | originalPath, 524 | this._allowRelativePaths 525 | ? RETURN_FALSE 526 | : throwError 527 | ) 528 | 529 | return this._t(path, cache, checkUnignored, slices) 530 | } 531 | 532 | _t (path, cache, checkUnignored, slices) { 533 | if (path in cache) { 534 | return cache[path] 535 | } 536 | 537 | if (!slices) { 538 | // path/to/a.js 539 | // ['path', 'to', 'a.js'] 540 | slices = path.split(SLASH) 541 | } 542 | 543 | slices.pop() 544 | 545 | // If the path has no parent directory, just test it 546 | if (!slices.length) { 547 | return cache[path] = this._testOne(path, checkUnignored) 548 | } 549 | 550 | const parent = this._t( 551 | slices.join(SLASH) + SLASH, 552 | cache, 553 | checkUnignored, 554 | slices 555 | ) 556 | 557 | // If the path contains a parent directory, check the parent first 558 | return cache[path] = parent.ignored 559 | // > It is not possible to re-include a file if a parent directory of 560 | // > that file is excluded. 561 | ? parent 562 | : this._testOne(path, checkUnignored) 563 | } 564 | 565 | ignores (path) { 566 | return this._test(path, this._ignoreCache, false).ignored 567 | } 568 | 569 | createFilter () { 570 | return path => !this.ignores(path) 571 | } 572 | 573 | filter (paths) { 574 | return makeArray(paths).filter(this.createFilter()) 575 | } 576 | 577 | // @returns {TestResult} 578 | test (path) { 579 | return this._test(path, this._testCache, true) 580 | } 581 | } 582 | 583 | const factory = options => new Ignore(options) 584 | 585 | const isPathValid = path => 586 | checkPath(path && checkPath.convert(path), path, RETURN_FALSE) 587 | 588 | factory.isPathValid = isPathValid 589 | 590 | // Fixes typescript 591 | factory.default = factory 592 | 593 | export default factory 594 | 595 | // Windows 596 | // -------------------------------------------------------------- 597 | /* istanbul ignore if */ 598 | if ( 599 | // Detect `process` so that it can run in browsers. 600 | typeof process !== 'undefined' 601 | && ( 602 | process.env && process.env.IGNORE_TEST_WIN32 603 | || process.platform === 'win32' 604 | ) 605 | ) { 606 | /* eslint no-control-regex: "off" */ 607 | const makePosix = str => /^\\\\\?\\/.test(str) 608 | || /["<>|\u0000-\u001F]+/u.test(str) 609 | ? str 610 | : str.replace(/\\/g, '/') 611 | 612 | checkPath.convert = makePosix 613 | 614 | // 'C:\\foo' <- 'C:\\foo' has been converted to 'C:/' 615 | // 'd:\\foo' 616 | const REGIX_IS_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i 617 | checkPath.isNotRelative = path => 618 | REGIX_IS_WINDOWS_PATH_ABSOLUTE.test(path) 619 | || isNotRelative(path) 620 | } --------------------------------------------------------------------------------