Longest lab of the day. Multi-part. Pace yourself—each part is a self-contained check, and you can stop between parts if you need to.
In the current starter
Shelf now starts smaller on purpose. The starter already has ESLint, TypeScript, and the minimal Playwright loop, but it does not ship knip.json, lefthook.yml, .gitleaks.toml, or scripts/run-gitleaks-staged.ts. This lab is where you add those files and then verify each one against a planted bad input.
The task
Build out the full static layer for Shelf and verify every piece fires on a planted bad input. For each part: add or extend the file, read back over the rule you just created, then trigger the probe and watch it catch.
What you can verify locally
Everything in Parts 1 through 5 is local and mechanical: extend eslint.config.js and tsconfig.json, create knip.json, lefthook.yml, .gitleaks.toml, and scripts/run-gitleaks-staged.ts, run npm run lint, npm run typecheck, npm run knip, npm run test, and npm run pre-push, plant one bad input at a time, then watch the right layer fail. Part 6 is local too if you are updating your own CLAUDE.md or Codex instructions file.
What remains manual or external
The only non-mechanical part is judging the agent behavior at the end. The final prompt is there to test whether the instructions and static layer are strong enough that the agent reads the error, repairs the code, and reruns the checks without coaching. If you decide to open a pull request afterward, treat that as an optional hosted follow-up, not a requirement for finishing the lab locally.
Part 1: ESLint custom rules
Open eslint.config.js. Find the no-restricted-syntax block. It bans four patterns:
page.waitForTimeout(anywhere intests/end-to-end/). Selector:CallExpression[callee.property.name='waitForTimeout']. Message:"page.waitForTimeout is banned. See CLAUDE.md → Playwright → Waiting."page.locatorcalled with a string argument (anywhere intests/end-to-end/). Selector:CallExpression[callee.property.name='locator'][arguments.0.type='Literal']. Message:"Use a getByRole/getByLabel locator. See CLAUDE.md → Playwright → Locators."page.waitForLoadState('networkidle')(anywhere). Selector:CallExpression[callee.property.name='waitForLoadState'] Literal[value='networkidle']. Message:"networkidle is unreliable. Wait on a real signal."- Reading
userIdfrom a request body in a route handler. Selector:MemberExpression[object.type='MemberExpression'][object.property.name='body'][property.name='userId']. Message:"Read userId from the session, not the request body. See CLAUDE.md → Auth."
Each rule has both a selector and a message. The message strings are load-bearing — they name the file, the section, and the fix. That’s the difference between a lint error the agent ignores and a lint error the agent reads and acts on.
The lesson’s Writing a no-restricted-syntax rule section in Lint and Types as Guardrails walks each of these four AST selectors in English. Read it alongside the file you build here so you understand why the body.userId rule uses a nested MemberExpression match instead of just a property name, and why the networkidle rule uses the descendant combinator.
In the Shelf workshop repository, npm is the source of truth. If your own project uses Bun, translate the commands back to bun as appropriate. The important part is that the checks are real, named scripts the agent is required to run.
Acceptance for Part 1
-
eslint.config.jscontains the four restricted-syntax rules above (selector + message). - Running
npm run linton the current (clean) Shelf repository exits zero. - Adding a
page.waitForTimeout(1000)line to any file undertests/end-to-end/makesnpm run lintexit non-zero, and the output contains the substringpage.waitForTimeout is banned. - Adding a
page.locator('.foo')line to any file undertests/end-to-end/makesnpm run lintexit non-zero, and the output contains the substringUse a getByRole/getByLabel locator. - Reverting the test changes restores a clean lint (
npm run lintexits zero).
Part 2: TypeScript strict mode
Open tsconfig.json. Find every strict flag from the lesson — strict: true, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noUnusedLocals, noUnusedParameters, noImplicitReturns, noFallthroughCasesInSwitch. They’re all there. Read each one and make sure you can explain, in one sentence, what kind of bug it catches that strict: true alone would miss.
Acceptance for Part 2
-
tsconfig.jsonhasstrict: true. -
tsconfig.jsonexplicitly enablesnoUncheckedIndexedAccess,exactOptionalPropertyTypes,noUnusedLocals, andnoUnusedParameters. -
npm run typecheckexits zero on the current Shelf source. - Adding
const x: string = items[0](without a guard) to any file produces a typecheck error.
Part 3: Dead code detection
Create knip.json. Set the entry and project globs so knip knows which files are roots (SvelteKit pages, tests, scripts) and which files are in-scope for the unused-exports analysis. Then run npm run knip to see it report zero findings against the current repo.
In the current Shelf starter, drizzle.config.ts can already fall back to file:./local.db, so you may not need an extra DATABASE_URL=... shim at all. If your own project’s config crashes without one, add a throwaway database URL in the script there. The point of the lab is the dead-code loop, not env gymnastics.
Acceptance for Part 3
-
knip.jsonexists with sensibleentryandprojectglobs. -
npm run knipexits zero on the current Shelf repo. - Adding an unreferenced
.tsfile undersrc/lib/causesnpm run knipto report it as unused. - Removing the file restores clean knip output.
- If your repository still contains a retired subtree like
src/lib/legacy-auth/, it is explicitly ignored. If your local Shelf clone does not contain that directory, do not invent it just to satisfy the lab.
Part 4: Lefthook
Create lefthook.yml. Add pre-commit and pre-push blocks. Pre-commit should run fast checks on {staged_files} with parallel: true and stage_fixed: true, while pre-push should run the slightly-slower npm run pre-push script (which calls typecheck, knip, and unit tests) against the whole tree. Read the Git Hooks with Lefthook lesson if you want the reasoning behind the split.
Acceptance for Part 4
-
lefthook.ymlexists at the repo root. -
pre-commitruns ESLint, Prettier, and a secret scan against{staged_files}, all markedparallel: true, and any auto-fixed files are restaged viastage_fixed: true. -
pre-pushrunsnpm run pre-push, which in turn runs at leastnpm run typecheckandnpm run knip. - Making a change with a lint error and running
lefthook run pre-commitaborts with the lint error visible. - Auto-fixable issues (formatting) get fixed and restaged automatically.
Part 5: Secret scanning
Add a secrets command under pre-commit in lefthook.yml so it shells out to npx tsx scripts/run-gitleaks-staged.ts. Then read back over that script and make sure it materializes the staged index into a tmp directory before running gitleaks dir.
Create a small bait file such as sample-config.json during the lab so you have one harmless false-positive to allowlist on purpose. In the current Shelf starter, tests/data/users.json is another deliberate allowlist candidate because it stores fake workshop credentials the seeding labs depend on.
With the current Gitleaks release used in this workshop, gitleaks git --staged was not a reliable pre-commit verifier for newly added files. The local Shelf repository fixes that by materializing the exact git index into a temporary directory and running gitleaks dir there from scripts/run-gitleaks-staged.ts. That wrapper is what the lefthook secrets command shells out to.
Acceptance for Part 5
-
gitleaks versionruns on your machine. -
lefthook.ymlhas asecretscommand underpre-committhat invokesnpx tsx scripts/run-gitleaks-staged.ts. -
.gitleaks.tomlallowlistssample-config.jsonandtests/data/users.json(ortests/data/if you prefer the directory-level rule). - Running the staged-snapshot script directly (
npx tsx scripts/run-gitleaks-staged.ts) exits zero for the clean staged state. - Attempting to stage a file containing
BETTER_AUTH_SECRET="7Xse4XqnSo3hcT31Yb2vi7LMt6BYI93w.0EWmIcjHKAdde1SY5TEVqh5fPu6NvFBf"triggers the hook and blocks the commit. - The
sample-config.jsonfile can still be committed without issue (the allowlist works).
Part 6: the CLAUDE.md update
Add sections to CLAUDE.md that reflect every layer you wired up. At minimum:
- A “static checks” section listing
npm run lint,npm run typecheck, andnpm run knipas mandatory pre-done commands. - In the Shelf starter, “done” only means
npm run typecheck,npm run lint, andnpm run test. This lab is where you extend that definition to includenpm run knipandnpm run pre-push. - Rules about
@ts-expect-error,eslint-disable, and--no-verify. - A secrets section per the gitleaks lesson.
- A reference back to the Playwright rules (locators, waiting, auth) so the custom lint rules are connected to the same source of truth.
Acceptance for Part 6
-
CLAUDE.mdlists all static check commands under a “what done means” heading. -
CLAUDE.mdexplicitly bans bypassing via@ts-expect-error,eslint-disable, or--no-verify. - The sections cross-reference each other where rules overlap (e.g., the lint rule for
waitForTimeoutpoints at the same source as the Playwright waiting rules). -
wc -l CLAUDE.mdstill reports a number under 150. (It’s going to grow; that’s fine. Just don’t let it become a novel.)
End-to-end verification
Once every layer is in place, hand the agent a single prompt that exercises all of them:
Add a new route
/shelf/exportthat lets a user download their shelf as a JSON file. Include a Playwright test for it. Run all static checks and the tests before declaring done.
Watch what the agent does. The correct behavior is:
- It writes the route handler.
- It writes the Playwright test.
- It runs
npm run lint,npm run typecheck,npm run knip,npm run test, andnpm run pre-push(which covers unit tests). - If any of them fail, it reads the error, fixes it, and re-runs.
- It does not use
page.waitForTimeout, does not usepage.locatorwith a CSS selector, does not use UI login, does not readuserIdfrom the request body, does not leave dead code behind.
If the agent does any of the forbidden things, go look at why the static layer didn’t catch it. That’s the gap.
Final acceptance
- The end-to-end prompt above completed with the agent running
npm run lint,npm run typecheck,npm run knip,npm run test, andnpm run pre-pushwithout being reminded. - No forbidden patterns made it into the final code.
- If you opened a pull request, it is clean.
- Every single checkbox in Parts 1 through 6 is ticked.
Stretch goals
- Add a Claude-specific
PostToolUsehook that runsnpm run lint -- --quietafter every edit, per the Claude hooks lesson. - Add a pinned knip-count script to pre-push that fails if the unused count goes up from a committed baseline (the ratchet pattern).
- Configure dependency-cruiser with the
no-orphansrule and, if your repository still has that boundary, thelegacy-authrule, and run it in pre-push. - Write a tiny report at the end of the lab: which layer caught what, and which layer never fired. The layers that never fired might be too loose (or might just be working so well there’s nothing to catch).
The one thing to remember
The static layer is five tools and an hour of setup. Every one of them runs under everything else, catches a specific class of mistake at the cheapest possible moment, and compounds with the others. At the end of this lab, the agent is working under a feedback net so tight that most mistakes can’t survive long enough to reach the tests we spent the morning building.