@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.
- package/.monomind/registry.json +6 -0
- package/__tests__/staleness/git-staleness.test.ts +3 -3
- package/dist/src/analysis/cluster-quality.d.ts +4 -4
- package/dist/src/analysis/cluster-quality.d.ts.map +1 -1
- package/dist/src/analysis/cluster-quality.js +24 -27
- package/dist/src/analysis/cluster-quality.js.map +1 -1
- package/dist/src/analysis/combined.d.ts.map +1 -1
- package/dist/src/analysis/combined.js +2 -0
- package/dist/src/analysis/combined.js.map +1 -1
- package/dist/src/analysis/targets.d.ts.map +1 -1
- package/dist/src/analysis/targets.js +4 -5
- package/dist/src/analysis/targets.js.map +1 -1
- package/dist/src/export/analysis-json.js +1 -1
- package/dist/src/export/analysis-json.js.map +1 -1
- package/dist/src/export/sarif.d.ts.map +1 -1
- package/dist/src/export/sarif.js +13 -2
- package/dist/src/export/sarif.js.map +1 -1
- package/dist/src/graph/baseline.d.ts.map +1 -1
- package/dist/src/graph/baseline.js +29 -20
- package/dist/src/graph/baseline.js.map +1 -1
- package/dist/src/graph/health-score.d.ts.map +1 -1
- package/dist/src/graph/health-score.js +11 -14
- package/dist/src/graph/health-score.js.map +1 -1
- package/dist/src/graph/hotspots.d.ts.map +1 -1
- package/dist/src/graph/hotspots.js +8 -4
- package/dist/src/graph/hotspots.js.map +1 -1
- package/dist/src/graph/statistics.d.ts +3 -2
- package/dist/src/graph/statistics.d.ts.map +1 -1
- package/dist/src/graph/statistics.js +18 -7
- package/dist/src/graph/statistics.js.map +1 -1
- package/dist/src/graph/stats.d.ts.map +1 -1
- package/dist/src/graph/stats.js +2 -1
- package/dist/src/graph/stats.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/src/pipeline/orchestrator.js +37 -0
- package/dist/src/pipeline/orchestrator.js.map +1 -1
- package/dist/src/reporting/graph-report.d.ts.map +1 -1
- package/dist/src/reporting/graph-report.js +8 -9
- package/dist/src/reporting/graph-report.js.map +1 -1
- package/dist/src/staleness/git-staleness.d.ts.map +1 -1
- package/dist/src/staleness/git-staleness.js +5 -2
- package/dist/src/staleness/git-staleness.js.map +1 -1
- package/dist/src/storage/db.d.ts.map +1 -1
- package/dist/src/storage/db.js +6 -1
- package/dist/src/storage/db.js.map +1 -1
- package/dist/src/storage/schema.d.ts +5 -0
- package/dist/src/storage/schema.d.ts.map +1 -1
- package/dist/src/storage/schema.js +25 -0
- package/dist/src/storage/schema.js.map +1 -1
- package/dist/src/web/analyze-api.d.ts.map +1 -1
- package/dist/src/web/analyze-api.js +3 -1
- package/dist/src/web/analyze-api.js.map +1 -1
- package/dist/src/web/api.d.ts +3 -0
- package/dist/src/web/api.d.ts.map +1 -1
- package/dist/src/web/api.js +59 -20
- package/dist/src/web/api.js.map +1 -1
- package/dist/src/web/async-jobs.d.ts.map +1 -1
- package/dist/src/web/async-jobs.js +15 -0
- package/dist/src/web/async-jobs.js.map +1 -1
- package/dist/src/web/react-ui.js +2 -2
- package/dist/src/wiki/wiki-generator.d.ts.map +1 -1
- package/dist/src/wiki/wiki-generator.js +9 -6
- package/dist/src/wiki/wiki-generator.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/analysis/cluster-quality.ts +25 -26
- package/src/analysis/combined.ts +2 -0
- package/src/analysis/targets.ts +4 -5
- package/src/export/analysis-json.ts +1 -1
- package/src/export/sarif.ts +11 -2
- package/src/graph/baseline.ts +29 -20
- package/src/graph/health-score.ts +13 -16
- package/src/graph/hotspots.ts +8 -4
- package/src/graph/statistics.ts +19 -7
- package/src/graph/stats.ts +2 -1
- package/src/index.ts +3 -0
- package/src/pipeline/orchestrator.ts +37 -0
- package/src/reporting/graph-report.ts +8 -9
- package/src/staleness/git-staleness.ts +5 -2
- package/src/storage/db.ts +8 -0
- package/src/storage/schema.ts +30 -0
- package/src/web/analyze-api.ts +3 -1
- package/src/web/api.ts +62 -22
- package/src/web/async-jobs.ts +15 -0
- package/src/web/react-ui.ts +2 -2
- package/src/wiki/wiki-generator.ts +9 -6
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* where m = total edge count, k_i = degree of node i, A_ij = adjacency.
|
|
105
|
+
* O(E + N) instead of O(N²).
|
|
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
|
-
//
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
141
|
+
return Q;
|
|
143
142
|
}
|
package/src/analysis/combined.ts
CHANGED
|
@@ -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
|
package/src/analysis/targets.ts
CHANGED
|
@@ -44,13 +44,12 @@ export function computeRefactoringTargets(db: MonographDb): RefactoringTargetsRe
|
|
|
44
44
|
n.id,
|
|
45
45
|
n.file_path,
|
|
46
46
|
n.properties,
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
51
|
-
LEFT JOIN edges
|
|
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(/[
|
|
85
|
+
const escaped = rootPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
86
86
|
return JSON.parse(json.replace(new RegExp(escaped, 'g'), ''));
|
|
87
87
|
}
|
package/src/export/sarif.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type Database from 'better-sqlite3';
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
|
-
import {
|
|
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:
|
|
243
|
+
runs: [{ tool: { driver: { name: 'monograph-health', version: PKG_VERSION, rules } }, results }],
|
|
235
244
|
};
|
|
236
245
|
}
|
package/src/graph/baseline.ts
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
177
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
?
|
|
58
|
+
? degreeRows.filter(r => r.degree > p95Degree).length
|
|
62
59
|
: 0;
|
|
63
60
|
|
|
64
61
|
// ── Circular edges (bidirectional import pairs) ───────────────────────────
|
package/src/graph/hotspots.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
+
COALESCE(d.deg, 0) as degree
|
|
127
127
|
FROM nodes n
|
|
128
|
-
LEFT JOIN
|
|
129
|
-
|
|
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 ──────────────────────────────────────
|
package/src/graph/statistics.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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 = (
|
|
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
|
|
116
|
-
*
|
|
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
|
|
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;
|
package/src/graph/stats.ts
CHANGED
|
@@ -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
|
|
32
|
-
if (
|
|
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)`);
|
package/src/storage/schema.ts
CHANGED
|
@@ -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)
|
package/src/web/analyze-api.ts
CHANGED
|
@@ -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':
|
|
61
|
+
...(allowedOrigin && { 'Access-Control-Allow-Origin': allowedOrigin }),
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
const dbPath = resolve(join(repoPath, '.monomind', 'monograph.db'));
|