SLOPSHOPPER

commitlint-preflight

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

newguardprocess
A shopper browsing a rack in a slop shop
README

Roblox Skills & Claude Code Extensions

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.

What's here

  • Skills - Agent skills for Roblox tooling, Luau, and related ecosystems
  • Hooks - Custom Claude Code hooks for my workflow
  • Plugins - Any other extensions I end up building

Installation

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.

Skills

Hand-maintained

Manually written with personal preferences and best practices.

SkillDescription
roblox-tsTypeScript to Roblox Lua transpiler
ecs-designBest practices for designing Entity Component Systems in Roblox

Generated from documentation

Generated from official docs.

SkillDescriptionSource
jecsEntity Component System for RobloxUkendio/jecs
pnpmFast, disk-efficient package managerpnpm/pnpm.io
roblox-tsTypeScript to Roblox Lua transpilerroblox-ts/roblox-ts

Plugins

commitlint-preflight

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:

  • commitlint rejects the title (the denial holds commitlint's output);
  • the title is not a literal string: it holds a variable, a command substitution, a glob, or a here-document, or its quote does not close;
  • gh pr create has no title flag (--fill, --web, the editor, or the prompt);
  • a flag, wrapper, or 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;
  • the repository configures commitlint but @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

Usage

See AGENTS.md for how skills are generated and maintained.

Adding your own

  1. Fork this repo
  2. pnpm install
  3. Update meta.ts with your projects
  4. nr start cleanup to clear existing submodules
  5. nr start init to clone fresh
  6. nr start sync for vendored skills
  7. Have your agent generate skills one project at a time

Attribution

Forked 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.

License

MIT. Vendored skills keep their original licenses.

Source 5 files
hooks/register.ts 135 lines
1import 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};
135
src/messages.ts 63 lines
1export 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}
63
src/repo.ts 84 lines
1/** 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];
84
src/shell.ts 410 lines
1export 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}
410
src/titles.ts 412 lines
1// 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