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.
The task
Wire the complete static layer into Shelf and verify every piece fires on a planted bad input.
Part 1: ESLint custom rules
Update eslint.config.js to include a no-restricted-syntax block that bans:
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 should set both the selector and the message exactly as listed so the acceptance criteria below can grep for them. 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 that before you paste the block into eslint.config.js 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
Update tsconfig.json to enable every strict flag from the lesson, including noUncheckedIndexedAccess and exactOptionalPropertyTypes.
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
Install knip. Configure it per the lesson. Run it on Shelf.
In this local repository, the knip script sets DATABASE_URL=file:./tmp/knip.db before invoking knip. That keeps drizzle.config.ts loadable during analysis without depending on a developer-specific .env.
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: Husky and lint-staged
Install husky and lint-staged. Wire pre-commit and pre-push hooks per the lesson.
Acceptance for Part 4
-
.husky/pre-commitexists and runsnpm run pre-commit. -
.husky/pre-pushexists and runsnpm run pre-push, which in turn runs at leastnpm run typecheckandnpm run knip. -
package.jsonhaspre-commitandlint-stagedconfiguration. - Making a change with a lint error and running
npm run pre-commitagainst the staged diff aborts with the lint error visible. - Auto-fixable issues (formatting) get fixed and restaged automatically.
Part 5: Secret scanning
Install gitleaks. Wire it into lint-staged. Run it against staged content.
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.
Acceptance for Part 5
-
gitleaks versionruns on your machine. -
lint-stagedhas a gitleaks entry. -
.gitleaks.tomlallowlistssample-config.jsonandtests/fixtures/. - Running the staged-snapshot script (
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 local Shelf repository, those commands are
npm run lint,npm run typecheck, andnpm run knip. - Rules about
@ts-expect-error,eslint-disable, and--no-verify. - A secrets section per the gitleaks lesson.
- A reference back to the Playwright rules from Module 3 (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, andnpm run test. - 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 all five check commands without being reminded.
- No forbidden patterns made it into the final code.
- The PR (if you opened one) 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.