@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.
- package/docs/decisions/README.md +23 -0
- package/package.json +1 -1
- package/scripts/install.sh +29 -15
- package/src/core/hash-edit.ts +17 -12
- package/src/core/index.ts +6 -0
- package/src/core/line-metrics.ts +95 -0
package/docs/decisions/README.md
CHANGED
|
@@ -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
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
|
package/src/core/hash-edit.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|