SLOPSHOPPER

linteljs

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

newbandguardprocessnetwork
v0.0.0MITupdated 2026-10-07Faran52/linteljs/.claude/skills/linteljs
A shopper browsing a rack in a slop shop
README

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

Quick start

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.

Packages

PackageVersionUse it for
@linteljs/createnpmStart a project, or bring an existing one under the standard.
@linteljs/eslint-confignpmCompose ESLint flat-config layers, by hand or through composeConfig.
@linteljs/eslint-pluginnpmUse the rules on their own. recommended holds the ones the config builds on.

Why

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.

Documentation

  • docs/DESIGN.md: the decisions the code cannot show, and the non-goals.
  • Each package's README, linked above: every option, flag and rule.

Contributing

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.

License

MIT

Source 7 files
hooks/register.tsx 14 lines
1// 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};
14
hooks/checkBand.tsx 96 lines
1// 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};
96
hooks/ciBand.tsx 244 lines
1// 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};
244
hooks/repoGuards.ts 275 lines
1// 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};
275
types/index.d.ts 23 lines
1// 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}
23
hooks/utils/guardUtils.ts 284 lines
1// 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};
284
hooks/utils/shellUtils.ts 296 lines
1/**
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