diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 82ea8bc62..3120d53c1 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -17,6 +17,8 @@ DO NOT UPDATE FOR: | Date | Learning | |----|--------| +| 2026-06-01 | An error-discard catalogue is an inventory, not a verdict — verify each site by reading before fixing | +| 2026-06-01 | Guard managed blocks before regenerating; don't trust the span to be machine-owned | | 2026-05-30 | Capture golden fixtures from the live legacy code path before deleting it | | 2026-05-30 | tpl package is magic-string-audit-exempt but its call sites are not | | 2026-05-30 | New exported types must live in types.go or TestTypeFileConvention fails | @@ -169,6 +171,26 @@ DO NOT UPDATE FOR: --- +## [2026-06-01-195111] An error-discard catalogue is an inventory, not a verdict — verify each site by reading before fixing + +**Context**: Phase EH audited ~184 silent error-discard sites under internal/. The catalogue was built by grep + pattern/name classification (e.g. 'x, _ := SomethingMarshal' => B-marshal). When fixing, several name-inferred verdicts were wrong. + +**Lesson**: Classifying a discard by the callee's name or a regex is a guess, not a fact. The discarded value's actual type and the call's role decide the category. Concrete false positives this pass: MergePublished returns (string, bool) — the discard is a 'markers missing' bool, not an error; LoadState returns a State value (not a pointer), so a 'nil-deref' was impossible; io/security's atomic writer already checked the meaningful close and only discarded error-path cleanup closes. All three would have been 'fixed' (churn or breakage) on name-inference alone. + +**Application**: Treat any auto-generated audit/catalogue as a worklist of candidates, not findings. Before editing a flagged site, read the callee signature (is the discarded value even an error?) and the enclosing control flow (is it an already-failed path, a best-effort callback, or a data path?). Only then assign return-error vs logWarn vs annotate. This mirrors the Constitution's Context Integrity Invariants: never act on assumed content. + +--- + +## [2026-06-01-174927] Guard managed blocks before regenerating; don't trust the span to be machine-owned + +**Context**: ctx learning add silently deleted entry bodies that lived between INDEX:START/END markers: index.Update replaced the whole marker span with a regenerated table, and ParseHeaders scanning the full file made the result look complete, hiding the loss. + +**Lesson**: Code that 'replaces the managed block' (index regen, KB managed blocks, moc.go) assumes the span between its markers is disposable and machine-owned. That assumption breaks the moment user content drifts inside the markers, and the regenerated output looks correct so the loss is invisible. The fix is a precondition guard that refuses to mutate when regeneration would lose data — not smarter parsing of the trapped content. + +**Application**: Before any 'replace between markers' write, validate the span: refuse on entry/content found where only generated output belongs, and on malformed/duplicated/out-of-order markers. Fail loud and leave the file byte-identical rather than regenerate. Run the guard at the read-before-mutate choke point so nothing is written on refusal. + +--- + ## [2026-05-30-212109] Capture golden fixtures from the live legacy code path before deleting it **Context**: Behavior-preserving refactors of LoopScript composition and the recall
/ assembly had fragile whitespace where hand-transcribing the expected output risked silent drift from the original bytes. diff --git a/.context/TASKS.md b/.context/TASKS.md index dc414d99f..8b76b1dcf 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -36,6 +36,48 @@ TASK STATUS LABELS: These have priority because other knowledge ingestion projects depend on them. +- [x] BUG (data loss): `ctx learning add` clobbers a dash-bullet-format + `LEARNINGS.md`. When the `INDEX:START/END` block uses the dash-bullet format + (what `ctx init` produces) rather than the pipe-table format, `ctx learning + add` (1) rewrites the index as a table, (2) **duplicates** the + `` marker, and (3) **drops every existing learning body** — + keeping only the newly added one. Observed live in `things-wtf-hub` (session + aa32f065): a `LEARNINGS.md` with 4 bodies collapsed to 1 (a -44-line commit, + 2dc4d1a); recovered via `git show :.context/LEARNINGS.md`. + `ctx decision add` is UNAFFECTED because that repo's `DECISIONS.md` was already + table-format — so the bug is specifically the learning-add path's handling of + the dash-bullet index variant (likely it can't parse dash-bullet entries, so + it treats the file as empty and regenerates from only the new entry). + - Repro: `ctx init` a repo, confirm `LEARNINGS.md` index is dash-bullet + (`- entry`), add 2+ learnings by hand in that format, then run + `ctx learning add "x" --context … --lesson … --application …`. Expect: the + hand-authored bodies vanish + a duplicated INDEX:START marker. + - Likely fix: detect the existing index format (dash-bullet vs table) and + preserve it round-trip, OR parse dash-bullet entries before regenerating; + never emit a second INDEX:START; never drop bodies the parser didn't + recognize (fail loud instead of silently regenerating). + - Guard: a round-trip test for BOTH index formats (dash-bullet + table) that + asserts existing bodies survive an add and exactly one marker pair remains. + - Severity: HIGH — silent destruction of persisted memory, the one thing ctx + promises to protect; only git made it recoverable. + - Provenance: things-wtf-hub session aa32f065 wrap-up; full write-up in that + repo's LEARNINGS.md ("`ctx learning add` clobbers a dash-bullet-format + LEARNINGS.md"). #priority:high #added:2026-05-30 + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Shipped: new `index.Validate` precondition guard refuses to regenerate the + index when it would lose data — entry bodies (`## [ts]` headers) trapped + between the markers, or markers that are missing/duplicated/out-of-order. + Wired into the two read-before-mutate choke points (`entry.Write` and + `index.Reindex`), so add and all reindex commands fail loud and leave the + file byte-identical instead of clobbering it. `index.Update`'s signature is + untouched (kept the CRITICAL blast radius stable). New `internal/err/index` + package + i18n messages. Verified: the real repro now errors with the file + unchanged; well-formed adds still preserve all bodies and one marker pair. + Tests: `index.TestValidate` (7 shapes) + `entry` round-trip + (refused-untouched + well-formed-preserved). Chosen behavior is fail-loud + + manual fix; auto-repair (`reindex --repair`) considered and declined. + Spec: specs/fix-learning-add-index-data-loss.md. + - [x] Make 'ctx kb reindex' nesting-aware: scan topics/** not topics/* (grouped topic folders currently blank the CTX: KB:TOPICS block) #priority:medium #session:c3d2dcb1 #branch: @@ -618,7 +660,7 @@ Many call sites use `_ =` or `_, _ =` to discard errors without any feedback. Some are legitimate (best-effort cleanup), most are lazy escapes that hide failures. -- [ ] EH.1: Catalogue all silent error discards — recursive walk of +- [x] EH.1: Catalogue all silent error discards — recursive walk of `internal/` for patterns: `_ = `, `_, _ = `, `//nolint:errcheck`, bare `return` after error-producing calls. Group by category: @@ -633,34 +675,70 @@ lazy escapes that hide failures. DoD: every `_ =` in the codebase is categorised and has a recommended action #priority:high #added:2026-03-14 - -- [ ] EH.2: Address category (b) — file write/read discards. These risk silent + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Done: `.context/audit/eh-silent-errors.md` catalogues all 184 non-test + discard sites with category + recommended action. Surfaced 4 high-priority + data-loss/crash findings (memory/publish MergePublished, pad/store + ReadEntriesWithIDs, hub/replicate Append, memory status nil-deref) plus 11 + write-handle defer-closes. Real fix workload ≈52 sites (B/A/C/SURFACE/ + NIL-DEREF); category (d) fmt.Fprint output is an accepted end-state per EH.5 + DoD. Tests excluded from this pass. + +- [x] EH.2: Address category (b) — file write/read discards. These risk silent data loss. Fix: return the error, or at minimum emit to stderr with `fmt.Fprintf(os.Stderr, "ctx: ...: %v\n", err)` following the pattern established in `internal/log/event.go`. DoD: no write/read error is silently discarded #priority:high #added:2026-03-14 - -- [ ] EH.3: Address category (a) — file close in defer. Most are `defer func() + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Done: pad ReadEntriesWithIDs + hub replicate Append (commit b66816cd); + marshal-error returns for the vscode/copilot/blocknonpathctx writers, + journal ScanDirectory + drift reload via logWarn (41e223f5). The + established sink turned out to be `internal/log/warn` (logWarn.Warn), + used throughout. Verified each site by reading before editing — two of + the catalogue's name-inferred B findings were false positives + (MergePublished bool, LoadState value type). + +- [x] EH.3: Address category (a) — file close in defer. Most are `defer func() { _ = f.Close() }()`. For read-only files, close errors are rare but should still surface. For write/append files, close can fail if the final flush fails — these are data loss. Fix: `if err := f.Close(); err != nil { fmt.Fprintf(os.Stderr, "ctx: close %s: %v\n", path, err) }`. DoD: all defer-close sites log failures to stderr #priority:medium #added:2026-03-14 - -- [ ] EH.4: Address category (c) — os.Remove/Rename discards. These are state + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Done: write/append-handle closes converted to a named-return merge so + a failed flush fails the op (6 kb appends, trace appendJSONL, skill + copy out, kb note Run — commit 9d07da57); read-handle and gRPC-client + closes surfaced via logWarn (commit 06109734). io/security + SafeWriteFileAtomic was already correct (meaningful close checked; + error-path closes annotated). + +- [x] EH.4: Address category (c) — os.Remove/Rename discards. These are state operations (rotation, pruning, temp file cleanup). Silent failure leaves stale state. Fix: stderr warning at minimum; for rotation/rename, consider returning the error. DoD: no Remove/Rename error is silently discarded #priority:medium #added:2026-03-14 - -- [ ] EH.5: Validate — `grep -rn '_ =' internal/` returns only category (d) + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Done (commit 06a88416): os.Remove/RemoveAll surfaced via logWarn where + failure leaves stale state with real consequences — partial skill + install, violations file (duplicate alerts), sync lock (blocks sync), + hub pid file, trace marker. io/security temp-file cleanup discards are + on already-failed paths and annotated as acceptable. + +- [x] EH.5: Validate — `grep -rn '_ =' internal/` returns only category (d) entries (fmt.Fprint to stderr) and entries explicitly annotated as acceptable. Run `make lint && make test` to confirm no regressions. DoD: grep output is clean or fully annotated; CI green #priority:high #added:2026-03-14 + #completed:2026-06-01 #branch:fix/learning-add-index-data-loss + Done (commit f7bf7d8f): `grep -rn '_ = ' internal/` (non-test) = 68 + sites — 47 category-(d) fmt.Fprint (accepted end-state) + 21 explicitly + annotated/handled. `:=`-form discards (x, _ := …) are outside this + grep's scope. make lint = 0 issues, make test = 0 failures. + Whole EH sweep: 7 commits (6ca1198a catalogue → f7bf7d8f), spec + specs/error-handling-audit.md. - [ ] Add AST-based lint test to detect exported functions with no external callers #added:2026-03-21-070357 diff --git a/.context/audit/eh-silent-errors.md b/.context/audit/eh-silent-errors.md new file mode 100644 index 000000000..87dd5a51a --- /dev/null +++ b/.context/audit/eh-silent-errors.md @@ -0,0 +1,266 @@ +# EH.1 — Silent Error Discard Catalogue + +Audit of every error-discard site under `internal/` (non-test). +Generated for Phase EH (Error Handling Audit). Every `_ =`, +`_, _ =`, and `x, _ :=` site is surfaced and categorised — no +triage that quietly drops some. Recommended actions drive EH.2–EH.5. + +Method: `grep -rnE '(_ :?= |, _ :?= |_, _ :?= )' internal/ --include='*.go'` +(excluding `_test.go`), then per-site classification by the discarded +value's type and the call's role. Tests are out of scope for this pass. + +## Category legend + +| Tag | Meaning | Action | +|-----|---------|--------| +| `B-data` | error dropped on a data-write path; corruption/loss | **return the error** | +| `NIL-DEREF` | error dropped then result dereferenced | **guard before deref** | +| `B-marshal` | Marshal/Unmarshal error dropped; bad data written/read | **surface** | +| `B-parse` | Parse error dropped; zero value used silently | **surface** | +| `A-close-WRITE` | defer-close on a write/append handle; flush-fail = data loss | **surface close error** | +| `A-close-read` | defer-close on a read handle | **stderr-log (EH.3)** | +| `A-close-bare` | non-defer close discard | **surface or stderr** | +| `SURFACE` | error dropped; stale/empty result flows on | **surface/stderr** | +| `C-state` | os.Remove/Rename/Shutdown dropped; stale state | **stderr warn (EH.4)** | +| `D-output` | fmt.Fprint to cmd out/err | **convert to cmd.Print* (drops the discard)** | +| `besteffort` | telemetry/display/callback; failure is tolerable | annotate intent | +| `OK*` | discarded value is `ok` bool / nil-safe / init-time programmer-error | annotate | +| `FALSE-POS` | discarded value is not an error (string, bool, compile assertion) | none | + +## Summary + +Counts by category (184 sites total): + +| Category | Count | +|----------|------:| +| D-output | 47 | +| FALSE-POS | 21 | +| OK-flag | 20 | +| besteffort | 16 | +| A-close-read | 13 | +| A-close-WRITE | 11 | +| OK-typeassert | 10 | +| B-marshal | 10 | +| OK-markflag | 7 | +| C-state | 7 | +| OK-atoi | 4 | +| OK | 4 | +| SURFACE | 3 | +| OK-glob | 3 | +| B-data | 3 | +| A-close-bare | 3 | +| NIL-DEREF | 1 | +| B-parse | 1 | + +### High-priority findings (data loss) + +These are not style nits — they silently lose or corrupt data: + +- `internal/cli/pad/core/store/store.go:257` — `ReadEntriesWithIDs` error + dropped; `(nil, nil)` is the legitimate no-pad case, so a non-nil error means + the prior blob exists but is unreadable/undecryptable — and the store is then + overwritten with reset IDs. Same data-loss shape as + `specs/fix-learning-add-index-data-loss.md`. **Fixed (EH.2):** surface the error. +- `internal/hub/replicate.go:121` — `store.Append` error dropped in the follower + replication loop; a replicated hub entry is silently lost. **Fixed (EH.2):** + `logWarn.Warn` (best-effort loop, no return path). +- 11 × `A-close-WRITE` — defer-close on `SafeAppendFile`/`SafeCreateFile` + handles, where a failed final flush silently loses the appended row. + +> **Correction (verified at fix time):** two callouts in the first cut of this +> catalogue were name-inferred and wrong. `internal/memory/publish.go:170` +> (`MergePublished`) returns `(string, bool)` — the discarded value is a "markers +> were missing" bool, not an error; reclassified `FALSE-POS`. +> `internal/cli/memory/cmd/status/run.go:54` (`LoadState`) returns a `State` +> **value** (not a pointer), so `state.LastSync` cannot nil-deref; reclassified +> `besteffort` (display-only). Lesson: the auto/name-inferred categories below +> are an inventory, not a verdict — every site is read before it is fixed. + +## Full catalogue + +| Category | Site | Expression | Recommended action | +|----------|------|------------|--------------------| +| B-data | `internal/cli/pad/core/store/store.go:257` | `existing, _ := ReadEntriesWithIDs()` | ReadEntriesWithIDs error dropped; existing IDs lost on rewrite — surface/return | +| B-data | `internal/hub/replicate.go:121` | `_, _ = store.Append([]Entry{entry})` | store.Append error dropped; replicated entry silently lost — stderr/return | +| FALSE-POS | `internal/memory/publish.go:170` | `merged, _ := MergePublished(string(existing), formatted)` | 2nd return is a "markers missing" bool, not an error; merged is always valid (verified) | +| besteffort | `internal/cli/memory/cmd/status/run.go:54` | `state, _ := mem.LoadState(contextDir)` | LoadState returns a State value (no nil-deref); display-only "never synced" on error — annotate | +| B-marshal | `internal/cli/initialize/core/vscode/extension.go:59` | `data, _ := json.MarshalIndent(content, "", token.Indent2)` | surface: empty/partial data written on failure | +| B-marshal | `internal/cli/initialize/core/vscode/mcp.go:50` | `data, _ := json.MarshalIndent(file, "", token.Indent2)` | surface: empty/partial data written on failure | +| B-marshal | `internal/cli/initialize/core/vscode/tasks.go:59` | `data, _ := json.MarshalIndent(file, "", token.Indent2)` | surface: empty/partial data written on failure | +| B-marshal | `internal/cli/setup/core/copilot/vscode.go:57` | `data, _ := json.MarshalIndent(mcpCfg, "", token.Indent2)` | surface: empty/partial data written on failure | +| B-marshal | `internal/cli/system/cmd/blocknonpathctx/run.go:82` | `data, _ := json.Marshal(resp)` | surface: empty/partial data written on failure | +| B-marshal | `internal/cli/system/core/session/session.go:91` | `_ = json.Unmarshal(res.data, &input)` | json.Unmarshal of hook stdin; best-effort w/ timeout — annotate, or stderr | +| B-marshal | `internal/cli/trigger/cmd/test/cmd.go:109` | `inputJSON, _ := json.MarshalIndent(input, "", token.Indent2)` | surface: empty/partial data written on failure | +| B-marshal | `internal/steering/format.go:147` | `raw, _ := yaml.Marshal(fm)` | surface: empty/partial data written on failure | +| B-marshal | `internal/steering/format.go:195` | `raw, _ := yaml.Marshal(fm)` | surface: empty/partial data written on failure | +| B-marshal | `internal/steering/parse.go:79` | `raw, _ := yaml.Marshal(sf)` | surface: empty/partial data written on failure | +| B-parse | `internal/write/kb/sourcecoverage/parse.go:42` | `updated, _ := time.Parse(` | surface: zero value silently on failure | +| A-close-WRITE | `internal/cli/kb/cmd/note/run.go:52` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/skill/copy.go:75` | `defer func() { _ = in.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/skill/copy.go:81` | `defer func() { _ = out.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/trace/jsonl.go:40` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/trace/jsonl.go:89` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/evidence/append.go:70` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/glossary/append.go:49` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/relationship/append.go:50` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/row/append.go:53` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/sourcemap/append.go:49` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| A-close-WRITE | `internal/write/kb/timeline/append.go:49` | `defer func() { _ = f.Close() }()` | write handle: surface close error (data loss on flush fail) | +| SURFACE | `internal/cli/drift/cmd/root/run.go:75` | `ctx, _ = load.Do("")` | load.Do error dropped; stale/empty ctx re-detected — surface | +| SURFACE | `internal/cli/setup/core/opencode/opencode.go:44` | `mcpPath, _ := globalConfigPath()` | globalConfigPath resolve error dropped — surface | +| SURFACE | `internal/journal/parser/query.go:49` | `sessions, _ := ScanDirectory(resolved)` | ScanDirectory error dropped; a dir's sessions silently skipped — stderr | +| C-state | `internal/cli/connection/core/sync/state.go:52` | `release := func() { _ = os.Remove(lockPath) }` | stderr warn (stale state on failure) | +| C-state | `internal/cli/hub/core/server/daemon.go:120` | `_ = os.Remove(pidPath)` | stderr warn (stale state on failure) | +| C-state | `internal/cli/trace/core/hook/hook.go:128` | `_ = os.Remove(path)` | stderr warn (stale state on failure) | +| C-state | `internal/hub/server.go:60` | `_ = s.cluster.Shutdown()` | cluster.Shutdown error dropped on teardown — stderr | +| C-state | `internal/io/security.go:200` | `cleanup := func() { _ = os.Remove(tmpPath) }` | stderr warn (stale state on failure) | +| C-state | `internal/mcp/handler/violations.go:39` | `_ = os.Remove(filepath.Join(stateDir, file.Violations))` | stderr warn (stale state on failure) | +| C-state | `internal/skill/install.go:56` | `_ = os.RemoveAll(destDir)` | stderr warn (stale state on failure) | +| A-close-bare | `internal/hub/failover.go:57` | `_ = conn.Close()` | surface or stderr-log | +| A-close-bare | `internal/io/security.go:202` | `_ = tmp.Close()` | surface or stderr-log | +| A-close-bare | `internal/io/security.go:207` | `_ = tmp.Close()` | surface or stderr-log | +| A-close-read | `internal/cli/connection/core/listen/listen.go:44` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/connection/core/publish/publish.go:44` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/connection/core/register/register.go:43` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/connection/core/status/status.go:39` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/connection/core/sync/sync.go:48` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/hub/core/status/status.go:40` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/cli/system/core/hubsync/sync.go:76` | `defer func() { _ = client.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/hub/replicate.go:84` | `defer func() { _ = conn.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/journal/parser/copilot.go:115` | `defer func() { _ = f.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/journal/parser/copilot.go:70` | `defer func() { _ = f.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/journal/parser/copilot_cli.go:77` | `defer func() { _ = f.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/trace/resolve_entry.go:85` | `defer func() { _ = f.Close() }()` | log close failure to stderr (EH.3) | +| A-close-read | `internal/trace/working_tasks.go:38` | `defer func() { _ = f.Close() }()` | log close failure to stderr (EH.3) | +| D-output | `internal/cli/journal/core/section/section.go:104` | `_, _ = fmt.Fprintf(sb,` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/cli/journal/core/section/section.go:108` | `_, _ = fmt.Fprintf(sb, tpl.JournalIndexSummary+nl, e.Summary)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/cli/journal/core/section/section.go:177` | `_, _ = fmt.Fprintf(sb, ltTpl+nl, label, e.Title, link)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/cli/journal/core/section/section.go:97` | `_, _ = fmt.Fprintf(sb, tpl.JournalMonthHeading+nl+nl, month)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/log/warn/warn.go:35` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:168` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:179` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:190` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:246` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:252` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:257` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:264` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:394` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:412` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:554` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:560` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/handler/tool.go:71` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/server/resource/resource.go:108` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/server/route/prompt/entry.go:45` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/server/route/prompt/prompt.go:111` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/mcp/server/route/prompt/prompt.go:60` | `_, _ = fmt.Fprintf(` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/memory/diff.go:46` | `_, _ = fmt.Fprintf(&buf, desc.Text(text.DescKeyMemoryDiffOldFormat), oldPath)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/memory/diff.go:47` | `_, _ = fmt.Fprintf(&buf, desc.Text(text.DescKeyMemoryDiffNewFormat), newPath)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:183` | `_, _ = fmt.Fprintf(cmd.ErrOrStderr(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:187` | `_, _ = fmt.Fprintf(cmd.ErrOrStderr(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:191` | `_, _ = fmt.Fprintf(cmd.ErrOrStderr(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:262` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:266` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:282` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, values...)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:294` | `_, _ = fmt.Fprintln(cmd.OutOrStdout())` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:351` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:367` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:384` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:388` | `_, _ = fmt.Fprintln(cmd.OutOrStdout())` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:399` | `_, _ = fmt.Fprintln(cmd.OutOrStdout())` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:413` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:416` | `_, _ = fmt.Fprintln(cmd.OutOrStdout())` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:428` | `_, _ = fmt.Fprintln(cmd.OutOrStdout(), body)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:429` | `_, _ = fmt.Fprintln(cmd.OutOrStdout())` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:441` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:456` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:471` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/journal/source.go:485` | `_, _ = fmt.Fprintf(cmd.OutOrStdout(),` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/load/load.go:70` | `_, _ = fmt.Fprintf(&sb, tpl.LoadBudget+nl+nl, budget, totalTokens)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/load/load.go:82` | `_, _ = fmt.Fprintf(&sb, nl+sep+nl+nl+tpl.LoadTruncated+nl, f.Name)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/load/load.go:86` | `_, _ = fmt.Fprintf(&sb, tpl.LoadSectionHeading+nl+nl, titleFn(f.Name))` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| D-output | `internal/write/stat/stream.go:20` | `_, _ = fmt.Fprintln(w, line)` | best-effort CLI output; convert to cmd.Print* to drop the discard | +| besteffort | `internal/cli/add/core/run/run.go:115` | `_ = trace.Record(fType+cfgTrace.RefFirstEntry, stateDir)` | trace.Record telemetry; best-effort — annotate | +| besteffort | `internal/cli/change/cmd/root/run.go:42` | `ctxChanges, _ := scan.FindContextChanges(refTime)` | FindContextChanges for display; best-effort — annotate | +| besteffort | `internal/cli/change/cmd/root/run.go:43` | `codeChanges, _ := scan.SummarizeCodeChanges(refTime)` | SummarizeCodeChanges for display; best-effort — annotate | +| besteffort | `internal/cli/initialize/core/claudecheck/detail.go:51` | `marketplaces, _ := readMarketplaces(marketplacesPath)` | readMarketplaces detection; best-effort — annotate | +| besteffort | `internal/cli/system/cmd/checkcontextsize/run.go:93` | `info, _ := coreSession.ReadTokenInfo(sessionID)` | ReadTokenInfo for display hint; best-effort — annotate | +| besteffort | `internal/cli/system/cmd/contextloadgate/run.go:135` | `ctxChanges, _ := changeCore.FindContextChanges(refTime)` | FindContextChanges in gate; best-effort — annotate | +| besteffort | `internal/cli/system/cmd/contextloadgate/run.go:136` | `codeChanges, _ := changeCore.SummarizeCodeChanges(refTime)` | SummarizeCodeChanges in gate; best-effort — annotate | +| besteffort | `internal/cli/system/cmd/heartbeat/run.go:95` | `info, _ := session.ReadTokenInfo(sessionID)` | ReadTokenInfo for display hint; best-effort — annotate | +| besteffort | `internal/cli/system/core/message/message.go:125` | `line, _ := ctxContext.DirLine()` | DirLine for display; best-effort — annotate | +| besteffort | `internal/cli/system/core/provenance/provenance.go:66` | `info, _ := coreSession.ReadTokenInfo(sessionID)` | ReadTokenInfo for display hint; best-effort — annotate | +| besteffort | `internal/cli/task/cmd/complete/run.go:45` | `_ = trace.Record(ref, stateDir)` | trace.Record telemetry; best-effort — annotate | +| besteffort | `internal/cli/trace/core/collect/collect.go:49` | `_ = trace.TruncatePending(stateDir)` | TruncatePending cleanup; best-effort — stderr at most | +| besteffort | `internal/cli/trace/core/collect/collect.go:69` | `_ = trace.TruncatePending(stateDir)` | TruncatePending cleanup; best-effort — stderr at most | +| besteffort | `internal/cli/trace/core/show/show.go:48` | `msg, _ := trace.CommitMessage(fullHash)` | CommitMessage for display; best-effort — annotate | +| besteffort | `internal/cli/trace/core/show/show.go:59` | `msg, _ := trace.CommitMessage(fullHash)` | CommitMessage for display; best-effort — annotate | +| besteffort | `internal/mcp/server/server.go:50` | `_ = srv.out.WriteJSON(n)` | WriteJSON in poller callback; no return path — stderr | +| OK | `internal/cli/add/core/extract/content.go:64` | `stat, _ := os.Stdin.Stat()` | os.Stdin.Stat for pipe detection; nil-safe fallback — annotate | +| OK | `internal/cli/setup/core/copilotcli/mcp.go:59` | `servers, _ := existing[cfgHook.KeyMCPServers].(map[string]interface{})` | type-assert ok-discard; zero map handled | +| OK | `internal/cli/setup/core/opencode/mcp.go:108` | `servers, _ := existing[cfgHook.KeyMCP].(map[string]interface{})` | type-assert ok-discard; zero map handled | +| OK | `internal/cli/system/core/session/session_token.go:81` | `if initialized, _ := state.Initialized(); !initialized {` | state.Initialized bool used; ok-discard | +| OK-flag | `internal/cli/doctor/cmd/root/cmd.go:36` | `jsonOut, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:29` | `hook, _ := cmd.Flags().GetString(cFlag.Hook)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:30` | `session, _ := cmd.Flags().GetString(cFlag.Session)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:31` | `event, _ := cmd.Flags().GetString(cFlag.Event)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:32` | `last, _ := cmd.Flags().GetInt(cFlag.Last)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:33` | `jsonOut, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/event/run.go:34` | `includeAll, _ := cmd.Flags().GetBool(cFlag.All)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/message/cmd/list/run.go:57` | `jsonFlag, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/pause/cmd/root/cmd.go:30` | `sessionID, _ := cmd.Flags().GetString(cFlag.SessionID)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/resolve/tool.go:34` | `v, _ := cmd.Flags().GetString(flag.Tool)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/resume/cmd/root/cmd.go:31` | `sessionID, _ := cmd.Flags().GetString(cFlag.SessionID)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/sysinfo/run.go:31` | `jsonFlag, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/system/cmd/bootstrap/run.go:51` | `quiet, _ := cmd.Flags().GetBool(cFlag.Quiet)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/system/cmd/bootstrap/run.go:66` | `jsonFlag, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/system/cmd/markjournal/run.go:42` | `check, _ := cmd.Flags().GetBool(cFlag.Check)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/system/core/check/pause_preamble.go:53` | `sessionID, _ := cmd.Flags().GetString(cFlag.SessionID)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/usage/run.go:31` | `follow, _ := cmd.Flags().GetBool(cFlag.Follow)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/usage/run.go:32` | `session, _ := cmd.Flags().GetString(cFlag.Session)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/usage/run.go:33` | `last, _ := cmd.Flags().GetInt(cFlag.Last)` | error only if flag unregistered (programmer err); annotate | +| OK-flag | `internal/cli/usage/run.go:34` | `jsonOut, _ := cmd.Flags().GetBool(cFlag.JSON)` | error only if flag unregistered (programmer err); annotate | +| OK-markflag | `internal/cli/add/core/build/build.go:119` | `_ = c.RegisterFlagCompletionFunc(` | RegisterFlagCompletionFunc; init-time, bad-name only — annotate | +| OK-markflag | `internal/cli/connection/cmd/register/cmd.go:48` | `_ = c.MarkFlagRequired(cFlag.Token)` | init-time; bad flag name only; annotate or handle | +| OK-markflag | `internal/cli/handover/cmd/write/cmd.go:67` | `_ = c.MarkFlagRequired(cFlag.Summary)` | init-time; bad flag name only; annotate or handle | +| OK-markflag | `internal/cli/handover/cmd/write/cmd.go:68` | `_ = c.MarkFlagRequired(cFlag.Next)` | init-time; bad flag name only; annotate or handle | +| OK-markflag | `internal/cli/system/cmd/sessionevent/cmd.go:46` | `_ = c.MarkFlagRequired(cFlag.Type)` | init-time; bad flag name only; annotate or handle | +| OK-markflag | `internal/cli/system/cmd/sessionevent/cmd.go:47` | `_ = c.MarkFlagRequired(cFlag.Caller)` | init-time; bad flag name only; annotate or handle | +| OK-markflag | `internal/cli/trace/cmd/tag/cmd.go:37` | `_ = c.MarkFlagRequired(cFlag.Note)` | init-time; bad flag name only; annotate or handle | +| OK-glob | `internal/cli/sync/core/validate/validate.go:93` | `matches, _ := filepath.Glob(cfg.Pattern)` | filepath.Glob; static pattern, nil-safe range — annotate | +| OK-glob | `internal/cli/system/core/stats/stats.go:288` | `matches, _ := filepath.Glob(globPat)` | filepath.Glob; static pattern, nil-safe range — annotate | +| OK-glob | `internal/cli/system/core/stats/stats.go:300` | `matches, _ = filepath.Glob(globPat)` | filepath.Glob; static pattern, nil-safe range — annotate | +| OK-atoi | `internal/cli/journal/core/normalize/boundary.go:33` | `num, _ := strconv.Atoi(m[1])` | regex-guaranteed digits; annotate why safe | +| OK-atoi | `internal/cli/journal/core/normalize/normalize.go:407` | `num, _ := strconv.Atoi(m[1])` | regex-guaranteed digits; annotate why safe | +| OK-atoi | `internal/cli/pad/core/parse/entry.go:52` | `id, _ := strconv.Atoi(match[1])` | regex-guaranteed digits; annotate why safe | +| OK-atoi | `internal/cli/system/core/nudge/pause.go:63` | `count, _ := strconv.Atoi(strings.TrimSpace(string(data)))` | regex-guaranteed digits; annotate why safe | +| OK-typeassert | `internal/mcp/server/extract/extract.go:33` | `entryType, _ := args[cli.AttrType].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/extract/extract.go:34` | `content, _ := args[field.Content].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/steering.go:35` | `prompt, _ := args[field.Prompt].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/steering.go:54` | `query, _ := args[field.Query].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/steering.go:78` | `summary, _ := args[field.Summary].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/tool.go:113` | `if sinceStr, _ := args[field.Since].(string); sinceStr != "" {` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/tool.go:209` | `recentAction, _ := args[field.RecentAction].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/tool.go:228` | `eventType, _ := args[cli.AttrType].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/tool.go:234` | `caller, _ := args[field.Caller].(string)` | discards ok bool; zero value handled | +| OK-typeassert | `internal/mcp/server/route/tool/tool.go:78` | `query, _ := args[field.Query].(string)` | discards ok bool; zero value handled | +| FALSE-POS | `internal/cli/config/cmd/status/cmd.go:23` | `short, _ := desc.Command(cmd.DescKeyConfigStatus)` | not an error discard | +| FALSE-POS | `internal/cli/initialize/core/project/getting_started.go:34` | `_ = contextDir` | not an error discard | +| FALSE-POS | `internal/cli/message/cmd/edit/cmd.go:21` | `short, _ := desc.Command(cmd.DescKeyMessageEdit)` | not an error discard | +| FALSE-POS | `internal/cli/message/cmd/list/cmd.go:23` | `short, _ := desc.Command(cmd.DescKeyMessageList)` | not an error discard | +| FALSE-POS | `internal/cli/message/cmd/reset/cmd.go:21` | `short, _ := desc.Command(cmd.DescKeyMessageReset)` | not an error discard | +| FALSE-POS | `internal/cli/message/cmd/show/cmd.go:21` | `short, _ := desc.Command(cmd.DescKeyMessageShow)` | not an error discard | +| FALSE-POS | `internal/cli/pad/cmd/add/cmd.go:26` | `short, _ := desc.Command(cmd.DescKeyPadAdd)` | not an error discard | +| FALSE-POS | `internal/cli/pad/cmd/mv/cmd.go:23` | `short, _ := desc.Command(cmd.DescKeyPadMv)` | not an error discard | +| FALSE-POS | `internal/cli/pad/cmd/normalize/cmd.go:21` | `short, _ := desc.Command(cmd.DescKeyPadNormalize)` | not an error discard | +| FALSE-POS | `internal/cli/pad/cmd/rm/cmd.go:25` | `short, _ := desc.Command(cmd.DescKeyPadRm)` | not an error discard | +| FALSE-POS | `internal/cli/remind/cmd/add/cmd.go:26` | `short, _ := desc.Command(cmd.DescKeyRemindAdd)` | not an error discard | +| FALSE-POS | `internal/cli/remind/cmd/dismiss/cmd.go:30` | `short, _ := desc.Command(cmd.DescKeyRemindDismiss)` | not an error discard | +| FALSE-POS | `internal/cli/remind/cmd/list/cmd.go:21` | `short, _ := desc.Command(cmd.DescKeyRemindList)` | not an error discard | +| FALSE-POS | `internal/cli/skill/cmd/list/cmd.go:27` | `short, _ := desc.Command(cmd.DescKeySkillList)` | not an error discard | +| FALSE-POS | `internal/cli/steering/cmd/list/cmd.go:28` | `short, _ := desc.Command(cmd.DescKeySteeringList)` | not an error discard | +| FALSE-POS | `internal/cli/system/cmd/checkmemorydrift/cmd.go:23` | `short, _ := desc.Command(cmd.DescKeySystemCheckMemoryDrift)` | not an error discard | +| FALSE-POS | `internal/cli/system/cmd/checkreminder/cmd.go:23` | `short, _ := desc.Command(cmd.DescKeySystemCheckReminder)` | not an error discard | +| FALSE-POS | `internal/cli/trigger/cmd/list/cmd.go:25` | `short, _ := desc.Command(cmd.DescKeyTriggerList)` | not an error discard | +| FALSE-POS | `internal/format/format.go:125` | `line, _, _ := strings.Cut(s, token.NewlineLF)` | strings.Cut discards after+found bool, not an error | +| FALSE-POS | `internal/hub/replicate.go:22` | `var _ = startReplication` | not an error discard | +| FALSE-POS | `internal/hub/store_sequence.go:11` | `var _ = (*Store).lastSequence` | var _ = method value: compile-time assertion, not an error | diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 83c3e147b..1a0aa69e9 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -36,6 +36,15 @@ err.fmt.no-files: short: 'no context files found in %s' err.fmt.needs-formatting: short: files need formatting +err.index.entries-in-block: + short: |- + %s has entry content between its INDEX:START and INDEX:END markers. + Refusing to regenerate the index because that would delete those entries. + Move the INDEX:END marker above your entries, then retry. +err.index.malformed-markers: + short: |- + %s has missing, duplicated, or out-of-order INDEX:START/INDEX:END markers. + Refusing to regenerate the index. Restore a single well-formed marker pair, then retry. err.backup.context-dir-not-found: short: "context directory not found: %s - run 'ctx init'" err.backup.create-archive-dir: diff --git a/internal/cli/add/core/build/build.go b/internal/cli/add/core/build/build.go index 4a402032d..af3af2d16 100644 --- a/internal/cli/add/core/build/build.go +++ b/internal/cli/add/core/build/build.go @@ -116,6 +116,8 @@ func Cmd(noun, descKey, useStr string) *cobra.Command { cFlag.Share, flag.DescKeyAddShare, ) + // Acceptable discard: RegisterFlagCompletionFunc only errors on an + // unregistered flag name; the priority flag is bound above. _ = c.RegisterFlagCompletionFunc( cFlag.Priority, func( _ *cobra.Command, _ []string, _ string, diff --git a/internal/cli/add/core/run/run.go b/internal/cli/add/core/run/run.go index 0af91d766..b3d8dc6be 100644 --- a/internal/cli/add/core/run/run.go +++ b/internal/cli/add/core/run/run.go @@ -112,6 +112,8 @@ func Run(cmd *cobra.Command, args []string, flags entity.AddConfig) error { } if fType == cfgEntry.Decision || fType == cfgEntry.Learning { + // Acceptable discard: trace provenance is best-effort and must + // never fail the add; a missed first-entry ref is tolerable. _ = trace.Record(fType+cfgTrace.RefFirstEntry, stateDir) } diff --git a/internal/cli/connection/cmd/register/cmd.go b/internal/cli/connection/cmd/register/cmd.go index bc0bbd19d..9f5fb6810 100644 --- a/internal/cli/connection/cmd/register/cmd.go +++ b/internal/cli/connection/cmd/register/cmd.go @@ -45,6 +45,8 @@ func Cmd() *cobra.Command { c, &adminToken, cFlag.Token, flag.DescKeyConnectionToken, ) + // Acceptable discard: MarkFlagRequired only errors on an + // unregistered flag name; the flag is bound immediately above. _ = c.MarkFlagRequired(cFlag.Token) return c diff --git a/internal/cli/connection/core/listen/listen.go b/internal/cli/connection/core/listen/listen.go index 6c37a0077..592584e53 100644 --- a/internal/cli/connection/core/listen/listen.go +++ b/internal/cli/connection/core/listen/listen.go @@ -15,7 +15,9 @@ import ( connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" "github.com/ActiveMemory/ctx/internal/cli/connection/core/render" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeConnect "github.com/ActiveMemory/ctx/internal/write/connect" ) @@ -41,7 +43,11 @@ func Run(cmd *cobra.Command, _ []string) error { if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() ctx, stop := signal.NotifyContext( context.Background(), os.Interrupt, diff --git a/internal/cli/connection/core/publish/publish.go b/internal/cli/connection/core/publish/publish.go index 1974ac3df..ee8804bd7 100644 --- a/internal/cli/connection/core/publish/publish.go +++ b/internal/cli/connection/core/publish/publish.go @@ -12,7 +12,9 @@ import ( "github.com/spf13/cobra" connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeConnect "github.com/ActiveMemory/ctx/internal/write/connect" ) @@ -41,7 +43,11 @@ func Run( if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() _, pubErr := client.Publish( context.Background(), entries, diff --git a/internal/cli/connection/core/register/register.go b/internal/cli/connection/core/register/register.go index 13878602d..5c3b9b21d 100644 --- a/internal/cli/connection/core/register/register.go +++ b/internal/cli/connection/core/register/register.go @@ -13,7 +13,9 @@ import ( "github.com/spf13/cobra" connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/rc" writeConnect "github.com/ActiveMemory/ctx/internal/write/connect" ) @@ -40,7 +42,11 @@ func Run( if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() ctxDir, ctxErr := rc.RequireContextDir() if ctxErr != nil { diff --git a/internal/cli/connection/core/status/status.go b/internal/cli/connection/core/status/status.go index 102e4a96c..22aa0b58d 100644 --- a/internal/cli/connection/core/status/status.go +++ b/internal/cli/connection/core/status/status.go @@ -12,7 +12,9 @@ import ( "github.com/spf13/cobra" connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeConnect "github.com/ActiveMemory/ctx/internal/write/connect" ) @@ -36,7 +38,11 @@ func Run(cmd *cobra.Command, _ []string) error { if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() resp, statusErr := client.Status( context.Background(), diff --git a/internal/cli/connection/core/sync/state.go b/internal/cli/connection/core/sync/state.go index 5763f386a..f7ab4de97 100644 --- a/internal/cli/connection/core/sync/state.go +++ b/internal/cli/connection/core/sync/state.go @@ -13,7 +13,9 @@ import ( "github.com/ActiveMemory/ctx/internal/config/fs" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -49,7 +51,11 @@ func loadState() (state, func(), error) { return s, nil, writeErr } - release := func() { _ = os.Remove(lockPath) } + release := func() { + if rmErr := os.Remove(lockPath); rmErr != nil { + logWarn.Warn(cfgWarn.Remove, lockPath, rmErr) + } + } path := filepath.Join(dir, cfgHub.FileSyncState) data, readErr := io.SafeReadUserFile(path) diff --git a/internal/cli/connection/core/sync/sync.go b/internal/cli/connection/core/sync/sync.go index efeb9677f..84bfe349f 100644 --- a/internal/cli/connection/core/sync/sync.go +++ b/internal/cli/connection/core/sync/sync.go @@ -13,7 +13,9 @@ import ( connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" "github.com/ActiveMemory/ctx/internal/cli/connection/core/render" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeConnect "github.com/ActiveMemory/ctx/internal/write/connect" ) @@ -45,7 +47,11 @@ func Run(cmd *cobra.Command) error { if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() entries, syncErr := client.Sync( context.Background(), diff --git a/internal/cli/drift/cmd/root/run.go b/internal/cli/drift/cmd/root/run.go index 7c88c7640..56515d6d6 100644 --- a/internal/cli/drift/cmd/root/run.go +++ b/internal/cli/drift/cmd/root/run.go @@ -13,10 +13,12 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/drift/core/fix" "github.com/ActiveMemory/ctx/internal/cli/drift/core/out" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/context/load" "github.com/ActiveMemory/ctx/internal/drift" errCtx "github.com/ActiveMemory/ctx/internal/err/context" errInit "github.com/ActiveMemory/ctx/internal/err/initialize" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/rc" writeDrift "github.com/ActiveMemory/ctx/internal/write/drift" ) @@ -72,7 +74,11 @@ func Run( // Re-run detection to show the updated status if result.Fixed > 0 { writeDrift.FixRecheck(cmd) - ctx, _ = load.Do("") + if reloaded, reloadErr := load.Do(""); reloadErr != nil { + logWarn.Warn(cfgWarn.DriftReload, reloadErr) + } else { + ctx = reloaded + } report = drift.Detect(ctx) } } diff --git a/internal/cli/handover/cmd/write/cmd.go b/internal/cli/handover/cmd/write/cmd.go index 359a57e80..df58b2742 100644 --- a/internal/cli/handover/cmd/write/cmd.go +++ b/internal/cli/handover/cmd/write/cmd.go @@ -64,6 +64,8 @@ func Cmd() *cobra.Command { c, &noFold, cFlag.NoFold, flag.DescKeyHandoverNoFold, ) + // Acceptable discard: MarkFlagRequired only errors on an + // unregistered flag name; both flags are bound above. _ = c.MarkFlagRequired(cFlag.Summary) _ = c.MarkFlagRequired(cFlag.Next) diff --git a/internal/cli/hub/core/server/daemon.go b/internal/cli/hub/core/server/daemon.go index 11c7602ed..b4e59ebd4 100644 --- a/internal/cli/hub/core/server/daemon.go +++ b/internal/cli/hub/core/server/daemon.go @@ -18,9 +18,11 @@ import ( cfgFlag "github.com/ActiveMemory/ctx/internal/config/flag" "github.com/ActiveMemory/ctx/internal/config/fs" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" errServe "github.com/ActiveMemory/ctx/internal/err/serve" execDaemon "github.com/ActiveMemory/ctx/internal/exec/daemon" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeServe "github.com/ActiveMemory/ctx/internal/write/serve" ) @@ -117,7 +119,9 @@ func Stop(cmd *cobra.Command, dataDir string) error { return errServe.Kill(pid, killErr) } - _ = os.Remove(pidPath) + if rmErr := os.Remove(pidPath); rmErr != nil { + logWarn.Warn(cfgWarn.Remove, pidPath, rmErr) + } writeServe.Stopped(cmd, pid) return nil } diff --git a/internal/cli/hub/core/status/status.go b/internal/cli/hub/core/status/status.go index b9cfe4a89..db2e05e54 100644 --- a/internal/cli/hub/core/status/status.go +++ b/internal/cli/hub/core/status/status.go @@ -13,7 +13,9 @@ import ( connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeHub "github.com/ActiveMemory/ctx/internal/write/hub" ) @@ -37,7 +39,11 @@ func Run(cmd *cobra.Command, _ []string) error { if dialErr != nil { return dialErr } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() resp, statusErr := client.Status( context.Background(), diff --git a/internal/cli/initialize/core/vscode/extension.go b/internal/cli/initialize/core/vscode/extension.go index 48f3c83fa..6a8294b53 100644 --- a/internal/cli/initialize/core/vscode/extension.go +++ b/internal/cli/initialize/core/vscode/extension.go @@ -56,7 +56,10 @@ func createExtensionsJSON(cmd *cobra.Command) error { content := map[string][]string{ cfgVscode.KeyRecommendations: {cfgVscode.ExtensionID}, } - data, _ := json.MarshalIndent(content, "", token.Indent2) + data, marshalErr := json.MarshalIndent(content, "", token.Indent2) + if marshalErr != nil { + return marshalErr + } data = append(data, token.NewlineLF...) if writeErr := io.SafeWriteFile(target, data, fs.PermFile); writeErr != nil { diff --git a/internal/cli/initialize/core/vscode/mcp.go b/internal/cli/initialize/core/vscode/mcp.go index 821f4eaec..0abcbd123 100644 --- a/internal/cli/initialize/core/vscode/mcp.go +++ b/internal/cli/initialize/core/vscode/mcp.go @@ -47,7 +47,10 @@ func createMCPJSON(cmd *cobra.Command) error { }, }, } - data, _ := json.MarshalIndent(file, "", token.Indent2) + data, marshalErr := json.MarshalIndent(file, "", token.Indent2) + if marshalErr != nil { + return marshalErr + } data = append(data, token.NewlineLF...) writeErr := ctxIo.SafeWriteFile( diff --git a/internal/cli/initialize/core/vscode/tasks.go b/internal/cli/initialize/core/vscode/tasks.go index c8d8c8d92..4eb9bc74c 100644 --- a/internal/cli/initialize/core/vscode/tasks.go +++ b/internal/cli/initialize/core/vscode/tasks.go @@ -56,7 +56,10 @@ func createTasksJSON(cmd *cobra.Command) error { Version: cfgVscode.TasksVersion, Tasks: tasks, } - data, _ := json.MarshalIndent(file, "", token.Indent2) + data, marshalErr := json.MarshalIndent(file, "", token.Indent2) + if marshalErr != nil { + return marshalErr + } data = append(data, token.NewlineLF...) writeErr := ctxIo.SafeWriteFile( diff --git a/internal/cli/kb/cmd/note/run.go b/internal/cli/kb/cmd/note/run.go index ac8ae91b1..4eba2b7e7 100644 --- a/internal/cli/kb/cmd/note/run.go +++ b/internal/cli/kb/cmd/note/run.go @@ -31,7 +31,7 @@ import ( // // Returns: // - error: refusal on empty input, or wrapped I/O failure. -func Run(cobraCmd *cobra.Command, noteText string) error { +func Run(cobraCmd *cobra.Command, noteText string) (err error) { if noteText == "" { cobraCmd.SilenceUsage = true return errKbCli.ErrNoteNoText @@ -49,7 +49,11 @@ func Run(cobraCmd *cobra.Command, noteText string) error { if openErr != nil { return errKbCli.OpenFindings(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbCli.WriteFinding(cerr) + } + }() stamp := time.Now().UTC().Format(time.RFC3339) line := fmt.Sprintf( diff --git a/internal/cli/pad/core/store/store.go b/internal/cli/pad/core/store/store.go index 837d7aed4..74c2c5636 100644 --- a/internal/cli/pad/core/store/store.go +++ b/internal/cli/pad/core/store/store.go @@ -253,8 +253,15 @@ func ReadEntries() ([]string, error) { func WriteEntries( cmd *cobra.Command, entries []string, ) error { - // Read existing IDs to preserve them. - existing, _ := ReadEntriesWithIDs() + // Read existing IDs to preserve them. A missing pad reads as + // (nil, nil); a non-nil error means the prior blob exists but + // could not be read or decrypted. Surfacing it prevents the + // overwrite below from silently resetting IDs against an + // unreadable pad. + existing, readErr := ReadEntriesWithIDs() + if readErr != nil { + return readErr + } idEntries := make([]parse.Entry, len(entries)) nextID := parse.NextID(existing) diff --git a/internal/cli/setup/core/copilot/vscode.go b/internal/cli/setup/core/copilot/vscode.go index 117d701d9..a8e2937b6 100644 --- a/internal/cli/setup/core/copilot/vscode.go +++ b/internal/cli/setup/core/copilot/vscode.go @@ -54,7 +54,10 @@ func ensureVSCodeMCP(cmd *cobra.Command) error { }, }, } - data, _ := json.MarshalIndent(mcpCfg, "", token.Indent2) + data, marshalErr := json.MarshalIndent(mcpCfg, "", token.Indent2) + if marshalErr != nil { + return marshalErr + } data = append(data, token.NewlineLF...) writeFileErr := ctxIo.SafeWriteFile( diff --git a/internal/cli/setup/core/opencode/opencode.go b/internal/cli/setup/core/opencode/opencode.go index 9a8437427..5abd839b9 100644 --- a/internal/cli/setup/core/opencode/opencode.go +++ b/internal/cli/setup/core/opencode/opencode.go @@ -41,6 +41,8 @@ func Deploy(cmd *cobra.Command) error { } if mcpErr := ensureMCPConfig(cmd); mcpErr != nil { + // Acceptable discard: the error is handled by the empty-path + // fallback immediately below (used only for the warning text). mcpPath, _ := globalConfigPath() if mcpPath == "" { mcpPath = cfgSetup.MCPConfigPathOpenCode diff --git a/internal/cli/system/cmd/blocknonpathctx/run.go b/internal/cli/system/cmd/blocknonpathctx/run.go index 59d7d3617..a25ff703b 100644 --- a/internal/cli/system/cmd/blocknonpathctx/run.go +++ b/internal/cli/system/cmd/blocknonpathctx/run.go @@ -79,7 +79,10 @@ func Run(cmd *cobra.Command, stdin *os.File) error { Reason: reason + token.NewlineLF + token.NewlineLF + desc.Text(text.DescKeyBlockConstitutionSuffix), } - data, _ := json.Marshal(resp) + data, marshalErr := json.Marshal(resp) + if marshalErr != nil { + return marshalErr + } writeSetup.BlockResponse(cmd, string(data)) blockRef := notify.NewTemplateRef(hook.BlockNonPathCtx, variant, nil) return nudge.Relay(fmt.Sprintf(desc.Text(text.DescKeyRelayPrefixFormat), diff --git a/internal/cli/system/cmd/sessionevent/cmd.go b/internal/cli/system/cmd/sessionevent/cmd.go index 6e9bed79f..518b04be8 100644 --- a/internal/cli/system/cmd/sessionevent/cmd.go +++ b/internal/cli/system/cmd/sessionevent/cmd.go @@ -43,6 +43,8 @@ func Cmd() *cobra.Command { flagbind.StringFlag(c, &caller, cFlag.Caller, embFlag.DescKeySystemSessionEventCaller, ) + // Acceptable discard: MarkFlagRequired only errors on an + // unregistered flag name; both flags are bound above. _ = c.MarkFlagRequired(cFlag.Type) _ = c.MarkFlagRequired(cFlag.Caller) diff --git a/internal/cli/system/core/hubsync/sync.go b/internal/cli/system/core/hubsync/sync.go index 3f4963e2e..1d918377a 100644 --- a/internal/cli/system/core/hubsync/sync.go +++ b/internal/cli/system/core/hubsync/sync.go @@ -18,7 +18,9 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/connection/core/render" "github.com/ActiveMemory/ctx/internal/config/embed/text" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/hub" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // Connected reports whether a hub connection config exists. @@ -73,7 +75,11 @@ func Sync(_ string) string { if dialErr != nil { return "" } - defer func() { _ = client.Close() }() + defer func() { + if cerr := client.Close(); cerr != nil { + logWarn.Warn(cfgWarn.CloseHubClient, cerr) + } + }() entries, syncErr := client.Sync( context.Background(), cfg.Types, 0, diff --git a/internal/cli/system/core/session/session.go b/internal/cli/system/core/session/session.go index 301c32300..58cfc7da4 100644 --- a/internal/cli/system/core/session/session.go +++ b/internal/cli/system/core/session/session.go @@ -88,6 +88,9 @@ func ReadInput(r io.Reader) entity.HookInput { select { case res := <-ch: if res.err == nil { + // Acceptable discard: best-effort parse of hook stdin. On + // malformed or absent input the zero-value input is the + // intended graceful fallback (the caller tolerates it). _ = json.Unmarshal(res.data, &input) } case <-time.After(hook.StdinReadTimeout * time.Second): diff --git a/internal/cli/system/core/stats/stats.go b/internal/cli/system/core/stats/stats.go index 1f33e8eb6..25271976f 100644 --- a/internal/cli/system/core/stats/stats.go +++ b/internal/cli/system/core/stats/stats.go @@ -297,6 +297,9 @@ func Stream(w io.Writer, dir, sessionFilter string, jsonOut bool) error { defer ticker.Stop() for range ticker.C { + // Acceptable discard: filepath.Glob only errors on a malformed + // pattern; matches is nil on error and the range below is a + // no-op, so a bad tick is skipped rather than crashing the loop. matches, _ = filepath.Glob(globPat) for _, path := range matches { sid := ExtractSessionID(filepath.Base(path)) diff --git a/internal/cli/task/cmd/complete/run.go b/internal/cli/task/cmd/complete/run.go index 6ee02787e..c31d2e903 100644 --- a/internal/cli/task/cmd/complete/run.go +++ b/internal/cli/task/cmd/complete/run.go @@ -42,6 +42,8 @@ func Run(cmd *cobra.Command, args []string) error { if dirErr != nil { return dirErr } + // Acceptable discard: trace provenance is best-effort and must + // never fail task completion; a missed ref is tolerable. _ = trace.Record(ref, stateDir) return nil diff --git a/internal/cli/trace/cmd/tag/cmd.go b/internal/cli/trace/cmd/tag/cmd.go index f74c2de26..7c34e91aa 100644 --- a/internal/cli/trace/cmd/tag/cmd.go +++ b/internal/cli/trace/cmd/tag/cmd.go @@ -34,6 +34,8 @@ func Cmd() *cobra.Command { }, } flagbind.StringFlag(c, ¬e, cFlag.Note, flag.DescKeyTraceTagNote) + // Acceptable discard: MarkFlagRequired only errors on an + // unregistered flag name; the flag is bound immediately above. _ = c.MarkFlagRequired(cFlag.Note) return c } diff --git a/internal/cli/trace/core/collect/collect.go b/internal/cli/trace/core/collect/collect.go index efa928942..9e127df15 100644 --- a/internal/cli/trace/core/collect/collect.go +++ b/internal/cli/trace/core/collect/collect.go @@ -46,6 +46,8 @@ func RecordCommit(commitHash string) error { // because they were accumulated for *this* commit window; // keeping them would attach stale context to the next commit. stateDir := filepath.Join(contextDir, dir.State) + // Acceptable discard: best-effort truncation of pending trace + // state; a failure leaves stale refs but must not fail the hook. _ = trace.TruncatePending(stateDir) return nil } @@ -66,6 +68,8 @@ func RecordCommit(commitHash string) error { } stateDir := filepath.Join(contextDir, dir.State) + // Acceptable discard: best-effort truncation of pending trace + // state; a failure leaves stale refs but must not fail the hook. _ = trace.TruncatePending(stateDir) return nil diff --git a/internal/cli/trace/core/hook/hook.go b/internal/cli/trace/core/hook/hook.go index c06bf7be2..723f7474c 100644 --- a/internal/cli/trace/core/hook/hook.go +++ b/internal/cli/trace/core/hook/hook.go @@ -17,9 +17,11 @@ import ( cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" cfgGit "github.com/ActiveMemory/ctx/internal/config/git" cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" errTrace "github.com/ActiveMemory/ctx/internal/err/trace" "github.com/ActiveMemory/ctx/internal/exec/git" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" writeTrace "github.com/ActiveMemory/ctx/internal/write/trace" ) @@ -125,7 +127,9 @@ func Remove(path string) { return } if strings.Contains(string(existing), cfgTrace.CtxTraceMarker) { - _ = os.Remove(path) + if rmErr := os.Remove(path); rmErr != nil { + logWarn.Warn(cfgWarn.Remove, path, rmErr) + } } } diff --git a/internal/cli/trigger/cmd/test/cmd.go b/internal/cli/trigger/cmd/test/cmd.go index d031279ea..e9b96290f 100644 --- a/internal/cli/trigger/cmd/test/cmd.go +++ b/internal/cli/trigger/cmd/test/cmd.go @@ -106,6 +106,9 @@ func Run(c *cobra.Command, hookType, toolName, path string) error { writeTrigger.TestingHeader(c, hookType) + // Acceptable discard: diagnostic echo of an already-valid input + // map; marshal cannot realistically fail and an empty echo would + // not affect the test run below. inputJSON, _ := json.MarshalIndent(input, "", token.Indent2) writeTrigger.TestInput(c, string(inputJSON)) diff --git a/internal/config/embed/text/err_index.go b/internal/config/embed/text/err_index.go new file mode 100644 index 000000000..b2fb476d6 --- /dev/null +++ b/internal/config/embed/text/err_index.go @@ -0,0 +1,17 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +// DescKeys for index-block guard errors. +const ( + // DescKeyErrIndexEntriesInBlock is the text key for the error raised when + // entry bodies are found between the INDEX:START/INDEX:END markers. + DescKeyErrIndexEntriesInBlock = "err.index.entries-in-block" + // DescKeyErrIndexMalformedMarkers is the text key for the error raised when + // the INDEX:START/INDEX:END markers are missing, duplicated, or out of order. + DescKeyErrIndexMalformedMarkers = "err.index.malformed-markers" +) diff --git a/internal/config/warn/warn.go b/internal/config/warn/warn.go index faaa2ef6a..3015d7ea9 100644 --- a/internal/config/warn/warn.go +++ b/internal/config/warn/warn.go @@ -97,6 +97,29 @@ const ( // broken .ctxrc or permissions regression. HubConnectedProbe = "probe hub connection: %v" + // JournalScanDir is the stderr format for a failed session- + // directory scan during journal querying. One unreadable dir + // should not silently drop its sessions from the result. + JournalScanDir = "scan journal dir %s: %v" + + // DriftReload is the stderr format for a failed context reload + // during the drift post-fix re-check. On failure the prior + // context is reused, so the re-displayed report may be stale. + DriftReload = "reload context for drift re-check: %v" + + // CloseHubClient is the stderr format for a failed hub gRPC + // client/connection close. The close runs in a defer after the + // command's real work, so the error is not actionable but should + // not vanish. + CloseHubClient = "close hub client: %v" + + // HubReplicateAppend is the stderr format for a failed + // [Store.Append] inside the follower replication stream. The + // loop is best-effort and has no return path, so a dropped + // append would silently lose a replicated entry; warning keeps + // the loss visible. + HubReplicateAppend = "hub replicate append: %v" + // StateInitializedProbe is the stderr format for failures // inside [state.Initialized] beyond "no context dir declared." // Hooks bail on false either way, but a visible warning shows diff --git a/internal/entry/testmain_test.go b/internal/entry/testmain_test.go new file mode 100644 index 000000000..2e5f96d6c --- /dev/null +++ b/internal/entry/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package entry + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/entry/write.go b/internal/entry/write.go index 6a9096583..3f1364121 100644 --- a/internal/entry/write.go +++ b/internal/entry/write.go @@ -62,6 +62,14 @@ func Write(params entity.EntryParams) error { return errFs.FileRead(filePath, readErr) } + // Decisions and Learnings carry an auto-generated index. Refuse to mutate + // the file when regenerating that index would lose data, before any write. + if fType == entry.Decision || fType == entry.Learning { + if vErr := index.Validate(string(existing), fileName); vErr != nil { + return vErr + } + } + var formatted string switch fType { case entry.Decision: diff --git a/internal/entry/write_test.go b/internal/entry/write_test.go new file mode 100644 index 000000000..482d8c9df --- /dev/null +++ b/internal/entry/write_test.go @@ -0,0 +1,93 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package entry + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/ctx" + cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// seedLearnings writes content to a temp LEARNINGS.md and returns its dir/path. +func seedLearnings(t *testing.T, content string) (dir, path string) { + t.Helper() + dir = t.TempDir() + path = filepath.Join(dir, ctx.Learning) + if err := os.WriteFile(path, []byte(content), fs.PermFile); err != nil { + t.Fatalf("seed LEARNINGS.md: %v", err) + } + return dir, path +} + +func learningParams(dir string) entity.EntryParams { + return entity.EntryParams{ + Type: cfgEntry.Learning, + Content: "New learning", + Context: "ctx", + Lesson: "lesson", + Application: "apply", + ContextDir: dir, + } +} + +// TestWrite_RefusesEntriesTrappedInIndexBlock is the regression guard for the +// data-loss bug: when entry bodies live between the INDEX markers, Write must +// refuse and leave the file byte-identical rather than regenerate the index +// and delete them. +func TestWrite_RefusesEntriesTrappedInIndexBlock(t *testing.T) { + malformed := "# Learnings\n\n\n\n" + + "## [2026-01-01-090000] First\n\n**Lesson:** alpha must survive.\n\n" + + "## [2026-01-02-090000] Second\n\n**Lesson:** beta must survive.\n\n" + + "\n" + dir, path := seedLearnings(t, malformed) + + if err := Write(learningParams(dir)); err == nil { + t.Fatal("Write() must refuse a LEARNINGS.md with entries trapped in the index block") + } + + got, readErr := os.ReadFile(path) //nolint:gosec // path is test-controlled + if readErr != nil { + t.Fatalf("read back: %v", readErr) + } + if string(got) != malformed { + t.Errorf("Write() must not modify a refused file\nGot:\n%s", got) + } +} + +// TestWrite_PreservesBodiesWellFormed confirms the guard does not regress the +// happy path: a well-formed file gains the new entry and keeps prior bodies +// and exactly one marker pair. +func TestWrite_PreservesBodiesWellFormed(t *testing.T) { + wellFormed := "# Learnings\n\n\n\n\n" + + "## [2026-01-01-090000] First\n\n**Lesson:** alpha must survive.\n" + dir, path := seedLearnings(t, wellFormed) + + if err := Write(learningParams(dir)); err != nil { + t.Fatalf("Write() on a well-formed file: %v", err) + } + + got, readErr := os.ReadFile(path) //nolint:gosec // path is test-controlled + if readErr != nil { + t.Fatalf("read back: %v", readErr) + } + body := string(got) + if !strings.Contains(body, "alpha must survive.") { + t.Errorf("Write() dropped an existing body\nGot:\n%s", body) + } + if !strings.Contains(body, "New learning") { + t.Errorf("Write() did not add the new entry\nGot:\n%s", body) + } + if n := strings.Count(body, ""); n != 1 { + t.Errorf("Write() left %d INDEX:START markers, want 1\nGot:\n%s", n, body) + } +} diff --git a/internal/err/index/doc.go b/internal/err/index/doc.go new file mode 100644 index 000000000..8f0afba76 --- /dev/null +++ b/internal/err/index/doc.go @@ -0,0 +1,40 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package index defines the typed error constructors +// returned by the index-block guard +// ([internal/index.Validate]). +// +// # Domain +// +// Every context file that carries an auto-generated +// index (DECISIONS.md, LEARNINGS.md) brackets that +// index between INDEX:START / INDEX:END markers. The +// index regenerator replaces the span between those +// markers wholesale. Before it runs, the guard checks +// that doing so is safe and refuses otherwise. Two +// refusals can occur: +// +// - **Entries in block**: real entry bodies live +// between the markers; regenerating would delete +// them. Constructor: [EntriesInBlock]. +// - **Malformed markers**: the marker pair is +// missing, duplicated, or out of order, so a +// regenerate would emit a second marker. +// Constructor: [MalformedMarkers]. +// +// # Wrapping Strategy +// +// Both constructors return plain formatted errors; +// there is no underlying cause to wrap. The file name +// is interpolated so the message names the offending +// file. All user-facing text resolves through +// [internal/assets/read/desc] at construction time. +// +// # Concurrency +// +// Pure constructors. Concurrent callers never race. +package index diff --git a/internal/err/index/index.go b/internal/err/index/index.go new file mode 100644 index 000000000..e5fbdd505 --- /dev/null +++ b/internal/err/index/index.go @@ -0,0 +1,44 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "fmt" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" +) + +// EntriesInBlock returns an error when entry bodies are found between the +// INDEX:START and INDEX:END markers, where regenerating the index would +// delete them. +// +// Parameters: +// - fileName: Display name of the offending file (e.g., "LEARNINGS.md") +// +// Returns: +// - error: A refusal explaining the data-loss risk and the manual fix +func EntriesInBlock(fileName string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrIndexEntriesInBlock), fileName, + ) +} + +// MalformedMarkers returns an error when the INDEX:START/INDEX:END markers are +// missing, duplicated, or out of order, where regenerating the index would +// emit a second marker. +// +// Parameters: +// - fileName: Display name of the offending file (e.g., "LEARNINGS.md") +// +// Returns: +// - error: A refusal explaining the marker problem and the manual fix +func MalformedMarkers(fileName string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrIndexMalformedMarkers), fileName, + ) +} diff --git a/internal/hub/failover.go b/internal/hub/failover.go index 87129ceca..6f2d4f29e 100644 --- a/internal/hub/failover.go +++ b/internal/hub/failover.go @@ -10,6 +10,9 @@ import ( "context" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" + "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) @@ -54,7 +57,9 @@ func newFailoverClient( resp, ) if callErr != nil { - _ = conn.Close() + if closeErr := conn.Close(); closeErr != nil { + logWarn.Warn(cfgWarn.Close, addr, closeErr) + } // Fail fast on auth errors; same token // won't work on other peers either. diff --git a/internal/hub/replicate.go b/internal/hub/replicate.go index 85bc4b761..d8475fdae 100644 --- a/internal/hub/replicate.go +++ b/internal/hub/replicate.go @@ -11,6 +11,8 @@ import ( "time" cfgHub "github.com/ActiveMemory/ctx/internal/config/hub" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -81,7 +83,11 @@ func replicateOnce( if dialErr != nil { return } - defer func() { _ = conn.Close() }() + defer func() { + if cerr := conn.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, masterAddr, cerr) + } + }() _, lastSeq := store.lastSequence() authed := addBearerMD(ctx, clientToken) @@ -118,6 +124,8 @@ func replicateOnce( Timestamp: time.Unix(msg.Timestamp, 0), Sequence: msg.Sequence, } - _, _ = store.Append([]Entry{entry}) + if _, appendErr := store.Append([]Entry{entry}); appendErr != nil { + logWarn.Warn(cfgWarn.HubReplicateAppend, appendErr) + } } } diff --git a/internal/hub/server.go b/internal/hub/server.go index 0b26fcf5a..fe8dc09db 100644 --- a/internal/hub/server.go +++ b/internal/hub/server.go @@ -57,6 +57,8 @@ func (s *Server) SetCluster(cluster *Cluster) { // GracefulStop stops the server gracefully. func (s *Server) GracefulStop() { if s.cluster != nil { + // Acceptable discard: best-effort teardown; a Shutdown error on + // graceful stop is not actionable by the caller. _ = s.cluster.Shutdown() } s.grpc.GracefulStop() diff --git a/internal/index/index.go b/internal/index/index.go index 55af9f079..925868279 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/entity" + errIndex "github.com/ActiveMemory/ctx/internal/err/index" errJournal "github.com/ActiveMemory/ctx/internal/err/journal" internalIo "github.com/ActiveMemory/ctx/internal/io" logWarn "github.com/ActiveMemory/ctx/internal/log/warn" @@ -56,6 +57,61 @@ func ParseHeaders(content string) []entity.IndexEntry { return entries } +// Validate reports whether the index in content can be safely regenerated. +// +// Update replaces the entire span between INDEX:START and INDEX:END with a +// freshly generated table. That is only safe when the span holds nothing but +// a prior index and the markers form exactly one well-ordered pair. Validate +// is the precondition that callers run before any write, so a malformed file +// fails loud and untouched instead of losing data. +// +// It refuses two shapes: +// - entry bodies (## [timestamp] headers) between the markers, which a +// regenerate would delete (errIndex.EntriesInBlock) +// - markers that are duplicated, missing one side, or out of order, which a +// regenerate would answer with a second marker (errIndex.MalformedMarkers) +// +// A file with no markers at all is permitted: Update's insert path creates a +// fresh index without disturbing existing content. +// +// Parameters: +// - content: The full content of a context file +// - fileName: Display name for the error message (e.g., "LEARNINGS.md") +// +// Returns: +// - error: Non-nil when regenerating the index would lose data or duplicate +// a marker; nil when regeneration is safe +func Validate(content, fileName string) error { + startCount := strings.Count(content, marker.IndexStart) + endCount := strings.Count(content, marker.IndexEnd) + + // No markers: legitimate fresh-index creation. Update's insert path adds + // a block without disturbing existing content. + if startCount == 0 && endCount == 0 { + return nil + } + + // Exactly one well-ordered pair is the only other safe shape. Any other + // count is a duplicate or a missing side; either would have Update emit a + // second marker. + if startCount != 1 || endCount != 1 { + return errIndex.MalformedMarkers(fileName) + } + + startIdx := strings.Index(content, marker.IndexStart) + endIdx := strings.Index(content, marker.IndexEnd) + if endIdx <= startIdx { + return errIndex.MalformedMarkers(fileName) + } + + span := content[startIdx+len(marker.IndexStart) : endIdx] + if regex.EntryHeading.MatchString(span) { + return errIndex.EntriesInBlock(fileName) + } + + return nil +} + // GenerateTable creates a Markdown table index from entries. // // The table has two columns: Date and the specified column header. @@ -227,6 +283,10 @@ func Reindex( return errJournal.ReindexFileRead(filePath, readErr) } + if vErr := Validate(string(content), fileName); vErr != nil { + return vErr + } + updated := updateFunc(string(content)) if writeErr := internalIo.SafeWriteFile( diff --git a/internal/index/index_test.go b/internal/index/index_test.go index fb65d1037..d9478e6a0 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -481,3 +481,80 @@ func TestUpdateLearnings_Idempotent(t *testing.T) { ) } } + +func TestValidate(t *testing.T) { + tests := []struct { + name string + content string + wantErr bool + }{ + { + name: "no markers is allowed (fresh creation)", + content: "# Learnings\n\n## [2026-01-01-090000] A\n\n**Lesson**: keep me.\n", + wantErr: false, + }, + { + name: "empty index block", + content: "# Learnings\n\n\n\n\n" + + "## [2026-01-01-090000] A\n\n**Lesson**: keep me.\n", + wantErr: false, + }, + { + name: "populated table between markers", + content: `# Learnings + + +| Date | Learning | +|----|--------| +| 2026-01-01 | A | + + +## [2026-01-01-090000] A + +**Lesson**: keep me. +`, + wantErr: false, + }, + { + name: "entry header trapped between markers", + content: `# Learnings + + + +## [2026-01-01-090000] A + +**Lesson**: would be deleted. + + +`, + wantErr: true, + }, + { + name: "duplicate INDEX:START", + content: "# Learnings\n\n\n\n" + + "\n\n## [2026-01-01-090000] A\n", + wantErr: true, + }, + { + name: "missing INDEX:END", + content: "# Learnings\n\n\n\n" + + "## [2026-01-01-090000] A\n", + wantErr: true, + }, + { + name: "INDEX:END before INDEX:START", + content: "# Learnings\n\n\n\n\n" + + "## [2026-01-01-090000] A\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.content, "LEARNINGS.md") + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/io/security.go b/internal/io/security.go index a5a6d407c..17fbe9fb9 100644 --- a/internal/io/security.go +++ b/internal/io/security.go @@ -197,6 +197,10 @@ func SafeWriteFileAtomic(path string, data []byte, perm os.FileMode) error { return createErr } tmpPath := tmp.Name() + // Best-effort cleanup. These discards sit on failure paths where + // the operation already errored and the temp file is thrown away, + // so a failed Close/Remove changes nothing the caller can act on. + // The meaningful close is checked on the success path below. cleanup := func() { _ = os.Remove(tmpPath) } if _, writeErr := tmp.Write(data); writeErr != nil { _ = tmp.Close() diff --git a/internal/journal/parser/copilot.go b/internal/journal/parser/copilot.go index 1a551ce50..0f30306b1 100644 --- a/internal/journal/parser/copilot.go +++ b/internal/journal/parser/copilot.go @@ -17,9 +17,11 @@ import ( "github.com/ActiveMemory/ctx/internal/config/env" "github.com/ActiveMemory/ctx/internal/config/file" "github.com/ActiveMemory/ctx/internal/config/session" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/entity" errParser "github.com/ActiveMemory/ctx/internal/err/parser" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // Ensure Copilot implements Session. @@ -67,7 +69,11 @@ func (p *Copilot) Matches(path string) bool { if scanErr != nil { return false } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, path, cerr) + } + }() if !scanner.Scan() { return false @@ -112,7 +118,11 @@ func (p *Copilot) ParseFile(path string) ([]*entity.Session, error) { if scanErr != nil { return nil, errParser.OpenFile(scanErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, path, cerr) + } + }() var sess *copilotRawSession diff --git a/internal/journal/parser/copilot_cli.go b/internal/journal/parser/copilot_cli.go index 851290a59..9c66d4120 100644 --- a/internal/journal/parser/copilot_cli.go +++ b/internal/journal/parser/copilot_cli.go @@ -74,7 +74,11 @@ func (p *CopilotCLI) Matches(path string) bool { if openErr != nil { return false } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + warn.Warn(cfgWarn.Close, path, cerr) + } + }() scanner := bufio.NewScanner(f) buf := make([]byte, 0, cfgCopilot.ScanBufInit) diff --git a/internal/journal/parser/query.go b/internal/journal/parser/query.go index 401f86416..6d76cc338 100644 --- a/internal/journal/parser/query.go +++ b/internal/journal/parser/query.go @@ -12,7 +12,9 @@ import ( "sort" "github.com/ActiveMemory/ctx/internal/config/dir" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/entity" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // findSessionsWithFilter scans common locations and additional directories @@ -46,7 +48,10 @@ func findSessionsWithFilter( } if info, statErr := os.Stat(resolved); statErr == nil && info.IsDir() { scannedDirs[resolved] = true - sessions, _ := ScanDirectory(resolved) + sessions, scanErr := ScanDirectory(resolved) + if scanErr != nil { + logWarn.Warn(cfgWarn.JournalScanDir, resolved, scanErr) + } allSessions = append(allSessions, sessions...) } } diff --git a/internal/mcp/handler/violations.go b/internal/mcp/handler/violations.go index b4cd53619..d513bb9d5 100644 --- a/internal/mcp/handler/violations.go +++ b/internal/mcp/handler/violations.go @@ -13,7 +13,9 @@ import ( "github.com/ActiveMemory/ctx/internal/config/dir" "github.com/ActiveMemory/ctx/internal/config/file" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" ctxio "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // readAndClearViolations reads violations from @@ -35,8 +37,13 @@ func readAndClearViolations(contextDir string) []violation { if readErr != nil { return nil } - // Remove the file immediately to prevent duplicate alerts. - _ = os.Remove(filepath.Join(stateDir, file.Violations)) + // Remove the file immediately to prevent duplicate alerts. A + // failed remove means the next read re-reports these violations, + // so surface it rather than swallowing. + violationsPath := filepath.Join(stateDir, file.Violations) + if rmErr := os.Remove(violationsPath); rmErr != nil { + logWarn.Warn(cfgWarn.Remove, violationsPath, rmErr) + } var vd violationsData if unmarshalErr := json.Unmarshal(data, &vd); unmarshalErr != nil { diff --git a/internal/mcp/server/server.go b/internal/mcp/server/server.go index 0969ac2a6..e523413bd 100644 --- a/internal/mcp/server/server.go +++ b/internal/mcp/server/server.go @@ -47,6 +47,9 @@ func New(contextDir, version string) *Server { resourceList: catalog.ToList(), } srv.poller = poll.NewPoller(contextDir, func(n proto.Notification) { + // Acceptable discard: best-effort notification push from the + // poller callback. There is no return path, and a failed write + // means the client has gone away. _ = srv.out.WriteJSON(n) }) return srv diff --git a/internal/skill/copy.go b/internal/skill/copy.go index 4abee8da7..5801ce9d3 100644 --- a/internal/skill/copy.go +++ b/internal/skill/copy.go @@ -12,7 +12,9 @@ import ( "path/filepath" "github.com/ActiveMemory/ctx/internal/config/fs" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" ctxIo "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // copyDir recursively copies the contents of src into dst. @@ -62,7 +64,7 @@ func copyDir(src, dst string) error { // // Returns: // - error: stat, open, create, or copy failure -func copyFile(src, dst string) error { +func copyFile(src, dst string) (err error) { info, statErr := ctxIo.SafeStat(src) if statErr != nil { return statErr @@ -72,13 +74,21 @@ func copyFile(src, dst string) error { if openErr != nil { return openErr } - defer func() { _ = in.Close() }() + defer func() { + if cerr := in.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, src, cerr) + } + }() out, createErr := ctxIo.SafeCreateFile(dst, info.Mode().Perm()) if createErr != nil { return createErr } - defer func() { _ = out.Close() }() + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() _, copyErr := io.Copy(out, in) return copyErr diff --git a/internal/skill/install.go b/internal/skill/install.go index 6d2422cc6..f53ef57b6 100644 --- a/internal/skill/install.go +++ b/internal/skill/install.go @@ -12,8 +12,10 @@ import ( "github.com/ActiveMemory/ctx/internal/config/fs" cfgSkill "github.com/ActiveMemory/ctx/internal/config/skill" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" errSkill "github.com/ActiveMemory/ctx/internal/err/skill" ctxIo "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // Install copies a skill from the source directory into skillsDir//. @@ -53,7 +55,9 @@ func Install(source, skillsDir string) (*Skill, error) { if copyErr := copyDir(source, destDir); copyErr != nil { // Clean up partial copy on failure. - _ = os.RemoveAll(destDir) + if rmErr := os.RemoveAll(destDir); rmErr != nil { + logWarn.Warn(cfgWarn.Remove, destDir, rmErr) + } return nil, errSkill.Install(sk.Name, copyErr) } diff --git a/internal/steering/format.go b/internal/steering/format.go index aa6d38dd8..ed732f37b 100644 --- a/internal/steering/format.go +++ b/internal/steering/format.go @@ -144,6 +144,9 @@ func formatCursor(sf *SteeringFile) []byte { AlwaysApply: sf.Inclusion == cfgSteering.InclusionAlways, } + // Acceptable discard: yaml.Marshal cannot fail for this plain + // frontmatter struct (fixed string/bool/slice fields), and the + // formatter returns []byte by contract. raw, _ := yaml.Marshal(fm) var buf bytes.Buffer @@ -192,6 +195,9 @@ func formatKiro(sf *SteeringFile) []byte { Mode: mapKiroMode(sf.Inclusion), } + // Acceptable discard: yaml.Marshal cannot fail for this plain + // frontmatter struct (fixed string/bool/slice fields), and the + // formatter returns []byte by contract. raw, _ := yaml.Marshal(fm) var buf bytes.Buffer diff --git a/internal/steering/parse.go b/internal/steering/parse.go index 5c73feb9d..1084e4fc1 100644 --- a/internal/steering/parse.go +++ b/internal/steering/parse.go @@ -76,6 +76,8 @@ func Parse(data []byte, filePath string) (*SteeringFile, error) { func Print(sf *SteeringFile) []byte { var buf bytes.Buffer + // Acceptable discard: yaml.Marshal cannot fail for this plain + // SteeringFile struct, and Print returns []byte by contract. raw, _ := yaml.Marshal(sf) buf.WriteString(token.FrontmatterDelimiter) diff --git a/internal/trace/jsonl.go b/internal/trace/jsonl.go index 0710a60ff..3b8ff5050 100644 --- a/internal/trace/jsonl.go +++ b/internal/trace/jsonl.go @@ -15,7 +15,9 @@ import ( cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" "github.com/ActiveMemory/ctx/internal/config/token" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // readJSONL is a generic helper that opens the file at path and @@ -37,7 +39,11 @@ func readJSONL[T any](path string) ([]T, error) { } return nil, openErr } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, path, cerr) + } + }() var entries []T scanner := bufio.NewScanner(f) @@ -70,7 +76,7 @@ func readJSONL[T any](path string) ([]T, error) { // // Returns: // - error: marshal, directory creation, or write failure -func appendJSONL[T any](dir, filename string, entry T) error { +func appendJSONL[T any](dir, filename string, entry T) (err error) { if mkErr := io.SafeMkdirAll(dir, cfgFs.PermRestrictedDir); mkErr != nil { return mkErr } @@ -86,7 +92,11 @@ func appendJSONL[T any](dir, filename string, entry T) error { if openErr != nil { return openErr } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + }() _, writeErr := f.Write(line) return writeErr diff --git a/internal/trace/resolve_entry.go b/internal/trace/resolve_entry.go index b90c04be6..cdef7938b 100644 --- a/internal/trace/resolve_entry.go +++ b/internal/trace/resolve_entry.go @@ -16,8 +16,10 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/regex" cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/index" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/task" ) @@ -82,7 +84,11 @@ func resolveTask( if err != nil { return resolved } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, path, cerr) + } + }() count := 0 scanner := bufio.NewScanner(f) diff --git a/internal/trace/working_tasks.go b/internal/trace/working_tasks.go index 107f51333..aabdccb0c 100644 --- a/internal/trace/working_tasks.go +++ b/internal/trace/working_tasks.go @@ -14,7 +14,9 @@ import ( cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/config/regex" cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/io" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/task" ) @@ -35,7 +37,11 @@ func inProgressTaskRefs(contextDir string) []string { if err != nil { return nil } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil { + logWarn.Warn(cfgWarn.Close, path, cerr) + } + }() var refs []string count := 0 diff --git a/internal/write/kb/evidence/append.go b/internal/write/kb/evidence/append.go index e841c47e0..16b4361d3 100644 --- a/internal/write/kb/evidence/append.go +++ b/internal/write/kb/evidence/append.go @@ -33,7 +33,7 @@ import ( // - Row: the appended row with ID populated. // - error: [errKbEvidence.ErrDuplicateID], // [errKbEvidence.ErrInvalidBand], or wrapped I/O errors. -func Append(path string, row Row) (Row, error) { +func Append(path string, row Row) (_ Row, err error) { if bandErr := validateBand(row.Confidence); bandErr != nil { return Row{}, bandErr } @@ -67,7 +67,11 @@ func Append(path string, row Row) (Row, error) { if openErr != nil { return Row{}, errKbEvidence.OpenIndex(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbEvidence.WriteRow(cerr) + } + }() var sb strings.Builder if needsHeader { diff --git a/internal/write/kb/glossary/append.go b/internal/write/kb/glossary/append.go index d0ee7cc0b..5e25b0fcb 100644 --- a/internal/write/kb/glossary/append.go +++ b/internal/write/kb/glossary/append.go @@ -28,7 +28,7 @@ import ( // // Returns: // - error: wrapped I/O failures. -func Append(path string, row Row) error { +func Append(path string, row Row) (err error) { if mkErr := ctxIo.SafeMkdirAll( filepath.Dir(path), cfgFs.PermExec, ); mkErr != nil { @@ -46,7 +46,11 @@ func Append(path string, row Row) error { if openErr != nil { return errKbGloss.OpenFile(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbGloss.WriteRow(cerr) + } + }() if needsHeader { if _, writeErr := f.WriteString( diff --git a/internal/write/kb/relationship/append.go b/internal/write/kb/relationship/append.go index 9639f6f04..b571f691a 100644 --- a/internal/write/kb/relationship/append.go +++ b/internal/write/kb/relationship/append.go @@ -29,7 +29,7 @@ import ( // // Returns: // - error: wrapped I/O failures. -func Append(path string, row Row) error { +func Append(path string, row Row) (err error) { if mkErr := ctxIo.SafeMkdirAll( filepath.Dir(path), cfgFs.PermExec, ); mkErr != nil { @@ -47,7 +47,11 @@ func Append(path string, row Row) error { if openErr != nil { return errKbRel.OpenFile(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbRel.WriteRow(cerr) + } + }() if needsHeader { if _, writeErr := f.WriteString( diff --git a/internal/write/kb/row/append.go b/internal/write/kb/row/append.go index 2bf1106b6..9757b200d 100644 --- a/internal/write/kb/row/append.go +++ b/internal/write/kb/row/append.go @@ -26,7 +26,7 @@ import ( // Returns: // - string: the allocated ID for this row. // - error: wrapped via the matching Err* constructor in h. -func Append(path string, h entity.KBRowHooks) (string, error) { +func Append(path string, h entity.KBRowHooks) (_ string, err error) { if mkErr := ctxIo.SafeMkdirAll( filepath.Dir(path), cfgFs.PermExec, ); mkErr != nil { @@ -50,7 +50,11 @@ func Append(path string, h entity.KBRowHooks) (string, error) { if openErr != nil { return "", h.ErrOpen(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = h.ErrWrite(cerr) + } + }() if needsHeader { if _, wErr := f.WriteString(h.Header); wErr != nil { diff --git a/internal/write/kb/sourcecoverage/parse.go b/internal/write/kb/sourcecoverage/parse.go index f27404f48..a6cd8d952 100644 --- a/internal/write/kb/sourcecoverage/parse.go +++ b/internal/write/kb/sourcecoverage/parse.go @@ -39,6 +39,9 @@ func parse(raw string) []Row { if cols[cfgKbSC.ColSource] == cfgKbSC.HeaderCellSource { continue } + // Acceptable discard: the Updated column is ctx-generated in + // DateOnly form; an unparseable value degrades to the zero + // time rather than dropping the row from the ledger. updated, _ := time.Parse( time.DateOnly, cols[cfgKbSC.ColUpdated], ) diff --git a/internal/write/kb/sourcemap/append.go b/internal/write/kb/sourcemap/append.go index d90bdfe98..61464c7be 100644 --- a/internal/write/kb/sourcemap/append.go +++ b/internal/write/kb/sourcemap/append.go @@ -28,7 +28,7 @@ import ( // // Returns: // - error: wrapped I/O failures. -func Append(path string, row Row) error { +func Append(path string, row Row) (err error) { if mkErr := ctxIo.SafeMkdirAll( filepath.Dir(path), cfgFs.PermExec, ); mkErr != nil { @@ -46,7 +46,11 @@ func Append(path string, row Row) error { if openErr != nil { return errKbSM.OpenFile(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbSM.WriteRow(cerr) + } + }() if needsHeader { if _, writeErr := f.WriteString( diff --git a/internal/write/kb/timeline/append.go b/internal/write/kb/timeline/append.go index db64eef56..74137d500 100644 --- a/internal/write/kb/timeline/append.go +++ b/internal/write/kb/timeline/append.go @@ -28,7 +28,7 @@ import ( // // Returns: // - error: wrapped I/O failures. -func Append(path string, row Row) error { +func Append(path string, row Row) (err error) { if mkErr := ctxIo.SafeMkdirAll( filepath.Dir(path), cfgFs.PermExec, ); mkErr != nil { @@ -46,7 +46,11 @@ func Append(path string, row Row) error { if openErr != nil { return errKbTL.OpenFile(openErr) } - defer func() { _ = f.Close() }() + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = errKbTL.WriteRow(cerr) + } + }() if needsHeader { if _, writeErr := f.WriteString( diff --git a/specs/error-handling-audit.md b/specs/error-handling-audit.md new file mode 100644 index 000000000..7708d85d9 --- /dev/null +++ b/specs/error-handling-audit.md @@ -0,0 +1,68 @@ +# Error-Handling Audit (Phase EH) + +Systematically surface and resolve every silently discarded error +under `internal/`. "Fix only the risky ones" is not the bar — every +discard is audited and given a verdict; the risky ones are fixed and +the rest are justified in place. + +## Problem + +The codebase discards errors at ~184 non-test sites via `_ =`, +`_, _ =`, and `x, _ :=`. Some are legitimate (a discarded `ok` bool, +best-effort CLI output), but many hide real failures: a dropped +`Marshal`/`Parse` error writes bad data, a dropped write-handle +`Close` loses the final flush, a dropped `os.Remove` leaves stale +state. The same class of silent loss motivated +`specs/fix-learning-add-index-data-loss.md`. + +## Approach + +1. **Catalogue (EH.1).** Recursive walk of `internal/`, every discard + site classified with a recommended action, written to + `.context/audit/eh-silent-errors.md`. The catalogue is an + inventory, not a verdict: categories assigned by pattern/name are + provisional. + +2. **Verify before fixing.** Every site is read in its enclosing + context before any edit. Name-inference is untrustworthy — the + first cut of the catalogue mislabelled two sites + (`MergePublished` returns `(string, bool)`, not an error; + `LoadState` returns a value, not a pointer, so no nil-deref). Per + the Constitution's Context Integrity Invariants, the discarded + value's actual type and the call's role decide the category. + +3. **Resolve by category.** + - **Data path** (a dropped error lets bad/empty/partial data get + written, or an unreadable source gets silently overwritten): + return the error. Fail loud. + - **Best-effort** (telemetry, display hints, background loops with + no return path, file close on read, `os.Remove`/`Rename` + cleanup): `logWarn.Warn(cfgWarn., err)` — the project sink + for "not actionable by the caller but must not be swallowed" + (`internal/log/warn`, prefixes `ctx: `, stderr in prod / + `io.Discard` in tests). + - **Write-handle close**: surface the close error (a failed final + flush is data loss), via a named-return merge or `logWarn`. + - **Category (d)** `fmt.Fprint(cmd.Out/Err …)`: accepted end-state + per EH.5; CLI output is best-effort by construction. + - **`ok`-discards / nil-safe / init-time programmer-error / + false-positives**: annotate intent so a reviewer (and the EH.5 + grep) reads them as deliberate. + +4. **Validate (EH.5).** `grep -rn '_ =' internal/` resolves to only + accepted-and-annotated sites; `make lint && make test` green. + +## Settled Decisions + +1. `logWarn.Warn` is the canonical sink for best-effort discards; + new per-call messages live as `cfgWarn` keys, not inline strings. +2. Data-path errors are returned (fail loud), never logged-and- + continued. +3. The catalogue's pattern-assigned categories are provisional; + the verdict is set per-site at fix time after reading the code. +4. Tests are out of scope for this pass. + +## Out of Scope + +- Phase ET (Error Package Taxonomy, `internal/err/`) — separate phase. +- Test-file discards. diff --git a/specs/fix-learning-add-index-data-loss.md b/specs/fix-learning-add-index-data-loss.md new file mode 100644 index 000000000..a797e931e --- /dev/null +++ b/specs/fix-learning-add-index-data-loss.md @@ -0,0 +1,130 @@ +# Fix: `ctx learning add` Destroys Bodies Trapped in the Index Block + +`ctx learning add` (and `ctx decision add`, and every `reindex` +command) silently destroys entry bodies that live between the +`` / `` markers. This phase +makes the index-regeneration path **fail loud and touch nothing** +when it cannot regenerate without data loss. + +## Problem + +`index.Update` (`internal/index/index.go:121-138`) treats the entire +span between `INDEX:START` and `INDEX:END` as a disposable, machine- +owned index and replaces it wholesale with a freshly generated table: + +```go +before := content[:startIdx+len(marker.IndexStart)] +after := content[endIdx:] +return before + nl + indexContent + after // span destroyed +``` + +That assumption is false for files where real entry bodies sit inside +the marker span. This is exactly the shape an older dash-bullet +`ctx init` produced (the marker block doubled as the entry list), and +the shape a `ctx hub` file drifts into. Because `ParseHeaders` scans +the *whole* file, the regenerated table still looks complete — which +**masks** that every body was just deleted. + +Verified live on `main` (scratch repo, `fix/learning-add-index-data-loss`): +a `LEARNINGS.md` with two hand-authored bodies between the markers +collapsed, after a single `ctx learning add`, to *just the index +table* — both existing bodies **and** the newly added body were gone. +Field-observed in `things-wtf-hub` session aa32f065 (commit 2dc4d1a, +−44 lines; recovered only via `git show`). + +A second, related failure lives in the same function: the +"no valid markers" branch (`index.go:140-164`) inserts a fresh block +via `marker.IndexBlockFmt` / `IndexBlockAppendFmt`, both of which emit +a **new** `INDEX:START`/`INDEX:END` pair (`marker/index_fmt.go:15-23`). +When the existing markers are duplicated, single, or out of order, +this produces a *second* `INDEX:START` rather than failing. + +Severity: HIGH — silent destruction of persisted memory, the one +thing ctx promises to protect. Only git made it recoverable. + +## Approach + +Fail loud, touch nothing. Do **not** auto-repair the file (an opt-in +`reindex --repair` is deferred to a follow-up task). Add a precondition +guard that runs *before any write* and refuses to proceed when +regenerating the index would lose data or duplicate a marker. + +New function `index.Validate(content, fileName string) error`: + +1. Count `INDEX:START` and `INDEX:END` occurrences. + - `0` and `0` → OK (legitimate fresh-index creation; `Update`'s + insert path preserves all content). + - any other count `!= 1` each → malformed (duplicate or missing + one) → error. Refusing here also closes the duplicate-marker + branch. +2. With exactly one of each: if `INDEX:END` precedes `INDEX:START` + → malformed → error. +3. Else inspect the span between the markers with `regex.EntryHeader`. + Any `## [timestamp] Title` match → trapped bodies → error. + Otherwise → OK. + +The guard never mutates; it only reads and classifies. + +### Wiring (two choke points) + +Every index-regenerating path funnels through one of these, and both +read the file before mutating it: + +- `entry.Write` (`internal/entry/write.go`) — the add path (covers + `learning/decision add`, `memory.Promote`, `watch addEntry`, + `add Run`). Call `index.Validate` on the *existing* content right + after read, gated to the indexed types (`Decision`, `Learning`), + before `AppendEntry` / the first `SafeWriteFile`. On error, return + it — nothing is written. +- `index.Reindex` (`internal/index/index.go`) — all three reindex + commands. Call `index.Validate` on the read content before + `updateFunc` / write. + +`index.Update`'s signature is left unchanged (`func(string) string`) +to avoid rippling through `Reindex`'s `updateFunc func(string) string` +and the reindex call sites — the CRITICAL blast radius gitnexus +flagged. The guard makes malformed content unreachable by `Update`. + +### Errors + +New `internal/err/index` package, two constructors backed by i18n desc +keys in `internal/assets/commands/text/errors.yaml`: + +- `err.index.entries-in-block` — `%s` is the file name. Explains that + entry content sits between the markers, that regeneration is refused + to avoid deletion, and that the fix is to move `INDEX:END` above the + entries. +- `err.index.malformed-markers` — `%s` is the file name. Markers are + missing, duplicated, or out of order; restore a single well-formed + pair and retry. + +## Guard (round-trip test, BOTH formats) + +Per the task: a round-trip test for **both** index formats that +asserts existing bodies survive an add and exactly one marker pair +remains. + +- `index` package: table tests for `Validate` — empty index (OK), + populated table between markers (OK), zero markers (OK), `##` + header between markers (error), duplicate `INDEX:START` (error), + `END` before `START` (error). +- `entry`/add level: well-formed dash-bullet-seeded and table-seeded + files both survive an add with all prior bodies intact and exactly + one marker pair; a malformed (bodies-in-block) file is refused with + **no write** (file byte-identical after the failed add). + +## Out of Scope + +- `ctx reindex --repair`: an opt-in self-heal that would + relocate `INDEX:END` above trapped bodies instead of refusing. Not + built and not filed — the chosen behavior is fail-loud + manual fix. + Recorded here only as the obvious extension if demand appears. + +## Settled Decisions + +1. Behavior is fail-loud + touch-nothing, not auto-repair (user + decision, this session). +2. `index.Update` signature stays `func(string) string`; the guard is + a separate precondition, not a return-value change — keeps the + CRITICAL-risk call graph untouched. +3. Zero markers is allowed (fresh creation), not treated as malformed.