diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 9788c8b..e439ddb 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -739,3 +739,40 @@ Architecture-shaping decisions graduate to an ADR under drafts (round 20); the intents-README "always they/them" persona-quote rule read against `03-personas.md`'s gendered narrative pronouns (round 24). +- 2026-08-06 — Bug-hunt round 30: `transcribe.mapSegments`'s word-level + emptiness check now decides presence on `session.SafeText`'s rendered form, + matching the segment-level guard five lines above it — a word that is + entirely invisible-only Unicode (e.g. ZWSP U+200B) was non-empty raw and + survived `strings.TrimSpace`, so it reached `transcript.jsonl`, + `timeline.jsonl`, and the analysis request as a timestamped word with no + visible content. `install.sh`'s `install_ffmpeg_local` error-handling + comment no longer claims the EXIT trap "covers only `install_binary`'s + `$tmp`" — that trap has swept `$tmp2` since round 21's fix; the comment was + stale from the moment it landed in the same commit that widened the trap. + Refuted: an `analyze.indexTimeline` map write keying `idx.uttText` under an + unguarded raw id (the read path is gated behind `idx.ids`, which is itself + gated, so the empty key is write-only dead data with no observable effect); + `session.WriteFileAtomicNoFollow` refusing only symlinks, not other + non-regular files, at its target path (the function never opens the + pre-existing file — it renames a temp file into place, and `rename(2)` + neither opens, blocks on, nor writes through a FIFO/device/socket, so the + hazard the sibling `openNoFollow`'s stricter check exists to prevent is + absent by construction); `cli.md`'s `-offset` table cell compressing the + "external audio vs the session's own `audio.wav`" split as "with/without + `-audio`" (the surrounding prose states the exception twice within ten + lines, and the table already compresses two other branches the same way); + `ci.yml`'s cross-compile step comment ("linux/amd64 already covered by + Build above") read as a claim about `CGO_ENABLED`, which it never makes — + the `CGO_ENABLED=0` clause and the coverage clause are two independent + statements joined by a semicolon, and the coverage claim holds regardless; + `analyse-a-session.md`'s "four steps" intro read against its five numbered + headings (the fifth, re-rendering the report, is grammatically set off by + an em dash as a follow-on outside the enumerated list, and invokes a + different pipeline command, `report`, from the four analysis-layer steps + proper); `session-directory.md`'s abbreviated `utt-003` example text read + against the fuller fixture sentence (the page's other examples are + established as illustrative reductions too — the manifest example already + drops three fixture fields — and no `findings.jsonl` quote cites `utt-003`, + so nothing depends on the byte-exact form). Also excluded before + verification, as a precedent duplicate: `AGENTS.md` claiming CI runs plain + `go test ./...` (round 13). diff --git a/install.sh b/install.sh index c208779..9966e0d 100644 --- a/install.sh +++ b/install.sh @@ -279,10 +279,11 @@ install_ffmpeg_local() { # available, and refuse on a bad or wrong-key signature. # Every fetch/unpack below is guarded with the err-skip-return # convention the parse failure already uses: ffmpeg is an OPTIONAL - # dependency, and under `set -eu` an unguarded failure aborted the - # whole installer with the child's raw exit code — skipping the ASR - # step and the closing guidance, and leaking $tmp2 (the EXIT trap - # covers only install_binary's $tmp). + # dependency, and under `set -eu` an unguarded failure would abort + # the whole installer with the child's raw exit code, skipping the + # ASR step and the closing guidance (install_binary's EXIT trap + # still sweeps $tmp2, so nothing leaks — but the abort itself is + # still the wrong outcome for an optional dependency). say "Fetching static ffmpeg build (evermeet.cx) ..." fetch "https://evermeet.cx/ffmpeg/info/ffmpeg/release" "$tmp2/info.json" \ || { err "could not reach evermeet.cx; skipping ffmpeg"; rm -rf "$tmp2"; return; } diff --git a/internal/transcribe/transcribe.go b/internal/transcribe/transcribe.go index 5bad042..17de563 100644 --- a/internal/transcribe/transcribe.go +++ b/internal/transcribe/transcribe.go @@ -525,7 +525,11 @@ func mapSegments(segs []segment, offset float64) ([]timeline.Utterance, error) { } for _, w := range s.words { word := strings.TrimSpace(w.W) - if word == "" { + // Presence is decided on the rendered form, matching the segment + // guard above: an invisible-only word (Cf) is non-empty raw and + // survives TrimSpace, so it would otherwise reach transcript.jsonl + // and the analysis request as a timestamped word with no content. + if strings.TrimSpace(session.SafeText(word)) == "" { continue } // An implausible word time is dropped, not refused: the same policy diff --git a/internal/transcribe/transcribe_test.go b/internal/transcribe/transcribe_test.go index 0c2e099..510226b 100644 --- a/internal/transcribe/transcribe_test.go +++ b/internal/transcribe/transcribe_test.go @@ -331,6 +331,36 @@ func TestMapSegmentsDropsInvisibleOnlyText(t *testing.T) { } } +// TestMapSegmentsDropsInvisibleOnlyWord mirrors +// TestMapSegmentsDropsInvisibleOnlyText at word level: the word-level +// emptiness check tested only strings.TrimSpace(w.W), the raw form, unlike +// the segment-level guard immediately above it, which decides presence on +// session.SafeText's rendered form. A word that is entirely invisible-only +// Unicode (ZWSP U+200B) is non-empty raw and survives TrimSpace, so it +// reached transcript.jsonl, timeline.jsonl, and the analysis request as a +// timestamped word with no visible content. An ordinary whitespace-only word +// must still be dropped the same way. +func TestMapSegmentsDropsInvisibleOnlyWord(t *testing.T) { + zeroWidthSpace := string(rune(0x200B)) + utts, err := mapSegments([]segment{ + {start: 0, end: 1, text: "real words", words: []timeline.Word{ + {W: "real", T: 0}, + {W: zeroWidthSpace, T: 0.5}, + {W: " ", T: 0.7}, + {W: "words", T: 1}, + }}, + }, 0) + if err != nil { + t.Fatalf("mapSegments: %v", err) + } + if len(utts) != 1 { + t.Fatalf("want 1 utterance, got %d", len(utts)) + } + if len(utts[0].Words) != 2 || utts[0].Words[0].W != "real" || utts[0].Words[1].W != "words" { + t.Fatalf("want only the two visible words kept, got %+v", utts[0].Words) + } +} + // TestCheckOffset pins the explicit-flag bound: the derived and sidecar paths // refuse a non-finite or over-magnitude offset where the bad value enters, and // the flag path must apply the same rule. Every genuine offset (including the