@monoes/monograph 1.3.0 → 1.4.0

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 (90) hide show
  1. package/.monomind/registry.json +6 -0
  2. package/__tests__/staleness/git-staleness.test.ts +3 -3
  3. package/dist/src/analysis/cluster-quality.d.ts +4 -4
  4. package/dist/src/analysis/cluster-quality.d.ts.map +1 -1
  5. package/dist/src/analysis/cluster-quality.js +24 -27
  6. package/dist/src/analysis/cluster-quality.js.map +1 -1
  7. package/dist/src/analysis/combined.d.ts.map +1 -1
  8. package/dist/src/analysis/combined.js +2 -0
  9. package/dist/src/analysis/combined.js.map +1 -1
  10. package/dist/src/analysis/targets.d.ts.map +1 -1
  11. package/dist/src/analysis/targets.js +4 -5
  12. package/dist/src/analysis/targets.js.map +1 -1
  13. package/dist/src/export/analysis-json.js +1 -1
  14. package/dist/src/export/analysis-json.js.map +1 -1
  15. package/dist/src/export/sarif.d.ts.map +1 -1
  16. package/dist/src/export/sarif.js +13 -2
  17. package/dist/src/export/sarif.js.map +1 -1
  18. package/dist/src/graph/baseline.d.ts.map +1 -1
  19. package/dist/src/graph/baseline.js +29 -20
  20. package/dist/src/graph/baseline.js.map +1 -1
  21. package/dist/src/graph/health-score.d.ts.map +1 -1
  22. package/dist/src/graph/health-score.js +11 -14
  23. package/dist/src/graph/health-score.js.map +1 -1
  24. package/dist/src/graph/hotspots.d.ts.map +1 -1
  25. package/dist/src/graph/hotspots.js +8 -4
  26. package/dist/src/graph/hotspots.js.map +1 -1
  27. package/dist/src/graph/statistics.d.ts +3 -2
  28. package/dist/src/graph/statistics.d.ts.map +1 -1
  29. package/dist/src/graph/statistics.js +18 -7
  30. package/dist/src/graph/statistics.js.map +1 -1
  31. package/dist/src/graph/stats.d.ts.map +1 -1
  32. package/dist/src/graph/stats.js +2 -1
  33. package/dist/src/graph/stats.js.map +1 -1
  34. package/dist/src/index.d.ts +3 -0
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +3 -0
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
  39. package/dist/src/pipeline/orchestrator.js +37 -0
  40. package/dist/src/pipeline/orchestrator.js.map +1 -1
  41. package/dist/src/reporting/graph-report.d.ts.map +1 -1
  42. package/dist/src/reporting/graph-report.js +8 -9
  43. package/dist/src/reporting/graph-report.js.map +1 -1
  44. package/dist/src/staleness/git-staleness.d.ts.map +1 -1
  45. package/dist/src/staleness/git-staleness.js +5 -2
  46. package/dist/src/staleness/git-staleness.js.map +1 -1
  47. package/dist/src/storage/db.d.ts.map +1 -1
  48. package/dist/src/storage/db.js +6 -1
  49. package/dist/src/storage/db.js.map +1 -1
  50. package/dist/src/storage/schema.d.ts +5 -0
  51. package/dist/src/storage/schema.d.ts.map +1 -1
  52. package/dist/src/storage/schema.js +25 -0
  53. package/dist/src/storage/schema.js.map +1 -1
  54. package/dist/src/web/analyze-api.d.ts.map +1 -1
  55. package/dist/src/web/analyze-api.js +3 -1
  56. package/dist/src/web/analyze-api.js.map +1 -1
  57. package/dist/src/web/api.d.ts +3 -0
  58. package/dist/src/web/api.d.ts.map +1 -1
  59. package/dist/src/web/api.js +59 -20
  60. package/dist/src/web/api.js.map +1 -1
  61. package/dist/src/web/async-jobs.d.ts.map +1 -1
  62. package/dist/src/web/async-jobs.js +15 -0
  63. package/dist/src/web/async-jobs.js.map +1 -1
  64. package/dist/src/web/react-ui.js +2 -2
  65. package/dist/src/wiki/wiki-generator.d.ts.map +1 -1
  66. package/dist/src/wiki/wiki-generator.js +9 -6
  67. package/dist/src/wiki/wiki-generator.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +1 -1
  70. package/src/analysis/cluster-quality.ts +25 -26
  71. package/src/analysis/combined.ts +2 -0
  72. package/src/analysis/targets.ts +4 -5
  73. package/src/export/analysis-json.ts +1 -1
  74. package/src/export/sarif.ts +11 -2
  75. package/src/graph/baseline.ts +29 -20
  76. package/src/graph/health-score.ts +13 -16
  77. package/src/graph/hotspots.ts +8 -4
  78. package/src/graph/statistics.ts +19 -7
  79. package/src/graph/stats.ts +2 -1
  80. package/src/index.ts +3 -0
  81. package/src/pipeline/orchestrator.ts +37 -0
  82. package/src/reporting/graph-report.ts +8 -9
  83. package/src/staleness/git-staleness.ts +5 -2
  84. package/src/storage/db.ts +8 -0
  85. package/src/storage/schema.ts +30 -0
  86. package/src/web/analyze-api.ts +3 -1
  87. package/src/web/api.ts +62 -22
  88. package/src/web/async-jobs.ts +15 -0
  89. package/src/web/react-ui.ts +2 -2
  90. package/src/wiki/wiki-generator.ts +9 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monograph",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Native TypeScript code intelligence engine for monomind",
6
6
  "main": "dist/src/index.js",
@@ -98,46 +98,45 @@ export function silhouetteScore(
98
98
  }
99
99
 
100
100
  /**
101
- * Compute Newman–Girvan modularity Q.
101
+ * Compute Newman–Girvan modularity Q using the community-level formula:
102
+ * Q = Σ_c [e_c / m - (a_c / 2m)²]
103
+ * where e_c = intra-community edges, a_c = sum of degrees in community c, m = total edges.
102
104
  *
103
- * Q = (1/2m) * Σ_{ij} [A_ij - k_i*k_j/(2m)] * δ(c_i, c_j)
104
- *
105
- * where m = total edge count, k_i = degree of node i, A_ij = adjacency.
105
+ * O(E + N) instead of O().
106
106
  * Returns a value in (-0.5, 1].
107
107
  */
108
108
  export function modularityScore(
109
109
  memberships: Map<string, number>,
110
110
  edges: Edge[],
111
111
  ): number {
112
+ if (edges.length === 0 || memberships.size === 0) return 0;
113
+
112
114
  const adj = buildAdjacency(edges);
115
+ const m = edges.length; // directed edges count (each undirected pair counted once in input)
113
116
 
114
- // Degree map (undirected) use adj to count undirected edges correctly
115
- const degree = new Map<string, number>();
116
- for (const [nodeId, neighbors] of adj) {
117
- degree.set(nodeId, neighbors.size);
117
+ // Sum of degrees per community and intra-community edge count
118
+ const communityDegreeSum = new Map<number, number>();
119
+ const intraCommunityEdges = new Map<number, number>();
120
+
121
+ for (const [nodeId, cid] of memberships) {
122
+ const deg = adj.get(nodeId)?.size ?? 0;
123
+ communityDegreeSum.set(cid, (communityDegreeSum.get(cid) ?? 0) + deg);
118
124
  }
119
125
 
120
- // m_undirected: each undirected edge is counted once in adj (both directions added)
121
- // so sum of degrees = 2 * m_undirected
122
- const totalDegree = [...degree.values()].reduce((s, d) => s + d, 0);
123
- const m = totalDegree / 2;
124
- if (m === 0) return 0;
126
+ for (const { sourceId, targetId } of edges) {
127
+ const cs = memberships.get(sourceId);
128
+ const ct = memberships.get(targetId);
129
+ if (cs !== undefined && ct !== undefined && cs === ct) {
130
+ intraCommunityEdges.set(cs, (intraCommunityEdges.get(cs) ?? 0) + 1);
131
+ }
132
+ }
125
133
 
126
134
  const twoM = 2 * m;
127
135
  let Q = 0;
128
-
129
- const nodes = [...memberships.keys()];
130
- for (let i = 0; i < nodes.length; i++) {
131
- const u = nodes[i]!;
132
- for (let j = i + 1; j < nodes.length; j++) {
133
- const v = nodes[j]!;
134
- if (memberships.get(u) !== memberships.get(v)) continue;
135
- const aij = adj.get(u)?.has(v) ? 1 : 0;
136
- const ki = degree.get(u) ?? 0;
137
- const kj = degree.get(v) ?? 0;
138
- Q += aij - (ki * kj) / twoM;
139
- }
136
+ for (const [cid, degSum] of communityDegreeSum) {
137
+ const ec = intraCommunityEdges.get(cid) ?? 0;
138
+ Q += ec / m - (degSum / twoM) ** 2;
140
139
  }
141
140
 
142
- return Q / m; // normalise by m (equivalent to 1/(2m) summed both directions)
141
+ return Q;
143
142
  }
@@ -29,6 +29,8 @@ export function resolveAnalyses(opts: CombinedOptions): Set<AnalysisKind> {
29
29
  return result;
30
30
  }
31
31
 
32
+ // monolean: stub — resolves which analyses to run but doesn't execute them.
33
+ // Wire up when dead-code/duplication detectors are called from a unified entry point.
32
34
  export async function runCombined(
33
35
  db: MonographDb,
34
36
  opts: CombinedOptions
@@ -44,13 +44,12 @@ export function computeRefactoringTargets(db: MonographDb): RefactoringTargetsRe
44
44
  n.id,
45
45
  n.file_path,
46
46
  n.properties,
47
- COUNT(DISTINCT e_in.id) as in_degree,
48
- COUNT(DISTINCT e_out.id) as out_degree
47
+ COALESCE(d_in.cnt, 0) as in_degree,
48
+ COALESCE(d_out.cnt, 0) as out_degree
49
49
  FROM nodes n
50
- LEFT JOIN edges e_in ON e_in.target_id = n.id
51
- LEFT JOIN edges e_out ON e_out.source_id = n.id
50
+ LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges GROUP BY target_id) d_in ON d_in.target_id = n.id
51
+ LEFT JOIN (SELECT source_id, COUNT(*) AS cnt FROM edges GROUP BY source_id) d_out ON d_out.source_id = n.id
52
52
  WHERE n.label = 'File' AND n.file_path IS NOT NULL
53
- GROUP BY n.id
54
53
  `).all() as FileNodeRow[];
55
54
 
56
55
  if (rows.length === 0) {
@@ -82,6 +82,6 @@ export function buildDuplicationResultsEnvelope(
82
82
 
83
83
  export function stripRootPrefix(obj: unknown, rootPrefix: string): unknown {
84
84
  const json = JSON.stringify(obj);
85
- const escaped = rootPrefix.replace(/[/\\]/g, s => `\\${s}`);
85
+ const escaped = rootPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
86
  return JSON.parse(json.replace(new RegExp(escaped, 'g'), ''));
87
87
  }
@@ -1,6 +1,15 @@
1
1
  import type Database from 'better-sqlite3';
2
2
  import { createHash } from 'crypto';
3
- import { relative } from 'path';
3
+ import { readFileSync } from 'fs';
4
+ import { relative, dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const PKG_VERSION = (() => {
9
+ try {
10
+ return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')).version ?? '0.0.0';
11
+ } catch { return '0.0.0'; }
12
+ })();
4
13
 
5
14
  export interface SarifRule {
6
15
  id: string;
@@ -231,6 +240,6 @@ export function exportHealthSarif(findings: SarifHealthFinding[], root?: string)
231
240
  return {
232
241
  $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
233
242
  version: '2.1.0',
234
- runs: [{ tool: { driver: { name: 'monograph-health', version: '1.0.0', rules } }, results }],
243
+ runs: [{ tool: { driver: { name: 'monograph-health', version: PKG_VERSION, rules } }, results }],
235
244
  };
236
245
  }
@@ -126,15 +126,19 @@ export function extractFindingsFromDb(
126
126
 
127
127
  // God nodes (degree > 50)
128
128
  const gods = db.prepare(`
129
- SELECT n.id, n.name, n.file_path, n.start_line,
130
- COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as degree
129
+ SELECT n.id, n.name, n.file_path, n.start_line, d.deg as degree
131
130
  FROM nodes n
132
- LEFT JOIN edges e1 ON e1.source_id = n.id
133
- LEFT JOIN edges e2 ON e2.target_id = n.id
134
- GROUP BY n.id
135
- HAVING degree > 50
136
- ORDER BY degree DESC
137
- LIMIT 100
131
+ JOIN (
132
+ SELECT node_id, SUM(cnt) AS deg FROM (
133
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
134
+ UNION ALL
135
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
136
+ ) GROUP BY node_id
137
+ HAVING deg > 50
138
+ ORDER BY deg DESC
139
+ LIMIT 100
140
+ ) d ON d.node_id = n.id
141
+ ORDER BY d.deg DESC
138
142
  `).all() as { id: string; name: string; file_path: string | null; start_line: number | null; degree: number }[];
139
143
 
140
144
  for (const n of gods) {
@@ -173,20 +177,25 @@ export function extractFindingsFromDb(
173
177
  });
174
178
  }
175
179
 
176
- // Bridge nodes: nodes whose edges span more than one community_id
177
- // Use JOIN+GROUP BY instead of correlated subqueries to avoid N*2 subquery executions
180
+ // Bridge nodes: nodes whose edges span 2+ distinct foreign communities.
181
+ // Pre-aggregate cross-community counts per direction to avoid 4-way cross product.
178
182
  const bridges = db.prepare(`
179
- SELECT n.id, n.name, n.file_path, n.start_line,
180
- COUNT(DISTINCT CASE WHEN e_out.source_id = n.id AND n_tgt.community_id != n.community_id THEN n_tgt.community_id END) +
181
- COUNT(DISTINCT CASE WHEN e_in.target_id = n.id AND n_src.community_id != n.community_id THEN n_src.community_id END) AS cross_communities
183
+ SELECT n.id, n.name, n.file_path, n.start_line
182
184
  FROM nodes n
183
- LEFT JOIN edges e_out ON e_out.source_id = n.id
184
- LEFT JOIN nodes n_tgt ON n_tgt.id = e_out.target_id AND n_tgt.community_id IS NOT NULL
185
- LEFT JOIN edges e_in ON e_in.target_id = n.id
186
- LEFT JOIN nodes n_src ON n_src.id = e_in.source_id AND n_src.community_id IS NOT NULL
187
- WHERE n.community_id IS NOT NULL
188
- GROUP BY n.id
189
- HAVING cross_communities >= 2
185
+ JOIN (
186
+ SELECT node_id, COUNT(DISTINCT foreign_community) AS cross_cnt FROM (
187
+ SELECT e.source_id AS node_id, n2.community_id AS foreign_community
188
+ FROM edges e
189
+ JOIN nodes n1 ON n1.id = e.source_id AND n1.community_id IS NOT NULL
190
+ JOIN nodes n2 ON n2.id = e.target_id AND n2.community_id IS NOT NULL AND n2.community_id != n1.community_id
191
+ UNION ALL
192
+ SELECT e.target_id AS node_id, n1.community_id AS foreign_community
193
+ FROM edges e
194
+ JOIN nodes n1 ON n1.id = e.source_id AND n1.community_id IS NOT NULL
195
+ JOIN nodes n2 ON n2.id = e.target_id AND n2.community_id IS NOT NULL AND n1.community_id != n2.community_id
196
+ ) GROUP BY node_id
197
+ HAVING cross_cnt >= 2
198
+ ) b ON b.node_id = n.id
190
199
  LIMIT 100
191
200
  `).all() as { id: string; name: string; file_path: string | null; start_line: number | null }[];
192
201
 
@@ -33,7 +33,7 @@ export function computeHealthScore(db: Database.Database): HealthScoreResult {
33
33
 
34
34
  // ── Unreachable file nodes ────────────────────────────────────────────────
35
35
  const unreachableRow = db.prepare(
36
- "SELECT COUNT(*) as c FROM nodes WHERE label = 'File' AND properties LIKE '%\"unreachable\"%'"
36
+ "SELECT COUNT(*) as c FROM nodes WHERE label = 'File' AND json_extract(properties, '$.unreachable') = 1"
37
37
  ).get() as { c: number };
38
38
  const unreachableFiles = unreachableRow.c;
39
39
 
@@ -42,23 +42,20 @@ export function computeHealthScore(db: Database.Database): HealthScoreResult {
42
42
  const totalNodes = totalNodesRow.c;
43
43
 
44
44
  // ── p95 degree (god nodes) ────────────────────────────────────────────────
45
- const degreesRows = db.prepare(`
46
- SELECT source_id as node_id, COUNT(*) as deg FROM edges GROUP BY source_id
47
- UNION ALL
48
- SELECT target_id as node_id, COUNT(*) as deg FROM edges GROUP BY target_id
49
- `).all() as { node_id: string; deg: number }[];
50
-
51
- // Sum degrees per node
52
- const degMap = new Map<string, number>();
53
- for (const row of degreesRows) {
54
- degMap.set(row.node_id, (degMap.get(row.node_id) ?? 0) + row.deg);
55
- }
56
- const degrees = [...degMap.values()].sort((a, b) => a - b);
57
- const p95Index = Math.floor(degrees.length * 0.95);
58
- const p95Degree = degrees[p95Index] ?? 0;
45
+ // Compute per-node total degree (in + out) entirely in SQL
46
+ const degreeRows = db.prepare(`
47
+ SELECT node_id, SUM(cnt) AS degree FROM (
48
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
49
+ UNION ALL
50
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
51
+ ) GROUP BY node_id ORDER BY degree ASC
52
+ `).all() as { node_id: string; degree: number }[];
53
+
54
+ const p95Index = Math.floor(degreeRows.length * 0.95);
55
+ const p95Degree = degreeRows[p95Index]?.degree ?? 0;
59
56
 
60
57
  const godNodeCount = p95Degree > 0
61
- ? [...degMap.values()].filter(d => d > p95Degree).length
58
+ ? degreeRows.filter(r => r.degree > p95Degree).length
62
59
  : 0;
63
60
 
64
61
  // ── Circular edges (bidirectional import pairs) ───────────────────────────
@@ -123,12 +123,16 @@ export function computeHotspots(
123
123
  // ── Step 3: Get file nodes + centrality from DB ───────────────────────────
124
124
  const fileNodes = db.prepare(`
125
125
  SELECT n.id, n.name, n.file_path, n.label, n.community_id,
126
- COUNT(DISTINCT e1.id) + COUNT(DISTINCT e2.id) as degree
126
+ COALESCE(d.deg, 0) as degree
127
127
  FROM nodes n
128
- LEFT JOIN edges e1 ON e1.source_id = n.id
129
- LEFT JOIN edges e2 ON e2.target_id = n.id
128
+ LEFT JOIN (
129
+ SELECT node_id, SUM(cnt) AS deg FROM (
130
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
131
+ UNION ALL
132
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
133
+ ) GROUP BY node_id
134
+ ) d ON d.node_id = n.id
130
135
  WHERE n.label = 'File' AND n.file_path IS NOT NULL
131
- GROUP BY n.id
132
136
  `).all() as { id: string; name: string; file_path: string; label: string; community_id: number | null; degree: number }[];
133
137
 
134
138
  // ── Step 4: Join churn + centrality ──────────────────────────────────────
@@ -77,21 +77,24 @@ export function clusteringCoefficient(db: MonographDb): number {
77
77
  let totalCoeff = 0;
78
78
  let countWithNeighbors = 0;
79
79
 
80
+ // Cap per-node neighbor scan to avoid O(k²) explosion on hub nodes
81
+ const MAX_K = 200;
80
82
  for (const [node, nbrs] of neighbors) {
81
83
  const k = nbrs.size;
82
84
  if (k < 2) continue;
83
85
 
84
86
  let triangles = 0;
85
- const nbList = [...nbrs];
86
- for (let i = 0; i < nbList.length; i++) {
87
- for (let j = i + 1; j < nbList.length; j++) {
87
+ const nbList = k <= MAX_K ? [...nbrs] : [...nbrs].slice(0, MAX_K);
88
+ const effectiveK = nbList.length;
89
+ for (let i = 0; i < effectiveK; i++) {
90
+ for (let j = i + 1; j < effectiveK; j++) {
88
91
  if (neighbors.get(nbList[i])?.has(nbList[j])) {
89
92
  triangles++;
90
93
  }
91
94
  }
92
95
  }
93
96
 
94
- const possible = (k * (k - 1)) / 2;
97
+ const possible = (effectiveK * (effectiveK - 1)) / 2;
95
98
  totalCoeff += triangles / possible;
96
99
  countWithNeighbors++;
97
100
  }
@@ -112,19 +115,28 @@ export interface PathStats {
112
115
  }
113
116
 
114
117
  /**
115
- * Compute both average path length and graph diameter in a single BFS pass.
116
- * Unreachable pairs are excluded from averagePathLength.
118
+ * Compute both average path length and graph diameter via BFS.
119
+ * For graphs > 500 nodes, uses deterministic sampling (every Nth node)
120
+ * to keep runtime practical — O(sample * E) instead of O(N * E).
117
121
  */
118
122
  export function pathStats(db: MonographDb): PathStats {
119
123
  const graph = getGraph(db);
120
124
  const nodes = graph.nodes();
121
125
  if (nodes.length < 2) return { averagePathLength: 0, diameter: 0 };
122
126
 
127
+ // Sample sources for large graphs — stride ensures even coverage
128
+ const MAX_SOURCES = 500;
129
+ const stride = nodes.length <= MAX_SOURCES ? 1 : Math.ceil(nodes.length / MAX_SOURCES);
130
+ const sources: string[] = [];
131
+ for (let i = 0; i < nodes.length && sources.length < MAX_SOURCES; i += stride) {
132
+ sources.push(nodes[i]);
133
+ }
134
+
123
135
  let totalLength = 0;
124
136
  let reachablePairs = 0;
125
137
  let maxDist = 0;
126
138
 
127
- for (const source of nodes) {
139
+ for (const source of sources) {
128
140
  bfsFromNode(graph, source, (node, _attr, depth) => {
129
141
  if (node !== source && depth > 0) {
130
142
  totalLength += depth;
@@ -143,10 +143,11 @@ export function computeGraphStats(db: Database.Database): GraphStatsSummary {
143
143
  /** Format a GraphStatsSummary as structured text for LLM consumption. */
144
144
  export function formatGraphStats(s: GraphStatsSummary): string {
145
145
  const cp = s.couplingProfile;
146
+ const totalEntities = cp.fanInProfile.low + cp.fanInProfile.medium + cp.fanInProfile.high + cp.fanInProfile.critical;
146
147
  const lines: string[] = [
147
148
  `Graph Stats`,
148
149
  ` Nodes: ${s.nodeCount} Edges: ${s.edgeCount} Communities: ${s.communityCount} Files: ${s.fileCount}`,
149
- `Coupling Profile (${cp.totalFiles} files)`,
150
+ `Coupling Profile (${totalEntities} entities, ${cp.totalFiles} files)`,
150
151
  ` Fan-in p50/p75/p90/p95: ${cp.p50FanIn}/${cp.p75FanIn}/${cp.p90FanIn}/${cp.p95FanIn}`,
151
152
  ` High-coupling (>p95): ${cp.couplingHighPct}%`,
152
153
  `Fan-in Risk: low=${cp.fanInProfile.low}(${cp.fanInProfile.lowPct}%) med=${cp.fanInProfile.medium}(${cp.fanInProfile.mediumPct}%) high=${cp.fanInProfile.high}(${cp.fanInProfile.highPct}%) crit=${cp.fanInProfile.critical}(${cp.fanInProfile.criticalPct}%)`,
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export * from './export/html.js';
16
16
  export * from './export/cypher.js';
17
17
  export * from './export/graphml.js';
18
18
  export * from './export/svg.js';
19
+ export * from './export/obsidian.js';
20
+ export * from './export/canvas.js';
21
+ export * from './export/mermaid.js';
19
22
  export * from './mcp-tools/index.js';
20
23
  export * from './staleness/git-staleness.js';
21
24
  export * from './search/rrf.js';
@@ -26,6 +26,7 @@ import type { PipelineOptions, PipelineContext } from './types.js';
26
26
  import { DEFAULT_OPTIONS } from './types.js';
27
27
  import type { PipelineProgress, SuggestedQuestion } from '../types.js';
28
28
  import { generateGraphReport } from '../reporting/graph-report.js';
29
+ import { analyzeChurn } from '../analysis/churn.js';
29
30
 
30
31
  function getCurrentCommitHash(repoPath: string): string | null {
31
32
  try {
@@ -164,10 +165,46 @@ async function buildAsyncLocked(
164
165
  await generateGraphReport(resolve(repoPath), undefined, dbPath, questions);
165
166
  }
166
167
 
168
+ // Populate churnScore on File nodes from git history (6-month window)
169
+ try {
170
+ const churnResult = await analyzeChurn(ctx.repoPath, '6m');
171
+ if (churnResult.files.length > 0) {
172
+ // Only consider files that exist as graph nodes for normalization —
173
+ // build artifacts and config files inflate the denominator otherwise
174
+ const graphFiles = new Set(
175
+ (db.prepare("SELECT file_path FROM nodes WHERE label = 'File' AND file_path IS NOT NULL").all() as { file_path: string }[])
176
+ .map(r => r.file_path),
177
+ );
178
+ const graphChurn = churnResult.files.filter(f => graphFiles.has(f.path));
179
+ const maxWeighted = graphChurn.reduce((m, f) => f.weightedCommits > m ? f.weightedCommits : m, 0);
180
+ if (maxWeighted > 0) {
181
+ const updateProps = db.prepare(`
182
+ UPDATE nodes SET properties = json_set(COALESCE(properties, '{}'), '$.churnScore', ?)
183
+ WHERE file_path = ? AND label = 'File'
184
+ `);
185
+ db.transaction(() => {
186
+ for (const f of graphChurn) {
187
+ updateProps.run(f.weightedCommits / maxWeighted, f.path);
188
+ }
189
+ })();
190
+ }
191
+ }
192
+ } catch {
193
+ // churn analysis is non-fatal (e.g. no git history)
194
+ }
195
+
167
196
  const hash = getCurrentCommitHash(resolve(repoPath));
168
197
  if (hash) {
169
198
  db.prepare("INSERT OR REPLACE INTO index_meta VALUES ('last_commit_hash', ?)").run(hash);
199
+ } else {
200
+ const msg = 'Could not determine git HEAD — staleness tracking will be unavailable';
201
+ if (options.onProgress) {
202
+ options.onProgress({ phase: 'warning', message: msg } as PipelineProgress);
203
+ } else {
204
+ process.stderr.write(`[monograph] ${msg}\n`);
205
+ }
170
206
  }
207
+ db.prepare("INSERT OR REPLACE INTO index_meta VALUES ('indexed_at', ?)").run(new Date().toISOString());
171
208
  } finally {
172
209
  closeDb(db);
173
210
  }
@@ -54,16 +54,15 @@ function queryEdgesByRelation(db: MonographDb): EdgeRelationStat[] {
54
54
 
55
55
  function queryTopNodesByDegree(db: MonographDb, limit = 10): TopDegreeNode[] {
56
56
  const rows = db.prepare(`
57
- SELECT
58
- n.id,
59
- n.name,
60
- n.label,
61
- (
62
- SELECT COUNT(*) FROM edges WHERE source_id = n.id
63
- ) + (
64
- SELECT COUNT(*) FROM edges WHERE target_id = n.id
65
- ) AS degree
57
+ SELECT n.id, n.name, n.label, COALESCE(d.degree, 0) AS degree
66
58
  FROM nodes n
59
+ LEFT JOIN (
60
+ SELECT node_id, SUM(cnt) AS degree FROM (
61
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
62
+ UNION ALL
63
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
64
+ ) GROUP BY node_id
65
+ ) d ON d.node_id = n.id
67
66
  ORDER BY degree DESC
68
67
  LIMIT ?
69
68
  `).all(limit) as TopDegreeNode[];
@@ -28,10 +28,13 @@ export function checkStaleness(db: MonographDb, repoPath: string): StalenessRepo
28
28
  const indexedCommitShort = indexedCommitFull ? indexedCommitFull.slice(0, 7) : null;
29
29
  const currentCommitShort = currentCommit ? currentCommit.slice(0, 7) : null;
30
30
 
31
- // 3. If no stored commit or short SHAs match, not stale
32
- if (!indexedCommitFull || indexedCommitShort === currentCommitShort) {
31
+ // 3. If SHAs match → fresh. If no stored commit staleness unknown (not "fresh").
32
+ if (indexedCommitShort === currentCommitShort) {
33
33
  return { isStale: false, indexedAt: null, indexedCommit: indexedCommitShort, currentCommit: currentCommitShort, changedSince: [], staleSince: null };
34
34
  }
35
+ if (!indexedCommitFull) {
36
+ return { isStale: true, indexedAt: null, indexedCommit: null, currentCommit: currentCommitShort, changedSince: [], staleSince: null };
37
+ }
35
38
 
36
39
  // Guard: indexedCommitFull is read from SQLite — validate before shell interpolation
37
40
  if (!/^[0-9a-f]{7,40}$/i.test(indexedCommitFull)) {
package/src/storage/db.ts CHANGED
@@ -5,6 +5,9 @@ import {
5
5
  CREATE_NODES, CREATE_EDGES, CREATE_COMMUNITIES,
6
6
  CREATE_INDEX_META, CREATE_NODES_FTS, CREATE_INDEXES, FTS_SYNC_TRIGGERS,
7
7
  CREATE_EMBEDDINGS, CREATE_WIKI_PAGES,
8
+ CREATE_AGENT_INTERACTIONS, CREATE_AGENT_INTERACTIONS_IDX,
9
+ CREATE_AGENT_INTERACTIONS_ORG_IDX, CREATE_AGENT_INTERACTIONS_TYPE_IDX,
10
+ CREATE_AGENT_INTERACTIONS_TS_IDX,
8
11
  } from './schema.js';
9
12
  import { MonographError } from '../types.js';
10
13
 
@@ -45,6 +48,11 @@ function applyMigrations(db: MonographDb): void {
45
48
  db.exec(FTS_SYNC_TRIGGERS);
46
49
  db.exec(CREATE_EMBEDDINGS);
47
50
  db.exec(CREATE_WIKI_PAGES);
51
+ db.exec(CREATE_AGENT_INTERACTIONS);
52
+ db.exec(CREATE_AGENT_INTERACTIONS_IDX);
53
+ db.exec(CREATE_AGENT_INTERACTIONS_ORG_IDX);
54
+ db.exec(CREATE_AGENT_INTERACTIONS_TYPE_IDX);
55
+ db.exec(CREATE_AGENT_INTERACTIONS_TS_IDX);
48
56
 
49
57
  // Schema version table — tracks incremental column additions.
50
58
  db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`);
@@ -124,6 +124,36 @@ export const CREATE_FILE_CACHE = `
124
124
  )
125
125
  `;
126
126
 
127
+ export const CREATE_AGENT_INTERACTIONS = `
128
+ CREATE TABLE IF NOT EXISTS agent_interactions (
129
+ id TEXT PRIMARY KEY,
130
+ session_id TEXT NOT NULL,
131
+ org_name TEXT,
132
+ agent_type TEXT NOT NULL,
133
+ parent_agent TEXT,
134
+ prompt_summary TEXT,
135
+ result_summary TEXT,
136
+ tokens_in INTEGER NOT NULL DEFAULT 0,
137
+ tokens_out INTEGER NOT NULL DEFAULT 0,
138
+ cost_usd REAL NOT NULL DEFAULT 0,
139
+ success INTEGER NOT NULL DEFAULT 1,
140
+ duration_ms INTEGER NOT NULL DEFAULT 0,
141
+ timestamp INTEGER NOT NULL,
142
+ metadata TEXT
143
+ )`;
144
+
145
+ export const CREATE_AGENT_INTERACTIONS_IDX = `
146
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_session ON agent_interactions(session_id)`;
147
+
148
+ export const CREATE_AGENT_INTERACTIONS_ORG_IDX = `
149
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_org ON agent_interactions(org_name)`;
150
+
151
+ export const CREATE_AGENT_INTERACTIONS_TYPE_IDX = `
152
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_type ON agent_interactions(agent_type)`;
153
+
154
+ export const CREATE_AGENT_INTERACTIONS_TS_IDX = `
155
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_ts ON agent_interactions(timestamp)`;
156
+
127
157
  export const FTS_SYNC_TRIGGERS = `
128
158
  CREATE TRIGGER IF NOT EXISTS nodes_fts_insert AFTER INSERT ON nodes BEGIN
129
159
  INSERT INTO nodes_fts(rowid, id, name, norm_label, file_path, label)
@@ -52,11 +52,13 @@ async function handleAnalyze(req: IncomingMessage, res: ServerResponse): Promise
52
52
  }
53
53
 
54
54
  // Set SSE headers
55
+ const origin = req.headers['origin'] ?? '';
56
+ const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) ? origin : '';
55
57
  res.writeHead(200, {
56
58
  'Content-Type': 'text/event-stream',
57
59
  'Cache-Control': 'no-cache',
58
60
  'Connection': 'keep-alive',
59
- 'Access-Control-Allow-Origin': 'http://localhost',
61
+ ...(allowedOrigin && { 'Access-Control-Allow-Origin': allowedOrigin }),
60
62
  });
61
63
 
62
64
  const dbPath = resolve(join(repoPath, '.monomind', 'monograph.db'));