@esneiderbravo/speclaw 0.3.0 → 0.3.1
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.
|
@@ -47,9 +47,17 @@ CREATE TABLE IF NOT EXISTS node_embeddings (
|
|
|
47
47
|
model TEXT NOT NULL,
|
|
48
48
|
vec BLOB NOT NULL
|
|
49
49
|
);
|
|
50
|
+
-- git_history_cache: memoized results of the expensive git-history scans
|
|
51
|
+
-- (churn, co-change), keyed by query and invalidated when HEAD moves.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS git_history_cache (
|
|
53
|
+
query_key TEXT PRIMARY KEY,
|
|
54
|
+
head_sha TEXT NOT NULL,
|
|
55
|
+
payload TEXT NOT NULL,
|
|
56
|
+
computed_at INTEGER NOT NULL
|
|
57
|
+
);
|
|
50
58
|
`;
|
|
51
59
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
52
|
-
export const SCHEMA_VERSION = "
|
|
60
|
+
export const SCHEMA_VERSION = "4";
|
|
53
61
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
54
62
|
function readSchemaVersion(db) {
|
|
55
63
|
try {
|
|
@@ -81,6 +89,7 @@ function isStale(db) {
|
|
|
81
89
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
82
90
|
function resetSchema(db) {
|
|
83
91
|
db.exec(`
|
|
92
|
+
DROP TABLE IF EXISTS git_history_cache;
|
|
84
93
|
DROP TABLE IF EXISTS node_embeddings;
|
|
85
94
|
DROP TABLE IF EXISTS edges;
|
|
86
95
|
DROP TABLE IF EXISTS nodes;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { churn, coChanges, headSha, } from "../../shared/git-history.js";
|
|
2
|
+
import { openDb } from "./db.js";
|
|
3
|
+
/**
|
|
4
|
+
* Look up a cached payload valid at the current HEAD, or compute it and store it.
|
|
5
|
+
*
|
|
6
|
+
* When `head` is `null` (no commits / not a repo) the cache is bypassed entirely
|
|
7
|
+
* and `compute()` runs directly, so an empty repo never poisons the cache.
|
|
8
|
+
*
|
|
9
|
+
* @param projectPath - Project root, whose `.speclaw/index.db` holds the cache.
|
|
10
|
+
* @param head - The current HEAD SHA, or `null` when there is none.
|
|
11
|
+
* @param queryKey - Stable key identifying this query (function + options).
|
|
12
|
+
* @param compute - Produces the fresh result on a miss.
|
|
13
|
+
* @param serialize - Turns the result into a JSON-safe payload string.
|
|
14
|
+
* @param deserialize - Rebuilds the result from a stored payload string.
|
|
15
|
+
* @returns The cached-or-freshly-computed result.
|
|
16
|
+
*/
|
|
17
|
+
function readThrough(projectPath, head, queryKey, compute, serialize, deserialize) {
|
|
18
|
+
if (head === null)
|
|
19
|
+
return compute();
|
|
20
|
+
const db = openDb(projectPath);
|
|
21
|
+
try {
|
|
22
|
+
const row = db
|
|
23
|
+
.prepare("SELECT head_sha, payload FROM git_history_cache WHERE query_key = ?")
|
|
24
|
+
.get(queryKey);
|
|
25
|
+
if (row && row.head_sha === head) {
|
|
26
|
+
return deserialize(row.payload);
|
|
27
|
+
}
|
|
28
|
+
const value = compute();
|
|
29
|
+
db.prepare(`INSERT INTO git_history_cache(query_key, head_sha, payload, computed_at)
|
|
30
|
+
VALUES (?, ?, ?, 0)
|
|
31
|
+
ON CONFLICT(query_key) DO UPDATE SET
|
|
32
|
+
head_sha = excluded.head_sha,
|
|
33
|
+
payload = excluded.payload,
|
|
34
|
+
computed_at = excluded.computed_at`).run(queryKey, head, serialize(value));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* {@link churn}, memoized in the Compass index until `HEAD` moves.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Project root to query.
|
|
45
|
+
* @param opts - Same options as {@link churn}.
|
|
46
|
+
* @returns Per-path change counts and the shallow marker, cached per HEAD.
|
|
47
|
+
*/
|
|
48
|
+
export function cachedChurn(projectPath, opts = {}) {
|
|
49
|
+
const key = `churn:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
|
|
50
|
+
return readThrough(projectPath, headSha(projectPath), key, () => churn(projectPath, opts), (value) => JSON.stringify({ shallow: value.shallow, byPath: [...value.byPath] }), (payload) => {
|
|
51
|
+
const parsed = JSON.parse(payload);
|
|
52
|
+
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link coChanges}, memoized in the Compass index until `HEAD` moves.
|
|
57
|
+
*
|
|
58
|
+
* @param projectPath - Project root to query.
|
|
59
|
+
* @param opts - Same options as {@link coChanges}.
|
|
60
|
+
* @returns The co-change pairs and the shallow marker, cached per HEAD.
|
|
61
|
+
*/
|
|
62
|
+
export function cachedCoChanges(projectPath, opts = {}) {
|
|
63
|
+
const key = `coChanges:${JSON.stringify({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
|
|
64
|
+
return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
|
|
65
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** ASCII NUL — the record/field separator git emits with `-z` and we request via `%x00`. */
|
|
3
|
+
const NUL = "\0";
|
|
4
|
+
/**
|
|
5
|
+
* Run git in `projectPath` and return stdout, or `null` on any failure.
|
|
6
|
+
*
|
|
7
|
+
* Best-effort like {@link isGitRepo}: git missing, not a repo, or a non-zero
|
|
8
|
+
* exit all yield `null` so callers can fail soft rather than throw.
|
|
9
|
+
*/
|
|
10
|
+
function git(projectPath, args) {
|
|
11
|
+
// `core.quotePath=false` keeps non-ASCII paths as raw UTF-8 instead of git's
|
|
12
|
+
// default octal-escaped, double-quoted form — so our path parsing stays exact.
|
|
13
|
+
const res = spawnSync("git", ["-C", projectPath, "-c", "core.quotePath=false", ...args], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
if (res.status !== 0 || typeof res.stdout !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return res.stdout;
|
|
20
|
+
}
|
|
21
|
+
/** Parse a `--numstat` count field: `-` (binary) becomes `0`, anything non-numeric too. */
|
|
22
|
+
function numstat(field) {
|
|
23
|
+
if (field === "-")
|
|
24
|
+
return 0;
|
|
25
|
+
const n = Number.parseInt(field, 10);
|
|
26
|
+
return Number.isFinite(n) ? n : 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The current `HEAD` commit SHA, or `null` when the repo has no commits yet
|
|
30
|
+
* (or is not a repo / git is unavailable).
|
|
31
|
+
*
|
|
32
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
33
|
+
* @returns The 40-char SHA, or `null`.
|
|
34
|
+
*/
|
|
35
|
+
export function headSha(projectPath) {
|
|
36
|
+
const out = git(projectPath, ["rev-parse", "HEAD"]);
|
|
37
|
+
const sha = out?.trim();
|
|
38
|
+
return sha ? sha : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether `projectPath` is inside a shallow clone (e.g. CI's `--depth=1`), where
|
|
42
|
+
* history is truncated and change/coupling counts would be misleadingly low.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
45
|
+
* @returns `true` only when git reports the repository is shallow.
|
|
46
|
+
*/
|
|
47
|
+
export function isShallowRepo(projectPath) {
|
|
48
|
+
const out = git(projectPath, ["rev-parse", "--is-shallow-repository"]);
|
|
49
|
+
return out?.trim() === "true";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The commits that touched `relPath`, most-recent first, with the line churn
|
|
53
|
+
* each introduced there.
|
|
54
|
+
*
|
|
55
|
+
* Fail-soft: a path with no history, a repo with no commits, or an unavailable
|
|
56
|
+
* git binary all yield an empty list. Records are parsed over NUL separators, so
|
|
57
|
+
* paths containing spaces or unicode are handled correctly.
|
|
58
|
+
*
|
|
59
|
+
* `since`/`until` bound the history as a **revision range** (`since..until`,
|
|
60
|
+
* `until` defaulting to `HEAD`) — the exact, deterministic form drift needs
|
|
61
|
+
* (`<archived-sha>..HEAD`), not an approximate date window. `since` is exclusive.
|
|
62
|
+
*
|
|
63
|
+
* @param projectPath - Project root to query.
|
|
64
|
+
* @param relPath - Project-relative path whose history to read.
|
|
65
|
+
* @param opts - Optional revision bounds: `since` (exclusive lower bound) and
|
|
66
|
+
* `until` (upper bound, default `HEAD`) — any revision git accepts, e.g. a SHA.
|
|
67
|
+
* @returns The touching commits, newest first; empty when there is no history.
|
|
68
|
+
*/
|
|
69
|
+
export function logForPath(projectPath, relPath, opts = {}) {
|
|
70
|
+
const range = [];
|
|
71
|
+
if (opts.since)
|
|
72
|
+
range.push(`${opts.since}..${opts.until ?? "HEAD"}`);
|
|
73
|
+
else if (opts.until)
|
|
74
|
+
range.push(opts.until);
|
|
75
|
+
// Per commit: <sha>\0<ts>\0 then one numstat line per file it touched.
|
|
76
|
+
const out = git(projectPath, [
|
|
77
|
+
"log",
|
|
78
|
+
"--format=%x00%H%x00%ct%x00",
|
|
79
|
+
"--numstat",
|
|
80
|
+
...range,
|
|
81
|
+
"--",
|
|
82
|
+
relPath,
|
|
83
|
+
]);
|
|
84
|
+
if (out === null)
|
|
85
|
+
return [];
|
|
86
|
+
const touches = [];
|
|
87
|
+
// The stream is a sequence of "\0<sha>\0<ts>\0<numstat lines>" per commit.
|
|
88
|
+
const records = out.split(NUL);
|
|
89
|
+
// records[0] is empty (leading NUL); then repeating [sha, ts, tail...] where
|
|
90
|
+
// `tail` holds the numstat lines for that commit up to the next leading NUL.
|
|
91
|
+
for (let i = 1; i + 1 < records.length; i += 3) {
|
|
92
|
+
const sha = records[i]?.trim();
|
|
93
|
+
const ts = Number.parseInt(records[i + 1] ?? "", 10);
|
|
94
|
+
const tail = records[i + 2] ?? "";
|
|
95
|
+
if (!sha || !Number.isFinite(ts))
|
|
96
|
+
continue;
|
|
97
|
+
let added = 0;
|
|
98
|
+
let deleted = 0;
|
|
99
|
+
for (const line of tail.split("\n")) {
|
|
100
|
+
const cols = line.split("\t");
|
|
101
|
+
if (cols.length < 3)
|
|
102
|
+
continue;
|
|
103
|
+
added += numstat(cols[0]);
|
|
104
|
+
deleted += numstat(cols[1]);
|
|
105
|
+
}
|
|
106
|
+
touches.push({ sha, ts, added, deleted });
|
|
107
|
+
}
|
|
108
|
+
return touches;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* How many commits touched each file in the window, summed from `--numstat`.
|
|
112
|
+
*
|
|
113
|
+
* Fail-soft: yields an empty map on any git failure. Does not follow renames —
|
|
114
|
+
* a renamed file is counted under its path as it appears in each commit (a safe
|
|
115
|
+
* superset, not a precise lineage). The result carries the {@link isShallowRepo}
|
|
116
|
+
* marker so consumers can degrade to "insufficient data" on a shallow clone.
|
|
117
|
+
*
|
|
118
|
+
* @param projectPath - Project root to query.
|
|
119
|
+
* @param opts - Optional `since` window (any date/revision git accepts) and a
|
|
120
|
+
* `pathspec` list to restrict which paths are considered.
|
|
121
|
+
* @returns Per-path change counts and the shallow marker.
|
|
122
|
+
*/
|
|
123
|
+
export function churn(projectPath, opts = {}) {
|
|
124
|
+
const shallow = isShallowRepo(projectPath);
|
|
125
|
+
const args = ["log", "--numstat", "--format=%x00"];
|
|
126
|
+
if (opts.since)
|
|
127
|
+
args.push(`--since=${opts.since}`);
|
|
128
|
+
if (opts.pathspec && opts.pathspec.length > 0)
|
|
129
|
+
args.push("--", ...opts.pathspec);
|
|
130
|
+
const out = git(projectPath, args);
|
|
131
|
+
const byPath = new Map();
|
|
132
|
+
if (out === null)
|
|
133
|
+
return { shallow, byPath };
|
|
134
|
+
for (const line of out.split("\n")) {
|
|
135
|
+
// Numstat rows are "<added>\t<deleted>\t<path>"; the %x00 format lines and
|
|
136
|
+
// blank lines have no tabs and are skipped.
|
|
137
|
+
const cols = line.split("\t");
|
|
138
|
+
if (cols.length < 3)
|
|
139
|
+
continue;
|
|
140
|
+
const path = cols[2].replace(/^\0+/, "").trim();
|
|
141
|
+
if (!path)
|
|
142
|
+
continue;
|
|
143
|
+
byPath.set(path, (byPath.get(path) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
return { shallow, byPath };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* For every pair of files that changed together, how many commits touched both.
|
|
149
|
+
*
|
|
150
|
+
* Groups each commit's changed files and emits a count per unordered pair. Pairs
|
|
151
|
+
* with fewer than `minSupport` shared commits are omitted. Fail-soft (empty on
|
|
152
|
+
* git failure) and does not follow renames. Carries the shallow marker.
|
|
153
|
+
*
|
|
154
|
+
* @param projectPath - Project root to query.
|
|
155
|
+
* @param opts - Optional `since` window and `minSupport` threshold (default `1`).
|
|
156
|
+
* @returns The qualifying co-change pairs and the shallow marker.
|
|
157
|
+
*/
|
|
158
|
+
export function coChanges(projectPath, opts = {}) {
|
|
159
|
+
const shallow = isShallowRepo(projectPath);
|
|
160
|
+
const minSupport = opts.minSupport ?? 1;
|
|
161
|
+
const args = ["log", "--name-only", "--format=%x00"];
|
|
162
|
+
if (opts.since)
|
|
163
|
+
args.push(`--since=${opts.since}`);
|
|
164
|
+
const out = git(projectPath, args);
|
|
165
|
+
if (out === null)
|
|
166
|
+
return { shallow, pairs: [] };
|
|
167
|
+
const counts = new Map();
|
|
168
|
+
// Each commit's file list is the run of lines between two %x00 markers.
|
|
169
|
+
for (const commitBlock of out.split(NUL)) {
|
|
170
|
+
const files = commitBlock
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((l) => l.trim())
|
|
173
|
+
.filter((l) => l.length > 0);
|
|
174
|
+
const unique = [...new Set(files)].sort();
|
|
175
|
+
for (let i = 0; i < unique.length; i++) {
|
|
176
|
+
for (let j = i + 1; j < unique.length; j++) {
|
|
177
|
+
const key = `${unique[i]}\t${unique[j]}`;
|
|
178
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const pairs = [];
|
|
183
|
+
for (const [key, count] of counts) {
|
|
184
|
+
if (count < minSupport)
|
|
185
|
+
continue;
|
|
186
|
+
const [a, b] = key.split("\t");
|
|
187
|
+
pairs.push({ a: a, b: b, count });
|
|
188
|
+
}
|
|
189
|
+
pairs.sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
|
|
190
|
+
return { shallow, pairs };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The SHA of the most recent commit that touched `relPath`, or `null` when the
|
|
194
|
+
* path has no history (or on any git failure).
|
|
195
|
+
*
|
|
196
|
+
* @param projectPath - Project root to query.
|
|
197
|
+
* @param relPath - Project-relative path.
|
|
198
|
+
* @returns The last-touching commit SHA, or `null`.
|
|
199
|
+
*/
|
|
200
|
+
export function lastTouch(projectPath, relPath) {
|
|
201
|
+
const out = git(projectPath, ["log", "-1", "--format=%H", "--", relPath]);
|
|
202
|
+
const sha = out?.trim();
|
|
203
|
+
return sha ? sha : null;
|
|
204
|
+
}
|