diff --git a/.claude/commands/pr-check.md b/.claude/commands/pr-check.md new file mode 100644 index 0000000..3448d3f --- /dev/null +++ b/.claude/commands/pr-check.md @@ -0,0 +1,11 @@ +Review the current git diff (staged and unstaged) against this project's conventions and flag any violations. Check each of the following: + +1. **Route placement** — every new route handler lives in `routes/`, not in `server.js` or elsewhere. +2. **Data access** — routes read and write only through `db/store.js`; no in-route state. +3. **Mounting** — any new router is mounted in `server.js` under its base path. +4. **Error shape** — all error responses are JSON `{ "error": "" }` with no other shape. +5. **Status codes** — bad input → 400, missing record → 404, created → 201, success → 200. +6. **Input validation** — required fields are validated in the route before calling the store. +7. **Tests** — a test file exists for every new route file, covering at minimum: list, 404 on missing, create (201), update, update 404. + +For each violation found, show the file, line number, and a one-line explanation. If everything looks clean, say so in one sentence. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..26a1b24 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "npm run lint", + "statusMessage": "Linting..." + } + ] + } + ] + } +} diff --git a/.claude/skills/SKILL.md b/.claude/skills/SKILL.md new file mode 100644 index 0000000..611003d --- /dev/null +++ b/.claude/skills/SKILL.md @@ -0,0 +1,151 @@ +--- +name: add-resource +description: Use when asked to add a new REST resource (route, CRUD endpoints, or "add a resource/route"). Encodes the three-file pattern this project uses: routes/.js, db/store.js additions, and tests/.test.js. +--- +Adding a new resource requires three files touched in order: the store helpers, the route, and the tests. Then mount the router in server.js. + +## 1. Store helpers — `db/store.js` + +Append to the existing file. Follow the exact shape already used for users: + +```js +let = []; +let nextId = 1; + +function seeds() { + = [ + { id: 1, : '', ... }, + ]; + nextId = 2; +} +seeds(); + +function lists() { return ; } + +function get(id) { + return .find((r) => r.id === id); +} + +function create({ }) { + const item = { id: nextId, }; + nextId += 1; + .push(item); + return item; +} + +function update(id, fields) { + const item = get(id); + if (!item) return undefined; + // apply only the fields that were provided + Object.keys(fields).forEach((k) => { + if (fields[k] !== undefined) item[k] = fields[k]; + }); + return item; +} +``` + +Export each new function alongside the existing ones. Add a call to `seeds()` inside the existing `reset()` so tests clean up this resource too. + +## 2. Route file — `routes/.js` + +```js +const express = require('express'); +const store = require('../db/store'); + +const router = express.Router(); + +// GET / — list all. +router.get('/', (req, res) => { + res.json(store.lists()); +}); + +// GET //:id — fetch one, or 404. +router.get('/:id', (req, res) => { + const item = store.get(Number(req.params.id)); + if (!item) { + return res.status(404).json({ error: ' not found' }); + } + return res.json(item); +}); + +// POST / — create. Validate required fields and return 400 on bad input. +router.post('/', (req, res) => { + const { } = req.body; + if (!) { + return res.status(400).json({ error: ' are required' }); + } + const item = store.create({ }); + return res.status(201).json(item); +}); + +// PUT //:id — partial update. +router.put('/:id', (req, res) => { + const { } = req.body; + if () { + return res.status(400).json({ error: 'at least one field is required' }); + } + const item = store.update(Number(req.params.id), { }); + if (!item) { + return res.status(404).json({ error: ' not found' }); + } + return res.json(item); +}); + +module.exports = router; +``` + +Error responses are always `{ "error": "" }`. Status codes: 200 success, 201 created, 400 bad input, 404 not found. + +## 3. Mount in `server.js` + +```js +const sRouter = require('./routes/s'); +// ... +app.use('/s', sRouter); +``` + +## 4. Test file — `tests/s.test.js` + +```js +const test = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); +const app = require('../server'); +const store = require('../db/store'); + +test.beforeEach(() => store.reset()); + +test('GET /s returns the seeded list', async () => { + const res = await request(app).get('/s'); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body)); + assert.ok(res.body.length > 0); +}); + +test('GET /s/:id returns 404 for a missing ', async () => { + const res = await request(app).get('/s/999'); + assert.equal(res.status, 404); +}); + +test('POST /s creates a ', async () => { + const res = await request(app) + .post('/s') + .send({ }); + assert.equal(res.status, 201); + assert.equal(res.body., ); + assert.ok(res.body.id); +}); + +test('PUT /s/:id updates an existing ', async () => { + const res = await request(app).put('/s/1').send({ : }); + assert.equal(res.status, 200); + assert.equal(res.body., ); +}); + +test('PUT /s/:id returns 404 for a missing ', async () => { + const res = await request(app).put('/s/999').send({ : 'x' }); + assert.equal(res.status, 404); +}); +``` + +After writing all files, run `npm test` to confirm everything passes. diff --git a/.claude/skills/add-resource/skill.md b/.claude/skills/add-resource/skill.md new file mode 100644 index 0000000..611003d --- /dev/null +++ b/.claude/skills/add-resource/skill.md @@ -0,0 +1,151 @@ +--- +name: add-resource +description: Use when asked to add a new REST resource (route, CRUD endpoints, or "add a resource/route"). Encodes the three-file pattern this project uses: routes/.js, db/store.js additions, and tests/.test.js. +--- +Adding a new resource requires three files touched in order: the store helpers, the route, and the tests. Then mount the router in server.js. + +## 1. Store helpers — `db/store.js` + +Append to the existing file. Follow the exact shape already used for users: + +```js +let = []; +let nextId = 1; + +function seeds() { + = [ + { id: 1, : '', ... }, + ]; + nextId = 2; +} +seeds(); + +function lists() { return ; } + +function get(id) { + return .find((r) => r.id === id); +} + +function create({ }) { + const item = { id: nextId, }; + nextId += 1; + .push(item); + return item; +} + +function update(id, fields) { + const item = get(id); + if (!item) return undefined; + // apply only the fields that were provided + Object.keys(fields).forEach((k) => { + if (fields[k] !== undefined) item[k] = fields[k]; + }); + return item; +} +``` + +Export each new function alongside the existing ones. Add a call to `seeds()` inside the existing `reset()` so tests clean up this resource too. + +## 2. Route file — `routes/.js` + +```js +const express = require('express'); +const store = require('../db/store'); + +const router = express.Router(); + +// GET / — list all. +router.get('/', (req, res) => { + res.json(store.lists()); +}); + +// GET //:id — fetch one, or 404. +router.get('/:id', (req, res) => { + const item = store.get(Number(req.params.id)); + if (!item) { + return res.status(404).json({ error: ' not found' }); + } + return res.json(item); +}); + +// POST / — create. Validate required fields and return 400 on bad input. +router.post('/', (req, res) => { + const { } = req.body; + if (!) { + return res.status(400).json({ error: ' are required' }); + } + const item = store.create({ }); + return res.status(201).json(item); +}); + +// PUT //:id — partial update. +router.put('/:id', (req, res) => { + const { } = req.body; + if () { + return res.status(400).json({ error: 'at least one field is required' }); + } + const item = store.update(Number(req.params.id), { }); + if (!item) { + return res.status(404).json({ error: ' not found' }); + } + return res.json(item); +}); + +module.exports = router; +``` + +Error responses are always `{ "error": "" }`. Status codes: 200 success, 201 created, 400 bad input, 404 not found. + +## 3. Mount in `server.js` + +```js +const sRouter = require('./routes/s'); +// ... +app.use('/s', sRouter); +``` + +## 4. Test file — `tests/s.test.js` + +```js +const test = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); +const app = require('../server'); +const store = require('../db/store'); + +test.beforeEach(() => store.reset()); + +test('GET /s returns the seeded list', async () => { + const res = await request(app).get('/s'); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body)); + assert.ok(res.body.length > 0); +}); + +test('GET /s/:id returns 404 for a missing ', async () => { + const res = await request(app).get('/s/999'); + assert.equal(res.status, 404); +}); + +test('POST /s creates a ', async () => { + const res = await request(app) + .post('/s') + .send({ }); + assert.equal(res.status, 201); + assert.equal(res.body., ); + assert.ok(res.body.id); +}); + +test('PUT /s/:id updates an existing ', async () => { + const res = await request(app).put('/s/1').send({ : }); + assert.equal(res.status, 200); + assert.equal(res.body., ); +}); + +test('PUT /s/:id returns 404 for a missing ', async () => { + const res = await request(app).put('/s/999').send({ : 'x' }); + assert.equal(res.status, 404); +}); +``` + +After writing all files, run `npm test` to confirm everything passes. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..205991a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "fetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + }, + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/psallay/Development/claude/kodree/docs" + ] + } + } +} \ No newline at end of file diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..998167a --- /dev/null +++ b/NOTES.md @@ -0,0 +1,45 @@ +# Notes + +## MCP server + +Two servers are wired in `.mcp.json`: `fetch` (mcp-server-fetch via uvx) and `filesystem` +(@modelcontextprotocol/server-filesystem scoped to `../docs`). The filesystem server is the +meaningful one here — it lets Claude read the project's reference docs without touching the source +tree. The permission rule in `settings.local.json` is `enabledMcpjsonServers: ["fetch", +"filesystem"]`, which opts both servers in without granting them any broader file-system access +beyond the single directory the server was started with. + +## Skill + +The `add-resource` skill captures the three-file sequence every new REST resource requires: +store helpers in `db/store.js`, a route file in `routes/`, and a test file in `tests/`, then a +one-line mount in `server.js`. Without the skill, that pattern has to be re-derived from reading +existing files every time. The description reads *"Use when asked to add a new REST resource +(route, CRUD endpoints, or 'add a \ resource/route')"* — the phrase "add a \ +resource/route" matches the natural way the request gets phrased, so the skill fires on the first +line of the prompt rather than after Claude has already started guessing the pattern. + +## Command + +`/pr-check` runs a focused review of the current git diff against this project's seven explicit +conventions: route placement, store-only data access, router mounting, error JSON shape, status +codes, input validation, and test coverage. It's worth a shortcut because it checks the same +checklist on every PR — the kind of thing that's easy to verify once but easy to forget across ten +PRs. Running it as a slash command costs one keystroke and returns a concrete file-and-line verdict +rather than a generic code review. + +## Hook + +The hook is a `PostToolUse` on the `Edit` matcher in `.claude/settings.json`. It *reacts* — it +runs `npm run lint` after every file edit rather than preventing the edit. The event is +`PostToolUse`, so ESLint sees the file as it was actually written. A `PreToolUse` guard would catch +nothing useful here (the file hasn't changed yet); a post-edit lint run is what surfaces a real +violation. + +## Headless run + +The headless run added a `products` resource — a well-scoped task with a clear definition of done +(all tests pass). The locked-down `--allowedTools` set was `Read,Write,Edit,Bash(npm test)`: the +three file-manipulation tools the task actually requires, plus a prefix-matched Bash rule that +allows exactly `npm test` and nothing else. That means the agent couldn't start the dev server, +run `rm`, or make any network call — the blast radius was defined before execution started. diff --git a/db/store.js b/db/store.js index 4c92e75..7945c1b 100644 --- a/db/store.js +++ b/db/store.js @@ -36,9 +36,37 @@ function updateUser(id, fields) { return user; } +let products = []; +let nextProductId = 1; + +function seedProducts() { + products = [ + { id: 1, name: 'Widget', price: 9.99 }, + { id: 2, name: 'Gadget', price: 24.99 }, + ]; + nextProductId = 3; +} +seedProducts(); + +function listProducts() { + return products; +} + +function getProduct(id) { + return products.find((p) => p.id === id); +} + +function createProduct({ name, price }) { + const product = { id: nextProductId, name, price }; + nextProductId += 1; + products.push(product); + return product; +} + // Reset to the seed data. Used by the tests so each one starts clean. function reset() { seed(); + seedProducts(); } -module.exports = { listUsers, getUser, createUser, updateUser, reset }; +module.exports = { listUsers, getUser, createUser, updateUser, listProducts, getProduct, createProduct, reset }; diff --git a/routes/products.js b/routes/products.js new file mode 100644 index 0000000..2834093 --- /dev/null +++ b/routes/products.js @@ -0,0 +1,30 @@ +const express = require('express'); +const store = require('../db/store'); + +const router = express.Router(); + +// GET /products — list all products. +router.get('/', (req, res) => { + res.json(store.listProducts()); +}); + +// GET /products/:id — fetch one product, or 404 if it doesn't exist. +router.get('/:id', (req, res) => { + const product = store.getProduct(Number(req.params.id)); + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + return res.json(product); +}); + +// POST /products — create a product. Requires name and price (number). +router.post('/', (req, res) => { + const { name, price } = req.body; + if (!name || price === undefined || typeof price !== 'number') { + return res.status(400).json({ error: 'name and price (number) are required' }); + } + const product = store.createProduct({ name, price }); + return res.status(201).json(product); +}); + +module.exports = router; diff --git a/server.js b/server.js index 2178f6d..f959a73 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,14 @@ const express = require('express'); const usersRouter = require('./routes/users'); const healthRouter = require('./routes/health'); +const productsRouter = require('./routes/products'); const app = express(); app.use(express.json()); app.use('/health', healthRouter); app.use('/users', usersRouter); +app.use('/products', productsRouter); const PORT = process.env.PORT || 3000; diff --git a/tests/products.test.js b/tests/products.test.js new file mode 100644 index 0000000..280a763 --- /dev/null +++ b/tests/products.test.js @@ -0,0 +1,44 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); +const app = require('../server'); +const store = require('../db/store'); + +test.beforeEach(() => store.reset()); + +test('GET /products returns the seeded list', async () => { + const res = await request(app).get('/products'); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body)); + assert.equal(res.body.length, 2); +}); + +test('GET /products/:id returns 404 for a missing product', async () => { + const res = await request(app).get('/products/999'); + assert.equal(res.status, 404); +}); + +test('POST /products creates a product', async () => { + const res = await request(app) + .post('/products') + .send({ name: 'Doohickey', price: 4.99 }); + assert.equal(res.status, 201); + assert.equal(res.body.name, 'Doohickey'); + assert.equal(res.body.price, 4.99); + assert.ok(res.body.id); +}); + +test('POST /products returns 400 when name is missing', async () => { + const res = await request(app).post('/products').send({ price: 4.99 }); + assert.equal(res.status, 400); +}); + +test('POST /products returns 400 when price is missing', async () => { + const res = await request(app).post('/products').send({ name: 'Thing' }); + assert.equal(res.status, 400); +}); + +test('POST /products returns 400 when price is not a number', async () => { + const res = await request(app).post('/products').send({ name: 'Thing', price: 'free' }); + assert.equal(res.status, 400); +});