@justindfuller/ban-code-comments 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/bin/ban-code-comments.js +4 -0
- package/package.json +40 -0
- package/src/api.js +9 -0
- package/src/check.js +17 -0
- package/src/cli-runner.js +21 -0
- package/src/cli.js +44 -0
- package/src/discovery.js +96 -0
- package/src/hook-launcher.js +18 -0
- package/src/hook.js +149 -0
- package/src/index.js +33 -0
- package/src/inputs.js +36 -0
- package/src/languages.js +47 -0
- package/src/launcher-entry.js +9 -0
- package/src/model.js +39 -0
- package/src/report.js +7 -0
- package/src/scanner.js +241 -0
- package/src/version.js +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justin Fuller
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ban-code-comments
|
|
2
|
+
|
|
3
|
+
`ban-code-comments` detects code comments so teams can enforce a no-comments policy locally and in CI. It is a self-contained Node.js package with no repository configuration file required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install the published package with Node.js 24 or newer:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global @justindfuller/ban-code-comments
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package includes the `ban-code-comments` executable and the programmatic API. It does not require Go, a native executable, or a runtime download.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Run `ban-code-comments` to scan the current repository, or pass one or more files or directories:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
ban-code-comments .
|
|
21
|
+
ban-code-comments src scripts
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The default output is JSON with `findings` and `summary` fields. Use `--format text` for concise path, line, column, and comment output.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
ban-code-comments --format text .
|
|
28
|
+
ban-code-comments --language go,python --include 'src/**' --exclude 'src/vendor/**' .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The practical default reports ordinary and documentation comments. Select `ordinary`, `documentation`, `header`, or `directive` categories with `--categories`; selecting categories replaces the default set. Repeat or comma-separate `--language`, `--include`, and `--exclude` values. `--debug` writes reasons for skipped files to stderr.
|
|
32
|
+
|
|
33
|
+
The process exits with `0` when no selected comments are found, `1` when findings exist, and `2` for invalid options, unreadable paths, or scan failures.
|
|
34
|
+
|
|
35
|
+
## Supported languages
|
|
36
|
+
|
|
37
|
+
Go; JavaScript, TypeScript, JSX, and TSX; Python; Rust; Java; C, C++, and C#; Kotlin; Swift; Ruby; PHP; shell; SQL; HTML and XML; CSS and SCSS; YAML; TOML; JSONC; HCL and Terraform; Dockerfile; Makefile; and INI. Standard JSON files are recognized and scanned as comment-free JSON.
|
|
38
|
+
|
|
39
|
+
The scanner ignores comment-shaped text inside strings, raw strings, templates, escaped literals, heredocs, and triple-quoted literals. Unsupported or irrelevant files are skipped; use `--debug` to inspect those decisions.
|
|
40
|
+
|
|
41
|
+
## GitHub Actions
|
|
42
|
+
|
|
43
|
+
The Action requires a checkout step and runs with read-only repository access. Its release contains the matching JavaScript implementation, so it does not download or cache a native CLI at runtime.
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
name: Ban code comments
|
|
47
|
+
|
|
48
|
+
on: [push, pull_request]
|
|
49
|
+
|
|
50
|
+
permissions:
|
|
51
|
+
contents: read
|
|
52
|
+
|
|
53
|
+
jobs:
|
|
54
|
+
comments:
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v4
|
|
58
|
+
- uses: JustinDFuller-org/ban-code-comments@v1
|
|
59
|
+
with:
|
|
60
|
+
format: text
|
|
61
|
+
languages: go,python,typescript
|
|
62
|
+
exclude: |
|
|
63
|
+
vendor/**
|
|
64
|
+
internal/scanner/testdata/fixtures/**
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use `@v1` to receive compatible releases automatically. For reproducible workflows, pin an exact release such as `@v1.0.0` or pin the Action to a full commit SHA. The moving `v1` tag is maintained to the latest compatible release.
|
|
68
|
+
|
|
69
|
+
The Action accepts the same configuration as the CLI:
|
|
70
|
+
|
|
71
|
+
| Input | Default | Description |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| `paths` | `.` | Files or directories to scan; separate multiple values with newlines. |
|
|
74
|
+
| `languages` | — | Languages to scan; separate values with commas or newlines. |
|
|
75
|
+
| `categories` | — | Comment categories to report; separate values with commas. |
|
|
76
|
+
| `include` | — | File globs to include; separate multiple values with newlines. |
|
|
77
|
+
| `exclude` | — | File globs to exclude; separate multiple values with newlines. |
|
|
78
|
+
| `format` | `json` | Output format: `json` or `text`. |
|
|
79
|
+
| `debug` | `false` | Set to `true` to write skipped-file diagnostics to stderr. |
|
|
80
|
+
|
|
81
|
+
The Action exits `0` for a clean scan, `1` when selected findings exist, and `2` for invalid options or scan failures. Findings and operational errors therefore fail the workflow step while remaining distinguishable in the log.
|
|
82
|
+
|
|
83
|
+
For direct automation outside GitHub Actions, install the npm package and run `ban-code-comments .` as a step. The CLI and Action use the same implementation, options, reports, and exit statuses.
|
|
84
|
+
|
|
85
|
+
The package also exposes plain-data operations:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
import { check, scanSource } from "@justindfuller/ban-code-comments";
|
|
89
|
+
|
|
90
|
+
const sourceFindings = scanSource("const value = 1; // finding\n", "fixture.js", "javascript");
|
|
91
|
+
const repositoryResult = await check(["."], { categories: new Set(["ordinary", "documentation"]) });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The API exports `scanSource`, `discover`, `check`, `runCLI`, `evaluateHook`, `renderJSON`, `renderText`, `lookup`, and the shared model and category helpers.
|
|
95
|
+
|
|
96
|
+
## Codex plugins
|
|
97
|
+
|
|
98
|
+
This repository provides two separately installable Codex plugins: `ban-code-comments-warn` allows supported traditional file edits and adds guidance, while `ban-code-comments-hard-block` denies supported traditional file edits that introduce findings.
|
|
99
|
+
|
|
100
|
+
Review plugin source and hook commands before trusting them, then add the repository marketplace and install one variant:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
codex plugin marketplace add JustinDFuller-org/ban-code-comments
|
|
104
|
+
codex plugin add ban-code-comments-warn@ban-code-comments
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use `ban-code-comments-hard-block@ban-code-comments` instead when denial is preferred, and review the enabled hook in `/hooks` after installation. The shared scanner covers the supported source and configuration languages in the language registry; Markdown, README files, literals, directives, and unsupported paths are not code-comment findings.
|
|
108
|
+
|
|
109
|
+
Pre-tool evaluation reconstructs documented file-edit payloads from `apply_patch`, `edit`, `write`, `write_file`, and `file_write`, then compares proposed findings with the file's existing findings, so unchanged legacy comments do not block unrelated edits. Bash, shell, exec, MCP, generators, redirection, and other opaque write paths are outside the plugin boundary; the repository scanner and GitHub Action enforce the final state in CI.
|
|
110
|
+
|
|
111
|
+
The guidance skill directs agents to use Git history, pull-request descriptions, simplified code, nearby README files, and Markdown instead of explanatory source comments. The plugin bundles are self-contained and work offline after installation. Roll back by disabling or removing the plugin with `codex plugin remove <plugin>@ban-code-comments`.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
Run the complete local checks with Node.js 24 or newer:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
npm ci
|
|
119
|
+
npm test
|
|
120
|
+
npm run coverage
|
|
121
|
+
npm run validate:plugins
|
|
122
|
+
npm run build
|
|
123
|
+
npm run build:plugins
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Coverage policy
|
|
127
|
+
|
|
128
|
+
CI runs the Node test suite, enforces at least 90 percent aggregate JavaScript line coverage, validates both Codex plugins, rebuilds generated distributions, runs the package CLI, and validates OpenSpec.
|
|
129
|
+
|
|
130
|
+
Semantic-version tags matching `vMAJOR.MINOR.PATCH` publish the corresponding npm package version. The repository tag and Action major tag remain release references; the Action and plugins use the bundled JavaScript implementation coupled to that release.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@justindfuller/ban-code-comments",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Detect and ban code comments across source and configuration files",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24"
|
|
8
|
+
},
|
|
9
|
+
"bin": "./bin/ban-code-comments.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/api.js",
|
|
12
|
+
"./model": "./src/model.js",
|
|
13
|
+
"./languages": "./src/languages.js",
|
|
14
|
+
"./cli": "./src/cli.js",
|
|
15
|
+
"./hook": "./src/hook.js"
|
|
16
|
+
},
|
|
17
|
+
"main": "src/api.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "ncc build src/index.js --minify --source-map --license licenses.txt -o dist",
|
|
26
|
+
"build:plugins": "node scripts/build-plugins.mjs",
|
|
27
|
+
"smoke:codex": "node scripts/codex-smoke.mjs",
|
|
28
|
+
"validate:plugins": "node scripts/validate-plugins.mjs",
|
|
29
|
+
"test": "node --test test/*.test.js",
|
|
30
|
+
"coverage": "c8 --all --include=src/**/*.js --exclude=src/../dist/** --reporter=text --reporter=cobertura --check-coverage --lines=90 node --test test/*.test.js",
|
|
31
|
+
"check": "npm test && npm run validate:plugins && npm run build && git diff --exit-code -- dist licenses.txt"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@actions/core": "3.0.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@vercel/ncc": "0.45.0",
|
|
38
|
+
"c8": "10.1.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { CATEGORIES, DEFAULT_CATEGORIES, exitCode, finding, position, result, sourceRange } from "./model.js";
|
|
2
|
+
export { extensions, languageAliases, lookup, parseSelection, supported } from "./languages.js";
|
|
3
|
+
export { exitStatus, parseArgs } from "./cli.js";
|
|
4
|
+
export { scanSource } from "./scanner.js";
|
|
5
|
+
export { renderJSON, renderText } from "./report.js";
|
|
6
|
+
export { discover } from "./discovery.js";
|
|
7
|
+
export { check } from "./check.js";
|
|
8
|
+
export { runCLI } from "./cli-runner.js";
|
|
9
|
+
export { evaluateHook, runHook } from "./hook.js";
|
package/src/check.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { discover } from "./discovery.js";
|
|
3
|
+
import { result } from "./model.js";
|
|
4
|
+
import { scanSource } from "./scanner.js";
|
|
5
|
+
|
|
6
|
+
export async function check(inputPaths = [], options = {}) {
|
|
7
|
+
const discovered = await discover(inputPaths, options);
|
|
8
|
+
if (options.onDiagnostic) for (const diagnostic of discovered.diagnostics) options.onDiagnostic(diagnostic);
|
|
9
|
+
const findings = [];
|
|
10
|
+
for (const candidate of discovered.candidates) {
|
|
11
|
+
let source;
|
|
12
|
+
try { source = await fs.readFile(candidate.path, "utf8"); }
|
|
13
|
+
catch (error) { throw new Error(`${candidate.relative}: ${error.message}`); }
|
|
14
|
+
findings.push(...scanSource(source, candidate.relative, candidate.language, options.categories));
|
|
15
|
+
}
|
|
16
|
+
return result(findings, { files_scanned: discovered.candidates.length, files_skipped: discovered.skipped, findings: findings.length });
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { check } from "./check.js";
|
|
2
|
+
import { exitStatus, parseArgs } from "./cli.js";
|
|
3
|
+
import { renderJSON, renderText } from "./report.js";
|
|
4
|
+
import { CLI_VERSION } from "./version.js";
|
|
5
|
+
|
|
6
|
+
export async function runCLI(args = [], streams = {}) {
|
|
7
|
+
const output = streams.stdout || process.stdout;
|
|
8
|
+
const errors = streams.stderr || process.stderr;
|
|
9
|
+
try {
|
|
10
|
+
const options = parseArgs(args);
|
|
11
|
+
if (options.help) { output.write("Usage: ban-code-comments [options] [path ...]\n"); return 0; }
|
|
12
|
+
if (options.version) { output.write(`${CLI_VERSION}\n`); return 0; }
|
|
13
|
+
const value = await check(options.paths, { ...options, cwd: streams.cwd, onDiagnostic: options.debug ? (item) => errors.write(`debug: ${item.path}: ${item.reason}\n`) : undefined });
|
|
14
|
+
output.write(options.format === "text" ? renderText(value) : renderJSON(value));
|
|
15
|
+
if (options.debug) errors.write(`scanned ${value.summary.files_scanned} source(s), skipped ${value.summary.files_skipped} path(s)\n`);
|
|
16
|
+
return exitStatus(value.findings);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
errors.write(`${error.message}\n`);
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DEFAULT_CATEGORIES, CATEGORIES } from "./model.js";
|
|
2
|
+
import { parseSelection } from "./languages.js";
|
|
3
|
+
|
|
4
|
+
const categoryNames = new Set(Object.values(CATEGORIES));
|
|
5
|
+
|
|
6
|
+
function valuesFor(args, index, option) {
|
|
7
|
+
const value = args[index + 1];
|
|
8
|
+
if (value === undefined || value.startsWith("-")) throw new Error(`${option} requires a value`);
|
|
9
|
+
return [value, index + 1];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseArgs(args = []) {
|
|
13
|
+
const languageValues = [];
|
|
14
|
+
const categoryValues = [];
|
|
15
|
+
const includes = [];
|
|
16
|
+
const excludes = [];
|
|
17
|
+
const paths = [];
|
|
18
|
+
let format = "json";
|
|
19
|
+
let debug = false;
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
const argument = args[index];
|
|
22
|
+
if (argument === "--debug") debug = true;
|
|
23
|
+
else if (argument === "--help" || argument === "-h") return { help: true };
|
|
24
|
+
else if (argument === "--version" || argument === "-v") return { version: true };
|
|
25
|
+
else if (["--language", "--languages"].includes(argument)) {
|
|
26
|
+
const [value, next] = valuesFor(args, index, argument); languageValues.push(value); index = next;
|
|
27
|
+
} else if (argument === "--categories") {
|
|
28
|
+
const [value, next] = valuesFor(args, index, argument); categoryValues.push(value); index = next;
|
|
29
|
+
} else if (["--include", "--exclude"].includes(argument)) {
|
|
30
|
+
const [value, next] = valuesFor(args, index, argument); (argument === "--include" ? includes : excludes).push(value); index = next;
|
|
31
|
+
} else if (argument === "--format") {
|
|
32
|
+
const [value, next] = valuesFor(args, index, argument); format = value.toLowerCase(); index = next;
|
|
33
|
+
} else if (argument.startsWith("-")) throw new Error(`unknown option ${argument}`);
|
|
34
|
+
else paths.push(argument);
|
|
35
|
+
}
|
|
36
|
+
if (!["json", "text"].includes(format)) throw new Error(`unsupported format ${JSON.stringify(format)}`);
|
|
37
|
+
const categories = categoryValues.flatMap((value) => String(value).split(",").map((item) => item.trim().toLowerCase())).filter(Boolean);
|
|
38
|
+
for (const category of categories) if (!categoryNames.has(category)) throw new Error(`unsupported category ${JSON.stringify(category)}`);
|
|
39
|
+
return { paths: paths.length ? paths : ["."], languages: parseSelection(languageValues), categories: new Set(categories.length ? categories : DEFAULT_CATEGORIES), includes, excludes, format, debug };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function exitStatus(findings, error = null) {
|
|
43
|
+
return error ? 2 : findings.length ? 1 : 0;
|
|
44
|
+
}
|
package/src/discovery.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { lookup } from "./languages.js";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const skippedDirectories = new Set([".git", "node_modules", "vendor", "dist", "build", "coverage"]);
|
|
9
|
+
|
|
10
|
+
export function normalizePath(value) { return String(value).replaceAll("\\", "/"); }
|
|
11
|
+
|
|
12
|
+
export function globToRegExp(pattern) {
|
|
13
|
+
let expression = "^";
|
|
14
|
+
const normalized = normalizePath(pattern);
|
|
15
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
16
|
+
const character = normalized[index];
|
|
17
|
+
if (character === "*" && normalized[index + 1] === "*") {
|
|
18
|
+
if (normalized[index + 2] === "/") { expression += "(?:.*/)?"; index += 2; }
|
|
19
|
+
else { expression += ".*"; index += 1; }
|
|
20
|
+
} else if (character === "*") expression += "[^/]*";
|
|
21
|
+
else if (character === "?") expression += "[^/]";
|
|
22
|
+
else expression += /[.+^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
|
|
23
|
+
}
|
|
24
|
+
return new RegExp(`${expression}$`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function matches(relativePath, patterns) { return patterns.length === 0 || patterns.some((pattern) => globToRegExp(pattern).test(relativePath)); }
|
|
28
|
+
function excluded(relativePath, patterns) { return patterns.some((pattern) => globToRegExp(pattern).test(relativePath)); }
|
|
29
|
+
function display(filePath, cwd) { return normalizePath(path.relative(cwd, filePath) || path.basename(filePath)); }
|
|
30
|
+
|
|
31
|
+
async function walk(directory, cwd, options, diagnostics) {
|
|
32
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
33
|
+
const files = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const fullPath = path.join(directory, entry.name);
|
|
36
|
+
const relative = display(fullPath, cwd);
|
|
37
|
+
if (entry.isSymbolicLink()) { diagnostics.push({ path: relative, reason: "symbolic link" }); continue; }
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (skippedDirectories.has(entry.name)) diagnostics.push({ path: relative, reason: "fixed directory exclusion" });
|
|
40
|
+
else files.push(...await walk(fullPath, cwd, options, diagnostics));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const language = lookup(fullPath);
|
|
44
|
+
if (!entry.isFile() || !language) { if (entry.isFile()) diagnostics.push({ path: relative, reason: "unsupported file" }); continue; }
|
|
45
|
+
if (!matches(relative, options.includes || [])) { diagnostics.push({ path: relative, reason: "not included" }); continue; }
|
|
46
|
+
if (excluded(relative, options.excludes || [])) { diagnostics.push({ path: relative, reason: "excluded" }); continue; }
|
|
47
|
+
if (options.languages?.size && !options.languages.has(language)) { diagnostics.push({ path: relative, reason: "language filter" }); continue; }
|
|
48
|
+
files.push({ path: fullPath, relative, language });
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function tracked(cwd) {
|
|
54
|
+
const { stdout: rootOutput } = await execFileAsync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
55
|
+
const repositoryRoot = path.resolve(rootOutput.trim());
|
|
56
|
+
const { stdout } = await execFileAsync("git", ["-C", repositoryRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { encoding: "utf8" });
|
|
57
|
+
return stdout.split("\0").filter(Boolean).map((file) => path.resolve(repositoryRoot, file));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function discover(inputPaths = [], options = {}) {
|
|
61
|
+
const cwd = await fs.realpath(path.resolve(options.cwd || process.cwd()));
|
|
62
|
+
const diagnostics = [];
|
|
63
|
+
const candidates = [];
|
|
64
|
+
const paths = inputPaths.length ? inputPaths : null;
|
|
65
|
+
if (!paths) {
|
|
66
|
+
try {
|
|
67
|
+
for (const fullPath of await tracked(cwd)) {
|
|
68
|
+
const language = lookup(fullPath); const relative = display(fullPath, cwd);
|
|
69
|
+
if (!language || relative.split("/").some((part) => skippedDirectories.has(part))) continue;
|
|
70
|
+
if (matches(relative, options.includes || []) && !excluded(relative, options.excludes || []) && (!options.languages?.size || options.languages.has(language))) candidates.push({ path: fullPath, relative, language });
|
|
71
|
+
else diagnostics.push({ path: relative, reason: "selection filter" });
|
|
72
|
+
}
|
|
73
|
+
} catch { candidates.push(...await walk(cwd, cwd, options, diagnostics)); }
|
|
74
|
+
} else {
|
|
75
|
+
for (const input of paths) {
|
|
76
|
+
const fullPath = path.resolve(cwd, input);
|
|
77
|
+
try {
|
|
78
|
+
const stats = await fs.lstat(fullPath);
|
|
79
|
+
if (stats.isDirectory()) candidates.push(...await walk(fullPath, cwd, options, diagnostics));
|
|
80
|
+
else if (stats.isFile()) {
|
|
81
|
+
const language = lookup(fullPath); const relative = display(fullPath, cwd);
|
|
82
|
+
if (!language) diagnostics.push({ path: relative, reason: "unsupported file" });
|
|
83
|
+
else if (!matches(relative, options.includes || [])) diagnostics.push({ path: relative, reason: "not included" });
|
|
84
|
+
else if (excluded(relative, options.excludes || [])) diagnostics.push({ path: relative, reason: "excluded" });
|
|
85
|
+
else if (options.languages?.size && !options.languages.has(language)) diagnostics.push({ path: relative, reason: "language filter" });
|
|
86
|
+
else candidates.push({ path: fullPath, relative, language });
|
|
87
|
+
}
|
|
88
|
+
} catch (error) { throw new Error(`${input}: ${error.message}`); }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const unique = [...new Map(candidates.map((candidate) => [path.resolve(candidate.path), candidate])).values()];
|
|
92
|
+
unique.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
93
|
+
return { candidates: unique, diagnostics, skipped: diagnostics.length };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { skippedDirectories };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runHook as evaluateHookInput } from "./hook.js";
|
|
2
|
+
|
|
3
|
+
async function runHook(mode, options = {}) {
|
|
4
|
+
return evaluateHookInput(options.input || process.stdin, mode, options.output || process.stdout);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function main(modeOverride) {
|
|
8
|
+
const modeArgumentIndex = process.argv.indexOf("--mode");
|
|
9
|
+
const requestedMode = modeArgumentIndex >= 0 ? process.argv[modeArgumentIndex + 1] : process.argv[2];
|
|
10
|
+
const mode = modeOverride || requestedMode || "hard";
|
|
11
|
+
if (mode !== "hard" && mode !== "warn") {
|
|
12
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: `ban-code-comments hook launcher failed: unsupported hook mode ${mode}` }) + "\n");
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
return runHook(mode);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { main, runHook };
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lookup } from "./languages.js";
|
|
4
|
+
import { CATEGORIES } from "./model.js";
|
|
5
|
+
import { scanSource } from "./scanner.js";
|
|
6
|
+
|
|
7
|
+
const tools = new Set(["apply_patch", "edit", "write", "write_file", "file_write"]);
|
|
8
|
+
const selected = new Set([CATEGORIES.ORDINARY, CATEGORIES.DOCUMENTATION]);
|
|
9
|
+
|
|
10
|
+
function errorResponse(mode, code, message) {
|
|
11
|
+
const text = `ban-code-comments hook operational error (${code}): ${message}`;
|
|
12
|
+
return mode === "hard" ? { decision: "block", reason: text } : { systemMessage: text };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolve(root, name) {
|
|
16
|
+
const normalized = String(name || "").trim().replaceAll("\\", "/").replace(new RegExp("^(?:a|b)/"), "");
|
|
17
|
+
if (!normalized) throw new Error(`invalid proposed path ${JSON.stringify(name || "")}`);
|
|
18
|
+
const target = path.isAbsolute(normalized) ? path.normalize(normalized) : path.resolve(root, normalized);
|
|
19
|
+
const relative = path.relative(root, target);
|
|
20
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`proposed path escapes workspace: ${JSON.stringify(name)}`);
|
|
21
|
+
return target;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function display(root, name) { return path.relative(root, resolve(root, name)).replaceAll(path.sep, "/"); }
|
|
25
|
+
|
|
26
|
+
function stringValue(input, ...keys) { for (const key of keys) if (typeof input?.[key] === "string") return input[key]; return ""; }
|
|
27
|
+
|
|
28
|
+
function parsePatch(patch) {
|
|
29
|
+
const lines = String(patch).replaceAll("\r\n", "\n").split("\n");
|
|
30
|
+
let index = lines[0]?.trim() === "*** Begin Patch" ? 1 : 0;
|
|
31
|
+
const changes = [];
|
|
32
|
+
while (index < lines.length) {
|
|
33
|
+
const line = lines[index];
|
|
34
|
+
if (!line || line.trim() === "*** End Patch") { index += 1; continue; }
|
|
35
|
+
if (line.startsWith("*** Add File: ")) {
|
|
36
|
+
const body = collectBody(lines, index + 1); changes.push({ path: line.slice(14), source: body.lines.filter((item) => item.startsWith("+")).map((item) => item.slice(1)).join("\n") }); index = body.next; continue;
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith("*** Delete File: ")) { changes.push({ path: line.slice(17), delete: true }); index += 1; continue; }
|
|
39
|
+
if (line.startsWith("*** Update File: ")) {
|
|
40
|
+
const body = collectBody(lines, index + 1); let newPath = ""; let patchLines = body.lines;
|
|
41
|
+
if (patchLines[0]?.startsWith("*** Move to: ")) { newPath = patchLines[0].slice(13); patchLines = patchLines.slice(1); }
|
|
42
|
+
changes.push({ path: line.slice(17), newPath, patchLines }); index = body.next; continue;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`unsupported patch line ${JSON.stringify(line)}`);
|
|
45
|
+
}
|
|
46
|
+
return changes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectBody(lines, start) {
|
|
50
|
+
let next = start;
|
|
51
|
+
while (next < lines.length && !/^(?:\*\*\* (?:Add|Delete|Update) File:|\*\*\* End Patch)/.test(lines[next])) next += 1;
|
|
52
|
+
return { lines: lines.slice(start, next), next };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function reconstruct(raw) {
|
|
56
|
+
if (raw === undefined || raw === null) throw new Error("hook event has no tool input");
|
|
57
|
+
if (typeof raw === "string") return parsePatch(raw);
|
|
58
|
+
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("tool input is not an object");
|
|
59
|
+
const patch = stringValue(raw, "patch", "input", "command");
|
|
60
|
+
if (patch.trimStart().startsWith("*** Begin Patch")) return parsePatch(patch);
|
|
61
|
+
const filePath = stringValue(raw, "path", "file_path", "filePath", "filename");
|
|
62
|
+
if (!filePath) throw new Error("tool input does not identify a file");
|
|
63
|
+
for (const key of ["content", "new_content", "newContent"]) if (typeof raw[key] === "string") return [{ path: filePath, source: raw[key] }];
|
|
64
|
+
const oldText = stringValue(raw, "old_string", "oldString");
|
|
65
|
+
const newText = stringValue(raw, "new_string", "newString");
|
|
66
|
+
if (Object.hasOwn(raw, "old_string") || Object.hasOwn(raw, "oldString")) return [{ path: filePath, oldText, newText }];
|
|
67
|
+
throw new Error("tool input does not contain proposed file content");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyPatch(source, patchLines) {
|
|
71
|
+
const finalNewline = source.endsWith("\n");
|
|
72
|
+
const oldLines = source.replace(/\n$/, "").split("\n");
|
|
73
|
+
if (oldLines.length === 1 && oldLines[0] === "") oldLines.length = 0;
|
|
74
|
+
const output = []; let cursor = 0; let changed = false; let hunk = [];
|
|
75
|
+
const flush = () => {
|
|
76
|
+
const operations = hunk.filter((line) => line && !line.startsWith("@@") && line !== "\");
|
|
77
|
+
if (!operations.length) return;
|
|
78
|
+
const oldBlock = operations.filter((line) => line[0] === " " || line[0] === "-").map((line) => line.slice(1));
|
|
79
|
+
const newBlock = operations.filter((line) => line[0] === " " || line[0] === "+").map((line) => line.slice(1));
|
|
80
|
+
if (operations.some((line) => ![" ", "+", "-"].includes(line[0]))) throw new Error(`unsupported hunk line ${JSON.stringify(operations.find((line) => ![" ", "+", "-"].includes(line[0])))}`);
|
|
81
|
+
let match = -1;
|
|
82
|
+
for (let candidate = cursor; candidate + oldBlock.length <= oldLines.length; candidate += 1) if (oldLines.slice(candidate, candidate + oldBlock.length).every((line, i) => line === oldBlock[i])) { match = candidate; break; }
|
|
83
|
+
if (match < 0) throw new Error("hunk context was not found");
|
|
84
|
+
output.push(...oldLines.slice(cursor, match), ...newBlock); cursor = match + oldBlock.length; changed = true;
|
|
85
|
+
};
|
|
86
|
+
for (const line of patchLines) { if (line.startsWith("@@")) { flush(); hunk = []; } hunk.push(line); }
|
|
87
|
+
flush();
|
|
88
|
+
if (!changed) throw new Error("patch contains no file operations");
|
|
89
|
+
output.push(...oldLines.slice(cursor));
|
|
90
|
+
return output.join("\n") + (finalNewline ? "\n" : "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function difference(before, after) {
|
|
94
|
+
const counts = new Map();
|
|
95
|
+
for (const item of before) { const key = `${item.language}\0${item.category}\0${item.text}`; counts.set(key, (counts.get(key) || 0) + 1); }
|
|
96
|
+
return after.filter((item) => { const key = `${item.language}\0${item.category}\0${item.text}`; const count = counts.get(key) || 0; if (count) { counts.set(key, count - 1); return false; } return true; });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function findingsFor(root, filePath, source) {
|
|
100
|
+
const language = lookup(filePath);
|
|
101
|
+
return language ? scanSource(source, display(root, filePath), language, selected) : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function evaluate(root, changes) {
|
|
105
|
+
const findings = [];
|
|
106
|
+
for (const change of changes) {
|
|
107
|
+
const oldPath = resolve(root, change.path);
|
|
108
|
+
let oldSource = "";
|
|
109
|
+
try { oldSource = await fs.readFile(oldPath, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
110
|
+
let newSource = change.source;
|
|
111
|
+
if (change.oldText !== undefined) { if (!oldSource.includes(change.oldText)) throw new Error(`proposed edit could not find old text in ${change.path}`); newSource = oldSource.replace(change.oldText, change.newText); }
|
|
112
|
+
if (change.patchLines?.length) newSource = applyPatch(oldSource, change.patchLines);
|
|
113
|
+
if (change.delete) newSource = "";
|
|
114
|
+
const newPath = change.newPath ? resolve(root, change.newPath) && change.newPath : change.path;
|
|
115
|
+
findings.push(...difference(await findingsFor(root, change.path, oldSource), await findingsFor(root, newPath, newSource)));
|
|
116
|
+
}
|
|
117
|
+
return findings;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatFindings(findings) {
|
|
121
|
+
return `ban-code-comments found newly introduced comments: ${[...findings].sort((a, b) => a.path.localeCompare(b.path) || a.range.start.line - b.range.start.line || a.range.start.column - b.range.start.column).map((item) => `${item.path}:${item.range.start.line}:${item.range.start.column} [${item.category}] ${item.text.trim()}`).join("; ")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findingResponse(mode, findings) {
|
|
125
|
+
if (!findings.length) return {};
|
|
126
|
+
const reason = formatFindings(findings);
|
|
127
|
+
if (mode === "hard") return { decision: "block", reason };
|
|
128
|
+
const guidance = `${reason} Use Git history for history, pull-request descriptions for rationale, simplified code or a nearby README for complexity, and Markdown for general documentation.`;
|
|
129
|
+
return { systemMessage: guidance, hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: guidance } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function evaluateHook(event, mode = "hard") {
|
|
133
|
+
if (mode !== "hard" && mode !== "warn") return errorResponse(mode, "invalid_mode", `unsupported hook mode ${JSON.stringify(mode)}`);
|
|
134
|
+
const eventName = String(event?.hook_event_name || "").trim().toLowerCase().replaceAll("_", "");
|
|
135
|
+
const toolName = String(event?.tool_name || "").trim().toLowerCase();
|
|
136
|
+
if (eventName !== "pretooluse" || !tools.has(toolName)) return {};
|
|
137
|
+
const root = path.resolve(event.cwd || process.cwd());
|
|
138
|
+
try { return findingResponse(mode, await evaluate(root, reconstruct(event.tool_input))); }
|
|
139
|
+
catch (error) { return errorResponse(mode, "proposal_unreadable", error.message); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function runHook(input, mode = "hard", output = process.stdout) {
|
|
143
|
+
let event;
|
|
144
|
+
try { event = JSON.parse(input); } catch (error) { output.write(`${JSON.stringify(errorResponse(mode, "invalid_event", `could not decode hook event: ${error.message}`))}\n`); return 0; }
|
|
145
|
+
output.write(`${JSON.stringify(await evaluateHook(event, mode))}\n`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { applyPatch, formatFindings, parsePatch, reconstruct, resolve };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as core from "@actions/core";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { cliArguments } from "./inputs.js";
|
|
5
|
+
import { runCLI } from "./cli-runner.js";
|
|
6
|
+
|
|
7
|
+
async function main(dependencies = {}) {
|
|
8
|
+
const coreAPI = dependencies.core || core;
|
|
9
|
+
const execute = dependencies.run || ((args, options) => runCLI(args, options));
|
|
10
|
+
const inputs = {
|
|
11
|
+
paths: coreAPI.getInput("paths"),
|
|
12
|
+
languages: coreAPI.getInput("languages"),
|
|
13
|
+
categories: coreAPI.getInput("categories"),
|
|
14
|
+
include: coreAPI.getInput("include"),
|
|
15
|
+
exclude: coreAPI.getInput("exclude"),
|
|
16
|
+
format: coreAPI.getInput("format") || "json",
|
|
17
|
+
debug: coreAPI.getInput("debug") || "false",
|
|
18
|
+
};
|
|
19
|
+
return execute(cliArguments(inputs), { cwd: process.env.GITHUB_WORKSPACE || process.cwd() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
23
|
+
main()
|
|
24
|
+
.then((code) => {
|
|
25
|
+
process.exitCode = code;
|
|
26
|
+
})
|
|
27
|
+
.catch((error) => {
|
|
28
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
29
|
+
process.exitCode = 2;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { main };
|
package/src/inputs.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function nonEmptyLines(value) {
|
|
2
|
+
return String(value || "")
|
|
3
|
+
.split(/\r?\n/)
|
|
4
|
+
.filter((line) => line.length > 0);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function addRepeated(args, flag, value) {
|
|
8
|
+
for (const item of nonEmptyLines(value)) {
|
|
9
|
+
args.push(flag, item);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseBoolean(value) {
|
|
14
|
+
const normalized = String(value || "false").toLowerCase();
|
|
15
|
+
if (normalized === "true") return true;
|
|
16
|
+
if (normalized === "false" || normalized === "") return false;
|
|
17
|
+
throw new Error(`invalid boolean input for debug: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cliArguments(inputs) {
|
|
21
|
+
const args = [];
|
|
22
|
+
const paths = nonEmptyLines(inputs.paths);
|
|
23
|
+
addRepeated(args, "--languages", inputs.languages);
|
|
24
|
+
if (inputs.categories) {
|
|
25
|
+
const categories = nonEmptyLines(inputs.categories).join(",");
|
|
26
|
+
if (categories) args.push("--categories", categories);
|
|
27
|
+
}
|
|
28
|
+
addRepeated(args, "--include", inputs.include);
|
|
29
|
+
addRepeated(args, "--exclude", inputs.exclude);
|
|
30
|
+
args.push("--format", inputs.format || "json");
|
|
31
|
+
if (parseBoolean(inputs.debug)) args.push("--debug");
|
|
32
|
+
args.push(...(paths.length > 0 ? paths : ["."]));
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { cliArguments, nonEmptyLines, parseBoolean };
|
package/src/languages.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const extensionLanguages = new Map([
|
|
2
|
+
[".go", "go"], [".js", "javascript"], [".jsx", "javascript"], [".mjs", "javascript"], [".cjs", "javascript"],
|
|
3
|
+
[".ts", "typescript"], [".tsx", "typescript"], [".mts", "typescript"], [".cts", "typescript"],
|
|
4
|
+
[".py", "python"], [".pyw", "python"], [".rs", "rust"], [".java", "java"],
|
|
5
|
+
[".c", "c"], [".h", "c"], [".cc", "cpp"], [".cpp", "cpp"], [".cxx", "cpp"], [".hh", "cpp"], [".hpp", "cpp"], [".hxx", "cpp"],
|
|
6
|
+
[".cs", "csharp"], [".kt", "kotlin"], [".kts", "kotlin"], [".swift", "swift"], [".rb", "ruby"], [".rake", "ruby"], [".php", "php"],
|
|
7
|
+
[".sh", "shell"], [".bash", "shell"], [".zsh", "shell"], [".fish", "shell"], [".ksh", "shell"], [".csh", "shell"], [".sql", "sql"],
|
|
8
|
+
[".html", "html"], [".htm", "html"], [".xhtml", "html"], [".xml", "xml"], [".svg", "xml"], [".css", "css"], [".scss", "scss"], [".sass", "scss"],
|
|
9
|
+
[".yaml", "yaml"], [".yml", "yaml"], [".toml", "toml"], [".json", "json"], [".jsonc", "jsonc"], [".hcl", "hcl"], [".tf", "terraform"], [".tfvars", "terraform"],
|
|
10
|
+
[".mk", "makefile"], [".mak", "makefile"], [".ini", "ini"], [".cfg", "ini"], [".conf", "ini"],
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const specialLanguages = new Map([["dockerfile", "dockerfile"], ["makefile", "makefile"]]);
|
|
14
|
+
|
|
15
|
+
const aliases = new Map([
|
|
16
|
+
["c++", "cpp"], ["c#", "csharp"], ["cs", "csharp"], ["js", "javascript"], ["jsx", "javascript"], ["ts", "typescript"], ["tsx", "typescript"],
|
|
17
|
+
["sh", "shell"], ["bash", "shell"], ["hcl", "hcl"], ["tf", "terraform"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function lookup(filePath) {
|
|
21
|
+
const normalized = String(filePath).replaceAll("\\", "/").toLowerCase();
|
|
22
|
+
const base = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
23
|
+
if (specialLanguages.has(base)) return specialLanguages.get(base);
|
|
24
|
+
const dot = base.lastIndexOf(".");
|
|
25
|
+
return extensionLanguages.get(dot < 0 ? "" : base.slice(dot)) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function supported() {
|
|
29
|
+
return [...new Set([...extensionLanguages.values(), ...specialLanguages.values()])].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseSelection(values = []) {
|
|
33
|
+
const selection = new Set();
|
|
34
|
+
for (const value of values) {
|
|
35
|
+
for (const raw of String(value).split(",")) {
|
|
36
|
+
const name = raw.trim().toLowerCase();
|
|
37
|
+
if (!name) continue;
|
|
38
|
+
const language = aliases.get(name) || name;
|
|
39
|
+
if (!supported().includes(language)) throw new Error(`unsupported language ${JSON.stringify(raw.trim())}`);
|
|
40
|
+
selection.add(language);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return selection;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const extensions = Object.freeze(Object.fromEntries(extensionLanguages));
|
|
47
|
+
export const languageAliases = Object.freeze(Object.fromEntries(aliases));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { main } from "./hook-launcher.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
6
|
+
main().then((code) => {
|
|
7
|
+
process.exitCode = code;
|
|
8
|
+
});
|
|
9
|
+
}
|
package/src/model.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const CATEGORIES = Object.freeze({
|
|
2
|
+
ORDINARY: "ordinary",
|
|
3
|
+
DOCUMENTATION: "documentation",
|
|
4
|
+
HEADER: "header",
|
|
5
|
+
DIRECTIVE: "directive",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CATEGORIES = Object.freeze([
|
|
9
|
+
CATEGORIES.ORDINARY,
|
|
10
|
+
CATEGORIES.DOCUMENTATION,
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function position(line, column) {
|
|
14
|
+
return { line, column };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sourceRange(start, end) {
|
|
18
|
+
return { start, end };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function finding(path, language, category, range, text) {
|
|
22
|
+
return { path, language, category, range, text };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function result(findings = [], summary = {}) {
|
|
26
|
+
return {
|
|
27
|
+
findings,
|
|
28
|
+
summary: {
|
|
29
|
+
files_scanned: summary.files_scanned ?? 0,
|
|
30
|
+
files_skipped: summary.files_skipped ?? 0,
|
|
31
|
+
findings: summary.findings ?? findings.length,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function exitCode(findings = [], error = null) {
|
|
37
|
+
if (error) return 2;
|
|
38
|
+
return findings.length > 0 ? 1 : 0;
|
|
39
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function renderJSON(result) {
|
|
2
|
+
return `${JSON.stringify(result)}\n`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function renderText(result) {
|
|
6
|
+
return result.findings.map((item) => `${item.path}:${item.range.start.line}:${item.range.start.column}: ${item.text}`).join("\n") + (result.findings.length ? "\n" : "");
|
|
7
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { CATEGORIES, finding, position, sourceRange } from "./model.js";
|
|
2
|
+
|
|
3
|
+
const syntax = {
|
|
4
|
+
c: { line: ["//"], block: [["/*", "*/"]] }, cpp: { line: ["//"], block: [["/*", "*/"]] }, csharp: { line: ["//"], block: [["/*", "*/"]] },
|
|
5
|
+
go: { line: ["//"], block: [["/*", "*/"]] }, java: { line: ["//"], block: [["/*", "*/"]] }, javascript: { line: ["//"], block: [["/*", "*/"]] },
|
|
6
|
+
jsonc: { line: ["//"], block: [["/*", "*/"]] }, kotlin: { line: ["//"], block: [["/*", "*/"]] }, rust: { line: ["//"], block: [["/*", "*/"]] }, swift: { line: ["//"], block: [["/*", "*/"]] },
|
|
7
|
+
typescript: { line: ["//"], block: [["/*", "*/"]] }, php: { line: ["//", "#"], block: [["/*", "*/"]] },
|
|
8
|
+
python: { line: ["#"] }, ruby: { line: ["#"] }, shell: { line: ["#"] }, yaml: { line: ["#"] }, toml: { line: ["#"] }, dockerfile: { line: ["#"] }, makefile: { line: ["#"] },
|
|
9
|
+
ini: { line: ["#", ";"] }, sql: { line: ["--", "#"], block: [["/*", "*/"]] }, html: { block: [["<!--", "-->"]] }, xml: { block: [["<!--", "-->"]] },
|
|
10
|
+
css: { block: [["/*", "*/"]] }, scss: { line: ["//"], block: [["/*", "*/"]] }, hcl: { line: ["#", "//"], block: [["/*", "*/"]] }, terraform: { line: ["#", "//"], block: [["/*", "*/"]] },
|
|
11
|
+
json: { line: [], block: [] },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function lineColumn(source, offset) {
|
|
15
|
+
const prefix = source.slice(0, offset);
|
|
16
|
+
const line = (prefix.match(/\n/g) || []).length + 1;
|
|
17
|
+
const lastBreak = Math.max(prefix.lastIndexOf("\n"), prefix.lastIndexOf("\r"));
|
|
18
|
+
return position(line, offset - lastBreak);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function escaped(source, offset) {
|
|
22
|
+
let count = 0;
|
|
23
|
+
for (let index = offset - 1; index >= 0 && source[index] === "\\"; index -= 1) count += 1;
|
|
24
|
+
return count % 2 === 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classify(text) {
|
|
28
|
+
const trimmed = text.trim();
|
|
29
|
+
const lower = trimmed.toLowerCase();
|
|
30
|
+
if (trimmed.startsWith("///") || trimmed.startsWith("//!") || trimmed.startsWith("/**") || trimmed.startsWith("<!---")) return CATEGORIES.DOCUMENTATION;
|
|
31
|
+
if (trimmed.startsWith("#!") || lower.startsWith("# syntax=") || lower.startsWith("# escape=")) return CATEGORIES.DIRECTIVE;
|
|
32
|
+
if (["go:", "cgo", "eslint", "ts-ignore", "ts-expect-error", "prettier-ignore", "nolint", "noinspection", "shellcheck", "yamllint", "yaml-language-server", "coding:", "type: ignore", "swift-tools-version", "region", "endregion"].some((marker) => lower.includes(marker))) return CATEGORIES.DIRECTIVE;
|
|
33
|
+
if (["copyright", "spdx-license", "licensed", "license", "generated by", "code generated", "auto-generated"].some((marker) => lower.includes(marker))) return CATEGORIES.HEADER;
|
|
34
|
+
return CATEGORIES.ORDINARY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function addFinding(findings, source, filePath, language, start, end, categories) {
|
|
38
|
+
const text = source.slice(start, end).replace(/[\r\n]+$/, "");
|
|
39
|
+
if (!text) return;
|
|
40
|
+
const category = classify(text);
|
|
41
|
+
if (!categories.has(category)) return;
|
|
42
|
+
findings.push(finding(filePath, language, category, sourceRange(lineColumn(source, start), lineColumn(source, start + text.length)), text));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skipQuoted(source, index, quote) {
|
|
46
|
+
for (let cursor = index + quote.length; cursor < source.length; cursor += 1) {
|
|
47
|
+
if (source.startsWith(quote, cursor) && !escaped(source, cursor)) return cursor + quote.length;
|
|
48
|
+
if (source[cursor] === "\n" && quote !== "`" && quote !== "'''" && quote !== '"""') return cursor;
|
|
49
|
+
}
|
|
50
|
+
return source.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skipDelimited(source, index, opener, closer, nested = false) {
|
|
54
|
+
let depth = 1;
|
|
55
|
+
for (let cursor = index + opener.length; cursor < source.length;) {
|
|
56
|
+
if (nested && source.startsWith(opener, cursor)) { depth += 1; cursor += opener.length; continue; }
|
|
57
|
+
if (source.startsWith(closer, cursor)) { depth -= 1; cursor += closer.length; if (depth === 0) return cursor; continue; }
|
|
58
|
+
cursor += 1;
|
|
59
|
+
}
|
|
60
|
+
return source.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function skipRawString(source, index, language) {
|
|
64
|
+
if (language === "cpp" && source[index] === "R" && source[index + 1] === '"') {
|
|
65
|
+
const open = source.indexOf("(", index + 2);
|
|
66
|
+
if (open >= 0 && open - index <= 16) {
|
|
67
|
+
const delimiter = source.slice(index + 2, open);
|
|
68
|
+
if (!/[\s()\\]/.test(delimiter)) {
|
|
69
|
+
const close = source.indexOf(`)${delimiter}"`, open + 1);
|
|
70
|
+
return close < 0 ? source.length : close + delimiter.length + 2;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (language !== "rust" || !/[rR]/.test(source[index] || "")) return null;
|
|
75
|
+
const match = source.slice(index).match(/^[rR](#+)"/);
|
|
76
|
+
if (!match) return null;
|
|
77
|
+
const close = source.indexOf(`"${match[1]}`, index + match[0].length);
|
|
78
|
+
return close < 0 ? source.length : close + match[1].length + 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function skipHashString(source, index, language) {
|
|
82
|
+
if (language !== "swift" || source[index] !== "#") return null;
|
|
83
|
+
const hashes = source.slice(index).match(/^#+(?=")/)?.[0];
|
|
84
|
+
if (!hashes) return null;
|
|
85
|
+
const opening = `${hashes}"`;
|
|
86
|
+
const close = source.indexOf(`"${hashes}`, index + opening.length);
|
|
87
|
+
return close < 0 ? source.length : close + hashes.length + 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function skipCSharpRawString(source, index, language) {
|
|
91
|
+
if (language !== "csharp" || source[index] !== '"') return null;
|
|
92
|
+
const opening = source.slice(index).match(/^"{3,}/)?.[0];
|
|
93
|
+
if (!opening) return null;
|
|
94
|
+
const close = source.slice(index + opening.length).match(/"+/g);
|
|
95
|
+
if (!close) return source.length;
|
|
96
|
+
let cursor = index + opening.length;
|
|
97
|
+
for (const run of close) {
|
|
98
|
+
cursor = source.indexOf(run, cursor);
|
|
99
|
+
if (run.length >= opening.length) return cursor + run.length;
|
|
100
|
+
cursor += run.length;
|
|
101
|
+
}
|
|
102
|
+
return source.length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stringAt(source, index, language) {
|
|
106
|
+
const raw = skipRawString(source, index, language) ?? skipHashString(source, index, language) ?? skipCSharpRawString(source, index, language);
|
|
107
|
+
if (raw !== null) return raw;
|
|
108
|
+
for (const quote of ["'''", '"""']) if (source.startsWith(quote, index)) return skipQuoted(source, index, quote);
|
|
109
|
+
if (language === "csharp" && (source.startsWith('@"', index) || source.startsWith('$@"', index) || source.startsWith('@$"', index))) return skipQuoted(source, index + (source[index] === "$" ? 2 : 1), '"');
|
|
110
|
+
if (language === "javascript" || language === "typescript") {
|
|
111
|
+
if (source[index] === "`" || source[index] === '"' || source[index] === "'") return skipQuoted(source, index, source[index]);
|
|
112
|
+
}
|
|
113
|
+
if (source[index] === '"' || source[index] === "'") return skipQuoted(source, index, source[index]);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function lineEnd(source, index) {
|
|
118
|
+
const end = source.slice(index).search(/[\r\n]/);
|
|
119
|
+
return end < 0 ? source.length : index + end;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function lineStart(source, index) { return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r"; }
|
|
123
|
+
|
|
124
|
+
function heredocEnd(source, index, marker, stripTabs = false) {
|
|
125
|
+
let cursor = lineEnd(source, index);
|
|
126
|
+
while (cursor < source.length) {
|
|
127
|
+
cursor += 1;
|
|
128
|
+
const end = lineEnd(source, cursor);
|
|
129
|
+
let value = source.slice(cursor, end).replace(/\r$/, "");
|
|
130
|
+
if (stripTabs) value = value.replace(/^\t+/, "");
|
|
131
|
+
if (value === marker) return end < source.length ? end + 1 : end;
|
|
132
|
+
cursor = end;
|
|
133
|
+
}
|
|
134
|
+
return source.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function skipShellHeredocs(source, index) {
|
|
138
|
+
const line = source.slice(index, lineEnd(source, index));
|
|
139
|
+
const markers = [];
|
|
140
|
+
for (let cursor = 0; cursor < line.length; cursor += 1) {
|
|
141
|
+
if (line[cursor] === "\\") { cursor += 1; continue; }
|
|
142
|
+
if (line[cursor] === "'" || line[cursor] === '"') {
|
|
143
|
+
const quote = line[cursor]; cursor += 1;
|
|
144
|
+
while (cursor < line.length && line[cursor] !== quote) { if (line[cursor] === "\\" && quote === '"') cursor += 1; cursor += 1; }
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!line.startsWith("<<", cursor)) continue;
|
|
148
|
+
cursor += 2;
|
|
149
|
+
const stripTabs = line[cursor] === "-";
|
|
150
|
+
if (stripTabs) cursor += 1;
|
|
151
|
+
while (/\s/.test(line[cursor] || "")) cursor += 1;
|
|
152
|
+
let marker = "";
|
|
153
|
+
if (line[cursor] === "'" || line[cursor] === '"') {
|
|
154
|
+
const quote = line[cursor++];
|
|
155
|
+
while (cursor < line.length && line[cursor] !== quote) marker += line[cursor++];
|
|
156
|
+
cursor += 1;
|
|
157
|
+
} else {
|
|
158
|
+
while (cursor < line.length && !/[\s;|&<>]/.test(line[cursor])) marker += line[cursor++];
|
|
159
|
+
}
|
|
160
|
+
if (marker) markers.push({ marker: marker.replaceAll("\\", ""), stripTabs });
|
|
161
|
+
}
|
|
162
|
+
if (!markers.length) return null;
|
|
163
|
+
let cursor = index;
|
|
164
|
+
for (const item of markers) cursor = heredocEnd(source, cursor, item.marker, item.stripTabs);
|
|
165
|
+
return cursor;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function skipYamlBlock(source, index) {
|
|
169
|
+
const line = source.slice(index, lineEnd(source, index));
|
|
170
|
+
if (!/[:]\s*[|>][-+]?\s*(?:#.*)?$/.test(line)) return null;
|
|
171
|
+
const indent = (line.match(/^ */) || [""])[0].length;
|
|
172
|
+
let cursor = lineEnd(source, index);
|
|
173
|
+
while (cursor < source.length) {
|
|
174
|
+
cursor += 1;
|
|
175
|
+
const end = lineEnd(source, cursor);
|
|
176
|
+
const candidate = source.slice(cursor, end);
|
|
177
|
+
if (candidate.trim() && (candidate.match(/^ */) || [""])[0].length <= indent) return cursor;
|
|
178
|
+
cursor = end;
|
|
179
|
+
}
|
|
180
|
+
return source.length;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function skipPhpHeredoc(source, index) {
|
|
184
|
+
const match = source.slice(index).match(/^<<<\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
let cursor = lineEnd(source, index);
|
|
187
|
+
while (cursor < source.length) {
|
|
188
|
+
cursor += 1;
|
|
189
|
+
const end = lineEnd(source, cursor);
|
|
190
|
+
const value = source.slice(cursor, end).trim().replace(/;$/, "");
|
|
191
|
+
if (value === match[2]) return end < source.length ? end + 1 : end;
|
|
192
|
+
cursor = end;
|
|
193
|
+
}
|
|
194
|
+
return source.length;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function scanSource(source, filePath = "<text>", language, categories = new Set([CATEGORIES.ORDINARY, CATEGORIES.DOCUMENTATION])) {
|
|
198
|
+
const spec = syntax[language];
|
|
199
|
+
if (!spec) return [];
|
|
200
|
+
const findings = [];
|
|
201
|
+
let index = 0;
|
|
202
|
+
while (index < source.length) {
|
|
203
|
+
if (lineStart(source, index)) {
|
|
204
|
+
if (language === "shell") { const end = skipShellHeredocs(source, index); if (end !== null) { index = end; continue; } }
|
|
205
|
+
if (language === "yaml") {
|
|
206
|
+
const end = skipYamlBlock(source, index);
|
|
207
|
+
if (end !== null) {
|
|
208
|
+
const currentEnd = lineEnd(source, index);
|
|
209
|
+
const indicator = source.slice(index, currentEnd);
|
|
210
|
+
const comment = indicator.indexOf("#");
|
|
211
|
+
if (comment >= 0) addFinding(findings, source, filePath, language, index + comment, currentEnd, categories);
|
|
212
|
+
index = end;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (language === "ruby" && source.startsWith("=begin", index)) { const end = source.indexOf("\n=end", index + 6); addFinding(findings, source, filePath, language, index, end < 0 ? source.length : end + 6, categories); index = end < 0 ? source.length : end + 6; continue; }
|
|
217
|
+
}
|
|
218
|
+
if (language === "php") { const end = skipPhpHeredoc(source, index); if (end !== null) { index = end; continue; } }
|
|
219
|
+
const stringEnd = stringAt(source, index, language);
|
|
220
|
+
if (stringEnd !== null) { index = stringEnd; continue; }
|
|
221
|
+
let matched = false;
|
|
222
|
+
for (const [start, endMarker] of spec.block || []) if (source.startsWith(start, index)) {
|
|
223
|
+
const end = skipDelimited(source, index, start, endMarker, language === "rust");
|
|
224
|
+
addFinding(findings, source, filePath, language, index, end, categories);
|
|
225
|
+
index = end; matched = true; break;
|
|
226
|
+
}
|
|
227
|
+
if (matched) continue;
|
|
228
|
+
for (const marker of spec.line || []) if (source.startsWith(marker, index)) {
|
|
229
|
+
const previous = source[index - 1];
|
|
230
|
+
if ((language === "shell" || language === "yaml") && marker === "#" && previous && !/[\s;]/.test(previous)) continue;
|
|
231
|
+
if (language === "sql" && marker === "#" && ([">", "<", "-"].includes(previous) || [">", "<"].includes(source[index + 1]))) continue;
|
|
232
|
+
const newline = source.slice(index).search(/[\r\n]/);
|
|
233
|
+
addFinding(findings, source, filePath, language, index, newline < 0 ? source.length : index + newline, categories);
|
|
234
|
+
index = newline < 0 ? source.length : index + newline; matched = true; break;
|
|
235
|
+
}
|
|
236
|
+
if (!matched) index += 1;
|
|
237
|
+
}
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export { classify, lineColumn, syntax };
|
package/src/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_VERSION = "1.0.2";
|