claude-nomad 0.32.0 → 0.32.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.32.1](https://github.com/funkadelic/claude-nomad/compare/v0.32.0...v0.32.1) (2026-05-30)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **redact:** make applyRedactions overlap-safe ([#186](https://github.com/funkadelic/claude-nomad/issues/186)) ([8da07d1](https://github.com/funkadelic/claude-nomad/commit/8da07d1ef58b7f6596ca479a3c57863fc75f35bb))
9
+
3
10
  ## [0.32.0](https://github.com/funkadelic/claude-nomad/compare/v0.31.0...v0.32.0) (2026-05-30)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -3,25 +3,6 @@ import { join } from 'node:path';
3
3
 
4
4
  import { REPO_HOME } from './config.ts';
5
5
 
6
- /**
7
- * Replace every occurrence of a literal secret value in a raw line. Uses
8
- * split/join to avoid regex escaping and to replace all occurrences. Pure,
9
- * no I/O.
10
- *
11
- * The replacement token `[REDACTED:<ruleId>]` contains no JSON-special
12
- * characters, so the result remains valid JSON when the value sits inside a
13
- * JSON string token.
14
- *
15
- * @param line Raw JSONL line text.
16
- * @param match Literal secret value to replace (empty string is a no-op).
17
- * @param ruleId Gitleaks rule identifier included in the replacement token.
18
- * @returns Line with all occurrences of `match` replaced by `[REDACTED:<ruleId>]`.
19
- */
20
- export function redactValue(line: string, match: string, ruleId: string): string {
21
- if (match === '') return line;
22
- return line.split(match).join(`[REDACTED:${ruleId}]`);
23
- }
24
-
25
6
  /** Minimal finding shape consumed by `applyRedactions`. */
26
7
  export type RedactFinding = {
27
8
  StartLine: number;
@@ -29,25 +10,95 @@ export type RedactFinding = {
29
10
  RuleID: string;
30
11
  };
31
12
 
13
+ /** A half-open byte interval `[start, end)` derived from a `Match` value. */
14
+ type MatchInterval = {
15
+ start: number;
16
+ end: number;
17
+ ruleId: string;
18
+ };
19
+
32
20
  /**
33
- * Apply all findings for one file in memory. Replaces each finding's `Match`
34
- * value globally across the whole content string (split/join, no column
35
- * arithmetic). To avoid a shorter secret being a substring of a longer one
36
- * causing a partial match, findings are sorted by `Match.length` descending so
37
- * the longer secret is replaced first. Findings with an empty `Match` are
38
- * silently skipped (a defensive guard: an empty match would otherwise inject
39
- * the token between every character). Pure, no I/O.
21
+ * Locate every occurrence of each finding's `Match` value in `content` using
22
+ * `indexOf`. Findings with an empty `Match` are skipped. Multiple
23
+ * non-overlapping occurrences of the same value are each recorded as a
24
+ * separate interval. Offsets are value-derived, not column-derived, so they
25
+ * are always correct regardless of gitleaks column alignment.
26
+ *
27
+ * @param content Full file content as a single string.
28
+ * @param findings Array of finding descriptors.
29
+ * @returns Array of `{start, end, ruleId}` intervals (unmerged, unsorted).
30
+ */
31
+ export function collectMatchIntervals(
32
+ content: string,
33
+ findings: readonly RedactFinding[],
34
+ ): MatchInterval[] {
35
+ const intervals: MatchInterval[] = [];
36
+ for (const f of findings) {
37
+ const match = f.Match;
38
+ if (match === '') continue;
39
+ let from = 0;
40
+ let pos = content.indexOf(match, from);
41
+ while (pos !== -1) {
42
+ intervals.push({ start: pos, end: pos + match.length, ruleId: f.RuleID });
43
+ from = pos + match.length;
44
+ pos = content.indexOf(match, from);
45
+ }
46
+ }
47
+ return intervals;
48
+ }
49
+
50
+ /**
51
+ * Sort and merge a list of (possibly overlapping or adjacent) intervals into a
52
+ * minimal list of non-overlapping spans. Sort order is start ascending, then
53
+ * end descending so that a longer interval at the same start position wins its
54
+ * `ruleId`. Overlapping or adjacent intervals are folded into one span that
55
+ * extends to the maximum end seen, keeping the first span's `ruleId`.
56
+ *
57
+ * @param intervals Unmerged intervals from `collectMatchIntervals`.
58
+ * @returns Sorted, merged, non-overlapping intervals.
59
+ */
60
+ export function mergeIntervals(intervals: MatchInterval[]): MatchInterval[] {
61
+ if (intervals.length === 0) return [];
62
+ const sorted = [...intervals].sort((a, b) => a.start - b.start || b.end - a.end);
63
+ let last = { ...sorted[0] };
64
+ const merged: MatchInterval[] = [last];
65
+ for (let i = 1; i < sorted.length; i++) {
66
+ const cur = sorted[i];
67
+ if (cur.start <= last.end) {
68
+ if (cur.end > last.end) last.end = cur.end;
69
+ } else {
70
+ last = { ...cur };
71
+ merged.push(last);
72
+ }
73
+ }
74
+ return merged;
75
+ }
76
+
77
+ /**
78
+ * Apply all findings for one file in memory. Collects every occurrence of each
79
+ * finding's `Match` value via `indexOf`, merges overlapping and adjacent spans
80
+ * into non-overlapping intervals, then replaces each interval right-to-left so
81
+ * earlier offsets remain valid. Findings with an empty `Match` are silently
82
+ * skipped. Returns `content` unchanged when there are no findings or no
83
+ * occurrences are found.
84
+ *
85
+ * The replacement token `[REDACTED:<ruleId>]` is byte-identical to the previous
86
+ * format. Right-to-left replacement guarantees that overlapping matches (e.g.
87
+ * two findings whose `Match` values share a middle span) are replaced by a
88
+ * single token covering the full union, leaving no fragment. Pure, no I/O.
40
89
  *
41
90
  * @param content Full file content as a single string.
42
91
  * @param findings Array of finding descriptors.
43
92
  * @returns Redacted file content.
44
93
  */
45
94
  export function applyRedactions(content: string, findings: readonly RedactFinding[]): string {
46
- const sorted = [...findings].sort((a, b) => b.Match.length - a.Match.length);
95
+ const raw = collectMatchIntervals(content, findings);
96
+ if (raw.length === 0) return content;
97
+ const merged = mergeIntervals(raw);
47
98
  let result = content;
48
- for (const f of sorted) {
49
- if (f.Match === '') continue;
50
- result = result.split(f.Match).join(`[REDACTED:${f.RuleID}]`);
99
+ for (let i = merged.length - 1; i >= 0; i--) {
100
+ const { start, end, ruleId } = merged[i];
101
+ result = result.slice(0, start) + `[REDACTED:${ruleId}]` + result.slice(end);
51
102
  }
52
103
  return result;
53
104
  }
@@ -15,7 +15,6 @@ export {
15
15
  appendGitleaksIgnore,
16
16
  formatFingerprint,
17
17
  isRecentlyModified,
18
- redactValue,
19
18
  } from './commands.redact.core.ts';
20
19
 
21
20
  /**