This repo's own Claude Code mods: the shipped check band, a CI band and repo guards

<img src="https://raw.githubusercontent.com/Faran52/linteljs/main/docs/assets/logo.svg" alt="linteljs" width="96">
<h1 align="center">linteljs</h1>
<a href="https://github.com/Faran52/linteljs/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Faran52/linteljs/ci.yml?branch=main&logo=github&style=for-the-badge" alt="Build Status"></a> <a href="https://github.com/Faran52/linteljs/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FFaran52%2Flinteljs%2Fbadges%2Fcoverage.json&style=for-the-badge" alt="Coverage"></a> <a href="https://www.npmjs.com/package/@linteljs/create"><img src="https://img.shields.io/npm/v/@linteljs/create.svg?style=for-the-badge" alt="npm"></a> <a href="https://www.npmjs.com/package/@linteljs/create"><img src="https://img.shields.io/node/v/@linteljs/create?style=for-the-badge" alt="Node"></a>
linteljs ships as three packages: a scaffolder, a shared ESLint flat config, and the custom rules behind it. The scaffolder writes a starter app with its tests, ESLint flat config, TypeScript settings, git hooks, and coding-agent rules and hooks, for React, Next.js, Vue, Nuxt, Svelte, Solid, Angular, Astro, React Native through Expo, Manifest V3 web extensions, and plain TypeScript libraries. Its check runs lint, the banned-pattern check, CSS lint, the typecheck, coverage at 100% when the project has tests, and the build, and passes on the first run.
Node 22.18 or newer.
npm create @linteljs my-app
cd my-app
npm run check
pnpm create @linteljs my-app
cd my-app
pnpm check
yarn create @linteljs my-app
cd my-app
yarn run check
bun create @linteljs my-app
cd my-app
bun run check
For an existing project, npx @linteljs/create --existing applies the standard in place, and sync, run through the project's own manager, updates it. The @linteljs/create README has the details.
| Package | Version | Use it for |
|---|---|---|
@linteljs/create | Start a project, or bring an existing one under the standard. | |
@linteljs/eslint-config | Compose ESLint flat-config layers, by hand or through composeConfig. | |
@linteljs/eslint-plugin | Use the rules on their own. recommended holds the ones the config builds on. |
Copied configuration drifts quietly: a missing setting disables a rule while two config files still look alike. linteljs keeps the shared rules in a published package and the generated files explicit, so an update arrives as a reviewable diff.
pnpm install
pnpm check
Needs Node 26.10.0+ and pnpm 12.6+. docs/CONTRIBUTING.md covers the rest, including the networked end-to-end suite.
hooks/register.tsx 14 lines1// This repo's own mod. The shipped check band is carried byte for byte (held equal by the shipped `hooks.test.ts`);
2// only its refresh registers here, since the CI band draws the check state on its own line.
3import { registerCheckRefresh } from './checkBand.tsx';
4import { registerCiBand } from './ciBand.tsx';
5import { registerGuards } from './repoGuards.ts';
6
7import type { Register } from 'claude-code';
8
9export const register: Register = (on) => {
10 registerCheckRefresh(on);
11 registerCiBand(on);
12 registerGuards(on);
13};
14hooks/checkBand.tsx 96 lines1// The band above the prompt: what the commit gate would say about the work tree now.
2import {
3 atom,
4 type EngineInterface,
5 type On,
6 read,
7 type Register,
8 update,
9} from 'claude-code';
10
11import type { CheckWord } from '../types/index.d.ts';
12
13const check = atom({
14 plugin: 'linteljs',
15 key: 'check',
16} as const, null);
17
18// Each state's glyph and its colour.
19export const MARKS: Record<CheckWord, [string, string]> = {
20 passed: ['✓', 'green'],
21 stale: ['◐', 'yellow'],
22 failed: ['✗', 'red'],
23 running: ['◐', 'yellow'],
24 none: ['○', 'gray'],
25};
26
27const isWord = (text: string): text is CheckWord => {
28 return Object.hasOwn(MARKS, text);
29};
30
31// Outside a git project with a `check` script the script prints nothing, and the band stays away.
32const printed = async ($: EngineInterface, cwd: string): Promise<string> => {
33 try {
34 const status = await $.process.run(['node', `${$.plugin.root}/hooks/checkStatusHook.ts`], { cwd });
35
36 return status.stdout.trim();
37 }
38 catch {
39 return '';
40 }
41};
42
43const refresh = async ($: EngineInterface): Promise<void> => {
44 const cwd = await $.session.root();
45 const text = await printed($, cwd);
46
47 await update($, check, () => {
48 return isWord(text) ? text : null;
49 });
50};
51
52export const registerCheckRefresh = (on: On): void => {
53 on('session.start', async ($, e, next) => {
54 const started = await next(e);
55 await refresh($);
56
57 return started;
58 });
59
60 on('turn.complete', async ($, e, next) => {
61 const completed = await next(e);
62
63 if (e.agentId === undefined) {
64 await refresh($);
65 }
66
67 return completed;
68 });
69};
70
71export const register: Register = (on) => {
72 registerCheckRefresh(on);
73
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const word = await read($, check);
76
77 if (e.props.hasSurvey || word === null) {
78 return next(e);
79 }
80
81 const { Box, Text } = $.ui.resolve(e);
82 const [glyph, color] = MARKS[word];
83 const below = await next(e);
84
85 return (
86 <Box flexDirection="column">
87 <Text>
88 <Text color={color}>{glyph}</Text>
89 {` check ${word}`}
90 </Text>
91 {below}
92 </Box>
93 );
94 });
95};
96hooks/ciBand.tsx 244 lines1// One line above the prompt: the check state, main's latest ci, e2e and audit runs, and whether GitHub Actions is up.
2import {
3 atom,
4 type EngineInterface,
5 type On,
6 read,
7 type RenderChildren,
8 update,
9} from 'claude-code';
10
11import { MARKS } from './checkBand.tsx';
12
13import type { CiMark } from '../types/index.d.ts';
14
15interface Run {
16 workflowName: string;
17 status: string;
18 conclusion: string;
19}
20
21interface Component {
22 name: string;
23 status: string;
24}
25
26interface Status {
27 components: Component[];
28}
29
30// The check band's atom, which the engine reads only as declared in the module that reads it.
31const check = atom({
32 plugin: 'linteljs',
33 key: 'check',
34} as const, null);
35
36const ci = atom({
37 plugin: 'linteljs',
38 key: 'ci',
39} as const, null);
40
41const WORKFLOWS = [
42 'ci',
43 'e2e',
44 'audit',
45];
46const REFRESH_MS = 180_000;
47const STATUS_URL = 'https://www.githubstatus.com/api/v2/components.json';
48const MAIN_LABEL = 'main';
49const ACTIONS_LABEL = 'actions ';
50const SEPARATOR = ' │ ';
51
52const isObject = (value: unknown): value is object => {
53 return typeof value === 'object' && value !== null;
54};
55
56const isRun = (value: unknown): value is Run => {
57 return isObject(value) && 'workflowName' in value && 'status' in value && 'conclusion' in value;
58};
59
60const isRuns = (value: unknown): value is Run[] => {
61 return Array.isArray(value) && value.every(isRun);
62};
63
64const isComponent = (value: unknown): value is Component => {
65 return isObject(value) && 'name' in value && 'status' in value;
66};
67
68const isStatus = (value: unknown): value is Status => {
69 return isObject(value) && 'components' in value && Array.isArray(value.components)
70 && value.components.every(isComponent);
71};
72
73const parsedAs = <T,>(text: string | undefined, guard: (value: unknown) => value is T): T | undefined => {
74 try {
75 const parsed: unknown = JSON.parse(text ?? '');
76
77 return guard(parsed) ? parsed : undefined;
78 }
79 catch {
80 return undefined;
81 }
82};
83
84const markOf = ({ status, conclusion }: Run): CiMark => {
85 if (status !== 'completed') {
86 return 'running';
87 }
88
89 return conclusion === 'success' ? 'passed' : 'failed';
90};
91
92const runMarks = async ($: EngineInterface, cwd: string): Promise<[string, CiMark][]> => {
93 const argv = [
94 'gh',
95 'run',
96 'list',
97 '--branch',
98 'main',
99 '--limit',
100 '30',
101 '--json',
102 'workflowName,status,conclusion',
103 ];
104 let stdout: string | undefined;
105
106 try {
107 const listed = await $.process.run(argv, { cwd });
108 stdout = listed.exitCode === 0 ? listed.stdout : undefined;
109 }
110 catch {
111 stdout = undefined;
112 }
113
114 const runs = parsedAs(stdout, isRuns) ?? [];
115
116 return WORKFLOWS
117 .flatMap((name) => {
118 const latest = runs
119 .find(({ workflowName }) => {
120 return workflowName === name;
121 });
122
123 const marks: [string, CiMark][] = latest === undefined ? [] : [[name, markOf(latest)]];
124
125 return marks;
126 });
127};
128
129const actionsMark = async ($: EngineInterface): Promise<CiMark | null> => {
130 let text: string | undefined;
131
132 try {
133 const response = await $.http.fetch(STATUS_URL);
134 text = response.ok ? response.text : undefined;
135 }
136 catch {
137 text = undefined;
138 }
139
140 const status = parsedAs(text, isStatus);
141 const actions = status?.components
142 .find(({ name }) => {
143 return name === 'Actions';
144 });
145
146 if (actions === undefined) {
147 return null;
148 }
149
150 if (actions.status === 'operational') {
151 return 'passed';
152 }
153
154 return actions.status === 'degraded_performance' ? 'running' : 'failed';
155};
156
157// Main session only, and at most every few minutes; offline or without `gh` it draws what it could read.
158const refresh = async ($: EngineInterface): Promise<void> => {
159 const now = await $.clock.now();
160 const last = await read($, ci);
161
162 if (last !== null && now - last.at < REFRESH_MS) {
163 return;
164 }
165
166 const root = await $.session.root();
167 const runs = await runMarks($, root);
168 const actions = await actionsMark($);
169 const band = {
170 at: now,
171 runs,
172 actions,
173 };
174
175 await update($, ci, () => {
176 return band;
177 });
178};
179
180export const registerCiBand = (on: On): void => {
181 // The classic events, since the check band holds `session.start` and `turn.complete`; `Stop` is the main session's.
182 on('classic.SessionStart', async ($, e, next) => {
183 await refresh($);
184
185 return next(e);
186 });
187
188 on('classic.Stop', async ($, e, next) => {
189 await refresh($);
190
191 return next(e);
192 });
193
194 // The check band's render too, so both draw on one line; a segment with nothing read is left out.
195 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196 const word = await read($, check);
197 const band = await read($, ci);
198 const { Box, Text } = $.ui.resolve(e);
199
200 const mark = ([glyph, color]: [string, string]): RenderChildren => {
201 return <Text color={color}>{glyph}</Text>;
202 };
203
204 const runs = band?.runs ?? [];
205 const actions = band?.actions ?? null;
206 const runParts = runs
207 .flatMap(([name, state]) => {
208 const part = [` ${name} `, mark(MARKS[state])];
209
210 return part;
211 });
212 const checkSegment = word === null ? [] : [mark(MARKS[word]), ` check ${word}`];
213 const mainSegment = runs.length === 0 ? [] : [MAIN_LABEL, ...runParts];
214 const actionsSegment = actions === null ? [] : [ACTIONS_LABEL, mark(MARKS[actions])];
215 const segments = [
216 checkSegment,
217 mainSegment,
218 actionsSegment,
219 ]
220 .filter((segment) => {
221 return segment.length > 0;
222 });
223
224 if (e.props.hasSurvey || segments.length === 0) {
225 return next(e);
226 }
227
228 const line = segments
229 .flatMap((segment, index) => {
230 const separated = [<Text dimColor>{SEPARATOR}</Text>, ...segment];
231
232 return index === 0 ? segment : separated;
233 });
234 const below = await next(e);
235
236 return (
237 <Box flexDirection="column">
238 <Text>{line}</Text>
239 {below}
240 </Box>
241 );
242 });
243};
244hooks/repoGuards.ts 275 lines1// The repo's own guards: reads too large to take whole, polling subagents, banned text on its way into the
2// repo, the commit message commitlint would refuse, and stale worktrees at an agent's spawn.
3import {
4 addedBans,
5 addedLines,
6 bansReason,
7 type CommitCommand,
8 commitMessage,
9 commitsIn,
10 isTmpScript,
11 leftoverNote,
12 POLL_REASON,
13 pollReason,
14 readReason,
15 resolvedPath,
16 shellReadReason,
17} from './utils/guardUtils.ts';
18import { shellCommands } from './utils/shellUtils.ts';
19
20import type { EngineInterface, On } from 'claude-code';
21
22// A command's stdout, or nothing when it fails or cannot run.
23const run = async ($: EngineInterface, argv: string[], cwd: string): Promise<string> => {
24 try {
25 const ran = await $.process.run(argv, { cwd });
26
27 return ran.exitCode === 0 ? ran.stdout : '';
28 }
29 catch {
30 return '';
31 }
32};
33
34const textOf = async ($: EngineInterface, path: string): Promise<string> => {
35 try {
36 const text = await $.fs.read(path);
37
38 return typeof text === 'string' ? text : '';
39 }
40 catch {
41 return '';
42 }
43};
44
45const namesIn = async ($: EngineInterface, dir: string): Promise<string[]> => {
46 try {
47 const entries = await $.fs.list(dir);
48
49 return entries
50 .map(({ name }) => {
51 return name;
52 });
53 }
54 catch {
55 return [];
56 }
57};
58
59// A throwaway script staged, or left at the repo root or a package root.
60const tmpReason = async ($: EngineInterface, { dir }: CommitCommand): Promise<string | undefined> => {
61 const stagedNames = await run($, [
62 'git',
63 'diff',
64 '--cached',
65 '--name-only',
66 ], dir);
67 const packages = await namesIn($, `${dir}/packages`);
68 const roots = await Promise.all(packages
69 .map(async (name) => {
70 const names = await namesIn($, `${dir}/packages/${name}`);
71
72 return names
73 .map((file) => {
74 return `packages/${name}/${file}`;
75 });
76 }));
77 const atRoot = await namesIn($, dir);
78 const found = [
79 ...stagedNames.split('\n'),
80 ...atRoot,
81 ...roots.flat(),
82 ].filter(isTmpScript);
83 const listed = [...new Set(found)].join(', ');
84
85 return found.length === 0
86 ? undefined
87 : `linteljs: delete the throwaway script before committing: ${listed}.`;
88};
89
90const stagedReason = async ($: EngineInterface, { dir }: CommitCommand): Promise<string | undefined> => {
91 const diff = await run($, [
92 'git',
93 'diff',
94 '--cached',
95 '-U0',
96 '--no-color',
97 '--no-ext-diff',
98 ], dir);
99 const added = [...addedLines(diff)];
100
101 return added
102 .map(([path, lines]) => {
103 const bans = addedBans(path, '', lines);
104
105 return bansReason(path, bans);
106 })
107 .find((reason) => {
108 return reason !== undefined;
109 });
110};
111
112const lintReason = async ($: EngineInterface, commit: CommitCommand): Promise<string | undefined> => {
113 const { text, file } = commitMessage(commit.args);
114 const fromPath = file === undefined || file === '' || file === '-'
115 ? undefined
116 : await textOf($, resolvedPath(commit.dir, file));
117 const fromFile = file === '-' ? commit.stdin : fromPath;
118 const message = text ?? fromFile;
119
120 if (message === undefined || message === '') {
121 return undefined;
122 }
123
124 try {
125 const ran = await $.process.run([
126 'pnpm',
127 'exec',
128 'commitlint',
129 ], { cwd: commit.dir, stdin: message });
130 const output = `${ran.stdout}\n${ran.stderr}`.trim();
131
132 return ran.exitCode === 0 ? undefined : `linteljs: commitlint refuses this message:\n${output}`;
133 }
134 catch {
135 return undefined;
136 }
137};
138
139// Each check runs only when the one before found nothing.
140const commitCheckReason = async ($: EngineInterface, commit: CommitCommand): Promise<string | undefined> => {
141 const tmp = await tmpReason($, commit);
142
143 if (tmp !== undefined) {
144 return tmp;
145 }
146
147 const staged = await stagedReason($, commit);
148
149 if (staged !== undefined) {
150 return staged;
151 }
152
153 return lintReason($, commit);
154};
155
156const commitReason = async ($: EngineInterface, command: string): Promise<string | undefined> => {
157 const cwd = await $.session.cwd();
158 const commits = commitsIn(shellCommands(command) ?? [], cwd);
159
160 for (const commit of commits) {
161 const reason = await commitCheckReason($, commit);
162
163 if (reason !== undefined) {
164 return reason;
165 }
166 }
167
168 return undefined;
169};
170
171// New worktrees start at origin's default branch, so a main ahead of it hands the agent an old tree.
172const baseNote = async ($: EngineInterface, root: string): Promise<string | undefined> => {
173 const refs = await run($, [
174 'git',
175 'rev-parse',
176 '--short',
177 'HEAD',
178 'refs/remotes/origin/HEAD',
179 ], root);
180 const [head, origin] = refs
181 .trim()
182 .split('\n');
183
184 return head === undefined || origin === undefined || head === origin
185 ? undefined
186 : `Your worktree may start at origin's ${origin} rather than ${head}, where the main session is. `
187 + `Before anything else, run \`git merge --ff-only ${head}\` in it and confirm \`git log --oneline -1\`.`;
188};
189
190const POLL_DENIAL = { deny: POLL_REASON };
191
192export const registerGuards = (on: On): void => {
193 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
194 const reason = readReason(e.file_path);
195
196 if (reason === undefined) {
197 return next(e);
198 }
199
200 const denied = { deny: reason };
201
202 return denied;
203 });
204
205 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
206 return e.agentId === undefined ? next(e) : POLL_DENIAL;
207 });
208
209 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
210 const polls = e.agentId === undefined ? undefined : pollReason(e.command);
211 const reads = shellReadReason(shellCommands(e.command) ?? []);
212 const reason = polls ?? reads ?? await commitReason($, e.command);
213
214 if (reason === undefined) {
215 return next(e);
216 }
217
218 const denied = { deny: reason };
219
220 return denied;
221 });
222
223 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
224 const bans = addedBans(e.file_path, e.old_string, e.new_string);
225 const reason = bansReason(e.file_path, bans);
226
227 if (reason === undefined) {
228 return next(e);
229 }
230
231 const denied = { deny: reason };
232
233 return denied;
234 });
235
236 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
237 const before = await textOf($, e.file_path);
238 const bans = addedBans(e.file_path, before, e.content);
239 const reason = bansReason(e.file_path, bans);
240
241 if (reason === undefined) {
242 return next(e);
243 }
244
245 const denied = { deny: reason };
246
247 return denied;
248 });
249
250 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
251 if (e.agentId !== undefined || e.isolation !== 'worktree') {
252 return next(e);
253 }
254
255 const root = await $.session.root();
256 const worktrees = await namesIn($, `${root}/.claude/worktrees`);
257 const agents = await $.agent.list();
258 const leftover = leftoverNote(worktrees, agents);
259 const note = await baseNote($, root);
260
261 if (leftover !== undefined) {
262 try {
263 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: leftover }] } });
264 }
265 catch {
266 // A warning only: a refused note leaves the spawn and its base note standing.
267 }
268 }
269
270 const spawned = note === undefined ? e : { ...e, prompt: `${note}\n\n${e.prompt}` };
271
272 return next(spawned);
273 });
274};
275types/index.d.ts 23 lines1// The state word `hooks/checkStatusHook.ts` prints, which the check band draws.
2export type CheckWord = 'failed' | 'none' | 'passed' | 'running' | 'stale';
3
4// A CI mark: a run or the Actions component passed (up), is running (degraded), or failed (down).
5export type CiMark = 'failed' | 'passed' | 'running';
6
7// What the CI band last read, and when, so a turn refreshes it at most every few minutes.
8export interface CiBand {
9 at: number;
10 runs: [string, CiMark][];
11 actions: CiMark | null;
12}
13
14// Inline, since `claude plugin validate` reads the state's shape from this declaration.
15declare module 'claude-code' {
16 interface PluginState {
17 linteljs: {
18 check: CheckWord | null;
19 ci: CiBand | null;
20 };
21 }
22}
23hooks/utils/guardUtils.ts 284 lines1// What the repo guards decide, kept apart from the engine so each answer is a plain function of its input.
2import type { ShellCommand } from './shellUtils.ts';
3
4export interface CommitCommand {
5 dir: string;
6 args: string[];
7 stdin?: string;
8}
9
10export interface CommitMessage {
11 text?: string;
12 file?: string;
13}
14
15// One agent loop as `$.agent.list()` reports it, as far as the note reads it.
16interface ListedAgent {
17 id: string;
18 status: string;
19}
20
21const LIVE = new Set([
22 'pending',
23 'running',
24 'waiting',
25]);
26
27const READERS = new Set([
28 'awk',
29 'bat',
30 'cat',
31 'head',
32 'less',
33 'more',
34 'sed',
35 'tail',
36]);
37
38const UNREADABLE: [RegExp, string][] = [
39 [/(?:^|\/)pnpm-lock\.yaml$/u, 'the lockfile'],
40 [/(?:^|\/)node_modules\//u, 'installed package code'],
41 [/(?:^|\/)coverage\//u, 'coverage output'],
42 [/\.jsonl$/u, 'a Claude transcript'],
43 [/\.output$/u, 'a task\'s output file'],
44];
45
46const POLL_LOOP = /\b(?:while|until)\b[\s\S]*?\bdo\b[\s\S]*?\bsleep\b/u;
47const WATCH = /(?:^|[\s;&|(])watch\s/u;
48
49const IGNORE_COMMENT = /\/[*/]\s*(?:(?:istanbul|[vc]8)\s+ignore|Stryker\s+disable)\b/giu;
50const URL_HOST = /https?:\/\/([\w.-]+)/giu;
51const MIRROR_HOST = /npmmirror|registry/iu;
52
53// Files that hold the ignore comments as data: suites, fixtures, prose and the shipped template text.
54const COMMENT_DATA = /\.test\.tsx?$|\/__mocks__\/|\.md$|(?:^|\/)templates\//u;
55
56const GIT_VALUED = new Set([
57 '-C',
58 '-c',
59 '--git-dir',
60 '--work-tree',
61 '--namespace',
62 '--exec-path',
63]);
64const MESSAGE_FLAGS = new Set(['-m', '--message']);
65const FILE_FLAGS = new Set(['-F', '--file']);
66
67const baseName = (path: string): string => {
68 return path.slice(path.lastIndexOf('/') + 1);
69};
70
71export const resolvedPath = (base: string, path: string): string => {
72 return path.startsWith('/') ? path : `${base}/${path}`;
73};
74
75export const readReason = (path: string): string | undefined => {
76 const found = UNREADABLE
77 .find(([pattern]) => {
78 return pattern.test(path);
79 });
80
81 return found === undefined
82 ? undefined
83 : `linteljs: ${path} is ${found[1]}, too large or too noisy to read whole. Grep it for what you need, `
84 + 'or use the targeted tool (`pnpm why`, the test runner\'s own output, a filtered `jq`).';
85};
86
87export const shellReadReason = (commands: ShellCommand[]): string | undefined => {
88 const paths = commands
89 .filter(({ words }) => {
90 const name = baseName(words[0] ?? '');
91
92 return READERS.has(name);
93 })
94 .flatMap(({ words }) => {
95 return words.slice(1);
96 });
97
98 return paths
99 .map(readReason)
100 .find((reason) => {
101 return reason !== undefined;
102 });
103};
104
105export const POLL_REASON = 'linteljs: a subagent does not poll. Run the command once, or with run_in_background '
106 + 'and end the turn: its completion wakes you. No sleep loops, no `watch`, no Monitor.';
107
108export const pollReason = (command: string): string | undefined => {
109 return POLL_LOOP.test(command) || WATCH.test(command) ? POLL_REASON : undefined;
110};
111
112const countOf = (pattern: RegExp, text: string): number => {
113 return text.match(pattern)?.length ?? 0;
114};
115
116const mirrorsIn = (text: string): string[] => {
117 const urls = [...text.matchAll(URL_HOST)];
118
119 return urls
120 .map(([, host = '']) => {
121 return host.toLowerCase();
122 })
123 .filter((host) => {
124 return MIRROR_HOST.test(host) && host !== 'registry.npmjs.org';
125 });
126};
127
128// What an edit adds that the repo bans: a registry other than npmjs, a coverage ignore or a Stryker disable.
129export const addedBans = (path: string, before: string, after: string): string[] => {
130 const known = new Set(mirrorsIn(before));
131 const added = new Set(mirrorsIn(after));
132 const mirrors = [...added]
133 .filter((host) => {
134 return !known.has(host);
135 });
136 const isData = COMMENT_DATA.test(path);
137 const comments = !isData && countOf(IGNORE_COMMENT, after) > countOf(IGNORE_COMMENT, before);
138
139 const bans = [
140 ...mirrors
141 .map((host) => {
142 return `a registry other than registry.npmjs.org (${host})`;
143 }),
144 ...(comments ? ['a coverage ignore or Stryker disable comment'] : []),
145 ];
146
147 return bans;
148};
149
150export const bansReason = (path: string, bans: string[]): string | undefined => {
151 return bans.length === 0
152 ? undefined
153 : `linteljs: ${path} would gain ${bans.join(' and ')}. The repo commits only registry.npmjs.org `
154 + '(set a mirror in the environment, never in a file), '
155 + 'and survivors and uncovered lines are fixed, never ignored.';
156};
157
158// Each file's added lines in a `git diff -U0`, by the path after `+++ b/`.
159export const addedLines = (diff: string): Map<string, string> => {
160 const added = new Map<string, string>();
161 let path = '';
162
163 for (const line of diff.split('\n')) {
164 if (line.startsWith('+++ ')) {
165 path = line.slice(line.indexOf('/') + 1);
166 }
167 else if (line.startsWith('+')) {
168 added.set(path, `${added.get(path) ?? ''}${line.slice(1)}\n`);
169 }
170 }
171
172 return added;
173};
174
175export const isTmpScript = (name: string): boolean => {
176 return name.endsWith('.tmp.ts');
177};
178
179const gitCommit = (words: string[], base: string): CommitCommand | undefined => {
180 let dir = base;
181 let index = 1;
182
183 while (words[index]?.startsWith('-') === true) {
184 const option = words[index] ?? '';
185 const value = words[index + 1] ?? '';
186 dir = option === '-C' ? resolvedPath(dir, value) : dir;
187 index += GIT_VALUED.has(option) ? 2 : 1;
188 }
189
190 if (baseName(words[0] ?? '') !== 'git' || words[index] !== 'commit') {
191 return undefined;
192 }
193
194 const commit = { dir, args: words.slice(index + 1) };
195
196 return commit;
197};
198
199// The commits a line runs, each in the directory a leading `cd` or `git -C` moves it to.
200export const commitsIn = (commands: ShellCommand[], cwd: string): CommitCommand[] => {
201 let base = cwd;
202 const commits: CommitCommand[] = [];
203
204 for (const { words, stdin } of commands) {
205 const commit = gitCommit(words, base);
206
207 if (words[0] === 'cd' && words[1] !== undefined) {
208 base = resolvedPath(base, words[1]);
209 }
210 else if (commit !== undefined) {
211 const fed = stdin === undefined ? commit : { ...commit, stdin };
212
213 commits.push(fed);
214 }
215 }
216
217 return commits;
218};
219
220// One option's value: the rest of a short cluster (`-mfix`, `-am fix`), after `=`, or the next word.
221const optionValue = (args: string[], index: number, flag: string): string | undefined => {
222 const word = args[index] ?? '';
223 const attached = word.startsWith('--') ? word.slice(flag.length + 1) : word.slice(word.indexOf(flag[1] ?? '') + 1);
224
225 return attached === '' ? args[index + 1] : attached;
226};
227
228const flagOf = (word: string): string | undefined => {
229 const long = word.split('=')[0] ?? '';
230
231 if (word.startsWith('--')) {
232 return MESSAGE_FLAGS.has(long) || FILE_FLAGS.has(long) ? long : undefined;
233 }
234
235 const short = /^-[a-zA-Z]*?([mF])/u.exec(word);
236
237 return short === null ? undefined : `-${short[1] ?? ''}`;
238};
239
240// The message a commit's `-m`s spell (paragraphs, as git joins them), or the file its `-F` names.
241export const commitMessage = (args: string[]): CommitMessage => {
242 const messages: string[] = [];
243 let file: string | undefined;
244
245 args
246 .forEach((word, index) => {
247 const flag = flagOf(word);
248 const value = flag === undefined ? undefined : optionValue(args, index, flag);
249
250 if (flag !== undefined && MESSAGE_FLAGS.has(flag) && value !== undefined) {
251 messages.push(value);
252 }
253 else if (flag !== undefined && FILE_FLAGS.has(flag)) {
254 file = value;
255 }
256 });
257
258 const message: CommitMessage = messages.length > 0
259 ? { text: messages.join('\n\n') }
260 : { ...(file === undefined ? {} : { file }) };
261
262 return message;
263};
264
265// Worktrees no live agent of this session owns; the harness names each `agent-<id>`.
266export const leftoverNote = (names: string[], agents: ListedAgent[]): string | undefined => {
267 const live = new Set(agents
268 .filter(({ status }) => {
269 return LIVE.has(status);
270 })
271 .map(({ id }) => {
272 return `agent-${id}`;
273 }));
274 const leftover = names
275 .filter((name) => {
276 return !live.has(name);
277 });
278
279 return leftover.length === 0
280 ? undefined
281 : `linteljs: worktrees no running agent owns sit under .claude/worktrees: ${leftover.join(', ')}. `
282 + 'Merge or drop what each holds, then `git worktree remove` it.';
283};
284hooks/utils/shellUtils.ts 296 lines1/**
2 * Reads a Bash line as its simple commands, each a list of literal words and the heredoc fed to it. A word built
3 * at run time (`$VAR`, any substitution but a quoted `$(cat <<'EOF' ... EOF)`) makes the line unreadable, and a
4 * guard reading `undefined` lets the line pass: the classic git guard and the husky hooks still stand behind it.
5 */
6export interface ShellCommand {
7 words: string[];
8 stdin?: string;
9}
10
11interface HeredocToken {
12 kind: 'heredoc';
13 body: string;
14}
15
16interface MarkToken {
17 kind: 'redirect' | 'separator';
18}
19
20interface WordToken {
21 kind: 'word';
22 text: string;
23}
24
25type Token = HeredocToken | MarkToken | WordToken;
26
27type Read<Value> = [Value, number] | undefined;
28
29const SEPARATOR = /^(?:&&|\|\||[;&|\n()])/u;
30const REDIRECT = /^\d*(?:>>|>&\d+|<&\d+|&>|[<>])/u;
31const REDIRECT_TO_FD = /&\d+$/u;
32const HEREDOC = /^<<-?[ \t]*(['"]?)([\w.-]+)\1/u;
33const CAT_HEREDOC = /^\$\(\s*cat\s+<<-?\s*(['"]?)([\w.-]+)\1/u;
34const WORD_END = new Set([
35 ' ',
36 '\t',
37 '\n',
38 ';',
39 '&',
40 '|',
41 '(',
42 ')',
43 '<',
44 '>',
45]);
46
47// The heredoc body after the newline at `from`, and the index past its delimiter line.
48const heredocBody = (line: string, from: number, delimiter: string): Read<string> => {
49 const lines = line
50 .slice(from + 1)
51 .split('\n');
52 const end = lines
53 .findIndex((text) => {
54 return text.trim() === delimiter;
55 });
56
57 if (end === -1) {
58 return undefined;
59 }
60
61 const body = lines
62 .slice(0, end)
63 .join('\n');
64 const through = lines
65 .slice(0, end + 1)
66 .join('\n');
67 const read: Read<string> = [body, from + 1 + through.length];
68
69 return read;
70};
71
72// A quoted `$(cat <<'EOF' ... EOF)`: its body, and its length. Bash ends it at the first delimiter line.
73const catHeredoc = (text: string): Read<string> => {
74 const head = CAT_HEREDOC.exec(text);
75 const start = head === null ? 0 : text.indexOf('\n', head[0].length) + 1;
76
77 if (head === null || start === 0) {
78 return undefined;
79 }
80
81 const delimiter = head[2] ?? '';
82 const closing = new RegExp(`\\n\\s*${delimiter.replaceAll('.', '\\.')}\\s*\\)`, 'u');
83 const rest = text.slice(start);
84 const close = closing.exec(rest);
85
86 if (close === null) {
87 return undefined;
88 }
89
90 const read: Read<string> = [rest.slice(0, close.index), start + close.index + close[0].length];
91
92 return read;
93};
94
95// A double-quoted string read from just past its opening quote.
96const doubleQuoted = (line: string, from: number): Read<string> => {
97 let text = '';
98 let index = from;
99
100 while (index < line.length && line[index] !== '"') {
101 const char = line[index] ?? '';
102 const isSubstitution = char === '$' || char === '`';
103 const substitution = isSubstitution ? catHeredoc(line.slice(index)) : undefined;
104
105 if (isSubstitution && substitution === undefined) {
106 return undefined;
107 }
108
109 if (substitution !== undefined) {
110 text += substitution[0];
111 index += substitution[1];
112 }
113 else if (char === '\\' && '"\\$`'.includes(line[index + 1] ?? '')) {
114 text += line[index + 1] ?? '';
115 index += 2;
116 }
117 else {
118 text += char;
119 index += 1;
120 }
121 }
122
123 if (index >= line.length) {
124 return undefined;
125 }
126
127 const read: Read<string> = [text, index + 1];
128
129 return read;
130};
131
132// One quoted, escaped or bare piece of a word.
133const wordPiece = (line: string, index: number): Read<string> => {
134 const char = line[index] ?? '';
135
136 if (char === '\'') {
137 const close = line.indexOf('\'', index + 1);
138
139 const read: Read<string> = close === -1 ? undefined : [line.slice(index + 1, close), close + 1];
140
141 return read;
142 }
143
144 if (char === '"') {
145 return doubleQuoted(line, index + 1);
146 }
147
148 if (char === '\\') {
149 const escaped = line[index + 1] ?? '';
150 const read: Read<string> = [escaped === '\n' ? '' : escaped, index + 2];
151
152 return read;
153 }
154
155 const read: Read<string> = char === '$' || char === '`' ? undefined : [char, index + 1];
156
157 return read;
158};
159
160const readWord = (line: string, from: number): Read<string> => {
161 let text = '';
162 let index = from;
163
164 while (index < line.length && !WORD_END.has(line[index] ?? '')) {
165 const piece = wordPiece(line, index);
166
167 if (piece === undefined) {
168 return undefined;
169 }
170
171 text += piece[0];
172 [, index] = piece;
173 }
174
175 const read: Read<string> = [text, index];
176
177 return read;
178};
179
180// Past a heredoc opener, redirect, separator, blank or comment at `index`, recording it; `undefined` at a word.
181const pastMark = (line: string, index: number, tokens: Token[], pending: string[]): number | undefined => {
182 const rest = line.slice(index);
183 const heredoc = HEREDOC.exec(rest);
184 const redirect = REDIRECT.exec(rest);
185 const separator = SEPARATOR.exec(rest);
186
187 if (heredoc !== null) {
188 pending.push(heredoc[2] ?? '');
189
190 return index + heredoc[0].length;
191 }
192
193 if (redirect !== null) {
194 if (!REDIRECT_TO_FD.test(redirect[0])) {
195 tokens.push({ kind: 'redirect' });
196 }
197
198 return index + redirect[0].length;
199 }
200
201 if (separator !== null) {
202 tokens.push({ kind: 'separator' });
203
204 return index + separator[0].length;
205 }
206
207 if (line[index] === ' ' || line[index] === '\t') {
208 return index + 1;
209 }
210
211 if (line[index] !== '#') {
212 return undefined;
213 }
214
215 const newline = line.indexOf('\n', index);
216
217 return newline === -1 ? line.length : newline;
218};
219
220// The index past the next token, recording it; `undefined` when the line cannot be read.
221const pastToken = (line: string, index: number, tokens: Token[], pending: string[]): number | undefined => {
222 const [delimiter] = pending;
223
224 if (line[index] === '\n' && delimiter !== undefined) {
225 const body = heredocBody(line, index, delimiter);
226
227 if (body === undefined) {
228 return undefined;
229 }
230
231 pending.shift();
232 tokens.push({ kind: 'heredoc', body: body[0] }, { kind: 'separator' });
233
234 return body[1];
235 }
236
237 const marked = pastMark(line, index, tokens, pending);
238
239 if (marked !== undefined) {
240 return marked;
241 }
242
243 const word = readWord(line, index);
244
245 if (word === undefined) {
246 return undefined;
247 }
248
249 tokens.push({ kind: 'word', text: word[0] });
250
251 return word[1];
252};
253
254const tokenize = (line: string): Token[] | undefined => {
255 const tokens: Token[] = [];
256 const pending: string[] = [];
257 let index: number | undefined = 0;
258
259 while (index !== undefined && index < line.length) {
260 index = pastToken(line, index, tokens, pending);
261 }
262
263 return index === undefined || pending.length > 0 ? undefined : tokens;
264};
265
266export const shellCommands = (line: string): ShellCommand[] | undefined => {
267 const tokens = tokenize(line);
268
269 if (tokens === undefined) {
270 return undefined;
271 }
272
273 const commands: ShellCommand[] = [];
274 let current: ShellCommand = { words: [] };
275 let isTarget = false;
276
277 const ended: Token[] = [...tokens, { kind: 'separator' }];
278
279 for (const token of ended) {
280 if (token.kind === 'separator' && current.words.length > 0) {
281 commands.push(current);
282 current = { words: [] };
283 }
284 else if (token.kind === 'heredoc') {
285 current.stdin = token.body;
286 }
287 else if (token.kind === 'word' && !isTarget) {
288 current.words.push(token.text);
289 }
290
291 isTarget = token.kind === 'redirect';
292 }
293
294 return commands;
295};
296