@booklib/skills 1.2.0 → 1.3.0
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/CONTRIBUTING.md +122 -0
- package/README.md +20 -2
- package/ROADMAP.md +36 -0
- package/animation-at-work/evals/evals.json +44 -0
- package/animation-at-work/examples/after.md +64 -0
- package/animation-at-work/examples/before.md +35 -0
- package/animation-at-work/scripts/audit_animations.py +295 -0
- package/bin/skills.js +552 -42
- package/clean-code-reviewer/SKILL.md +109 -1
- package/clean-code-reviewer/evals/evals.json +121 -3
- package/clean-code-reviewer/examples/after.md +48 -0
- package/clean-code-reviewer/examples/before.md +33 -0
- package/clean-code-reviewer/references/api_reference.md +158 -0
- package/clean-code-reviewer/references/practices-catalog.md +282 -0
- package/clean-code-reviewer/references/review-checklist.md +254 -0
- package/clean-code-reviewer/scripts/pre-review.py +206 -0
- package/data-intensive-patterns/evals/evals.json +43 -0
- package/data-intensive-patterns/examples/after.md +61 -0
- package/data-intensive-patterns/examples/before.md +38 -0
- package/data-intensive-patterns/scripts/adr.py +213 -0
- package/data-pipelines/evals/evals.json +45 -0
- package/data-pipelines/examples/after.md +97 -0
- package/data-pipelines/examples/before.md +37 -0
- package/data-pipelines/scripts/new_pipeline.py +444 -0
- package/design-patterns/evals/evals.json +46 -0
- package/design-patterns/examples/after.md +52 -0
- package/design-patterns/examples/before.md +29 -0
- package/design-patterns/scripts/scaffold.py +807 -0
- package/domain-driven-design/SKILL.md +120 -0
- package/domain-driven-design/evals/evals.json +48 -0
- package/domain-driven-design/examples/after.md +80 -0
- package/domain-driven-design/examples/before.md +43 -0
- package/domain-driven-design/scripts/scaffold.py +421 -0
- package/effective-java/evals/evals.json +46 -0
- package/effective-java/examples/after.md +83 -0
- package/effective-java/examples/before.md +37 -0
- package/effective-java/scripts/checkstyle_setup.py +211 -0
- package/effective-kotlin/evals/evals.json +45 -0
- package/effective-kotlin/examples/after.md +36 -0
- package/effective-kotlin/examples/before.md +38 -0
- package/effective-python/evals/evals.json +44 -0
- package/effective-python/examples/after.md +56 -0
- package/effective-python/examples/before.md +40 -0
- package/effective-python/references/api_reference.md +218 -0
- package/effective-python/references/practices-catalog.md +483 -0
- package/effective-python/references/review-checklist.md +190 -0
- package/effective-python/scripts/lint.py +173 -0
- package/kotlin-in-action/evals/evals.json +43 -0
- package/kotlin-in-action/examples/after.md +53 -0
- package/kotlin-in-action/examples/before.md +39 -0
- package/kotlin-in-action/scripts/setup_detekt.py +224 -0
- package/lean-startup/evals/evals.json +43 -0
- package/lean-startup/examples/after.md +80 -0
- package/lean-startup/examples/before.md +34 -0
- package/lean-startup/scripts/new_experiment.py +286 -0
- package/microservices-patterns/SKILL.md +140 -0
- package/microservices-patterns/evals/evals.json +45 -0
- package/microservices-patterns/examples/after.md +69 -0
- package/microservices-patterns/examples/before.md +40 -0
- package/microservices-patterns/scripts/new_service.py +583 -0
- package/package.json +2 -8
- package/refactoring-ui/evals/evals.json +45 -0
- package/refactoring-ui/examples/after.md +85 -0
- package/refactoring-ui/examples/before.md +58 -0
- package/refactoring-ui/scripts/audit_css.py +250 -0
- package/skill-router/SKILL.md +142 -0
- package/skill-router/evals/evals.json +38 -0
- package/skill-router/examples/after.md +63 -0
- package/skill-router/examples/before.md +39 -0
- package/skill-router/references/api_reference.md +24 -0
- package/skill-router/references/routing-heuristics.md +89 -0
- package/skill-router/references/skill-catalog.md +156 -0
- package/skill-router/scripts/route.py +266 -0
- package/storytelling-with-data/evals/evals.json +47 -0
- package/storytelling-with-data/examples/after.md +50 -0
- package/storytelling-with-data/examples/before.md +33 -0
- package/storytelling-with-data/scripts/chart_review.py +301 -0
- package/system-design-interview/evals/evals.json +45 -0
- package/system-design-interview/examples/after.md +94 -0
- package/system-design-interview/examples/before.md +27 -0
- package/system-design-interview/scripts/new_design.py +421 -0
- package/using-asyncio-python/evals/evals.json +43 -0
- package/using-asyncio-python/examples/after.md +68 -0
- package/using-asyncio-python/examples/before.md +39 -0
- package/using-asyncio-python/scripts/check_blocking.py +270 -0
- package/web-scraping-python/evals/evals.json +46 -0
- package/web-scraping-python/examples/after.md +109 -0
- package/web-scraping-python/examples/before.md +40 -0
- package/web-scraping-python/scripts/new_scraper.py +231 -0
- /package/{effective-python-skill → effective-python}/SKILL.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-01-pythonic-thinking.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-02-lists-and-dicts.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-03-functions.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-04-comprehensions-generators.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-05-classes-interfaces.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-06-metaclasses-attributes.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-07-concurrency.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-08-robustness-performance.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-09-testing-debugging.md +0 -0
- /package/{effective-python-skill → effective-python}/ref-10-collaboration.md +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
lint.py — Ruff linter tuned to Effective Python items.
|
|
4
|
+
Usage: python lint.py <path>
|
|
5
|
+
|
|
6
|
+
Runs ruff with rules that map directly to Effective Python advice,
|
|
7
|
+
then annotates each violation with the relevant item number and title.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Maps ruff rule code prefixes/exact codes -> (Item number, short description)
|
|
18
|
+
RULE_TO_ITEM = {
|
|
19
|
+
"E711": ("Item 2", "PEP 8: use 'is None' / 'is not None', not == None"),
|
|
20
|
+
"E712": ("Item 2", "PEP 8: use 'is True' / 'is False', not == True/False"),
|
|
21
|
+
"B006": ("Item 24", "Avoid mutable default arguments"),
|
|
22
|
+
"B007": ("Item 7", "Unused loop variable — use _ for throwaway"),
|
|
23
|
+
"B008": ("Item 24", "Do not call mutable objects as default arguments"),
|
|
24
|
+
"C400": ("Item 27", "Rewrite map() as a list comprehension"),
|
|
25
|
+
"C401": ("Item 27", "Rewrite set() with a comprehension"),
|
|
26
|
+
"C402": ("Item 27", "Rewrite dict() with a dict comprehension"),
|
|
27
|
+
"C403": ("Item 27", "Rewrite list() with a list comprehension"),
|
|
28
|
+
"C404": ("Item 27", "Rewrite list of tuples as dict comprehension"),
|
|
29
|
+
"C405": ("Item 27", "Rewrite set literal — unnecessary literal call"),
|
|
30
|
+
"C406": ("Item 27", "Rewrite dict literal — unnecessary literal call"),
|
|
31
|
+
"C408": ("Item 27", "Rewrite dict()/list()/tuple() with literal"),
|
|
32
|
+
"C409": ("Item 28", "Unnecessary literal in tuple()"),
|
|
33
|
+
"C410": ("Item 28", "Unnecessary literal in list()"),
|
|
34
|
+
"C411": ("Item 27", "Unnecessary list() call"),
|
|
35
|
+
"C413": ("Item 27", "Unnecessary list/reversed() around sorted()"),
|
|
36
|
+
"C414": ("Item 27", "Unnecessary double cast in comprehension"),
|
|
37
|
+
"C415": ("Item 29", "Unnecessary subscript reversal in comprehension"),
|
|
38
|
+
"C416": ("Item 27", "Unnecessary list comprehension — use list()"),
|
|
39
|
+
"C417": ("Item 27", "Unnecessary map() — use generator/comprehension"),
|
|
40
|
+
"SIM101": ("Item 7", "Merge duplicate isinstance() checks with tuple"),
|
|
41
|
+
"SIM102": ("Item 7", "Collapse nested if into single if"),
|
|
42
|
+
"SIM103": ("Item 7", "Return condition directly, not if/else True/False"),
|
|
43
|
+
"SIM105": ("Item 35", "Use contextlib.suppress instead of try/except/pass"),
|
|
44
|
+
"SIM108": ("Item 7", "Use ternary operator instead of if/else block"),
|
|
45
|
+
"SIM110": ("Item 27", "Use comprehension instead of for-loop with append"),
|
|
46
|
+
"SIM115": ("Item 66", "Use context manager for open()"),
|
|
47
|
+
"SIM117": ("Item 66", "Merge nested with statements"),
|
|
48
|
+
"UP001": ("Item 2", "pyupgrade: use modern Python syntax"),
|
|
49
|
+
"UP003": ("Item 2", "pyupgrade: use type() instead of deprecated form"),
|
|
50
|
+
"UP006": ("Item 90", "Use 'list' instead of 'List' for type hints (3.9+)"),
|
|
51
|
+
"UP007": ("Item 90", "Use 'X | Y' instead of 'Optional[X]' (3.10+)"),
|
|
52
|
+
"UP008": ("Item 90", "Use 'super()' without arguments"),
|
|
53
|
+
"UP009": ("Item 2", "pyupgrade: UTF-8 encoding declaration unnecessary"),
|
|
54
|
+
"UP010": ("Item 2", "pyupgrade: unnecessary __future__ import"),
|
|
55
|
+
"UP032": ("Item 4", "Use f-string instead of .format()"),
|
|
56
|
+
"UP034": ("Item 2", "pyupgrade: extraneous parentheses"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
RUFF_CONFIG = textwrap.dedent("""\
|
|
60
|
+
[lint]
|
|
61
|
+
select = [
|
|
62
|
+
"E711", "E712",
|
|
63
|
+
"B006", "B007", "B008",
|
|
64
|
+
"C4",
|
|
65
|
+
"SIM",
|
|
66
|
+
"UP",
|
|
67
|
+
]
|
|
68
|
+
ignore = []
|
|
69
|
+
""")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_item(code: str):
|
|
73
|
+
if code in RULE_TO_ITEM:
|
|
74
|
+
return RULE_TO_ITEM[code]
|
|
75
|
+
# Try prefix match (e.g. SIM, UP, C4xx)
|
|
76
|
+
for prefix_len in (5, 4, 3, 2):
|
|
77
|
+
prefix = code[:prefix_len]
|
|
78
|
+
if prefix in RULE_TO_ITEM:
|
|
79
|
+
return RULE_TO_ITEM[prefix]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_ruff(target: Path, config_path: Path):
|
|
84
|
+
cmd = [
|
|
85
|
+
"ruff", "check",
|
|
86
|
+
"--config", str(config_path),
|
|
87
|
+
"--output-format", "json",
|
|
88
|
+
str(target),
|
|
89
|
+
]
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
92
|
+
return result.stdout, result.stderr, result.returncode
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return None, "NOT_FOUND", 1
|
|
95
|
+
except subprocess.TimeoutExpired:
|
|
96
|
+
return None, "TIMEOUT", 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
if len(sys.argv) < 2:
|
|
101
|
+
print("Usage: python lint.py <path>")
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
target = Path(sys.argv[1])
|
|
105
|
+
if not target.exists():
|
|
106
|
+
print(f"Error: path not found: {target}")
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
with tempfile.NamedTemporaryFile(
|
|
110
|
+
mode="w", suffix=".toml", prefix="ruff_ep_", delete=False
|
|
111
|
+
) as tmp:
|
|
112
|
+
tmp.write(RUFF_CONFIG)
|
|
113
|
+
config_path = Path(tmp.name)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
stdout, stderr, returncode = run_ruff(target, config_path)
|
|
117
|
+
finally:
|
|
118
|
+
config_path.unlink(missing_ok=True)
|
|
119
|
+
|
|
120
|
+
if stdout is None:
|
|
121
|
+
if stderr == "NOT_FOUND":
|
|
122
|
+
print("Error: ruff is not installed.")
|
|
123
|
+
print("Install it with: pip install ruff")
|
|
124
|
+
print("Or globally: pipx install ruff")
|
|
125
|
+
elif stderr == "TIMEOUT":
|
|
126
|
+
print("Error: ruff timed out.")
|
|
127
|
+
else:
|
|
128
|
+
print(f"Error running ruff: {stderr}")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
print(f"Effective Python lint report — {target}")
|
|
132
|
+
print("-" * 70)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
violations = json.loads(stdout) if stdout.strip() else []
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
print("Raw ruff output:")
|
|
138
|
+
print(stdout)
|
|
139
|
+
sys.exit(returncode)
|
|
140
|
+
|
|
141
|
+
if not violations:
|
|
142
|
+
print("No violations found. Code aligns well with Effective Python.")
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
|
|
145
|
+
# Group by item
|
|
146
|
+
by_item: dict[str, list] = {}
|
|
147
|
+
for v in violations:
|
|
148
|
+
code = v.get("code", "?")
|
|
149
|
+
mapping = find_item(code)
|
|
150
|
+
item_key = mapping[0] if mapping else "Other"
|
|
151
|
+
by_item.setdefault(item_key, []).append((v, mapping))
|
|
152
|
+
|
|
153
|
+
total = 0
|
|
154
|
+
for item_key in sorted(by_item):
|
|
155
|
+
entries = by_item[item_key]
|
|
156
|
+
item_desc = entries[0][1][1] if entries[0][1] else "ruff violation"
|
|
157
|
+
print(f"\n[{item_key}] {item_desc}")
|
|
158
|
+
for v, _ in entries:
|
|
159
|
+
loc = v.get("location", {})
|
|
160
|
+
row, col = loc.get("row", "?"), loc.get("column", "?")
|
|
161
|
+
filename = Path(v.get("filename", str(target))).name
|
|
162
|
+
message = v.get("message", "")
|
|
163
|
+
code = v.get("code", "?")
|
|
164
|
+
print(f" {filename}:{row}:{col} [{code}] {message}")
|
|
165
|
+
total += 1
|
|
166
|
+
|
|
167
|
+
print(f"\n{'-' * 70}")
|
|
168
|
+
print(f"Total violations: {total}")
|
|
169
|
+
sys.exit(1 if violations else 0)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"evals": [
|
|
3
|
+
{
|
|
4
|
+
"id": "eval-01-java-style-null-handling",
|
|
5
|
+
"prompt": "Review this Kotlin code:\n\n```kotlin\nclass OrderService(private val repo: OrderRepository) {\n\n fun getShippingCity(orderId: String): String {\n val order = repo.findById(orderId)\n if (order != null) {\n val address = order.shippingAddress\n if (address != null) {\n val city = address.city\n if (city != null) {\n return city.uppercase()\n }\n }\n }\n return \"UNKNOWN\"\n }\n\n fun applyDiscount(orderId: String, percent: Double): Double {\n val order = repo.findById(orderId)\n var total = 0.0\n if (order != null) {\n total = order.total\n val membership = order.customer.membership\n if (membership != null) {\n if (membership.isActive != null && membership.isActive == true) {\n total = total * (1.0 - percent / 100.0)\n }\n }\n }\n return total\n }\n}\n```",
|
|
6
|
+
"expectations": [
|
|
7
|
+
"Flags the deeply nested null-checking pyramid in `getShippingCity` as Java-style; recommends replacing with safe call chaining: `repo.findById(orderId)?.shippingAddress?.city?.uppercase() ?: \"UNKNOWN\"` (Ch 7: safe calls and Elvis operator)",
|
|
8
|
+
"Flags the nested null checks in `applyDiscount` as Java-style; recommends safe calls and let for null-safe transformations (Ch 7)",
|
|
9
|
+
"Flags `membership.isActive == true` as redundant when `isActive` is nullable Boolean; recommends `membership.isActive == true` can be replaced with `membership.isActive ?: false` or smart cast after null check (Ch 7)",
|
|
10
|
+
"Notes that `var total = 0.0` followed by conditional assignment is a code smell; recommends using an expression: `val total = order?.total ?: 0.0` (Ch 2: val over var, expression style)",
|
|
11
|
+
"Recommends using `let` for null-safe scoping: `membership?.takeIf { it.isActive == true }?.let { ... }` (Ch 7: let for null checks)",
|
|
12
|
+
"Provides a refactored version using safe calls, Elvis operator, and let throughout"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "eval-02-missing-extension-functions-named-args",
|
|
17
|
+
"prompt": "Review this Kotlin code:\n\n```kotlin\nfun formatUserDisplay(firstName: String, lastName: String, email: String, isAdmin: Boolean): String {\n val name = firstName + \" \" + lastName\n val badge = if (isAdmin == true) \"[ADMIN] \" else \"\"\n return badge + name + \" <\" + email + \">\"\n}\n\nfun truncate(text: String, maxLen: Int, suffix: String): String {\n return if (text.length > maxLen) {\n text.substring(0, maxLen) + suffix\n } else {\n text\n }\n}\n\nclass UserDisplayHelper {\n fun render(firstName: String, lastName: String, email: String, isAdmin: Boolean): String {\n return formatUserDisplay(firstName, lastName, email, isAdmin)\n }\n\n fun renderTruncated(firstName: String, lastName: String, email: String, isAdmin: Boolean, maxLen: Int): String {\n val display = formatUserDisplay(firstName, lastName, email, isAdmin)\n return truncate(display, maxLen, \"...\")\n }\n}\n```",
|
|
18
|
+
"expectations": [
|
|
19
|
+
"Flags string concatenation with `+` throughout; recommends string templates (Ch 2: string templates are idiomatic Kotlin)",
|
|
20
|
+
"Flags `isAdmin == true` comparison on a non-nullable Boolean; should be just `isAdmin` (Ch 7: unnecessary null comparison on non-nullable type)",
|
|
21
|
+
"Notes that `truncate` and `formatUserDisplay` are standalone utility functions that would be more idiomatic as extension functions on `String` (Ch 3: prefer extension functions over utility classes)",
|
|
22
|
+
"Flags `UserDisplayHelper` as an unnecessary Java-style utility class wrapping top-level functions; recommends converting to top-level functions or extension functions (Ch 3: top-level functions replace static utility classes)",
|
|
23
|
+
"Identifies that `renderTruncated` has many same-typed parameters in a row which are ambiguous at call sites; recommends named arguments or introducing a value class or data class (Ch 3: use named/default arguments for clarity)",
|
|
24
|
+
"Notes that `truncate` could use a default parameter value for `suffix` instead of requiring callers to pass `\"...\"` every time (Ch 3: default parameter values)",
|
|
25
|
+
"Provides a refactored version using extension functions on String, string templates, and named/default args"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "eval-03-clean-kotlin-coroutines-sealed",
|
|
30
|
+
"prompt": "Review this Kotlin code:\n\n```kotlin\nsealed interface PaymentResult {\n data class Success(val transactionId: String, val amount: Double) : PaymentResult\n data class Declined(val reason: String, val code: Int) : PaymentResult\n data class NetworkError(val cause: Throwable) : PaymentResult\n}\n\ninterface PaymentGateway {\n suspend fun charge(amount: Double, token: String): PaymentResult\n}\n\nclass PaymentProcessor(\n private val gateway: PaymentGateway,\n private val scope: CoroutineScope\n) {\n suspend fun processOrder(orderId: String, amount: Double, token: String): PaymentResult {\n return withContext(Dispatchers.IO) {\n try {\n gateway.charge(amount, token)\n } catch (e: CancellationException) {\n throw e\n } catch (e: Exception) {\n PaymentResult.NetworkError(e)\n }\n }\n }\n\n fun processOrderAsync(orderId: String, amount: Double, token: String) =\n scope.async { processOrder(orderId, amount, token) }\n}\n\nfun renderResult(result: PaymentResult): String = when (result) {\n is PaymentResult.Success -> \"Charged \\$${result.amount} — txn ${result.transactionId}\"\n is PaymentResult.Declined -> \"Declined (${result.code}): ${result.reason}\"\n is PaymentResult.NetworkError -> \"Network error: ${result.cause.message}\"\n}\n```",
|
|
31
|
+
"expectations": [
|
|
32
|
+
"Recognizes this is already idiomatic, well-structured Kotlin and says so explicitly",
|
|
33
|
+
"Praises the sealed interface hierarchy for `PaymentResult` providing exhaustive when expressions without a catch-all branch (Ch 4: sealed classes for restricted hierarchies)",
|
|
34
|
+
"Praises re-throwing `CancellationException` to maintain cooperative coroutine cancellation (Ch 14: structured concurrency, cancellation handling)",
|
|
35
|
+
"Praises using `withContext(Dispatchers.IO)` for the blocking gateway call rather than blocking on a non-IO dispatcher (Ch 14: dispatcher usage)",
|
|
36
|
+
"Praises use of `data class` subtypes giving automatic `equals`, `hashCode`, `copy`, and `toString` (Ch 4: data classes for value types)",
|
|
37
|
+
"Praises the exhaustive `when` expression in `renderResult` that the compiler enforces due to the sealed hierarchy (Ch 4)",
|
|
38
|
+
"Does NOT manufacture issues to appear thorough; any suggestions are explicitly framed as minor optional improvements",
|
|
39
|
+
"May note minor optional suggestions such as whether `processOrderAsync` is needed given `processOrder` is already suspend, but frames them as design questions, not violations"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# After
|
|
2
|
+
|
|
3
|
+
Idiomatic Kotlin using a sealed interface for channels, safe-call operators, named parameters, and extension functions — eliminating all manual null checks and string comparisons.
|
|
4
|
+
|
|
5
|
+
```kotlin
|
|
6
|
+
// Sealed interface models exactly the valid channels — exhaustive when is enforced
|
|
7
|
+
sealed interface NotificationChannel {
|
|
8
|
+
data class Email(val address: String) : NotificationChannel
|
|
9
|
+
data class Sms(val phoneNumber: String) : NotificationChannel
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Extension function resolves the preferred channel from a User
|
|
13
|
+
fun User.preferredChannel(): NotificationChannel? = when {
|
|
14
|
+
email != null -> NotificationChannel.Email(email)
|
|
15
|
+
phoneNumber != null -> NotificationChannel.Sms(phoneNumber)
|
|
16
|
+
else -> null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class NotificationService(
|
|
20
|
+
private val emailSender: EmailSender,
|
|
21
|
+
private val smsSender: SmsSender,
|
|
22
|
+
) {
|
|
23
|
+
|
|
24
|
+
fun sendNotification(user: User, message: String, channel: NotificationChannel) {
|
|
25
|
+
require(message.isNotBlank()) { "Notification message must not be blank" }
|
|
26
|
+
|
|
27
|
+
when (channel) {
|
|
28
|
+
is NotificationChannel.Email -> {
|
|
29
|
+
val subject = "Notification for ${user.firstName} ${user.lastName}"
|
|
30
|
+
emailSender.send(to = channel.address, subject = subject, body = message)
|
|
31
|
+
}
|
|
32
|
+
is NotificationChannel.Sms -> {
|
|
33
|
+
smsSender.send(to = channel.phoneNumber, body = message)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Usage — caller resolves channel; service focuses on delivery
|
|
40
|
+
fun notifyUser(user: User, message: String, service: NotificationService) {
|
|
41
|
+
user.preferredChannel()
|
|
42
|
+
?.let { channel -> service.sendNotification(user, message, channel) }
|
|
43
|
+
?: logger.warn("No notification channel available for user ${user.id}")
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Key improvements:
|
|
48
|
+
- `sealed interface NotificationChannel` replaces the `String` channel parameter — the compiler enforces exhaustive `when` and eliminates the "Unknown channel" else branch (Ch 4: Sealed classes)
|
|
49
|
+
- `User?` parameter becomes non-null `User` — the caller is responsible for ensuring a valid user exists; null-safety is pushed to the boundary (Ch 7: Null safety)
|
|
50
|
+
- `user.preferredChannel()` extension function encapsulates the channel-resolution logic outside the service class (Ch 3: Extension functions)
|
|
51
|
+
- `require(message.isNotBlank())` replaces silent println for invalid input (Effective Kotlin Item 5: Specify your expectations on arguments)
|
|
52
|
+
- Named parameters `to =`, `subject =`, `body =` make the send calls self-documenting (Ch 3: Named arguments)
|
|
53
|
+
- String template `"${user.firstName} ${user.lastName}"` replaces concatenation (Ch 2: String templates)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Before
|
|
2
|
+
|
|
3
|
+
Kotlin code written with Java-style null checks, no extension functions, and no use of sealed classes or safe-call operators.
|
|
4
|
+
|
|
5
|
+
```kotlin
|
|
6
|
+
class NotificationService(
|
|
7
|
+
private val emailSender: EmailSender,
|
|
8
|
+
private val smsSender: SmsSender
|
|
9
|
+
) {
|
|
10
|
+
|
|
11
|
+
fun sendNotification(user: User?, message: String?, channel: String) {
|
|
12
|
+
if (user == null) {
|
|
13
|
+
println("User is null")
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
if (message == null || message.isEmpty()) {
|
|
17
|
+
println("Message is empty")
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (channel == "EMAIL") {
|
|
22
|
+
if (user.email != null) {
|
|
23
|
+
val subject = "Notification for " + user.firstName + " " + user.lastName
|
|
24
|
+
emailSender.send(user.email, subject, message)
|
|
25
|
+
} else {
|
|
26
|
+
println("No email for user " + user.id)
|
|
27
|
+
}
|
|
28
|
+
} else if (channel == "SMS") {
|
|
29
|
+
if (user.phoneNumber != null) {
|
|
30
|
+
smsSender.send(user.phoneNumber, message)
|
|
31
|
+
} else {
|
|
32
|
+
println("No phone for user " + user.id)
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
println("Unknown channel: " + channel)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
setup_detekt.py - Set up Detekt with Kotlin-in-Action aligned rules.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python setup_detekt.py [--output-dir ./]
|
|
7
|
+
|
|
8
|
+
Generates:
|
|
9
|
+
detekt.yml - Detekt config with rules mapped to Kotlin in Action chapters
|
|
10
|
+
run_detekt.sh - Shell script to run Detekt with this config
|
|
11
|
+
|
|
12
|
+
Each rule includes a comment referencing the relevant chapter from
|
|
13
|
+
"Kotlin in Action" by Dmitry Jemerov and Svetlana Isakova.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import pathlib
|
|
18
|
+
import stat
|
|
19
|
+
|
|
20
|
+
DETEKT_YML = """\
|
|
21
|
+
# detekt.yml
|
|
22
|
+
# Detekt configuration aligned with "Kotlin in Action" by Jemerov & Isakova.
|
|
23
|
+
# Each rule references the chapter that motivates it.
|
|
24
|
+
|
|
25
|
+
build:
|
|
26
|
+
maxIssues: 0
|
|
27
|
+
excludeCorrectable: false
|
|
28
|
+
|
|
29
|
+
config:
|
|
30
|
+
validation: true
|
|
31
|
+
warningsAsErrors: false
|
|
32
|
+
|
|
33
|
+
complexity:
|
|
34
|
+
active: true
|
|
35
|
+
|
|
36
|
+
# Chapter 5 - Lambdas: keep lambdas and functions concise and readable.
|
|
37
|
+
LongMethod:
|
|
38
|
+
active: true
|
|
39
|
+
threshold: 20
|
|
40
|
+
|
|
41
|
+
# Chapter 3 - Functions: prefer functions with few, well-named parameters.
|
|
42
|
+
# Many parameters suggest the need for a data class or builder pattern.
|
|
43
|
+
LongParameterList:
|
|
44
|
+
active: true
|
|
45
|
+
functionThreshold: 4
|
|
46
|
+
constructorThreshold: 6
|
|
47
|
+
ignoreDefaultParameters: true
|
|
48
|
+
|
|
49
|
+
# Chapter 10 - Higher-order functions: avoid deep nesting; flatten with
|
|
50
|
+
# higher-order functions (map, filter, let, run) instead.
|
|
51
|
+
NestedBlockDepth:
|
|
52
|
+
active: true
|
|
53
|
+
threshold: 3
|
|
54
|
+
|
|
55
|
+
# Chapter 4 - Classes: classes should be focused and not overly complex.
|
|
56
|
+
TooManyFunctions:
|
|
57
|
+
active: true
|
|
58
|
+
thresholdInFiles: 20
|
|
59
|
+
thresholdInClasses: 15
|
|
60
|
+
thresholdInInterfaces: 10
|
|
61
|
+
thresholdInObjects: 10
|
|
62
|
+
thresholdInEnums: 5
|
|
63
|
+
|
|
64
|
+
naming:
|
|
65
|
+
active: true
|
|
66
|
+
|
|
67
|
+
# Chapter 3 - Functions: Kotlin convention is camelCase for function names.
|
|
68
|
+
FunctionNaming:
|
|
69
|
+
active: true
|
|
70
|
+
functionPattern: '[a-z][a-zA-Z0-9]*'
|
|
71
|
+
excludes: ['**/test/**']
|
|
72
|
+
|
|
73
|
+
# Chapter 2 - Basics: variables follow camelCase; properties are idiomatic Kotlin.
|
|
74
|
+
VariableNaming:
|
|
75
|
+
active: true
|
|
76
|
+
variablePattern: '[a-z][a-zA-Z0-9]*'
|
|
77
|
+
privateVariablePattern: '(_)?[a-z][a-zA-Z0-9]*'
|
|
78
|
+
|
|
79
|
+
# Chapter 4 - Classes: class names are PascalCase per Kotlin convention.
|
|
80
|
+
ClassNaming:
|
|
81
|
+
active: true
|
|
82
|
+
classPattern: '[A-Z][a-zA-Z0-9]*'
|
|
83
|
+
|
|
84
|
+
# Chapter 8 - Generics: type parameter names should be single uppercase letters
|
|
85
|
+
# or descriptive PascalCase names.
|
|
86
|
+
TypeParameterNaming:
|
|
87
|
+
active: true
|
|
88
|
+
typeParameterPattern: '[A-Z][A-Za-z]*'
|
|
89
|
+
|
|
90
|
+
style:
|
|
91
|
+
active: true
|
|
92
|
+
|
|
93
|
+
# Chapter 4 - Classes, Objects, Interfaces: abstract classes without abstract
|
|
94
|
+
# members should be interfaces or open classes instead.
|
|
95
|
+
UnnecessaryAbstractClass:
|
|
96
|
+
active: true
|
|
97
|
+
|
|
98
|
+
# Chapter 3 - Functions: Unit return type is implicit; declaring it is redundant.
|
|
99
|
+
OptionalUnit:
|
|
100
|
+
active: true
|
|
101
|
+
|
|
102
|
+
# Chapter 2 - Basics: val properties that can be const should be const
|
|
103
|
+
# for compile-time optimisation.
|
|
104
|
+
MayBeConst:
|
|
105
|
+
active: true
|
|
106
|
+
|
|
107
|
+
# Chapter 4 - Objects: use object declarations instead of classes with only
|
|
108
|
+
# static members.
|
|
109
|
+
UseDataClass:
|
|
110
|
+
active: true
|
|
111
|
+
allowVars: false
|
|
112
|
+
|
|
113
|
+
# Chapter 11 - DSL: trailing lambdas should be outside parentheses per convention.
|
|
114
|
+
UnnecessaryParentheses:
|
|
115
|
+
active: true
|
|
116
|
+
|
|
117
|
+
potential-bugs:
|
|
118
|
+
active: true
|
|
119
|
+
|
|
120
|
+
# Chapter 6 - Null Safety: lateinit signals deferred initialisation, which
|
|
121
|
+
# makes nullability guarantees harder to reason about. Prefer constructor injection.
|
|
122
|
+
LateinitUsage:
|
|
123
|
+
active: true
|
|
124
|
+
excludes: ['**/test/**']
|
|
125
|
+
ignoreOnClassesPattern: ''
|
|
126
|
+
|
|
127
|
+
# Chapter 6 - Null Safety: the !! operator bypasses null safety and will
|
|
128
|
+
# throw NPE at runtime. Use safe calls (?.) or Elvis operator (?:) instead.
|
|
129
|
+
UnsafeCallOnNullableType:
|
|
130
|
+
active: true
|
|
131
|
+
excludes: ['**/test/**']
|
|
132
|
+
|
|
133
|
+
# Chapter 6 - Null Safety: explicit null checks with == null should be
|
|
134
|
+
# replaced with safe-call or let idioms.
|
|
135
|
+
NullableToStringCall:
|
|
136
|
+
active: true
|
|
137
|
+
|
|
138
|
+
coroutines:
|
|
139
|
+
active: true
|
|
140
|
+
|
|
141
|
+
# Chapter 12 (Appendix) - Coroutines: GlobalScope couples coroutines to
|
|
142
|
+
# application lifetime and makes cancellation impossible. Use structured
|
|
143
|
+
# concurrency with a scoped CoroutineScope instead.
|
|
144
|
+
GlobalCoroutineUsage:
|
|
145
|
+
active: true
|
|
146
|
+
|
|
147
|
+
# Chapter 12 - Coroutines: suspend modifier on a function that contains no
|
|
148
|
+
# suspension points is misleading and adds unnecessary overhead.
|
|
149
|
+
RedundantSuspendModifier:
|
|
150
|
+
active: true
|
|
151
|
+
|
|
152
|
+
# Chapter 12 - Coroutines: use withContext for blocking calls rather than
|
|
153
|
+
# running them on the default dispatcher.
|
|
154
|
+
BlockingMethodInNonBlockingContext:
|
|
155
|
+
active: true
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
RUN_SCRIPT = """\
|
|
159
|
+
#!/usr/bin/env bash
|
|
160
|
+
# run_detekt.sh
|
|
161
|
+
# Runs Detekt with the Kotlin-in-Action aligned configuration.
|
|
162
|
+
# Generated by setup_detekt.py
|
|
163
|
+
|
|
164
|
+
set -euo pipefail
|
|
165
|
+
|
|
166
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
167
|
+
CONFIG="${SCRIPT_DIR}/detekt.yml"
|
|
168
|
+
SRC_DIR="${1:-src}"
|
|
169
|
+
|
|
170
|
+
if ! command -v detekt &>/dev/null && ! command -v detekt-cli &>/dev/null; then
|
|
171
|
+
echo "ERROR: detekt not found on PATH."
|
|
172
|
+
echo ""
|
|
173
|
+
echo "Install options:"
|
|
174
|
+
echo " brew install detekt # macOS"
|
|
175
|
+
echo " sdk install detekt # SDKMAN"
|
|
176
|
+
echo " # Or run via Gradle plugin - add to build.gradle.kts:"
|
|
177
|
+
echo " # plugins { id(\\"io.gitlab.arturbosch.detekt\\") version \\"1.23.5\\" }"
|
|
178
|
+
exit 1
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
CMD="detekt"
|
|
182
|
+
command -v detekt-cli &>/dev/null && CMD="detekt-cli"
|
|
183
|
+
|
|
184
|
+
echo "Running Detekt on $SRC_DIR ..."
|
|
185
|
+
$CMD --config "$CONFIG" --input "$SRC_DIR"
|
|
186
|
+
echo "Done."
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_file(path: pathlib.Path, content: str, executable: bool = False) -> None:
|
|
191
|
+
path.write_text(content, encoding="utf-8")
|
|
192
|
+
if executable:
|
|
193
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
194
|
+
print(f"Wrote: {path}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main() -> None:
|
|
198
|
+
parser = argparse.ArgumentParser(
|
|
199
|
+
description="Set up Detekt with Kotlin-in-Action aligned rules."
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--output-dir",
|
|
203
|
+
default=".",
|
|
204
|
+
help="Directory to write config files (default: ./)",
|
|
205
|
+
)
|
|
206
|
+
args = parser.parse_args()
|
|
207
|
+
|
|
208
|
+
output_dir = pathlib.Path(args.output_dir).resolve()
|
|
209
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
|
|
211
|
+
write_file(output_dir / "detekt.yml", DETEKT_YML)
|
|
212
|
+
write_file(output_dir / "run_detekt.sh", RUN_SCRIPT, executable=True)
|
|
213
|
+
|
|
214
|
+
print()
|
|
215
|
+
print("Setup complete.")
|
|
216
|
+
print(f" Config : {output_dir / 'detekt.yml'}")
|
|
217
|
+
print(f" Runner : {output_dir / 'run_detekt.sh'}")
|
|
218
|
+
print()
|
|
219
|
+
print("Run Detekt:")
|
|
220
|
+
print(f" cd {output_dir} && ./run_detekt.sh [src-dir]")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"evals": [
|
|
3
|
+
{
|
|
4
|
+
"id": "eval-01-six-month-build-before-user-testing",
|
|
5
|
+
"prompt": "Review this product development plan:\n\n```\nProduct: TaskFlow — AI-powered project management for remote teams\nTeam: 4 engineers, 1 designer, 1 product manager\nTimeline: 6-month build before any user testing\n\nMonth 1-2: Core task management (create, assign, due dates, status)\nMonth 3: AI features (auto-prioritization, workload balancing)\nMonth 4: Integrations (Slack, GitHub, Jira, Google Calendar)\nMonth 5: Mobile apps (iOS and Android)\nMonth 6: Analytics dashboard and reporting\n\nSuccess criteria: Ship to beta users after 6 months\nMarketing plan: Product Hunt launch in month 7\n\nAssumptions we're making:\n- Remote teams struggle with task prioritization\n- AI suggestions will be trusted and acted on\n- Teams are willing to switch from their current PM tool\n- Integration with existing tools is a must-have on day one\n```",
|
|
6
|
+
"expectations": [
|
|
7
|
+
"Identifies the 6-month build before any user testing as a direct violation of the Build-Measure-Learn loop: validated learning cannot happen without users (Ch 3-4: validated learning, experimentation over planning)",
|
|
8
|
+
"Flags the listed assumptions ('remote teams struggle', 'AI suggestions will be trusted', 'willing to switch') as leap-of-faith assumptions that should be tested first, not assumed (Ch 5: identify and test leap-of-faith assumptions before building)",
|
|
9
|
+
"Flags the absence of any hypothesis or success metric beyond 'ship to beta': recommends defining a value hypothesis and growth hypothesis with measurable success criteria (Ch 5: value hypothesis and growth hypothesis)",
|
|
10
|
+
"Flags the absence of an MVP: 6 months of features is not an MVP — it is a v1.0 product launch; recommends identifying the single riskiest assumption and testing it with the minimum viable experiment (Ch 6: MVP is for learning, not launching)",
|
|
11
|
+
"Flags that integrations, mobile apps, and analytics are included before validating whether users even want the core task management and AI features; recommends cutting scope to the riskiest assumption (Ch 6: smallest experiment to test the riskiest assumption)",
|
|
12
|
+
"Flags the absence of innovation accounting: there are no cohort metrics, funnel metrics, or baseline measurements planned (Ch 7: innovation accounting)",
|
|
13
|
+
"Recommends concrete alternatives such as a concierge MVP (manually prioritize tasks for 5 teams for 2 weeks) or a Wizard of Oz test (fake AI powered by a human) to validate the AI trust assumption before building it"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "eval-02-feature-request-without-validated-learning",
|
|
18
|
+
"prompt": "Review this feature request and justification:\n\n```\nFeature Request: Advanced Filtering and Saved Views\n\nSubmitted by: Product Manager\nPriority: High\n\nRequest:\nAdd advanced filtering to the task list — filter by assignee, due date range,\ntag, project, and custom fields. Users should be able to save filter combinations\nas named views (e.g., \"My overdue tasks\", \"Sprint backlog\").\n\nJustification:\n- 3 customers mentioned filtering in support tickets last quarter\n- Our top competitor, Asana, has this feature\n- The engineering team estimates 3 sprints (6 weeks) to build\n- A customer on a discovery call said \"filtering would be great\"\n\nExpected outcome:\nImprove user retention and increase upsell conversion to Pro tier.\n\nApproval requested: Start sprint planning next week\n```",
|
|
19
|
+
"expectations": [
|
|
20
|
+
"Flags that 'customers mentioned in support tickets' and 'a customer said it would be great' are anecdotal, not validated learning — these are opinions, not measured behavior (Ch 3: validated learning requires empirical evidence, not opinions)",
|
|
21
|
+
"Flags competitor benchmarking ('Asana has this feature') as a reason to build without testing whether this feature drives retention or conversion for this specific user base (Ch 5: test your own value hypothesis, not competitors')",
|
|
22
|
+
"Flags that the expected outcome 'improve retention and increase upsell' is stated without a hypothesis, baseline metric, or success threshold; recommends defining a testable hypothesis before committing 6 weeks of engineering (Ch 7: innovation accounting — establish baseline, define success threshold)",
|
|
23
|
+
"Flags no mention of which leap-of-faith assumption is being tested: is the assumption that users can't find what they need, that they churn because of missing filters, or that Pro upsell is blocked by this feature? (Ch 5: identify the specific assumption)",
|
|
24
|
+
"Recommends a smaller experiment to validate before building: add a prominent 'Filter tasks' button that shows a 'Coming soon — join waitlist' modal and measure click rate to quantify demand (Ch 6: smallest experiment to validate the assumption)",
|
|
25
|
+
"Flags the absence of a pivot/persevere decision framework: what happens if the feature ships and retention does not improve? (Ch 8: define pivot/persevere criteria before building)"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "eval-03-well-structured-mvp-experiment",
|
|
30
|
+
"prompt": "Review this product experiment plan:\n\n```\nExperiment: Validate demand for AI-assisted code review summaries\n\nHypothesis:\nWe believe that engineering managers at companies with 10-50 engineers\nspend more than 2 hours per week reading through pull request diffs to\nstay informed. We believe they will pay for an automated daily digest\nthat summarizes what changed, why, and any risks flagged.\n\nRiskiest assumption: Managers will read and act on AI-generated summaries\nrather than skimming or ignoring them.\n\nMVP Design (Wizard of Oz — 3-week test):\n- Recruit 8 engineering managers via LinkedIn outreach\n- Each connects their GitHub repo (OAuth)\n- Every weekday morning, a human analyst (us) reads the day's PRs\n and writes a 200-word summary email manually\n- We send it from a branded email: digest@codebrief.io\n- Participants do not know summaries are human-written\n\nSuccess metrics (measured after 3 weeks):\n- Primary: Open rate >= 70% across all 5 weekday sends\n- Secondary: At least 5 of 8 managers respond to exit survey saying\n they found summaries 'useful' or 'very useful'\n- Conversion signal: At least 3 managers ask 'how do I keep this?'\n\nPivot/persevere criteria:\n- If primary AND secondary metrics are met: build v1 with real AI\n- If only one is met: run a second 2-week test with adjusted format\n- If neither is met: pivot to a different user segment or problem\n\nTimeline: Recruiting this week, experiment runs weeks 2-4, decision week 5\n```",
|
|
31
|
+
"expectations": [
|
|
32
|
+
"Recognizes this is a well-structured Lean Startup experiment and says so explicitly",
|
|
33
|
+
"Praises the falsifiable hypothesis that names a specific user segment, problem, and measurable behavior (Ch 3: validated learning with empirical evidence)",
|
|
34
|
+
"Praises identifying the riskiest assumption ('managers will act on summaries') separately from the general hypothesis — this is the correct application of leap-of-faith assumption identification (Ch 5: leap-of-faith assumptions)",
|
|
35
|
+
"Praises the Wizard of Oz MVP design that tests the value hypothesis without building any AI infrastructure (Ch 6: MVP is for learning, not launching — concierge/Wizard of Oz types)",
|
|
36
|
+
"Praises the quantified success metrics with specific thresholds (70% open rate, 5/8 satisfaction, 3 conversion signals) rather than vague goals (Ch 7: actionable metrics, not vanity metrics)",
|
|
37
|
+
"Praises the explicit pivot/persevere criteria defined before the experiment runs, making the decision data-driven rather than gut-driven (Ch 8: structured pivot/persevere decision)",
|
|
38
|
+
"Praises the tight 5-week timeline keeping the Build-Measure-Learn loop short (Ch 9: small batches, fast learning cycles)",
|
|
39
|
+
"Does NOT manufacture issues to appear thorough; any suggestions are explicitly framed as minor optional improvements"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|