@booklib/skills 1.3.2 → 1.5.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/AGENTS.md +108 -0
- package/CLAUDE.md +58 -0
- package/CODE_OF_CONDUCT.md +31 -0
- package/CONTRIBUTING.md +13 -0
- package/README.md +69 -45
- package/SECURITY.md +9 -0
- package/assets/logo.svg +36 -0
- package/demo.gif +0 -0
- package/demo.tape +40 -0
- package/docs/index.html +187 -0
- package/package.json +2 -2
- package/skills/effective-typescript/SKILL.md +166 -0
- package/skills/effective-typescript/evals/evals.json +36 -0
- package/skills/effective-typescript/examples/after.md +70 -0
- package/skills/effective-typescript/examples/before.md +47 -0
- package/skills/effective-typescript/references/api_reference.md +118 -0
- package/skills/effective-typescript/references/practices-catalog.md +371 -0
- package/skills/programming-with-rust/SKILL.md +194 -0
- package/skills/programming-with-rust/evals/evals.json +37 -0
- package/skills/programming-with-rust/examples/after.md +107 -0
- package/skills/programming-with-rust/examples/before.md +59 -0
- package/skills/programming-with-rust/references/api_reference.md +152 -0
- package/skills/programming-with-rust/references/practices-catalog.md +335 -0
- package/skills/rust-in-action/SKILL.md +290 -0
- package/skills/rust-in-action/evals/evals.json +38 -0
- package/skills/rust-in-action/examples/after.md +156 -0
- package/skills/rust-in-action/examples/before.md +56 -0
- package/skills/rust-in-action/references/practices-catalog.md +346 -0
- package/skills/rust-in-action/scripts/review.py +147 -0
- package/skills/skill-router/SKILL.md +16 -13
- package/skills/skill-router/references/skill-catalog.md +19 -1
- package/skills/spring-boot-in-action/SKILL.md +312 -0
- package/skills/spring-boot-in-action/evals/evals.json +39 -0
- package/skills/spring-boot-in-action/examples/after.md +185 -0
- package/skills/spring-boot-in-action/examples/before.md +84 -0
- package/skills/spring-boot-in-action/references/practices-catalog.md +403 -0
- package/skills/spring-boot-in-action/scripts/review.py +184 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# Spring Boot in Action — Practices Catalog
|
|
2
|
+
|
|
3
|
+
Before/after examples from each chapter group.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Auto-Configuration: Let Boot Do Its Job (Ch 2, 3)
|
|
8
|
+
|
|
9
|
+
**Before — manual DataSource fighting auto-config:**
|
|
10
|
+
```java
|
|
11
|
+
@Configuration
|
|
12
|
+
public class DatabaseConfig {
|
|
13
|
+
@Bean
|
|
14
|
+
public DataSource dataSource() {
|
|
15
|
+
DriverManagerDataSource ds = new DriverManagerDataSource();
|
|
16
|
+
ds.setUrl("jdbc:postgresql://localhost/mydb");
|
|
17
|
+
ds.setUsername("user");
|
|
18
|
+
ds.setPassword("pass");
|
|
19
|
+
return ds;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
**After — delete the class; use properties:**
|
|
24
|
+
```properties
|
|
25
|
+
spring.datasource.url=jdbc:postgresql://localhost/mydb
|
|
26
|
+
spring.datasource.username=${DB_USER}
|
|
27
|
+
spring.datasource.password=${DB_PASS}
|
|
28
|
+
```
|
|
29
|
+
Spring Boot auto-configures a connection pool (HikariCP) from these properties automatically.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Overriding Auto-Configuration Surgically (Ch 3)
|
|
34
|
+
|
|
35
|
+
**When auto-config is not enough — extend, don't replace:**
|
|
36
|
+
```java
|
|
37
|
+
// Wrong: replaces all security auto-config
|
|
38
|
+
@Configuration
|
|
39
|
+
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|
40
|
+
@Override
|
|
41
|
+
protected void configure(HttpSecurity http) throws Exception {
|
|
42
|
+
http.authorizeRequests().anyRequest().permitAll(); // disables everything
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Right: override only what differs (Ch 3)
|
|
47
|
+
@Configuration
|
|
48
|
+
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|
49
|
+
@Override
|
|
50
|
+
protected void configure(HttpSecurity http) throws Exception {
|
|
51
|
+
http
|
|
52
|
+
.authorizeRequests()
|
|
53
|
+
.antMatchers("/api/public/**").permitAll()
|
|
54
|
+
.anyRequest().authenticated()
|
|
55
|
+
.and()
|
|
56
|
+
.httpBasic();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Externalized Configuration (Ch 3)
|
|
64
|
+
|
|
65
|
+
**Before — hardcoded values:**
|
|
66
|
+
```java
|
|
67
|
+
@Service
|
|
68
|
+
public class MailService {
|
|
69
|
+
private String host = "smtp.gmail.com"; // hardcoded
|
|
70
|
+
private int port = 587; // hardcoded
|
|
71
|
+
private int timeout = 5000; // hardcoded
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
**After — `@ConfigurationProperties` (type-safe, testable):**
|
|
75
|
+
```java
|
|
76
|
+
@ConfigurationProperties(prefix = "app.mail")
|
|
77
|
+
@Component
|
|
78
|
+
public class MailProperties {
|
|
79
|
+
private String host;
|
|
80
|
+
private int port = 587;
|
|
81
|
+
private int timeoutMs = 5000;
|
|
82
|
+
// getters + setters
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
```properties
|
|
86
|
+
# application.properties
|
|
87
|
+
app.mail.host=smtp.gmail.com
|
|
88
|
+
app.mail.port=587
|
|
89
|
+
app.mail.timeout-ms=5000
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Profiles for Environment Differences (Ch 3)
|
|
95
|
+
|
|
96
|
+
**Before — environment check in code:**
|
|
97
|
+
```java
|
|
98
|
+
@Bean
|
|
99
|
+
public DataSource dataSource() {
|
|
100
|
+
if (System.getProperty("env").equals("dev")) {
|
|
101
|
+
return new EmbeddedDatabaseBuilder().setType(H2).build();
|
|
102
|
+
}
|
|
103
|
+
return productionDataSource(); // messy
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
**After — profile-specific properties files:**
|
|
107
|
+
```properties
|
|
108
|
+
# application.properties (defaults)
|
|
109
|
+
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}/myapp
|
|
110
|
+
|
|
111
|
+
# application-dev.properties (activated by: spring.profiles.active=dev)
|
|
112
|
+
spring.datasource.url=jdbc:h2:mem:myapp
|
|
113
|
+
spring.jpa.hibernate.ddl-auto=create-drop
|
|
114
|
+
logging.level.org.springframework=DEBUG
|
|
115
|
+
|
|
116
|
+
# application-production.properties
|
|
117
|
+
spring.jpa.hibernate.ddl-auto=validate
|
|
118
|
+
logging.level.root=WARN
|
|
119
|
+
```
|
|
120
|
+
```java
|
|
121
|
+
// Profile-specific beans (Ch 3)
|
|
122
|
+
@Bean
|
|
123
|
+
@Profile("dev")
|
|
124
|
+
public DataSource devDataSource() { ... }
|
|
125
|
+
|
|
126
|
+
@Bean
|
|
127
|
+
@Profile("production")
|
|
128
|
+
public DataSource prodDataSource() { ... }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Constructor Injection (Ch 2)
|
|
134
|
+
|
|
135
|
+
**Before — field injection, untestable:**
|
|
136
|
+
```java
|
|
137
|
+
@Service
|
|
138
|
+
public class OrderService {
|
|
139
|
+
@Autowired
|
|
140
|
+
private OrderRepository repo; // can't test without Spring context
|
|
141
|
+
|
|
142
|
+
@Autowired
|
|
143
|
+
private PaymentGateway gateway;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
**After — constructor injection:**
|
|
147
|
+
```java
|
|
148
|
+
@Service
|
|
149
|
+
public class OrderService {
|
|
150
|
+
private final OrderRepository repo;
|
|
151
|
+
private final PaymentGateway gateway;
|
|
152
|
+
|
|
153
|
+
public OrderService(OrderRepository repo, PaymentGateway gateway) {
|
|
154
|
+
this.repo = repo;
|
|
155
|
+
this.gateway = gateway;
|
|
156
|
+
}
|
|
157
|
+
// Now testable: new OrderService(mockRepo, mockGateway)
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## REST Controllers: Status Codes & ResponseEntity (Ch 2)
|
|
164
|
+
|
|
165
|
+
**Before — always 200, null on missing:**
|
|
166
|
+
```java
|
|
167
|
+
@GetMapping("/users/{id}")
|
|
168
|
+
public User getUser(@PathVariable Long id) {
|
|
169
|
+
return repo.findById(id).orElse(null); // 200 with null body
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@PostMapping("/users")
|
|
173
|
+
public User createUser(@RequestBody User user) {
|
|
174
|
+
return repo.save(user); // 200, should be 201
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
**After — correct HTTP semantics:**
|
|
178
|
+
```java
|
|
179
|
+
@GetMapping("/users/{id}")
|
|
180
|
+
public ResponseEntity<User> getUser(@PathVariable Long id) {
|
|
181
|
+
return repo.findById(id)
|
|
182
|
+
.map(ResponseEntity::ok)
|
|
183
|
+
.orElse(ResponseEntity.notFound().build()); // 404
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@PostMapping("/users")
|
|
187
|
+
public ResponseEntity<User> createUser(@RequestBody User user) {
|
|
188
|
+
User saved = repo.save(user);
|
|
189
|
+
URI location = URI.create("/users/" + saved.getId());
|
|
190
|
+
return ResponseEntity.created(location).body(saved); // 201 + Location
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Testing: Match Slice to Layer (Ch 4)
|
|
197
|
+
|
|
198
|
+
**Before — full context for every test (slow):**
|
|
199
|
+
```java
|
|
200
|
+
@SpringBootTest // loads ALL beans — overkill for a controller test
|
|
201
|
+
public class ProductControllerTest {
|
|
202
|
+
@Autowired
|
|
203
|
+
private ProductController controller;
|
|
204
|
+
|
|
205
|
+
@Test
|
|
206
|
+
public void testGet() {
|
|
207
|
+
// No HTTP semantics, no status code, no content-type testing
|
|
208
|
+
assertNotNull(controller.getProduct(1L));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
**After — slice tests by layer:**
|
|
213
|
+
```java
|
|
214
|
+
// Controller slice — only web layer, MockMvc, no DB (Ch 4)
|
|
215
|
+
@WebMvcTest(ProductController.class)
|
|
216
|
+
public class ProductControllerTest {
|
|
217
|
+
@Autowired
|
|
218
|
+
private MockMvc mockMvc;
|
|
219
|
+
|
|
220
|
+
@MockBean
|
|
221
|
+
private ProductService service;
|
|
222
|
+
|
|
223
|
+
@Test
|
|
224
|
+
void getProduct_found() throws Exception {
|
|
225
|
+
given(service.findById(1L)).willReturn(new Product(1L, "Widget", 9.99));
|
|
226
|
+
|
|
227
|
+
mockMvc.perform(get("/api/products/1").accept(MediaType.APPLICATION_JSON))
|
|
228
|
+
.andExpect(status().isOk())
|
|
229
|
+
.andExpect(jsonPath("$.name").value("Widget"))
|
|
230
|
+
.andExpect(jsonPath("$.price").value(9.99));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@Test
|
|
234
|
+
void getProduct_notFound() throws Exception {
|
|
235
|
+
given(service.findById(99L))
|
|
236
|
+
.willThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
|
|
237
|
+
|
|
238
|
+
mockMvc.perform(get("/api/products/99"))
|
|
239
|
+
.andExpect(status().isNotFound());
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Repository slice — real DB (H2 in-memory), no web layer (Ch 4)
|
|
244
|
+
@DataJpaTest
|
|
245
|
+
public class ProductRepositoryTest {
|
|
246
|
+
@Autowired
|
|
247
|
+
private ProductRepository repo;
|
|
248
|
+
|
|
249
|
+
@Test
|
|
250
|
+
void findByName_returnsMatches() {
|
|
251
|
+
repo.save(new Product(null, "Blue Widget", 9.99));
|
|
252
|
+
List<Product> found = repo.findByNameContaining("Widget");
|
|
253
|
+
assertThat(found).hasSize(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Full stack test — only when testing HTTP ↔ DB integration (Ch 4)
|
|
258
|
+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
259
|
+
public class ProductIntegrationTest {
|
|
260
|
+
@Autowired
|
|
261
|
+
private TestRestTemplate restTemplate;
|
|
262
|
+
|
|
263
|
+
@Test
|
|
264
|
+
void createAndFetch() {
|
|
265
|
+
ResponseEntity<Product> created = restTemplate
|
|
266
|
+
.postForEntity("/api/products", new Product(null, "Gadget", 19.99), Product.class);
|
|
267
|
+
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Testing Security (Ch 4)
|
|
275
|
+
|
|
276
|
+
```java
|
|
277
|
+
@WebMvcTest(AdminController.class)
|
|
278
|
+
public class AdminControllerTest {
|
|
279
|
+
|
|
280
|
+
@Autowired
|
|
281
|
+
private MockMvc mockMvc;
|
|
282
|
+
|
|
283
|
+
@Test
|
|
284
|
+
void adminEndpoint_rejectsAnonymous() throws Exception {
|
|
285
|
+
mockMvc.perform(get("/admin/dashboard"))
|
|
286
|
+
.andExpect(status().isUnauthorized()); // or 403 depending on config
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@Test
|
|
290
|
+
@WithMockUser(roles = "ADMIN")
|
|
291
|
+
void adminEndpoint_allowsAdmin() throws Exception {
|
|
292
|
+
mockMvc.perform(get("/admin/dashboard"))
|
|
293
|
+
.andExpect(status().isOk());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@Test
|
|
297
|
+
@WithMockUser(roles = "USER")
|
|
298
|
+
void adminEndpoint_rejectsUser() throws Exception {
|
|
299
|
+
mockMvc.perform(get("/admin/dashboard"))
|
|
300
|
+
.andExpect(status().isForbidden());
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Actuator: Health, Metrics, Custom Indicators (Ch 7)
|
|
308
|
+
|
|
309
|
+
```java
|
|
310
|
+
// Custom HealthIndicator (Ch 7)
|
|
311
|
+
@Component
|
|
312
|
+
public class ExternalApiHealthIndicator implements HealthIndicator {
|
|
313
|
+
private final RestTemplate restTemplate;
|
|
314
|
+
|
|
315
|
+
public ExternalApiHealthIndicator(RestTemplate restTemplate) {
|
|
316
|
+
this.restTemplate = restTemplate;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@Override
|
|
320
|
+
public Health health() {
|
|
321
|
+
try {
|
|
322
|
+
ResponseEntity<String> resp = restTemplate.getForEntity(
|
|
323
|
+
"https://api.partner.com/health", String.class);
|
|
324
|
+
if (resp.getStatusCode().is2xxSuccessful()) {
|
|
325
|
+
return Health.up().withDetail("partner-api", "reachable").build();
|
|
326
|
+
}
|
|
327
|
+
return Health.down().withDetail("status", resp.getStatusCode()).build();
|
|
328
|
+
} catch (Exception e) {
|
|
329
|
+
return Health.down(e).build();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Custom counter metric (Ch 7)
|
|
335
|
+
@Service
|
|
336
|
+
public class OrderService {
|
|
337
|
+
private final Counter ordersCreated;
|
|
338
|
+
|
|
339
|
+
public OrderService(MeterRegistry registry) {
|
|
340
|
+
this.ordersCreated = Counter.builder("orders.created")
|
|
341
|
+
.description("Total orders placed")
|
|
342
|
+
.register(registry);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
public Order place(Order order) {
|
|
346
|
+
Order saved = repo.save(order);
|
|
347
|
+
ordersCreated.increment(); // visible in /actuator/metrics
|
|
348
|
+
return saved;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```properties
|
|
354
|
+
# Secure Actuator — expose only what's needed (Ch 7)
|
|
355
|
+
management.endpoints.web.exposure.include=health,info,metrics
|
|
356
|
+
management.endpoint.health.show-details=when-authorized
|
|
357
|
+
management.endpoint.shutdown.enabled=false
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Deployment: JAR, Profiles, Flyway (Ch 8)
|
|
363
|
+
|
|
364
|
+
```xml
|
|
365
|
+
<!-- pom.xml: flyway for production migrations (Ch 8) -->
|
|
366
|
+
<dependency>
|
|
367
|
+
<groupId>org.flywaydb</groupId>
|
|
368
|
+
<artifactId>flyway-core</artifactId>
|
|
369
|
+
</dependency>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```sql
|
|
373
|
+
-- src/main/resources/db/migration/V1__create_books.sql
|
|
374
|
+
CREATE TABLE book (
|
|
375
|
+
id BIGSERIAL PRIMARY KEY,
|
|
376
|
+
title VARCHAR(255) NOT NULL,
|
|
377
|
+
isbn VARCHAR(20) UNIQUE NOT NULL
|
|
378
|
+
);
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```properties
|
|
382
|
+
# application-production.properties (Ch 8)
|
|
383
|
+
# Never use create or create-drop in production
|
|
384
|
+
spring.jpa.hibernate.ddl-auto=validate
|
|
385
|
+
spring.flyway.enabled=true
|
|
386
|
+
|
|
387
|
+
# Disable dev tools
|
|
388
|
+
spring.devtools.restart.enabled=false
|
|
389
|
+
|
|
390
|
+
# Tighten logging
|
|
391
|
+
logging.level.root=WARN
|
|
392
|
+
logging.level.com.example=INFO
|
|
393
|
+
|
|
394
|
+
# Actuator: health only
|
|
395
|
+
management.endpoints.web.exposure.include=health
|
|
396
|
+
management.endpoint.health.show-details=never
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
# Build and run (Ch 8)
|
|
401
|
+
mvn clean package -DskipTests
|
|
402
|
+
java -jar target/library-1.0.0.jar --spring.profiles.active=production
|
|
403
|
+
```
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
review.py — Pre-analysis script for Spring Boot in Action reviews.
|
|
4
|
+
Usage: python review.py <file.java|application.properties|application.yml>
|
|
5
|
+
|
|
6
|
+
Scans Spring Boot source files for anti-patterns from the book:
|
|
7
|
+
field injection, hardcoded credentials, missing ResponseEntity, auto-config
|
|
8
|
+
fighting, wrong test annotations, missing Actuator security, and ddl-auto=create.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
JAVA_CHECKS = [
|
|
17
|
+
(
|
|
18
|
+
r"@Autowired\s*\n\s*(private|protected)",
|
|
19
|
+
"Ch 2: Field injection (@Autowired on field)",
|
|
20
|
+
"use constructor injection — fields with @Autowired are not testable without Spring context",
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
r"DriverManagerDataSource|BasicDataSource|HikariDataSource",
|
|
24
|
+
"Ch 2/3: Manual DataSource bean",
|
|
25
|
+
"delete this bean and set spring.datasource.* in application.properties — Boot auto-configures HikariCP",
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
r"orElse\(null\)",
|
|
29
|
+
"Ch 2: orElse(null) returns null to client",
|
|
30
|
+
"use .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)) or .map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build())",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
r'setPassword\s*\(\s*"[^"$][^"]*"',
|
|
34
|
+
"Ch 3: Hardcoded password",
|
|
35
|
+
"externalize to environment variable: spring.datasource.password=${DB_PASS}",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
r'setUsername\s*\(\s*"[^"$][^"]*"',
|
|
39
|
+
"Ch 3: Hardcoded username",
|
|
40
|
+
"externalize to environment variable: spring.datasource.username=${DB_USER}",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
r"new ObjectMapper\(\)",
|
|
44
|
+
"Ch 2: Manual ObjectMapper construction",
|
|
45
|
+
"Spring Boot auto-configures Jackson — inject ObjectMapper bean or configure via spring.jackson.* properties",
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
r"@SpringBootTest\b(?!.*WebEnvironment)",
|
|
49
|
+
"Ch 4: @SpringBootTest without WebEnvironment",
|
|
50
|
+
"for controller tests use @WebMvcTest; for repository tests use @DataJpaTest; full @SpringBootTest only for integration tests",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
r"System\.out\.println",
|
|
54
|
+
"Ch 3: System.out.println instead of logger",
|
|
55
|
+
"use SLF4J: private static final Logger log = LoggerFactory.getLogger(MyClass.class)",
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
r'@Value\s*\(\s*"\$\{[^}]+\}"\s*\)(?:[\s\S]{0,200}@Value\s*\(\s*"\$\{){2}',
|
|
59
|
+
"Ch 3: Multiple @Value annotations",
|
|
60
|
+
"group related config values in a @ConfigurationProperties class with a prefix",
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
PROPERTIES_CHECKS = [
|
|
65
|
+
(
|
|
66
|
+
r"ddl-auto\s*=\s*(create|create-drop)(?!\s*#.*test)",
|
|
67
|
+
"Ch 8: ddl-auto=create or create-drop",
|
|
68
|
+
"destroys data on restart — use 'validate' in production; use Flyway/Liquibase for migrations",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
r"management\.endpoints\.web\.exposure\.include\s*=\s*\*",
|
|
72
|
+
"Ch 7: Actuator exposes all endpoints",
|
|
73
|
+
"restrict to: management.endpoints.web.exposure.include=health,info — never expose env, beans, or shutdown publicly",
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
r"spring\.security\.user\.password\s*=\s*(?!(\$\{|\s*$))",
|
|
77
|
+
"Ch 3: Hardcoded security password in properties",
|
|
78
|
+
"use environment variable: spring.security.user.password=${ADMIN_PASS}",
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
r"datasource\.password\s*=\s*(?!\$\{)(\S+)",
|
|
82
|
+
"Ch 3: Hardcoded datasource password",
|
|
83
|
+
"use environment variable: spring.datasource.password=${DB_PASS}",
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
r"datasource\.url\s*=\s*jdbc:[a-z]+://(?!(\$\{|localhost|127\.0\.0\.1))\S+",
|
|
87
|
+
"Ch 3: Hardcoded production datasource URL",
|
|
88
|
+
"use environment variable: spring.datasource.url=${DB_URL}",
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scan_java(source: str) -> list[dict]:
|
|
94
|
+
findings = []
|
|
95
|
+
lines = source.splitlines()
|
|
96
|
+
for lineno, line in enumerate(lines, start=1):
|
|
97
|
+
if line.strip().startswith("//"):
|
|
98
|
+
continue
|
|
99
|
+
for pattern, label, advice in JAVA_CHECKS:
|
|
100
|
+
if re.search(pattern, line):
|
|
101
|
+
findings.append({"line": lineno, "text": line.rstrip(), "label": label, "advice": advice})
|
|
102
|
+
return findings
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def scan_properties(source: str) -> list[dict]:
|
|
106
|
+
findings = []
|
|
107
|
+
lines = source.splitlines()
|
|
108
|
+
for lineno, line in enumerate(lines, start=1):
|
|
109
|
+
if line.strip().startswith("#"):
|
|
110
|
+
continue
|
|
111
|
+
for pattern, label, advice in PROPERTIES_CHECKS:
|
|
112
|
+
if re.search(pattern, line):
|
|
113
|
+
findings.append({"line": lineno, "text": line.rstrip(), "label": label, "advice": advice})
|
|
114
|
+
return findings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sep(char="-", width=70) -> str:
|
|
118
|
+
return char * width
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> None:
|
|
122
|
+
if len(sys.argv) < 2:
|
|
123
|
+
print("Usage: python review.py <file.java|application.properties|application.yml>")
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
path = Path(sys.argv[1])
|
|
127
|
+
if not path.exists():
|
|
128
|
+
print(f"Error: file not found: {path}")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
132
|
+
|
|
133
|
+
if path.suffix == ".java":
|
|
134
|
+
findings = scan_java(source)
|
|
135
|
+
file_type = "Java"
|
|
136
|
+
elif path.suffix in (".properties", ".yml", ".yaml"):
|
|
137
|
+
findings = scan_properties(source)
|
|
138
|
+
file_type = "Properties/YAML"
|
|
139
|
+
else:
|
|
140
|
+
print(f"Warning: unsupported extension '{path.suffix}' — scanning as Java")
|
|
141
|
+
findings = scan_java(source)
|
|
142
|
+
file_type = "Unknown"
|
|
143
|
+
|
|
144
|
+
groups: dict[str, list] = {}
|
|
145
|
+
for f in findings:
|
|
146
|
+
groups.setdefault(f["label"], []).append(f)
|
|
147
|
+
|
|
148
|
+
print(sep("="))
|
|
149
|
+
print("SPRING BOOT IN ACTION — PRE-REVIEW REPORT")
|
|
150
|
+
print(sep("="))
|
|
151
|
+
print(f"File : {path} ({file_type})")
|
|
152
|
+
print(f"Lines : {len(source.splitlines())}")
|
|
153
|
+
print(f"Issues : {len(findings)} potential anti-patterns across {len(groups)} categories")
|
|
154
|
+
print()
|
|
155
|
+
|
|
156
|
+
if not findings:
|
|
157
|
+
print(" [OK] No common Spring Boot anti-patterns detected.")
|
|
158
|
+
print()
|
|
159
|
+
else:
|
|
160
|
+
for label, items in groups.items():
|
|
161
|
+
print(sep())
|
|
162
|
+
print(f" {label} ({len(items)} occurrence{'s' if len(items) != 1 else ''})")
|
|
163
|
+
print(sep())
|
|
164
|
+
print(f" Advice: {items[0]['advice']}")
|
|
165
|
+
print()
|
|
166
|
+
for item in items[:5]:
|
|
167
|
+
print(f" line {item['line']:>4}: {item['text'][:100]}")
|
|
168
|
+
if len(items) > 5:
|
|
169
|
+
print(f" ... and {len(items) - 5} more")
|
|
170
|
+
print()
|
|
171
|
+
|
|
172
|
+
severity = (
|
|
173
|
+
"HIGH" if len(findings) >= 5
|
|
174
|
+
else "MEDIUM" if len(findings) >= 2
|
|
175
|
+
else "LOW" if findings
|
|
176
|
+
else "NONE"
|
|
177
|
+
)
|
|
178
|
+
print(sep("="))
|
|
179
|
+
print(f"SEVERITY: {severity} | Key chapters: Ch 2 (injection/REST), Ch 3 (config/profiles), Ch 4 (testing), Ch 7 (Actuator), Ch 8 (deployment)")
|
|
180
|
+
print(sep("="))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|