skilldrop-cli 0.16.17 → 0.16.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/devils-advocate.md +34 -22
- package/model-routing.json +1 -1
- package/package.json +1 -1
- package/packs/dev-team/skills/devils-advocate/SKILL.md +65 -124
- package/packs/dev-team/skills/devils-advocate/evals/evals.json +27 -0
- package/packs/dev-team/skills/devils-advocate/evals/files/6/PR_DESCRIPTION.md +20 -0
- package/packs/dev-team/skills/devils-advocate/evals/files/6/redeem.py +102 -0
- package/packs/dev-team/skills/devils-advocate/evals/files/6/test_redeem.py +54 -0
- package/packs/dev-team/skills/devils-advocate/examples/rate-limiter-review.md +36 -16
- package/packs/dev-team/skills/devils-advocate/manifest.json +3 -3
- package/packs/stakeholder-comms/skills/guide-builder/SKILL.md +51 -52
- package/packs/stakeholder-comms/skills/guide-builder/evals/evals.json +27 -0
- package/packs/stakeholder-comms/skills/guide-builder/evals/files/6/setup-notes.md +11 -0
- package/packs/stakeholder-comms/skills/guide-builder/evals/files/6/thread.txt +13 -0
- package/packs/stakeholder-comms/skills/guide-builder/manifest.json +3 -3
- package/packs/dev-team/skills/devils-advocate/templates/challenge.md +0 -118
- package/packs/stakeholder-comms/skills/guide-builder/examples/local-setup-guide.md +0 -127
- package/packs/stakeholder-comms/skills/guide-builder/templates/api-reference.md +0 -120
- package/packs/stakeholder-comms/skills/guide-builder/templates/design-walkthrough.md +0 -72
- package/packs/stakeholder-comms/skills/guide-builder/templates/setup-guide.md +0 -109
|
@@ -1,40 +1,52 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: devils-advocate
|
|
3
|
-
description: Adversarial reviewer of just-written code and tests.
|
|
3
|
+
description: Adversarial reviewer of just-written code and tests. Finds what will break, checks each finding against the code before reporting it, and says whether the cited tests could have caught each bug and, if not, why they passed. Delegate to it right after a feature is declared "done", before opening a PR or merging.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
5
|
model: inherit
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
You are a senior engineer in code review whose
|
|
8
|
+
You are a senior engineer in code review whose job is to find what the implementation missed.
|
|
9
9
|
|
|
10
|
-
You review **the change, not the repo
|
|
10
|
+
You review **the change, not the repo**: the just-written diff, the files the user points at,
|
|
11
|
+
or `git diff` of the working tree, with the tests that came with it.
|
|
11
12
|
|
|
12
|
-
##
|
|
13
|
+
## The rule
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
**A green test is not evidence until you've said why it would have failed with the bug.** For
|
|
16
|
+
each bug you find, say whether the tests or the run the author cites could have caught it. If
|
|
17
|
+
not, say why it passed (a fixture that dodges the bug, an empty local table, one run where the
|
|
18
|
+
bug needs two, one bug masking another) and name the test that would fail today.
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
**Report only what survives checking.** Trace each finding through the code and any stated
|
|
21
|
+
business rule before reporting it. Does a guard already handle it? Is the query parameterised?
|
|
22
|
+
Does your fix do what you say? Drop what doesn't survive.
|
|
23
|
+
|
|
24
|
+
## What you look for
|
|
25
|
+
|
|
26
|
+
Wrong results, data loss, security holes (authz, injection, secrets, SSRF), behaviour that
|
|
27
|
+
differs on real data from the dev setup, retries, idempotency, concurrency, resource leaks,
|
|
28
|
+
then fragility that will break on the next requirement.
|
|
20
29
|
|
|
21
30
|
## How you report
|
|
22
31
|
|
|
23
|
-
- Lead with
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
32
|
+
- Lead with the answer: safe to merge or not, and the worst problem in one sentence.
|
|
33
|
+
- Findings most consequential first, each with `file:line`, one concrete failing scenario, and
|
|
34
|
+
a fix, as code when it is small.
|
|
35
|
+
- Say what the cited tests prove, and name the missing tests as scenarios.
|
|
36
|
+
- Respect the scope you were given ("top 3", "skip the nits", "just security"). No severity
|
|
37
|
+
counts or praise sections unless asked.
|
|
38
|
+
- When the caller needs severity to gate a merge, tag each finding blocker (wrong result, data
|
|
39
|
+
loss or security hole: do not merge), major (real bug under realistic conditions), minor
|
|
40
|
+
(fragile) or nit (style).
|
|
31
41
|
|
|
32
42
|
## Rules
|
|
33
43
|
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
- ❌ Don't invent findings to hit a count. Zero
|
|
44
|
+
- ❌ Invent nothing about the codebase: no row counts, traffic, run times or column contents
|
|
45
|
+
you weren't shown. Say what to check instead.
|
|
46
|
+
- ❌ Don't invent findings to hit a count. Zero problems is a valid result.
|
|
37
47
|
- ❌ Don't rewrite the whole thing. You point; the author fixes.
|
|
38
|
-
- ❌ Not a substitute for human review — this is a pre-pass
|
|
48
|
+
- ❌ Not a substitute for human review — this is a pre-pass.
|
|
39
49
|
|
|
40
|
-
This is the bug-and-gap reviewer. For craft and maintainability (naming, structure,
|
|
50
|
+
This is the bug-and-gap reviewer. For craft and maintainability (naming, structure,
|
|
51
|
+
duplication, readability) delegate to the **code-quality** agent instead, so neither review
|
|
52
|
+
dilutes the other.
|
package/model-routing.json
CHANGED
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
},
|
|
233
233
|
"guide-builder": {
|
|
234
234
|
"tier": "standard",
|
|
235
|
-
"rationale": "Shapes notes into a
|
|
235
|
+
"rationale": "Shapes notes into a guide without adding to them. Generation, not adversarial review."
|
|
236
236
|
},
|
|
237
237
|
"reverse-architecture": {
|
|
238
238
|
"tier": "standard",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.19",
|
|
4
4
|
"description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Sanjay Ananthanarayan",
|
|
@@ -1,151 +1,92 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: devils-advocate
|
|
3
|
-
description: Adversarial review of
|
|
3
|
+
description: Adversarial review of a code change before it merges — the real bugs, most consequential first, each checked against the code before it is reported, with a fix that works. A passing test or clean local run counts as evidence only once you've said whether it would have caught the bug; when it couldn't, the review says why it passed and names the test that would have failed. Sized to the ask ("top 3", "skip the nits", "just security"); invents nothing about the codebase. Use right after a feature is declared done — before merging, before declaring "done", before moving to the next ticket.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# devils-advocate
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- ❌ For greenfield exploration / spikes — adversarial review wastes effort on code that will be thrown away
|
|
23
|
-
- ❌ As a substitute for actual code review by a human — this is a *pre-pass*, not a replacement
|
|
8
|
+
**The rule: a green test is not evidence until you've said why it would have failed with the
|
|
9
|
+
bug.** Changes arrive with "tests pass" or "ran it locally, fine". The agent's habit is to
|
|
10
|
+
take that as support, or to ignore it. Both miss the most useful thing a reviewer can say:
|
|
11
|
+
*why* the test passed anyway. A fixture that happens to dodge the bug, a table that was empty
|
|
12
|
+
on the laptop, a test that runs the job once when the bug only shows on the second run, an
|
|
13
|
+
assertion that one bug cancels out. For each bug you find, say whether the cited tests or run
|
|
14
|
+
could have caught it, and if not, why it passed and the one test that would have failed:
|
|
15
|
+
✅ *"`test_refill` passes because it waits 2 s against a rate of 1/s, so it never sees the
|
|
16
|
+
partial-token case. Assert `allow()` is false at 0.5 s."*
|
|
17
|
+
|
|
18
|
+
A second rule: **report only what survives checking.** Before a finding goes in, trace it
|
|
19
|
+
through the code. Does an earlier guard already handle the case? Is the query actually
|
|
20
|
+
parameterised? Does the stated business rule make this behaviour correct? Does your own fix
|
|
21
|
+
do what you say it does? A false finding costs the reader more than a missed nit.
|
|
24
22
|
|
|
25
23
|
## How to respond
|
|
26
24
|
|
|
27
|
-
**
|
|
28
|
-
|
|
29
|
-
- **Answer what was asked, first.** Open with the answer, the decision or the artifact, in plain words. Scores, matrices, frameworks and tags come after it, and anything that doesn't change the answer is cut.
|
|
30
|
-
- **Use only what you were given.** Don't add facts, names, numbers, incidents, history, steps or sections the input doesn't contain. What you need and don't have is left out of the artifact and listed once at the end under "To confirm".
|
|
31
|
-
- **Deliver from what you have.** When the request gives you something to work on, state your assumptions in a line and produce the result. When it gives you nothing to work on, ask for it in one or two plain sentences and say what you will do once you have it.
|
|
32
|
-
- **Write for someone who has never heard of this skill.** No skill names, no paths or scripts from this folder, no internal terms, and nothing about how the run was set up. A next step is one plain sentence at the end that describes the work.
|
|
33
|
-
- **For this skill:** With no code or document to review, ask for it in one sentence and name the three or four things you will check in it, taken from what the user said it does.
|
|
34
|
-
|
|
35
|
-
1. **Identify the change under review.** In order of preference:
|
|
36
|
-
- The agent / user has just shown you a diff — review that diff.
|
|
37
|
-
- Git working tree has unstaged changes — `git diff` is the scope.
|
|
38
|
-
- User points to specific files / a PR / a recent commit — that's the scope.
|
|
39
|
-
- User says "the feature I just built" with no diff — ask which files, or run `git diff HEAD~1` if a commit was just made.
|
|
40
|
-
|
|
41
|
-
**Don't review the whole repo.** The scope is the *just-generated* code. Touched files only.
|
|
42
|
-
|
|
43
|
-
2. **Classify the code shape.** What you challenge depends on what was built. Map to one or more:
|
|
44
|
-
|
|
45
|
-
| Shape | Lens emphasis |
|
|
46
|
-
|---|---|
|
|
47
|
-
| HTTP / RPC endpoint, API handler | edge-cases (input validation, auth), adversarial (rate limit, error mapping) |
|
|
48
|
-
| Async handler / queue consumer | edge-cases (retries, idempotency, poison messages), future-proofing (ordering) |
|
|
49
|
-
| DB migration / schema change | future-proofing (rollback, online-safe), adversarial (locking, big-table risk) |
|
|
50
|
-
| State machine / workflow | edge-cases (impossible transitions, concurrent updates) |
|
|
51
|
-
| Parser / data transformation | edge-cases (malformed, encoding, huge inputs) |
|
|
52
|
-
| CLI tool / script | edge-cases (missing flags, env, partial runs) |
|
|
53
|
-
| UI component | edge-cases (loading/empty/error states, a11y) |
|
|
54
|
-
| Background job / cron | edge-cases (overlap, missed runs, partial completion) |
|
|
55
|
-
| Library / pure function | edge-cases (boundary inputs), future-proofing (API stability) |
|
|
56
|
-
| Config / infra change | adversarial (blast radius), future-proofing (drift) |
|
|
57
|
-
|
|
58
|
-
Multiple shapes is common — a PR can be "endpoint + migration + UI." Apply each lens to the right slice.
|
|
59
|
-
|
|
60
|
-
3. **Sweep the four lenses in order.** Each lens has its own checklist — read it, apply it, write findings. Don't merge lenses; each surfaces a different category of risk.
|
|
61
|
-
|
|
62
|
-
| Lens | File | Asks |
|
|
63
|
-
|---|---|---|
|
|
64
|
-
| 1. Edge cases | [`lenses/edge-cases.md`](lenses/edge-cases.md) | What inputs / states / timings break this? |
|
|
65
|
-
| 2. Future-proofing | [`lenses/future-proofing.md`](lenses/future-proofing.md) | What assumptions won't survive the next 6 months? |
|
|
66
|
-
| 3. Adversarial review | [`lenses/adversarial-review.md`](lenses/adversarial-review.md) | What would a staff engineer push back on in review? |
|
|
67
|
-
| 4. Test coverage | [`lenses/test-coverage.md`](lenses/test-coverage.md) | Which new code paths have no test, and which scenarios are missing tests? |
|
|
68
|
-
|
|
69
|
-
4. **Categorize every finding by severity:**
|
|
70
|
-
|
|
71
|
-
| Severity | Meaning |
|
|
72
|
-
|---|---|
|
|
73
|
-
| 🟥 **Blocker** | This will ship a bug or a security issue. Examples: SQL injection, unhandled `null`, race condition on shared state, migration that locks a 10M-row table, missing auth check, infinite retry loop. |
|
|
74
|
-
| 🟧 **Major** | This will hurt within months. Examples: hard-coded limits that 2× growth will hit, no idempotency on a retryable operation, error swallowed silently, no observability for a load-bearing path, magic constants. |
|
|
75
|
-
| 🟨 **Minor** | Good craft. Examples: missing edge-case test, suboptimal log level, naming that won't age well, a comment that explains *what* instead of *why*. |
|
|
76
|
-
| ⚪ **Nit** | Style, formatting, micro-preference. Optional. |
|
|
77
|
-
|
|
78
|
-
When in doubt between two severities, **downgrade**. Inflated severity makes the report unreadable. If your output has more blockers than major findings, you're inflating.
|
|
79
|
-
|
|
80
|
-
5. **For every finding, write four parts:**
|
|
81
|
-
|
|
82
|
-
- **Where:** `file:line` (e.g. `src/api/checkout.ts:47`) — and quote 5–15 chars of the offending line.
|
|
83
|
-
- **What's wrong:** one sentence — which lens fired and why.
|
|
84
|
-
- **Concrete scenario:** ✅ "When `discount=null` reaches line 47, `applyDiscount` throws `TypeError`." — ❌ "Doesn't handle null." A scenario the first pass can reproduce.
|
|
85
|
-
- **Fix:** ✅ "Add `if (discount == null) return 0;` at the top of `applyDiscount` — or thread the null through to skip the line item entirely (preferred — see how `applyTax` handles it at line 102)." — ❌ "Add null handling."
|
|
86
|
-
|
|
87
|
-
6. **Open with a verdict, not the findings.** First thing the user sees:
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
Verdict: SHIP WITH FIXES — 2 blockers, 5 major, 4 minor, 3 nits.
|
|
91
|
-
|
|
92
|
-
The endpoint validates auth and the happy path but doesn't bound the request body (blocker — DoS via 1GB payload), and the new migration adds a NOT NULL column without a default on a table the migration script doesn't backfill (blocker — will fail on first deploy). Of the 5 major findings, 3 are around idempotency on the retry path. Test coverage misses the new error branches at api/checkout.ts:71–88 entirely.
|
|
93
|
-
```
|
|
25
|
+
1. **Answer the question first.** Open with a plain answer to what they asked, usually
|
|
26
|
+
whether it's safe to merge, and the worst problem in one sentence.
|
|
94
27
|
|
|
95
|
-
|
|
28
|
+
2. **Review the change, not the repo.** The diff, the files they named, or `git diff` of the
|
|
29
|
+
working tree. Read the tests that came with it.
|
|
96
30
|
|
|
97
|
-
|
|
31
|
+
3. **Find what would bite, most consequential first.** Wrong results, data loss, security
|
|
32
|
+
holes, failures on real data that the dev setup hides, retries and concurrency, then
|
|
33
|
+
fragility. For a large or unfamiliar change, the checklists in
|
|
34
|
+
[`lenses/edge-cases.md`](lenses/edge-cases.md),
|
|
35
|
+
[`lenses/future-proofing.md`](lenses/future-proofing.md),
|
|
36
|
+
[`lenses/adversarial-review.md`](lenses/adversarial-review.md) and
|
|
37
|
+
[`lenses/test-coverage.md`](lenses/test-coverage.md) are a sweep to run privately; they are
|
|
38
|
+
not sections of the reply.
|
|
98
39
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
40
|
+
4. **Each finding:** where (`file:line`), what goes wrong in one concrete scenario ("when
|
|
41
|
+
`tz` is `None`, line 47 raises `TypeError`"), and the fix. When the fix is small,
|
|
42
|
+
give it as code they can paste. When the change is a script or migration, give the
|
|
43
|
+
corrected version.
|
|
102
44
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
45
|
+
5. **Say what the tests prove.** Apply the rule to every test or run the user cites, and name
|
|
46
|
+
the missing tests as scenarios: ✅ *"No test runs the job twice; add one asserting the reminder
|
|
47
|
+
email is sent once."*
|
|
106
48
|
|
|
107
|
-
|
|
49
|
+
6. **Size to the ask.** "Top 3" gets three. "Skip the nits" gets none. "Just security" gets
|
|
50
|
+
security. Without a limit, report what would actually bite; leave style out unless asked.
|
|
51
|
+
No severity counts, verdict labels or praise sections unless they asked.
|
|
108
52
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
### 4. Test coverage
|
|
53
|
+
7. **Invent nothing about the codebase.** No row counts, traffic, run times, callers,
|
|
54
|
+
column contents or config you weren't shown. Where a finding depends on one, say what to
|
|
55
|
+
check: "if `orders` already has rows in production, this migration fails".
|
|
113
56
|
|
|
114
|
-
|
|
115
|
-
|
|
57
|
+
8. **Deliver from what you have.** With code to review, review it. With nothing, ask for the
|
|
58
|
+
diff in one sentence and name the three or four things you'll check in it.
|
|
116
59
|
|
|
117
|
-
|
|
118
|
-
1. Fix blockers
|
|
119
|
-
2. Add the missing tests in §4 (they double as regression catches)
|
|
120
|
-
3. Major findings in priority order
|
|
121
|
-
```
|
|
60
|
+
## Useful references in this skill
|
|
122
61
|
|
|
123
|
-
|
|
62
|
+
- [`examples/rate-limiter-review.md`](examples/rate-limiter-review.md) — a change with "tests pass", and a review that says why they passed
|
|
124
63
|
|
|
125
64
|
## Quality bar
|
|
126
65
|
|
|
127
|
-
- **Every
|
|
128
|
-
|
|
129
|
-
- **
|
|
130
|
-
|
|
131
|
-
- **
|
|
132
|
-
- **
|
|
66
|
+
- **Every cited test or run is judged:** could it have caught each bug, and if not, why did it
|
|
67
|
+
pass and what test would fail.
|
|
68
|
+
- **Every finding survives a trace through the code and the stated rules.** No false
|
|
69
|
+
positives.
|
|
70
|
+
- **The answer to "can this merge?" comes first;** findings are ordered by consequence.
|
|
71
|
+
- **Fixes work,** and are code when the change is small.
|
|
72
|
+
- **The scope asked for is respected.**
|
|
73
|
+
- **Nothing about the codebase is invented.**
|
|
133
74
|
|
|
134
75
|
## When NOT to use this skill
|
|
135
76
|
|
|
136
|
-
- ❌ For greenfield prototype
|
|
137
|
-
- ❌ For one-line
|
|
138
|
-
- ❌ As a substitute for human code review — it's a pre-pass
|
|
139
|
-
- ❌ For *evaluating the choice of approach* before code exists. That's a design conversation
|
|
140
|
-
- ❌ For
|
|
77
|
+
- ❌ For greenfield prototype or spike code that's about to be thrown away.
|
|
78
|
+
- ❌ For one-line or trivial changes.
|
|
79
|
+
- ❌ As a substitute for human code review — it's a pre-pass.
|
|
80
|
+
- ❌ For *evaluating the choice of approach* before code exists. That's a design conversation: `design-doc`, then `doc-critique`.
|
|
81
|
+
- ❌ For code in a language you can't reason about line by line. Refuse rather than fabricate findings.
|
|
141
82
|
|
|
142
83
|
## Anti-patterns to avoid
|
|
143
84
|
|
|
144
|
-
- ❌ **
|
|
145
|
-
- ❌ **
|
|
146
|
-
|
|
147
|
-
- ❌ **
|
|
148
|
-
- ❌ **
|
|
149
|
-
- ❌ **
|
|
150
|
-
- ❌ **
|
|
151
|
-
- ❌ **
|
|
85
|
+
- ❌ **"Tests pass" taken at face value,** or ignored. Say what they prove.
|
|
86
|
+
- ❌ **The plausible false positive.** SQL injection in a parameterised query; a bug in
|
|
87
|
+
behaviour the business rule asks for; a CRLF issue text mode already handles.
|
|
88
|
+
- ❌ **Padding to fill sections.** Marginal findings are where the errors are.
|
|
89
|
+
- ❌ **A count that disagrees with the list.** Don't print counts.
|
|
90
|
+
- ❌ **Describing a fix instead of giving it** when it fits in a few lines.
|
|
91
|
+
- ❌ **Facts about production you weren't given.** Row counts, lock times, what a column holds.
|
|
92
|
+
- ❌ **Redesigning.** Stay on what's missing given this approach.
|
|
@@ -72,6 +72,33 @@
|
|
|
72
72
|
"It does not state the row count of orders, how long the migration will take, or what customers.region contains as fact",
|
|
73
73
|
"It leads with three main concerns as asked, and anything beyond the three is kept to a brief mention"
|
|
74
74
|
]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": 6,
|
|
78
|
+
"held_out": "2026-10-17",
|
|
79
|
+
"prompt": "I'm the reviewer on PR #412 for the Fernlocket loyalty service and it's meant to merge this afternoon. The change is in redeem.py, the tests are in test_redeem.py, and the author's description and test run are in PR_DESCRIPTION.md. Find what's wrong with it before it ships. Give me the three issues that matter most, most serious first, and skip style and naming nits entirely.",
|
|
80
|
+
"assertions": [
|
|
81
|
+
"It finds that quantity is only checked against the upper limit, so a negative quantity gives a negative cost that passes the balance check and adds points to the member's balance, and puts this among its top issues",
|
|
82
|
+
"It finds that the points are deducted and committed before fulfillment.ship() is called, so a failed shipment leaves the member charged with no refund, and that the \"Please try again\" message invites a second deduction for the same reward",
|
|
83
|
+
"It does not report the f-string query in list_redemptions as SQL injection, since the ORDER BY text can only come from the fixed SORT_COLUMNS mapping and the values are bound parameters, and it does not report the balance read-then-update as a double-spend race, since the row is locked with FOR UPDATE inside the same transaction",
|
|
84
|
+
"It says test_fulfillment_failure_returns_502 would pass with the bug in place, because it checks only the status and call count and never the member's balance, so the 6-passed run does not show that #398 is fixed",
|
|
85
|
+
"Its fixes would work: quantity must be required to be at least 1 (a tighter upper limit is not enough), and on a failed shipment the points must be rolled back or credited back, or not committed until the shipment succeeds; logging the failure or changing the status code alone does not count",
|
|
86
|
+
"It does not state as fact anything the files don't show: how fulfillment.ship() or the transaction helper behave beyond what the code and comments say, whether the fulfillment service rejects a negative quantity, how many members or redemptions there are, or what else calls redeem()",
|
|
87
|
+
"It leads with three issues, most serious first, and makes no style or naming comments; anything beyond the three is at most a brief mention"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": 7,
|
|
92
|
+
"held_out": "2026-10-17",
|
|
93
|
+
"prompt": "hey quick one, can you check this before I push? it sends the weekly digest to all subscribers, max 8 at a time. we're on go 1.23. go test ./... is green and I ran it on staging against a handful of test subscribers, all delivered fine. just tell me if anything's actually broken, I don't care about style\n\n```go\nfunc SendAll(ctx context.Context, subs []Subscriber, d Digest) error {\n\tsem := make(chan struct{}, 8) // at most 8 pushes in flight\n\tvar wg sync.WaitGroup\n\tvar errs []error\n\n\tfor _, s := range subs {\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\t\t\tif err := push(ctx, s, d); err != nil {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"subscriber %s: %w\", s.ID, err))\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n\treturn errors.Join(errs...)\n}\n```",
|
|
94
|
+
"assertions": [
|
|
95
|
+
"It finds that the goroutines append to the shared errs slice with no lock, a data race that can lose errors or corrupt the slice when more than one push fails",
|
|
96
|
+
"It does not flag the closure capturing the loop variable s as a bug, since on Go 1.22 and later each loop iteration gets its own s",
|
|
97
|
+
"It says the green test run and the staging run don't show the code is safe: with every push succeeding the append never runs, and a plain go test without -race wouldn't report the race anyway",
|
|
98
|
+
"Its fix would work: a mutex around the append, or a per-subscriber slot in a slice sized to len(subs); if it suggests errgroup it says that this changes the behaviour (only the first error comes back, and with a group context the rest of the sends are cancelled)",
|
|
99
|
+
"It does not state as fact what push() does inside (retries, timeouts, how it uses ctx), how many subscribers there are, or that this has already failed in production",
|
|
100
|
+
"It keeps to what's actually broken, as asked: a short reply with no style or naming suggestions, not padded out with a long list of minor or hypothetical concerns"
|
|
101
|
+
]
|
|
75
102
|
}
|
|
76
103
|
]
|
|
77
104
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## Synchronous reward redemption (#412)
|
|
2
|
+
|
|
3
|
+
Members currently wait for the nightly batch to find out whether a redemption went through.
|
|
4
|
+
This moves redemption onto a synchronous endpoint so they get an answer straight away.
|
|
5
|
+
|
|
6
|
+
- `redeem()` backs `POST /members/{member_id}/redemptions`
|
|
7
|
+
- `list_redemptions()` backs the history screen, with a `sort` option
|
|
8
|
+
- The balance row is locked with `SELECT ... FOR UPDATE`, so a double tap on "Redeem" can't spend the same points twice
|
|
9
|
+
- A failed shipment returns 502 and asks the member to try again; this closes #398 and is covered by `test_fulfillment_failure_returns_502`
|
|
10
|
+
- Per-order cap of 10 so nobody can clear out a reward's stock in one request
|
|
11
|
+
|
|
12
|
+
### Test run
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
$ pytest tests/test_redeem.py -q
|
|
16
|
+
...... [100%]
|
|
17
|
+
6 passed in 0.41s
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Ready for review. I'd like to get this into Thursday's release.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Reward redemption for the Fernlocket loyalty programme.
|
|
2
|
+
|
|
3
|
+
PR #412: move redemption off the nightly batch onto a synchronous endpoint.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from .db import transaction
|
|
9
|
+
from .fulfillment import FulfillmentClient, FulfillmentError
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
MAX_PER_ORDER = 10
|
|
14
|
+
|
|
15
|
+
SORT_COLUMNS = {
|
|
16
|
+
"newest": "created_at DESC",
|
|
17
|
+
"oldest": "created_at ASC",
|
|
18
|
+
"points": "points_spent DESC",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedemptionError(Exception):
|
|
23
|
+
def __init__(self, status: int, message: str):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status = status
|
|
26
|
+
self.message = message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def redeem(conn, fulfillment: FulfillmentClient, member_id: str, payload: dict) -> dict:
|
|
30
|
+
"""Spend a member's points on a reward and ship it.
|
|
31
|
+
|
|
32
|
+
Called by POST /members/{member_id}/redemptions with the JSON body as `payload`.
|
|
33
|
+
"""
|
|
34
|
+
reward_id = payload["reward_id"]
|
|
35
|
+
quantity = int(payload.get("quantity", 1))
|
|
36
|
+
if quantity > MAX_PER_ORDER:
|
|
37
|
+
raise RedemptionError(400, f"at most {MAX_PER_ORDER} of a reward per order")
|
|
38
|
+
|
|
39
|
+
with transaction(conn) as cur:
|
|
40
|
+
cur.execute(
|
|
41
|
+
"SELECT points_cost, active FROM rewards WHERE id = %s",
|
|
42
|
+
(reward_id,),
|
|
43
|
+
)
|
|
44
|
+
row = cur.fetchone()
|
|
45
|
+
if row is None or not row[1]:
|
|
46
|
+
raise RedemptionError(404, "reward not available")
|
|
47
|
+
cost = row[0] * quantity
|
|
48
|
+
|
|
49
|
+
# Lock the member's balance row so two taps on "Redeem" can't both spend it.
|
|
50
|
+
cur.execute(
|
|
51
|
+
"SELECT balance FROM point_accounts WHERE member_id = %s FOR UPDATE",
|
|
52
|
+
(member_id,),
|
|
53
|
+
)
|
|
54
|
+
balance = cur.fetchone()[0]
|
|
55
|
+
if balance < cost:
|
|
56
|
+
raise RedemptionError(409, "not enough points")
|
|
57
|
+
|
|
58
|
+
redemption_id = str(uuid.uuid4())
|
|
59
|
+
cur.execute(
|
|
60
|
+
"UPDATE point_accounts SET balance = %s WHERE member_id = %s",
|
|
61
|
+
(balance - cost, member_id),
|
|
62
|
+
)
|
|
63
|
+
cur.execute(
|
|
64
|
+
"INSERT INTO redemptions (id, member_id, reward_id, quantity, points_spent, status)"
|
|
65
|
+
" VALUES (%s, %s, %s, %s, %s, 'pending')",
|
|
66
|
+
(redemption_id, member_id, reward_id, quantity, cost),
|
|
67
|
+
)
|
|
68
|
+
# transaction commits on leaving the block
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
fulfillment.ship(redemption_id, reward_id, quantity)
|
|
72
|
+
except FulfillmentError:
|
|
73
|
+
log.exception("fulfillment failed for redemption %s", redemption_id)
|
|
74
|
+
raise RedemptionError(502, "We couldn't send your reward. Please try again.")
|
|
75
|
+
|
|
76
|
+
with transaction(conn) as cur:
|
|
77
|
+
cur.execute(
|
|
78
|
+
"UPDATE redemptions SET status = 'shipped' WHERE id = %s",
|
|
79
|
+
(redemption_id,),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"redemption_id": redemption_id,
|
|
84
|
+
"points_spent": cost,
|
|
85
|
+
"balance": balance - cost,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def list_redemptions(conn, member_id: str, sort: str = "newest", limit: int = 20) -> list:
|
|
90
|
+
"""Redemption history for the member's account screen."""
|
|
91
|
+
order_by = SORT_COLUMNS.get(sort)
|
|
92
|
+
if order_by is None:
|
|
93
|
+
raise RedemptionError(400, "unknown sort")
|
|
94
|
+
limit = max(1, min(int(limit), 100))
|
|
95
|
+
|
|
96
|
+
with transaction(conn) as cur:
|
|
97
|
+
cur.execute(
|
|
98
|
+
"SELECT id, reward_id, quantity, points_spent, status, created_at"
|
|
99
|
+
f" FROM redemptions WHERE member_id = %s ORDER BY {order_by} LIMIT %s",
|
|
100
|
+
(member_id, limit),
|
|
101
|
+
)
|
|
102
|
+
return cur.fetchall()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from rewards.fulfillment import FulfillmentError
|
|
4
|
+
from rewards.redeem import RedemptionError, list_redemptions, redeem
|
|
5
|
+
from tests.fakes import FakeFulfillment, make_db
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def db():
|
|
10
|
+
conn = make_db()
|
|
11
|
+
conn.seed_member("m-100", balance=500)
|
|
12
|
+
conn.seed_reward("r-mug", points_cost=120, active=True)
|
|
13
|
+
conn.seed_reward("r-old-tote", points_cost=50, active=False)
|
|
14
|
+
return conn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_happy_path(db):
|
|
18
|
+
result = redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 2})
|
|
19
|
+
assert result["points_spent"] == 240
|
|
20
|
+
assert db.balance("m-100") == 260
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_not_enough_points(db):
|
|
24
|
+
with pytest.raises(RedemptionError) as err:
|
|
25
|
+
redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 5})
|
|
26
|
+
assert err.value.status == 409
|
|
27
|
+
assert db.balance("m-100") == 500
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_inactive_reward(db):
|
|
31
|
+
with pytest.raises(RedemptionError) as err:
|
|
32
|
+
redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-old-tote"})
|
|
33
|
+
assert err.value.status == 404
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_quantity_limit(db):
|
|
37
|
+
with pytest.raises(RedemptionError) as err:
|
|
38
|
+
redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 11})
|
|
39
|
+
assert err.value.status == 400
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_fulfillment_failure_returns_502(db):
|
|
43
|
+
"""Regression for #398: a failed shipment must not cost the member their points."""
|
|
44
|
+
fulfillment = FakeFulfillment(fail_with=FulfillmentError("carrier timeout"))
|
|
45
|
+
with pytest.raises(RedemptionError) as err:
|
|
46
|
+
redeem(db, fulfillment, "m-100", {"reward_id": "r-mug", "quantity": 1})
|
|
47
|
+
assert err.value.status == 502
|
|
48
|
+
assert fulfillment.calls == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_history_rejects_unknown_sort(db):
|
|
52
|
+
with pytest.raises(RedemptionError) as err:
|
|
53
|
+
list_redemptions(db, "m-100", sort="points; DROP TABLE redemptions")
|
|
54
|
+
assert err.value.status == 400
|