@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.
- package/package.json +4 -1
- package/src/ai/__tests__/delegation.test.ts +105 -0
- package/src/ai/delegation.ts +111 -0
- package/src/ai/try-generate.ts +17 -0
- package/src/cloud/__tests__/auth.test.ts +164 -0
- package/src/cloud/__tests__/client.test.ts +253 -0
- package/src/cloud/auth.ts +232 -0
- package/src/cloud/client.ts +190 -0
- package/src/cloud/types.ts +106 -0
- package/src/context/relevance.ts +5 -0
- package/src/context/retrieval.ts +3 -0
- package/src/context/semantic.ts +3 -0
- package/src/feedback/__tests__/trace-analysis.test.ts +98 -0
- package/src/feedback/trace-analysis.ts +153 -0
- package/src/index.ts +55 -0
- package/src/init/__tests__/init.test.ts +51 -0
- package/src/init/index.ts +43 -0
- package/src/language/__tests__/detect.test.ts +61 -1
- package/src/language/__tests__/profile.test.ts +68 -1
- package/src/language/detect.ts +33 -3
- package/src/language/profile.ts +67 -2
- package/src/ticket/index.ts +5 -0
- package/src/verify/__tests__/consistency.test.ts +98 -0
- package/src/verify/__tests__/lighthouse.test.ts +215 -0
- package/src/verify/__tests__/linters/checkstyle.test.ts +23 -0
- package/src/verify/__tests__/linters/dotnet-format.test.ts +18 -0
- package/src/verify/__tests__/pipeline.test.ts +21 -2
- package/src/verify/__tests__/typecheck.test.ts +160 -0
- package/src/verify/__tests__/zap.test.ts +188 -0
- package/src/verify/consistency.ts +199 -0
- package/src/verify/detect.ts +13 -1
- package/src/verify/lighthouse.ts +173 -0
- package/src/verify/linters/checkstyle.ts +41 -0
- package/src/verify/linters/dotnet-format.ts +37 -0
- package/src/verify/pipeline.ts +20 -2
- package/src/verify/syntax-guard.ts +8 -0
- package/src/verify/typecheck.ts +178 -0
- 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 {
|
|
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
|
});
|
package/src/language/detect.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/language/profile.ts
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
* file patterns, and slop detection rules.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export type LanguageId =
|
|
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
|
|
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 {
|