opencode-hashline 1.3.1 → 1.3.3

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.
@@ -4,7 +4,7 @@ import {
4
4
  resolveConfig,
5
5
  shouldExclude,
6
6
  stripHashes
7
- } from "./chunk-GKXY5ZBM.js";
7
+ } from "./chunk-C323JLG3.js";
8
8
 
9
9
  // src/hooks.ts
10
10
  import { appendFileSync } from "fs";
@@ -12,18 +12,23 @@ import { join } from "path";
12
12
  import { homedir } from "os";
13
13
  var DEBUG_LOG = join(homedir(), ".config", "opencode", "hashline-debug.log");
14
14
  var MAX_PROCESSED_IDS = 1e4;
15
- function createBoundedSet(maxSize) {
16
- const set = /* @__PURE__ */ new Set();
17
- const originalAdd = set.add.bind(set);
18
- set.add = (value) => {
19
- if (set.size >= maxSize) {
20
- const first = set.values().next().value;
21
- if (first !== void 0) set.delete(first);
15
+ var BoundedSet = class {
16
+ constructor(maxSize) {
17
+ this.maxSize = maxSize;
18
+ }
19
+ maxSize;
20
+ set = /* @__PURE__ */ new Set();
21
+ has(value) {
22
+ return this.set.has(value);
23
+ }
24
+ add(value) {
25
+ if (this.set.size >= this.maxSize) {
26
+ const first = this.set.values().next().value;
27
+ if (first !== void 0) this.set.delete(first);
22
28
  }
23
- return originalAdd(value);
24
- };
25
- return set;
26
- }
29
+ this.set.add(value);
30
+ }
31
+ };
27
32
  var debugEnabled = false;
28
33
  function setDebug(enabled) {
29
34
  debugEnabled = enabled;
@@ -58,7 +63,7 @@ function createFileReadAfterHook(cache, config) {
58
63
  const resolved = config ?? resolveConfig();
59
64
  const hashLen = resolved.hashLength || 0;
60
65
  const prefix = resolved.prefix;
61
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
66
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
62
67
  return async (input, output) => {
63
68
  debug("tool.execute.after:", input.tool, "args:", input.args);
64
69
  if (input.callID) {
@@ -67,6 +72,8 @@ function createFileReadAfterHook(cache, config) {
67
72
  return;
68
73
  }
69
74
  processedCallIds.add(input.callID);
75
+ } else {
76
+ debug("no callID \u2014 deduplication disabled for this call");
70
77
  }
71
78
  if (!isFileReadTool(input.tool, input.args)) {
72
79
  debug("skipped: not a file-read tool");
@@ -105,13 +112,16 @@ function createFileReadAfterHook(cache, config) {
105
112
  function createFileEditBeforeHook(config) {
106
113
  const resolved = config ?? resolveConfig();
107
114
  const prefix = resolved.prefix;
108
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
115
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
109
116
  return async (input, output) => {
110
117
  if (input.callID) {
111
118
  if (processedCallIds.has(input.callID)) {
119
+ debug("skipped: duplicate callID (edit)", input.callID);
112
120
  return;
113
121
  }
114
122
  processedCallIds.add(input.callID);
123
+ } else {
124
+ debug("no callID \u2014 deduplication disabled for this edit call");
115
125
  }
116
126
  const toolName = input.tool.toLowerCase();
117
127
  const isFileEdit = FILE_EDIT_TOOLS.some(
@@ -204,6 +204,7 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
204
204
  for (let idx = 0; idx < lines.length; idx++) {
205
205
  hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
206
206
  }
207
+ let dirtyIndices = null;
207
208
  let hasCollisions = true;
208
209
  while (hasCollisions) {
209
210
  hasCollisions = false;
@@ -217,16 +218,29 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
217
218
  seen.set(h, [idx]);
218
219
  }
219
220
  }
221
+ const nextDirty = /* @__PURE__ */ new Set();
220
222
  for (const [, group] of seen) {
221
223
  if (group.length < 2) continue;
224
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
222
225
  for (const idx of group) {
223
226
  const newLen = Math.min(hashLens[idx] + 1, 8);
224
227
  if (newLen === hashLens[idx]) continue;
225
228
  hashLens[idx] = newLen;
226
229
  hashes[idx] = computeLineHash(idx, lines[idx], newLen);
230
+ nextDirty.add(idx);
227
231
  hasCollisions = true;
228
232
  }
229
233
  }
234
+ dirtyIndices = nextDirty;
235
+ }
236
+ const finalSeen = /* @__PURE__ */ new Map();
237
+ for (let idx = 0; idx < lines.length; idx++) {
238
+ const existing = finalSeen.get(hashes[idx]);
239
+ if (existing !== void 0) {
240
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
241
+ } else {
242
+ finalSeen.set(hashes[idx], idx);
243
+ }
230
244
  }
231
245
  const annotatedLines = lines.map((line, idx) => {
232
246
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
@@ -241,12 +255,16 @@ var stripRegexCache = /* @__PURE__ */ new Map();
241
255
  function stripHashes(content, prefix) {
242
256
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
243
257
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
244
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
245
- if (!hashLinePattern) {
246
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
247
- stripRegexCache.set(escapedPrefix, hashLinePattern);
258
+ let cached = stripRegexCache.get(escapedPrefix);
259
+ if (!cached) {
260
+ cached = {
261
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
262
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
263
+ };
264
+ stripRegexCache.set(escapedPrefix, cached);
248
265
  }
249
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
266
+ const hashLinePattern = cached.hashLine;
267
+ const revPattern = cached.rev;
250
268
  const lineEnding = detectLineEnding(content);
251
269
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
252
270
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -395,10 +413,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
395
413
  content: rangeLines.join(lineEnding)
396
414
  };
397
415
  }
398
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
416
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
399
417
  const lineEnding = detectLineEnding(content);
400
418
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
401
- const range = resolveRange(startRef, endRef, normalized, hashLen);
419
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
402
420
  const lines = normalized.split("\n");
403
421
  const before = lines.slice(0, range.startLine - 1);
404
422
  const after = lines.slice(range.endLine);
@@ -568,9 +586,8 @@ function matchesGlob(filePath, pattern) {
568
586
  function shouldExclude(filePath, patterns) {
569
587
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
570
588
  }
571
- var textEncoder = new TextEncoder();
572
589
  function getByteLength(content) {
573
- return textEncoder.encode(content).length;
590
+ return Buffer.byteLength(content, "utf-8");
574
591
  }
575
592
  function detectLineEnding(content) {
576
593
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -25,7 +25,7 @@ import {
25
25
  stripHashes,
26
26
  verifyFileRev,
27
27
  verifyHash
28
- } from "./chunk-GKXY5ZBM.js";
28
+ } from "./chunk-C323JLG3.js";
29
29
  export {
30
30
  DEFAULT_CONFIG,
31
31
  DEFAULT_EXCLUDE_PATTERNS,
@@ -262,7 +262,7 @@ declare function resolveRange(startRef: string, endRef: string, content: string,
262
262
  * @param hashLen - override hash length (0 or undefined = use hash.length from ref)
263
263
  * @returns new file content with the range replaced
264
264
  */
265
- declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number): string;
265
+ declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number, safeReapply?: boolean): string;
266
266
  /**
267
267
  * Apply a hash-aware edit operation directly against file content.
268
268
  *
@@ -308,6 +308,11 @@ declare function matchesGlob(filePath: string, pattern: string): boolean;
308
308
  * Check if a file path should be excluded based on config patterns.
309
309
  */
310
310
  declare function shouldExclude(filePath: string, patterns: string[]): boolean;
311
+ /**
312
+ * Get the UTF-8 byte length of a string.
313
+ * Uses TextEncoder for accurate UTF-8 byte counting.
314
+ * This correctly handles multi-byte characters (Cyrillic, CJK, emoji, etc.).
315
+ */
311
316
  declare function getByteLength(content: string): number;
312
317
  /**
313
318
  * A Hashline instance with custom configuration.
@@ -262,7 +262,7 @@ declare function resolveRange(startRef: string, endRef: string, content: string,
262
262
  * @param hashLen - override hash length (0 or undefined = use hash.length from ref)
263
263
  * @returns new file content with the range replaced
264
264
  */
265
- declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number): string;
265
+ declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number, safeReapply?: boolean): string;
266
266
  /**
267
267
  * Apply a hash-aware edit operation directly against file content.
268
268
  *
@@ -308,6 +308,11 @@ declare function matchesGlob(filePath: string, pattern: string): boolean;
308
308
  * Check if a file path should be excluded based on config patterns.
309
309
  */
310
310
  declare function shouldExclude(filePath: string, patterns: string[]): boolean;
311
+ /**
312
+ * Get the UTF-8 byte length of a string.
313
+ * Uses TextEncoder for accurate UTF-8 byte counting.
314
+ * This correctly handles multi-byte characters (Cyrillic, CJK, emoji, etc.).
315
+ */
311
316
  declare function getByteLength(content: string): number;
312
317
  /**
313
318
  * A Hashline instance with custom configuration.
@@ -159,6 +159,7 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
159
159
  for (let idx = 0; idx < lines.length; idx++) {
160
160
  hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
161
161
  }
162
+ let dirtyIndices = null;
162
163
  let hasCollisions = true;
163
164
  while (hasCollisions) {
164
165
  hasCollisions = false;
@@ -172,16 +173,29 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
172
173
  seen.set(h, [idx]);
173
174
  }
174
175
  }
176
+ const nextDirty = /* @__PURE__ */ new Set();
175
177
  for (const [, group] of seen) {
176
178
  if (group.length < 2) continue;
179
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
177
180
  for (const idx of group) {
178
181
  const newLen = Math.min(hashLens[idx] + 1, 8);
179
182
  if (newLen === hashLens[idx]) continue;
180
183
  hashLens[idx] = newLen;
181
184
  hashes[idx] = computeLineHash(idx, lines[idx], newLen);
185
+ nextDirty.add(idx);
182
186
  hasCollisions = true;
183
187
  }
184
188
  }
189
+ dirtyIndices = nextDirty;
190
+ }
191
+ const finalSeen = /* @__PURE__ */ new Map();
192
+ for (let idx = 0; idx < lines.length; idx++) {
193
+ const existing = finalSeen.get(hashes[idx]);
194
+ if (existing !== void 0) {
195
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
196
+ } else {
197
+ finalSeen.set(hashes[idx], idx);
198
+ }
185
199
  }
186
200
  const annotatedLines = lines.map((line, idx) => {
187
201
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
@@ -195,12 +209,16 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
195
209
  function stripHashes(content, prefix) {
196
210
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
197
211
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
198
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
199
- if (!hashLinePattern) {
200
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
201
- stripRegexCache.set(escapedPrefix, hashLinePattern);
212
+ let cached = stripRegexCache.get(escapedPrefix);
213
+ if (!cached) {
214
+ cached = {
215
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
216
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
217
+ };
218
+ stripRegexCache.set(escapedPrefix, cached);
202
219
  }
203
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
220
+ const hashLinePattern = cached.hashLine;
221
+ const revPattern = cached.rev;
204
222
  const lineEnding = detectLineEnding(content);
205
223
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
206
224
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -349,10 +367,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
349
367
  content: rangeLines.join(lineEnding)
350
368
  };
351
369
  }
352
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
370
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
353
371
  const lineEnding = detectLineEnding(content);
354
372
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
355
- const range = resolveRange(startRef, endRef, normalized, hashLen);
373
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
356
374
  const lines = normalized.split("\n");
357
375
  const before = lines.slice(0, range.startLine - 1);
358
376
  const after = lines.slice(range.endLine);
@@ -463,7 +481,7 @@ function shouldExclude(filePath, patterns) {
463
481
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
464
482
  }
465
483
  function getByteLength(content) {
466
- return textEncoder.encode(content).length;
484
+ return Buffer.byteLength(content, "utf-8");
467
485
  }
468
486
  function detectLineEnding(content) {
469
487
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -531,7 +549,7 @@ function createHashline(config) {
531
549
  }
532
550
  };
533
551
  }
534
- var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
552
+ var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache;
535
553
  var init_hashline = __esm({
536
554
  "src/hashline.ts"() {
537
555
  "use strict";
@@ -702,7 +720,6 @@ var init_hashline = __esm({
702
720
  }
703
721
  };
704
722
  globMatcherCache = /* @__PURE__ */ new Map();
705
- textEncoder = new TextEncoder();
706
723
  }
707
724
  });
708
725
 
@@ -711,7 +728,8 @@ var src_exports = {};
711
728
  __export(src_exports, {
712
729
  HashlinePlugin: () => HashlinePlugin,
713
730
  createHashlinePlugin: () => createHashlinePlugin,
714
- default: () => src_default
731
+ default: () => src_default,
732
+ sanitizeConfig: () => sanitizeConfig
715
733
  });
716
734
  module.exports = __toCommonJS(src_exports);
717
735
  var import_fs3 = require("fs");
@@ -727,18 +745,23 @@ var import_os = require("os");
727
745
  init_hashline();
728
746
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
729
747
  var MAX_PROCESSED_IDS = 1e4;
730
- function createBoundedSet(maxSize) {
731
- const set = /* @__PURE__ */ new Set();
732
- const originalAdd = set.add.bind(set);
733
- set.add = (value) => {
734
- if (set.size >= maxSize) {
735
- const first = set.values().next().value;
736
- if (first !== void 0) set.delete(first);
748
+ var BoundedSet = class {
749
+ constructor(maxSize) {
750
+ this.maxSize = maxSize;
751
+ }
752
+ maxSize;
753
+ set = /* @__PURE__ */ new Set();
754
+ has(value) {
755
+ return this.set.has(value);
756
+ }
757
+ add(value) {
758
+ if (this.set.size >= this.maxSize) {
759
+ const first = this.set.values().next().value;
760
+ if (first !== void 0) this.set.delete(first);
737
761
  }
738
- return originalAdd(value);
739
- };
740
- return set;
741
- }
762
+ this.set.add(value);
763
+ }
764
+ };
742
765
  var debugEnabled = false;
743
766
  function setDebug(enabled) {
744
767
  debugEnabled = enabled;
@@ -773,7 +796,7 @@ function createFileReadAfterHook(cache, config) {
773
796
  const resolved = config ?? resolveConfig();
774
797
  const hashLen = resolved.hashLength || 0;
775
798
  const prefix = resolved.prefix;
776
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
799
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
777
800
  return async (input, output) => {
778
801
  debug("tool.execute.after:", input.tool, "args:", input.args);
779
802
  if (input.callID) {
@@ -782,6 +805,8 @@ function createFileReadAfterHook(cache, config) {
782
805
  return;
783
806
  }
784
807
  processedCallIds.add(input.callID);
808
+ } else {
809
+ debug("no callID \u2014 deduplication disabled for this call");
785
810
  }
786
811
  if (!isFileReadTool(input.tool, input.args)) {
787
812
  debug("skipped: not a file-read tool");
@@ -820,13 +845,16 @@ function createFileReadAfterHook(cache, config) {
820
845
  function createFileEditBeforeHook(config) {
821
846
  const resolved = config ?? resolveConfig();
822
847
  const prefix = resolved.prefix;
823
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
848
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
824
849
  return async (input, output) => {
825
850
  if (input.callID) {
826
851
  if (processedCallIds.has(input.callID)) {
852
+ debug("skipped: duplicate callID (edit)", input.callID);
827
853
  return;
828
854
  }
829
855
  processedCallIds.add(input.callID);
856
+ } else {
857
+ debug("no callID \u2014 deduplication disabled for this edit call");
830
858
  }
831
859
  const toolName = input.tool.toLowerCase();
832
860
  const isFileEdit = FILE_EDIT_TOOLS.some(
@@ -1110,9 +1138,7 @@ function sanitizeConfig(raw) {
1110
1138
  const r = raw;
1111
1139
  const result = {};
1112
1140
  if (Array.isArray(r.exclude)) {
1113
- result.exclude = r.exclude.filter(
1114
- (p) => typeof p === "string" && p.length <= 512
1115
- );
1141
+ result.exclude = r.exclude.filter((p) => typeof p === "string" && p.length <= 512).slice(0, 1e3);
1116
1142
  }
1117
1143
  if (typeof r.maxFileSize === "number" && Number.isFinite(r.maxFileSize) && r.maxFileSize >= 0) {
1118
1144
  result.maxFileSize = r.maxFileSize;
@@ -1164,14 +1190,13 @@ function loadConfig(projectDir, userConfig) {
1164
1190
  }
1165
1191
  function createHashlinePlugin(userConfig) {
1166
1192
  return async (input) => {
1167
- const projectDir = input.directory;
1168
- const worktree = input.worktree;
1193
+ const { directory: projectDir, worktree } = input;
1169
1194
  const fileConfig = loadConfig(projectDir, userConfig);
1170
1195
  const config = resolveConfig(fileConfig);
1171
1196
  const cache = new HashlineCache(config.cacheSize);
1172
1197
  setDebug(config.debug);
1173
- const { appendFileSync: writeLog } = await import("fs");
1174
1198
  const debugLog = (0, import_path3.join)((0, import_os2.homedir)(), ".config", "opencode", "hashline-debug.log");
1199
+ const writeLog = import_fs3.appendFileSync;
1175
1200
  if (config.debug) {
1176
1201
  try {
1177
1202
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] plugin loaded, prefix: ${JSON.stringify(config.prefix)}, maxFileSize: ${config.maxFileSize}, projectDir: ${projectDir}
@@ -1264,5 +1289,6 @@ var src_default = HashlinePlugin;
1264
1289
  // Annotate the CommonJS export names for ESM import in node:
1265
1290
  0 && (module.exports = {
1266
1291
  HashlinePlugin,
1267
- createHashlinePlugin
1292
+ createHashlinePlugin,
1293
+ sanitizeConfig
1268
1294
  });
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
- import { H as HashlineConfig } from './hashline-A7k2yn3G.cjs';
3
- export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.cjs';
2
+ import { H as HashlineConfig } from './hashline-DWndArr4.cjs';
3
+ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-DWndArr4.cjs';
4
4
 
5
5
  /**
6
6
  * opencode-hashline — Hashline plugin for OpenCode
@@ -14,6 +14,13 @@ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as Ha
14
14
  * constants, import from "opencode-hashline/utils".
15
15
  */
16
16
 
17
+ /**
18
+ * Sanitize and validate a raw parsed config object.
19
+ * Accepts only known keys with expected types; silently drops invalid values.
20
+ * This prevents prototype pollution, type confusion, and prompt injection via
21
+ * a malicious or hand-crafted config file.
22
+ */
23
+ declare function sanitizeConfig(raw: unknown): HashlineConfig;
17
24
  /**
18
25
  * Create a Hashline plugin instance with optional user configuration.
19
26
  *
@@ -45,4 +52,4 @@ declare function createHashlinePlugin(userConfig?: HashlineConfig): Plugin;
45
52
  */
46
53
  declare const HashlinePlugin: Plugin;
47
54
 
48
- export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default };
55
+ export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default, sanitizeConfig };
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
- import { H as HashlineConfig } from './hashline-A7k2yn3G.js';
3
- export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.js';
2
+ import { H as HashlineConfig } from './hashline-DWndArr4.js';
3
+ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-DWndArr4.js';
4
4
 
5
5
  /**
6
6
  * opencode-hashline — Hashline plugin for OpenCode
@@ -14,6 +14,13 @@ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as Ha
14
14
  * constants, import from "opencode-hashline/utils".
15
15
  */
16
16
 
17
+ /**
18
+ * Sanitize and validate a raw parsed config object.
19
+ * Accepts only known keys with expected types; silently drops invalid values.
20
+ * This prevents prototype pollution, type confusion, and prompt injection via
21
+ * a malicious or hand-crafted config file.
22
+ */
23
+ declare function sanitizeConfig(raw: unknown): HashlineConfig;
17
24
  /**
18
25
  * Create a Hashline plugin instance with optional user configuration.
19
26
  *
@@ -45,4 +52,4 @@ declare function createHashlinePlugin(userConfig?: HashlineConfig): Plugin;
45
52
  */
46
53
  declare const HashlinePlugin: Plugin;
47
54
 
48
- export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default };
55
+ export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default, sanitizeConfig };
@@ -3,17 +3,17 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  setDebug
6
- } from "./chunk-VSVVWPET.js";
6
+ } from "./chunk-2FSVSE7C.js";
7
7
  import {
8
8
  HashlineCache,
9
9
  HashlineError,
10
10
  applyHashEdit,
11
11
  getByteLength,
12
12
  resolveConfig
13
- } from "./chunk-GKXY5ZBM.js";
13
+ } from "./chunk-C323JLG3.js";
14
14
 
15
15
  // src/index.ts
16
- import { readFileSync as readFileSync2, realpathSync as realpathSync2, writeFileSync as writeFileSync2, mkdtempSync, openSync, closeSync, rmSync, constants as fsConstants } from "fs";
16
+ import { readFileSync as readFileSync2, realpathSync as realpathSync2, writeFileSync as writeFileSync2, appendFileSync, mkdtempSync, openSync, closeSync, rmSync, constants as fsConstants } from "fs";
17
17
  import { join, resolve as resolve2, sep as sep2 } from "path";
18
18
  import { homedir, tmpdir } from "os";
19
19
  import { randomBytes } from "crypto";
@@ -173,9 +173,7 @@ function sanitizeConfig(raw) {
173
173
  const r = raw;
174
174
  const result = {};
175
175
  if (Array.isArray(r.exclude)) {
176
- result.exclude = r.exclude.filter(
177
- (p) => typeof p === "string" && p.length <= 512
178
- );
176
+ result.exclude = r.exclude.filter((p) => typeof p === "string" && p.length <= 512).slice(0, 1e3);
179
177
  }
180
178
  if (typeof r.maxFileSize === "number" && Number.isFinite(r.maxFileSize) && r.maxFileSize >= 0) {
181
179
  result.maxFileSize = r.maxFileSize;
@@ -227,14 +225,13 @@ function loadConfig(projectDir, userConfig) {
227
225
  }
228
226
  function createHashlinePlugin(userConfig) {
229
227
  return async (input) => {
230
- const projectDir = input.directory;
231
- const worktree = input.worktree;
228
+ const { directory: projectDir, worktree } = input;
232
229
  const fileConfig = loadConfig(projectDir, userConfig);
233
230
  const config = resolveConfig(fileConfig);
234
231
  const cache = new HashlineCache(config.cacheSize);
235
232
  setDebug(config.debug);
236
- const { appendFileSync: writeLog } = await import("fs");
237
233
  const debugLog = join(homedir(), ".config", "opencode", "hashline-debug.log");
234
+ const writeLog = appendFileSync;
238
235
  if (config.debug) {
239
236
  try {
240
237
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] plugin loaded, prefix: ${JSON.stringify(config.prefix)}, maxFileSize: ${config.maxFileSize}, projectDir: ${projectDir}
@@ -256,7 +253,7 @@ function createHashlinePlugin(userConfig) {
256
253
  const out = output;
257
254
  const hashLen = config.hashLength || 0;
258
255
  const prefix = config.prefix;
259
- const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-37RYBX5A.js");
256
+ const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-DDPVX355.js");
260
257
  for (const p of out.parts ?? []) {
261
258
  if (p.type !== "file") continue;
262
259
  if (!p.url || !p.mime?.startsWith("text/")) continue;
@@ -327,5 +324,6 @@ var src_default = HashlinePlugin;
327
324
  export {
328
325
  HashlinePlugin,
329
326
  createHashlinePlugin,
330
- src_default as default
327
+ src_default as default,
328
+ sanitizeConfig
331
329
  };
package/dist/utils.cjs CHANGED
@@ -268,6 +268,7 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
268
268
  for (let idx = 0; idx < lines.length; idx++) {
269
269
  hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
270
270
  }
271
+ let dirtyIndices = null;
271
272
  let hasCollisions = true;
272
273
  while (hasCollisions) {
273
274
  hasCollisions = false;
@@ -281,16 +282,29 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
281
282
  seen.set(h, [idx]);
282
283
  }
283
284
  }
285
+ const nextDirty = /* @__PURE__ */ new Set();
284
286
  for (const [, group] of seen) {
285
287
  if (group.length < 2) continue;
288
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
286
289
  for (const idx of group) {
287
290
  const newLen = Math.min(hashLens[idx] + 1, 8);
288
291
  if (newLen === hashLens[idx]) continue;
289
292
  hashLens[idx] = newLen;
290
293
  hashes[idx] = computeLineHash(idx, lines[idx], newLen);
294
+ nextDirty.add(idx);
291
295
  hasCollisions = true;
292
296
  }
293
297
  }
298
+ dirtyIndices = nextDirty;
299
+ }
300
+ const finalSeen = /* @__PURE__ */ new Map();
301
+ for (let idx = 0; idx < lines.length; idx++) {
302
+ const existing = finalSeen.get(hashes[idx]);
303
+ if (existing !== void 0) {
304
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
305
+ } else {
306
+ finalSeen.set(hashes[idx], idx);
307
+ }
294
308
  }
295
309
  const annotatedLines = lines.map((line, idx) => {
296
310
  return `${effectivePrefix}${idx + 1}:${hashes[idx]}|${line}`;
@@ -305,12 +319,16 @@ var stripRegexCache = /* @__PURE__ */ new Map();
305
319
  function stripHashes(content, prefix) {
306
320
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
307
321
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
309
- if (!hashLinePattern) {
310
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
311
- stripRegexCache.set(escapedPrefix, hashLinePattern);
322
+ let cached = stripRegexCache.get(escapedPrefix);
323
+ if (!cached) {
324
+ cached = {
325
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
326
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
327
+ };
328
+ stripRegexCache.set(escapedPrefix, cached);
312
329
  }
313
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
330
+ const hashLinePattern = cached.hashLine;
331
+ const revPattern = cached.rev;
314
332
  const lineEnding = detectLineEnding(content);
315
333
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
316
334
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -459,10 +477,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
459
477
  content: rangeLines.join(lineEnding)
460
478
  };
461
479
  }
462
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
480
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
463
481
  const lineEnding = detectLineEnding(content);
464
482
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
465
- const range = resolveRange(startRef, endRef, normalized, hashLen);
483
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
466
484
  const lines = normalized.split("\n");
467
485
  const before = lines.slice(0, range.startLine - 1);
468
486
  const after = lines.slice(range.endLine);
@@ -632,9 +650,8 @@ function matchesGlob(filePath, pattern) {
632
650
  function shouldExclude(filePath, patterns) {
633
651
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
634
652
  }
635
- var textEncoder = new TextEncoder();
636
653
  function getByteLength(content) {
637
- return textEncoder.encode(content).length;
654
+ return Buffer.byteLength(content, "utf-8");
638
655
  }
639
656
  function detectLineEnding(content) {
640
657
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -709,18 +726,23 @@ var import_path = require("path");
709
726
  var import_os = require("os");
710
727
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
711
728
  var MAX_PROCESSED_IDS = 1e4;
712
- function createBoundedSet(maxSize) {
713
- const set = /* @__PURE__ */ new Set();
714
- const originalAdd = set.add.bind(set);
715
- set.add = (value) => {
716
- if (set.size >= maxSize) {
717
- const first = set.values().next().value;
718
- if (first !== void 0) set.delete(first);
729
+ var BoundedSet = class {
730
+ constructor(maxSize) {
731
+ this.maxSize = maxSize;
732
+ }
733
+ maxSize;
734
+ set = /* @__PURE__ */ new Set();
735
+ has(value) {
736
+ return this.set.has(value);
737
+ }
738
+ add(value) {
739
+ if (this.set.size >= this.maxSize) {
740
+ const first = this.set.values().next().value;
741
+ if (first !== void 0) this.set.delete(first);
719
742
  }
720
- return originalAdd(value);
721
- };
722
- return set;
723
- }
743
+ this.set.add(value);
744
+ }
745
+ };
724
746
  var debugEnabled = false;
725
747
  function debug(...args) {
726
748
  if (!debugEnabled) return;
@@ -752,7 +774,7 @@ function createFileReadAfterHook(cache, config) {
752
774
  const resolved = config ?? resolveConfig();
753
775
  const hashLen = resolved.hashLength || 0;
754
776
  const prefix = resolved.prefix;
755
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
777
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
756
778
  return async (input, output) => {
757
779
  debug("tool.execute.after:", input.tool, "args:", input.args);
758
780
  if (input.callID) {
@@ -761,6 +783,8 @@ function createFileReadAfterHook(cache, config) {
761
783
  return;
762
784
  }
763
785
  processedCallIds.add(input.callID);
786
+ } else {
787
+ debug("no callID \u2014 deduplication disabled for this call");
764
788
  }
765
789
  if (!isFileReadTool(input.tool, input.args)) {
766
790
  debug("skipped: not a file-read tool");
@@ -799,13 +823,16 @@ function createFileReadAfterHook(cache, config) {
799
823
  function createFileEditBeforeHook(config) {
800
824
  const resolved = config ?? resolveConfig();
801
825
  const prefix = resolved.prefix;
802
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
826
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
803
827
  return async (input, output) => {
804
828
  if (input.callID) {
805
829
  if (processedCallIds.has(input.callID)) {
830
+ debug("skipped: duplicate callID (edit)", input.callID);
806
831
  return;
807
832
  }
808
833
  processedCallIds.add(input.callID);
834
+ } else {
835
+ debug("no callID \u2014 deduplication disabled for this edit call");
809
836
  }
810
837
  const toolName = input.tool.toLowerCase();
811
838
  const isFileEdit = FILE_EDIT_TOOLS.some(
package/dist/utils.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.cjs';
2
- export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.cjs';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-DWndArr4.cjs';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-DWndArr4.cjs';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.js';
2
- export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.js';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-DWndArr4.js';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-DWndArr4.js';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  isFileReadTool
6
- } from "./chunk-VSVVWPET.js";
6
+ } from "./chunk-2FSVSE7C.js";
7
7
  import {
8
8
  DEFAULT_CONFIG,
9
9
  DEFAULT_EXCLUDE_PATTERNS,
@@ -30,7 +30,7 @@ import {
30
30
  stripHashes,
31
31
  verifyFileRev,
32
32
  verifyHash
33
- } from "./chunk-GKXY5ZBM.js";
33
+ } from "./chunk-C323JLG3.js";
34
34
  export {
35
35
  DEFAULT_CONFIG,
36
36
  DEFAULT_EXCLUDE_PATTERNS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hashline",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
5
5
  "main": "dist/opencode-hashline.cjs",
6
6
  "module": "dist/opencode-hashline.js",
@@ -53,7 +53,7 @@
53
53
  "tsup": "^8.5.1",
54
54
  "typescript": "^5.9.3",
55
55
  "vitest": "^4.0.18",
56
- "zod": "^4.1.8"
56
+ "zod": "~4.1.8"
57
57
  },
58
58
  "dependencies": {
59
59
  "picomatch": "^4.0.3"