@davesheffer/hunch 1.10.4 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store/db.js CHANGED
@@ -59,22 +59,60 @@ function createDb(sqlitePath) {
59
59
  db.exec("PRAGMA busy_timeout = 5000");
60
60
  return db;
61
61
  }
62
+ /** Does this error mean the derived index FILE itself is unusable?
63
+ *
64
+ * Matched narrowly, on SQLite's own corruption signatures only. An environment failure —
65
+ * a permission denial, a full disk, a locked file — must still propagate: deleting the
66
+ * file would not fix it and would destroy a cache the user may still be able to keep.
67
+ * `SQLITE_CANTOPEN` ("unable to open database file") is deliberately NOT here for that
68
+ * reason: it usually means a permissions or path problem, not corruption. */
69
+ function isCorruptIndexFile(error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ return /database disk image is malformed|file is not a database|file is encrypted or is not a database|malformed database schema|database corruption/i.test(message);
72
+ }
73
+ function discardDerivedIndex(sqlitePath) {
74
+ for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
75
+ rmSync(path, { force: true });
76
+ }
62
77
  export function openDb(sqlitePath) {
63
78
  mkdirSync(dirname(sqlitePath), { recursive: true });
64
- let db = createDb(sqlitePath);
79
+ // A corrupt file can fail at OPEN as well as at schema init (node:sqlite opens lazily,
80
+ // so "file is not a database" typically surfaces on the first statement — but not always).
81
+ let db;
82
+ try {
83
+ db = createDb(sqlitePath);
84
+ }
85
+ catch (error) {
86
+ if (!isCorruptIndexFile(error))
87
+ throw error;
88
+ discardDerivedIndex(sqlitePath);
89
+ db = createDb(sqlitePath);
90
+ }
65
91
  try {
66
92
  initializeSchema(db);
67
93
  return db;
68
94
  }
69
95
  catch (error) {
70
- if (!(error instanceof RebuildDerivedIndex)) {
96
+ // The ENTIRE database is derived from the Git-native JSON in .hunch/ — it is a cache,
97
+ // and a cache that cannot be read should be rebuilt, not fatal. That reasoning was
98
+ // already written here, but it was wired to exactly ONE trigger: RebuildDerivedIndex,
99
+ // thrown only when an fts5 index meets a runtime without the FTS5 module. Every other
100
+ // error propagated as a raw SQLite string, so a corrupt file took out `hunch index`,
101
+ // `query`, `check` and `doctor` at once — and, because the pre-edit hook must emit
102
+ // nothing and exit 0 on any failure (con_03a0b94b2e), it also went SILENTLY blind,
103
+ // permanently, with no command left that could repair it.
104
+ if (!(error instanceof RebuildDerivedIndex) && !isCorruptIndexFile(error)) {
71
105
  db.close();
72
106
  throw error;
73
107
  }
74
- db.close();
75
- for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
76
- rmSync(path, { force: true });
108
+ try {
109
+ db.close();
110
+ }
111
+ catch { /* a corrupt handle may refuse to close; the file goes anyway */ }
112
+ discardDerivedIndex(sqlitePath);
77
113
  db = createDb(sqlitePath);
114
+ // Deliberately NOT wrapped in another rebuild attempt: if a freshly created file also
115
+ // fails, the problem is the environment, not the cache, and it must surface.
78
116
  initializeSchema(db);
79
117
  return db;
80
118
  }
@@ -95,9 +95,49 @@ export function pickWinner(ours, theirs) {
95
95
  const tr = recency(theirs);
96
96
  if (orr !== tr)
97
97
  return orr > tr ? ours : theirs;
98
+ // A merge must never silently UNDO recorded progress. Closing a bug does not touch
99
+ // `provenance` or `date` — captureTestRun writes
100
+ // `{ ...bug, status: "fixed", lineage: { ...lineage, fixed_commit: sha } }` — so
101
+ // recency() TIES against the still-open side and control always reached the
102
+ // lexicographic tiebreak below. That tiebreak then picked the LESS-resolved record,
103
+ // twice over: `"fixed_commit":null` sorts above `"fixed_commit":"<sha>"` (n > "), and
104
+ // `"status":"open"` sorts above `"status":"fixed"` (o > f). So a clean merge reverted
105
+ // the closure every time, in the subsystem whose entire job is not losing state.
106
+ //
107
+ // Prefer the side carrying more one-way lifecycle evidence. Deterministic and
108
+ // side-independent (a pure function of each record), so A-merges-B and B-merges-A
109
+ // still agree.
110
+ const oe = closureEvidence(ours);
111
+ const te = closureEvidence(theirs);
112
+ if (oe !== te)
113
+ return oe > te ? ours : theirs;
98
114
  // Deterministic, side-independent tiebreak so A-merges-B and B-merges-A agree.
99
115
  return canon(ours) >= canon(theirs) ? ours : theirs;
100
116
  }
117
+ /** Count the one-way lifecycle facts a record carries: a fix commit, a spawned
118
+ * decision/constraint, a supersession, an end of validity. Each is something that
119
+ * HAPPENED and was recorded — never something a merge should quietly discard.
120
+ *
121
+ * Deliberately counts EVIDENCE fields rather than reading `status`: a status string can
122
+ * be moved in either direction (a reopened bug goes fixed -> open), but a recorded
123
+ * `fixed_commit` is a fact about history. Ranking on evidence means a genuine reopen —
124
+ * which clears the commit — is still allowed to win, while a merge can no longer drop a
125
+ * closure that nobody reopened. */
126
+ function closureEvidence(r) {
127
+ let n = 0;
128
+ const lineage = r.lineage;
129
+ if (isRec(lineage)) {
130
+ for (const key of ["fixed_commit", "spawned_decision", "spawned_constraint"]) {
131
+ if (typeof lineage[key] === "string" && lineage[key].length > 0)
132
+ n += 1;
133
+ }
134
+ }
135
+ for (const key of ["superseded_by", "valid_to"]) {
136
+ if (typeof r[key] === "string" && r[key].length > 0)
137
+ n += 1;
138
+ }
139
+ return n;
140
+ }
101
141
  function parseSide(text) {
102
142
  const trimmed = (text ?? "").trim();
103
143
  if (!trimmed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.4",
3
+ "version": "1.10.5",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",