@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,273 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getContextDb } from "../../db/index.ts";
6
+ import { buildIssueBody, createTicket, detectModules } from "../index.ts";
7
+
8
+ const TEST_DIR = join(tmpdir(), `maina-ticket-test-${Date.now()}`);
9
+
10
+ beforeAll(() => {
11
+ mkdirSync(TEST_DIR, { recursive: true });
12
+ });
13
+
14
+ afterAll(() => {
15
+ rmSync(TEST_DIR, { recursive: true, force: true });
16
+ });
17
+
18
+ // ── detectModules ───────────────────────────────────────────────────────────
19
+
20
+ describe("detectModules", () => {
21
+ test("returns empty array when DB does not exist", async () => {
22
+ const nonExistent = join(TEST_DIR, "no-such-dir");
23
+ const result = detectModules(nonExistent, "some title", "some body");
24
+ expect(result).toEqual([]);
25
+ });
26
+
27
+ test("returns empty array when no entities match keywords", () => {
28
+ const mainaDir = join(TEST_DIR, "no-match");
29
+ mkdirSync(mainaDir, { recursive: true });
30
+
31
+ // Create DB with entities that don't match
32
+ const dbResult = getContextDb(mainaDir);
33
+ expect(dbResult.ok).toBe(true);
34
+ if (!dbResult.ok) return;
35
+ const { db } = dbResult.value;
36
+
37
+ // Insert a semantic entity in the "auth" module
38
+ db.exec(`
39
+ INSERT INTO semantic_entities (id, file_path, name, kind, start_line, end_line, updated_at)
40
+ VALUES ('e1', 'src/auth/login.ts', 'login', 'function', 1, 10, '2026-01-01')
41
+ `);
42
+
43
+ const modules = detectModules(
44
+ mainaDir,
45
+ "fix database query",
46
+ "slow query in cache layer",
47
+ );
48
+ // "auth" should not match "database query" or "cache layer"
49
+ // but "cache" is not in the DB, so result should be empty
50
+ expect(modules).toEqual([]);
51
+ db.close();
52
+ });
53
+
54
+ test("returns matching module names from semantic entities", () => {
55
+ const mainaDir = join(TEST_DIR, "match-modules");
56
+ mkdirSync(mainaDir, { recursive: true });
57
+
58
+ const dbResult = getContextDb(mainaDir);
59
+ expect(dbResult.ok).toBe(true);
60
+ if (!dbResult.ok) return;
61
+ const { db } = dbResult.value;
62
+
63
+ // Insert entities in different modules
64
+ db.exec(`
65
+ INSERT INTO semantic_entities (id, file_path, name, kind, start_line, end_line, updated_at)
66
+ VALUES
67
+ ('e1', 'src/context/engine.ts', 'assembleContext', 'function', 1, 50, '2026-01-01'),
68
+ ('e2', 'src/verify/pipeline.ts', 'runPipeline', 'function', 1, 30, '2026-01-01'),
69
+ ('e3', 'src/verify/slop.ts', 'detectSlop', 'function', 1, 20, '2026-01-01'),
70
+ ('e4', 'src/cli/commands/commit.ts', 'commitAction', 'function', 1, 40, '2026-01-01')
71
+ `);
72
+
73
+ const modules = detectModules(
74
+ mainaDir,
75
+ "Fix context engine budget",
76
+ "The context assembly is exceeding token budget",
77
+ );
78
+
79
+ expect(modules).toContain("context");
80
+ expect(modules).not.toContain("cli");
81
+ db.close();
82
+ });
83
+
84
+ test("deduplicates module names", () => {
85
+ const mainaDir = join(TEST_DIR, "dedup-modules");
86
+ mkdirSync(mainaDir, { recursive: true });
87
+
88
+ const dbResult = getContextDb(mainaDir);
89
+ expect(dbResult.ok).toBe(true);
90
+ if (!dbResult.ok) return;
91
+ const { db } = dbResult.value;
92
+
93
+ // Multiple entities in the same module
94
+ db.exec(`
95
+ INSERT INTO semantic_entities (id, file_path, name, kind, start_line, end_line, updated_at)
96
+ VALUES
97
+ ('e1', 'src/verify/pipeline.ts', 'runPipeline', 'function', 1, 30, '2026-01-01'),
98
+ ('e2', 'src/verify/slop.ts', 'detectSlop', 'function', 1, 20, '2026-01-01'),
99
+ ('e3', 'src/verify/diff-filter.ts', 'filterByDiff', 'function', 1, 15, '2026-01-01')
100
+ `);
101
+
102
+ const modules = detectModules(
103
+ mainaDir,
104
+ "Fix verify pipeline",
105
+ "The verify slop detector has a bug in diff filter",
106
+ );
107
+
108
+ // Should have "verify" only once
109
+ const verifyCount = modules.filter((m) => m === "verify").length;
110
+ expect(verifyCount).toBeLessThanOrEqual(1);
111
+ expect(modules).toContain("verify");
112
+ db.close();
113
+ });
114
+ });
115
+
116
+ // ── buildIssueBody ──────────────────────────────────────────────────────────
117
+
118
+ describe("buildIssueBody", () => {
119
+ test("appends Modules section when modules are found", () => {
120
+ const body = "Fix the context engine budget calculation.";
121
+ const modules = ["context", "verify", "cli"];
122
+
123
+ const result = buildIssueBody(body, modules);
124
+
125
+ expect(result).toContain(body);
126
+ expect(result).toContain("**Modules:** context, verify, cli");
127
+ });
128
+
129
+ test("returns body unchanged when no modules", () => {
130
+ const body = "Fix the context engine budget calculation.";
131
+
132
+ const result = buildIssueBody(body, []);
133
+
134
+ expect(result).toBe(body);
135
+ });
136
+
137
+ test("handles empty body with modules", () => {
138
+ const result = buildIssueBody("", ["core"]);
139
+
140
+ expect(result).toContain("**Modules:** core");
141
+ });
142
+ });
143
+
144
+ // ── createTicket ────────────────────────────────────────────────────────────
145
+
146
+ describe("createTicket", () => {
147
+ test("returns success with url and number on successful gh call", async () => {
148
+ const mockSpawn = async (_args: string[], _opts?: { cwd?: string }) => ({
149
+ exitCode: 0,
150
+ stdout: "https://github.com/owner/repo/issues/42\n",
151
+ stderr: "",
152
+ });
153
+
154
+ const result = await createTicket(
155
+ {
156
+ title: "Test issue",
157
+ body: "Test body",
158
+ labels: ["bug"],
159
+ },
160
+ { spawn: mockSpawn },
161
+ );
162
+
163
+ expect(result.ok).toBe(true);
164
+ if (!result.ok) return;
165
+ expect(result.value.url).toBe("https://github.com/owner/repo/issues/42");
166
+ expect(result.value.number).toBe(42);
167
+ });
168
+
169
+ test("passes labels to gh issue create", async () => {
170
+ let capturedArgs: string[] = [];
171
+ const mockSpawn = async (args: string[], _opts?: { cwd?: string }) => {
172
+ capturedArgs = args;
173
+ return {
174
+ exitCode: 0,
175
+ stdout: "https://github.com/owner/repo/issues/1\n",
176
+ stderr: "",
177
+ };
178
+ };
179
+
180
+ await createTicket(
181
+ {
182
+ title: "Test",
183
+ body: "Body",
184
+ labels: ["bug", "context"],
185
+ },
186
+ { spawn: mockSpawn },
187
+ );
188
+
189
+ expect(capturedArgs).toContain("--label");
190
+ expect(capturedArgs).toContain("bug,context");
191
+ });
192
+
193
+ test("returns error when gh fails", async () => {
194
+ const mockSpawn = async (_args: string[], _opts?: { cwd?: string }) => ({
195
+ exitCode: 1,
196
+ stdout: "",
197
+ stderr: "gh: not logged in",
198
+ });
199
+
200
+ const result = await createTicket(
201
+ {
202
+ title: "Test",
203
+ body: "Body",
204
+ },
205
+ { spawn: mockSpawn },
206
+ );
207
+
208
+ expect(result.ok).toBe(false);
209
+ if (result.ok) return;
210
+ expect(result.error).toContain("gh: not logged in");
211
+ });
212
+
213
+ test("returns error when gh is not installed (spawn throws)", async () => {
214
+ const mockSpawn = async (_args: string[], _opts?: { cwd?: string }) => {
215
+ throw new Error("spawn: command not found: gh");
216
+ };
217
+
218
+ const result = await createTicket(
219
+ {
220
+ title: "Test",
221
+ body: "Body",
222
+ },
223
+ { spawn: mockSpawn },
224
+ );
225
+
226
+ expect(result.ok).toBe(false);
227
+ if (result.ok) return;
228
+ expect(result.error).toContain("gh");
229
+ });
230
+
231
+ test("extracts issue number from URL", async () => {
232
+ const mockSpawn = async (_args: string[], _opts?: { cwd?: string }) => ({
233
+ exitCode: 0,
234
+ stdout: "https://github.com/bikash/maina/issues/123\n",
235
+ stderr: "",
236
+ });
237
+
238
+ const result = await createTicket(
239
+ {
240
+ title: "Test",
241
+ body: "Body",
242
+ },
243
+ { spawn: mockSpawn },
244
+ );
245
+
246
+ expect(result.ok).toBe(true);
247
+ if (!result.ok) return;
248
+ expect(result.value.number).toBe(123);
249
+ });
250
+
251
+ test("passes cwd to spawn", async () => {
252
+ let capturedOpts: { cwd?: string } | undefined;
253
+ const mockSpawn = async (_args: string[], opts?: { cwd?: string }) => {
254
+ capturedOpts = opts;
255
+ return {
256
+ exitCode: 0,
257
+ stdout: "https://github.com/owner/repo/issues/1\n",
258
+ stderr: "",
259
+ };
260
+ };
261
+
262
+ await createTicket(
263
+ {
264
+ title: "Test",
265
+ body: "Body",
266
+ cwd: "/some/path",
267
+ },
268
+ { spawn: mockSpawn },
269
+ );
270
+
271
+ expect(capturedOpts?.cwd).toBe("/some/path");
272
+ });
273
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Ticket Module — Create GitHub Issues with automatic module tagging.
3
+ *
4
+ * Uses the semantic entity index from the Context Engine DB to detect
5
+ * which modules are relevant to an issue's title and body, then calls
6
+ * `gh issue create` to create the issue on GitHub.
7
+ */
8
+
9
+ import type { Result } from "../db/index.ts";
10
+ import { getContextDb } from "../db/index.ts";
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────────────
13
+
14
+ export interface TicketOptions {
15
+ title: string;
16
+ body: string;
17
+ labels?: string[];
18
+ cwd?: string;
19
+ }
20
+
21
+ export interface TicketResult {
22
+ url: string;
23
+ number: number;
24
+ }
25
+
26
+ /** Dependency injection for spawn, enabling testability. */
27
+ export interface SpawnDeps {
28
+ spawn: (
29
+ args: string[],
30
+ opts?: { cwd?: string },
31
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
32
+ }
33
+
34
+ // ── Default spawn via Bun.spawn ──────────────────────────────────────────────
35
+
36
+ async function defaultSpawn(
37
+ args: string[],
38
+ opts?: { cwd?: string },
39
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
40
+ const proc = Bun.spawn(args, {
41
+ cwd: opts?.cwd,
42
+ stdout: "pipe",
43
+ stderr: "pipe",
44
+ });
45
+
46
+ const stdout = await new Response(proc.stdout).text();
47
+ const stderr = await new Response(proc.stderr).text();
48
+ const exitCode = await proc.exited;
49
+
50
+ return { exitCode, stdout, stderr };
51
+ }
52
+
53
+ const defaultDeps: SpawnDeps = { spawn: defaultSpawn };
54
+
55
+ // ── detectModules ────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Extract module/directory names from the semantic entity index that are
59
+ * relevant to the given title and body keywords.
60
+ *
61
+ * Looks at `file_path` in `semantic_entities` table, extracts the first
62
+ * directory component after `src/`, and matches against keywords from
63
+ * the title and body.
64
+ *
65
+ * Returns a deduplicated list of module names (e.g., ["context", "verify"]).
66
+ * If no DB or no matches, returns empty array (graceful).
67
+ */
68
+ export function detectModules(
69
+ mainaDir: string,
70
+ title: string,
71
+ body: string,
72
+ ): string[] {
73
+ try {
74
+ const dbResult = getContextDb(mainaDir);
75
+ if (!dbResult.ok) return [];
76
+
77
+ const db = dbResult.value.db;
78
+ try {
79
+ // Get all unique module names from semantic entities
80
+ const rows = db
81
+ .prepare("SELECT DISTINCT file_path FROM semantic_entities")
82
+ .all() as Array<{ file_path: string }>;
83
+
84
+ if (rows.length === 0) {
85
+ return [];
86
+ }
87
+
88
+ // Extract module names from file paths (first dir after src/)
89
+ const moduleSet = new Set<string>();
90
+ for (const row of rows) {
91
+ const match = row.file_path.match(/(?:^|\/)?src\/([^/]+)\//);
92
+ if (match?.[1]) {
93
+ moduleSet.add(match[1]);
94
+ }
95
+ }
96
+
97
+ // Build keywords from title + body (lowercase, split by non-alpha)
98
+ const text = `${title} ${body}`.toLowerCase();
99
+ const keywords = text.split(/[^a-z0-9]+/).filter((w) => w.length > 2);
100
+
101
+ // Match modules against keywords
102
+ const matched: string[] = [];
103
+ for (const mod of moduleSet) {
104
+ const modLower = mod.toLowerCase();
105
+ if (
106
+ keywords.some((kw) => modLower.includes(kw) || kw.includes(modLower))
107
+ ) {
108
+ matched.push(mod);
109
+ }
110
+ }
111
+
112
+ return [...new Set(matched)];
113
+ } finally {
114
+ db.close();
115
+ }
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ // ── buildIssueBody ──────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Format the issue body with a module tags section appended.
125
+ * If modules found: adds `\n\n**Modules:** context, verify, cli`
126
+ * If no modules: returns body unchanged.
127
+ */
128
+ export function buildIssueBody(body: string, modules: string[]): string {
129
+ if (modules.length === 0) return body;
130
+ return `${body}\n\n**Modules:** ${modules.join(", ")}`;
131
+ }
132
+
133
+ // ── createTicket ────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Create a GitHub Issue via `gh issue create`.
137
+ * Returns the issue URL and number on success, or an error on failure.
138
+ * Never throws — all errors are returned as Result Err values.
139
+ */
140
+ export async function createTicket(
141
+ options: TicketOptions,
142
+ deps: SpawnDeps = defaultDeps,
143
+ ): Promise<Result<TicketResult>> {
144
+ try {
145
+ const args = [
146
+ "gh",
147
+ "issue",
148
+ "create",
149
+ "--title",
150
+ options.title,
151
+ "--body",
152
+ options.body,
153
+ ];
154
+
155
+ if (options.labels && options.labels.length > 0) {
156
+ args.push("--label", options.labels.join(","));
157
+ }
158
+
159
+ const { exitCode, stdout, stderr } = await deps.spawn(args, {
160
+ cwd: options.cwd,
161
+ });
162
+
163
+ if (exitCode !== 0) {
164
+ const message =
165
+ stderr.trim() || stdout.trim() || "gh issue create failed";
166
+ return { ok: false, error: message };
167
+ }
168
+
169
+ const url = stdout.trim();
170
+ const numberMatch = url.match(/\/issues\/(\d+)/);
171
+ const issueNumber = numberMatch?.[1]
172
+ ? Number.parseInt(numberMatch[1], 10)
173
+ : 0;
174
+
175
+ return {
176
+ ok: true,
177
+ value: { url, number: issueNumber },
178
+ };
179
+ } catch (e) {
180
+ return {
181
+ ok: false,
182
+ error: e instanceof Error ? e.message : String(e),
183
+ };
184
+ }
185
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared utility functions used across core modules.
3
+ */
4
+
5
+ /**
6
+ * Convert a string to kebab-case.
7
+ * Handles: spaces, camelCase, PascalCase, underscores.
8
+ */
9
+ export function toKebabCase(input: string): string {
10
+ return (
11
+ input
12
+ // Insert hyphen before uppercase letters in camelCase/PascalCase
13
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
14
+ // Replace spaces, underscores, and multiple hyphens with single hyphen
15
+ .replace(/[\s_]+/g, "-")
16
+ // Remove non-alphanumeric characters except hyphens
17
+ .replace(/[^a-z0-9-]/gi, "")
18
+ // Collapse multiple hyphens
19
+ .replace(/-+/g, "-")
20
+ // Trim leading/trailing hyphens
21
+ .replace(/^-|-$/g, "")
22
+ .toLowerCase()
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Common stop words filtered out during keyword matching.
28
+ */
29
+ export const STOP_WORDS = new Set([
30
+ "the",
31
+ "and",
32
+ "for",
33
+ "are",
34
+ "but",
35
+ "not",
36
+ "you",
37
+ "all",
38
+ "can",
39
+ "has",
40
+ "her",
41
+ "was",
42
+ "one",
43
+ "our",
44
+ "out",
45
+ "with",
46
+ "that",
47
+ "this",
48
+ "from",
49
+ "have",
50
+ "will",
51
+ "should",
52
+ ]);
53
+
54
+ /**
55
+ * Extract the content under `## Acceptance criteria` from a spec file.
56
+ * Returns individual criterion lines (trimmed, without leading `- ` or `- [ ] `).
57
+ */
58
+ export function extractAcceptanceCriteria(specContent: string): string[] {
59
+ const lines = specContent.split("\n");
60
+ const criteria: string[] = [];
61
+ let inSection = false;
62
+
63
+ for (const line of lines) {
64
+ const trimmed = line.trim();
65
+
66
+ // Detect start of acceptance criteria section (case-insensitive)
67
+ if (/^##\s+acceptance\s+criteria/i.test(trimmed)) {
68
+ inSection = true;
69
+ continue;
70
+ }
71
+
72
+ // Stop at next heading
73
+ if (inSection && /^##\s/.test(trimmed)) {
74
+ break;
75
+ }
76
+
77
+ if (inSection && trimmed.startsWith("-")) {
78
+ // Strip leading `- `, `- [ ] `, `- [x] `
79
+ const content = trimmed.replace(/^-\s*(\[.\]\s*)?/, "").trim();
80
+ if (content.length > 0) {
81
+ criteria.push(content);
82
+ }
83
+ }
84
+ }
85
+
86
+ return criteria;
87
+ }