@esneiderbravo/speclaw 0.3.10 → 0.3.11
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/cli/commands/query.js +49 -2
- package/dist/cli/commands/update.js +8 -0
- package/dist/cli/index.js +7 -3
- package/dist/modules/compass/db.js +15 -2
- package/dist/modules/compass/extract.js +44 -0
- package/dist/modules/compass/git-history-cache.js +19 -2
- package/dist/modules/compass/hotspots.js +230 -0
- package/dist/modules/compass/indexer.js +2 -0
- package/dist/modules/compass/languages.js +39 -0
- package/dist/modules/compass/register.js +17 -0
- package/dist/shared/exposure.js +2 -0
- package/dist/shared/git-history.js +85 -5
- package/package.json +1 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
|
|
2
2
|
import { affectedTests } from "../../modules/compass/affected.js";
|
|
3
|
+
import { hotspots, coupling } from "../../modules/compass/hotspots.js";
|
|
3
4
|
import { list } from "../lib/args.js";
|
|
4
5
|
import { ui } from "../lib/ui.js";
|
|
5
6
|
/**
|
|
6
7
|
* Run a Compass query from the shell — the same surface agents call via MCP.
|
|
7
8
|
*
|
|
8
|
-
* @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`,
|
|
9
|
-
* `affected-tests`.
|
|
9
|
+
* @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`,
|
|
10
|
+
* `affected-tests`, `hotspots`, or `coupling`.
|
|
10
11
|
* @param flags - Parsed flags supplying positional args and options in `_`.
|
|
11
12
|
* @throws Exits the process with code 1 on missing arguments or query errors.
|
|
12
13
|
*/
|
|
@@ -114,6 +115,52 @@ export async function runQuery(cmd, flags) {
|
|
|
114
115
|
result.warnings.forEach((w) => ui.warn(w));
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
118
|
+
case "hotspots": {
|
|
119
|
+
const sortRaw = typeof flags.sort === "string" ? flags.sort : undefined;
|
|
120
|
+
const sortBy = sortRaw === "churn" || sortRaw === "complexity" || sortRaw === "combined"
|
|
121
|
+
? sortRaw
|
|
122
|
+
: "combined";
|
|
123
|
+
const result = hotspots(cwd, {
|
|
124
|
+
days: flags.days ? Number(flags.days) : undefined,
|
|
125
|
+
since: typeof flags.since === "string" ? flags.since : undefined,
|
|
126
|
+
sortBy,
|
|
127
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
128
|
+
});
|
|
129
|
+
if (asJson) {
|
|
130
|
+
console.log(JSON.stringify(result, null, 2));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
ui.heading(`Hotspots (${result.window.label}, sort=${result.sortBy}): ${result.hotspots.length}`);
|
|
134
|
+
for (const h of result.hotspots) {
|
|
135
|
+
const health = h.health
|
|
136
|
+
? `branches=${h.health.worstBranches} nest=${h.health.worstNesting} loc=${h.health.worstLoc}`
|
|
137
|
+
: "health=n/a";
|
|
138
|
+
ui.info(`${h.file} commits=${h.activity.commits} authors=${h.activity.authors} ${health}`);
|
|
139
|
+
}
|
|
140
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case "coupling": {
|
|
144
|
+
const file = need(args[0], "coupling <file>");
|
|
145
|
+
const result = coupling(cwd, file, {
|
|
146
|
+
days: flags.days ? Number(flags.days) : undefined,
|
|
147
|
+
since: typeof flags.since === "string" ? flags.since : undefined,
|
|
148
|
+
minShared: flags["min-shared"] ? Number(flags["min-shared"]) : undefined,
|
|
149
|
+
maxFilesPerCommit: flags["max-files"] ? Number(flags["max-files"]) : undefined,
|
|
150
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
151
|
+
});
|
|
152
|
+
if (asJson) {
|
|
153
|
+
console.log(JSON.stringify(result, null, 2));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
ui.heading(`Coupling for ${result.file} (${result.window.label}): ${result.partners.length} partner(s)`);
|
|
157
|
+
ui.info(`scanned=${result.diagnostics.commitsScanned} skippedTooLarge=${result.diagnostics.skippedTooLarge}`);
|
|
158
|
+
for (const p of result.partners) {
|
|
159
|
+
ui.info(`${p.file} both=${p.both} strength=${p.strength.toFixed(3)} in_graph=${p.inGraph} isTestPair=${p.isTestPair}`);
|
|
160
|
+
}
|
|
161
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
117
164
|
case "trace": {
|
|
118
165
|
const r = trace(cwd, need(args[0], "trace <from> <to>"), need(args[1], "trace <from> <to>"));
|
|
119
166
|
ui.heading(`Trace ${r.from} → ${r.to}`);
|
|
@@ -117,6 +117,14 @@ const MIGRATIONS = [
|
|
|
117
117
|
"`.speclaw/affected.json` overrides globals/test globs.\n" +
|
|
118
118
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
version: "0.3.11",
|
|
122
|
+
describe: "Hotspots + coupling: schema 8 node_metrics, 90d activity window",
|
|
123
|
+
agentPrompt: "- Mention `compass_hotspots` / `speclaw hotspots` and `compass_coupling` / `speclaw coupling` " +
|
|
124
|
+
"(activity × AST health; Jaccard strength + in_graph + isTestPair). Compass schema is now 8 " +
|
|
125
|
+
"(`node_metrics`) — reindex with `speclaw index`. Default history window is 90 days.\n" +
|
|
126
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
127
|
+
},
|
|
120
128
|
];
|
|
121
129
|
/**
|
|
122
130
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -25,6 +25,8 @@ Compass (code intelligence — the same surface agents use via MCP)
|
|
|
25
25
|
recall "<query>" Find code by meaning (semantic)
|
|
26
26
|
impact <node> Blast radius (grouped by module; --flat / --json)
|
|
27
27
|
affected-tests Tests affected by a change (--file / --from-diff / --json)
|
|
28
|
+
hotspots Rank files by recent churn × AST complexity (--json / --sort)
|
|
29
|
+
coupling <file> Temporal co-change partners for a file (--json)
|
|
28
30
|
trace <from> <to> A call path between two nodes
|
|
29
31
|
visualize [node] Interactive HTML graph → .speclaw/graph.html
|
|
30
32
|
|
|
@@ -51,9 +53,9 @@ Other
|
|
|
51
53
|
// Commands that open with the one-line branded header. These are the
|
|
52
54
|
// interactive, human-facing commands whose stdout is prose. Deliberately
|
|
53
55
|
// excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
|
|
54
|
-
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests
|
|
55
|
-
// machine-consumed output), `mcp` (a long-running stdio
|
|
56
|
-
// (already opens with the fuller `banner()`).
|
|
56
|
+
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`/
|
|
57
|
+
// `hotspots`/`coupling`, machine-consumed output), `mcp` (a long-running stdio
|
|
58
|
+
// server), and `init` (already opens with the fuller `banner()`).
|
|
57
59
|
const HEADER_COMMANDS = new Set([
|
|
58
60
|
undefined,
|
|
59
61
|
"help",
|
|
@@ -128,6 +130,8 @@ async function dispatch(cmd, flags) {
|
|
|
128
130
|
case "impact":
|
|
129
131
|
case "trace":
|
|
130
132
|
case "affected-tests":
|
|
133
|
+
case "hotspots":
|
|
134
|
+
case "coupling":
|
|
131
135
|
return (await import("./commands/query.js")).runQuery(cmd, flags);
|
|
132
136
|
case "visualize":
|
|
133
137
|
return (await import("./commands/visualize.js")).runVisualize(flags);
|
|
@@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|
|
33
33
|
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
|
34
34
|
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
|
|
35
35
|
CREATE INDEX IF NOT EXISTS idx_nodes_norm_hash ON nodes(norm_hash);
|
|
36
|
+
-- node_metrics: AST health frames (LOC / nesting / branches) per definition.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS node_metrics (
|
|
38
|
+
node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
|
39
|
+
loc INTEGER NOT NULL,
|
|
40
|
+
max_nesting INTEGER NOT NULL,
|
|
41
|
+
branches INTEGER NOT NULL
|
|
42
|
+
);
|
|
36
43
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
37
44
|
CREATE TABLE IF NOT EXISTS edges (
|
|
38
45
|
id INTEGER PRIMARY KEY,
|
|
@@ -104,7 +111,7 @@ CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
|
|
|
104
111
|
CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
|
|
105
112
|
`;
|
|
106
113
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
107
|
-
export const SCHEMA_VERSION = "
|
|
114
|
+
export const SCHEMA_VERSION = "8";
|
|
108
115
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
109
116
|
function readSchemaVersion(db) {
|
|
110
117
|
try {
|
|
@@ -134,7 +141,12 @@ function isStale(db) {
|
|
|
134
141
|
if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
|
|
135
142
|
return true;
|
|
136
143
|
const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
|
|
137
|
-
|
|
144
|
+
if (!fileCols.includes("is_test") || !fileCols.includes("module"))
|
|
145
|
+
return true;
|
|
146
|
+
const hasMetrics = db
|
|
147
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_metrics'")
|
|
148
|
+
.get();
|
|
149
|
+
return !hasMetrics;
|
|
138
150
|
}
|
|
139
151
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
140
152
|
function resetSchema(db) {
|
|
@@ -144,6 +156,7 @@ function resetSchema(db) {
|
|
|
144
156
|
DROP TABLE IF EXISTS git_history_cache;
|
|
145
157
|
DROP TABLE IF EXISTS node_embeddings;
|
|
146
158
|
DROP TABLE IF EXISTS edges;
|
|
159
|
+
DROP TABLE IF EXISTS node_metrics;
|
|
147
160
|
DROP TABLE IF EXISTS nodes;
|
|
148
161
|
DROP TABLE IF EXISTS files;
|
|
149
162
|
DROP TABLE IF EXISTS meta;
|
|
@@ -37,6 +37,46 @@ function calleeName(node, lang) {
|
|
|
37
37
|
function signatureOf(node) {
|
|
38
38
|
return node.text.split("\n")[0].trim().slice(0, 200);
|
|
39
39
|
}
|
|
40
|
+
const BOOL_OPS = new Set(["&&", "||", "and", "or"]);
|
|
41
|
+
/**
|
|
42
|
+
* Compute LOC / max nesting / branch counts for a definition subtree.
|
|
43
|
+
* Nesting depth is relative to the definition body (starts at 0).
|
|
44
|
+
*/
|
|
45
|
+
export function metricsOf(defNode, lang) {
|
|
46
|
+
const nesting = new Set(lang.nestingNodes);
|
|
47
|
+
const branchesSet = new Set(lang.branchNodes);
|
|
48
|
+
let maxNesting = 0;
|
|
49
|
+
let branches = 0;
|
|
50
|
+
const walk = (node, depth) => {
|
|
51
|
+
const nestHere = nesting.has(node.type);
|
|
52
|
+
const nextDepth = nestHere ? depth + 1 : depth;
|
|
53
|
+
if (nestHere)
|
|
54
|
+
maxNesting = Math.max(maxNesting, nextDepth);
|
|
55
|
+
if (branchesSet.has(node.type)) {
|
|
56
|
+
branches++;
|
|
57
|
+
}
|
|
58
|
+
else if (node.type === "binary_expression") {
|
|
59
|
+
const op = node.childForFieldName("operator")?.text ?? "";
|
|
60
|
+
if (BOOL_OPS.has(op))
|
|
61
|
+
branches++;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
64
|
+
const child = node.child(i);
|
|
65
|
+
if (child)
|
|
66
|
+
walk(child, nextDepth);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
for (let i = 0; i < defNode.childCount; i++) {
|
|
70
|
+
const child = defNode.child(i);
|
|
71
|
+
if (child)
|
|
72
|
+
walk(child, 0);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
loc: defNode.endPosition.row - defNode.startPosition.row + 1,
|
|
76
|
+
maxNesting,
|
|
77
|
+
branches,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
40
80
|
/** Parse Covers:/Needs: directives from a comment node's text. */
|
|
41
81
|
function parseCoverageComment(node, ownerIndex) {
|
|
42
82
|
const text = node.text;
|
|
@@ -105,6 +145,7 @@ export async function extract(source, lang) {
|
|
|
105
145
|
const name = defName(node);
|
|
106
146
|
if (name) {
|
|
107
147
|
const index = symbols.length;
|
|
148
|
+
const health = metricsOf(node, lang);
|
|
108
149
|
symbols.push({
|
|
109
150
|
name,
|
|
110
151
|
kind: kinds.get(node.type),
|
|
@@ -116,6 +157,9 @@ export async function extract(source, lang) {
|
|
|
116
157
|
signature: signatureOf(node),
|
|
117
158
|
bodyHash: rawHash(source, node.startIndex, node.endIndex),
|
|
118
159
|
normHash: structuralHash(node),
|
|
160
|
+
loc: health.loc,
|
|
161
|
+
maxNesting: health.maxNesting,
|
|
162
|
+
branches: health.branches,
|
|
119
163
|
});
|
|
120
164
|
nextOwner = index;
|
|
121
165
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { churn, coChanges, headSha, } from "../../shared/git-history.js";
|
|
1
|
+
import { churn, coChanges, fileActivity, headSha, } from "../../shared/git-history.js";
|
|
2
2
|
import { openDb } from "./db.js";
|
|
3
3
|
/**
|
|
4
4
|
* Look up a cached payload valid at the current HEAD, or compute it and store it.
|
|
@@ -52,6 +52,19 @@ export function cachedChurn(projectPath, opts = {}) {
|
|
|
52
52
|
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link fileActivity}, memoized in the Compass index until `HEAD` moves.
|
|
57
|
+
*/
|
|
58
|
+
export function cachedFileActivity(projectPath, opts = {}) {
|
|
59
|
+
const key = `fileActivity:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
|
|
60
|
+
return readThrough(projectPath, headSha(projectPath), key, () => fileActivity(projectPath, opts), (value) => JSON.stringify({
|
|
61
|
+
shallow: value.shallow,
|
|
62
|
+
byPath: [...value.byPath],
|
|
63
|
+
}), (payload) => {
|
|
64
|
+
const parsed = JSON.parse(payload);
|
|
65
|
+
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
55
68
|
/**
|
|
56
69
|
* {@link coChanges}, memoized in the Compass index until `HEAD` moves.
|
|
57
70
|
*
|
|
@@ -60,6 +73,10 @@ export function cachedChurn(projectPath, opts = {}) {
|
|
|
60
73
|
* @returns The co-change pairs and the shallow marker, cached per HEAD.
|
|
61
74
|
*/
|
|
62
75
|
export function cachedCoChanges(projectPath, opts = {}) {
|
|
63
|
-
const key = `coChanges:${JSON.stringify({
|
|
76
|
+
const key = `coChanges:${JSON.stringify({
|
|
77
|
+
since: opts.since ?? null,
|
|
78
|
+
minSupport: opts.minSupport ?? null,
|
|
79
|
+
maxFilesPerCommit: opts.maxFilesPerCommit ?? null,
|
|
80
|
+
})}`;
|
|
64
81
|
return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
|
|
65
82
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { openDb } from "./db.js";
|
|
2
|
+
import { cachedCoChanges, cachedFileActivity } from "./git-history-cache.js";
|
|
3
|
+
import { jaccardStrength } from "../../shared/git-history.js";
|
|
4
|
+
/** Default history window for hotspot / coupling ranking. */
|
|
5
|
+
export const DEFAULT_WINDOW_DAYS = 90;
|
|
6
|
+
/** Default max files in a commit before coupling discards it. */
|
|
7
|
+
export const DEFAULT_MAX_FILES_PER_COMMIT = 50;
|
|
8
|
+
/** Default minimum shared commits for a coupling pair. */
|
|
9
|
+
export const DEFAULT_MIN_SHARED = 2;
|
|
10
|
+
/** ISO date string for `git --since` N days ago (UTC calendar day). */
|
|
11
|
+
export function sinceDaysAgo(days, now = new Date()) {
|
|
12
|
+
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
13
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
14
|
+
return d.toISOString().slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Combined sort heuristic: activity commits × (1 + worstBranches + worstNesting/2).
|
|
18
|
+
* Axes remain on each entry; this score is only for ordering.
|
|
19
|
+
*/
|
|
20
|
+
function combinedScore(activity, health) {
|
|
21
|
+
const complexity = health ? 1 + health.worstBranches + health.worstNesting / 2 : 1;
|
|
22
|
+
return activity.commits * complexity;
|
|
23
|
+
}
|
|
24
|
+
function loadFileHealth(projectPath) {
|
|
25
|
+
const db = openDb(projectPath);
|
|
26
|
+
try {
|
|
27
|
+
const rows = db
|
|
28
|
+
.prepare(`SELECT f.path AS path,
|
|
29
|
+
COUNT(n.id) AS symbols,
|
|
30
|
+
COALESCE(MAX(m.loc), 0) AS worst_loc,
|
|
31
|
+
COALESCE(MAX(m.max_nesting), 0) AS worst_nesting,
|
|
32
|
+
COALESCE(MAX(m.branches), 0) AS worst_branches
|
|
33
|
+
FROM files f
|
|
34
|
+
LEFT JOIN nodes n ON n.file_id = f.id
|
|
35
|
+
LEFT JOIN node_metrics m ON m.node_id = n.id
|
|
36
|
+
GROUP BY f.id`)
|
|
37
|
+
.all();
|
|
38
|
+
const map = new Map();
|
|
39
|
+
for (const r of rows) {
|
|
40
|
+
map.set(r.path, {
|
|
41
|
+
worstLoc: Number(r.worst_loc),
|
|
42
|
+
worstNesting: Number(r.worst_nesting),
|
|
43
|
+
worstBranches: Number(r.worst_branches),
|
|
44
|
+
symbols: Number(r.symbols),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
db.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Rank files by git activity × AST health for agent attention.
|
|
55
|
+
*
|
|
56
|
+
* @param projectPath - Project root with `.speclaw/index.db` and git history.
|
|
57
|
+
* @param opts - Window, sort, and result limit.
|
|
58
|
+
*/
|
|
59
|
+
export function hotspots(projectPath, opts = {}) {
|
|
60
|
+
const days = opts.days ?? DEFAULT_WINDOW_DAYS;
|
|
61
|
+
const since = opts.since ?? sinceDaysAgo(days);
|
|
62
|
+
const sortBy = opts.sortBy ?? "combined";
|
|
63
|
+
const limit = opts.limit ?? 25;
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const activity = cachedFileActivity(projectPath, { since });
|
|
66
|
+
if (activity.shallow) {
|
|
67
|
+
warnings.push("Repository is a shallow clone; history may be truncated.");
|
|
68
|
+
}
|
|
69
|
+
const healthByFile = loadFileHealth(projectPath);
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const [file, act] of activity.byPath) {
|
|
72
|
+
if (act.commits <= 0)
|
|
73
|
+
continue;
|
|
74
|
+
const health = healthByFile.get(file) ?? null;
|
|
75
|
+
entries.push({
|
|
76
|
+
file,
|
|
77
|
+
activity: {
|
|
78
|
+
commits: act.commits,
|
|
79
|
+
linesAdded: act.linesAdded,
|
|
80
|
+
linesDeleted: act.linesDeleted,
|
|
81
|
+
authors: act.authors,
|
|
82
|
+
},
|
|
83
|
+
health,
|
|
84
|
+
combinedScore: combinedScore(act, health),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const rank = (a, b) => {
|
|
88
|
+
if (sortBy === "churn") {
|
|
89
|
+
return (b.activity.commits - a.activity.commits ||
|
|
90
|
+
b.activity.linesAdded +
|
|
91
|
+
b.activity.linesDeleted -
|
|
92
|
+
(a.activity.linesAdded + a.activity.linesDeleted) ||
|
|
93
|
+
a.file.localeCompare(b.file));
|
|
94
|
+
}
|
|
95
|
+
if (sortBy === "complexity") {
|
|
96
|
+
const bw = b.health?.worstBranches ?? -1;
|
|
97
|
+
const aw = a.health?.worstBranches ?? -1;
|
|
98
|
+
return (bw - aw ||
|
|
99
|
+
(b.health?.worstNesting ?? -1) - (a.health?.worstNesting ?? -1) ||
|
|
100
|
+
(b.health?.worstLoc ?? -1) - (a.health?.worstLoc ?? -1) ||
|
|
101
|
+
a.file.localeCompare(b.file));
|
|
102
|
+
}
|
|
103
|
+
return b.combinedScore - a.combinedScore || a.file.localeCompare(b.file);
|
|
104
|
+
};
|
|
105
|
+
entries.sort(rank);
|
|
106
|
+
return {
|
|
107
|
+
window: { days, since, label: `last ${days} days (since ${since})` },
|
|
108
|
+
sortBy,
|
|
109
|
+
hotspots: entries.slice(0, limit),
|
|
110
|
+
diagnostics: {
|
|
111
|
+
filesWithActivity: entries.length,
|
|
112
|
+
indexedHealthFiles: [...healthByFile.keys()].length,
|
|
113
|
+
},
|
|
114
|
+
warnings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function fileMeta(projectPath, paths) {
|
|
118
|
+
const db = openDb(projectPath);
|
|
119
|
+
try {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
if (paths.length === 0)
|
|
122
|
+
return map;
|
|
123
|
+
const placeholders = paths.map(() => "?").join(",");
|
|
124
|
+
const rows = db
|
|
125
|
+
.prepare(`SELECT id, path, is_test FROM files WHERE path IN (${placeholders})`)
|
|
126
|
+
.all(...paths);
|
|
127
|
+
for (const r of rows)
|
|
128
|
+
map.set(r.path, { isTest: r.is_test === 1, id: r.id });
|
|
129
|
+
return map;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
db.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** True when any call/import edge links symbols in the two files (either direction). */
|
|
136
|
+
function pairInGraph(projectPath, a, b) {
|
|
137
|
+
const db = openDb(projectPath);
|
|
138
|
+
try {
|
|
139
|
+
const row = db
|
|
140
|
+
.prepare(`SELECT 1 AS ok
|
|
141
|
+
FROM edges e
|
|
142
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
143
|
+
JOIN nodes dn ON dn.id = e.dst_node_id
|
|
144
|
+
JOIN files df ON df.id = dn.file_id
|
|
145
|
+
WHERE e.kind IN ('call', 'import')
|
|
146
|
+
AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
|
|
147
|
+
LIMIT 1`)
|
|
148
|
+
.get(a, b, b, a);
|
|
149
|
+
if (row)
|
|
150
|
+
return true;
|
|
151
|
+
// Name-only imports: dst_node_id NULL — check import edge text contains other path basename loosely via file paths of same module is hard;
|
|
152
|
+
// also match unresolved edges where dst resolves by file path of an indexed import target is out of scope.
|
|
153
|
+
// Fallback: any edge from a whose dst_name matches a symbol defined in b (or reverse).
|
|
154
|
+
const byName = db
|
|
155
|
+
.prepare(`SELECT 1 AS ok
|
|
156
|
+
FROM edges e
|
|
157
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
158
|
+
JOIN nodes dn ON dn.name = e.dst_name
|
|
159
|
+
JOIN files df ON df.id = dn.file_id
|
|
160
|
+
WHERE e.dst_node_id IS NULL
|
|
161
|
+
AND e.kind IN ('call', 'import')
|
|
162
|
+
AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
|
|
163
|
+
LIMIT 1`)
|
|
164
|
+
.get(a, b, b, a);
|
|
165
|
+
return Boolean(byName);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Temporal coupling partners for a seed file, with graph contrast facts.
|
|
173
|
+
*/
|
|
174
|
+
export function coupling(projectPath, file, opts = {}) {
|
|
175
|
+
const days = opts.days ?? DEFAULT_WINDOW_DAYS;
|
|
176
|
+
const since = opts.since ?? sinceDaysAgo(days);
|
|
177
|
+
const minShared = opts.minShared ?? DEFAULT_MIN_SHARED;
|
|
178
|
+
const maxFilesPerCommit = opts.maxFilesPerCommit ?? DEFAULT_MAX_FILES_PER_COMMIT;
|
|
179
|
+
const limit = opts.limit ?? 25;
|
|
180
|
+
const warnings = [];
|
|
181
|
+
const rel = file.replace(/^\.\//, "");
|
|
182
|
+
const co = cachedCoChanges(projectPath, {
|
|
183
|
+
since,
|
|
184
|
+
minSupport: minShared,
|
|
185
|
+
maxFilesPerCommit,
|
|
186
|
+
});
|
|
187
|
+
if (co.shallow) {
|
|
188
|
+
warnings.push("Repository is a shallow clone; history may be truncated.");
|
|
189
|
+
}
|
|
190
|
+
const activity = cachedFileActivity(projectPath, { since });
|
|
191
|
+
const commitsSelf = activity.byPath.get(rel)?.commits ?? 0;
|
|
192
|
+
const partnersRaw = [];
|
|
193
|
+
for (const p of co.pairs) {
|
|
194
|
+
if (p.a === rel)
|
|
195
|
+
partnersRaw.push({ other: p.b, both: p.count });
|
|
196
|
+
else if (p.b === rel)
|
|
197
|
+
partnersRaw.push({ other: p.a, both: p.count });
|
|
198
|
+
}
|
|
199
|
+
const paths = [rel, ...partnersRaw.map((p) => p.other)];
|
|
200
|
+
const meta = fileMeta(projectPath, paths);
|
|
201
|
+
const selfTest = meta.get(rel)?.isTest ?? false;
|
|
202
|
+
const partners = partnersRaw
|
|
203
|
+
.map(({ other, both }) => {
|
|
204
|
+
const commitsOther = activity.byPath.get(other)?.commits ?? 0;
|
|
205
|
+
const otherTest = meta.get(other)?.isTest ?? false;
|
|
206
|
+
return {
|
|
207
|
+
file: other,
|
|
208
|
+
both,
|
|
209
|
+
commitsSelf,
|
|
210
|
+
commitsOther,
|
|
211
|
+
strength: jaccardStrength(both, commitsSelf, commitsOther),
|
|
212
|
+
inGraph: pairInGraph(projectPath, rel, other),
|
|
213
|
+
isTestPair: selfTest !== otherTest && (selfTest || otherTest),
|
|
214
|
+
};
|
|
215
|
+
})
|
|
216
|
+
.sort((a, b) => b.strength - a.strength || b.both - a.both || a.file.localeCompare(b.file))
|
|
217
|
+
.slice(0, limit);
|
|
218
|
+
return {
|
|
219
|
+
file: rel,
|
|
220
|
+
window: { days, since, label: `last ${days} days (since ${since})` },
|
|
221
|
+
partners,
|
|
222
|
+
diagnostics: {
|
|
223
|
+
commitsScanned: co.commitsScanned ?? 0,
|
|
224
|
+
skippedTooLarge: co.skippedTooLarge ?? 0,
|
|
225
|
+
maxFilesPerCommit,
|
|
226
|
+
minShared,
|
|
227
|
+
},
|
|
228
|
+
warnings,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -214,6 +214,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
214
214
|
const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
|
|
215
215
|
const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash)
|
|
216
216
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
217
|
+
const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
|
|
217
218
|
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
218
219
|
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
219
220
|
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
@@ -265,6 +266,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
265
266
|
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
266
267
|
const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash).lastInsertRowid);
|
|
267
268
|
nodeIds.push(id);
|
|
269
|
+
insMetrics.run(id, s.loc, s.maxNesting, s.branches);
|
|
268
270
|
// embed the node from its name + signature (cheap, meaningful text)
|
|
269
271
|
const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
|
|
270
272
|
insEmbed.run(id, embedder.dim, embedder.id, toBlob(vec));
|
|
@@ -17,6 +17,19 @@ export const LANGUAGES = [
|
|
|
17
17
|
callNode: "call",
|
|
18
18
|
callField: "function",
|
|
19
19
|
importNodes: ["import_statement", "import_from_statement"],
|
|
20
|
+
nestingNodes: ["block", "suite"],
|
|
21
|
+
branchNodes: [
|
|
22
|
+
"if_statement",
|
|
23
|
+
"elif_clause",
|
|
24
|
+
"for_statement",
|
|
25
|
+
"while_statement",
|
|
26
|
+
"match_statement",
|
|
27
|
+
"case_clause",
|
|
28
|
+
"conditional_expression",
|
|
29
|
+
"except_clause",
|
|
30
|
+
"with_statement",
|
|
31
|
+
"boolean_operator",
|
|
32
|
+
],
|
|
20
33
|
},
|
|
21
34
|
{
|
|
22
35
|
id: "javascript",
|
|
@@ -30,6 +43,19 @@ export const LANGUAGES = [
|
|
|
30
43
|
callNode: "call_expression",
|
|
31
44
|
callField: "function",
|
|
32
45
|
importNodes: ["import_statement"],
|
|
46
|
+
nestingNodes: ["statement_block", "class_body"],
|
|
47
|
+
branchNodes: [
|
|
48
|
+
"if_statement",
|
|
49
|
+
"else_clause",
|
|
50
|
+
"for_statement",
|
|
51
|
+
"for_in_statement",
|
|
52
|
+
"while_statement",
|
|
53
|
+
"do_statement",
|
|
54
|
+
"switch_case",
|
|
55
|
+
"switch_default",
|
|
56
|
+
"catch_clause",
|
|
57
|
+
"ternary_expression",
|
|
58
|
+
],
|
|
33
59
|
},
|
|
34
60
|
{
|
|
35
61
|
id: "typescript",
|
|
@@ -46,6 +72,19 @@ export const LANGUAGES = [
|
|
|
46
72
|
callNode: "call_expression",
|
|
47
73
|
callField: "function",
|
|
48
74
|
importNodes: ["import_statement"],
|
|
75
|
+
nestingNodes: ["statement_block", "class_body"],
|
|
76
|
+
branchNodes: [
|
|
77
|
+
"if_statement",
|
|
78
|
+
"else_clause",
|
|
79
|
+
"for_statement",
|
|
80
|
+
"for_in_statement",
|
|
81
|
+
"while_statement",
|
|
82
|
+
"do_statement",
|
|
83
|
+
"switch_case",
|
|
84
|
+
"switch_default",
|
|
85
|
+
"catch_clause",
|
|
86
|
+
"ternary_expression",
|
|
87
|
+
],
|
|
49
88
|
},
|
|
50
89
|
];
|
|
51
90
|
const BY_EXT = new Map();
|
|
@@ -4,6 +4,7 @@ import { shouldExpose } from "../../shared/exposure.js";
|
|
|
4
4
|
import { buildIndex } from "./indexer.js";
|
|
5
5
|
import { explore, search, recall, impact, trace } from "./query.js";
|
|
6
6
|
import { affectedTests } from "./affected.js";
|
|
7
|
+
import { hotspots, coupling } from "./hotspots.js";
|
|
7
8
|
import { startWatch, stopWatch, watchStatus } from "./watcher.js";
|
|
8
9
|
import { visualize } from "./visualize.js";
|
|
9
10
|
// ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
|
|
@@ -55,6 +56,22 @@ export function registerCompass(server, opts = {}) {
|
|
|
55
56
|
fromDiff: z.string().optional(),
|
|
56
57
|
maxDepth: z.number().int().min(1).max(12).optional(),
|
|
57
58
|
}, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
|
|
59
|
+
add("compass_hotspots", "Rank files by recent churn and AST complexity; two axes, no magic score.", {
|
|
60
|
+
projectPath: z.string(),
|
|
61
|
+
days: z.number().int().min(1).max(3650).optional(),
|
|
62
|
+
since: z.string().optional(),
|
|
63
|
+
sortBy: z.enum(["churn", "complexity", "combined"]).optional(),
|
|
64
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
65
|
+
}, async ({ projectPath, days, since, sortBy, limit }) => text(hotspots(projectPath, { days, since, sortBy, limit })));
|
|
66
|
+
add("compass_coupling", "Files that co-change with a target; strength, graph edge, and test-pair facts.", {
|
|
67
|
+
projectPath: z.string(),
|
|
68
|
+
file: z.string(),
|
|
69
|
+
days: z.number().int().min(1).max(3650).optional(),
|
|
70
|
+
since: z.string().optional(),
|
|
71
|
+
minShared: z.number().int().min(1).optional(),
|
|
72
|
+
maxFilesPerCommit: z.number().int().min(2).optional(),
|
|
73
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
74
|
+
}, async ({ projectPath, file, days, since, minShared, maxFilesPerCommit, limit }) => text(coupling(projectPath, file, { days, since, minShared, maxFilesPerCommit, limit })));
|
|
58
75
|
add("compass_trace", "Find a call path between two symbols within a depth limit.", {
|
|
59
76
|
projectPath: z.string(),
|
|
60
77
|
from: z.string(),
|
package/dist/shared/exposure.js
CHANGED
|
@@ -144,34 +144,114 @@ export function churn(projectPath, opts = {}) {
|
|
|
144
144
|
}
|
|
145
145
|
return { shallow, byPath };
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Richer per-file activity (commits, lines added/deleted, distinct authors).
|
|
149
|
+
*
|
|
150
|
+
* Fail-soft and shallow-aware like {@link churn}. Does not follow renames.
|
|
151
|
+
* Suitable for hotspot ranking; existing {@link churn} callers stay on commit counts.
|
|
152
|
+
*
|
|
153
|
+
* @param projectPath - Project root to query.
|
|
154
|
+
* @param opts - Optional `since` window and `pathspec` filter.
|
|
155
|
+
*/
|
|
156
|
+
export function fileActivity(projectPath, opts = {}) {
|
|
157
|
+
const shallow = isShallowRepo(projectPath);
|
|
158
|
+
// Per commit: \0<author>\0 then numstat lines until the next leading NUL.
|
|
159
|
+
const args = ["log", "--numstat", "--format=%x00%an%x00"];
|
|
160
|
+
if (opts.since)
|
|
161
|
+
args.push(`--since=${opts.since}`);
|
|
162
|
+
if (opts.pathspec && opts.pathspec.length > 0)
|
|
163
|
+
args.push("--", ...opts.pathspec);
|
|
164
|
+
const out = git(projectPath, args);
|
|
165
|
+
const byPath = new Map();
|
|
166
|
+
const authorsByPath = new Map();
|
|
167
|
+
if (out === null)
|
|
168
|
+
return { shallow, byPath };
|
|
169
|
+
const records = out.split(NUL);
|
|
170
|
+
for (let i = 1; i + 1 < records.length; i += 2) {
|
|
171
|
+
const author = (records[i] ?? "").trim();
|
|
172
|
+
const tail = records[i + 1] ?? "";
|
|
173
|
+
if (!author && !tail.trim())
|
|
174
|
+
continue;
|
|
175
|
+
for (const line of tail.split("\n")) {
|
|
176
|
+
const cols = line.split("\t");
|
|
177
|
+
if (cols.length < 3)
|
|
178
|
+
continue;
|
|
179
|
+
const path = cols[2].replace(/^\0+/, "").trim();
|
|
180
|
+
if (!path)
|
|
181
|
+
continue;
|
|
182
|
+
const added = numstat(cols[0]);
|
|
183
|
+
const deleted = numstat(cols[1]);
|
|
184
|
+
const cur = byPath.get(path) ?? { commits: 0, linesAdded: 0, linesDeleted: 0, authors: 0 };
|
|
185
|
+
cur.commits += 1;
|
|
186
|
+
cur.linesAdded += added;
|
|
187
|
+
cur.linesDeleted += deleted;
|
|
188
|
+
byPath.set(path, cur);
|
|
189
|
+
if (author) {
|
|
190
|
+
let set = authorsByPath.get(path);
|
|
191
|
+
if (!set) {
|
|
192
|
+
set = new Set();
|
|
193
|
+
authorsByPath.set(path, set);
|
|
194
|
+
}
|
|
195
|
+
set.add(author);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const [path, act] of byPath) {
|
|
200
|
+
act.authors = authorsByPath.get(path)?.size ?? 0;
|
|
201
|
+
}
|
|
202
|
+
return { shallow, byPath };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Jaccard-style coupling strength: `both / (commitsA + commitsB - both)`.
|
|
206
|
+
* Returns `0` when the denominator is zero.
|
|
207
|
+
*/
|
|
208
|
+
export function jaccardStrength(both, commitsA, commitsB) {
|
|
209
|
+
const denom = commitsA + commitsB - both;
|
|
210
|
+
if (denom <= 0)
|
|
211
|
+
return 0;
|
|
212
|
+
return both / denom;
|
|
213
|
+
}
|
|
147
214
|
/**
|
|
148
215
|
* For every pair of files that changed together, how many commits touched both.
|
|
149
216
|
*
|
|
150
217
|
* Groups each commit's changed files and emits a count per unordered pair. Pairs
|
|
151
|
-
* with fewer than `minSupport` shared commits are omitted.
|
|
152
|
-
*
|
|
218
|
+
* with fewer than `minSupport` shared commits are omitted. Commits that touch
|
|
219
|
+
* more than `maxFilesPerCommit` files (when set) are skipped and counted in
|
|
220
|
+
* `skippedTooLarge`. Fail-soft (empty on git failure) and does not follow
|
|
221
|
+
* renames. Carries the shallow marker.
|
|
153
222
|
*
|
|
154
223
|
* @param projectPath - Project root to query.
|
|
155
|
-
* @param opts - Optional `since` window
|
|
224
|
+
* @param opts - Optional `since` window, `minSupport` (default `1`), and
|
|
225
|
+
* `maxFilesPerCommit` (omit to keep all commits).
|
|
156
226
|
* @returns The qualifying co-change pairs and the shallow marker.
|
|
157
227
|
*/
|
|
158
228
|
export function coChanges(projectPath, opts = {}) {
|
|
159
229
|
const shallow = isShallowRepo(projectPath);
|
|
160
230
|
const minSupport = opts.minSupport ?? 1;
|
|
231
|
+
const maxFiles = opts.maxFilesPerCommit;
|
|
161
232
|
const args = ["log", "--name-only", "--format=%x00"];
|
|
162
233
|
if (opts.since)
|
|
163
234
|
args.push(`--since=${opts.since}`);
|
|
164
235
|
const out = git(projectPath, args);
|
|
165
236
|
if (out === null)
|
|
166
|
-
return { shallow, pairs: [] };
|
|
237
|
+
return { shallow, pairs: [], skippedTooLarge: 0, commitsScanned: 0 };
|
|
167
238
|
const counts = new Map();
|
|
239
|
+
let skippedTooLarge = 0;
|
|
240
|
+
let commitsScanned = 0;
|
|
168
241
|
// Each commit's file list is the run of lines between two %x00 markers.
|
|
169
242
|
for (const commitBlock of out.split(NUL)) {
|
|
170
243
|
const files = commitBlock
|
|
171
244
|
.split("\n")
|
|
172
245
|
.map((l) => l.trim())
|
|
173
246
|
.filter((l) => l.length > 0);
|
|
247
|
+
if (files.length === 0)
|
|
248
|
+
continue;
|
|
174
249
|
const unique = [...new Set(files)].sort();
|
|
250
|
+
if (maxFiles !== undefined && unique.length > maxFiles) {
|
|
251
|
+
skippedTooLarge++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
commitsScanned++;
|
|
175
255
|
for (let i = 0; i < unique.length; i++) {
|
|
176
256
|
for (let j = i + 1; j < unique.length; j++) {
|
|
177
257
|
const key = `${unique[i]}\t${unique[j]}`;
|
|
@@ -187,7 +267,7 @@ export function coChanges(projectPath, opts = {}) {
|
|
|
187
267
|
pairs.push({ a: a, b: b, count });
|
|
188
268
|
}
|
|
189
269
|
pairs.sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
|
|
190
|
-
return { shallow, pairs };
|
|
270
|
+
return { shallow, pairs, skippedTooLarge, commitsScanned };
|
|
191
271
|
}
|
|
192
272
|
/**
|
|
193
273
|
* The SHA of the most recent commit that touched `relPath`, or `null` when the
|