@esneiderbravo/speclaw 0.3.10 → 0.3.12
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/lawbook.js +43 -6
- package/dist/cli/commands/query.js +49 -2
- package/dist/cli/commands/quick.js +35 -0
- package/dist/cli/commands/update.js +18 -0
- package/dist/cli/index.js +17 -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/modules/foundation/doctor.js +63 -0
- package/dist/modules/lawbook/assets/commands/archive.md +5 -6
- package/dist/modules/lawbook/assets/commands/draft.md +6 -7
- package/dist/modules/lawbook/assets/commands/quick.md +14 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +28 -25
- package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
- package/dist/modules/lawbook/engine.js +115 -55
- package/dist/modules/lawbook/levels.js +421 -0
- package/dist/modules/lawbook/quick.js +86 -0
- package/dist/modules/lawbook/register.js +10 -0
- package/dist/shared/exposure.js +3 -0
- package/dist/shared/git-history.js +85 -5
- package/package.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
|
|
2
|
+
import { handleLevel } from "../../modules/lawbook/quick.js";
|
|
3
|
+
import { list } from "../lib/args.js";
|
|
2
4
|
import { ui } from "../lib/ui.js";
|
|
3
5
|
function today() {
|
|
4
6
|
// The MCP path passes the date in; the CLL runs on a real machine, so read it here.
|
|
5
7
|
return new Date().toISOString().slice(0, 10);
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
|
-
* Run a spec-workflow subcommand: init, list, validate, sync, or
|
|
10
|
+
* Run a spec-workflow subcommand: init, list, validate, sync, archive, or level.
|
|
9
11
|
*
|
|
10
12
|
* @param flags - Parsed flags; `_[0]` is the subcommand and `_[1]` the change name where required.
|
|
11
13
|
* @throws Exits the process with code 1 on unknown subcommands, missing arguments, or engine errors.
|
|
@@ -27,13 +29,48 @@ export async function runSpec(flags) {
|
|
|
27
29
|
if (!r.initialized)
|
|
28
30
|
return ui.warn("No lawbook/ — run `speclaw lawbook init`.");
|
|
29
31
|
ui.heading("Lawbook workspace");
|
|
30
|
-
|
|
32
|
+
if (r.activeChanges.length === 0)
|
|
33
|
+
ui.info("active changes: none");
|
|
34
|
+
else {
|
|
35
|
+
ui.info("active changes:");
|
|
36
|
+
for (const name of r.activeChanges) {
|
|
37
|
+
const lvl = r.activeLevels[name] ?? 3;
|
|
38
|
+
ui.info(` ${name} (level ${lvl})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
ui.info(`archived: ${r.archivedChanges.join(", ") || "none"}`);
|
|
32
42
|
ui.info(`capabilities: ${r.capabilities.join(", ") || "none"}`);
|
|
33
43
|
return;
|
|
34
44
|
}
|
|
45
|
+
case "level": {
|
|
46
|
+
const modeRaw = change ?? "propose";
|
|
47
|
+
const mode = modeRaw;
|
|
48
|
+
if (!["propose", "set", "promote", "explain"].includes(mode)) {
|
|
49
|
+
ui.err("Usage: speclaw lawbook level <propose|set|promote|explain> [--change <c>] [--path …] [--level N] [--reason …] [--json]");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const levelFlag = flags.level;
|
|
53
|
+
const level = levelFlag === undefined || levelFlag === true
|
|
54
|
+
? undefined
|
|
55
|
+
: Number(levelFlag);
|
|
56
|
+
const result = handleLevel({
|
|
57
|
+
projectPath: cwd,
|
|
58
|
+
mode,
|
|
59
|
+
change: typeof flags.change === "string" ? flags.change : flags._[2],
|
|
60
|
+
paths: list(flags.path),
|
|
61
|
+
symbols: list(flags.symbol),
|
|
62
|
+
level,
|
|
63
|
+
reason: typeof flags.reason === "string" ? flags.reason : undefined,
|
|
64
|
+
});
|
|
65
|
+
if (flags.json) {
|
|
66
|
+
console.log(JSON.stringify(result, null, 2));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
35
72
|
case "validate": {
|
|
36
|
-
const r = specValidate(cwd, req(change, "
|
|
73
|
+
const r = specValidate(cwd, req(change, "lawbook validate <change>"));
|
|
37
74
|
if (r.valid)
|
|
38
75
|
ui.ok(`${r.change} is valid (${r.deltaSpecs.length} delta spec(s))`);
|
|
39
76
|
else {
|
|
@@ -47,13 +84,13 @@ export async function runSpec(flags) {
|
|
|
47
84
|
return;
|
|
48
85
|
}
|
|
49
86
|
case "sync": {
|
|
50
|
-
const r = specSync(cwd, req(change, "
|
|
87
|
+
const r = specSync(cwd, req(change, "lawbook sync <change>"));
|
|
51
88
|
ui.ok(`promoted ${r.promoted.length} spec(s)`);
|
|
52
89
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
53
90
|
return;
|
|
54
91
|
}
|
|
55
92
|
case "archive": {
|
|
56
|
-
const r = specArchive(cwd, req(change, "
|
|
93
|
+
const r = specArchive(cwd, req(change, "lawbook archive <change>"), today());
|
|
57
94
|
ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
|
|
58
95
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
59
96
|
for (const s of r.seals) {
|
|
@@ -66,7 +103,7 @@ export async function runSpec(flags) {
|
|
|
66
103
|
return;
|
|
67
104
|
}
|
|
68
105
|
default:
|
|
69
|
-
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive> [change]");
|
|
106
|
+
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level> [change]");
|
|
70
107
|
process.exit(1);
|
|
71
108
|
}
|
|
72
109
|
}
|
|
@@ -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}`);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { list } from "../lib/args.js";
|
|
2
|
+
import { ui } from "../lib/ui.js";
|
|
3
|
+
import { scaffoldQuick } from "../../modules/lawbook/quick.js";
|
|
4
|
+
/**
|
|
5
|
+
* Scaffold a level-0 change (`speclaw quick <name>`).
|
|
6
|
+
*
|
|
7
|
+
* @param flags - `_[0]` is the change name; optional `--path` / `--symbol` / `--json`.
|
|
8
|
+
*/
|
|
9
|
+
export async function runQuick(flags) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const name = flags._[0];
|
|
12
|
+
if (!name || typeof name !== "string") {
|
|
13
|
+
ui.err("Usage: speclaw quick <name> [--path <file>] [--symbol <sym>] [--json]");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const result = scaffoldQuick(cwd, name, {
|
|
18
|
+
paths: list(flags.path),
|
|
19
|
+
symbols: list(flags.symbol),
|
|
20
|
+
});
|
|
21
|
+
if (flags.json) {
|
|
22
|
+
console.log(JSON.stringify(result, null, 2));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ui.ok(`level-0 change ${ui.code(result.change)} at ${result.dir}`);
|
|
26
|
+
ui.info(result.proposal.rationale);
|
|
27
|
+
if (result.proposal.level !== null && result.proposal.level > 0) {
|
|
28
|
+
ui.warn(`measured proposal was level ${result.proposal.level} — promote if the fix grows`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
ui.err(err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -117,6 +117,24 @@ 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
|
+
},
|
|
128
|
+
{
|
|
129
|
+
version: "0.3.12",
|
|
130
|
+
describe: "Adaptive ceremony levels 0–3, speclaw quick, lawbook_level",
|
|
131
|
+
agentPrompt: "- Mention ceremony levels 0–3 (`change.json`), `speclaw quick` for level-0 scaffolds, and " +
|
|
132
|
+
"`lawbook_level` / `speclaw lawbook level` for propose/set/promote. Artifact volume follows " +
|
|
133
|
+
"the confirmed level; missing `change.json` still means full ceremony (level 3). Optional " +
|
|
134
|
+
"`ceremony:` block in `lawbook/config.yaml` (cuts default [3, 8, 15]). Update LAWS / " +
|
|
135
|
+
"docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
|
|
136
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
137
|
+
},
|
|
120
138
|
];
|
|
121
139
|
/**
|
|
122
140
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -25,12 +25,16 @@ 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
|
|
|
31
33
|
Lawbook (spec-driven workflow)
|
|
34
|
+
quick <name> Scaffold a level-0 change (record.md + reports)
|
|
32
35
|
lawbook init Create the lawbook/ workspace
|
|
33
36
|
lawbook list Active/archived changes and capabilities
|
|
37
|
+
lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
|
|
34
38
|
lawbook validate <c> Validate a change's artifacts
|
|
35
39
|
lawbook sync <c> Promote delta specs to canonical
|
|
36
40
|
lawbook archive <c> Finalize and archive a change
|
|
@@ -51,9 +55,10 @@ Other
|
|
|
51
55
|
// Commands that open with the one-line branded header. These are the
|
|
52
56
|
// interactive, human-facing commands whose stdout is prose. Deliberately
|
|
53
57
|
// excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
|
|
54
|
-
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests
|
|
55
|
-
// machine-consumed output), `
|
|
56
|
-
// (
|
|
58
|
+
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`/
|
|
59
|
+
// `hotspots`/`coupling`, machine-consumed output), `quick` (often --json),
|
|
60
|
+
// `mcp` (a long-running stdio
|
|
61
|
+
// server), and `init` (already opens with the fuller `banner()`).
|
|
57
62
|
const HEADER_COMMANDS = new Set([
|
|
58
63
|
undefined,
|
|
59
64
|
"help",
|
|
@@ -69,6 +74,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
69
74
|
"index",
|
|
70
75
|
"watch",
|
|
71
76
|
"lawbook",
|
|
77
|
+
"quick",
|
|
72
78
|
]);
|
|
73
79
|
/**
|
|
74
80
|
* Print the branded header once, ahead of a command's output, when it is a
|
|
@@ -92,6 +98,10 @@ function maybeHeader(cmd, flags) {
|
|
|
92
98
|
return;
|
|
93
99
|
if (cmd === "drift" && flags.json)
|
|
94
100
|
return;
|
|
101
|
+
if (cmd === "quick" && flags.json)
|
|
102
|
+
return;
|
|
103
|
+
if (cmd === "lawbook" && flags.json && flags._[0] === "level")
|
|
104
|
+
return;
|
|
95
105
|
header();
|
|
96
106
|
}
|
|
97
107
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -128,9 +138,13 @@ async function dispatch(cmd, flags) {
|
|
|
128
138
|
case "impact":
|
|
129
139
|
case "trace":
|
|
130
140
|
case "affected-tests":
|
|
141
|
+
case "hotspots":
|
|
142
|
+
case "coupling":
|
|
131
143
|
return (await import("./commands/query.js")).runQuery(cmd, flags);
|
|
132
144
|
case "visualize":
|
|
133
145
|
return (await import("./commands/visualize.js")).runVisualize(flags);
|
|
146
|
+
case "quick":
|
|
147
|
+
return (await import("./commands/quick.js")).runQuick(flags);
|
|
134
148
|
case "lawbook":
|
|
135
149
|
return (await import("./commands/lawbook.js")).runSpec(flags);
|
|
136
150
|
case "doctor":
|
|
@@ -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
|
+
}
|