SLOPSHOPPER

truemain-guard

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…

newguardtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · truemain-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ truemain-guard │ ⏺ Read(src/auth.ts) │ truemain-guard: --force rewritten to │ ⎿ Read 6 lines │ --force-with-lease. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

TrueMain

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.

The idea

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

How it works

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)

Ingestor — the data pipeline

A .NET worker that runs a set of scheduled, independently recorded processes:

  • Discovery walks ranked ladders across platforms to find candidate players.
  • Match ingestion / harvest pulls solo/duo (queue 420) match and timeline data for known accounts. Other queues are dropped at ingest — TrueMain is ranked-only by design.
  • Scoring / main analysis computes the games-vs-mastery signal that promotes a player to true main status for a champion.
  • Aggregation folds match participants into per-champion aggregates: builds, runes, skill orders, matchups (with their 15-minute lane outcomes and gold/XP gaps), synergies, bans, kill positions. Aggregates are computed per patch and rank scope; past patches are frozen once their live matches are retired.
  • Retention trims raw match data that has already been aggregated, keeping the database bounded.

Api

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 & Core

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

web

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.

admin

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.

Repository layout

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)

Infrastructure

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.

Further reading

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

License

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.

Source 2 files
hooks/register.ts 61 lines
1import 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}
61
hooks/rules.ts 258 lines
1// 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