pi-vcc 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/LICENSE +21 -0
- package/README.md +120 -0
- package/demo.gif +0 -0
- package/flow/plans/20260515-1300/plan.md +206 -0
- package/index.ts +14 -0
- package/package.json +36 -0
- package/pi-vcc-config.schema.json +131 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +33 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +381 -0
- package/src/core/build-sections.ts +87 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +56 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +63 -0
- package/src/core/normalize.ts +66 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +68 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +230 -0
- package/src/core/settings.ts +215 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +159 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +7 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/references.ts +214 -0
- package/src/extract/signals.ts +145 -0
- package/src/hooks/before-compact.ts +405 -0
- package/src/sections.ts +14 -0
- package/src/tools/recall.ts +109 -0
- package/src/types.ts +14 -0
- package/tests/before-compact-hook.test.ts +181 -0
- package/tests/before-compact.test.ts +140 -0
- package/tests/brief.test.ts +206 -0
- package/tests/build-sections.test.ts +90 -0
- package/tests/compile.test.ts +110 -0
- package/tests/config-integration.test.ts +107 -0
- package/tests/content.test.ts +31 -0
- package/tests/edge-cases.test.ts +368 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/extract-references.test.ts +475 -0
- package/tests/extract-signals.test.ts +561 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +30 -0
- package/tests/format.test.ts +91 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +51 -0
- package/tests/normalize.test.ts +97 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +62 -0
- package/tests/report.test.ts +44 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +144 -0
- package/tests/settings-scaffold.test.ts +120 -0
- package/tests/settings.test.ts +32 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractGoals } from "../src/extract/goals";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("extractGoals", () => {
|
|
6
|
+
it("returns empty for no blocks", () => {
|
|
7
|
+
expect(extractGoals([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns empty when no user blocks", () => {
|
|
11
|
+
const blocks: NormalizedBlock[] = [
|
|
12
|
+
{ kind: "assistant", text: "hello" },
|
|
13
|
+
];
|
|
14
|
+
expect(extractGoals(blocks)).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts first user message lines as goals", () => {
|
|
18
|
+
const blocks: NormalizedBlock[] = [
|
|
19
|
+
{ kind: "user", text: "Fix login bug\nCheck auth flow" },
|
|
20
|
+
];
|
|
21
|
+
const goals = extractGoals(blocks);
|
|
22
|
+
expect(goals).toEqual(["Fix login bug", "Check auth flow"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("takes up to 6 lines from first user block", () => {
|
|
26
|
+
const blocks: NormalizedBlock[] = [
|
|
27
|
+
{ kind: "user", text: "fix the login bug\ncheck auth flow\nupdate the tests\nrefactor utils\nclean up" },
|
|
28
|
+
];
|
|
29
|
+
expect(extractGoals(blocks)).toHaveLength(5);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("ignores subsequent user blocks", () => {
|
|
33
|
+
const blocks: NormalizedBlock[] = [
|
|
34
|
+
{ kind: "user", text: "first goal" },
|
|
35
|
+
{ kind: "assistant", text: "ok" },
|
|
36
|
+
{ kind: "user", text: "second request" },
|
|
37
|
+
];
|
|
38
|
+
expect(extractGoals(blocks)).toEqual(["first goal"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("detects scope change with explicit pivot keywords", () => {
|
|
42
|
+
const blocks: NormalizedBlock[] = [
|
|
43
|
+
{ kind: "user", text: "Fix login bug" },
|
|
44
|
+
{ kind: "assistant", text: "ok" },
|
|
45
|
+
{ kind: "user", text: "Actually, instead let's refactor the auth module" },
|
|
46
|
+
];
|
|
47
|
+
const goals = extractGoals(blocks);
|
|
48
|
+
expect(goals).toContain("Fix login bug");
|
|
49
|
+
expect(goals).toContain("[Scope change]");
|
|
50
|
+
expect(goals.some((g) => g.includes("refactor"))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects scope change from new task statements", () => {
|
|
54
|
+
const blocks: NormalizedBlock[] = [
|
|
55
|
+
{ kind: "user", text: "Fix login bug" },
|
|
56
|
+
{ kind: "assistant", text: "done" },
|
|
57
|
+
{ kind: "user", text: "Now implement the user registration flow" },
|
|
58
|
+
];
|
|
59
|
+
const goals = extractGoals(blocks);
|
|
60
|
+
expect(goals).toContain("[Scope change]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps latest scope change only", () => {
|
|
64
|
+
const blocks: NormalizedBlock[] = [
|
|
65
|
+
{ kind: "user", text: "Fix login bug" },
|
|
66
|
+
{ kind: "assistant", text: "done" },
|
|
67
|
+
{ kind: "user", text: "Actually, fix the signup page instead" },
|
|
68
|
+
{ kind: "assistant", text: "ok" },
|
|
69
|
+
{ kind: "user", text: "Change of plan, implement password reset" },
|
|
70
|
+
];
|
|
71
|
+
const goals = extractGoals(blocks);
|
|
72
|
+
const scopeIdx = goals.indexOf("[Scope change]");
|
|
73
|
+
expect(goals[scopeIdx + 1]).toContain("password reset");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips noise short user messages as goals", () => {
|
|
77
|
+
const blocks: NormalizedBlock[] = [
|
|
78
|
+
{ kind: "user", text: "ok" },
|
|
79
|
+
{ kind: "assistant", text: "hello" },
|
|
80
|
+
{ kind: "user", text: "Fix the authentication module" },
|
|
81
|
+
];
|
|
82
|
+
const goals = extractGoals(blocks);
|
|
83
|
+
expect(goals[0]).toContain("Fix the authentication");
|
|
84
|
+
expect(goals.some((g) => g === "ok")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractPreferences } from "../src/extract/preferences";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("extractPreferences", () => {
|
|
6
|
+
it("returns empty for no blocks", () => {
|
|
7
|
+
expect(extractPreferences([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("captures preference patterns from user", () => {
|
|
11
|
+
const blocks: NormalizedBlock[] = [
|
|
12
|
+
{ kind: "user", text: "I prefer TypeScript over JavaScript" },
|
|
13
|
+
];
|
|
14
|
+
expect(extractPreferences(blocks).length).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("ignores assistant blocks", () => {
|
|
18
|
+
const blocks: NormalizedBlock[] = [
|
|
19
|
+
{ kind: "assistant", text: "I always use best practices" },
|
|
20
|
+
];
|
|
21
|
+
expect(extractPreferences(blocks)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("captures please use pattern", () => {
|
|
25
|
+
const blocks: NormalizedBlock[] = [
|
|
26
|
+
{ kind: "user", text: "please use bun instead of node" },
|
|
27
|
+
];
|
|
28
|
+
expect(extractPreferences(blocks).length).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractReferences, formatReferences } from "../src/extract/references";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
// ── extractReferences ──
|
|
6
|
+
|
|
7
|
+
describe("extractReferences", () => {
|
|
8
|
+
// ── URLs ──
|
|
9
|
+
|
|
10
|
+
it("returns empty for no blocks", () => {
|
|
11
|
+
expect(extractReferences([]).urls).toEqual([]);
|
|
12
|
+
expect(extractReferences([]).githubRefs).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("extracts http URL from user message", () => {
|
|
16
|
+
const blocks: NormalizedBlock[] = [
|
|
17
|
+
{ kind: "user", text: "Check out http://example.com for the docs" },
|
|
18
|
+
];
|
|
19
|
+
const refs = extractReferences(blocks);
|
|
20
|
+
expect(refs.urls).toContain("http://example.com");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("extracts https URL from user message", () => {
|
|
24
|
+
const blocks: NormalizedBlock[] = [
|
|
25
|
+
{ kind: "user", text: "See https://docs.site.com/api/v2#section" },
|
|
26
|
+
];
|
|
27
|
+
const refs = extractReferences(blocks);
|
|
28
|
+
expect(refs.urls).toContain("https://docs.site.com/api/v2#section");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts IP:port URL", () => {
|
|
32
|
+
const blocks: NormalizedBlock[] = [
|
|
33
|
+
{ kind: "user", text: "The server is at http://100.114.135.99:4747" },
|
|
34
|
+
];
|
|
35
|
+
const refs = extractReferences(blocks);
|
|
36
|
+
expect(refs.urls).toContain("http://100.114.135.99:4747");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("strips trailing punctuation from URLs", () => {
|
|
40
|
+
const blocks: NormalizedBlock[] = [
|
|
41
|
+
{ kind: "user", text: "See https://example.com/docs. And also https://other.com/path," },
|
|
42
|
+
];
|
|
43
|
+
const refs = extractReferences(blocks);
|
|
44
|
+
expect(refs.urls).toContain("https://example.com/docs");
|
|
45
|
+
expect(refs.urls).toContain("https://other.com/path");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("strips trailing closing paren from URLs", () => {
|
|
49
|
+
const blocks: NormalizedBlock[] = [
|
|
50
|
+
{ kind: "user", text: "Check the API (https://api.example.com/v2)" },
|
|
51
|
+
];
|
|
52
|
+
const refs = extractReferences(blocks);
|
|
53
|
+
expect(refs.urls).toContain("https://api.example.com/v2");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("extracts multiple URLs from one message", () => {
|
|
57
|
+
const blocks: NormalizedBlock[] = [
|
|
58
|
+
{ kind: "user", text: "See https://a.com and http://b.com and https://c.com" },
|
|
59
|
+
];
|
|
60
|
+
const refs = extractReferences(blocks);
|
|
61
|
+
expect(refs.urls).toHaveLength(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("deduplicates same URL across blocks", () => {
|
|
65
|
+
const blocks: NormalizedBlock[] = [
|
|
66
|
+
{ kind: "user", text: "Use https://example.com" },
|
|
67
|
+
{ kind: "assistant", text: "I checked https://example.com" },
|
|
68
|
+
];
|
|
69
|
+
const refs = extractReferences(blocks);
|
|
70
|
+
expect(refs.urls).toEqual(["https://example.com"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("extracts URLs from assistant blocks too", () => {
|
|
74
|
+
const blocks: NormalizedBlock[] = [
|
|
75
|
+
{ kind: "assistant", text: "The docs are at https://docs.example.com/api" },
|
|
76
|
+
];
|
|
77
|
+
const refs = extractReferences(blocks);
|
|
78
|
+
expect(refs.urls).toContain("https://docs.example.com/api");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("extracts URL from markdown link syntax", () => {
|
|
82
|
+
const blocks: NormalizedBlock[] = [
|
|
83
|
+
{ kind: "user", text: "Check [the docs](https://docs.example.com/guide) for details" },
|
|
84
|
+
];
|
|
85
|
+
const refs = extractReferences(blocks);
|
|
86
|
+
expect(refs.urls).toContain("https://docs.example.com/guide");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does NOT extract file:// URLs", () => {
|
|
90
|
+
const blocks: NormalizedBlock[] = [
|
|
91
|
+
{ kind: "user", text: "Open file:///path/to/file" },
|
|
92
|
+
];
|
|
93
|
+
const refs = extractReferences(blocks);
|
|
94
|
+
expect(refs.urls).not.toContain("file:///path/to/file");
|
|
95
|
+
expect(refs.urls).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("skips tool_result blocks for URL extraction", () => {
|
|
99
|
+
const blocks: NormalizedBlock[] = [
|
|
100
|
+
{ kind: "tool_result", name: "bash", text: "curl https://example.com/api\nResponse: 200 OK", isError: false },
|
|
101
|
+
];
|
|
102
|
+
const refs = extractReferences(blocks);
|
|
103
|
+
expect(refs.urls).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skips tool_call blocks for URL extraction", () => {
|
|
107
|
+
const blocks: NormalizedBlock[] = [
|
|
108
|
+
{ kind: "tool_call", name: "bash", args: { command: "curl https://example.com/api" } },
|
|
109
|
+
];
|
|
110
|
+
const refs = extractReferences(blocks);
|
|
111
|
+
expect(refs.urls).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("caps URLs at 10 entries", () => {
|
|
115
|
+
const urls = Array.from({ length: 15 }, (_, i) => `https://site${i}.com`);
|
|
116
|
+
const blocks: NormalizedBlock[] = [
|
|
117
|
+
{ kind: "user", text: urls.join(" ") },
|
|
118
|
+
];
|
|
119
|
+
const refs = extractReferences(blocks);
|
|
120
|
+
expect(refs.urls).toHaveLength(10);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── GitHub refs ──
|
|
124
|
+
|
|
125
|
+
it("extracts bare issue number #42", () => {
|
|
126
|
+
const blocks: NormalizedBlock[] = [
|
|
127
|
+
{ kind: "user", text: "Fix issue #42" },
|
|
128
|
+
];
|
|
129
|
+
const refs = extractReferences(blocks);
|
|
130
|
+
expect(refs.githubRefs).toContain("#42");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("extracts PR reference PR #7", () => {
|
|
134
|
+
const blocks: NormalizedBlock[] = [
|
|
135
|
+
{ kind: "user", text: "Merge PR #7" },
|
|
136
|
+
];
|
|
137
|
+
const refs = extractReferences(blocks);
|
|
138
|
+
expect(refs.githubRefs).toContain("PR #7");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("extracts pr#12 variant (normalized to 'pr #12')", () => {
|
|
142
|
+
const blocks: NormalizedBlock[] = [
|
|
143
|
+
{ kind: "user", text: "Review pr#12 when ready" },
|
|
144
|
+
];
|
|
145
|
+
const refs = extractReferences(blocks);
|
|
146
|
+
// The PR regex captures 'pr' and '12' separately, joining as 'pr #12'
|
|
147
|
+
expect(refs.githubRefs).toContain("pr #12");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("extracts owner/repo path", () => {
|
|
151
|
+
const blocks: NormalizedBlock[] = [
|
|
152
|
+
{ kind: "user", text: "The repo is at buihongduc132/pi-plugins" },
|
|
153
|
+
];
|
|
154
|
+
const refs = extractReferences(blocks);
|
|
155
|
+
expect(refs.githubRefs).toContain("buihongduc132/pi-plugins");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does NOT match file paths as owner/repo", () => {
|
|
159
|
+
const blocks: NormalizedBlock[] = [
|
|
160
|
+
{ kind: "user", text: "Edit src/components/button.tsx" },
|
|
161
|
+
];
|
|
162
|
+
const refs = extractReferences(blocks);
|
|
163
|
+
// src/components doesn't look like owner/repo (owner must be alnum with hyphens)
|
|
164
|
+
expect(refs.githubRefs.every(r => !r.startsWith("src/"))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("extracts full GitHub URL as both URL and github ref", () => {
|
|
168
|
+
const blocks: NormalizedBlock[] = [
|
|
169
|
+
{ kind: "user", text: "See https://github.com/owner/repo/issues/42" },
|
|
170
|
+
];
|
|
171
|
+
const refs = extractReferences(blocks);
|
|
172
|
+
expect(refs.urls).toContain("https://github.com/owner/repo/issues/42");
|
|
173
|
+
expect(refs.githubRefs).toContain("owner/repo#42");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("deduplicates github refs", () => {
|
|
177
|
+
const blocks: NormalizedBlock[] = [
|
|
178
|
+
{ kind: "user", text: "Fix #42" },
|
|
179
|
+
{ kind: "assistant", text: "Also related to #42" },
|
|
180
|
+
];
|
|
181
|
+
const refs = extractReferences(blocks);
|
|
182
|
+
expect(refs.githubRefs.filter(r => r === "#42")).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("caps github refs at 8 entries", () => {
|
|
186
|
+
const issues = Array.from({ length: 12 }, (_, i) => `#${i + 1}`).join(" ");
|
|
187
|
+
const blocks: NormalizedBlock[] = [
|
|
188
|
+
{ kind: "user", text: `Fix ${issues}` },
|
|
189
|
+
];
|
|
190
|
+
const refs = extractReferences(blocks);
|
|
191
|
+
expect(refs.githubRefs.length).toBeLessThanOrEqual(8);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Versions ──
|
|
195
|
+
|
|
196
|
+
it("extracts v1.2.3 version", () => {
|
|
197
|
+
const blocks: NormalizedBlock[] = [
|
|
198
|
+
{ kind: "user", text: "Upgrade to v1.2.3" },
|
|
199
|
+
];
|
|
200
|
+
const refs = extractReferences(blocks);
|
|
201
|
+
expect(refs.versions).toContain("v1.2.3");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("extracts v0.3.12 version", () => {
|
|
205
|
+
const blocks: NormalizedBlock[] = [
|
|
206
|
+
{ kind: "user", text: "We're at v0.3.12" },
|
|
207
|
+
];
|
|
208
|
+
const refs = extractReferences(blocks);
|
|
209
|
+
expect(refs.versions).toContain("v0.3.12");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("extracts bare semver 2.0.1", () => {
|
|
213
|
+
const blocks: NormalizedBlock[] = [
|
|
214
|
+
{ kind: "user", text: "The version is 2.0.1 now" },
|
|
215
|
+
];
|
|
216
|
+
const refs = extractReferences(blocks);
|
|
217
|
+
expect(refs.versions).toContain("2.0.1");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("does NOT match IP address octets as versions", () => {
|
|
221
|
+
const blocks: NormalizedBlock[] = [
|
|
222
|
+
{ kind: "user", text: "The server is at 192.168.1.100" },
|
|
223
|
+
];
|
|
224
|
+
const refs = extractReferences(blocks);
|
|
225
|
+
// 192.168.1 is not a version (no v prefix and in IP context), 1.100 not standalone
|
|
226
|
+
expect(refs.versions).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("deduplicates versions", () => {
|
|
230
|
+
const blocks: NormalizedBlock[] = [
|
|
231
|
+
{ kind: "user", text: "Using v1.0.0" },
|
|
232
|
+
{ kind: "assistant", text: "Confirmed v1.0.0" },
|
|
233
|
+
];
|
|
234
|
+
const refs = extractReferences(blocks);
|
|
235
|
+
expect(refs.versions).toEqual(["v1.0.0"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("caps versions at 5 entries", () => {
|
|
239
|
+
const versions = Array.from({ length: 8 }, (_, i) => `v${i}.0.0`).join(", ");
|
|
240
|
+
const blocks: NormalizedBlock[] = [
|
|
241
|
+
{ kind: "user", text: `Versions: ${versions}` },
|
|
242
|
+
];
|
|
243
|
+
const refs = extractReferences(blocks);
|
|
244
|
+
expect(refs.versions.length).toBeLessThanOrEqual(5);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── Branches ──
|
|
248
|
+
|
|
249
|
+
it("extracts feat/ branch", () => {
|
|
250
|
+
const blocks: NormalizedBlock[] = [
|
|
251
|
+
{ kind: "user", text: "Work on feat/new-auth module" },
|
|
252
|
+
];
|
|
253
|
+
const refs = extractReferences(blocks);
|
|
254
|
+
expect(refs.branches).toContain("feat/new-auth");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("extracts fix/ branch", () => {
|
|
258
|
+
const blocks: NormalizedBlock[] = [
|
|
259
|
+
{ kind: "user", text: "Check the fix/login-bug branch" },
|
|
260
|
+
];
|
|
261
|
+
const refs = extractReferences(blocks);
|
|
262
|
+
expect(refs.branches).toContain("fix/login-bug");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("extracts hotfix/, release/, chore/, refactor/, docs/ branches", () => {
|
|
266
|
+
const blocks: NormalizedBlock[] = [
|
|
267
|
+
{ kind: "user", text: "Branches: hotfix/urgent-fix, release/v2, chore/cleanup, refactor/api, docs/readme" },
|
|
268
|
+
];
|
|
269
|
+
const refs = extractReferences(blocks);
|
|
270
|
+
expect(refs.branches).toContain("hotfix/urgent-fix");
|
|
271
|
+
expect(refs.branches).toContain("release/v2");
|
|
272
|
+
expect(refs.branches).toContain("chore/cleanup");
|
|
273
|
+
expect(refs.branches).toContain("refactor/api");
|
|
274
|
+
expect(refs.branches).toContain("docs/readme");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("extracts test/ branch", () => {
|
|
278
|
+
const blocks: NormalizedBlock[] = [
|
|
279
|
+
{ kind: "user", text: "Work on test/unit-tests" },
|
|
280
|
+
];
|
|
281
|
+
const refs = extractReferences(blocks);
|
|
282
|
+
expect(refs.branches).toContain("test/unit-tests");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does NOT extract random word/something as branch", () => {
|
|
286
|
+
const blocks: NormalizedBlock[] = [
|
|
287
|
+
{ kind: "user", text: "Just some/random text" },
|
|
288
|
+
];
|
|
289
|
+
const refs = extractReferences(blocks);
|
|
290
|
+
expect(refs.branches).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("deduplicates branches", () => {
|
|
294
|
+
const blocks: NormalizedBlock[] = [
|
|
295
|
+
{ kind: "user", text: "On feat/new-auth" },
|
|
296
|
+
{ kind: "assistant", text: "Checking feat/new-auth" },
|
|
297
|
+
];
|
|
298
|
+
const refs = extractReferences(blocks);
|
|
299
|
+
expect(refs.branches).toEqual(["feat/new-auth"]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("caps branches at 5 entries", () => {
|
|
303
|
+
const branches = Array.from({ length: 8 }, (_, i) => `feat/feature-${i}`).join(" ");
|
|
304
|
+
const blocks: NormalizedBlock[] = [
|
|
305
|
+
{ kind: "user", text: branches },
|
|
306
|
+
];
|
|
307
|
+
const refs = extractReferences(blocks);
|
|
308
|
+
expect(refs.branches.length).toBeLessThanOrEqual(5);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ── Commit refs ──
|
|
312
|
+
|
|
313
|
+
it("extracts 7-char hex commit ref", () => {
|
|
314
|
+
const blocks: NormalizedBlock[] = [
|
|
315
|
+
{ kind: "user", text: "This was fixed in abc1234" },
|
|
316
|
+
];
|
|
317
|
+
const refs = extractReferences(blocks);
|
|
318
|
+
expect(refs.commitRefs).toContain("abc1234");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("extracts 12-char hex commit ref", () => {
|
|
322
|
+
const blocks: NormalizedBlock[] = [
|
|
323
|
+
{ kind: "user", text: "See commit abc123456789" },
|
|
324
|
+
];
|
|
325
|
+
const refs = extractReferences(blocks);
|
|
326
|
+
expect(refs.commitRefs).toContain("abc123456789");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("does NOT extract hex from tool_result blocks", () => {
|
|
330
|
+
const blocks: NormalizedBlock[] = [
|
|
331
|
+
{ kind: "tool_result", name: "bash", text: "[main abc1234] fix: login bug", isError: false },
|
|
332
|
+
];
|
|
333
|
+
const refs = extractReferences(blocks);
|
|
334
|
+
expect(refs.commitRefs).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("deduplicates commit refs", () => {
|
|
338
|
+
const blocks: NormalizedBlock[] = [
|
|
339
|
+
{ kind: "user", text: "Fixed in abc1234" },
|
|
340
|
+
{ kind: "assistant", text: "abc1234 was the fix commit" },
|
|
341
|
+
];
|
|
342
|
+
const refs = extractReferences(blocks);
|
|
343
|
+
expect(refs.commitRefs).toEqual(["abc1234"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("caps commit refs at 5 entries", () => {
|
|
347
|
+
const commits = Array.from({ length: 8 }, (_, i) =>
|
|
348
|
+
`abc${i.toString().padStart(4, "0")}`
|
|
349
|
+
).join(" ");
|
|
350
|
+
const blocks: NormalizedBlock[] = [
|
|
351
|
+
{ kind: "user", text: commits },
|
|
352
|
+
];
|
|
353
|
+
const refs = extractReferences(blocks);
|
|
354
|
+
expect(refs.commitRefs.length).toBeLessThanOrEqual(5);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── General skipping ──
|
|
358
|
+
|
|
359
|
+
it("skips thinking blocks", () => {
|
|
360
|
+
const blocks: NormalizedBlock[] = [
|
|
361
|
+
{ kind: "thinking", text: "Check https://example.com and #42", redacted: false },
|
|
362
|
+
];
|
|
363
|
+
const refs = extractReferences(blocks);
|
|
364
|
+
expect(refs.urls).toHaveLength(0);
|
|
365
|
+
expect(refs.githubRefs).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("returns all-zeros for empty blocks array", () => {
|
|
369
|
+
const refs = extractReferences([]);
|
|
370
|
+
expect(refs).toEqual({
|
|
371
|
+
urls: [],
|
|
372
|
+
githubRefs: [],
|
|
373
|
+
versions: [],
|
|
374
|
+
branches: [],
|
|
375
|
+
commitRefs: [],
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ── formatReferences ──
|
|
381
|
+
|
|
382
|
+
describe("formatReferences", () => {
|
|
383
|
+
it("returns empty array when no references", () => {
|
|
384
|
+
expect(formatReferences({
|
|
385
|
+
urls: [],
|
|
386
|
+
githubRefs: [],
|
|
387
|
+
versions: [],
|
|
388
|
+
branches: [],
|
|
389
|
+
commitRefs: [],
|
|
390
|
+
})).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("formats URLs with prefix", () => {
|
|
394
|
+
const lines = formatReferences({
|
|
395
|
+
urls: ["https://example.com"],
|
|
396
|
+
githubRefs: [],
|
|
397
|
+
versions: [],
|
|
398
|
+
branches: [],
|
|
399
|
+
commitRefs: [],
|
|
400
|
+
});
|
|
401
|
+
expect(lines).toEqual(["URL: https://example.com"]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("formats GitHub refs with prefix", () => {
|
|
405
|
+
const lines = formatReferences({
|
|
406
|
+
urls: [],
|
|
407
|
+
githubRefs: ["#42", "buihongduc132/pi-plugins"],
|
|
408
|
+
versions: [],
|
|
409
|
+
branches: [],
|
|
410
|
+
commitRefs: [],
|
|
411
|
+
});
|
|
412
|
+
expect(lines).toContain("GitHub: #42, buihongduc132/pi-plugins");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("formats versions with prefix", () => {
|
|
416
|
+
const lines = formatReferences({
|
|
417
|
+
urls: [],
|
|
418
|
+
githubRefs: [],
|
|
419
|
+
versions: ["v1.2.3"],
|
|
420
|
+
branches: [],
|
|
421
|
+
commitRefs: [],
|
|
422
|
+
});
|
|
423
|
+
expect(lines).toContain("Version: v1.2.3");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("formats branches with prefix", () => {
|
|
427
|
+
const lines = formatReferences({
|
|
428
|
+
urls: [],
|
|
429
|
+
githubRefs: [],
|
|
430
|
+
versions: [],
|
|
431
|
+
branches: ["feat/new-auth"],
|
|
432
|
+
commitRefs: [],
|
|
433
|
+
});
|
|
434
|
+
expect(lines).toContain("Branch: feat/new-auth");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("formats commit refs with prefix", () => {
|
|
438
|
+
const lines = formatReferences({
|
|
439
|
+
urls: [],
|
|
440
|
+
githubRefs: [],
|
|
441
|
+
versions: [],
|
|
442
|
+
branches: [],
|
|
443
|
+
commitRefs: ["abc1234"],
|
|
444
|
+
});
|
|
445
|
+
expect(lines).toContain("CommitRef: abc1234");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("combines multiple categories", () => {
|
|
449
|
+
const lines = formatReferences({
|
|
450
|
+
urls: ["https://example.com", "https://other.com"],
|
|
451
|
+
githubRefs: ["#42"],
|
|
452
|
+
versions: ["v1.0.0"],
|
|
453
|
+
branches: ["feat/new"],
|
|
454
|
+
commitRefs: ["abc1234"],
|
|
455
|
+
});
|
|
456
|
+
expect(lines).toHaveLength(5);
|
|
457
|
+
expect(lines[0]).toBe("URL: https://example.com, https://other.com");
|
|
458
|
+
expect(lines[1]).toBe("GitHub: #42");
|
|
459
|
+
expect(lines[2]).toBe("Version: v1.0.0");
|
|
460
|
+
expect(lines[3]).toBe("Branch: feat/new");
|
|
461
|
+
expect(lines[4]).toBe("CommitRef: abc1234");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("skips empty categories entirely", () => {
|
|
465
|
+
const lines = formatReferences({
|
|
466
|
+
urls: ["https://example.com"],
|
|
467
|
+
githubRefs: [],
|
|
468
|
+
versions: [],
|
|
469
|
+
branches: [],
|
|
470
|
+
commitRefs: [],
|
|
471
|
+
});
|
|
472
|
+
expect(lines).toHaveLength(1);
|
|
473
|
+
expect(lines[0]).toBe("URL: https://example.com");
|
|
474
|
+
});
|
|
475
|
+
});
|