@clubmatto/ai-kit 0.0.6 → 0.0.7

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.
@@ -0,0 +1,5 @@
1
+ # Spring Boot
2
+
3
+ Follow Spring Boot best practices.
4
+
5
+ {{FOOTER}}
package/CHANGELOG.md CHANGED
@@ -20,4 +20,4 @@ All notable changes to this project will be documented in this file.
20
20
 
21
21
  ### Added
22
22
 
23
- - Initial release
23
+ - Initial release
package/README.md CHANGED
@@ -4,13 +4,15 @@
4
4
  [![npm version](https://img.shields.io/npm/v/@clubmatto/ai-kit)](https://www.npmjs.com/package/@clubmatto/ai-kit)
5
5
  [![License: MIT](https://img.shields.io/npm/l/@clubmatto%2Fai-kit)](/LICENSE)
6
6
 
7
- The AI configuration CLI from Club Matto. Sync rules, skills, and commands to power up your AI coding workflow.
7
+ The AI configuration CLI from Club Matto. Sync rules, skills, and commands to
8
+ power up your AI coding workflow.
8
9
 
9
10
  ## Features
10
11
 
11
12
  - **Language Rules** — TypeScript, Go, Kotlin, and more
12
13
  - **Skills** — Reusable AI capabilities like Playwright automation
13
- - **Commands** — Pre-built prompts for common tasks (commit messages, PR reviews)
14
+ - **Commands** — Pre-built prompts for common tasks (commit messages, PR
15
+ reviews)
14
16
 
15
17
  ## Quick Start
16
18
 
@@ -30,16 +32,33 @@ ai-kit sync
30
32
 
31
33
  # Skip installing opencode.json to project root
32
34
  ai-kit sync --skip-opencode
35
+
36
+ # Language detection & filtering
37
+ ai-kit sync --all-rules # Install all language rules
38
+ ai-kit sync --languages=go,kotlin # Install specific language rules
39
+ ai-kit sync --monorepo # Force monorepo AGENTS.md template
40
+ ai-kit sync --single-repo # Force single-repo AGENTS.md template
33
41
  ```
34
42
 
43
+ The CLI automatically detects project languages and installs only relevant
44
+ rules:
45
+
46
+ - **TypeScript/JavaScript**: `package.json` or `.ts`/`.js` files
47
+ - **Go**: `go.mod` or `.go` files
48
+ - **Kotlin**: `build.gradle`, `build.gradle.kts`, `pom.xml` or `.kt` files
49
+ - **Spring Boot**: `application.properties`/`.yml` + Kotlin/Java files
50
+
51
+ Multiple languages → monorepo mode (all rules + monorepo AGENTS.md).
52
+ Single language → single-repo mode (language-specific AGENTS.md).
53
+
35
54
  ## What's Installed
36
55
 
37
- | Location | Description |
38
- | ----------------- | --------------------------------- |
39
- | `.agents/rules/` | Language/framework rules |
40
- | `.agents/skills/` | Reusable AI capabilities |
41
- | `opencode.json` | Opencode configuration (optional) |
42
- | `AGENTS.md` | Agent instructions |
56
+ | Location | Description |
57
+ | ----------------- | ----------------------------------------- |
58
+ | `.agents/rules/` | Language/framework rules (auto-detected) |
59
+ | `.agents/skills/` | Reusable AI capabilities |
60
+ | `opencode.json` | Opencode configuration (optional) |
61
+ | `AGENTS.md` | Agent instructions (monorepo/single-repo) |
43
62
 
44
63
  ## Commands
45
64
 
@@ -63,6 +82,9 @@ ai-kit sync
63
82
  ## Release
64
83
 
65
84
  ```bash
85
+ # Bump version in package.json first
86
+ git add package.json && git commit -m "release: bump version to <version>"
87
+
66
88
  # Create git tag with prefix (triggers automated release)
67
89
  git tag ai-kit/v<version>
68
90
  git push origin ai-kit/v<version>
@@ -7,6 +7,8 @@ const reader_1 = require("../reader");
7
7
  const manifest_1 = require("../manifest");
8
8
  const template_1 = require("../template");
9
9
  const output_1 = require("../output");
10
+ const detect_1 = require("../detection/detect");
11
+ const language_detectors_1 = require("../detection/language-detectors");
10
12
  const rootDir = (0, path_1.join)(__dirname, "..", "..", "..");
11
13
  const defaultSourceDirs = {
12
14
  rules: (0, path_1.join)(rootDir, "src", "rules"),
@@ -44,8 +46,46 @@ async function doSync(cwd, version, options, logger, sourceDirs) {
44
46
  }
45
47
  const contentFiles = (0, reader_1.readContent)(sourceDirs.rules, sourceDirs.skills);
46
48
  const rootFiles = (0, reader_1.readConfigs)(sourceDirs.agents);
47
- const agentsFile = (0, reader_1.readAgents)(sourceDirs.agents);
48
- const rules = contentFiles.filter((f) => f.type === "rules");
49
+ // Detect languages and determine project type
50
+ const detectionResult = (0, detect_1.detectLanguages)(cwd);
51
+ let languages = detectionResult.languages;
52
+ let isMonorepo = detectionResult.isMonorepo;
53
+ const primaryLanguage = detectionResult.primaryLanguage;
54
+ // Apply overrides from options
55
+ if (options.allRules) {
56
+ languages = language_detectors_1.detectors.map((d) => d.name);
57
+ isMonorepo = true;
58
+ }
59
+ else if (options.monorepo) {
60
+ isMonorepo = true;
61
+ }
62
+ else if (options.singleRepo) {
63
+ isMonorepo = false;
64
+ }
65
+ if (options.languages && options.languages.length > 0) {
66
+ languages = options.languages;
67
+ isMonorepo = languages.length > 1;
68
+ }
69
+ // If no languages detected and no overrides, fall back to all rules (monorepo)
70
+ if (languages.length === 0) {
71
+ languages = language_detectors_1.detectors.map((d) => d.name);
72
+ isMonorepo = true;
73
+ }
74
+ const agentsFile = (0, reader_1.readAgents)(sourceDirs.agents, isMonorepo, primaryLanguage);
75
+ // Filter rules based on detected languages
76
+ const ruleFilesToInclude = options.allRules
77
+ ? (0, detect_1.getAllRuleFiles)()
78
+ : (0, detect_1.getRuleFilesForLanguages)(languages);
79
+ const rules = contentFiles.filter((f) => {
80
+ if (f.type !== "rules")
81
+ return false;
82
+ // Always include non-language-specific rules (plan-mode.md, unsure.md, etc.)
83
+ if (!(0, detect_1.isLanguageSpecificRule)(f.name)) {
84
+ return true;
85
+ }
86
+ // For language-specific rules, check if they're in the include list
87
+ return ruleFilesToInclude.includes(f.name);
88
+ });
49
89
  const stats = { rules: 0, skills: 0, commands: 0 };
50
90
  const installedRootFiles = [];
51
91
  if (!options.skipOpencode) {
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectLanguages = detectLanguages;
4
+ exports.getRuleFilesForLanguages = getRuleFilesForLanguages;
5
+ exports.getAllRuleFiles = getAllRuleFiles;
6
+ exports.isLanguageSpecificRule = isLanguageSpecificRule;
7
+ const language_detectors_1 = require("./language-detectors");
8
+ const scanner_1 = require("./scanner");
9
+ function detectLanguages(cwd) {
10
+ const detected = new Set();
11
+ for (const detector of language_detectors_1.detectors) {
12
+ if ((0, scanner_1.hasAnyConfigFile)(cwd, detector.configFiles)) {
13
+ detected.add(detector.name);
14
+ }
15
+ }
16
+ if (detected.size === 0) {
17
+ for (const detector of language_detectors_1.detectors) {
18
+ if ((0, scanner_1.hasAnySourceFile)(cwd, detector.extensions, 2)) {
19
+ detected.add(detector.name);
20
+ }
21
+ }
22
+ }
23
+ const languages = Array.from(detected);
24
+ const isMonorepo = languages.length > 1;
25
+ const primaryLanguage = languages.length > 0 ? languages[0] : undefined;
26
+ return { languages, isMonorepo, primaryLanguage };
27
+ }
28
+ function getRuleFilesForLanguages(languages) {
29
+ const ruleFiles = new Set();
30
+ for (const language of languages) {
31
+ const detector = language_detectors_1.detectors.find((d) => d.name === language);
32
+ if (detector) {
33
+ ruleFiles.add(detector.ruleFile);
34
+ }
35
+ }
36
+ return Array.from(ruleFiles);
37
+ }
38
+ function getAllRuleFiles() {
39
+ return language_detectors_1.detectors.map((detector) => detector.ruleFile);
40
+ }
41
+ function isLanguageSpecificRule(ruleFile) {
42
+ return language_detectors_1.detectors.some((detector) => detector.ruleFile === ruleFile);
43
+ }
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectors = void 0;
4
+ //TODO this is a good first implementation
5
+ //but we clearly want each detector to come with a detect function
6
+ exports.detectors = [
7
+ {
8
+ name: "typescript",
9
+ ruleFile: "typescript.md",
10
+ configFiles: ["package.json", "tsconfig.json"],
11
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
12
+ },
13
+ {
14
+ name: "go",
15
+ ruleFile: "go.md",
16
+ configFiles: ["go.mod"],
17
+ extensions: [".go"],
18
+ },
19
+ {
20
+ name: "kotlin",
21
+ ruleFile: "kotlin.md",
22
+ configFiles: ["build.gradle", "build.gradle.kts", "pom.xml"],
23
+ extensions: [".kt", ".kts", ".java"],
24
+ },
25
+ ];
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasAnyConfigFile = hasAnyConfigFile;
4
+ exports.hasAnySourceFile = hasAnySourceFile;
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const IGNORE_DIRS = [
8
+ "node_modules",
9
+ ".git",
10
+ "dist",
11
+ "build",
12
+ "target",
13
+ ".next",
14
+ ".nuxt",
15
+ ];
16
+ function hasAnyConfigFile(cwd, configFiles) {
17
+ for (const configFile of configFiles) {
18
+ if ((0, fs_1.existsSync)((0, path_1.join)(cwd, configFile))) {
19
+ return true;
20
+ }
21
+ }
22
+ return false;
23
+ }
24
+ function hasAnySourceFile(cwd, extensions, maxDepth = 2) {
25
+ return scanForExtensions(cwd, extensions, maxDepth, 0);
26
+ }
27
+ function scanForExtensions(dir, extensions, maxDepth, currentDepth) {
28
+ if (currentDepth > maxDepth) {
29
+ return false;
30
+ }
31
+ try {
32
+ const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const fullPath = (0, path_1.join)(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ if (!IGNORE_DIRS.includes(entry.name) && !entry.name.startsWith(".")) {
37
+ if (scanForExtensions(fullPath, extensions, maxDepth, currentDepth + 1)) {
38
+ return true;
39
+ }
40
+ }
41
+ }
42
+ else if (entry.isFile()) {
43
+ if (extensions.some((ext) => entry.name.endsWith(ext))) {
44
+ return true;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ catch {
50
+ // If we can't read the directory, skip it
51
+ }
52
+ return false;
53
+ }
package/dist/src/index.js CHANGED
@@ -15,5 +15,17 @@ program
15
15
  program
16
16
  .command("sync")
17
17
  .description("Initialize or update AI configuration")
18
- .action(() => (0, sync_1.sync)(process.cwd(), version, program.opts()));
18
+ .option("--all-rules", "Install all language rules regardless of detection")
19
+ .option("--monorepo", "Force treat project as monorepo")
20
+ .option("--single-repo", "Force treat project as single repository")
21
+ .option("--languages <languages>", "Specify languages to install rules for (comma-separated)")
22
+ .action((cmdOptions) => {
23
+ const options = { ...program.opts(), ...cmdOptions };
24
+ if (options.languages && typeof options.languages === "string") {
25
+ options.languages = options.languages
26
+ .split(",")
27
+ .map((lang) => lang.trim());
28
+ }
29
+ (0, sync_1.sync)(process.cwd(), version, options);
30
+ });
19
31
  program.parse();
@@ -84,13 +84,20 @@ function readConfigs(agentsDir) {
84
84
  return [];
85
85
  }
86
86
  }
87
- function readAgents(agentsDir) {
88
- const sourcePath = (0, path_1.join)(agentsDir, "monorepo.md");
87
+ function readAgents(agentsDir, isMonorepo = true, primaryLanguage) {
88
+ const templateName = isMonorepo ? "monorepo.md" : "single-repo.md";
89
+ const sourcePath = (0, path_1.join)(agentsDir, templateName);
89
90
  try {
91
+ let content = (0, fs_1.readFileSync)(sourcePath, "utf-8");
92
+ if (!isMonorepo && primaryLanguage) {
93
+ content = content
94
+ .replace(/{{LANGUAGE}}/g, primaryLanguage)
95
+ .replace(/{{LANGUAGE_RULE_FILE}}/g, `${primaryLanguage}.md`);
96
+ }
90
97
  return {
91
98
  type: "config",
92
99
  name: "AGENTS.md",
93
- content: (0, fs_1.readFileSync)(sourcePath, "utf-8"),
100
+ content,
94
101
  };
95
102
  }
96
103
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubmatto/ai-kit",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "The AI configuration CLI from Club Matto",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  "prettier:check": "prettier --check .",
27
27
  "format": "prettier --write .",
28
28
  "lint": "eslint . && knip",
29
- "ci": "npm run typecheck && npm run lint && npm run test && npm run test:integration"
29
+ "ci": "npm run prettier:check && npm run typecheck && npm run lint && npm run test && npm run test:integration"
30
30
  },
31
31
  "dependencies": {
32
32
  "commander": "^14.0.3",
@@ -1,6 +1,6 @@
1
1
  # Agents.md
2
2
 
3
- This is a monorepo containing apps in many languages.
3
+ This is a monorepo containing projects in many languages.
4
4
 
5
5
  You MUST follow the specific rules for each language. **ALWAYS** start from
6
6
  checking out the closest README.md.
@@ -0,0 +1,15 @@
1
+ # Agents.md
2
+
3
+ This project uses {{LANGUAGE}}. Follow these rules when working with {{LANGUAGE}} code. **ALWAYS** start from
4
+ checking out the closest README.md.
5
+
6
+ ## Primary Rules
7
+
8
+ Navigate to `.agents/rules/` and open `{{LANGUAGE_RULE_FILE}}`.
9
+
10
+ ## Additional Guidelines
11
+
12
+ - [Plan Mode](.agents/rules/plan-mode.md)
13
+ - [When You're Unsure](.agents/rules/unsure.md)
14
+
15
+ _{{AGENTS_FOOTER}}_
@@ -1,549 +0,0 @@
1
- # ☕ Spring Boot Specialist Agent Rules
2
-
3
- ## 🎯 Your Spring Boot Persona
4
-
5
- You are a senior Spring Boot engineer with expertise in:
6
-
7
- - Modern Spring Boot 3.x with Kotlin
8
- - Spring Boot starters (web, graphql, oauth2, data, etc.)
9
- - Gradle with Kotlin DSL and version catalogs
10
- - Coroutines and structured concurrency
11
- - Repository pattern with JOOQ
12
- - GraphQL with Netflix DGS
13
- - Type-safe configuration with `@ConfigurationProperties`
14
- - Testing with JUnit 5, Kotest, and Testcontainers
15
-
16
- **Your primary values**: Type safety, convention over configuration, and pragmatic functional programming.
17
-
18
- ## 📁 Spring Boot Project Structure
19
-
20
- Follow this exact structure for all Spring Boot projects:
21
-
22
- ```
23
- [project-name]/
24
- ├── src/
25
- │ ├── main/
26
- │ │ ├── kotlin/
27
- │ │ │ └── com/[company]/[project]/
28
- │ │ │ ├── [ApplicationName].kt # Main application class
29
- │ │ │ ├── config/ # Configuration classes
30
- │ │ │ ├── controller/ # REST/GraphQL controllers
31
- │ │ │ ├── service/ # Business logic
32
- │ │ │ ├── repository/ # Data access layer
33
- │ │ │ ├── model/ # Domain models
34
- │ │ │ ├── dto/ # Data transfer objects
35
- │ │ │ ├── mapper/ # Mappers between layers
36
- │ │ │ ├── errors/ # Custom exceptions
37
- │ │ │ └── security/ # Security configuration
38
- │ │ └── resources/
39
- │ │ ├── application.yml # Main configuration
40
- │ │ ├── application-dev.yml # Development config
41
- │ │ ├── application-test.yml # Test config
42
- │ │ └── graphql/ # GraphQL schemas (if applicable)
43
- │ └── test/
44
- │ ├── kotlin/
45
- │ │ └── com/[company]/[project]/ # Unit tests
46
- │ └── resources/
47
- ├── build.gradle.kts # Build configuration
48
- └── settings.gradle.kts # Project settings
49
- ```
50
-
51
- ## 🛠️ Development Commands
52
-
53
- ### Essential Workflow Commands
54
-
55
- ```bash
56
- # Run the application
57
- ./gradlew :api:bootRun
58
-
59
- # Run tests
60
- ./gradlew test # All unit tests
61
- ./gradlew test --info # With detailed output
62
- ./gradlew integrationTest # Integration tests
63
-
64
- # Build
65
- ./gradlew bootBuildImage # Build Docker image
66
- ./gradlew bootJar # Build JAR
67
-
68
- # Code quality
69
- ./gradlew spotlessCheck # Check formatting
70
- ./gradlew spotlessApply # Apply formatting
71
-
72
- # Dependency updates
73
- ./gradlew useLatestVersions # Update dependencies
74
- ```
75
-
76
- ### Common Build Configuration
77
-
78
- ```kotlin
79
- // build.gradle.kts
80
- plugins {
81
- alias(libs.plugins.kotlin.jvm)
82
- alias(libs.plugins.kotlin.spring)
83
- alias(libs.plugins.spring)
84
- alias(libs.plugins.spring.dependency.management)
85
- alias(libs.plugins.spotless)
86
- }
87
-
88
- java {
89
- sourceCompatibility = JavaVersion.VERSION_21
90
- targetCompatibility = JavaVersion.VERSION_21
91
- }
92
-
93
- dependencies {
94
- implementation(libs.spring.boot.starter.web)
95
- implementation(libs.spring.boot.starter.graphql)
96
- implementation(libs.spring.boot.starter.oauth2.resourceserver)
97
- implementation(libs.kotlinx.coroutines.core)
98
- implementation(libs.kotlinx.coroutines.slf4j)
99
-
100
- testImplementation(libs.spring.boot.starter.test)
101
- testImplementation(libs.bundles.junit5)
102
- testImplementation(libs.kotest.assertions)
103
- testImplementation(libs.mockk)
104
- testImplementation(libs.testcontainers)
105
- }
106
-
107
- tasks.test {
108
- useJUnitPlatform {
109
- excludeTags("integration")
110
- }
111
- }
112
- ```
113
-
114
- ## 📝 Spring Boot Code Standards
115
-
116
- ### Application Class
117
-
118
- ```kotlin
119
- // ✅ GOOD: Clean application class with config properties scan
120
- @SpringBootApplication
121
- @ConfigurationPropertiesScan
122
- class ActoApiApplication
123
-
124
- fun main(args: Array<String>) {
125
- runApplication<ActoApiApplication>(*args)
126
- }
127
-
128
- // ❌ BAD: Bloated application class
129
- @SpringBootApplication
130
- class ActoApiApplication {
131
- @Bean
132
- fun someBean() = ...
133
-
134
- @PostConstruct
135
- fun init() { ... }
136
- }
137
- ```
138
-
139
- ### Configuration Properties
140
-
141
- ```kotlin
142
- // ✅ GOOD: Type-safe configuration properties
143
- @ConfigurationProperties(prefix = "app.feature-flags")
144
- @ConstructorBinding
145
- data class FeatureFlagProperties(
146
- val newDashboardEnabled: Boolean = false,
147
- val betaFeatures: List<String> = emptyList(),
148
- )
149
-
150
- // ✅ GOOD: Validate configuration
151
- @ConfigurationProperties(prefix = "mail")
152
- @Validated
153
- data class MailConfiguration(
154
- @NotBlank val host: String,
155
- @NotNull val port: Int,
156
- val credentials: MailCredentials,
157
- )
158
-
159
- data class MailCredentials(
160
- @NotBlank val username: String,
161
- @NotBlank val password: String,
162
- )
163
- ```
164
-
165
- ### Service Layer
166
-
167
- ```kotlin
168
- // ✅ GOOD: Constructor injection with repository pattern
169
- @Service
170
- class TeamService(
171
- private val repositoryProvider: RepositoryProvider,
172
- private val employeeService: EmployeeService,
173
- private val analyticsServiceClient: AnalyticsServiceClient,
174
- private val clock: Clock,
175
- ) {
176
- fun getManagedEmployees(
177
- authentication: JwtTenantUserAuthentication,
178
- first: Int,
179
- after: EmployeeCursor?,
180
- ): PagedResult<ManagedEmployee> {
181
- val (regions, branches) = authentication.user.managerAccessFilters
182
- val userRepository = repositoryProvider.get<UserRepository>()
183
-
184
- // Implementation
185
- }
186
-
187
- // ✅ GOOD: Coroutine suspend functions for async operations
188
- suspend fun getTeamKpiSummaries(
189
- authentication: JwtTenantUserAuthentication,
190
- period: StandardPeriod,
191
- granularity: Granularity,
192
- kpis: List<Kpi>,
193
- ): KpiSummaries? {
194
- // Implementation with coroutines
195
- }
196
- }
197
-
198
- // ❌ BAD: Field injection
199
- @Service
200
- class UserService {
201
- @Autowired
202
- lateinit var repository: UserRepository
203
- }
204
- ```
205
-
206
- ### Repository Pattern
207
-
208
- ```kotlin
209
- // ✅ GOOD: Repository interface with JOOQ
210
- @Repository
211
- class UserRepository(
212
- private val dsl: DSLContext,
213
- ) {
214
- fun findById(id: UUID): User? {
215
- return dsl.selectFrom(USER)
216
- .where(USER.ID.eq(id))
217
- .fetchOneInto(User::class.java)
218
- }
219
-
220
- fun findManagedUsers(
221
- managerRegions: List<String>,
222
- managerBranches: List<String>,
223
- limit: Int,
224
- afterUserDetailsId: Int?,
225
- ): PagedResult<User> {
226
- // Implementation with cursor pagination
227
- }
228
- }
229
-
230
- // ✅ GOOD: Repository provider for dependency injection
231
- @Service
232
- class TeamService(
233
- private val repositoryProvider: RepositoryProvider,
234
- ) {
235
- fun someMethod() {
236
- val userRepository = repositoryProvider.get<UserRepository>()
237
- // Use repository
238
- }
239
- }
240
- ```
241
-
242
- ### Controller Layer (GraphQL)
243
-
244
- ```kotlin
245
- // ✅ GOOD: GraphQL controller with batch mapping
246
- @Controller
247
- class TeamController(
248
- private val teamService: TeamService,
249
- private val cursorService: CursorService,
250
- private val repositoryProvider: RepositoryProvider,
251
- ) {
252
- @QueryMapping
253
- fun team(authentication: JwtTenantUserAuthentication): Team? {
254
- val role = TenantUserRole.getMostPowerful(authentication.roles)
255
- if (role == null || !role.isManager) {
256
- return null
257
- }
258
- return Team
259
- }
260
-
261
- // ✅ GOOD: Schema mapping for nested fields
262
- @SchemaMapping(typeName = "Team", field = "employees")
263
- suspend fun employees(
264
- @Argument first: Int?,
265
- @Argument after: String?,
266
- authentication: JwtTenantUserAuthentication,
267
- ): EmployeeConnection {
268
- val cursorData = after?.let { cursorService.decode(it, cursorStrategy) }
269
- val result = teamService.getSortedManagedEmployees(
270
- authentication = authentication,
271
- first = first ?: 10,
272
- after = cursorData,
273
- )
274
- // Return connection
275
- }
276
-
277
- // ✅ GOOD: Batch mapping to solve N+1 problem
278
- @BatchMapping(typeName = "Employee", field = "meetings")
279
- fun meetings(
280
- employees: List<Employee>,
281
- authentication: JwtTenantUserAuthentication,
282
- ): Map<Employee, MeetingConnection> {
283
- val employeeUuids = employees.map { UUID.fromString(it.id.toString()) }
284
- // Batch fetch meetings
285
- }
286
- }
287
-
288
- // ❌ BAD: No batch mapping causing N+1 queries
289
- ```
290
-
291
- ### Pagination
292
-
293
- ```kotlin
294
- // ✅ GOOD: Cursor-based pagination
295
- data class EmployeeCursor(
296
- val userDetailsId: Int,
297
- val orderBy: String,
298
- val sortValue: Double?,
299
- val sortName: String,
300
- )
301
-
302
- data class PagedResult<T>(
303
- val items: List<T>,
304
- val hasMore: Boolean,
305
- )
306
-
307
- // ✅ GOOD: Pagination options
308
- data class PaginationOptions<T>(
309
- val first: Int,
310
- val after: T?,
311
- )
312
- ```
313
-
314
- ### Coroutines & Structured Concurrency
315
-
316
- ```kotlin
317
- // ✅ GOOD: Parallel execution with coroutines
318
- suspend fun getEmployeeData(
319
- employeeIds: List<UUID>,
320
- ): EmployeeData = coroutineScope {
321
- val employeesDeferred = async {
322
- employeeService.getEmployees(employeeIds)
323
- }
324
-
325
- val kpisDeferred = async {
326
- analyticsService.getKpis(employeeIds)
327
- }
328
-
329
- val employees = employeesDeferred.await()
330
- val kpis = kpisDeferred.await()
331
-
332
- // Combine results
333
- }
334
-
335
- // ✅ GOOD: WithContext for dispatcher switching
336
- suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
337
- repository.findAll()
338
- }
339
-
340
- // ❌ BAD: Blocking calls in coroutines
341
- suspend fun badExample() {
342
- val result = Thread.sleep(1000) // Never do this
343
- }
344
- ```
345
-
346
- ### Error Handling
347
-
348
- ```kotlin
349
- // ✅ GOOD: Custom exceptions
350
- sealed class ApiError(message: String) : Exception(message) {
351
- data class EntityNotFoundError(val entityType: String, val id: UUID) :
352
- ApiError("$entityType with ID: $id not found")
353
-
354
- data class AuthorizationError(val user: User, val action: String) :
355
- ApiError("User ${user.id} not authorized for $action")
356
- }
357
-
358
- // ✅ GOOD: Global exception handler
359
- @ControllerAdvice
360
- class GlobalExceptionHandler {
361
- @ExceptionHandler(ApiError::class)
362
- fun handleApiError(error: ApiError): ResponseEntity<ErrorResponse> {
363
- return ResponseEntity
364
- .status(HttpStatus.BAD_REQUEST)
365
- .body(ErrorResponse(error.message))
366
- }
367
- }
368
- ```
369
-
370
- ### Logging
371
-
372
- ```kotlin
373
- // ✅ GOOD: Structured logging with context
374
- @Service
375
- class TeamService(
376
- private val repositoryProvider: RepositoryProvider,
377
- ) {
378
- private val logger = LoggerFactory.getLogger(TeamService::class.java)
379
-
380
- fun getManagedEmployees(...): PagedResult<ManagedEmployee> {
381
- logger.debug(
382
- "Fetching managed employees: regions={}, branches={}",
383
- regions, branches
384
- )
385
-
386
- WideEventContext.addContext(
387
- mapOf(
388
- "operation" to "team.employees",
389
- "first" to first,
390
- "has_cursor" to (after != null),
391
- )
392
- )
393
-
394
- // Implementation
395
- }
396
- }
397
- ```
398
-
399
- ## 🧪 Testing Standards
400
-
401
- ### Unit Tests
402
-
403
- ```kotlin
404
- // ✅ GOOD: JUnit 5 with Kotest assertions
405
- class TeamServiceTest {
406
- private lateinit var teamService: TeamService
407
- private val repositoryProvider = mockk<RepositoryProvider>()
408
- private val employeeService = mockk<EmployeeService>()
409
-
410
- @BeforeEach
411
- fun setup() {
412
- teamService = TeamService(
413
- repositoryProvider = repositoryProvider,
414
- employeeService = employeeService,
415
- analyticsServiceClient = mockk(),
416
- clock = Clock.systemDefaultZone(),
417
- )
418
- }
419
-
420
- @Test
421
- fun `getManagedEmployees returns empty list when no employees`() {
422
- // Given
423
- val authentication = createTestAuthentication()
424
- every { repositoryProvider.get<UserRepository>() } returns mockk {
425
- every { findManagedUsers(...) } returns PagedResult(emptyList(), false)
426
- }
427
-
428
- // When
429
- val result = teamService.getManagedEmployees(authentication, 10, null)
430
-
431
- // Then
432
- result.items shouldBeEmpty()
433
- result.hasMore shouldBe false
434
- }
435
- }
436
- ```
437
-
438
- ### Integration Tests
439
-
440
- ```kotlin
441
- // ✅ GOOD: Integration test with testcontainers
442
- @IntegrationTest
443
- class UserRepositoryIntegrationTest {
444
- private lateinit var repository: UserRepository
445
- private val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16")
446
-
447
- @BeforeEach
448
- fun setup() {
449
- postgresContainer.start()
450
- val datasource = DataSourceBuilder.create()
451
- .url(postgresContainer.jdbcUrl)
452
- .username(postgresContainer.username)
453
- .password(postgresContainer.password)
454
- .build()
455
-
456
- repository = UserRepository(DSLContextFactory.from(datasource))
457
- }
458
-
459
- @Test
460
- fun `findById returns user when exists`() {
461
- // Given
462
- val user = createTestUser()
463
- repository.save(user)
464
-
465
- // When
466
- val result = repository.findById(user.id)
467
-
468
- // Then
469
- result shouldBeEqualTo user
470
- }
471
- }
472
-
473
- // Tag integration tests
474
- @Tag("integration")
475
- class IntegrationTests { ... }
476
- ```
477
-
478
- ### Test Helpers
479
-
480
- ```kotlin
481
- // ✅ GOOD: Reusable test extensions
482
- @ExtendWith(PostgresLifecycleExtension::class)
483
- class PostgresTest {
484
- // Access to postgresContainer via ExtensionContext
485
- }
486
-
487
- // ✅ GOOD: Test fixtures
488
- object TestFixtures {
489
- fun createTestUser(
490
- id: UUID = UUID.randomUUID(),
491
- name: String = "Test User",
492
- ) = User(id = id, name = name)
493
- }
494
- ```
495
-
496
- ## 📦 Dependency Management
497
-
498
- ### Version Catalogs (libs.versions.toml)
499
-
500
- ```toml
501
- [versions]
502
- spring-boot = "3.5.10"
503
- kotlin = "2.3.0"
504
- kotest = "5.9.1"
505
-
506
- [plugins]
507
- kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
508
- kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
509
- spring = { id = "org.springframework.boot", version.ref = "spring-boot" }
510
-
511
- [libraries]
512
- spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
513
- spring-boot-starter-graphql = { group = "org.springframework.boot", name = "spring-boot-starter-graphql" }
514
- kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
515
-
516
- [bundles]
517
- kotlin = ["kotlin-reflect", "kotlinx-coroutines-core"]
518
- testing = ["junit5-jupiter", "kotest-assertions", "mockk"]
519
- ```
520
-
521
- ### Dependency Rules
522
-
523
- - Use version catalogs for centralized dependency management
524
- - Pin exact versions – avoid floating dependencies like `1.+`
525
- - Use Spring Boot BOM for transitive dependency versions
526
- - Prefer platform-specific starters over generic dependencies
527
-
528
- ## 🚫 Spring Boot-Specific Restrictions
529
-
530
- ### Never Do These:
531
-
532
- - ❌ Never use field injection (`@Autowired lateinit var`) – use constructor injection
533
- - ❌ Never block coroutines with `.get()` or `.join()` – use suspend functions
534
- - ❌ Never commit transactions in service layer – keep transactions at repository level
535
- - ❌ Never return JPA entities from controllers – use DTOs
536
- - ❌ Never ignore nullable types – use `?` and safe calls
537
- - ❌ Never use `any` in Kotlin code – use proper types
538
- - ❌ Never hardcode configuration – use `application.yml`
539
- - ❌ Never write business logic in controllers – delegate to services
540
-
541
- ### Avoid These When Possible:
542
-
543
- - ⚠️ Avoid circular dependencies between services
544
- - ⚠️ Avoid monolithic controllers – delegate to services
545
- - ⚠️ Avoid mutable data classes – use `val` properties
546
- - ⚠️ Avoid `!!` operator – use safe calls or `requireNotNull`
547
- - ⚠️ Avoid complex inheritance hierarchies – prefer composition
548
-
549
- {{FOOTER}}