@mainahq/core 0.2.0 → 0.4.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.
Files changed (38) hide show
  1. package/package.json +4 -1
  2. package/src/ai/__tests__/delegation.test.ts +105 -0
  3. package/src/ai/delegation.ts +111 -0
  4. package/src/ai/try-generate.ts +17 -0
  5. package/src/cloud/__tests__/auth.test.ts +164 -0
  6. package/src/cloud/__tests__/client.test.ts +253 -0
  7. package/src/cloud/auth.ts +232 -0
  8. package/src/cloud/client.ts +190 -0
  9. package/src/cloud/types.ts +106 -0
  10. package/src/context/relevance.ts +5 -0
  11. package/src/context/retrieval.ts +3 -0
  12. package/src/context/semantic.ts +3 -0
  13. package/src/feedback/__tests__/trace-analysis.test.ts +98 -0
  14. package/src/feedback/trace-analysis.ts +153 -0
  15. package/src/index.ts +55 -0
  16. package/src/init/__tests__/init.test.ts +51 -0
  17. package/src/init/index.ts +43 -0
  18. package/src/language/__tests__/detect.test.ts +61 -1
  19. package/src/language/__tests__/profile.test.ts +68 -1
  20. package/src/language/detect.ts +33 -3
  21. package/src/language/profile.ts +67 -2
  22. package/src/ticket/index.ts +5 -0
  23. package/src/verify/__tests__/consistency.test.ts +98 -0
  24. package/src/verify/__tests__/lighthouse.test.ts +215 -0
  25. package/src/verify/__tests__/linters/checkstyle.test.ts +23 -0
  26. package/src/verify/__tests__/linters/dotnet-format.test.ts +18 -0
  27. package/src/verify/__tests__/pipeline.test.ts +21 -2
  28. package/src/verify/__tests__/typecheck.test.ts +160 -0
  29. package/src/verify/__tests__/zap.test.ts +188 -0
  30. package/src/verify/consistency.ts +199 -0
  31. package/src/verify/detect.ts +13 -1
  32. package/src/verify/lighthouse.ts +173 -0
  33. package/src/verify/linters/checkstyle.ts +41 -0
  34. package/src/verify/linters/dotnet-format.ts +37 -0
  35. package/src/verify/pipeline.ts +20 -2
  36. package/src/verify/syntax-guard.ts +8 -0
  37. package/src/verify/typecheck.ts +178 -0
  38. package/src/verify/zap.ts +189 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Post-workflow RL Trace Analysis — analyzes completed workflow traces
3
+ * to propose prompt improvements.
4
+ *
5
+ * After a full workflow completes (brainstorm → ... → pr):
6
+ * 1. Collects the full trace from workflow context
7
+ * 2. Analyzes: which steps had issues? How did findings trend?
8
+ * 3. Proposes prompt improvements based on patterns
9
+ * 4. Feeds into maina learn automatically
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ // ─── Types ────────────────────────────────────────────────────────────────
16
+
17
+ export interface TraceStep {
18
+ command: string;
19
+ timestamp: string;
20
+ summary: string;
21
+ findingsCount?: number;
22
+ }
23
+
24
+ export interface PromptImprovement {
25
+ promptFile: string;
26
+ reason: string;
27
+ suggestion: string;
28
+ confidence: number;
29
+ }
30
+
31
+ export interface TraceResult {
32
+ steps: TraceStep[];
33
+ improvements: PromptImprovement[];
34
+ summary: string;
35
+ }
36
+
37
+ // ─── Trace Parsing ───────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Parse the workflow context file into structured trace steps.
41
+ */
42
+ function parseWorkflowContext(content: string): TraceStep[] {
43
+ const steps: TraceStep[] = [];
44
+ const stepPattern =
45
+ /^## (\w+) \((\d{4}-\d{2}-\d{2}T[\d:.]+Z)\)\s*\n([\s\S]*?)(?=\n## |\n*$)/gm;
46
+
47
+ for (const match of content.matchAll(stepPattern)) {
48
+ const command = match[1] ?? "";
49
+ const timestamp = match[2] ?? "";
50
+ const summary = (match[3] ?? "").trim();
51
+
52
+ // Extract findings count if present
53
+ const findingsMatch = summary.match(/(\d+)\s+findings?/);
54
+ const findingsCount = findingsMatch
55
+ ? Number.parseInt(findingsMatch[1] ?? "0", 10)
56
+ : undefined;
57
+
58
+ steps.push({ command, timestamp, summary, findingsCount });
59
+ }
60
+
61
+ return steps;
62
+ }
63
+
64
+ // ─── Analysis ────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Analyze trace steps for patterns that suggest prompt improvements.
68
+ */
69
+ function analyzePatterns(steps: TraceStep[]): PromptImprovement[] {
70
+ const improvements: PromptImprovement[] = [];
71
+
72
+ // Pattern 1: Multiple commits with findings before a clean one
73
+ const commitSteps = steps.filter(
74
+ (s) => s.command === "commit" && s.findingsCount !== undefined,
75
+ );
76
+ const dirtyCommits = commitSteps.filter(
77
+ (s) => s.findingsCount !== undefined && s.findingsCount > 0,
78
+ );
79
+
80
+ if (dirtyCommits.length >= 2) {
81
+ improvements.push({
82
+ promptFile: "prompts/review.md",
83
+ reason: `${dirtyCommits.length} commits had verification findings before clean pass — review prompt may need to catch these patterns earlier.`,
84
+ suggestion:
85
+ "Add examples of common finding patterns to the review prompt so AI catches them in the first pass.",
86
+ confidence: 0.6,
87
+ });
88
+ }
89
+
90
+ // Pattern 2: Workflow has no review step
91
+ const hasReview = steps.some((s) => s.command === "review");
92
+ if (steps.length >= 3 && !hasReview) {
93
+ improvements.push({
94
+ promptFile: "prompts/commit.md",
95
+ reason:
96
+ "Workflow completed without a review step — commit prompt could remind about review.",
97
+ suggestion:
98
+ "Add a reminder to run maina review before committing when changes are substantial.",
99
+ confidence: 0.4,
100
+ });
101
+ }
102
+
103
+ return improvements;
104
+ }
105
+
106
+ /**
107
+ * Generate a human-readable summary of the trace analysis.
108
+ */
109
+ function generateSummary(
110
+ steps: TraceStep[],
111
+ improvements: PromptImprovement[],
112
+ ): string {
113
+ if (steps.length === 0) return "No workflow trace found.";
114
+
115
+ const commands = steps.map((s) => s.command).join(" → ");
116
+ const totalFindings = steps
117
+ .filter((s) => s.findingsCount !== undefined)
118
+ .reduce((sum, s) => sum + (s.findingsCount ?? 0), 0);
119
+
120
+ let summary = `Workflow: ${commands} (${steps.length} steps, ${totalFindings} total findings)`;
121
+
122
+ if (improvements.length > 0) {
123
+ summary += `\n${improvements.length} improvement(s) suggested.`;
124
+ }
125
+
126
+ return summary;
127
+ }
128
+
129
+ // ─── Main ────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Analyze the current workflow trace and generate improvement proposals.
133
+ *
134
+ * This runs after maina pr completes. It reads the workflow context,
135
+ * correlates with feedback data, and proposes prompt improvements
136
+ * that are automatically fed into maina learn.
137
+ */
138
+ export async function analyzeWorkflowTrace(
139
+ mainaDir: string,
140
+ ): Promise<TraceResult> {
141
+ const workflowFile = join(mainaDir, "workflow", "current.md");
142
+
143
+ if (!existsSync(workflowFile)) {
144
+ return { steps: [], improvements: [], summary: "No workflow trace found." };
145
+ }
146
+
147
+ const content = readFileSync(workflowFile, "utf-8");
148
+ const steps = parseWorkflowContext(content);
149
+ const improvements = analyzePatterns(steps);
150
+ const summary = generateSummary(steps, improvements);
151
+
152
+ return { steps, improvements, summary };
153
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,13 @@ export const VERSION = "0.1.0";
2
2
 
3
3
  // AI
4
4
  export { generateCommitMessage } from "./ai/commit-msg";
5
+ // AI — Delegation
6
+ export {
7
+ type DelegationRequest,
8
+ formatDelegationRequest,
9
+ outputDelegationRequest,
10
+ parseDelegationRequest,
11
+ } from "./ai/delegation";
5
12
  export {
6
13
  type DesignApproach,
7
14
  generateDesignApproaches,
@@ -43,6 +50,26 @@ export {
43
50
  type CacheStats,
44
51
  createCacheManager,
45
52
  } from "./cache/manager";
53
+ // Cloud
54
+ export {
55
+ type AuthConfig,
56
+ clearAuthConfig,
57
+ loadAuthConfig,
58
+ pollForToken,
59
+ saveAuthConfig,
60
+ startDeviceFlow,
61
+ } from "./cloud/auth";
62
+ export { type CloudClient, createCloudClient } from "./cloud/client";
63
+ export type {
64
+ ApiResponse,
65
+ CloudConfig,
66
+ CloudFeedbackPayload,
67
+ DeviceCodeResponse,
68
+ PromptRecord,
69
+ TeamInfo,
70
+ TeamMember,
71
+ TokenResponse,
72
+ } from "./cloud/types";
46
73
  // Config
47
74
  export { getApiKey, isHostMode, shouldDelegateToHost } from "./config/index";
48
75
  export { calculateTokens } from "./context/budget";
@@ -137,6 +164,12 @@ export {
137
164
  type RulePreference,
138
165
  savePreferences,
139
166
  } from "./feedback/preferences";
167
+ export {
168
+ analyzeWorkflowTrace,
169
+ type PromptImprovement,
170
+ type TraceResult,
171
+ type TraceStep,
172
+ } from "./feedback/trace-analysis";
140
173
  // Git
141
174
  export {
142
175
  type Commit,
@@ -171,9 +204,11 @@ export {
171
204
  getPrimaryLanguage,
172
205
  } from "./language/detect";
173
206
  export {
207
+ CSHARP_PROFILE,
174
208
  GO_PROFILE,
175
209
  getProfile,
176
210
  getSupportedLanguages,
211
+ JAVA_PROFILE,
177
212
  type LanguageId,
178
213
  type LanguageProfile,
179
214
  PYTHON_PROFILE,
@@ -259,6 +294,10 @@ export {
259
294
  resolveReferencedFunctions,
260
295
  runAIReview,
261
296
  } from "./verify/ai-review";
297
+ export {
298
+ type ConsistencyResult,
299
+ checkConsistency,
300
+ } from "./verify/consistency";
262
301
  // Verify — Coverage
263
302
  export {
264
303
  type CoverageOptions,
@@ -289,6 +328,13 @@ export {
289
328
  hashFinding,
290
329
  parseFixResponse,
291
330
  } from "./verify/fix";
331
+ // Verify — Lighthouse
332
+ export {
333
+ type LighthouseOptions,
334
+ type LighthouseResult,
335
+ parseLighthouseJson,
336
+ runLighthouse,
337
+ } from "./verify/lighthouse";
292
338
  // Verify — Mutation
293
339
  export {
294
340
  type MutationOptions,
@@ -334,6 +380,8 @@ export {
334
380
  type SyntaxGuardResult,
335
381
  syntaxGuard,
336
382
  } from "./verify/syntax-guard";
383
+ // Verify — Typecheck + Consistency (built-in checks)
384
+ export { runTypecheck, type TypecheckResult } from "./verify/typecheck";
337
385
  // Verify — Visual
338
386
  export {
339
387
  captureScreenshot,
@@ -348,6 +396,13 @@ export {
348
396
  type VisualDiffResult,
349
397
  type VisualVerifyResult,
350
398
  } from "./verify/visual";
399
+ // Verify — ZAP DAST
400
+ export {
401
+ parseZapJson,
402
+ runZap,
403
+ type ZapOptions,
404
+ type ZapResult,
405
+ } from "./verify/zap";
351
406
  // Workflow
352
407
  export {
353
408
  appendWorkflowStep,
@@ -225,4 +225,55 @@ describe("bootstrap", () => {
225
225
  }
226
226
  }
227
227
  });
228
+
229
+ test("auto-configures biome.json when no linter detected", async () => {
230
+ // Project with no linter in dependencies
231
+ writeFileSync(
232
+ join(tmpDir, "package.json"),
233
+ JSON.stringify({ dependencies: {} }),
234
+ );
235
+
236
+ const result = await bootstrap(tmpDir);
237
+ expect(result.ok).toBe(true);
238
+ if (result.ok) {
239
+ const biomePath = join(tmpDir, "biome.json");
240
+ expect(existsSync(biomePath)).toBe(true);
241
+
242
+ const biomeConfig = JSON.parse(readFileSync(biomePath, "utf-8"));
243
+ expect(biomeConfig.linter.enabled).toBe(true);
244
+ expect(biomeConfig.linter.rules.recommended).toBe(true);
245
+ expect(biomeConfig.formatter.enabled).toBe(true);
246
+
247
+ expect(result.value.created).toContain("biome.json");
248
+ expect(result.value.detectedStack.linter).toBe("biome");
249
+ }
250
+ });
251
+
252
+ test("does not overwrite existing biome.json", async () => {
253
+ writeFileSync(
254
+ join(tmpDir, "package.json"),
255
+ JSON.stringify({ dependencies: {} }),
256
+ );
257
+ writeFileSync(join(tmpDir, "biome.json"), '{"custom": true}');
258
+
259
+ const result = await bootstrap(tmpDir);
260
+ expect(result.ok).toBe(true);
261
+
262
+ const content = readFileSync(join(tmpDir, "biome.json"), "utf-8");
263
+ expect(JSON.parse(content)).toEqual({ custom: true });
264
+ });
265
+
266
+ test("skips biome.json when linter already detected", async () => {
267
+ writeFileSync(
268
+ join(tmpDir, "package.json"),
269
+ JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
270
+ );
271
+
272
+ const result = await bootstrap(tmpDir);
273
+ expect(result.ok).toBe(true);
274
+ if (result.ok) {
275
+ expect(existsSync(join(tmpDir, "biome.json"))).toBe(false);
276
+ expect(result.value.detectedStack.linter).toBe("eslint");
277
+ }
278
+ });
228
279
  });
package/src/init/index.ts CHANGED
@@ -291,6 +291,38 @@ function getFileManifest(stack: DetectedStack): FileEntry[] {
291
291
  ];
292
292
  }
293
293
 
294
+ /**
295
+ * Build a sensible default biome.json for projects without a linter.
296
+ * Ensures every maina-initialized project has at least one real linter.
297
+ */
298
+ function buildBiomeConfig(): string {
299
+ return JSON.stringify(
300
+ {
301
+ $schema: "https://biomejs.dev/schemas/2.0.0/schema.json",
302
+ linter: {
303
+ enabled: true,
304
+ rules: {
305
+ recommended: true,
306
+ correctness: {
307
+ noUnusedVariables: "warn",
308
+ noUnusedImports: "warn",
309
+ },
310
+ style: {
311
+ useConst: "error",
312
+ },
313
+ },
314
+ },
315
+ formatter: {
316
+ enabled: true,
317
+ indentStyle: "tab",
318
+ lineWidth: 100,
319
+ },
320
+ },
321
+ null,
322
+ 2,
323
+ );
324
+ }
325
+
294
326
  /** Directories to create even if they have no files */
295
327
  const EXTRA_DIRS = [".maina/hooks"];
296
328
 
@@ -345,6 +377,17 @@ export async function bootstrap(
345
377
  }
346
378
  }
347
379
 
380
+ // ── Auto-configure Biome if no linter detected ──────────────────
381
+ if (detectedStack.linter === "unknown") {
382
+ const biomePath = join(repoRoot, "biome.json");
383
+ if (!existsSync(biomePath) || force) {
384
+ const biomeConfig = buildBiomeConfig();
385
+ writeFileSync(biomePath, biomeConfig, "utf-8");
386
+ created.push("biome.json");
387
+ detectedStack.linter = "biome";
388
+ }
389
+ }
390
+
348
391
  return {
349
392
  ok: true,
350
393
  value: {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { detectLanguages } from "../detect";
4
+ import { detectFileLanguage, detectLanguages } from "../detect";
5
5
 
6
6
  describe("detectLanguages", () => {
7
7
  const testDir = join(import.meta.dir, "__fixtures__/detect");
@@ -74,4 +74,64 @@ describe("detectLanguages", () => {
74
74
  expect(result).toContain("python");
75
75
  cleanup();
76
76
  });
77
+
78
+ it("should detect PHP from composer.json", () => {
79
+ setup({ "composer.json": '{"require": {"php": ">=8.1"}}' });
80
+ const result = detectLanguages(testDir);
81
+ expect(result).toContain("php");
82
+ cleanup();
83
+ });
84
+
85
+ it("should detect PHP from composer.lock", () => {
86
+ setup({ "composer.lock": '{"packages": []}' });
87
+ const result = detectLanguages(testDir);
88
+ expect(result).toContain("php");
89
+ cleanup();
90
+ });
91
+ });
92
+
93
+ describe("detectFileLanguage", () => {
94
+ it("should detect TypeScript from .ts extension", () => {
95
+ expect(detectFileLanguage("src/index.ts")).toBe("typescript");
96
+ });
97
+
98
+ it("should detect TypeScript from .tsx extension", () => {
99
+ expect(detectFileLanguage("components/App.tsx")).toBe("typescript");
100
+ });
101
+
102
+ it("should detect Python from .py extension", () => {
103
+ expect(detectFileLanguage("app/main.py")).toBe("python");
104
+ });
105
+
106
+ it("should detect Go from .go extension", () => {
107
+ expect(detectFileLanguage("cmd/server.go")).toBe("go");
108
+ });
109
+
110
+ it("should detect Rust from .rs extension", () => {
111
+ expect(detectFileLanguage("src/lib.rs")).toBe("rust");
112
+ });
113
+
114
+ it("should detect C# from .cs extension", () => {
115
+ expect(detectFileLanguage("Controllers/HomeController.cs")).toBe("csharp");
116
+ });
117
+
118
+ it("should detect Java from .java extension", () => {
119
+ expect(detectFileLanguage("src/Main.java")).toBe("java");
120
+ });
121
+
122
+ it("should detect PHP from .php extension", () => {
123
+ expect(detectFileLanguage("src/Controller.php")).toBe("php");
124
+ });
125
+
126
+ it("should return null for unknown extensions", () => {
127
+ expect(detectFileLanguage("data.csv")).toBeNull();
128
+ expect(detectFileLanguage("README.md")).toBeNull();
129
+ expect(detectFileLanguage("Dockerfile")).toBeNull();
130
+ });
131
+
132
+ it("should handle case-insensitive extensions", () => {
133
+ expect(detectFileLanguage("script.PY")).toBe("python");
134
+ expect(detectFileLanguage("main.Go")).toBe("go");
135
+ expect(detectFileLanguage("index.PHP")).toBe("php");
136
+ });
77
137
  });
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { getProfile, TYPESCRIPT_PROFILE } from "../profile";
2
+ import {
3
+ getProfile,
4
+ getSupportedLanguages,
5
+ TYPESCRIPT_PROFILE,
6
+ } from "../profile";
3
7
 
4
8
  describe("LanguageProfile", () => {
5
9
  it("should return TypeScript profile by default", () => {
@@ -35,6 +39,21 @@ describe("LanguageProfile", () => {
35
39
  expect(profile.commentPrefixes).toContain("//");
36
40
  });
37
41
 
42
+ it("should return C# profile", () => {
43
+ const profile = getProfile("csharp");
44
+ expect(profile.id).toBe("csharp");
45
+ expect(profile.extensions).toContain(".cs");
46
+ expect(profile.syntaxTool).toBe("dotnet-format");
47
+ });
48
+
49
+ it("should return Java profile", () => {
50
+ const profile = getProfile("java");
51
+ expect(profile.id).toBe("java");
52
+ expect(profile.extensions).toContain(".java");
53
+ expect(profile.extensions).toContain(".kt");
54
+ expect(profile.syntaxTool).toBe("checkstyle");
55
+ });
56
+
38
57
  it("should have test file pattern for each language", () => {
39
58
  expect(TYPESCRIPT_PROFILE.testFilePattern.test("app.test.ts")).toBe(true);
40
59
  expect(getProfile("python").testFilePattern.test("test_app.py")).toBe(true);
@@ -48,4 +67,52 @@ describe("LanguageProfile", () => {
48
67
  expect(getProfile("go").printPattern.test("fmt.Println(x)")).toBe(true);
49
68
  expect(getProfile("rust").printPattern.test("println!(x)")).toBe(true);
50
69
  });
70
+
71
+ it("should return PHP profile", () => {
72
+ const profile = getProfile("php");
73
+ expect(profile.id).toBe("php");
74
+ expect(profile.displayName).toBe("PHP");
75
+ expect(profile.extensions).toContain(".php");
76
+ expect(profile.syntaxTool).toBe("phpstan");
77
+ expect(profile.commentPrefixes).toContain("//");
78
+ expect(profile.commentPrefixes).toContain("#");
79
+ expect(profile.commentPrefixes).toContain("/*");
80
+ });
81
+
82
+ it("should have PHP test file pattern", () => {
83
+ const profile = getProfile("php");
84
+ expect(profile.testFilePattern.test("UserTest.php")).toBe(true);
85
+ expect(profile.testFilePattern.test("tests/UserTest.php")).toBe(true);
86
+ expect(profile.testFilePattern.test("src/User.php")).toBe(false);
87
+ });
88
+
89
+ it("should have PHP print/debug patterns", () => {
90
+ const profile = getProfile("php");
91
+ expect(profile.printPattern.test("echo('hello')")).toBe(true);
92
+ expect(profile.printPattern.test("print('hello')")).toBe(true);
93
+ expect(profile.printPattern.test("var_dump($x)")).toBe(true);
94
+ expect(profile.printPattern.test("print_r($arr)")).toBe(true);
95
+ expect(profile.printPattern.test("error_log('msg')")).toBe(true);
96
+ });
97
+
98
+ it("should have PHP lint ignore patterns", () => {
99
+ const profile = getProfile("php");
100
+ expect(profile.lintIgnorePattern.test("@phpstan-ignore")).toBe(true);
101
+ expect(profile.lintIgnorePattern.test("@psalm-suppress")).toBe(true);
102
+ expect(profile.lintIgnorePattern.test("phpcs:ignore")).toBe(true);
103
+ });
104
+
105
+ it("should have PHP import pattern", () => {
106
+ const profile = getProfile("php");
107
+ expect(profile.importPattern.test("use App\\Models\\User;")).toBe(true);
108
+ expect(profile.importPattern.test("require 'vendor/autoload.php';")).toBe(
109
+ true,
110
+ );
111
+ expect(profile.importPattern.test("include 'config.php';")).toBe(true);
112
+ });
113
+
114
+ it("should include php in supported languages", () => {
115
+ const languages = getSupportedLanguages();
116
+ expect(languages).toContain("php");
117
+ });
51
118
  });
@@ -2,9 +2,9 @@
2
2
  * Language Detection — detects project languages from marker files.
3
3
  */
4
4
 
5
- import { existsSync, readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import type { LanguageId } from "./profile";
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
+ import { extname, join } from "node:path";
7
+ import { type LanguageId, PROFILES } from "./profile";
8
8
 
9
9
  const LANGUAGE_MARKERS: Record<LanguageId, string[]> = {
10
10
  typescript: ["tsconfig.json", "tsconfig.build.json"],
@@ -17,6 +17,9 @@ const LANGUAGE_MARKERS: Record<LanguageId, string[]> = {
17
17
  ],
18
18
  go: ["go.mod", "go.sum"],
19
19
  rust: ["Cargo.toml", "Cargo.lock"],
20
+ csharp: ["global.json", "Directory.Build.props"],
21
+ java: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"],
22
+ php: ["composer.json", "composer.lock"],
20
23
  };
21
24
 
22
25
  /**
@@ -39,6 +42,18 @@ export function detectLanguages(cwd: string): LanguageId[] {
39
42
  }
40
43
  }
41
44
 
45
+ // C# — check for .sln or .csproj files (names vary)
46
+ if (!detected.includes("csharp")) {
47
+ try {
48
+ const entries = readdirSync(cwd);
49
+ if (
50
+ entries.some((e: string) => e.endsWith(".sln") || e.endsWith(".csproj"))
51
+ ) {
52
+ detected.push("csharp");
53
+ }
54
+ } catch {}
55
+ }
56
+
42
57
  // Also check package.json for TypeScript dependency
43
58
  if (!detected.includes("typescript")) {
44
59
  const pkgPath = join(cwd, "package.json");
@@ -68,3 +83,18 @@ export function getPrimaryLanguage(cwd: string): LanguageId {
68
83
  const languages = detectLanguages(cwd);
69
84
  return languages[0] ?? "typescript";
70
85
  }
86
+
87
+ /**
88
+ * Detect language for a single file based on its extension.
89
+ * Returns the LanguageId if matched, or null if unknown.
90
+ */
91
+ export function detectFileLanguage(filePath: string): LanguageId | null {
92
+ const ext = extname(filePath).toLowerCase();
93
+ if (!ext) return null;
94
+ for (const profile of Object.values(PROFILES)) {
95
+ if (profile.extensions.includes(ext)) {
96
+ return profile.id;
97
+ }
98
+ }
99
+ return null;
100
+ }
@@ -3,7 +3,14 @@
3
3
  * file patterns, and slop detection rules.
4
4
  */
5
5
 
6
- export type LanguageId = "typescript" | "python" | "go" | "rust";
6
+ export type LanguageId =
7
+ | "typescript"
8
+ | "python"
9
+ | "go"
10
+ | "rust"
11
+ | "csharp"
12
+ | "java"
13
+ | "php";
7
14
 
8
15
  export interface LanguageProfile {
9
16
  id: LanguageId;
@@ -94,11 +101,69 @@ export const RUST_PROFILE: LanguageProfile = {
94
101
  fileGlobs: ["*.rs"],
95
102
  };
96
103
 
97
- const PROFILES: Record<LanguageId, LanguageProfile> = {
104
+ export const CSHARP_PROFILE: LanguageProfile = {
105
+ id: "csharp",
106
+ displayName: "C#",
107
+ extensions: [".cs"],
108
+ syntaxTool: "dotnet-format",
109
+ syntaxArgs: (files, _cwd) => [
110
+ "dotnet",
111
+ "format",
112
+ "--verify-no-changes",
113
+ "--include",
114
+ ...files,
115
+ ],
116
+ commentPrefixes: ["//", "/*"],
117
+ testFilePattern: /(?:Tests?\.cs$|\.Tests?\.|tests\/)/,
118
+ printPattern: /Console\.Write(?:Line)?\s*\(/,
119
+ lintIgnorePattern:
120
+ /#pragma\s+warning\s+disable|\/\/\s*noinspection|\[SuppressMessage/,
121
+ importPattern: /^using\s+(\S+)/,
122
+ fileGlobs: ["*.cs"],
123
+ };
124
+
125
+ export const JAVA_PROFILE: LanguageProfile = {
126
+ id: "java",
127
+ displayName: "Java",
128
+ extensions: [".java", ".kt"],
129
+ syntaxTool: "checkstyle",
130
+ syntaxArgs: (files, _cwd) => ["checkstyle", "-f", "xml", ...files],
131
+ commentPrefixes: ["//", "/*"],
132
+ testFilePattern: /(?:Test\.java$|Spec\.java$|src\/test\/)/,
133
+ printPattern: /System\.out\.print(?:ln)?\s*\(/,
134
+ lintIgnorePattern: /@SuppressWarnings|\/\/\s*NOPMD|\/\/\s*NOSONAR/,
135
+ importPattern: /^import\s+(\S+)/,
136
+ fileGlobs: ["*.java", "*.kt"],
137
+ };
138
+
139
+ export const PHP_PROFILE: LanguageProfile = {
140
+ id: "php",
141
+ displayName: "PHP",
142
+ extensions: [".php"],
143
+ syntaxTool: "phpstan",
144
+ syntaxArgs: (files, _cwd) => [
145
+ "phpstan",
146
+ "analyse",
147
+ "--error-format=json",
148
+ "--no-progress",
149
+ ...files,
150
+ ],
151
+ commentPrefixes: ["//", "/*", "#"],
152
+ testFilePattern: /(?:Test\.php$|tests\/)/,
153
+ printPattern: /\b(?:echo|print|var_dump|print_r|error_log)\s*\(/,
154
+ lintIgnorePattern: /@phpstan-ignore|@psalm-suppress|phpcs:ignore/,
155
+ importPattern: /^(?:use|require|include)\s+/,
156
+ fileGlobs: ["*.php"],
157
+ };
158
+
159
+ export const PROFILES: Record<LanguageId, LanguageProfile> = {
98
160
  typescript: TYPESCRIPT_PROFILE,
99
161
  python: PYTHON_PROFILE,
100
162
  go: GO_PROFILE,
101
163
  rust: RUST_PROFILE,
164
+ csharp: CSHARP_PROFILE,
165
+ java: JAVA_PROFILE,
166
+ php: PHP_PROFILE,
102
167
  };
103
168
 
104
169
  export function getProfile(id: LanguageId): LanguageProfile {