@askjo/pi-reflect 1.0.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.
@@ -0,0 +1,174 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import {
4
+ resolvePath,
5
+ formatTimestamp,
6
+ escapeRegex,
7
+ truncateText,
8
+ projectNameFromDir,
9
+ } from "../extensions/reflect.js";
10
+
11
+ describe("resolvePath", () => {
12
+ it("expands ~ to HOME", () => {
13
+ const home = process.env.HOME!;
14
+ assert.equal(resolvePath("~/foo/bar"), `${home}/foo/bar`);
15
+ });
16
+
17
+ it("expands ~/", () => {
18
+ const home = process.env.HOME!;
19
+ assert.equal(resolvePath("~/.pi/agent"), `${home}/.pi/agent`);
20
+ });
21
+
22
+ it("resolves absolute paths as-is", () => {
23
+ assert.equal(resolvePath("/tmp/test"), "/tmp/test");
24
+ });
25
+
26
+ it("resolves relative paths against cwd", () => {
27
+ const result = resolvePath("foo/bar");
28
+ assert.ok(result.startsWith("/"));
29
+ assert.ok(result.endsWith("foo/bar"));
30
+ });
31
+ });
32
+
33
+ describe("formatTimestamp", () => {
34
+ it("returns a 15-char string", () => {
35
+ const ts = formatTimestamp();
36
+ assert.equal(ts.length, 15);
37
+ });
38
+
39
+ it("contains no colons or periods", () => {
40
+ const ts = formatTimestamp();
41
+ assert.ok(!ts.includes(":"));
42
+ assert.ok(!ts.includes("."));
43
+ });
44
+
45
+ it("starts with a year", () => {
46
+ const ts = formatTimestamp();
47
+ assert.match(ts, /^20\d{2}/);
48
+ });
49
+
50
+ it("contains underscore separator between date and time", () => {
51
+ const ts = formatTimestamp();
52
+ assert.ok(ts.includes("_"));
53
+ });
54
+ });
55
+
56
+ describe("escapeRegex", () => {
57
+ it("escapes dots", () => {
58
+ assert.equal(escapeRegex("a.b"), "a\\.b");
59
+ });
60
+
61
+ it("escapes parentheses", () => {
62
+ assert.equal(escapeRegex("(foo)"), "\\(foo\\)");
63
+ });
64
+
65
+ it("escapes brackets", () => {
66
+ assert.equal(escapeRegex("[a]"), "\\[a\\]");
67
+ });
68
+
69
+ it("escapes all special regex chars", () => {
70
+ const specials = ".*+?^${}()|[]\\";
71
+ const escaped = escapeRegex(specials);
72
+ // Every special char should be preceded by a backslash
73
+ for (const ch of specials) {
74
+ assert.ok(escaped.includes(`\\${ch}`), `Expected \\${ch} in ${escaped}`);
75
+ }
76
+ });
77
+
78
+ it("leaves normal text unchanged", () => {
79
+ assert.equal(escapeRegex("hello world"), "hello world");
80
+ });
81
+
82
+ it("handles empty string", () => {
83
+ assert.equal(escapeRegex(""), "");
84
+ });
85
+
86
+ it("escaped string works in RegExp constructor", () => {
87
+ const dangerous = "price is $100.00 (USD)";
88
+ const re = new RegExp(escapeRegex(dangerous));
89
+ assert.ok(re.test(dangerous));
90
+ assert.ok(!re.test("price is X100Y00 ZUSDW")); // shouldn't match with unescaped
91
+ });
92
+ });
93
+
94
+ describe("truncateText", () => {
95
+ it("returns null for null input", () => {
96
+ assert.equal(truncateText(null, 100), null);
97
+ });
98
+
99
+ it("returns text unchanged when under limit", () => {
100
+ assert.equal(truncateText("short", 100), "short");
101
+ });
102
+
103
+ it("returns text unchanged when exactly at limit", () => {
104
+ const text = "a".repeat(100);
105
+ assert.equal(truncateText(text, 100), text);
106
+ });
107
+
108
+ it("truncates text over limit", () => {
109
+ const text = "a".repeat(200);
110
+ const result = truncateText(text, 100)!;
111
+ assert.ok(result.startsWith("a".repeat(100)));
112
+ assert.ok(result.includes("[...truncated"));
113
+ });
114
+
115
+ it("includes omitted char count in truncation message", () => {
116
+ const text = "a".repeat(200);
117
+ const result = truncateText(text, 100)!;
118
+ assert.ok(result.includes("100 chars omitted"));
119
+ });
120
+
121
+ it("handles limit of 0", () => {
122
+ const result = truncateText("hello", 0)!;
123
+ assert.ok(result.includes("truncated"));
124
+ assert.ok(result.includes("5 chars omitted"));
125
+ });
126
+ });
127
+
128
+ describe("projectNameFromDir", () => {
129
+ const user = process.env.USER ?? "user";
130
+
131
+ it("strips macOS home prefix", () => {
132
+ // pi uses -- as path separator, single dashes are part of names
133
+ const dirname = `--Users-${user}-personal--myproject`;
134
+ assert.equal(projectNameFromDir(dirname), "personal/myproject");
135
+ });
136
+
137
+ it("strips Linux home prefix", () => {
138
+ const dirname = `--home-${user}-projects--myapp`;
139
+ assert.equal(projectNameFromDir(dirname), "projects/myapp");
140
+ });
141
+
142
+ it("preserves single dashes within path segments", () => {
143
+ const dirname = `--Users-${user}-my-project`;
144
+ // Single dash is part of the name, not a separator
145
+ assert.equal(projectNameFromDir(dirname), "my-project");
146
+ });
147
+
148
+ it("converts double dashes to slashes", () => {
149
+ const dirname = `--Users-${user}-a--b--c`;
150
+ assert.equal(projectNameFromDir(dirname), "a/b/c");
151
+ });
152
+
153
+ it("returns 'workspace' for empty result after stripping", () => {
154
+ const dirname = `--Users-${user}-`;
155
+ assert.equal(projectNameFromDir(dirname), "workspace");
156
+ });
157
+
158
+ it("returns dirname as-is when no prefix matches", () => {
159
+ const dirname = "some-random-dirname";
160
+ assert.equal(projectNameFromDir(dirname), "some-random-dirname");
161
+ });
162
+
163
+ it("handles nested project paths", () => {
164
+ const dirname = `--Users-${user}-personal--workspace--jo_bot`;
165
+ assert.equal(projectNameFromDir(dirname), "personal/workspace/jo_bot");
166
+ });
167
+
168
+ it("strips leading/trailing dashes and slashes from result", () => {
169
+ const dirname = `--Users-${user}---leading`;
170
+ const result = projectNameFromDir(dirname);
171
+ assert.ok(!result.startsWith("/"));
172
+ assert.ok(!result.startsWith("-"));
173
+ });
174
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Test helpers — temp directories, fixture builders, mock sessions.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+
9
+ export function makeTempDir(): string {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-reflect-test-"));
11
+ }
12
+
13
+ export function cleanup(dir: string): void {
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ }
16
+
17
+ /**
18
+ * Build a JSONL session file from a simple exchange list.
19
+ * Each exchange is { role, text?, thinking? }.
20
+ */
21
+ export function buildSessionJsonl(exchanges: Array<{ role: string; text?: string; thinking?: string }>): string {
22
+ const lines: string[] = [];
23
+ for (const ex of exchanges) {
24
+ const content: any[] = [];
25
+ if (ex.text) content.push({ type: "text", text: ex.text });
26
+ if (ex.thinking) content.push({ type: "thinking", thinking: ex.thinking });
27
+ lines.push(JSON.stringify({
28
+ type: "message",
29
+ message: { role: ex.role, content },
30
+ }));
31
+ }
32
+ return lines.join("\n") + "\n";
33
+ }
34
+
35
+ /**
36
+ * Create a fake sessions directory structure for collectTranscripts testing.
37
+ * Returns the sessions dir path.
38
+ *
39
+ * sessionsDir/
40
+ * --Users-<user>-project-name/
41
+ * 2026-02-12T03:00:00.000Z.jsonl
42
+ */
43
+ export function createSessionFixture(
44
+ baseDir: string,
45
+ opts: {
46
+ projectDirName: string;
47
+ fileName: string;
48
+ exchanges: Array<{ role: string; text?: string; thinking?: string }>;
49
+ }[],
50
+ ): string {
51
+ const sessionsDir = path.join(baseDir, "sessions");
52
+ for (const opt of opts) {
53
+ const projectDir = path.join(sessionsDir, opt.projectDirName);
54
+ fs.mkdirSync(projectDir, { recursive: true });
55
+ const content = buildSessionJsonl(opt.exchanges);
56
+ fs.writeFileSync(path.join(projectDir, opt.fileName), content, "utf-8");
57
+ }
58
+ return sessionsDir;
59
+ }
60
+
61
+ /**
62
+ * A realistic AGENTS.md fixture for edit testing.
63
+ */
64
+ export const SAMPLE_AGENTS_MD = `# Agent Guide
65
+
66
+ ## Communication Style
67
+ - Maintain a professional demeanor
68
+ - Avoid excessive enthusiasm or positivity
69
+
70
+ ## Read Before Acting
71
+ - **ALWAYS read existing code before writing any code**. The #1 source of rework is acting before understanding.
72
+ - **Check if functionality already exists**: Before implementing, search docs/code/examples first.
73
+ - **Verify assumptions**: Before implementing, verify that variable names, function signatures, file paths actually exist.
74
+
75
+ ## Rules
76
+ - **Execute first, explain minimally**: Do the work, then say what you did in 1-2 sentences.
77
+ - **Don't ask clarifying questions when the directive is clear**: If the user gives a specific command, execute it.
78
+ - **ANTI-OVER-ENGINEERING**: Implement EXACTLY what was asked for. Do NOT add "helpful" additional complexity.
79
+ - **Keep code DRY**: NEVER duplicate logic.
80
+
81
+ ## Deployment
82
+ - **NEVER push to main/master**: Pushing to main triggers production deployment.
83
+ - **Deploy by pushing to main/master**: All deployments happen automatically via CI/CD.
84
+ `;
@@ -0,0 +1,85 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import { buildReflectionPrompt } from "../extensions/reflect.js";
4
+ import { SAMPLE_AGENTS_MD } from "./helpers.js";
5
+
6
+ describe("buildReflectionPrompt", () => {
7
+ it("includes the target file name", () => {
8
+ const prompt = buildReflectionPrompt("/path/to/AGENTS.md", SAMPLE_AGENTS_MD, "transcripts here");
9
+ assert.ok(prompt.includes("AGENTS.md"));
10
+ });
11
+
12
+ it("includes the target file content in <target_file> tags", () => {
13
+ const prompt = buildReflectionPrompt("/path/to/AGENTS.md", SAMPLE_AGENTS_MD, "transcripts");
14
+ assert.ok(prompt.includes("<target_file>"));
15
+ assert.ok(prompt.includes("</target_file>"));
16
+ assert.ok(prompt.includes("ANTI-OVER-ENGINEERING"));
17
+ });
18
+
19
+ it("includes transcripts in <transcripts> tags", () => {
20
+ const transcriptContent = "### Session: project [2026-02-12]\n\n**USER:** fix the bug\n";
21
+ const prompt = buildReflectionPrompt("/AGENTS.md", SAMPLE_AGENTS_MD, transcriptContent);
22
+ assert.ok(prompt.includes("<transcripts>"));
23
+ assert.ok(prompt.includes("</transcripts>"));
24
+ assert.ok(prompt.includes("fix the bug"));
25
+ });
26
+
27
+ it("instructs JSON-only output", () => {
28
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
29
+ assert.ok(prompt.includes("single JSON object"));
30
+ assert.ok(prompt.includes("No markdown, no explanation, no preamble"));
31
+ });
32
+
33
+ it("describes the strengthen edit type", () => {
34
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
35
+ assert.ok(prompt.includes('"strengthen"'));
36
+ assert.ok(prompt.includes("old_text"));
37
+ assert.ok(prompt.includes("character-for-character"));
38
+ });
39
+
40
+ it("describes the add edit type", () => {
41
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
42
+ assert.ok(prompt.includes('"add"'));
43
+ assert.ok(prompt.includes("after_text"));
44
+ });
45
+
46
+ it("mentions duplication prevention", () => {
47
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
48
+ assert.ok(prompt.includes("Never duplicate content"));
49
+ });
50
+
51
+ it("requires 2+ occurrences for new rules", () => {
52
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
53
+ assert.ok(prompt.includes("2+ occurrences"));
54
+ });
55
+
56
+ it("lists correction signals to look for", () => {
57
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
58
+ assert.ok(prompt.includes("bro"));
59
+ assert.ok(prompt.includes("wtf"));
60
+ assert.ok(prompt.includes("actually"));
61
+ });
62
+
63
+ it("warns about false positives", () => {
64
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
65
+ assert.ok(prompt.includes("no worries"));
66
+ assert.ok(prompt.includes("NOT corrections"));
67
+ });
68
+
69
+ it("uses basename, not full path in prompt", () => {
70
+ const prompt = buildReflectionPrompt("/very/long/path/to/RULES.md", "content", "transcripts");
71
+ assert.ok(prompt.includes("RULES.md"));
72
+ // Should not leak full filesystem path into the prompt
73
+ assert.ok(!prompt.includes("/very/long/path/to/RULES.md") || prompt.includes("Target file: RULES.md"));
74
+ });
75
+
76
+ it("includes expected JSON structure with all fields", () => {
77
+ const prompt = buildReflectionPrompt("/AGENTS.md", "content", "transcripts");
78
+ assert.ok(prompt.includes('"corrections_found"'));
79
+ assert.ok(prompt.includes('"sessions_with_corrections"'));
80
+ assert.ok(prompt.includes('"edits"'));
81
+ assert.ok(prompt.includes('"patterns_not_added"'));
82
+ assert.ok(prompt.includes('"summary"'));
83
+ assert.ok(prompt.includes('"reason"'));
84
+ });
85
+ });
@@ -0,0 +1,227 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import { applyEdits, type AnalysisEdit } from "../extensions/reflect.js";
4
+ import { SAMPLE_AGENTS_MD } from "./helpers.js";
5
+
6
+ /**
7
+ * Tests that simulate realistic LLM-generated edit payloads against AGENTS.md.
8
+ * These are the exact patterns the extension encounters in production.
9
+ */
10
+
11
+ describe("realistic LLM edit patterns", () => {
12
+ it("strengthens a rule with added emphasis and examples", () => {
13
+ const edits: AnalysisEdit[] = [{
14
+ type: "strengthen",
15
+ section: "Read Before Acting",
16
+ old_text: "- **ALWAYS read existing code before writing any code**. The #1 source of rework is acting before understanding.",
17
+ new_text: '- **ALWAYS read existing code before writing any code**. The #1 source of rework is acting before understanding. If a file, doc, or plan is referenced — read it completely first. If a bug is reported — read the logs first.',
18
+ reason: "Agent repeatedly started implementing before reading existing code in 5 sessions",
19
+ }];
20
+ const { result, applied } = applyEdits(SAMPLE_AGENTS_MD, edits);
21
+ assert.equal(applied, 1);
22
+ assert.ok(result.includes("read the logs first"));
23
+ // Ensure no duplication of the original rule
24
+ const matches = result.match(/ALWAYS read existing code/g);
25
+ assert.equal(matches?.length, 1);
26
+ });
27
+
28
+ it("adds a new rule after an existing one", () => {
29
+ const edits: AnalysisEdit[] = [{
30
+ type: "add",
31
+ section: "Rules",
32
+ after_text: '- **ANTI-OVER-ENGINEERING**: Implement EXACTLY what was asked for. Do NOT add "helpful" additional complexity.',
33
+ new_text: '- **3-Attempt Rule**: If a fix fails 3 times, STOP. Try a fundamentally different approach or ask the user.',
34
+ reason: "Agent spiraled into 5+ attempts on the same approach in multiple sessions",
35
+ }];
36
+ const { result, applied } = applyEdits(SAMPLE_AGENTS_MD, edits);
37
+ assert.equal(applied, 1);
38
+ assert.ok(result.includes("3-Attempt Rule"));
39
+ // Verify ordering
40
+ const overEngIdx = result.indexOf("ANTI-OVER-ENGINEERING");
41
+ const newIdx = result.indexOf("3-Attempt Rule");
42
+ assert.ok(newIdx > overEngIdx);
43
+ });
44
+
45
+ it("handles a batch of mixed edits like a real LLM response", () => {
46
+ const edits: AnalysisEdit[] = [
47
+ {
48
+ type: "strengthen",
49
+ section: "Communication Style",
50
+ old_text: "- Avoid excessive enthusiasm or positivity",
51
+ new_text: '- Avoid excessive enthusiasm or positivity. When the user says "continue", that means get on with the work.',
52
+ },
53
+ {
54
+ type: "add",
55
+ section: "Read Before Acting",
56
+ after_text: "- **Verify assumptions**: Before implementing, verify that variable names, function signatures, file paths actually exist.",
57
+ new_text: "- **Read recent commits when debugging**: ALWAYS run `git log --oneline -10` before diagnosing issues. The user expects you to know what changed recently.",
58
+ },
59
+ {
60
+ type: "strengthen",
61
+ section: "Rules",
62
+ old_text: '- **Don\'t ask clarifying questions when the directive is clear**: If the user gives a specific command, execute it.',
63
+ new_text: '- **Don\'t ask clarifying questions when the directive is clear**: If the user gives a specific command, execute it. Don\'t present multiple-choice options when a single obvious action exists.',
64
+ },
65
+ ];
66
+ const { result, applied, skipped } = applyEdits(SAMPLE_AGENTS_MD, edits);
67
+ assert.equal(applied, 3);
68
+ assert.equal(skipped.length, 0);
69
+ assert.ok(result.includes("get on with the work"));
70
+ assert.ok(result.includes("git log --oneline -10"));
71
+ assert.ok(result.includes("multiple-choice options"));
72
+ });
73
+
74
+ it("rejects an edit that would create duplication (old_text > 50 chars)", () => {
75
+ // Simulates an LLM that accidentally repeats the old content in the replacement
76
+ // The duplication check only triggers when old_text > 50 chars
77
+ const longRule = "- **ALWAYS read existing code before writing any code**. The #1 source of rework is acting before understanding.";
78
+ const content = `## Rules\n\n${longRule}\n- **Other rule**: Do Y.\n`;
79
+ const edits: AnalysisEdit[] = [{
80
+ type: "strengthen",
81
+ old_text: longRule,
82
+ new_text: longRule + " " + longRule, // obvious duplication
83
+ }];
84
+ const { applied, skipped } = applyEdits(content, edits);
85
+ assert.equal(applied, 0);
86
+ assert.equal(skipped.length, 1);
87
+ assert.ok(skipped[0].includes("Duplication"));
88
+ });
89
+
90
+ it("does not catch duplication when old_text < 50 chars (by design)", () => {
91
+ // Short old_text bypasses the duplication check — this is expected behavior
92
+ // because short snippets appearing twice is less likely to be a real problem
93
+ const content = "## Rules\n\n- **Rule A**: Do X.\n- **Rule B**: Do Y.\n";
94
+ const edits: AnalysisEdit[] = [{
95
+ type: "strengthen",
96
+ old_text: "- **Rule A**: Do X.",
97
+ new_text: "- **Rule A**: Do X. - **Rule A**: Do X.", // duplicated but short
98
+ }];
99
+ const { applied } = applyEdits(content, edits);
100
+ assert.equal(applied, 1); // passes because < 50 chars
101
+ });
102
+
103
+ it("handles real-world multiline bullet points", () => {
104
+ const content = [
105
+ "## Rules",
106
+ "- **NEVER push to main/master**: Pushing to main triggers production deployment.",
107
+ " Only the user pushes. Agents may commit on branches, build artifacts.",
108
+ "- **Keep code DRY**: NEVER duplicate logic.",
109
+ ].join("\n");
110
+
111
+ const edits: AnalysisEdit[] = [{
112
+ type: "strengthen",
113
+ old_text: "- **NEVER push to main/master**: Pushing to main triggers production deployment.\n Only the user pushes. Agents may commit on branches, build artifacts.",
114
+ new_text: "- **NEVER push to main/master**: Pushing to main triggers production deployment.\n Only the user pushes. Agents may commit on branches, build artifacts.\n Even if the user's instructions seem to imply pushing, stop and confirm first.",
115
+ }];
116
+ const { result, applied } = applyEdits(content, edits);
117
+ assert.equal(applied, 1);
118
+ assert.ok(result.includes("stop and confirm first"));
119
+ });
120
+
121
+ it("preserves file structure when adding at the end of a section", () => {
122
+ const edits: AnalysisEdit[] = [{
123
+ type: "add",
124
+ after_text: "- **Deploy by pushing to main/master**: All deployments happen automatically via CI/CD.",
125
+ new_text: "- **Verify deployment completion**: After pushing, check logs to confirm the deploy succeeded.",
126
+ }];
127
+ const { result, applied } = applyEdits(SAMPLE_AGENTS_MD, edits);
128
+ assert.equal(applied, 1);
129
+ // The new rule should be the last line (after Deployment section's last rule)
130
+ assert.ok(result.includes("Verify deployment completion"));
131
+ });
132
+
133
+ it("idempotent: running the same add edit twice fails on second run (dedup)", () => {
134
+ const edits: AnalysisEdit[] = [{
135
+ type: "add",
136
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
137
+ new_text: "- **New rule**: Something new.",
138
+ }];
139
+
140
+ // First application
141
+ const { result: result1, applied: applied1 } = applyEdits(SAMPLE_AGENTS_MD, edits);
142
+ assert.equal(applied1, 1);
143
+
144
+ // Second application on the modified content
145
+ const { applied: applied2, skipped: skipped2 } = applyEdits(result1, edits);
146
+ assert.equal(applied2, 0);
147
+ assert.ok(skipped2[0].includes("already exists"));
148
+ });
149
+
150
+ it("handles edits with special markdown characters", () => {
151
+ const content = '- **Use `backticks` for code**: Always wrap code in backticks.\n- Other rule.';
152
+ const edits: AnalysisEdit[] = [{
153
+ type: "strengthen",
154
+ old_text: '- **Use `backticks` for code**: Always wrap code in backticks.',
155
+ new_text: '- **Use `backticks` for code**: Always wrap code in backticks. Use triple backticks for multi-line blocks.',
156
+ }];
157
+ const { applied } = applyEdits(content, edits);
158
+ assert.equal(applied, 1);
159
+ });
160
+
161
+ it("handles edits near markdown headers", () => {
162
+ const content = "## Section A\n\n- Rule under A.\n\n## Section B\n\n- Rule under B.";
163
+ const edits: AnalysisEdit[] = [{
164
+ type: "add",
165
+ after_text: "- Rule under A.",
166
+ new_text: "- New rule under A.",
167
+ }];
168
+ const { result, applied } = applyEdits(content, edits);
169
+ assert.equal(applied, 1);
170
+ // Verify the new rule is between Section A and Section B
171
+ const ruleIdx = result.indexOf("New rule under A.");
172
+ const sectionBIdx = result.indexOf("## Section B");
173
+ assert.ok(ruleIdx < sectionBIdx);
174
+ });
175
+ });
176
+
177
+ describe("edge cases in edit content", () => {
178
+ it("handles old_text that is a substring of another rule", () => {
179
+ // Two rules where one is a prefix of the other
180
+ const content = "- **Rule**: Short.\n- **Rule**: Short. But also long with extra text.";
181
+ const edits: AnalysisEdit[] = [{
182
+ type: "strengthen",
183
+ old_text: "- **Rule**: Short.",
184
+ new_text: "- **Rule**: Short, improved.",
185
+ }];
186
+ // "- **Rule**: Short." appears in both lines, so this should be ambiguous
187
+ const { applied, skipped } = applyEdits(content, edits);
188
+ assert.equal(applied, 0);
189
+ assert.ok(skipped[0].includes("Ambiguous"));
190
+ });
191
+
192
+ it("handles new_text with trailing newlines", () => {
193
+ const content = "- Rule A.\n- Rule B.";
194
+ const edits: AnalysisEdit[] = [{
195
+ type: "add",
196
+ after_text: "- Rule A.",
197
+ new_text: "- New rule.\n",
198
+ }];
199
+ const { result, applied } = applyEdits(content, edits);
200
+ assert.equal(applied, 1);
201
+ // Should not create triple newlines
202
+ assert.ok(!result.includes("\n\n\n"));
203
+ });
204
+
205
+ it("handles unicode in edit text", () => {
206
+ const content = "- **Rule**: Use → arrows and • bullets.";
207
+ const edits: AnalysisEdit[] = [{
208
+ type: "strengthen",
209
+ old_text: "- **Rule**: Use → arrows and • bullets.",
210
+ new_text: "- **Rule**: Use → arrows, • bullets, and — dashes.",
211
+ }];
212
+ const { applied } = applyEdits(content, edits);
213
+ assert.equal(applied, 1);
214
+ });
215
+
216
+ it("handles very long edit text (>1000 chars)", () => {
217
+ const longRule = "- **Long rule**: " + "word ".repeat(200);
218
+ const content = longRule + "\n- Short rule.";
219
+ const edits: AnalysisEdit[] = [{
220
+ type: "strengthen",
221
+ old_text: longRule,
222
+ new_text: longRule + " Plus more words at the end.",
223
+ }];
224
+ const { applied } = applyEdits(content, edits);
225
+ assert.equal(applied, 1);
226
+ });
227
+ });