@bigknoxy/hashpilot 4.8.2 → 4.8.4

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.
@@ -88,3 +88,26 @@ Format:
88
88
  - **Decision:** Add `bun.lock` to `package.json`'s `files` array so it ships in the npm tarball. The existing `install.sh` logic already uses `--frozen-lockfile` when `bun.lock` is present.
89
89
  - **Alternatives considered:** (1) Generate a lockfile during install — rejected: defeats the purpose of pinning. (2) Keep `bun.lock` out and accept fresh resolution — rejected: supply-chain pinning gap.
90
90
  - **Consequences:** npm-installed packages now include `bun.lock` and install with `--frozen-lockfile`. The npm package size increases slightly. The `else` branch in `install.sh` (lines 423-432) becomes unreachable for current npm installs but is kept as a safe fallback.
91
+
92
+ ## D009-D010 numbering note: D010 (npm install mode-selector fix) was merged
93
+ ## to main (#201) during the v4.8.2 hotfix and precedes D009 in the file for
94
+ ## the same reason it precedes it in git history — each record captures a
95
+ ## decision in merge order.
96
+
97
+ ## D010: npm-sourced installs with a shipped bun.lock use --frozen-lockfile --production
98
+
99
+ - **Date:** 2026-09-06
100
+ - **PR:** #201
101
+ - **Context:** v4.8.2 shipped broken for the **default npm install path**. D007 (#199) added `bun.lock` to the npm package's `files`; the dependency-mode guard from #194 treated `NPM_INSTALLED=true && bun.lock present` as a fatal ("refusing to guess which dependency mode is correct"). D007 made that guard's fire-branch the *normal* case, so every fresh npm install of v4.8.2 aborted before installing dependencies. The bug passed CI because smoke installs from `@latest` npm — main's run predated the semantic-release publish and tested 4.8.1 (no lockfile); the breakage only surfaced once v4.8.2 reached the registry.
102
+ - **Decision:** The dependency-mode decision now keys off `$NPM_INSTALLED`, not a bare lockfile-presence check. npm source + shipped lock → `bun install --frozen-lockfile --production` (pinned, devDeps skipped). npm source, no lock → `--production` (legacy). git/local + lock → `--frozen-lockfile` (dev wants devDeps). git/local, no lock → hard error (unchanged).
103
+ - **Alternatives considered:** (1) Revert D007 (don't ship bun.lock) — rejected: pinning transitive deps is the correct supply-chain posture; the packaging was not the flaw, the mode-selector was. (2) Remove the #194 guard entirely and let the old `[ -f bun.lock ]` branch run plain `--frozen-lockfile` — rejected: that would pull devDependencies (semantic-release) into npm installs, breaking the smoke's devDep-exclusion assertion and bloating user installs.
104
+ - **Consequences:** npm-installed packages are pinned to the shipped lockfile while still skipping devDeps. **Coverage hole identified:** smoke's npm-path test installs `@latest` (published version), so an unpublished PR branch is never validated against its own packed tarball — add a version-consistency guard so "testing the wrong release" is loud, not silent.
105
+
106
+ ## D009: linesChanged counts each line exactly once via structured LineMetrics
107
+
108
+ - **Date:** 2026-09-06
109
+ - **PR:** #200 (pending #166 fix)
110
+ - **Context:** Bug B65 (#166). `replaceHash` computed `linesChanged = |Δlines| + countChangedLines(...)`, and `countChangedLines` treated any line missing on one side of a paired comparison as "changed." A pure append of N lines was therefore counted twice (once in the size delta, once in the comparison diffs), reporting 2N.
111
+ - **Decision:** Extract blast-radius math into a reusable, idempotent `LineMetrics` module (`computeLineMetrics`) returning `{ added, removed, modified, changed }` where `changed = added + removed + modified` and each line is counted exactly once. `linesChanged` becomes `changed`. The result now also carries a structured `lineMetrics` breakdown alongside the scalar.
112
+ - **Alternatives considered:** (1) Fix the old formula in place — rejected: the double-count is inherent to adding |Δ| to a positional diff; and the metrics logic would stay buried in `hash-edit`, un-reusable and untestable in isolation. (2) LCS-based diff for exact pairwise alignment — rejected: overkill for blast-radius; position-equality is a correct, predictable metric for range replacement. (3) Keep `countChangedLines` positional semantics — rejected: it is the source of the bug.
113
+ - **Consequences:** `linesChanged` and `message` wording for success results change (both now reflect true touched-line count; message appends a human-readable breakdown). The comparison predicate is injectable (extensible seam). Metrics are pure, idempotent, and dual-format (structured `lineMetrics` + `describeLineMetrics` text). No change to actual edit application or returned file content — metrics-only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigknoxy/hashpilot",
3
- "version": "4.8.2",
3
+ "version": "4.8.4",
4
4
  "description": "HashPilot — Global Tool-Agnostic Structured Editing Core for Coding Agents",
5
5
  "type": "module",
6
6
  "engines": {
@@ -405,31 +405,45 @@ log "Installing dependencies..."
405
405
  # (e.g. an npm extraction that somehow shipped a lockfile, or a git/local
406
406
  # source that's missing one) fails loudly here instead of silently
407
407
  # guessing.
408
- if [ "$NPM_INSTALLED" = "true" ] && [ -f "$SOURCE_DIR/bun.lock" ]; then
409
- err "npm-sourced install unexpectedly has a bun.lock — refusing to guess which dependency mode is correct"
410
- exit 1
411
- fi
412
408
  if [ "$NPM_INSTALLED" = "false" ] && [ ! -f "$SOURCE_DIR/bun.lock" ]; then
413
409
  err "Source is missing bun.lock and wasn't installed from npm — refusing to guess which dependency mode is correct"
414
410
  err "(local-clone and --source installs are expected to have bun.lock, same as the git repo does)"
415
411
  exit 1
416
412
  fi
417
413
 
418
- if [ -f "$SOURCE_DIR/bun.lock" ]; then
414
+ # Dependency mode decided here, not by a bare [ -f bun.lock ] presence check:
415
+ # - git/local source with bun.lock -> --frozen-lockfile (dev wants devDeps)
416
+ # - npm source with bun.lock (D007) -> --frozen-lockfile --production (pinned,
417
+ # but skip devDeps — the npm package lists them in package.json even though
418
+ # `files` excludes them; a plain --frozen-lockfile would pull semantic-release)
419
+ # - npm source without bun.lock -> --production (legacy, pre-D007)
420
+ if [ "$NPM_INSTALLED" = "true" ]; then
421
+ # npm always skips devDependencies. When the shipped lockfile is present
422
+ # (D007), pin to it too; otherwise resolve production deps fresh.
423
+ if [ -f "$SOURCE_DIR/bun.lock" ]; then
424
+ detail "bun.lock shipped (npm package) — installing pinned production dependencies"
425
+ cd "$TARGET_DIR/structured-editing"
426
+ bun install --frozen-lockfile --production 2>&1 | while IFS= read -r line; do detail "$line"; done
427
+ cd "$OLDPWD"
428
+ else
429
+ # The npm-published package.json still lists devDependencies (npm's
430
+ # `files` field controls which FILES ship, not which package.json fields
431
+ # do) — a plain `bun install` would resolve and install semantic-release,
432
+ # fast-check, and the rest of the dev toolchain for no reason on an end
433
+ # user's machine. --production skips them; the CLI never needs them.
434
+ detail "No bun.lock shipped (npm package install) — resolving production dependencies fresh"
435
+ rm -f "$TARGET_DIR/structured-editing/bun.lock"
436
+ cd "$TARGET_DIR/structured-editing"
437
+ bun install --production 2>&1 | while IFS= read -r line; do detail "$line"; done
438
+ cd "$OLDPWD"
439
+ fi
440
+ elif [ -f "$SOURCE_DIR/bun.lock" ]; then
419
441
  cd "$TARGET_DIR/structured-editing"
420
442
  bun install --frozen-lockfile 2>&1 | while IFS= read -r line; do detail "$line"; done
421
443
  cd "$OLDPWD"
422
444
  else
423
- # The npm-published package.json still lists devDependencies (npm's
424
- # `files` field controls which FILES ship, not which package.json fields
425
- # do) — a plain `bun install` would resolve and install semantic-release,
426
- # fast-check, and the rest of the dev toolchain for no reason on an end
427
- # user's machine. --production skips them; the CLI never needs them.
428
- detail "No bun.lock shipped (npm package install) — resolving production dependencies fresh"
429
- rm -f "$TARGET_DIR/structured-editing/bun.lock"
430
- cd "$TARGET_DIR/structured-editing"
431
- bun install --production 2>&1 | while IFS= read -r line; do detail "$line"; done
432
- cd "$OLDPWD"
445
+ err "Source has no bun.lock and was not installed from npm — nothing to pin against"
446
+ exit 1
433
447
  fi
434
448
  detail "Dependencies installed"
435
449
 
@@ -5,6 +5,11 @@ import { assertWritable, atomicWrite, PathDeniedError, type AssertWritableOption
5
5
  import { recordSnapshot } from "./snapshot";
6
6
  import { firstParseError } from "./ast-edit";
7
7
  import { readDecoded } from "./encoding";
8
+ import {
9
+ computeLineMetrics,
10
+ describeLineMetrics,
11
+ type LineMetrics,
12
+ } from "./line-metrics";
8
13
 
9
14
  /**
10
15
  * What to do when the anchor hash no longer matches the content at the given range.
@@ -57,6 +62,13 @@ export interface ReplaceHashResult {
57
62
  */
58
63
  newRange?: { start: number; end: number };
59
64
  linesChanged: number;
65
+ /**
66
+ * Structured breakdown of the blast radius: `added`, `removed`, `modified`,
67
+ * and `changed`. `changed` equals `linesChanged`. Never double-counts lines
68
+ * that are both appended/removed and reflected in the size delta (#166).
69
+ * Absent on failure paths.
70
+ */
71
+ lineMetrics?: LineMetrics;
60
72
  stale: boolean;
61
73
  message: string;
62
74
  diff?: string;
@@ -291,7 +303,8 @@ async function applyReplacement(
291
303
  const newRangeText = newContentLines.join("\n");
292
304
  const newRangeHash = computeHash(newRangeText);
293
305
  const diff = buildDiff(targetStart + 1, targetLines, newContentLines);
294
- const linesChanged = Math.abs(newContentLines.length - targetLines.length) + countChangedLines(targetLines, newContentLines);
306
+ const lineMetrics = computeLineMetrics(targetLines, newContentLines);
307
+ const linesChanged = lineMetrics.changed;
295
308
  const rangeLabel = `range ${targetStart + 1}-${targetEnd}`;
296
309
 
297
310
  // A hash edit is content-blind: it will happily splice half a function into
@@ -338,12 +351,13 @@ async function applyReplacement(
338
351
  fileHash: newFullHash,
339
352
  newRange: { start: targetStart + 1, end: targetStart + newContentLines.length },
340
353
  linesChanged,
354
+ lineMetrics,
341
355
  stale,
342
356
  retries,
343
357
  relocatedTo,
344
358
  message: dryRun
345
- ? `${action} ${targetLines.length} lines with ${newContentLines.length} lines${messageSuffix}`
346
- : `${action} ${targetLines.length} lines with ${newContentLines.length} lines${messageSuffix} (${rangeLabel})`,
359
+ ? `${action} ${targetLines.length} lines with ${newContentLines.length} lines${describeLineMetrics(lineMetrics)}${messageSuffix}`
360
+ : `${action} ${targetLines.length} lines with ${newContentLines.length} lines${describeLineMetrics(lineMetrics)}${messageSuffix} (${rangeLabel})`,
347
361
  diff,
348
362
  };
349
363
  }
@@ -404,13 +418,4 @@ function buildDiff(
404
418
  }
405
419
  }
406
420
  return parts.join("\n");
407
- }
408
-
409
- function countChangedLines(oldLines: string[], newLines: string[]): number {
410
- let count = 0;
411
- const maxLen = Math.max(oldLines.length, newLines.length);
412
- for (let i = 0; i < maxLen; i++) {
413
- if ((oldLines[i] ?? "") !== (newLines[i] ?? "")) count++;
414
- }
415
- return count;
416
421
  }
package/src/core/index.ts CHANGED
@@ -6,6 +6,12 @@ export { search, parseZgMarkdown, matchesSource, DEFAULT_SOURCE_GLOBS } from "./
6
6
  export type { SearchResult, SearchHit, ZgSearchResult, GrepSearchResult, SearchOptions } from "./search";
7
7
  export { replaceHash } from "./hash-edit";
8
8
  export type { ReplaceHashResult, ReplaceHashOptions } from "./hash-edit";
9
+ export {
10
+ computeLineMetrics,
11
+ describeLineMetrics,
12
+ computeChangedLinesScore,
13
+ } from "./line-metrics";
14
+ export type { LineMetrics, LineComparer } from "./line-metrics";
9
15
  export {
10
16
  findSymbols,
11
17
  findSymbolsDetailed,
@@ -0,0 +1,95 @@
1
+ /**
2
+ * LineMetrics — blast-radius metrics for line-level edits.
3
+ *
4
+ * Computes how many lines a replace operation touches, WITHOUT double-counting
5
+ * lines that are both appended/removed AND reflected in the size delta.
6
+ *
7
+ * SOLID design:
8
+ * - Single responsibility: this module only measures line deltas. It does not
9
+ * apply edits, write files, or build diffs.
10
+ * - Open for extension: the comparison predicate is injectable (see
11
+ * `countChangedBy`), so callers can define their own notion of "changed"
12
+ * (e.g. ignore whitespace) without editing core.
13
+ * - Idempotent: pure function of its inputs. Same (old, new) → same result.
14
+ * - Dual-format: `compute` returns a structured, agent-parseable object;
15
+ * `describe` renders the same data as human-readable text. Human text is
16
+ * generated FROM the structured value, never maintained separately.
17
+ */
18
+
19
+ /** Structured line-metrics result (agent-parseable shape). */
20
+ export interface LineMetrics {
21
+ /** Lines present in new but not old. */
22
+ added: number;
23
+ /** Lines present in old but not new. */
24
+ removed: number;
25
+ /** Lines present in both but different. */
26
+ modified: number;
27
+ /**
28
+ * Total lines touched by the edit. NEVER double-counts: a line is counted
29
+ * exactly once — as added, removed, or modified. Pure append of N lines is
30
+ * `added=N, changed=N` (not `2N`).
31
+ */
32
+ changed: number;
33
+ }
34
+
35
+ /** Predicate defining when a line occupying the same slot is "changed". */
36
+ export type LineComparer = (oldLine: string, newLine: string) => boolean;
37
+
38
+ /**
39
+ * Compute line metrics between two line arrays.
40
+ *
41
+ * The tricky case (fixed here): a line that exists on only one side of the
42
+ * comparison is "added" or "removed", NOT "modified". Naive implementations
43
+ * (e.g. `old[i] ?? "" !== new[i] ?? ""`) count such lines as "modified", so a
44
+ * pure append of N lines reports N added + N modified = 2N. This module counts
45
+ * each line exactly once.
46
+ *
47
+ * Idempotence guarantee: `compute(a, b) === compute(a, b)` for identical
48
+ * inputs — no state, no randomness, no mutation.
49
+ */
50
+ export function computeLineMetrics(
51
+ oldLines: string[],
52
+ newLines: string[],
53
+ isChanged: LineComparer = (a, b) => a !== b,
54
+ ): LineMetrics {
55
+ let added = 0;
56
+ let removed = 0;
57
+ let modified = 0;
58
+
59
+ const shared = Math.min(oldLines.length, newLines.length);
60
+ for (let i = 0; i < shared; i++) {
61
+ if (isChanged(oldLines[i], newLines[i])) modified++;
62
+ }
63
+ // Tail beyond the shorter array is purely added or purely removed.
64
+ if (newLines.length > oldLines.length) {
65
+ added = newLines.length - oldLines.length;
66
+ } else if (oldLines.length > newLines.length) {
67
+ removed = oldLines.length - newLines.length;
68
+ }
69
+
70
+ return { added, removed, modified, changed: added + removed + modified };
71
+ }
72
+
73
+ /** Human-readable rendering of {@link LineMetrics} (generated from structured). */
74
+ export function describeLineMetrics(m: LineMetrics): string {
75
+ const parts: string[] = [];
76
+ if (m.added) parts.push(`${m.added} added`);
77
+ if (m.removed) parts.push(`${m.removed} removed`);
78
+ if (m.modified) parts.push(`${m.modified} modified`);
79
+ const detail = parts.length ? ` (${parts.join(", ")})` : "";
80
+ return `${m.changed} line${m.changed === 1 ? "" : "s"} touched${detail}`;
81
+ }
82
+
83
+ /**
84
+ * Convenience: compute metrics for a range replacement given the OLD target
85
+ * lines and NEW replacement lines. Keeps the "changed" number available both
86
+ * as an object and as a plain count for callers that only need the scalar.
87
+ */
88
+ export function computeChangedLinesScore(
89
+ oldLines: string[],
90
+ newLines: string[],
91
+ isChanged?: LineComparer,
92
+ ): { metrics: LineMetrics; changed: number } {
93
+ const metrics = computeLineMetrics(oldLines, newLines, isChanged);
94
+ return { metrics, changed: metrics.changed };
95
+ }