@mainahq/core 0.2.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 (156) hide show
  1. package/README.md +31 -0
  2. package/package.json +37 -0
  3. package/src/ai/__tests__/ai.test.ts +207 -0
  4. package/src/ai/__tests__/design-approaches.test.ts +192 -0
  5. package/src/ai/__tests__/spec-questions.test.ts +191 -0
  6. package/src/ai/__tests__/tiers.test.ts +110 -0
  7. package/src/ai/commit-msg.ts +28 -0
  8. package/src/ai/design-approaches.ts +76 -0
  9. package/src/ai/index.ts +205 -0
  10. package/src/ai/pr-summary.ts +60 -0
  11. package/src/ai/spec-questions.ts +74 -0
  12. package/src/ai/tiers.ts +52 -0
  13. package/src/ai/try-generate.ts +89 -0
  14. package/src/ai/validate.ts +66 -0
  15. package/src/benchmark/__tests__/reporter.test.ts +525 -0
  16. package/src/benchmark/__tests__/runner.test.ts +113 -0
  17. package/src/benchmark/__tests__/story-loader.test.ts +152 -0
  18. package/src/benchmark/reporter.ts +332 -0
  19. package/src/benchmark/runner.ts +91 -0
  20. package/src/benchmark/story-loader.ts +88 -0
  21. package/src/benchmark/types.ts +95 -0
  22. package/src/cache/__tests__/keys.test.ts +97 -0
  23. package/src/cache/__tests__/manager.test.ts +312 -0
  24. package/src/cache/__tests__/ttl.test.ts +94 -0
  25. package/src/cache/keys.ts +44 -0
  26. package/src/cache/manager.ts +231 -0
  27. package/src/cache/ttl.ts +77 -0
  28. package/src/config/__tests__/config.test.ts +376 -0
  29. package/src/config/index.ts +198 -0
  30. package/src/context/__tests__/budget.test.ts +179 -0
  31. package/src/context/__tests__/engine.test.ts +163 -0
  32. package/src/context/__tests__/episodic.test.ts +291 -0
  33. package/src/context/__tests__/relevance.test.ts +323 -0
  34. package/src/context/__tests__/retrieval.test.ts +143 -0
  35. package/src/context/__tests__/selector.test.ts +174 -0
  36. package/src/context/__tests__/semantic.test.ts +252 -0
  37. package/src/context/__tests__/treesitter.test.ts +229 -0
  38. package/src/context/__tests__/working.test.ts +236 -0
  39. package/src/context/budget.ts +130 -0
  40. package/src/context/engine.ts +394 -0
  41. package/src/context/episodic.ts +251 -0
  42. package/src/context/relevance.ts +325 -0
  43. package/src/context/retrieval.ts +325 -0
  44. package/src/context/selector.ts +93 -0
  45. package/src/context/semantic.ts +331 -0
  46. package/src/context/treesitter.ts +216 -0
  47. package/src/context/working.ts +192 -0
  48. package/src/db/__tests__/db.test.ts +151 -0
  49. package/src/db/index.ts +211 -0
  50. package/src/db/schema.ts +84 -0
  51. package/src/design/__tests__/design.test.ts +310 -0
  52. package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
  53. package/src/design/__tests__/review.test.ts +561 -0
  54. package/src/design/index.ts +297 -0
  55. package/src/design/review.ts +327 -0
  56. package/src/explain/__tests__/explain.test.ts +173 -0
  57. package/src/explain/index.ts +181 -0
  58. package/src/features/__tests__/analyzer.test.ts +358 -0
  59. package/src/features/__tests__/checklist.test.ts +454 -0
  60. package/src/features/__tests__/numbering.test.ts +319 -0
  61. package/src/features/__tests__/quality.test.ts +295 -0
  62. package/src/features/__tests__/traceability.test.ts +147 -0
  63. package/src/features/analyzer.ts +445 -0
  64. package/src/features/checklist.ts +366 -0
  65. package/src/features/index.ts +18 -0
  66. package/src/features/numbering.ts +404 -0
  67. package/src/features/quality.ts +349 -0
  68. package/src/features/test-stubs.ts +157 -0
  69. package/src/features/traceability.ts +260 -0
  70. package/src/feedback/__tests__/async-feedback.test.ts +52 -0
  71. package/src/feedback/__tests__/collector.test.ts +219 -0
  72. package/src/feedback/__tests__/compress.test.ts +150 -0
  73. package/src/feedback/__tests__/preferences.test.ts +169 -0
  74. package/src/feedback/collector.ts +135 -0
  75. package/src/feedback/compress.ts +92 -0
  76. package/src/feedback/preferences.ts +108 -0
  77. package/src/git/__tests__/git.test.ts +62 -0
  78. package/src/git/index.ts +110 -0
  79. package/src/hooks/__tests__/runner.test.ts +266 -0
  80. package/src/hooks/index.ts +8 -0
  81. package/src/hooks/runner.ts +130 -0
  82. package/src/index.ts +356 -0
  83. package/src/init/__tests__/init.test.ts +228 -0
  84. package/src/init/index.ts +364 -0
  85. package/src/language/__tests__/detect.test.ts +77 -0
  86. package/src/language/__tests__/profile.test.ts +51 -0
  87. package/src/language/detect.ts +70 -0
  88. package/src/language/profile.ts +110 -0
  89. package/src/prompts/__tests__/defaults.test.ts +52 -0
  90. package/src/prompts/__tests__/engine.test.ts +183 -0
  91. package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
  92. package/src/prompts/__tests__/evolution.test.ts +187 -0
  93. package/src/prompts/__tests__/loader.test.ts +105 -0
  94. package/src/prompts/candidates/review-v2.md +55 -0
  95. package/src/prompts/defaults/ai-review.md +49 -0
  96. package/src/prompts/defaults/commit.md +30 -0
  97. package/src/prompts/defaults/context.md +26 -0
  98. package/src/prompts/defaults/design-approaches.md +57 -0
  99. package/src/prompts/defaults/design-hld-lld.md +55 -0
  100. package/src/prompts/defaults/design.md +53 -0
  101. package/src/prompts/defaults/explain.md +31 -0
  102. package/src/prompts/defaults/fix.md +32 -0
  103. package/src/prompts/defaults/index.ts +38 -0
  104. package/src/prompts/defaults/review.md +41 -0
  105. package/src/prompts/defaults/spec-questions.md +59 -0
  106. package/src/prompts/defaults/tests.md +72 -0
  107. package/src/prompts/engine.ts +137 -0
  108. package/src/prompts/evolution.ts +409 -0
  109. package/src/prompts/loader.ts +71 -0
  110. package/src/review/__tests__/review.test.ts +288 -0
  111. package/src/review/comprehensive.ts +362 -0
  112. package/src/review/index.ts +417 -0
  113. package/src/stats/__tests__/tracker.test.ts +323 -0
  114. package/src/stats/index.ts +11 -0
  115. package/src/stats/tracker.ts +492 -0
  116. package/src/ticket/__tests__/ticket.test.ts +273 -0
  117. package/src/ticket/index.ts +185 -0
  118. package/src/utils.ts +87 -0
  119. package/src/verify/__tests__/ai-review.test.ts +242 -0
  120. package/src/verify/__tests__/coverage.test.ts +83 -0
  121. package/src/verify/__tests__/detect.test.ts +175 -0
  122. package/src/verify/__tests__/diff-filter.test.ts +338 -0
  123. package/src/verify/__tests__/fix.test.ts +478 -0
  124. package/src/verify/__tests__/linters/clippy.test.ts +45 -0
  125. package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
  126. package/src/verify/__tests__/linters/ruff.test.ts +64 -0
  127. package/src/verify/__tests__/mutation.test.ts +141 -0
  128. package/src/verify/__tests__/pipeline.test.ts +553 -0
  129. package/src/verify/__tests__/proof.test.ts +97 -0
  130. package/src/verify/__tests__/secretlint.test.ts +190 -0
  131. package/src/verify/__tests__/semgrep.test.ts +217 -0
  132. package/src/verify/__tests__/slop.test.ts +366 -0
  133. package/src/verify/__tests__/sonar.test.ts +113 -0
  134. package/src/verify/__tests__/syntax-guard.test.ts +227 -0
  135. package/src/verify/__tests__/trivy.test.ts +191 -0
  136. package/src/verify/__tests__/visual.test.ts +139 -0
  137. package/src/verify/ai-review.ts +276 -0
  138. package/src/verify/coverage.ts +134 -0
  139. package/src/verify/detect.ts +171 -0
  140. package/src/verify/diff-filter.ts +183 -0
  141. package/src/verify/fix.ts +317 -0
  142. package/src/verify/linters/clippy.ts +52 -0
  143. package/src/verify/linters/go-vet.ts +32 -0
  144. package/src/verify/linters/ruff.ts +47 -0
  145. package/src/verify/mutation.ts +143 -0
  146. package/src/verify/pipeline.ts +328 -0
  147. package/src/verify/proof.ts +277 -0
  148. package/src/verify/secretlint.ts +168 -0
  149. package/src/verify/semgrep.ts +170 -0
  150. package/src/verify/slop.ts +493 -0
  151. package/src/verify/sonar.ts +146 -0
  152. package/src/verify/syntax-guard.ts +251 -0
  153. package/src/verify/trivy.ts +161 -0
  154. package/src/verify/visual.ts +460 -0
  155. package/src/workflow/__tests__/context.test.ts +110 -0
  156. package/src/workflow/context.ts +81 -0
@@ -0,0 +1,169 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ acknowledgeFinding,
6
+ dismissFinding,
7
+ getNoisyRules,
8
+ loadPreferences,
9
+ type Preferences,
10
+ savePreferences,
11
+ } from "../preferences";
12
+
13
+ let tmpDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = join(
17
+ import.meta.dir,
18
+ `tmp-prefs-${Date.now()}-${Math.random().toString(36).slice(2)}`,
19
+ );
20
+ mkdirSync(tmpDir, { recursive: true });
21
+ });
22
+
23
+ afterEach(() => {
24
+ try {
25
+ const { rmSync } = require("node:fs");
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ } catch {
28
+ // ignore
29
+ }
30
+ });
31
+
32
+ describe("loadPreferences", () => {
33
+ test("returns defaults when no file exists", () => {
34
+ const prefs = loadPreferences(tmpDir);
35
+
36
+ expect(prefs.rules).toEqual({});
37
+ expect(prefs.updatedAt).toBeTruthy();
38
+ });
39
+ });
40
+
41
+ describe("dismissFinding", () => {
42
+ test("increments dismiss count", () => {
43
+ dismissFinding(tmpDir, "no-console-log");
44
+ dismissFinding(tmpDir, "no-console-log");
45
+
46
+ const prefs = loadPreferences(tmpDir);
47
+ const rule = prefs.rules["no-console-log"];
48
+
49
+ expect(rule).toBeDefined();
50
+ expect(rule?.dismissCount).toBe(2);
51
+ expect(rule?.totalCount).toBe(2);
52
+ expect(rule?.falsePositiveRate).toBeCloseTo(1.0, 5);
53
+ });
54
+ });
55
+
56
+ describe("acknowledgeFinding", () => {
57
+ test("increments total count without incrementing dismiss count", () => {
58
+ acknowledgeFinding(tmpDir, "no-unused-vars");
59
+ acknowledgeFinding(tmpDir, "no-unused-vars");
60
+
61
+ const prefs = loadPreferences(tmpDir);
62
+ const rule = prefs.rules["no-unused-vars"];
63
+
64
+ expect(rule).toBeDefined();
65
+ expect(rule?.dismissCount).toBe(0);
66
+ expect(rule?.totalCount).toBe(2);
67
+ expect(rule?.falsePositiveRate).toBe(0);
68
+ });
69
+ });
70
+
71
+ describe("getNoisyRules", () => {
72
+ test("returns rules with >50% false positive rate and >= 5 samples", () => {
73
+ // noisy rule: 5 dismissed out of 6 total (83%) — meets MIN_RULE_SAMPLES
74
+ dismissFinding(tmpDir, "noisy-rule");
75
+ dismissFinding(tmpDir, "noisy-rule");
76
+ dismissFinding(tmpDir, "noisy-rule");
77
+ dismissFinding(tmpDir, "noisy-rule");
78
+ dismissFinding(tmpDir, "noisy-rule");
79
+ acknowledgeFinding(tmpDir, "noisy-rule");
80
+
81
+ // good rule: 1 dismissed out of 6 total (17%) — not noisy
82
+ dismissFinding(tmpDir, "good-rule");
83
+ acknowledgeFinding(tmpDir, "good-rule");
84
+ acknowledgeFinding(tmpDir, "good-rule");
85
+ acknowledgeFinding(tmpDir, "good-rule");
86
+ acknowledgeFinding(tmpDir, "good-rule");
87
+ acknowledgeFinding(tmpDir, "good-rule");
88
+
89
+ // borderline rule: exactly 50% — should NOT be included (>50% required)
90
+ dismissFinding(tmpDir, "borderline-rule");
91
+ acknowledgeFinding(tmpDir, "borderline-rule");
92
+ dismissFinding(tmpDir, "borderline-rule");
93
+ acknowledgeFinding(tmpDir, "borderline-rule");
94
+ dismissFinding(tmpDir, "borderline-rule");
95
+ acknowledgeFinding(tmpDir, "borderline-rule");
96
+
97
+ const noisy = getNoisyRules(tmpDir);
98
+
99
+ expect(noisy.length).toBe(1);
100
+ expect(noisy[0]?.ruleId).toBe("noisy-rule");
101
+ expect(noisy[0]?.falsePositiveRate).toBeCloseTo(5 / 6, 5);
102
+ });
103
+
104
+ test("excludes rules with fewer than 5 samples even if false positive rate is 100%", () => {
105
+ // Only 1 sample — 100% false positive rate but below MIN_RULE_SAMPLES
106
+ dismissFinding(tmpDir, "low-sample-rule");
107
+
108
+ const noisy = getNoisyRules(tmpDir);
109
+
110
+ expect(noisy.length).toBe(0);
111
+ });
112
+ });
113
+
114
+ describe("savePreferences", () => {
115
+ test("writes JSON file", () => {
116
+ const prefs: Preferences = {
117
+ rules: {
118
+ "test-rule": {
119
+ ruleId: "test-rule",
120
+ dismissCount: 5,
121
+ totalCount: 10,
122
+ falsePositiveRate: 0.5,
123
+ },
124
+ },
125
+ updatedAt: new Date().toISOString(),
126
+ };
127
+
128
+ savePreferences(tmpDir, prefs);
129
+
130
+ const filePath = join(tmpDir, "preferences.json");
131
+ const raw = readFileSync(filePath, "utf-8");
132
+ const parsed = JSON.parse(raw);
133
+
134
+ expect(parsed.rules["test-rule"].dismissCount).toBe(5);
135
+ expect(parsed.rules["test-rule"].totalCount).toBe(10);
136
+ });
137
+ });
138
+
139
+ describe("round-trip", () => {
140
+ test("preferences survive save then load", () => {
141
+ const prefs: Preferences = {
142
+ rules: {
143
+ "rule-a": {
144
+ ruleId: "rule-a",
145
+ dismissCount: 3,
146
+ totalCount: 7,
147
+ falsePositiveRate: 3 / 7,
148
+ },
149
+ "rule-b": {
150
+ ruleId: "rule-b",
151
+ dismissCount: 0,
152
+ totalCount: 5,
153
+ falsePositiveRate: 0,
154
+ },
155
+ },
156
+ updatedAt: "2026-04-03T00:00:00.000Z",
157
+ };
158
+
159
+ savePreferences(tmpDir, prefs);
160
+ const loaded = loadPreferences(tmpDir);
161
+
162
+ expect(loaded.rules["rule-a"]?.dismissCount).toBe(3);
163
+ expect(loaded.rules["rule-a"]?.totalCount).toBe(7);
164
+ expect(loaded.rules["rule-a"]?.falsePositiveRate).toBeCloseTo(3 / 7, 5);
165
+ expect(loaded.rules["rule-b"]?.dismissCount).toBe(0);
166
+ expect(loaded.rules["rule-b"]?.totalCount).toBe(5);
167
+ expect(loaded.updatedAt).toBe("2026-04-03T00:00:00.000Z");
168
+ });
169
+ });
@@ -0,0 +1,135 @@
1
+ import { hashContent } from "../cache/keys";
2
+ import { getFeedbackDb } from "../db/index";
3
+ import { recordOutcome } from "../prompts/engine";
4
+ import { compressReview, storeCompressedReview } from "./compress";
5
+
6
+ export interface FeedbackRecord {
7
+ promptHash: string;
8
+ task: string;
9
+ accepted: boolean;
10
+ modification?: string;
11
+ timestamp: string;
12
+ }
13
+
14
+ /**
15
+ * Record feedback for an AI interaction.
16
+ * Appends to feedback.db using existing recordOutcome from prompts/engine.
17
+ */
18
+ export function recordFeedback(mainaDir: string, record: FeedbackRecord): void {
19
+ recordOutcome(mainaDir, record.promptHash, {
20
+ accepted: record.accepted,
21
+ command: record.task,
22
+ context: record.modification ?? undefined,
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Get feedback summary for a task.
28
+ */
29
+ export function getFeedbackSummary(
30
+ mainaDir: string,
31
+ task: string,
32
+ ): {
33
+ total: number;
34
+ accepted: number;
35
+ rejected: number;
36
+ acceptRate: number;
37
+ } {
38
+ const dbResult = getFeedbackDb(mainaDir);
39
+ if (!dbResult.ok) {
40
+ return { total: 0, accepted: 0, rejected: 0, acceptRate: 0 };
41
+ }
42
+
43
+ const { db } = dbResult.value;
44
+
45
+ const row = db
46
+ .query(
47
+ `SELECT
48
+ COUNT(*) as total,
49
+ SUM(CASE WHEN accepted = 1 THEN 1 ELSE 0 END) as accepted_count
50
+ FROM feedback WHERE command = ?`,
51
+ )
52
+ .get(task) as { total: number; accepted_count: number } | null;
53
+
54
+ if (!row || row.total === 0) {
55
+ return { total: 0, accepted: 0, rejected: 0, acceptRate: 0 };
56
+ }
57
+
58
+ const total = row.total;
59
+ const accepted = row.accepted_count;
60
+ const rejected = total - accepted;
61
+
62
+ return {
63
+ total,
64
+ accepted,
65
+ rejected,
66
+ acceptRate: total > 0 ? accepted / total : 0,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Record feedback and, if the review was accepted, compress and store
72
+ * it as an episodic entry for future context.
73
+ */
74
+ export function recordFeedbackWithCompression(
75
+ mainaDir: string,
76
+ record: FeedbackRecord & { aiOutput?: string; diff?: string },
77
+ ): void {
78
+ // Record the feedback
79
+ recordFeedback(mainaDir, record);
80
+
81
+ // If accepted review, compress and store as episodic
82
+ if (record.accepted && record.task === "review" && record.aiOutput) {
83
+ const compressed = compressReview({
84
+ diff: record.diff ?? "",
85
+ aiOutput: record.aiOutput,
86
+ task: record.task,
87
+ accepted: true,
88
+ });
89
+ if (compressed) {
90
+ storeCompressedReview(mainaDir, compressed, record.task);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Record feedback asynchronously — never blocks the calling command.
97
+ * Uses queueMicrotask for zero-latency fire-and-forget.
98
+ */
99
+ export function recordFeedbackAsync(
100
+ mainaDir: string,
101
+ record: FeedbackRecord & {
102
+ workflowStep?: string;
103
+ workflowId?: string;
104
+ },
105
+ ): void {
106
+ queueMicrotask(() => {
107
+ try {
108
+ // Use existing recordFeedback for the base record
109
+ recordFeedback(mainaDir, record);
110
+
111
+ // Write workflow columns directly if provided
112
+ if (record.workflowStep || record.workflowId) {
113
+ const dbResult = getFeedbackDb(mainaDir);
114
+ if (!dbResult.ok) return;
115
+ const { db } = dbResult.value;
116
+
117
+ // Update the most recent row for this prompt hash
118
+ db.prepare(
119
+ `UPDATE feedback SET workflow_step = ?, workflow_id = ?
120
+ WHERE id = (SELECT id FROM feedback ORDER BY created_at DESC LIMIT 1)`,
121
+ ).run(record.workflowStep ?? null, record.workflowId ?? null);
122
+ }
123
+ } catch {
124
+ // Never throw from background feedback
125
+ }
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Generate a workflow ID from a branch name.
131
+ * All commands on the same branch share the same workflow ID.
132
+ */
133
+ export function getWorkflowId(branchName: string): string {
134
+ return hashContent(`workflow:${branchName}`).slice(0, 12);
135
+ }
@@ -0,0 +1,92 @@
1
+ import { addEntry } from "../context/episodic";
2
+
3
+ /** Maximum character length for compressed review (approx 500 tokens) */
4
+ const MAX_COMPRESSED_CHARS = 2000;
5
+
6
+ /**
7
+ * Compress an accepted AI review into a short episodic entry
8
+ * suitable as a few-shot example for future context.
9
+ *
10
+ * Target: under 500 tokens per compressed entry.
11
+ */
12
+ export function compressReview(review: {
13
+ diff: string;
14
+ aiOutput: string;
15
+ task: string;
16
+ accepted: boolean;
17
+ }): string | null {
18
+ if (!review.accepted) return null;
19
+
20
+ // Extract file names from diff headers (+++ b/path)
21
+ const fileRegex = /^\+\+\+ b\/(.+)$/gm;
22
+ const files: string[] = [];
23
+ let match: RegExpExecArray | null = null;
24
+ match = fileRegex.exec(review.diff);
25
+ while (match !== null) {
26
+ files.push(match[1] as string);
27
+ match = fileRegex.exec(review.diff);
28
+ }
29
+ const uniqueFiles = [...new Set(files)];
30
+
31
+ // Extract key findings from AI output (lines with issue/fix/error/warning keywords)
32
+ const findingKeywords = /\b(issue|fix|error|warning|bug|problem|critical)\b/i;
33
+ const aiLines = review.aiOutput.split("\n");
34
+ const findings = aiLines.filter((line) => findingKeywords.test(line));
35
+
36
+ // Extract verdict/summary (look for lines starting with "Overall", "Summary", "Verdict", or last non-empty line)
37
+ const verdictRegex = /^(overall|summary|verdict|conclusion)\b/i;
38
+ let verdict = aiLines.find((line) => verdictRegex.test(line.trim()));
39
+ if (!verdict) {
40
+ // Fall back to last non-empty, non-finding line
41
+ const nonEmpty = aiLines.filter(
42
+ (line) => line.trim().length > 0 && !findingKeywords.test(line),
43
+ );
44
+ verdict = nonEmpty[nonEmpty.length - 1] ?? "";
45
+ }
46
+
47
+ // Build the compressed entry
48
+ const parts: string[] = [];
49
+
50
+ parts.push(`[${review.task}] Accepted review`);
51
+
52
+ if (uniqueFiles.length > 0) {
53
+ parts.push(`Files: ${uniqueFiles.join(", ")}`);
54
+ }
55
+
56
+ if (findings.length > 0) {
57
+ // Limit findings to keep under budget
58
+ const limitedFindings = findings.slice(0, 10);
59
+ parts.push("Findings:");
60
+ for (const f of limitedFindings) {
61
+ parts.push(` - ${f.trim()}`);
62
+ }
63
+ }
64
+
65
+ if (verdict) {
66
+ parts.push(`Verdict: ${verdict.trim()}`);
67
+ }
68
+
69
+ let compressed = parts.join("\n");
70
+
71
+ // Trim to MAX_COMPRESSED_CHARS
72
+ if (compressed.length > MAX_COMPRESSED_CHARS) {
73
+ compressed = `${compressed.slice(0, MAX_COMPRESSED_CHARS - 3)}...`;
74
+ }
75
+
76
+ return compressed;
77
+ }
78
+
79
+ /**
80
+ * Store a compressed review as an episodic entry.
81
+ */
82
+ export function storeCompressedReview(
83
+ mainaDir: string,
84
+ compressed: string,
85
+ task: string,
86
+ ): void {
87
+ addEntry(mainaDir, {
88
+ content: compressed,
89
+ summary: task === "review" ? "Accepted review" : `Accepted ${task} review`,
90
+ type: "review",
91
+ });
92
+ }
@@ -0,0 +1,108 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface RulePreference {
5
+ ruleId: string;
6
+ dismissCount: number;
7
+ totalCount: number;
8
+ falsePositiveRate: number;
9
+ }
10
+
11
+ export interface Preferences {
12
+ rules: Record<string, RulePreference>;
13
+ updatedAt: string;
14
+ }
15
+
16
+ const MIN_RULE_SAMPLES = 5;
17
+
18
+ const PREFS_FILE = "preferences.json";
19
+
20
+ function prefsPath(mainaDir: string): string {
21
+ return join(mainaDir, PREFS_FILE);
22
+ }
23
+
24
+ /**
25
+ * Load preferences from .maina/preferences.json.
26
+ * Returns defaults when no file exists.
27
+ */
28
+ export function loadPreferences(mainaDir: string): Preferences {
29
+ const path = prefsPath(mainaDir);
30
+ if (!existsSync(path)) {
31
+ return {
32
+ rules: {},
33
+ updatedAt: new Date().toISOString(),
34
+ };
35
+ }
36
+
37
+ try {
38
+ const raw = readFileSync(path, "utf-8");
39
+ return JSON.parse(raw) as Preferences;
40
+ } catch {
41
+ return {
42
+ rules: {},
43
+ updatedAt: new Date().toISOString(),
44
+ };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save preferences to .maina/preferences.json.
50
+ */
51
+ export function savePreferences(mainaDir: string, prefs: Preferences): void {
52
+ const path = prefsPath(mainaDir);
53
+ writeFileSync(path, JSON.stringify(prefs, null, 2), "utf-8");
54
+ }
55
+
56
+ function ensureRule(prefs: Preferences, ruleId: string): RulePreference {
57
+ if (!prefs.rules[ruleId]) {
58
+ prefs.rules[ruleId] = {
59
+ ruleId,
60
+ dismissCount: 0,
61
+ totalCount: 0,
62
+ falsePositiveRate: 0,
63
+ };
64
+ }
65
+ return prefs.rules[ruleId];
66
+ }
67
+
68
+ function updateRate(rule: RulePreference): void {
69
+ rule.falsePositiveRate =
70
+ rule.totalCount > 0 ? rule.dismissCount / rule.totalCount : 0;
71
+ }
72
+
73
+ /**
74
+ * Record a finding dismissal (user saw it and chose to ignore it).
75
+ */
76
+ export function dismissFinding(mainaDir: string, ruleId: string): void {
77
+ const prefs = loadPreferences(mainaDir);
78
+ const rule = ensureRule(prefs, ruleId);
79
+ rule.dismissCount += 1;
80
+ rule.totalCount += 1;
81
+ updateRate(rule);
82
+ prefs.updatedAt = new Date().toISOString();
83
+ savePreferences(mainaDir, prefs);
84
+ }
85
+
86
+ /**
87
+ * Record a finding acknowledgment (user saw it and it was valid).
88
+ */
89
+ export function acknowledgeFinding(mainaDir: string, ruleId: string): void {
90
+ const prefs = loadPreferences(mainaDir);
91
+ const rule = ensureRule(prefs, ruleId);
92
+ rule.totalCount += 1;
93
+ updateRate(rule);
94
+ prefs.updatedAt = new Date().toISOString();
95
+ savePreferences(mainaDir, prefs);
96
+ }
97
+
98
+ /**
99
+ * Get rules with high false positive rates (>50% dismissed).
100
+ * These should be downgraded in severity or suppressed.
101
+ */
102
+ export function getNoisyRules(mainaDir: string): RulePreference[] {
103
+ const prefs = loadPreferences(mainaDir);
104
+ return Object.values(prefs.rules).filter(
105
+ (rule) =>
106
+ rule.falsePositiveRate > 0.5 && rule.totalCount >= MIN_RULE_SAMPLES,
107
+ );
108
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ getBranchName,
4
+ getChangedFiles,
5
+ getCurrentBranch,
6
+ getDiff,
7
+ getRecentCommits,
8
+ getRepoRoot,
9
+ getStagedFiles,
10
+ } from "../index";
11
+
12
+ describe("git operations", () => {
13
+ test("getCurrentBranch() returns a non-empty string", async () => {
14
+ const branch = await getCurrentBranch();
15
+ expect(typeof branch).toBe("string");
16
+ expect(branch.length).toBeGreaterThan(0);
17
+ });
18
+
19
+ test("getBranchName() is an alias for getCurrentBranch()", async () => {
20
+ const branch = await getBranchName();
21
+ expect(typeof branch).toBe("string");
22
+ expect(branch.length).toBeGreaterThan(0);
23
+ });
24
+
25
+ test("getRepoRoot() returns a path that contains 'maina'", async () => {
26
+ const root = await getRepoRoot();
27
+ expect(typeof root).toBe("string");
28
+ expect(root).toContain("maina");
29
+ });
30
+
31
+ test("getRecentCommits(5) returns an array (may be empty for new repo)", async () => {
32
+ const commits = await getRecentCommits(5);
33
+ expect(Array.isArray(commits)).toBe(true);
34
+ for (const commit of commits) {
35
+ expect(typeof commit.hash).toBe("string");
36
+ expect(typeof commit.message).toBe("string");
37
+ expect(typeof commit.author).toBe("string");
38
+ expect(typeof commit.date).toBe("string");
39
+ }
40
+ });
41
+
42
+ test("getChangedFiles() returns an array of strings", async () => {
43
+ const files = await getChangedFiles();
44
+ expect(Array.isArray(files)).toBe(true);
45
+ for (const file of files) {
46
+ expect(typeof file).toBe("string");
47
+ }
48
+ });
49
+
50
+ test("getStagedFiles() returns an array", async () => {
51
+ const files = await getStagedFiles();
52
+ expect(Array.isArray(files)).toBe(true);
53
+ for (const file of files) {
54
+ expect(typeof file).toBe("string");
55
+ }
56
+ });
57
+
58
+ test("getDiff() returns a string", async () => {
59
+ const diff = await getDiff();
60
+ expect(typeof diff).toBe("string");
61
+ });
62
+ });
@@ -0,0 +1,110 @@
1
+ export interface Commit {
2
+ hash: string;
3
+ message: string;
4
+ author: string;
5
+ date: string;
6
+ }
7
+
8
+ async function exec(
9
+ args: string[],
10
+ cwd: string = process.cwd(),
11
+ ): Promise<string> {
12
+ try {
13
+ const proc = Bun.spawn(["git", ...args], {
14
+ cwd,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ const output = await new Response(proc.stdout).text();
19
+ const exitCode = await proc.exited;
20
+ if (exitCode !== 0) return "";
21
+ return output.trim();
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ export async function getCurrentBranch(cwd?: string): Promise<string> {
28
+ const branch = await exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
29
+ return branch;
30
+ }
31
+
32
+ export async function getBranchName(cwd?: string): Promise<string> {
33
+ return getCurrentBranch(cwd);
34
+ }
35
+
36
+ export async function getRepoRoot(cwd?: string): Promise<string> {
37
+ const root = await exec(["rev-parse", "--show-toplevel"], cwd);
38
+ return root;
39
+ }
40
+
41
+ export async function getRecentCommits(
42
+ n: number,
43
+ cwd?: string,
44
+ ): Promise<Commit[]> {
45
+ const separator = "|||";
46
+ const format = `%H${separator}%s${separator}%an${separator}%ai`;
47
+ const output = await exec(["log", `-${n}`, `--pretty=format:${format}`], cwd);
48
+ if (!output) return [];
49
+ return output
50
+ .split("\n")
51
+ .filter((line) => line.trim().length > 0)
52
+ .map((line) => {
53
+ const parts = line.split(separator);
54
+ return {
55
+ hash: parts[0]?.trim() ?? "",
56
+ message: parts[1]?.trim() ?? "",
57
+ author: parts[2]?.trim() ?? "",
58
+ date: parts[3]?.trim() ?? "",
59
+ };
60
+ });
61
+ }
62
+
63
+ export async function getChangedFiles(
64
+ since?: string,
65
+ cwd?: string,
66
+ ): Promise<string[]> {
67
+ let output: string;
68
+ if (since) {
69
+ output = await exec(["diff", "--name-only", since], cwd);
70
+ } else {
71
+ output = await exec(["status", "--porcelain"], cwd);
72
+ if (!output) return [];
73
+ return output
74
+ .split("\n")
75
+ .filter((line) => line.trim().length > 0)
76
+ .map((line) => line.slice(3).trim());
77
+ }
78
+ if (!output) return [];
79
+ return output.split("\n").filter((line) => line.trim().length > 0);
80
+ }
81
+
82
+ export async function getDiff(
83
+ ref1?: string,
84
+ ref2?: string,
85
+ cwd?: string,
86
+ ): Promise<string> {
87
+ const args: string[] = ["diff"];
88
+ if (ref1 && ref2) {
89
+ args.push(ref1, ref2);
90
+ } else if (ref1) {
91
+ args.push(ref1);
92
+ }
93
+ const output = await exec(args, cwd);
94
+ return output;
95
+ }
96
+
97
+ export async function getStagedFiles(cwd?: string): Promise<string[]> {
98
+ const output = await exec(["diff", "--cached", "--name-only"], cwd);
99
+ if (!output) return [];
100
+ return output.split("\n").filter((line) => line.trim().length > 0);
101
+ }
102
+
103
+ export async function getTrackedFiles(cwd?: string): Promise<string[]> {
104
+ const output = await exec(
105
+ ["ls-files", "--cached", "--exclude-standard"],
106
+ cwd,
107
+ );
108
+ if (!output) return [];
109
+ return output.split("\n").filter((line) => line.trim().length > 0);
110
+ }