@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 +43 -5
- package/dist/store/merge.js +40 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
}
|
package/dist/store/merge.js
CHANGED
|
@@ -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.
|
|
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.",
|