Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
370 lines (312 loc) · 16 KB
/
Copy pathMakefile
File metadata and controls
370 lines (312 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
SHELL := /bin/bash
DATE ?= $(shell date +%Y-%m-%d)
export DATE
DOCS := reports
MODEL ?= claude-sonnet-4-6
MIN_TESTS ?= 880
VENV ?= .venv
# Prefer the project's virtual environment after `make setup`; fall back to
# python3 so a fresh WSL checkout can still create that environment.
PYTHON ?= $(if $(wildcard $(VENV)/bin/python),$(VENV)/bin/python,python3)
PIP ?= $(VENV)/bin/pip
# WSL commonly exposes a Windows Node installation as node.exe while native
# Linux/macOS setups expose it as node. Resolve either form for local helpers.
NODE ?= $(shell command -v node 2>/dev/null || command -v node.exe 2>/dev/null)
# Test invocation flags. Quiet by default: `make test` output is read far more
# often by agents than by humans, and one PASSED line per test (917 and rising)
# buries the failures that actually matter. Use `make test-verbose` for the
# per-test roll-call.
PYTEST_FLAGS ?= -q --tb=short --no-header
# --dist loadfile keeps a whole test file on one worker: tests/conftest.py
# rebinds module-level path constants, so per-file affinity is the conservative
# split. Never add -p no:cacheprovider here — test-fast's --lf needs the cache.
PYTEST_PAR ?= -n auto --dist loadfile
# Failed local test runs are preserved here, with a timestamp in their file
# name. Successful runs remove their temporary log so this stays a failure
# inbox rather than a growing copy of every green run.
TEST_LOG_DIR ?= test-logs
define run_pytest
@mkdir -p $(TEST_LOG_DIR)
@log="$(TEST_LOG_DIR)/$(1)-$$(date +%Y-%m-%d_%H-%M-%S).log"; \
set -o pipefail; \
$(PYTHON) -m pytest $(2) 2>&1 | tee "$$log"; \
status=$${PIPESTATUS[0]}; \
if [ "$$status" -eq 0 ]; then rm -f "$$log"; else echo "Failure log: $$log"; fi; \
exit "$$status"
endef
.PHONY: setup platform-logos lucide css test test-verbose test-fast test-e2e test-e2e-fast test-contract test-all \
check-deps check-licenses check-secrets check-csrf check-alpine check-sw-version check-tests check-deleted \
badges check-badges check-roadmap \
checks checks-fast \
report-review report-security report-test reports \
qa fix verify release-check preflight status \
install-playwright install-hooks \
dev dev-down dev-logs seed-dev release
# NOTE: This Makefile must be run from within shelf/ (cd shelf && make ...).
# Running via `make -C shelf` will break targets that use git commands.
# ---------------------------------------------------------------------------
# One-time setup
# ---------------------------------------------------------------------------
setup:
@test -x $(PYTHON) || python3 -m venv $(VENV)
$(PIP) install -r requirements-dev.txt
npm install
$(MAKE) lucide
$(MAKE) platform-logos
$(PYTHON) -m playwright install chromium
@echo "=== Setup complete ==="
# The logos are a pinned, generated development dependency rather than files
# carried in this fork. They are deliberately ignored by Git; rerun this target
# to restore them after deleting static/icons/platforms or to refresh a clone.
platform-logos:
$(PYTHON) scripts/fetch_platform_logos.py
lucide:
"$(NODE)" scripts/copy_lucide.cjs
# ---------------------------------------------------------------------------
# Frontend assets
# ---------------------------------------------------------------------------
# Rebuild the committed Tailwind stylesheet after changing templates,
# static/js, or static/css/input.css. Resolves the dedicated v4 CLI from
# node_modules
# (version pinned in package.json) rather than re-fetching it over the network
# on every invocation — run `npm install` / `make setup` first.
# app.css is precached by the service worker, so a rebuild must rename its
# cache or browsers keep serving the stale copy. Stamping SW_VERSION from the
# precache digest here is what makes that automatic (scripts/stamp_sw_version.py).
css:
npx @tailwindcss/cli -i static/css/input.css -o static/css/app.css --minify
$(PYTHON) scripts/stamp_sw_version.py
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
test:
$(call run_pytest,unit,tests/ --ignore=tests/e2e --ignore=tests/contract -m "not live" $(PYTEST_FLAGS) $(PYTEST_PAR))
# Per-test roll-call, for when a human is reading the output.
test-verbose:
$(call run_pytest,unit-verbose,tests/ --ignore=tests/e2e --ignore=tests/contract -v --tb=short)
# Inner fix loop: re-run only what failed last time. Stays parallel because
# --lf falls back to the *whole* suite when nothing failed last run, and a
# serial fallback there costs 97s instead of 17s. (--lf is a collection-time
# filter, so it composes fine with xdist — don't add -x, which does not.)
test-fast:
$(call run_pytest,unit-fast,tests/ --ignore=tests/e2e --ignore=tests/contract $(PYTEST_FLAGS) $(PYTEST_PAR) --lf)
test-e2e:
$(call run_pytest,e2e,tests/e2e/ $(PYTEST_FLAGS) -m "e2e and not live")
# Fast E2E feedback: only the failures cached by the last E2E run. If there
# are none, pytest deliberately falls back to the complete E2E suite.
test-e2e-fast:
$(call run_pytest,e2e-fast,tests/e2e/ $(PYTEST_FLAGS) -m "e2e and not live" --lf)
# The live contract check for the UPC Item DB stub's recording. Spends one
# trial-tier lookup per run (100/day); run at release, never on a gate
# (#123). A skip means the quota was already spent — read the reason.
test-contract:
$(call run_pytest,contract,tests/contract/ $(PYTEST_FLAGS) -m live -rs)
test-all: test test-e2e
# ---------------------------------------------------------------------------
# Static checks
# ---------------------------------------------------------------------------
check-deps:
@mkdir -p $(DOCS)
set -o pipefail && $(PYTHON) -m pip_audit -r requirements.txt --desc 2>&1 | tee $(DOCS)/dep-audit-$(DATE).txt
check-licenses:
@mkdir -p $(DOCS)
$(PYTHON) -m piplicenses --format=markdown --with-urls --order=license 2>&1 | tee $(DOCS)/licenses-$(DATE).md
# A real gate, not a print statement: `git grep` exits 0 when it *finds*
# something, so the previous `git grep ... || echo` recipe passed whether or not
# it matched -- and it did match twice. Both were false positives, which is why
# the value pattern is narrow: a plausible literal secret has no spaces, braces
# or parens, so `data-token="{{ sl.token }}"` (a Jinja placeholder) and
# `indexOf('csrf_token=') === 0` (a quote boundary) no longer trip it, while an
# actual pasted key still does. Case-insensitive: the original recipe was
# not, so a literal in an API_TOKEN or SECRET constant walked straight past.
# Markdown is scanned too: a pasted key lands in a README or a docs page more
# readily than in source. The tests/ exclusion stays because fixtures need
# fake-looking token values.
check-secrets:
@echo "Scanning tracked files for hardcoded secrets..."
@if git grep -niE '(password|secret|token|api_key)\s*=\s*["'"'"'][A-Za-z0-9_./+-]{8,}["'"'"']' \
-- ':!tests/' ':!requirements*.txt'; then \
echo "ERROR: hardcoded secret literal(s) above. Move them to .env or the encrypted settings table."; \
exit 1; \
else \
echo "No hardcoded secrets found."; \
fi
check-csrf:
$(PYTHON) scripts/check_csrf_fetch.py
# Every read of items or item_copies in app/ must go through its TEMP view
# (app/database.py::get_db() creates items_live and copies_live on every
# connection), never the physical table directly — a direct read sees
# soft-deleted rows, and for item_copies a live-item's trashed copy too.
# Catches both FROM items/item_copies and JOIN items/item_copies; the latter
# has no FROM items anywhere in some files (app/routers/checkouts.py), so a
# lint that only looked for FROM would miss it silently.
check-deleted:
$(PYTHON) scripts/check_items_live.py
check-alpine:
$(PYTHON) scripts/check_alpine_csp.py
check-sw-version:
$(PYTHON) scripts/stamp_sw_version.py --check
check-tests:
$(PYTHON) scripts/check_test_conventions.py
# README's two test-count badges are generated from `pytest --co`, never
# hand-edited — the same rule as sw.js's SW_VERSION. Add a test, run this.
badges:
$(PYTHON) scripts/stamp_test_badges.py
check-badges:
$(PYTHON) scripts/stamp_test_badges.py --check
# docs/roadmap.md is a projection of the private .devdocs/ROADMAP.md; the map in
# .devdocs ties them together. Skips itself in a public clone, where .devdocs
# (a symlink into the private repo) does not exist.
check-roadmap:
$(PYTHON) scripts/check_roadmap_map.py
# Instant, offline lints — the inner-loop target.
checks-fast: check-secrets check-csrf check-deleted check-alpine check-sw-version check-tests check-badges check-roadmap
# Everything, including the network-bound pip-audit and the dated report files.
# Keep this the full set: the release procedure in ../CLAUDE.md step 1 calls it.
checks: checks-fast check-deps check-licenses
# Local release preparation. The supported Playwright version covers the
# browser suite locally; live-contract tests remain separate because they use
# external credentials and quota.
preflight:
$(MAKE) css
$(MAKE) badges
$(MAKE) test
$(MAKE) test-e2e
$(MAKE) checks
# ---------------------------------------------------------------------------
# Claude agent reports
# ---------------------------------------------------------------------------
report-review:
@mkdir -p $(DOCS)
@test ! -f $(DOCS)/CODE_REVIEW_$(DATE).md || (echo "WARN: $(DOCS)/CODE_REVIEW_$(DATE).md already exists — use 'make FORCE=1 report-review' to overwrite"; [ "$(FORCE)" = "1" ] || exit 1)
@output=$$(claude --model $(MODEL) --max-turns 30 --allowedTools "Write,Edit,Read,Glob,Grep,Bash" -p \
"Review the shelf/ codebase. Write a comprehensive code review report to shelf/reports/CODE_REVIEW_$(DATE).md. \
Use these severity levels: CRITICAL (security/data-loss), HIGH (correctness/reliability), MEDIUM (maintainability), LOW (style/nits)." \
2>&1); echo "$$output"; \
if echo "$$output" | grep -q "Reached max turns"; then \
echo "ERROR: report-review hit max turns — report may be incomplete"; exit 1; \
fi
@test -f $(DOCS)/CODE_REVIEW_$(DATE).md || (echo "ERROR: report-review produced no output file"; exit 1)
report-security:
@mkdir -p $(DOCS)
@test ! -f $(DOCS)/SECURITY_AUDIT_$(DATE).md || (echo "WARN: $(DOCS)/SECURITY_AUDIT_$(DATE).md already exists — use 'make FORCE=1 report-security' to overwrite"; [ "$(FORCE)" = "1" ] || exit 1)
@output=$$(claude --model $(MODEL) --max-turns 30 --allowedTools "Write,Edit,Read,Glob,Grep,Bash" -p \
"Audit the shelf/ codebase for security issues. Write findings to shelf/reports/SECURITY_AUDIT_$(DATE).md. \
Use these severity levels: CRITICAL (security/data-loss), HIGH (correctness/reliability), MEDIUM (maintainability), LOW (style/nits)." \
2>&1); echo "$$output"; \
if echo "$$output" | grep -q "Reached max turns"; then \
echo "ERROR: report-security hit max turns — report may be incomplete"; exit 1; \
fi
@test -f $(DOCS)/SECURITY_AUDIT_$(DATE).md || (echo "ERROR: report-security produced no output file"; exit 1)
report-test:
@mkdir -p $(DOCS)
@test ! -f $(DOCS)/TEST_AUDIT_$(DATE).md || (echo "WARN: $(DOCS)/TEST_AUDIT_$(DATE).md already exists — use 'make FORCE=1 report-test' to overwrite"; [ "$(FORCE)" = "1" ] || exit 1)
@output=$$(claude --model $(MODEL) --max-turns 30 --allowedTools "Write,Edit,Read,Glob,Grep,Bash" -p \
"Audit test coverage for shelf/. Identify gaps and write findings to shelf/reports/TEST_AUDIT_$(DATE).md. \
Use these severity levels: CRITICAL (security/data-loss), HIGH (correctness/reliability), MEDIUM (maintainability), LOW (style/nits)." \
2>&1); echo "$$output"; \
if echo "$$output" | grep -q "Reached max turns"; then \
echo "ERROR: report-test hit max turns — report may be incomplete"; exit 1; \
fi
@test -f $(DOCS)/TEST_AUDIT_$(DATE).md || (echo "ERROR: report-test produced no output file"; exit 1)
reports:
$(MAKE) -j3 report-review report-security report-test
# ---------------------------------------------------------------------------
# Full QA pipeline (Pass 1)
# ---------------------------------------------------------------------------
qa: test-all checks reports
@echo ""
@echo "=== QA COMPLETE ==="
@echo "Reports in $(DOCS)/. Review them, then run: make fix"
# ---------------------------------------------------------------------------
# Fix & verify (Pass 2)
# ---------------------------------------------------------------------------
fix:
@output=$$(claude --model $(MODEL) --max-turns 75 --allowedTools "Write,Edit,Read,Glob,Grep,Bash" -p \
"Read the latest audit reports in $(DOCS)/ (CODE_REVIEW, SECURITY_AUDIT, TEST_AUDIT). \
Fix all critical and high severity issues. Skip low/info items unless trivial. \
Write tests for any code you change. \
When done, write a summary of all changes to $(DOCS)/FIX_SUMMARY_$(DATE).md." \
2>&1); echo "$$output"; \
if echo "$$output" | grep -q "Reached max turns"; then \
echo "WARNING: Fix agent hit turn limit — fixes may be incomplete"; \
fi
@echo ""; echo "=== Changes made by fix agent ==="; git diff --stat || true
$(MAKE) verify
# The count regex is anchored to the start of the line on purpose. A bare
# '\d+' also matches the digits in "in 0.49s", yielding a multi-line count
# that makes the comparison die with "integer expected" — which bash treats
# as false, silently passing the guard no matter how many tests were deleted.
verify: test-all
@count=$$($(PYTHON) -m pytest tests/ --ignore=tests/e2e --ignore=tests/contract --co -q 2>/dev/null \
| grep -oP '^\d+(?= tests? collected)' | tail -1); \
if [ -z "$$count" ]; then \
echo "ERROR: could not determine unit test count"; exit 1; \
fi; \
if [ "$$count" -lt $(MIN_TESTS) ]; then \
echo "ERROR: Unit test count $$count < minimum $(MIN_TESTS)"; exit 1; \
fi; \
echo "Unit test count: $$count (minimum $(MIN_TESTS))"
@echo "=== VERIFICATION PASSED ==="
# ---------------------------------------------------------------------------
# Dev instance management
# ---------------------------------------------------------------------------
PROD_DIR ?= $(HOME)/shelf-prod
dev:
docker compose up -d --build
dev-local:
@set -e; \
npx @tailwindcss/cli -i static/css/input.css -o static/css/app.css --minify --watch --poll & \
css_pid=$$!; \
WATCHFILES_FORCE_POLLING=true DATA_DIR=./data-dev .venv/bin/uvicorn app.main:app --reload & \
app_pid=$$!; \
npx browser-sync start --config scripts/browser-sync.cjs & \
sync_pid=$$!; \
trap 'kill $$css_pid $$app_pid $$sync_pid 2>/dev/null || true' EXIT INT TERM; \
wait $$app_pid
dev-down:
docker compose down
dev-logs:
docker compose logs -f
seed-dev:
@test -f $(PROD_DIR)/data/shelf.db || (echo "ERROR: No prod DB at $(PROD_DIR)/data/shelf.db"; exit 1)
mkdir -p data-dev/covers
cp $(PROD_DIR)/data/shelf.db data-dev/shelf.db
cp -r $(PROD_DIR)/data/covers/ data-dev/covers/ 2>/dev/null || true
@echo "Dev data seeded from prod. Certs will auto-generate on first run."
release:
docker build -t shelf:prod .
@echo ""
@echo "Tagged shelf:prod. To deploy:"
@echo " cd $(PROD_DIR) && ./upgrade.sh"
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
status:
@echo "=== QA Pipeline Status ==="
@echo ""
@echo "Reports:"
@for prefix in CODE_REVIEW SECURITY_AUDIT TEST_AUDIT; do \
latest=$$(ls -1t $(DOCS)/$${prefix}_*.md 2>/dev/null | head -1); \
if [ -n "$$latest" ]; then \
echo " $$prefix: $$latest"; \
else \
echo " $$prefix: (none)"; \
fi; \
done
@echo ""
@echo "Last test run:"
@$(PYTHON) -m pytest tests/ --ignore=tests/e2e --tb=no -q 2>/dev/null | tail -1 || echo " (no test results)"
# ---------------------------------------------------------------------------
# Aliases
# ---------------------------------------------------------------------------
release-check: qa
# ---------------------------------------------------------------------------
# Legacy aliases (kept for backwards compatibility)
# ---------------------------------------------------------------------------
install-playwright: setup
install-hooks:
@echo '#!/bin/bash' > ../.git/hooks/pre-push
@echo 'cd shelf && make test-all' >> ../.git/hooks/pre-push
@chmod +x ../.git/hooks/pre-push
@echo "Pre-push hook installed."