Denies gh pr create and gh pr edit when the PR title fails the repository's commitlint config, before gh runs

Personal collection of Agent Skills, hooks, and plugins for Claude Code, focused on Roblox development.
This started as a fork of antfu/skills. I'm repurposing it for my own workflow but keeping it open source in case others find it useful.
pnpx skills add christopher-buss/skills -skill='*'
Or install everything globally:
pnpx skills add christopher-buss/skills -skill='*' -g
More on the CLI at skills.
Manually written with personal preferences and best practices.
| Skill | Description |
|---|---|
| roblox-ts | TypeScript to Roblox Lua transpiler |
| ecs-design | Best practices for designing Entity Component Systems in Roblox |
Generated from official docs.
| Skill | Description | Source |
|---|---|---|
| jecs | Entity Component System for Roblox | Ukendio/jecs |
| pnpm | Fast, disk-efficient package manager | pnpm/pnpm.io |
| roblox-ts | TypeScript to Roblox Lua transpiler | roblox-ts/roblox-ts |
A Claude Code mod that checks a PR title before gh pr create or gh pr edit runs. A squash merge makes the PR title the commit message, so the title must pass the repository's commitlint config.
For each gh pr create or gh pr edit in a Bash or PowerShell command, it reads the --title, --title=, or -t value and runs the repository's own @commitlint/cli on it. It denies the call when:
gh pr create has no title flag (--fill, --web, the editor, or the prompt);bash -c / pwsh -Command script around gh pr cannot be parsed;pwsh or powershell runs an encoded command (-EncodedCommand, -enc, -e, -ec, and the other spellings PowerShell takes), which hides its script;@commitlint/cli is not installed.Each denial says what it could not check and the command to run instead: the same gh pr command with a literal --title, the install command for the repository's package manager, or a manual commitlint check.
A repository without a commitlint config is not checked. Enable it in settings.json:
{
"extraKnownMarketplaces": {
"isentinel": {
"source": { "source": "github", "repo": "christopher-buss/skills" }
}
},
"enabledPlugins": {
"commitlint-preflight@isentinel": true
}
}
Its .claude-plugin/types/ holds the mods API types that Claude Code writes, so lint and typecheck work from a clone. After a Claude Code upgrade, refresh them and commit the diff:
claude -p --plugin-dir plugins/commitlint-preflight "reply ok"
git add -f plugins/commitlint-preflight/.claude-plugin/types/claude-code{,-tools}/index.d.ts
See AGENTS.md for how skills are generated and maintained.
pnpm installmeta.ts with your projectsnr start cleanup to clear existing submodulesnr start init to clone freshnr start sync for vendored skillsForked from Anthony Fu's skills. The original project's approach of using git submodules to reference source documentation is clever - skills stay current with upstream changes without manual updates.
MIT. Vendored skills keep their original licenses.
hooks/register.ts 135 lines1import type { EngineInterface, Register } from "claude-code";
2
3import { errorReason, failedReason, missingCliReason } from "../src/messages.ts";
4import {
5 ancestors,
6 binEntry,
7 CLI_PACKAGE,
8 COMMITLINT_CONFIG_FILES,
9 hasCommitlintKey,
10 join,
11 LOCK_FILES,
12} from "../src/repo.ts";
13import type { Dialect } from "../src/shell.ts";
14import { findTitleChecks, mayHoldTitle } from "../src/titles.ts";
15
16const TIMEOUT_MS = 30_000;
17
18async function findRoot($: EngineInterface): Promise<string | undefined> {
19 for (const directory of ancestors(await $.session.cwd())) {
20 if (await $.fs.exists(join(directory, ".git"))) {
21 return directory;
22 }
23 }
24
25 return undefined;
26}
27
28async function readText($: EngineInterface, path: string): Promise<string | undefined> {
29 try {
30 return await $.fs.read(path);
31 } catch {
32 return undefined;
33 }
34}
35
36async function usesCommitlint($: EngineInterface, root: string): Promise<boolean> {
37 for (const name of COMMITLINT_CONFIG_FILES) {
38 if (await $.fs.exists(join(root, name))) {
39 return true;
40 }
41 }
42
43 return hasCommitlintKey((await readText($, join(root, "package.json"))) ?? "");
44}
45
46async function cliEntry($: EngineInterface, root: string): Promise<string | undefined> {
47 const packageDirectory = join(root, CLI_PACKAGE);
48 const entry = binEntry((await readText($, join(packageDirectory, "package.json"))) ?? "");
49 return entry !== undefined && (await $.fs.exists(join(packageDirectory, entry)))
50 ? join(packageDirectory, entry)
51 : undefined;
52}
53
54async function installCommand($: EngineInterface, root: string): Promise<string> {
55 for (const [lockfile, install] of LOCK_FILES) {
56 if (await $.fs.exists(join(root, lockfile))) {
57 return install;
58 }
59 }
60
61 return "npm install";
62}
63
64async function lint(
65 $: EngineInterface,
66 root: string,
67 cli: string,
68 title: string,
69): Promise<string | undefined> {
70 try {
71 const { exitCode, stderr, stdout } = await $.process.run(["node", cli], {
72 cwd: root,
73 stdin: title,
74 timeoutMs: TIMEOUT_MS,
75 });
76 return exitCode === 0 ? undefined : failedReason(title, `${stdout}\n${stderr}`.trim());
77 } catch (err) {
78 return errorReason(title, err);
79 }
80}
81
82async function evaluate(
83 $: EngineInterface,
84 command: string,
85 dialect: Dialect,
86): Promise<string | undefined> {
87 const checks = findTitleChecks(command, dialect);
88 const titles: Array<string> = [];
89 for (const check of checks) {
90 if ("reason" in check) {
91 return check.reason;
92 }
93
94 titles.push(check.title);
95 }
96
97 if (titles.length === 0) {
98 return undefined;
99 }
100
101 const root = await findRoot($);
102 if (root === undefined || !(await usesCommitlint($, root))) {
103 return undefined;
104 }
105
106 const cli = await cliEntry($, root);
107 if (cli === undefined) {
108 return missingCliReason(root, await installCommand($, root));
109 }
110
111 for (const title of titles) {
112 const reason = await lint($, root, cli, title);
113 if (reason !== undefined) {
114 return reason;
115 }
116 }
117
118 return undefined;
119}
120
121export const register: Register = (on) => {
122 on("tool.call", { tool: ["Bash", "PowerShell"] }, async ($, e, next) => {
123 if (!mayHoldTitle(e.command)) {
124 return next(e);
125 }
126
127 const reason = await evaluate(
128 $,
129 e.command,
130 e.tool === "Bash" ? "bash" : "powershell",
131 ).catch((err: unknown) => errorReason(undefined, err));
132 return reason === undefined ? next(e) : { deny: reason };
133 });
134};
135src/messages.ts 63 lines1export const PLUGIN_NAME = "commitlint-preflight";
2
3export const TITLE = '--title "<type>(<scope>): <subject>"';
4
5const DIRECT = `Run \`gh pr …\` directly, with a literal ${TITLE}.`;
6const ESCAPED_QUOTE = String.raw`'\''`;
7
8/**
9 * Quotes a word so a shell reads it back as the same text.
10 *
11 * @param text - The word.
12 * @returns The word, in single quotes when it needs them.
13 */
14export function quote(text: string): string {
15 return /^[\w%+,./:=@-]+$/u.test(text) ? text : `'${text.replaceAll("'", ESCAPED_QUOTE)}'`;
16}
17
18/**
19 * Why a title cannot be read, with the command to run instead.
20 *
21 * @param problem - What is wrong with the title.
22 * @param args - The words after `gh`, less the title and the flags that replace it.
23 * @returns A denial that names the problem and gives the command with a
24 * literal title.
25 */
26export function titleReason(problem: string, args: ReadonlyArray<string>): string {
27 return `${PLUGIN_NAME}: the PR title ${problem}, so commitlint cannot check it. Run instead:\ngh ${[...args, TITLE].join(" ")}`;
28}
29
30export function unknownFlagReason(flag: string, help: string): string {
31 return `${PLUGIN_NAME}: gh flag \`${flag}\` is unknown here, so the PR title cannot be read. Check \`${help} --help\`, or drop the flag.`;
32}
33
34export function missingValueReason(flag: string): string {
35 return `${PLUGIN_NAME}: gh flag \`${flag}\` has no value, so the PR title cannot be read. Give \`${flag}\` its value, or drop it.`;
36}
37
38export function wrapperReason(wrapper: string): string {
39 return `${PLUGIN_NAME}: \`${wrapper}\` wraps a \`gh pr\` command with options this mod cannot parse, so its title cannot be checked. ${DIRECT.replace("directly", "directly, without the wrapper")}`;
40}
41
42export function scriptReason(shell: string): string {
43 return `${PLUGIN_NAME}: the script \`${shell}\` runs is not a fixed string, so a \`gh pr\` title in it cannot be checked. ${DIRECT}`;
44}
45
46export function encodedReason(shell: string): string {
47 return `${PLUGIN_NAME}: \`${shell} -EncodedCommand\` hides its script, so a \`gh pr\` title in it cannot be checked. Run the script as plain text: the PowerShell tool, or \`${shell} -Command "<script>"\`.`;
48}
49
50export function missingCliReason(root: string, install: string): string {
51 return `${PLUGIN_NAME}: ${root} configures commitlint but @commitlint/cli is not installed, so the PR title cannot be checked. Run \`${install}\` in ${root}, then the same command.`;
52}
53
54export function failedReason(title: string, output: string): string {
55 return `${PLUGIN_NAME}: commitlint rejects the PR title "${title}" (a squash merge makes it the commit message):\n${output}\nFix the title as that output says (e.g. shorten it to the limit it names), and keep the rest of the command.`;
56}
57
58export function errorReason(title: string | undefined, error: unknown): string {
59 const message = error instanceof Error ? error.message : String(error);
60 const shown = title === undefined ? "the PR title" : `the PR title "${title}"`;
61 return `${PLUGIN_NAME}: commitlint did not run on ${shown}: ${message}\nRetry the command, or check the title by hand: printf '%s\\n' ${quote(title ?? "<title>")} | npx commitlint`;
62}
63src/repo.ts 84 lines1/** The config file names commitlint reads at a repository root. */
2export const COMMITLINT_CONFIG_FILES: ReadonlyArray<string> = [
3 ".commitlintrc",
4 ...["json", "yaml", "yml", "js", "cjs", "mjs", "ts", "cts", "mts"].map(
5 (extension) => `.commitlintrc.${extension}`,
6 ),
7 ...["js", "cjs", "mjs", "ts", "cts", "mts"].map(
8 (extension) => `commitlint.config.${extension}`,
9 ),
10];
11
12export const CLI_PACKAGE = "node_modules/@commitlint/cli";
13
14/**
15 * The directory and each of its parents, nearest first, with `/` separators.
16 *
17 * @param directory - An absolute directory, either separator.
18 * @returns The directory, then each parent up to the root.
19 */
20export function ancestors(directory: string): Array<string> {
21 const parts = directory.replaceAll("\\", "/").replace(/\/+$/u, "").split("/");
22 const result: Array<string> = [];
23 for (let { length } = parts; length > 0; length--) {
24 const path = parts.slice(0, length).join("/");
25 result.push(path === "" || path.endsWith(":") ? `${path}/` : path);
26 }
27
28 return result;
29}
30
31export function join(directory: string, path: string): string {
32 return `${directory.replace(/\/$/u, "")}/${path.replace(/^\.\//u, "")}`;
33}
34
35/**
36 * Whether a `package.json` text configures commitlint under a `commitlint` key.
37 *
38 * @param packageJson - The text of a `package.json`.
39 * @returns True when the key is there.
40 */
41export function hasCommitlintKey(packageJson: string): boolean {
42 const manifest = parseObject(packageJson);
43 return manifest !== undefined && Object.hasOwn(manifest, "commitlint");
44}
45
46/**
47 * The CLI entry a package's `package.json` names in `bin`.
48 *
49 * @param packageJson - The text of the package's `package.json`.
50 * @returns The entry, relative to the package, if `bin` names one.
51 */
52export function binEntry(packageJson: string): string | undefined {
53 const bin = parseObject(packageJson)?.["bin"];
54 if (typeof bin === "string") {
55 return bin;
56 }
57
58 const named =
59 typeof bin === "object" && bin !== null
60 ? (bin as Record<string, unknown>)["commitlint"]
61 : undefined;
62 return typeof named === "string" ? named : undefined;
63}
64
65function parseObject(text: string): Record<string, unknown> | undefined {
66 try {
67 const value: unknown = JSON.parse(text);
68 return typeof value === "object" && value !== null
69 ? (value as Record<string, unknown>)
70 : undefined;
71 } catch {
72 return undefined;
73 }
74}
75
76/** Each lockfile with the install command of its package manager. */
77export const LOCK_FILES: ReadonlyArray<readonly [string, string]> = [
78 ["pnpm-lock.yaml", "pnpm install"],
79 ["package-lock.json", "npm install"],
80 ["yarn.lock", "yarn install"],
81 ["bun.lock", "bun install"],
82 ["bun.lockb", "bun install"],
83];
84src/shell.ts 410 lines1export type Dialect = "bash" | "powershell";
2
3/** One shell word: its text with quotes removed, and whether it is not a fixed string. */
4export interface Word {
5 /** The word holds an expansion, glob, redirection, or here-document marker. */
6 dynamic: boolean;
7 text: string;
8 /** A quote in the word never closes. */
9 unclosed: boolean;
10}
11
12interface Lexer {
13 commands: Array<Array<Word>>;
14 depth: number;
15 dialect: Dialect;
16 heredocs: Array<string>;
17 index: number;
18 isBash: boolean;
19 source: string;
20 syntax: Syntax;
21 word: undefined | Word;
22 words: Array<Word>;
23}
24
25interface Syntax {
26 dynamic: ReadonlySet<string>;
27 escape: string;
28 readSingle: (lexer: Lexer, word: Word) => void;
29 separators: ReadonlySet<string>;
30}
31
32const SEPARATORS = new Set(["\n", "&", "(", ")", ";", "|"]);
33const BLANKS = new Set(["\t", "\r", " "]);
34const BASH_DOUBLE_ESCAPES = new Set(["\n", '"', "$", "\\", "`"]);
35const POWERSHELL_ESCAPES = new Set(['"', "$", "'", "`"]);
36const HEREDOC = /^<<-?[\t ]*(?:'([^\n']*)'|"([^\n"]*)"|([^\s&();<>|]+))/u;
37
38/**
39 * Splits shell source into simple commands, each a list of words. Command
40 * separators, comments, quotes, and escapes follow the dialect; here-document
41 * bodies are skipped, so their text never reads as a command.
42 *
43 * @param source - The shell source.
44 * @param dialect - The shell that runs it.
45 * @returns Each simple command's words, in order.
46 */
47export function splitCommands(source: string, dialect: Dialect): Array<Array<Word>> {
48 const lexer = createLexer(source, dialect, 0, []);
49 lex(lexer, false);
50 return lexer.commands;
51}
52
53function endWord(lexer: Lexer): void {
54 if (lexer.word === undefined) {
55 return;
56 }
57
58 lexer.words.push(lexer.word);
59 lexer.word = undefined;
60}
61
62function endCommand(lexer: Lexer): void {
63 endWord(lexer);
64 if (lexer.words.length > 0) {
65 lexer.commands.push(lexer.words);
66 }
67
68 lexer.words = [];
69}
70
71function lineEnd(source: string, from: number): number {
72 const end = source.indexOf("\n", from);
73 return end === -1 ? source.length : end;
74}
75
76function skipHeredocBodies(lexer: Lexer): void {
77 for (const delimiter of lexer.heredocs) {
78 let isClosed = false;
79 while (!isClosed && lexer.index < lexer.source.length) {
80 const stop = lineEnd(lexer.source, lexer.index);
81 isClosed = lexer.source.slice(lexer.index, stop).replace(/^\t+/u, "") === delimiter;
82 lexer.index = stop + 1;
83 }
84 }
85
86 lexer.heredocs = [];
87}
88
89/**
90 * Handles a comment, blank, or separator.
91 *
92 * @param lexer - The lexer state.
93 * @param char - The character at the lexer's index.
94 * @returns Whether the character was one.
95 */
96function readLayout(lexer: Lexer, char: string): boolean {
97 if (char === "#" && lexer.word === undefined) {
98 lexer.index = lineEnd(lexer.source, lexer.index);
99 } else if (BLANKS.has(char)) {
100 endWord(lexer);
101 lexer.index += 1;
102 } else if (lexer.syntax.separators.has(char)) {
103 endCommand(lexer);
104 lexer.index += 1;
105 if (char === "\n") {
106 skipHeredocBodies(lexer);
107 }
108 } else {
109 return false;
110 }
111
112 return true;
113}
114
115function current(lexer: Lexer): Word {
116 lexer.word ??= { dynamic: false, text: "", unclosed: false };
117 return lexer.word;
118}
119
120function readPlain(lexer: Lexer, char: string): void {
121 const word = current(lexer);
122 word.dynamic ||= lexer.syntax.dynamic.has(char);
123 word.text += char;
124 lexer.index += 1;
125}
126
127/**
128 * Reads commands until the source ends or, when nested, until the `)` that
129 * closes the substitution.
130 *
131 * @param lexer - The lexer state.
132 * @param isNested - Whether the lexer reads a `$( … )` body.
133 * @returns Whether a nested body closes.
134 */
135function lex(lexer: Lexer, isNested: boolean): boolean {
136 while (lexer.index < lexer.source.length) {
137 const char = lexer.source.charAt(lexer.index);
138 if (isNested && char === ")" && lexer.depth === 0) {
139 endCommand(lexer);
140 lexer.index += 1;
141 return true;
142 }
143
144 lexer.depth = Math.max(0, lexer.depth + Number(char === "(") - Number(char === ")"));
145 if (!readLayout(lexer, char) && !readQuoted(lexer, char)) {
146 readPlain(lexer, char);
147 }
148 }
149
150 endCommand(lexer);
151 return !isNested;
152}
153
154function readHeredocMarker(lexer: Lexer): void {
155 const match = HEREDOC.exec(lexer.source.slice(lexer.index));
156 const word = current(lexer);
157 word.dynamic = true;
158 if (match === null) {
159 word.text += "<<";
160 lexer.index += 2;
161 return;
162 }
163
164 word.text += match[0];
165 lexer.heredocs.push(match[0].replace(/^<<-?\s*/u, "").replaceAll(/["']/gu, ""));
166 lexer.index += match[0].length;
167}
168
169function readBashSingle(lexer: Lexer, word: Word): void {
170 const close = lexer.source.indexOf("'", lexer.index + 1);
171 word.unclosed = close === -1;
172 const end = word.unclosed ? lexer.source.length : close;
173 word.text += lexer.source.slice(lexer.index + 1, end);
174 lexer.index = end + 1;
175}
176
177function readPowerShellSingle(lexer: Lexer, word: Word): void {
178 const { source } = lexer;
179 let index = lexer.index + 1;
180 while (index < source.length) {
181 const char = source.charAt(index);
182 const isDoubled = char === "'" && source.charAt(index + 1) === "'";
183 if (char === "'" && !isDoubled) {
184 lexer.index = index + 1;
185 return;
186 }
187
188 word.text += char;
189 index += isDoubled ? 2 : 1;
190 }
191
192 word.unclosed = true;
193 lexer.index = index;
194}
195
196/**
197 * Reads one unit inside double quotes.
198 *
199 * @param lexer - The lexer state.
200 * @param word - The word to append to.
201 * @param index - Where the unit starts.
202 * @returns How many characters the unit took.
203 */
204function readDoubleUnit(lexer: Lexer, word: Word, index: number): number {
205 const char = lexer.source.charAt(index);
206 const next = lexer.source.charAt(index + 1);
207 if (lexer.isBash && char === "\\" && BASH_DOUBLE_ESCAPES.has(next)) {
208 word.text += next === "\n" ? "" : next;
209 return 2;
210 }
211
212 if (!lexer.isBash && char === "`") {
213 word.dynamic ||= !POWERSHELL_ESCAPES.has(next);
214 word.text += next;
215 return 2;
216 }
217
218 if (!lexer.isBash && char === '"' && next === '"') {
219 word.text += '"';
220 return 2;
221 }
222
223 if (char === "$" && next === "(") {
224 return readSubstitution(lexer, word, index);
225 }
226
227 if (lexer.isBash && char === "`") {
228 return readBackticks(lexer, word, index);
229 }
230
231 word.dynamic ||= char === "$";
232 word.text += char;
233 return 1;
234}
235
236/**
237 * Reads `$(( … ))` as arithmetic, or `$( … )`, whose commands join the list.
238 *
239 * @param lexer - The lexer state.
240 * @param word - The word the substitution is part of.
241 * @param index - Where the `$` is.
242 * @returns How many characters the substitution took.
243 */
244function readSubstitution(lexer: Lexer, word: Word, index: number): number {
245 word.dynamic = true;
246 if (lexer.isBash && lexer.source.startsWith("$((", index)) {
247 let depth = 2;
248 let end = index + 3;
249 for (; depth > 0 && end < lexer.source.length; end++) {
250 const char = lexer.source.charAt(end);
251 depth += Number(char === "(") - Number(char === ")");
252 }
253
254 word.unclosed ||= depth > 0;
255 return end - index;
256 }
257
258 const body = createLexer(lexer.source, lexer.dialect, index + 2, lexer.commands);
259 word.unclosed ||= !lex(body, true);
260 return body.index - index;
261}
262
263/**
264 * Reads a backtick substitution, whose commands join the list.
265 *
266 * @param lexer - The lexer state.
267 * @param word - The word the substitution is part of.
268 * @param index - Where the opening backtick is.
269 * @returns How many characters the substitution took.
270 */
271function readBackticks(lexer: Lexer, word: Word, index: number): number {
272 let body = "";
273 let end = index + 1;
274 while (end < lexer.source.length && lexer.source.charAt(end) !== "`") {
275 const char = lexer.source.charAt(end);
276 const next = lexer.source.charAt(end + 1);
277 const isEscape = char === "\\" && ["$", "\\", "`"].includes(next);
278 body += isEscape ? next : char;
279 end += isEscape ? 2 : 1;
280 }
281
282 word.dynamic = true;
283 word.unclosed ||= end >= lexer.source.length;
284 lexer.commands.push(...splitCommands(body, "bash"));
285 return end + 1 - index;
286}
287
288function isDoubleClose(lexer: Lexer, index: number): boolean {
289 return (
290 lexer.source.charAt(index) === '"' &&
291 (lexer.isBash || lexer.source.charAt(index + 1) !== '"')
292 );
293}
294
295function readDouble(lexer: Lexer, word: Word): void {
296 let index = lexer.index + 1;
297 while (index < lexer.source.length) {
298 if (isDoubleClose(lexer, index)) {
299 lexer.index = index + 1;
300 return;
301 }
302
303 index += readDoubleUnit(lexer, word, index);
304 }
305
306 word.unclosed = true;
307 lexer.index = index;
308}
309
310function readHereString(lexer: Lexer, word: Word): void {
311 const quote = lexer.source.charAt(lexer.index + 1);
312 const close = lexer.source.indexOf(`\n${quote}@`, lexer.index + 2);
313 word.dynamic = true;
314 word.unclosed = close === -1;
315 lexer.index = word.unclosed ? lexer.source.length : close + 3;
316}
317
318function readEscape(lexer: Lexer): void {
319 const next = lexer.source.charAt(lexer.index + 1);
320 if (next !== "\n") {
321 current(lexer).text += next;
322 }
323
324 lexer.index += 2;
325}
326
327function isHereString(lexer: Lexer, char: string): boolean {
328 return (
329 !lexer.isBash &&
330 char === "@" &&
331 lexer.word === undefined &&
332 /^@["']\r?\n/u.test(lexer.source.slice(lexer.index))
333 );
334}
335
336/**
337 * Handles a quote, escape, or here-document.
338 *
339 * @param lexer - The lexer state.
340 * @param char - The character at the lexer's index.
341 * @returns Whether the character began one.
342 */
343function readQuoted(lexer: Lexer, char: string): boolean {
344 switch (char) {
345 case '"': {
346 readDouble(lexer, current(lexer));
347
348 break;
349 }
350 case "'": {
351 lexer.syntax.readSingle(lexer, current(lexer));
352
353 break;
354 }
355 case lexer.syntax.escape: {
356 readEscape(lexer);
357
358 break;
359 }
360 default: {
361 if (lexer.source.startsWith("$(", lexer.index) || (lexer.isBash && char === "`")) {
362 lexer.index += readDoubleUnit(lexer, current(lexer), lexer.index);
363 } else if (lexer.isBash && lexer.source.startsWith("<<", lexer.index)) {
364 readHeredocMarker(lexer);
365 } else if (isHereString(lexer, char)) {
366 readHereString(lexer, current(lexer));
367 } else {
368 return false;
369 }
370 }
371 }
372
373 return true;
374}
375
376const SYNTAX = {
377 bash: {
378 dynamic: new Set(["$", "*", "<", ">", "?", "[", "`", "{", "~"]),
379 escape: "\\",
380 readSingle: readBashSingle,
381 separators: SEPARATORS,
382 },
383 powershell: {
384 dynamic: new Set(["$", "<", ">", "@"]),
385 escape: "`",
386 readSingle: readPowerShellSingle,
387 separators: new Set([...SEPARATORS, "{", "}"]),
388 },
389} satisfies Record<Dialect, Syntax>;
390
391function createLexer(
392 source: string,
393 dialect: Dialect,
394 index: number,
395 commands: Array<Array<Word>>,
396): Lexer {
397 return {
398 commands,
399 depth: 0,
400 dialect,
401 heredocs: [],
402 index,
403 isBash: dialect === "bash",
404 source,
405 syntax: SYNTAX[dialect],
406 word: undefined,
407 words: [],
408 };
409}
410src/titles.ts 412 lines1// cspell:ignore encodedcommand
2import {
3 encodedReason,
4 missingValueReason,
5 quote,
6 scriptReason,
7 titleReason,
8 unknownFlagReason,
9 wrapperReason,
10} from "./messages.ts";
11import type { Dialect, Word } from "./shell.ts";
12import { splitCommands } from "./shell.ts";
13
14export type TitleCheck = { reason: string } | { title: string };
15
16interface FlagTable {
17 /** Long flag names, each mapped to whether it takes a value. */
18 long: ReadonlyMap<string, boolean>;
19 short: ReadonlyMap<string, string>;
20}
21
22interface Flag {
23 name: string;
24 skip: number;
25 value: null | undefined | Word;
26}
27
28const ASSIGNMENT = /^[A-Z_a-z]\w*=/u;
29const PREFIXES = new Set(["!", "do", "else", "then", "{"]);
30const WRAPPERS = new Set(["command", "env", "exec", "nohup", "sudo", "time"]);
31const GH = /(?:^|[/\\])gh(?:\.exe)?$/iu;
32const BASH = /(?:^|[/\\])(?:ba)?sh(?:\.exe)?$/iu;
33const BASH_COMMAND = /^-[a-z]*c[a-z]*$/u;
34const POWERSHELL = /(?:^|[/\\])(?:pwsh|powershell)(?:\.exe)?$/iu;
35const POWERSHELL_FILE = /^-f(?:i(?:le?)?)?$/iu;
36const POWERSHELL_COMMAND = /^-c(?:o(?:m(?:m(?:a(?:nd?)?)?)?)?)?$/iu;
37const FILL = new Set(["--fill", "--fill-first", "--fill-verbose", "-f"]);
38const WEB = new Set(["--web", "-w"]);
39const ENCODED = "encodedcommand";
40
41/** The root flags of gh, and the ones every `gh pr` subcommand inherits. */
42const ROOT = "R/repo= help version";
43
44/** From `gh pr create --help` and `gh pr edit --help`; `=` marks a value. */
45const SUBCOMMAND_FLAGS = {
46 create: flagTable(
47 `${ROOT} a/assignee= B/base= b/body= F/body-file= d/draft dry-run e/editor f/fill fill-first fill-verbose H/head= l/label= m/milestone= no-maintainer-edit p/project= recover= r/reviewer= T/template= t/title= w/web`,
48 ),
49 edit: flagTable(
50 `${ROOT} add-assignee= add-label= add-project= add-reviewer= B/base= b/body= F/body-file= m/milestone= remove-assignee= remove-label= remove-milestone remove-project= remove-reviewer= t/title=`,
51 ),
52};
53
54const ROOT_FLAGS = flagTable(ROOT);
55const ALIASES = new Map<string, keyof typeof SUBCOMMAND_FLAGS>([
56 ["create", "create"],
57 ["edit", "edit"],
58 ["new", "create"],
59]);
60
61/**
62 * Cheap test that a command may hold a `gh pr` invocation.
63 *
64 * @param command - The shell command.
65 * @returns True for every command that holds one.
66 */
67export function mentionsGhPr(command: string): boolean {
68 return /gh[\s\S]*pr/u.test(command);
69}
70
71/**
72 * Cheap test that a command needs a closer look: a `gh pr` invocation, or a
73 * PowerShell call whose script may hide one.
74 *
75 * @param command - The shell command.
76 * @returns True for every command that may hold a title.
77 */
78export function mayHoldTitle(command: string): boolean {
79 return mentionsGhPr(command) || /pwsh|powershell/iu.test(command);
80}
81
82/**
83 * One check per `gh pr create` or `gh pr edit` in the command. A `gh pr edit`
84 * without a title flag has nothing to check and yields none.
85 *
86 * @param command - The shell command.
87 * @param dialect - The shell that runs it.
88 * @returns Each literal title, or why one cannot be read statically.
89 */
90export function findTitleChecks(command: string, dialect: Dialect): Array<TitleCheck> {
91 return splitCommands(command, dialect).flatMap((words) => checkCommand(words, command));
92}
93
94function flagTable(spec: string): FlagTable {
95 const long = new Map<string, boolean>();
96 const short = new Map<string, string>();
97 for (const entry of spec.split(" ")) {
98 const name = entry.replace(/^\w\//u, "").replace(/=$/u, "");
99 long.set(name, entry.endsWith("="));
100 if (entry.charAt(1) === "/") {
101 short.set(entry.charAt(0), name);
102 }
103 }
104
105 return { long, short };
106}
107
108function readLongFlag(word: Word, following: null | Word, table: FlagTable): Flag | undefined {
109 const [name = "", ...values] = word.text.slice(2).split("=");
110 const hasValue = table.long.get(name);
111 if (hasValue === undefined) {
112 return undefined;
113 }
114
115 if (!hasValue || values.length > 0) {
116 return { name: hasValue ? name : "", skip: 1, value: { ...word, text: values.join("=") } };
117 }
118
119 return { name, skip: 2, value: following };
120}
121
122function readShortFlags(word: Word, following: null | Word, table: FlagTable): Flag | undefined {
123 for (let offset = 1; offset < word.text.length; offset++) {
124 const name = table.short.get(word.text.charAt(offset));
125 if (name === undefined) {
126 return undefined;
127 }
128
129 const rest = word.text.slice(offset + 1);
130 if (table.long.get(name) === true) {
131 return rest === ""
132 ? { name, skip: 2, value: following }
133 : { name, skip: 1, value: { ...word, text: rest.replace(/^=/u, "") } };
134 }
135
136 if (rest.startsWith("=")) {
137 break;
138 }
139 }
140
141 return { name: "", skip: 1, value: undefined };
142}
143
144/**
145 * Reads one flag word the way pflag does: `--name=value`, `--name value`, or a
146 * run of shorthands where a value flag takes the rest of the word (less a
147 * leading `=`) or the next word.
148 *
149 * @param word - A word that starts with `-`.
150 * @param following - The word after it, if any.
151 * @param table - The flags the command takes.
152 * @returns The last value flag read and how many words it took, or
153 * `undefined` for an unknown flag.
154 */
155function readFlag(word: Word, following: null | Word, table: FlagTable): Flag | undefined {
156 return word.text.startsWith("--")
157 ? readLongFlag(word, following, table)
158 : readShortFlags(word, following, table);
159}
160
161function isFlag(text: string): boolean {
162 return text.startsWith("-") && text !== "-";
163}
164
165/**
166 * Finds the subcommand after gh's root flags.
167 *
168 * @param args - The words after `gh`.
169 * @returns The subcommand and where its arguments start, `undefined` for no
170 * `gh pr create` or `gh pr edit`, or why the flags cannot be read.
171 */
172function parseRoot(
173 args: ReadonlyArray<Word>,
174): TitleCheck | undefined | { start: number; subcommand: keyof typeof SUBCOMMAND_FLAGS } {
175 const positionals: Array<string> = [];
176 let index = 0;
177 for (let word = args[0]; word !== undefined && positionals.length < 2; word = args[index]) {
178 const flag = isFlag(word.text)
179 ? readFlag(word, args[index + 1] ?? null, ROOT_FLAGS)
180 : { name: "", skip: 1, value: undefined };
181 if (flag === undefined) {
182 return { reason: unknownFlagReason(word.text, "gh") };
183 }
184
185 positionals.push(...(isFlag(word.text) ? [] : [word.text]));
186 index += flag.skip;
187 }
188
189 const subcommand = ALIASES.get(positionals[1] ?? "");
190 return positionals[0] === "pr" && subcommand !== undefined
191 ? { start: index, subcommand }
192 : undefined;
193}
194
195function missingProblem(words: ReadonlyArray<string>): string {
196 const fill = words.find((text) => FILL.has(text));
197 if (fill !== undefined) {
198 return `is not written out (${fill} takes it from the commit subject)`;
199 }
200
201 const web = words.find((text) => WEB.has(text));
202 return web === undefined
203 ? "is not written out (gh would prompt for it)"
204 : `is not written out (${web} sets it in the browser)`;
205}
206
207function titleProblem(
208 subcommand: string,
209 word: null | undefined | Word,
210 words: ReadonlyArray<string>,
211): string | undefined {
212 if (word === undefined) {
213 return subcommand === "create" ? missingProblem(words) : undefined;
214 }
215
216 if (word === null) {
217 return "flag has no value";
218 }
219
220 if (word.unclosed) {
221 return "has a quote that does not close";
222 }
223
224 return word.dynamic
225 ? "holds a shell expansion, glob, redirection, or here-document"
226 : undefined;
227}
228
229function render(word: Word): string {
230 return word.dynamic || word.unclosed ? word.text : quote(word.text);
231}
232
233/**
234 * Reads the subcommand's flags as pflag does.
235 *
236 * @param args - The words after `gh`.
237 * @param start - Where the subcommand's arguments start.
238 * @param subcommand - `create` or `edit`, which picks the flag table.
239 * @returns The last title flag's value and every other word, or why the flags
240 * cannot be read.
241 */
242function readSubcommand(
243 args: ReadonlyArray<Word>,
244 start: number,
245 subcommand: keyof typeof SUBCOMMAND_FLAGS,
246): { kept: Array<string>; title: null | undefined | Word } | { reason: string } {
247 const kept = args.slice(0, start).map(render);
248 let title: null | undefined | Word;
249 let index = start;
250 for (let word = args[index]; word !== undefined && word.text !== "--"; word = args[index]) {
251 const flag = isFlag(word.text)
252 ? readFlag(word, args[index + 1] ?? null, SUBCOMMAND_FLAGS[subcommand])
253 : { name: "", skip: 1, value: undefined };
254 if (flag === undefined) {
255 return { reason: unknownFlagReason(word.text, `gh pr ${subcommand}`) };
256 }
257
258 if (flag.value === null && flag.name !== "title") {
259 return { reason: missingValueReason(word.text) };
260 }
261
262 title = flag.name === "title" ? flag.value : title;
263 kept.push(
264 ...(flag.name === "title" ? [] : args.slice(index, index + flag.skip).map(render)),
265 );
266 index += flag.skip;
267 }
268
269 kept.push(...args.slice(index).map(render));
270 return { kept, title };
271}
272
273/**
274 * Parses the words after `gh` as gh does.
275 *
276 * @param args - The words after `gh`.
277 * @returns The check for its title, `undefined` for none, or why it cannot be
278 * read.
279 */
280function checkGh(args: ReadonlyArray<Word>): TitleCheck | undefined {
281 const root = parseRoot(args);
282 if (root === undefined || !("subcommand" in root)) {
283 return root;
284 }
285
286 const read = readSubcommand(args, root.start, root.subcommand);
287 if ("reason" in read) {
288 return read;
289 }
290
291 const { kept, title } = read;
292 const problem = titleProblem(
293 root.subcommand,
294 title,
295 args.map(({ text }) => text),
296 );
297 if (problem !== undefined) {
298 return {
299 reason: titleReason(
300 problem,
301 kept.filter((text) => !FILL.has(text) && !WEB.has(text)),
302 ),
303 };
304 }
305
306 return title?.text === undefined ? undefined : { title: title.text };
307}
308
309function basename(path: string): string {
310 return path.replace(/^.*[/\\]/u, "");
311}
312
313/**
314 * Whether a PowerShell argument is `-EncodedCommand` in a spelling it takes:
315 * `-`, `--`, or `/`, then `ec` or any prefix of `encodedcommand`.
316 *
317 * @param text - One argument.
318 * @returns True for the encoded-command parameter.
319 */
320function isEncoded(text: string): boolean {
321 const lower = text.toLowerCase();
322 const name = lower.replace(/^(?:--?|\/)/u, "");
323 return name !== lower && (name === "ec" || (name !== "" && ENCODED.startsWith(name)));
324}
325
326/**
327 * Checks the script a shell runs from its command line.
328 *
329 * @param script - The words that make up the script, if any.
330 * @param dialect - The shell that runs it.
331 * @param command - The whole command, raw.
332 * @param shell - The shell that runs it.
333 * @returns The script's checks.
334 */
335function checkScript(
336 script: ReadonlyArray<Word>,
337 dialect: Dialect,
338 command: string,
339 shell: string,
340): Array<TitleCheck> {
341 if (script.length === 0 || script.some(({ dynamic, unclosed }) => dynamic || unclosed)) {
342 return mentionsGhPr(command) ? [{ reason: scriptReason(basename(shell)) }] : [];
343 }
344
345 return findTitleChecks(script.map(({ text }) => text).join(" "), dialect);
346}
347
348/**
349 * Skips assignments, keywords, and wrapper commands with their options.
350 *
351 * @param words - One simple command's words.
352 * @returns The words from the command name on, and the first wrapper, if any.
353 */
354function unwrap(words: ReadonlyArray<Word>): { rest: Array<Word>; wrapper: string | undefined } {
355 let wrapper: string | undefined;
356 const start = words.findIndex(({ text }) => {
357 wrapper ??= WRAPPERS.has(text) ? text : undefined;
358 const isWrapped = wrapper !== undefined;
359 return (
360 !WRAPPERS.has(text) &&
361 !ASSIGNMENT.test(text) &&
362 !PREFIXES.has(text) &&
363 (!isWrapped || !isFlag(text))
364 );
365 });
366 return { rest: start === -1 ? [] : words.slice(start), wrapper };
367}
368
369function checkShell(name: string, args: ReadonlyArray<Word>, command: string): Array<TitleCheck> {
370 if (BASH.test(name)) {
371 const option = args.findIndex(({ text }) => BASH_COMMAND.test(text));
372 return option === -1
373 ? []
374 : checkScript(args.slice(option + 1, option + 2), "bash", command, name);
375 }
376
377 const option = args.findIndex(
378 ({ text }) => POWERSHELL_COMMAND.test(text) || POWERSHELL_FILE.test(text),
379 );
380 const flag = args[option];
381 if (
382 args.slice(0, flag === undefined ? args.length : option).some(({ text }) => isEncoded(text))
383 ) {
384 return [{ reason: encodedReason(basename(name)) }];
385 }
386
387 return flag === undefined || POWERSHELL_FILE.test(flag.text)
388 ? []
389 : checkScript(args.slice(option + 1), "powershell", command, name);
390}
391
392function checkCommand(words: ReadonlyArray<Word>, command: string): Array<TitleCheck> {
393 const { rest, wrapper } = unwrap(words);
394 const [name, ...args] = rest;
395 if (name === undefined || name.dynamic) {
396 return [];
397 }
398
399 if (BASH.test(name.text) || POWERSHELL.test(name.text)) {
400 return checkShell(name.text, args, command);
401 }
402
403 if (!GH.test(name.text)) {
404 const hasHiddenGh =
405 args.some(({ text }) => GH.test(text)) && args.some(({ text }) => text === "pr");
406 return wrapper !== undefined && hasHiddenGh ? [{ reason: wrapperReason(wrapper) }] : [];
407 }
408
409 const check = checkGh(args);
410 return check === undefined ? [] : [check];
411}
412