@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.
Files changed (30) hide show
  1. package/dist/cli/commands/lawbook.js +43 -6
  2. package/dist/cli/commands/query.js +49 -2
  3. package/dist/cli/commands/quick.js +35 -0
  4. package/dist/cli/commands/update.js +18 -0
  5. package/dist/cli/index.js +17 -3
  6. package/dist/modules/compass/db.js +15 -2
  7. package/dist/modules/compass/extract.js +44 -0
  8. package/dist/modules/compass/git-history-cache.js +19 -2
  9. package/dist/modules/compass/hotspots.js +230 -0
  10. package/dist/modules/compass/indexer.js +2 -0
  11. package/dist/modules/compass/languages.js +39 -0
  12. package/dist/modules/compass/register.js +17 -0
  13. package/dist/modules/foundation/doctor.js +63 -0
  14. package/dist/modules/lawbook/assets/commands/archive.md +5 -6
  15. package/dist/modules/lawbook/assets/commands/draft.md +6 -7
  16. package/dist/modules/lawbook/assets/commands/quick.md +14 -0
  17. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
  18. package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
  19. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
  20. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +28 -25
  21. package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
  22. package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
  23. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
  24. package/dist/modules/lawbook/engine.js +115 -55
  25. package/dist/modules/lawbook/levels.js +421 -0
  26. package/dist/modules/lawbook/quick.js +86 -0
  27. package/dist/modules/lawbook/register.js +10 -0
  28. package/dist/shared/exposure.js +3 -0
  29. package/dist/shared/git-history.js +85 -5
  30. 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 archive.
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
- ui.info(`active changes: ${r.activeChanges.join(", ") || "none"}`);
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, "spec validate <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, "spec sync <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, "spec archive <change>"), today());
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`, or
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), `mcp` (a long-running stdio server), and `init`
56
- // (already opens with the fuller `banner()`).
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 = "7";
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
- return !fileCols.includes("is_test") || !fileCols.includes("module");
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({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
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
+ }