@cruxy/cli 0.27.0 → 0.28.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.
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath } from "./paths.js";
5
- import { countOccurrences } from "./edit-file.js";
5
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
8
8
  const HunkSchema = z.object({
@@ -143,25 +143,29 @@ async function planOp(i, op, abs, ctx) {
143
143
  }
144
144
  return { ok: false, error: opError(i, op, err.message) };
145
145
  }
146
+ // Detect the file's line ending once, from the original bytes, so every hunk
147
+ // re-encodes newStr to the same convention as content mutates across hunks.
148
+ const fileEol = detectEol(content);
146
149
  for (let h = 0; h < op.hunks.length; h++) {
147
150
  const { oldStr, newStr } = op.hunks[h];
148
- const matches = countOccurrences(content, oldStr);
149
- if (matches === 0) {
151
+ const match = findMatch(content, oldStr);
152
+ if (match.kind === "none") {
150
153
  return {
151
154
  ok: false,
152
155
  error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
153
156
  };
154
157
  }
155
- if (matches > 1) {
158
+ if (match.kind === "ambiguous") {
156
159
  return {
157
160
  ok: false,
158
- error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${matches} matches)`),
161
+ error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
159
162
  };
160
163
  }
161
- // Replace by index so `$` patterns in newStr aren't interpreted.
162
- const idx = content.indexOf(oldStr);
164
+ // Splice by offset so `$` patterns in newStr aren't interpreted.
163
165
  content =
164
- content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
166
+ content.slice(0, match.start) +
167
+ applyEol(newStr, fileEol) +
168
+ content.slice(match.end);
165
169
  }
166
170
  return {
167
171
  ok: true,
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
2
  import type { Tool } from "../types.js";
3
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
4
- export declare function countOccurrences(haystack: string, needle: string): number;
5
3
  /**
6
4
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
5
  * The uniqueness requirement is checked before approval so the model can fix an
@@ -1,16 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
5
- export function countOccurrences(haystack, needle) {
6
- let count = 0;
7
- let i = haystack.indexOf(needle);
8
- while (i !== -1) {
9
- count++;
10
- i = haystack.indexOf(needle, i + needle.length);
11
- }
12
- return count;
13
- }
4
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
14
5
  /**
15
6
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
16
7
  * The uniqueness requirement is checked before approval so the model can fix an
@@ -47,14 +38,14 @@ export const editFileTool = {
47
38
  }
48
39
  return { ok: false, error: err.message };
49
40
  }
50
- const matches = countOccurrences(content, input.old_str);
51
- if (matches === 0) {
41
+ const match = findMatch(content, input.old_str);
42
+ if (match.kind === "none") {
52
43
  return { ok: false, error: `old_str not found in ${input.path}` };
53
44
  }
54
- if (matches > 1) {
45
+ if (match.kind === "ambiguous") {
55
46
  return {
56
47
  ok: false,
57
- error: `old_str not unique (${matches} matches); add surrounding context to disambiguate`,
48
+ error: `old_str not unique (${match.count} matches${tierLabel(match.tier)}); add surrounding context to disambiguate`,
58
49
  };
59
50
  }
60
51
  const decision = await ctx.requestApproval({
@@ -68,11 +59,11 @@ export const editFileTool = {
68
59
  error: decision.feedback ?? `edit to ${input.path} denied`,
69
60
  };
70
61
  }
71
- // Replace the single occurrence by index to avoid `$`-pattern interpretation.
72
- const idx = content.indexOf(input.old_str);
73
- const updated = content.slice(0, idx) +
74
- input.new_str +
75
- content.slice(idx + input.old_str.length);
62
+ // Splice the matched span by offset (avoids `$`-pattern interpretation) and
63
+ // re-encode new_str to the file's line ending so a CRLF file stays CRLF.
64
+ const updated = content.slice(0, match.start) +
65
+ applyEol(input.new_str, detectEol(content)) +
66
+ content.slice(match.end);
76
67
  try {
77
68
  await fs.writeFile(abs, updated, "utf8");
78
69
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ export type Tier = "exact" | "eol" | "eol+trailws";
20
+ export type MatchResult = {
21
+ kind: "found";
22
+ start: number;
23
+ end: number;
24
+ } | {
25
+ kind: "ambiguous";
26
+ count: number;
27
+ tier: Tier;
28
+ } | {
29
+ kind: "none";
30
+ };
31
+ /**
32
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
33
+ * and trailing-whitespace differences. Returns original-string byte offsets on
34
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
35
+ * `none` if no tier matched.
36
+ */
37
+ export declare function findMatch(content: string, oldStr: string): MatchResult;
38
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
39
+ export declare function detectEol(content: string): "\r\n" | "\n";
40
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
41
+ export declare function applyEol(text: string, eol: "\r\n" | "\n"): string;
42
+ /** Human phrase for the tier named in an ambiguity error. */
43
+ export declare function tierLabel(tier: Tier): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Locating `old_str` inside a file for edit_file / apply_patch.
3
+ *
4
+ * Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
5
+ * checkouts/editors) — a byte-exact `indexOf` then never matches and the model
6
+ * can't replace existing code. We match in ordered tiers, tightest first, and
7
+ * stop at the first tier that finds anything:
8
+ *
9
+ * 0. exact — raw bytes; every currently-working edit takes this path
10
+ * 1. eol — CRLF and lone CR treated as LF (comparison only)
11
+ * 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
12
+ *
13
+ * Looser tiers compare against a normalized copy but return offsets into the
14
+ * ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
15
+ * file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
16
+ * more than one match at a tier is a hard error, and we do not fall through to a
17
+ * looser tier (looser can only be more ambiguous, never less).
18
+ */
19
+ const TIERS = ["exact", "eol", "eol+trailws"];
20
+ /**
21
+ * Find the single occurrence of `oldStr` in `content`, tolerating line-ending
22
+ * and trailing-whitespace differences. Returns original-string byte offsets on
23
+ * a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
24
+ * `none` if no tier matched.
25
+ */
26
+ export function findMatch(content, oldStr) {
27
+ for (const tier of TIERS) {
28
+ const occ = occurrences(content, oldStr, tier);
29
+ if (occ.length === 1) {
30
+ return { kind: "found", start: occ[0].start, end: occ[0].end };
31
+ }
32
+ if (occ.length > 1) {
33
+ return { kind: "ambiguous", count: occ.length, tier };
34
+ }
35
+ // 0 matches → try the next, looser tier.
36
+ }
37
+ return { kind: "none" };
38
+ }
39
+ /** Non-overlapping occurrences of `oldStr` under `tier`, as original offsets. */
40
+ function occurrences(content, oldStr, tier) {
41
+ const out = [];
42
+ if (tier === "exact") {
43
+ let i = content.indexOf(oldStr);
44
+ while (i !== -1) {
45
+ out.push({ start: i, end: i + oldStr.length });
46
+ i = content.indexOf(oldStr, i + oldStr.length);
47
+ }
48
+ return out;
49
+ }
50
+ const { norm, map } = normalize(content, tier);
51
+ const { norm: needle } = normalize(oldStr, tier);
52
+ // An all-whitespace old_str can normalize to empty under eol+trailws; refuse
53
+ // to "match everywhere" rather than delete at an arbitrary point.
54
+ if (needle.length === 0)
55
+ return out;
56
+ let i = norm.indexOf(needle);
57
+ while (i !== -1) {
58
+ out.push({ start: map[i], end: map[i + needle.length] });
59
+ i = norm.indexOf(needle, i + needle.length);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Build a normalized view of `s` plus `map`, where `map[k]` is the original
65
+ * offset of the k-th normalized char and `map[norm.length]` is a sentinel
66
+ * (`s.length`). This lets a match found in normalized space splice back into
67
+ * the original bytes exactly.
68
+ */
69
+ function normalize(s, tier) {
70
+ const stripTrail = tier === "eol+trailws";
71
+ let norm = "";
72
+ const map = [];
73
+ // Whitespace whose trailing-vs-not status isn't known yet: flushed when real
74
+ // content follows on the line, dropped when a newline / EOF follows.
75
+ const pending = [];
76
+ const emit = (ch, off) => {
77
+ norm += ch;
78
+ map.push(off);
79
+ };
80
+ const flushPending = () => {
81
+ for (const p of pending)
82
+ emit(p.ch, p.off);
83
+ pending.length = 0;
84
+ };
85
+ let i = 0;
86
+ while (i < s.length) {
87
+ const c = s[i];
88
+ if (c === "\r" || c === "\n") {
89
+ pending.length = 0; // any pending whitespace was trailing → drop it
90
+ emit("\n", i);
91
+ i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
92
+ continue;
93
+ }
94
+ if (stripTrail && (c === " " || c === "\t")) {
95
+ pending.push({ ch: c, off: i });
96
+ i += 1;
97
+ continue;
98
+ }
99
+ flushPending();
100
+ emit(c, i);
101
+ i += 1;
102
+ }
103
+ // Trailing whitespace at end-of-string (in `pending`) is intentionally dropped.
104
+ map.push(s.length);
105
+ return { norm, map };
106
+ }
107
+ /** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
108
+ export function detectEol(content) {
109
+ const i = content.indexOf("\n");
110
+ return i > 0 && content[i - 1] === "\r" ? "\r\n" : "\n";
111
+ }
112
+ /** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
113
+ export function applyEol(text, eol) {
114
+ const lf = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
115
+ return eol === "\r\n" ? lf.replace(/\n/g, "\r\n") : lf;
116
+ }
117
+ /** Human phrase for the tier named in an ambiguity error. */
118
+ export function tierLabel(tier) {
119
+ switch (tier) {
120
+ case "exact":
121
+ return "";
122
+ case "eol":
123
+ return " after end-of-line normalization";
124
+ case "eol+trailws":
125
+ return " after end-of-line + trailing-whitespace normalization";
126
+ }
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {