Guards the TrueMain git/GitHub workflow: no direct push to develop/master, branch naming, shared-stash safety, squash vs merge per PR base, --delete-branch…

TrueMain is a League of Legends analytics site. It answers one question per champion: what do the players who truly main this champion actually do? — and turns that into build, rune, skill-order and matchup guidance backed by real match data instead of raw pick-rate averages.
The site is live at truemain.lol.
Most stat sites aggregate every game played on a champion, which drowns the signal in one-trick-free noise. TrueMain instead identifies true mains — players whose games-played-to-mastery ratio on a champion shows sustained, current investment — and computes its statistics from their matches only. The result is closer to "what do dedicated specialists build and how do they play the early game" than "what does the average queue do".
Data flows through three .NET services and two Nuxt frontends:
Riot API ──► Ingestor ──► PostgreSQL ──► Api ──► web (truemain.lol)
│ └─► admin (ops portal)
└── MongoDB (logs, audit, Riot-usage metrics)
A .NET worker that runs a set of scheduled, independently recorded processes:
An ASP.NET Core service exposing the read side: champion aggregates, true-main leaderboards, player profiles, and authenticated /ops endpoints for the admin portal. Reads are purpose-built query services returning read models, each shaped by the question it answers — no generic repository.
Data owns the EF Core model (PostgreSQL), migrations, repositories used by the ingestion write side, and the Mongo-backed logging/metrics sinks. Core holds shared League domain types (platforms, regional routing, queues, ranks).
The public Nuxt frontend: champion pages (builds, runes, skill order, matchups, synergies), player pages, and search. It talks to the Api through a Nitro server proxy, so the browser never reaches the backend directly.
A standalone Nuxt portal (separate deployable, not a route of web) for operating the pipeline: process runs and health, manual seeding, Riot API usage metrics, and data audits. Sits behind session authentication.
backend/ .NET solution — Api, Ingestor, Core, Data, tests (unit, integration, test kit)
web/ public Nuxt frontend
admin/ standalone Nuxt admin portal
docs/ API reference, RFCs, production migration notes
compose*.yaml Docker stacks (dev with hot-reload, preprod and prod from published images)
Everything runs as Docker containers on a single host. In production, Caddy terminates TLS (Let's Encrypt) and is the only public entry point; the frontends and databases live on an internal network. CI builds the backend in Release with analyzers as errors, runs unit tests on every run and integration tests (against real PostgreSQL) on pull requests, builds both frontends, and publishes images to GHCR. Pull requests get an automated Claude code review with a blocking verdict.
docs/api.md — HTTP endpoint reference (parameters and response shapes).docs/preprod.md — preprod environment: :preprod images tracking develop, fresh database with a dedicated Riot API key, data-diet knobs, deployment runbook.docs/load-testing.md — load testing preprod with k6 from GitHub Actions: scenarios, what a simulated visitor does, the rate-limit caveat, how to read the summary.docs/riot-production-key-application.md — what has to be true before applying for a Riot production API key, and what the application must say.docs/riot-key-switch.md — runbook for replacing the Riot API key: PUUIDs are encrypted per key, so the switch invalidates every stored identifier and is recovered through the Riot ID.docs/diagrams/architecture.drawio — architecture diagram.docs/phase-*.md — RFCs behind the major evolutions of the data model and pipeline. All three are implemented and kept as historical records; each carries a status banner saying what shipped differently from the proposal.TrueMain is licensed under the PolyForm Noncommercial License 1.0.0: you may read, run, modify and share the code for any noncommercial purpose — personal use, study, research, hobby projects. Any commercial use, including running it or a derivative as part of a product or service, needs a separate license from the copyright holder.
Third-party code and assets keep their own licenses. League of Legends data and artwork belong to Riot Games; TrueMain is not endorsed by Riot Games.
hooks/register.ts 61 lines1import type { Register } from 'claude-code'
2
3import { judge, type GitContext } from './rules'
4
5export const register: Register = on => {
6 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
7 if (!/\b(git|gh)\b/.test(e.command) && !/Co-Authored-By: Claude|Generated with \[Claude Code\]/.test(e.command)) {
8 return next(e)
9 }
10
11 const cwd = await $.session.cwd()
12 const run = async (argv: readonly string[], timeoutMs = 15_000) => {
13 try {
14 return await $.process.run(argv, { cwd, timeoutMs })
15 } catch {
16 return null
17 }
18 }
19
20 const ctx: GitContext = {
21 currentBranch: async () => {
22 const ran = await run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
23 if (ran === null || ran.exitCode !== 0) return null
24 const name = ran.stdout.trim()
25 return name === '' || name === 'HEAD' ? null : name
26 },
27 remoteHasBranch: async branch => {
28 const ran = await run(['git', 'ls-remote', '--heads', 'origin', branch], 20_000)
29 if (ran === null || ran.exitCode !== 0) return null
30 return ran.stdout.trim() !== ''
31 },
32 changedFiles: async () => {
33 const ran = await run(['git', 'diff', '--name-only', 'origin/develop...HEAD'])
34 if (ran === null || ran.exitCode !== 0) return null
35 return ran.stdout.split('\n').map(line => line.trim()).filter(line => line !== '')
36 },
37 pr: async selector => {
38 const argv = ['gh', 'pr', 'view', ...(selector === null ? [] : [selector]), '--json', 'baseRefName,headRefName']
39 const ran = await run(argv, 20_000)
40 if (ran === null || ran.exitCode !== 0) return null
41 try {
42 const parsed = JSON.parse(ran.stdout) as { baseRefName?: string; headRefName?: string }
43 if (typeof parsed.baseRefName !== 'string' || typeof parsed.headRefName !== 'string') return null
44 return { base: parsed.baseRefName, head: parsed.headRefName }
45 } catch {
46 return null
47 }
48 },
49 }
50
51 const verdict = await judge(e.command, ctx)
52 if ('deny' in verdict) {
53 $.ui.toast(`truemain-guard refused a command: ${verdict.deny.slice(0, 80)}…`)
54 return { deny: `truemain-guard: ${verdict.deny}` }
55 }
56 for (const note of verdict.notes) $.ui.toast(`truemain-guard: ${note}`)
57
58 return verdict.command === e.command ? next(e) : next({ ...e, command: verdict.command })
59 }).catch(($, e, next) => next(e))
60}
61hooks/rules.ts 258 lines1// Pure rules over a Bash command string. The hook in register.ts supplies the
2// git/GitHub context lazily so a command that triggers no rule costs nothing.
3
4export const PROTECTED = new Set(['develop', 'master'])
5
6// <type>/<issue>-<short-kebab> — the convention of CLAUDE.md "Git & PR workflow".
7export const BRANCH_RE = /^(feat|fix|perf|refactor|chore|docs|ci|test|build|style)\/\d+-[a-z0-9][a-z0-9.-]*$/
8const EXEMPT_BRANCH_RE = /^(dependabot\/|release\/|renovate\/)/
9
10const TRAILER_RE = /^[ \t]*(Co-Authored-By: Claude[^\n]*|🤖 Generated with \[Claude Code\][^\n]*)\n?/gim
11
12export type GitContext = {
13 /** The checked-out branch, or null when detached or unknown. */
14 currentBranch: () => Promise<string | null>
15 /** Whether origin already has the branch; null when it cannot be told. */
16 remoteHasBranch: (branch: string) => Promise<boolean | null>
17 /** Paths changed on HEAD against origin/develop; null when unknown. */
18 changedFiles: () => Promise<readonly string[] | null>
19 /** The base and head branches of a PR (number, URL, branch, or null for the current branch). */
20 pr: (selector: string | null) => Promise<{ base: string; head: string } | null>
21}
22
23export type Verdict = { deny: string } | { command: string; notes: readonly string[] }
24
25/** Judges one Bash command: a denial with its reason, or the command to run (rewritten or not) and notes. */
26export async function judge(command: string, ctx: GitContext): Promise<Verdict> {
27 const notes: string[] = []
28 let rewritten = command
29
30 if (TRAILER_RE.test(rewritten)) {
31 TRAILER_RE.lastIndex = 0
32 rewritten = rewritten.replace(TRAILER_RE, '')
33 notes.push('Claude attribution trailer removed (CLAUDE.md: no Co-Authored-By / generated-with footers).')
34 }
35 TRAILER_RE.lastIndex = 0
36
37 for (const segment of segments(rewritten)) {
38 const tokens = tokenize(segment)
39 const git = indexOfCommand(tokens, 'git')
40 const gh = indexOfCommand(tokens, 'gh')
41
42 if (git !== -1) {
43 const sub = tokens[git + 1]
44 const args = tokens.slice(git + 2)
45 if (sub === 'push') {
46 const result = await judgePush(args, ctx)
47 if ('deny' in result) return result
48 if (result.forceRewrite) {
49 rewritten = rewritten.replace(/(\bgit\s+push\b[^\n;&|]*?)(\s)(--force|-f)(?=\s|$)/, '$1$2--force-with-lease')
50 notes.push('--force rewritten to --force-with-lease.')
51 }
52 } else if (sub === 'stash') {
53 const deny = judgeStash(args)
54 if (deny) return { deny }
55 }
56 }
57
58 if (gh !== -1 && tokens[gh + 1] === 'pr') {
59 const verb = tokens[gh + 2]
60 const args = tokens.slice(gh + 3)
61 if (verb === 'create') {
62 const deny = await judgePrCreate(args, ctx)
63 if (deny) return { deny }
64 } else if (verb === 'merge') {
65 const deny = await judgePrMerge(args, ctx)
66 if (deny) return { deny }
67 }
68 }
69 }
70
71 return { command: rewritten, notes }
72}
73
74type PushVerdict = { deny: string } | { forceRewrite: boolean }
75
76async function judgePush(args: readonly string[], ctx: GitContext): Promise<PushVerdict> {
77 const positional: string[] = []
78 let isDelete = false
79 let hasTags = false
80 let hasForce = false
81 for (let i = 0; i < args.length; i += 1) {
82 const arg = args[i] ?? ''
83 if (arg === '--delete' || arg === '-d') isDelete = true
84 else if (arg === '--tags') hasTags = true
85 else if (arg === '--force' || arg === '-f') hasForce = true
86 else if (arg === '-o' || arg === '--push-option' || arg === '--receive-pack' || arg === '--repo') i += 1
87 else if (arg.startsWith('-')) continue
88 else positional.push(arg)
89 }
90 const refspecs = positional.slice(1)
91 const current = await ctx.currentBranch()
92
93 const targets: { source: string | null; dest: string }[] = []
94 if (refspecs.length === 0) {
95 if (hasTags) return { forceRewrite: false }
96 if (current === null) return { forceRewrite: false }
97 targets.push({ source: current, dest: current })
98 }
99 for (const spec of refspecs) {
100 const bare = spec.replace(/^\+/, '')
101 if (isDelete) {
102 targets.push({ source: null, dest: bare })
103 } else if (bare.includes(':')) {
104 const [src, dst] = bare.split(':', 2) as [string, string]
105 targets.push({ source: src === '' ? null : src === 'HEAD' ? current : src, dest: dst })
106 } else {
107 targets.push({ source: bare === 'HEAD' ? current : bare, dest: bare === 'HEAD' ? current ?? bare : bare })
108 }
109 }
110
111 for (const { source, dest } of targets) {
112 const destBranch = dest.replace(/^refs\/heads\//, '')
113 if (PROTECTED.has(destBranch)) {
114 return {
115 deny: `pushing straight to '${destBranch}' is not allowed (branch protection, CLAUDE.md "No direct push to develop"). Open a PR instead: feature work targets develop through the ship skill; develop→master goes through the release skill's PR.`,
116 }
117 }
118 if (isDelete) {
119 continue
120 }
121 if (source !== null && source === current) {
122 if (!PROTECTED.has(source) && !BRANCH_RE.test(source) && !EXEMPT_BRANCH_RE.test(source)) {
123 const onRemote = await ctx.remoteHasBranch(source)
124 if (onRemote !== true) {
125 return {
126 deny: `branch '${source}' does not follow <type>/<issue>-<short-kebab> (e.g. feat/526-rank-scoped-builds; types: feat, fix, perf, refactor, chore, docs, ci, test, build, style). Rename it before the first push: git branch -m <type>/<issue>-<slug>`,
127 }
128 }
129 }
130 const files = await ctx.changedFiles()
131 if (files !== null) {
132 const hasMigration = files.some(path => path.startsWith('backend/Data/Migrations/'))
133 const hasCompiledModel = files.some(path => path.startsWith('backend/Data/CompiledModels/'))
134 if (hasMigration && !hasCompiledModel) {
135 return {
136 deny: `the branch adds an EF migration under backend/Data/Migrations/ but backend/Data/CompiledModels/ is unchanged against origin/develop. A stale compiled model silently drops columns: run \`dotnet ef dbcontext optimize\` (from backend/Data, output in Data/CompiledModels), commit the regenerated model, then push.`,
137 }
138 }
139 }
140 }
141 }
142
143 return { forceRewrite: hasForce }
144}
145
146function judgeStash(args: readonly string[]): string | null {
147 const sub = args[0]
148 const shared = 'the stash stack is shared with the main checkout and every other worktree (another session may push or pop concurrently).'
149 if (sub === undefined || sub.startsWith('-')) {
150 return `bare \`git stash\` is refused: ${shared} Prefer a temporary WIP commit; if you must stash: git stash push -u -m "<unique-tag>", then git stash list --format='%H %gs' and git stash apply <sha>.`
151 }
152 if (sub === 'pop') {
153 return `\`git stash pop\` is refused: ${shared} It could pop another session's entry. Find yours by tag with git stash list --format='%H %gs', then git stash apply <sha> and drop it afterwards.`
154 }
155 if (sub === 'clear') {
156 return `\`git stash clear\` is refused: ${shared} It would drop every other session's entries. Drop only your own entry, found by its tag.`
157 }
158 if (sub === 'push' || sub === 'save') {
159 const hasMessage = args.some(arg => arg === '-m' || arg === '--message' || arg.startsWith('--message='))
160 if (sub === 'save' || !hasMessage) {
161 return `\`git stash ${sub}\` without a message is refused: ${shared} Use git stash push -u -m "<unique-tag>" so your entry can be told apart and re-found by tag.`
162 }
163 }
164 return null
165}
166
167async function judgePrCreate(args: readonly string[], ctx: GitContext): Promise<string | null> {
168 const base = flagValue(args, ['--base', '-B'])
169 const head = flagValue(args, ['--head', '-H']) ?? (await ctx.currentBranch())
170 if (base === 'master' && head !== 'develop') {
171 return `a PR to master from '${head ?? 'this branch'}' is refused: the only PR allowed to target master is the release PR from develop (release skill). Feature PRs target develop (the default base, so drop --base master).`
172 }
173 if (head !== null && PROTECTED.has(head) && base !== 'master') {
174 return `a PR from '${head}' to '${base ?? 'develop'}' is refused: develop only ever opens the release PR to master. Feature work goes on a <type>/<issue>-<slug> branch.`
175 }
176 return null
177}
178
179async function judgePrMerge(args: readonly string[], ctx: GitContext): Promise<string | null> {
180 const flags = new Set<string>()
181 let selector: string | null = null
182 for (const arg of args) {
183 if (arg.startsWith('--')) flags.add(arg.split('=')[0] ?? arg)
184 else if (arg.startsWith('-') && arg.length > 1) for (const letter of arg.slice(1)) flags.add(`-${letter}`)
185 else if (selector === null) selector = arg
186 }
187 const has = (...names: string[]) => names.some(name => flags.has(name))
188 const pr = await ctx.pr(selector)
189 if (pr === null) return null
190
191 if (pr.base === 'master') {
192 if (has('--squash', '-s', '--rebase', '-r')) {
193 return `PR #${selector ?? ''} is a release PR (develop→master): merge it with a merge commit (\`gh pr merge ${selector ?? ''} --merge\`), never squash or rebase — a squash is what creates the false conflicts on the next release.`.replace('# ', '#')
194 }
195 if (has('--delete-branch', '-d')) {
196 return `never delete develop: drop --delete-branch from the release PR merge (\`gh pr merge ${selector ?? ''} --merge\`).`
197 }
198 if (!has('--merge', '-m')) {
199 return `a release PR (develop→master) is merged with an explicit merge commit: \`gh pr merge ${selector ?? ''} --merge\` (no --delete-branch).`
200 }
201 return null
202 }
203
204 if (pr.base === 'develop') {
205 if (has('--merge', '-m', '--rebase', '-r')) {
206 return `feature PRs into develop are squash-merged: \`gh pr merge ${selector ?? ''} --squash --delete-branch\` (the PR title becomes the squash commit).`
207 }
208 if (!has('--squash', '-s')) {
209 return `say how to merge: feature PRs into develop use \`gh pr merge ${selector ?? ''} --squash --delete-branch\`.`
210 }
211 if (!has('--delete-branch', '-d')) {
212 return `add --delete-branch: the repo does not auto-delete merged branches (\`gh pr merge ${selector ?? ''} --squash --delete-branch\`). From a worktree the local cleanup step may fail after the merge succeeded — then verify with gh pr view and \`git push origin --delete ${pr.head}\`.`
213 }
214 }
215 return null
216}
217
218function flagValue(args: readonly string[], names: readonly string[]): string | null {
219 for (let i = 0; i < args.length; i += 1) {
220 const arg = args[i] ?? ''
221 for (const name of names) {
222 if (arg === name) return args[i + 1] ?? null
223 if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
224 }
225 }
226 return null
227}
228
229/** The simple commands of a shell line: split on newlines, `&&`, `||`, `;` and `|` (quotes are not honoured; the rules only read command heads). */
230export function segments(command: string): string[] {
231 return command
232 .split(/\n|&&|\|\||;|\|/)
233 .map(part => part.trim())
234 .filter(part => part !== '')
235}
236
237/** Whitespace tokens, single- and double-quoted strings kept whole and unquoted. */
238export function tokenize(segment: string): string[] {
239 const tokens: string[] = []
240 const re = /'([^']*)'|"([^"]*)"|(\S+)/g
241 let match: RegExpExecArray | null
242 while ((match = re.exec(segment)) !== null) {
243 tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
244 }
245 return tokens
246}
247
248/** Where `name` is run as a command in the tokens: at the head, or after env assignments / `sudo` / `command`. */
249function indexOfCommand(tokens: readonly string[], name: string): number {
250 for (let i = 0; i < tokens.length; i += 1) {
251 const token = tokens[i] ?? ''
252 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token) || token === 'sudo' || token === 'command' || token === 'env') continue
253 if (token === 'cd' || token === 'time' || token === 'nohup') return -1
254 return token === name || token.endsWith(`/${name}`) ? i : -1
255 }
256 return -1
257}
258