@10kdevs/matha 0.1.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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/analysis/contract-matcher.js +223 -0
- package/dist/analysis/git-analyser.js +261 -0
- package/dist/analysis/stability-classifier.js +122 -0
- package/dist/brain/cortex.js +258 -0
- package/dist/brain/dopamine.js +184 -0
- package/dist/brain/frontal-lobe.js +219 -0
- package/dist/brain/hippocampus.js +134 -0
- package/dist/commands/after.js +334 -0
- package/dist/commands/before.js +328 -0
- package/dist/commands/init.js +266 -0
- package/dist/commands/migrate.js +16 -0
- package/dist/index.js +114 -0
- package/dist/mcp/server.js +305 -0
- package/dist/mcp/tools.js +379 -0
- package/dist/storage/reader.js +29 -0
- package/dist/storage/writer.js +111 -0
- package/dist/utils/markdown-parser.js +173 -0
- package/dist/utils/schema-version.js +91 -0
- package/package.json +62 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
// ──────────────────────────────────────────────────────────────
|
|
5
|
+
// CONSTANTS
|
|
6
|
+
// ──────────────────────────────────────────────────────────────
|
|
7
|
+
const DEFAULT_MAX_COMMITS = 500;
|
|
8
|
+
const DEFAULT_MAX_CO_CHANGE_PAIRS = 50;
|
|
9
|
+
const DEFAULT_EXCLUDE_PATHS = ['node_modules', '.git', 'dist', '.matha', 'coverage'];
|
|
10
|
+
const CO_CHANGE_FILES_PER_COMMIT_CAP = 20;
|
|
11
|
+
const BINARY_EXTENSIONS = new Set([
|
|
12
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg',
|
|
13
|
+
'.woff', '.woff2', '.ttf', '.eot',
|
|
14
|
+
'.pdf', '.zip', '.tar', '.gz',
|
|
15
|
+
]);
|
|
16
|
+
// ──────────────────────────────────────────────────────────────
|
|
17
|
+
// HELPERS
|
|
18
|
+
// ──────────────────────────────────────────────────────────────
|
|
19
|
+
function normalisePath(filepath) {
|
|
20
|
+
return filepath.replace(/\\/g, '/');
|
|
21
|
+
}
|
|
22
|
+
function isBinaryFile(filepath) {
|
|
23
|
+
const ext = path.extname(filepath).toLowerCase();
|
|
24
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
25
|
+
}
|
|
26
|
+
function isExcluded(filepath, excludePaths) {
|
|
27
|
+
const normalised = normalisePath(filepath);
|
|
28
|
+
for (const exclude of excludePaths) {
|
|
29
|
+
if (normalised.startsWith(exclude + '/') || normalised === exclude) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
// Also check path segments
|
|
33
|
+
const segments = normalised.split('/');
|
|
34
|
+
if (segments.includes(exclude)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function shouldIncludeFile(filepath, excludePaths) {
|
|
41
|
+
if (!filepath || filepath.trim() === '')
|
|
42
|
+
return false;
|
|
43
|
+
if (isBinaryFile(filepath))
|
|
44
|
+
return false;
|
|
45
|
+
if (isExcluded(filepath, excludePaths))
|
|
46
|
+
return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function makeCoChangeKey(a, b) {
|
|
50
|
+
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
51
|
+
}
|
|
52
|
+
function toISO(dateStr) {
|
|
53
|
+
try {
|
|
54
|
+
return new Date(dateStr).toISOString();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return new Date().toISOString();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function emptyResult() {
|
|
61
|
+
return {
|
|
62
|
+
analysedAt: new Date().toISOString(),
|
|
63
|
+
commitCount: 0,
|
|
64
|
+
fileCount: 0,
|
|
65
|
+
files: [],
|
|
66
|
+
coChanges: [],
|
|
67
|
+
oldestCommit: '',
|
|
68
|
+
newestCommit: '',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// ──────────────────────────────────────────────────────────────
|
|
72
|
+
// MAIN FUNCTION
|
|
73
|
+
// ──────────────────────────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Analyse a git repository and produce structured change data.
|
|
76
|
+
*
|
|
77
|
+
* **Never throws** — returns an empty result for non-git directories,
|
|
78
|
+
* empty repos, or any other error condition.
|
|
79
|
+
*/
|
|
80
|
+
export async function analyseRepository(repoPath, options) {
|
|
81
|
+
try {
|
|
82
|
+
// Check if .git exists
|
|
83
|
+
try {
|
|
84
|
+
await fs.access(path.join(repoPath, '.git'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return emptyResult();
|
|
88
|
+
}
|
|
89
|
+
const git = simpleGit(repoPath);
|
|
90
|
+
const maxCommits = options?.maxCommits ?? DEFAULT_MAX_COMMITS;
|
|
91
|
+
const excludePaths = options?.excludePaths ?? DEFAULT_EXCLUDE_PATHS;
|
|
92
|
+
const maxCoChangePairs = options?.maxCoChangePairs ?? DEFAULT_MAX_CO_CHANGE_PAIRS;
|
|
93
|
+
// Build log options
|
|
94
|
+
const logOptions = {
|
|
95
|
+
maxCount: maxCommits,
|
|
96
|
+
'--name-only': null,
|
|
97
|
+
};
|
|
98
|
+
if (options?.since) {
|
|
99
|
+
logOptions['--after'] = options.since;
|
|
100
|
+
}
|
|
101
|
+
// Get commit log
|
|
102
|
+
let logResult;
|
|
103
|
+
try {
|
|
104
|
+
logResult = await git.log(logOptions);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Empty repo or other git error
|
|
108
|
+
return emptyResult();
|
|
109
|
+
}
|
|
110
|
+
const commits = logResult.all;
|
|
111
|
+
if (commits.length === 0) {
|
|
112
|
+
return emptyResult();
|
|
113
|
+
}
|
|
114
|
+
// ────────────────────────────────────────────────────────────
|
|
115
|
+
// SCAN COMMITS
|
|
116
|
+
// ────────────────────────────────────────────────────────────
|
|
117
|
+
// Per-file tracking
|
|
118
|
+
const fileData = new Map();
|
|
119
|
+
// Co-change pair tracking
|
|
120
|
+
const coChangeMap = new Map();
|
|
121
|
+
let oldestDate = '';
|
|
122
|
+
let newestDate = '';
|
|
123
|
+
for (const commit of commits) {
|
|
124
|
+
const commitDate = toISO(commit.date);
|
|
125
|
+
const author = commit.author_name || 'unknown';
|
|
126
|
+
// Track oldest/newest
|
|
127
|
+
if (!oldestDate || commitDate < oldestDate)
|
|
128
|
+
oldestDate = commitDate;
|
|
129
|
+
if (!newestDate || commitDate > newestDate)
|
|
130
|
+
newestDate = commitDate;
|
|
131
|
+
// Get files from this commit
|
|
132
|
+
// simple-git log with --name-only puts files in diff.files or body
|
|
133
|
+
const rawFiles = extractFilesFromCommit(commit);
|
|
134
|
+
const filteredFiles = rawFiles
|
|
135
|
+
.map(normalisePath)
|
|
136
|
+
.filter(f => shouldIncludeFile(f, excludePaths));
|
|
137
|
+
// Update per-file data
|
|
138
|
+
for (const filepath of filteredFiles) {
|
|
139
|
+
const existing = fileData.get(filepath);
|
|
140
|
+
if (existing) {
|
|
141
|
+
existing.changeCount++;
|
|
142
|
+
if (commitDate > existing.lastChanged)
|
|
143
|
+
existing.lastChanged = commitDate;
|
|
144
|
+
if (commitDate < existing.firstSeen)
|
|
145
|
+
existing.firstSeen = commitDate;
|
|
146
|
+
existing.authors.add(author);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
fileData.set(filepath, {
|
|
150
|
+
changeCount: 1,
|
|
151
|
+
lastChanged: commitDate,
|
|
152
|
+
firstSeen: commitDate,
|
|
153
|
+
authors: new Set([author]),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Co-change pairs (skip if too many files in this commit)
|
|
158
|
+
if (filteredFiles.length >= 2 && filteredFiles.length <= CO_CHANGE_FILES_PER_COMMIT_CAP) {
|
|
159
|
+
for (let i = 0; i < filteredFiles.length; i++) {
|
|
160
|
+
for (let j = i + 1; j < filteredFiles.length; j++) {
|
|
161
|
+
const key = makeCoChangeKey(filteredFiles[i], filteredFiles[j]);
|
|
162
|
+
coChangeMap.set(key, (coChangeMap.get(key) ?? 0) + 1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ────────────────────────────────────────────────────────────
|
|
168
|
+
// BUILD CO-CHANGE RECORDS
|
|
169
|
+
// ────────────────────────────────────────────────────────────
|
|
170
|
+
// Filter out pairs with count < 2, sort descending, take top N
|
|
171
|
+
const coChangePairs = [];
|
|
172
|
+
for (const [key, count] of coChangeMap) {
|
|
173
|
+
if (count >= 2) {
|
|
174
|
+
const [fileA, fileB] = key.split('|');
|
|
175
|
+
coChangePairs.push({ fileA, fileB, coChangeCount: count });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
coChangePairs.sort((a, b) => b.coChangeCount - a.coChangeCount);
|
|
179
|
+
const topCoChanges = coChangePairs.slice(0, maxCoChangePairs);
|
|
180
|
+
// ────────────────────────────────────────────────────────────
|
|
181
|
+
// BUILD FILE RECORDS with coChangedWith
|
|
182
|
+
// ────────────────────────────────────────────────────────────
|
|
183
|
+
// Build per-file co-change index for top 5
|
|
184
|
+
const perFileCoChange = new Map();
|
|
185
|
+
for (const [key, count] of coChangeMap) {
|
|
186
|
+
if (count < 2)
|
|
187
|
+
continue;
|
|
188
|
+
const [a, b] = key.split('|');
|
|
189
|
+
if (!perFileCoChange.has(a))
|
|
190
|
+
perFileCoChange.set(a, new Map());
|
|
191
|
+
if (!perFileCoChange.has(b))
|
|
192
|
+
perFileCoChange.set(b, new Map());
|
|
193
|
+
perFileCoChange.get(a).set(b, count);
|
|
194
|
+
perFileCoChange.get(b).set(a, count);
|
|
195
|
+
}
|
|
196
|
+
const files = [];
|
|
197
|
+
for (const [filepath, data] of fileData) {
|
|
198
|
+
// Top 5 co-changed files
|
|
199
|
+
const coMap = perFileCoChange.get(filepath);
|
|
200
|
+
let coChangedWith = [];
|
|
201
|
+
if (coMap) {
|
|
202
|
+
coChangedWith = Array.from(coMap.entries())
|
|
203
|
+
.sort((a, b) => b[1] - a[1])
|
|
204
|
+
.slice(0, 5)
|
|
205
|
+
.map(([f]) => f);
|
|
206
|
+
}
|
|
207
|
+
files.push({
|
|
208
|
+
filepath,
|
|
209
|
+
changeCount: data.changeCount,
|
|
210
|
+
lastChanged: data.lastChanged,
|
|
211
|
+
firstSeen: data.firstSeen,
|
|
212
|
+
authors: Array.from(data.authors),
|
|
213
|
+
coChangedWith,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Sort files by changeCount descending
|
|
217
|
+
files.sort((a, b) => b.changeCount - a.changeCount);
|
|
218
|
+
return {
|
|
219
|
+
analysedAt: new Date().toISOString(),
|
|
220
|
+
commitCount: commits.length,
|
|
221
|
+
fileCount: files.length,
|
|
222
|
+
files,
|
|
223
|
+
coChanges: topCoChanges,
|
|
224
|
+
oldestCommit: oldestDate,
|
|
225
|
+
newestCommit: newestDate,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Catch-all: never throw
|
|
230
|
+
return emptyResult();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ──────────────────────────────────────────────────────────────
|
|
234
|
+
// EXTRACT FILES FROM COMMIT
|
|
235
|
+
// ──────────────────────────────────────────────────────────────
|
|
236
|
+
/**
|
|
237
|
+
* Extract file paths from a simple-git log entry.
|
|
238
|
+
* simple-git puts the file list in `diff.files` when --name-only is used,
|
|
239
|
+
* or sometimes in the `body` field as newline-separated paths.
|
|
240
|
+
*/
|
|
241
|
+
function extractFilesFromCommit(commit) {
|
|
242
|
+
const files = [];
|
|
243
|
+
// Try diff.files first (simple-git standard format)
|
|
244
|
+
if (commit.diff && commit.diff.files && commit.diff.files.length > 0) {
|
|
245
|
+
for (const f of commit.diff.files) {
|
|
246
|
+
if (f.file)
|
|
247
|
+
files.push(f.file);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Fallback: parse body for file paths (--name-only output)
|
|
251
|
+
if (files.length === 0 && commit.body) {
|
|
252
|
+
const bodyLines = commit.body.split('\n');
|
|
253
|
+
for (const line of bodyLines) {
|
|
254
|
+
const trimmed = line.trim();
|
|
255
|
+
if (trimmed && !trimmed.startsWith('commit ') && !trimmed.startsWith('Author:') && !trimmed.startsWith('Date:')) {
|
|
256
|
+
files.push(trimmed);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return files.filter(f => f.length > 0);
|
|
261
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ──────────────────────────────────────────────────────────────
|
|
2
|
+
// CONSTANTS
|
|
3
|
+
// ──────────────────────────────────────────────────────────────
|
|
4
|
+
const DEFAULT_FROZEN_THRESHOLD = 2; // max changes/month
|
|
5
|
+
const DEFAULT_VOLATILE_THRESHOLD = 8; // min changes/month
|
|
6
|
+
const DEFAULT_MIN_AGE_FOR_FROZEN = 30; // days
|
|
7
|
+
// ──────────────────────────────────────────────────────────────
|
|
8
|
+
// MAIN FUNCTION
|
|
9
|
+
// ──────────────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Classify stability for every file in a GitAnalysisResult.
|
|
12
|
+
*
|
|
13
|
+
* **Never throws** — returns an empty ClassificationResult on any error.
|
|
14
|
+
*/
|
|
15
|
+
export function classifyStability(analysis, options) {
|
|
16
|
+
try {
|
|
17
|
+
const frozenThreshold = options?.frozenThreshold ?? DEFAULT_FROZEN_THRESHOLD;
|
|
18
|
+
const volatileThreshold = options?.volatileThreshold ?? DEFAULT_VOLATILE_THRESHOLD;
|
|
19
|
+
const minAgeForFrozen = options?.minAgeForFrozen ?? DEFAULT_MIN_AGE_FOR_FROZEN;
|
|
20
|
+
const now = new Date(analysis.analysedAt || new Date().toISOString());
|
|
21
|
+
const classifications = [];
|
|
22
|
+
for (const file of analysis.files) {
|
|
23
|
+
const classification = classifyFile(file, now, frozenThreshold, volatileThreshold, minAgeForFrozen);
|
|
24
|
+
classifications.push(classification);
|
|
25
|
+
}
|
|
26
|
+
// Build summary
|
|
27
|
+
const summary = { frozen: 0, stable: 0, volatile: 0, disposable: 0 };
|
|
28
|
+
for (const c of classifications) {
|
|
29
|
+
summary[c.stability]++;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
classifiedAt: new Date().toISOString(),
|
|
33
|
+
fileCount: classifications.length,
|
|
34
|
+
classifications,
|
|
35
|
+
summary,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return {
|
|
40
|
+
classifiedAt: new Date().toISOString(),
|
|
41
|
+
fileCount: 0,
|
|
42
|
+
classifications: [],
|
|
43
|
+
summary: { frozen: 0, stable: 0, volatile: 0, disposable: 0 },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ──────────────────────────────────────────────────────────────
|
|
48
|
+
// PER-FILE CLASSIFICATION
|
|
49
|
+
// ──────────────────────────────────────────────────────────────
|
|
50
|
+
function classifyFile(file, now, frozenThreshold, volatileThreshold, minAgeForFrozen) {
|
|
51
|
+
const { filepath, changeCount, coChangedWith } = file;
|
|
52
|
+
// Calculate age metrics
|
|
53
|
+
const firstSeenDate = new Date(file.firstSeen);
|
|
54
|
+
const lastChangedDate = new Date(file.lastChanged);
|
|
55
|
+
let ageInDays = Math.floor((now.getTime() - firstSeenDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
56
|
+
if (ageInDays < 1)
|
|
57
|
+
ageInDays = 1; // guard against division by zero
|
|
58
|
+
const daysSinceLastChange = Math.floor((now.getTime() - lastChangedDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
59
|
+
// Calculate churn rate
|
|
60
|
+
const changesPerMonth = (changeCount / ageInDays) * 30;
|
|
61
|
+
const changesPerMonthRounded = Math.round(changesPerMonth * 100) / 100;
|
|
62
|
+
// Co-change count
|
|
63
|
+
const coChangeCount = coChangedWith.length;
|
|
64
|
+
// ──────────────────────────────────────────────────────────
|
|
65
|
+
// CLASSIFICATION RULES (first match wins)
|
|
66
|
+
// ──────────────────────────────────────────────────────────
|
|
67
|
+
let stability;
|
|
68
|
+
let reason;
|
|
69
|
+
if (changesPerMonth <= frozenThreshold &&
|
|
70
|
+
ageInDays >= minAgeForFrozen &&
|
|
71
|
+
coChangeCount >= 3) {
|
|
72
|
+
// FROZEN
|
|
73
|
+
stability = 'frozen';
|
|
74
|
+
reason =
|
|
75
|
+
`Low churn (${changesPerMonthRounded} changes/month), ` +
|
|
76
|
+
`high connectivity (${coChangeCount} co-changed files), ` +
|
|
77
|
+
`aged ${ageInDays} days`;
|
|
78
|
+
}
|
|
79
|
+
else if (changesPerMonth >= volatileThreshold) {
|
|
80
|
+
// VOLATILE
|
|
81
|
+
stability = 'volatile';
|
|
82
|
+
reason = `High churn (${changesPerMonthRounded} changes/month)`;
|
|
83
|
+
}
|
|
84
|
+
else if (changesPerMonth <= frozenThreshold &&
|
|
85
|
+
coChangeCount <= 1 &&
|
|
86
|
+
ageInDays >= minAgeForFrozen) {
|
|
87
|
+
// DISPOSABLE
|
|
88
|
+
stability = 'disposable';
|
|
89
|
+
reason =
|
|
90
|
+
`Low churn (${changesPerMonthRounded} changes/month), ` +
|
|
91
|
+
`low connectivity, aged ${ageInDays} days`;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// STABLE (catch-all)
|
|
95
|
+
stability = 'stable';
|
|
96
|
+
reason = `Moderate churn (${changesPerMonthRounded} changes/month)`;
|
|
97
|
+
}
|
|
98
|
+
// ──────────────────────────────────────────────────────────
|
|
99
|
+
// CONFIDENCE
|
|
100
|
+
// ──────────────────────────────────────────────────────────
|
|
101
|
+
let confidence;
|
|
102
|
+
if (ageInDays >= 90 && changeCount >= 5) {
|
|
103
|
+
confidence = 'high';
|
|
104
|
+
}
|
|
105
|
+
else if (ageInDays >= 30 && changeCount >= 2) {
|
|
106
|
+
confidence = 'medium';
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
confidence = 'low';
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
filepath,
|
|
113
|
+
stability,
|
|
114
|
+
confidence,
|
|
115
|
+
reason,
|
|
116
|
+
changeCount,
|
|
117
|
+
coChangeCount,
|
|
118
|
+
ageInDays,
|
|
119
|
+
daysSinceLastChange,
|
|
120
|
+
classificationSource: 'derived',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { readJsonOrNull } from '../storage/reader.js';
|
|
3
|
+
import { writeAtomic } from '../storage/writer.js';
|
|
4
|
+
import { analyseRepository } from '../analysis/git-analyser.js';
|
|
5
|
+
import { classifyStability } from '../analysis/stability-classifier.js';
|
|
6
|
+
// ──────────────────────────────────────────────────────────────
|
|
7
|
+
// PATHS
|
|
8
|
+
// ──────────────────────────────────────────────────────────────
|
|
9
|
+
function stabilityPath(mathaDir) {
|
|
10
|
+
return path.join(mathaDir, 'cortex', 'stability.json');
|
|
11
|
+
}
|
|
12
|
+
function coChangesPath(mathaDir) {
|
|
13
|
+
return path.join(mathaDir, 'cortex', 'co-changes.json');
|
|
14
|
+
}
|
|
15
|
+
// ──────────────────────────────────────────────────────────────
|
|
16
|
+
// HELPERS
|
|
17
|
+
// ──────────────────────────────────────────────────────────────
|
|
18
|
+
function normalisePath(filepath) {
|
|
19
|
+
return filepath.replace(/\\/g, '/');
|
|
20
|
+
}
|
|
21
|
+
function buildSummary(records) {
|
|
22
|
+
const summary = { frozen: 0, stable: 0, volatile: 0, disposable: 0, declared: 0 };
|
|
23
|
+
for (const r of records) {
|
|
24
|
+
if (r.stability === 'frozen')
|
|
25
|
+
summary.frozen++;
|
|
26
|
+
else if (r.stability === 'stable')
|
|
27
|
+
summary.stable++;
|
|
28
|
+
else if (r.stability === 'volatile')
|
|
29
|
+
summary.volatile++;
|
|
30
|
+
else if (r.stability === 'disposable')
|
|
31
|
+
summary.disposable++;
|
|
32
|
+
if (r.classificationSource === 'declared')
|
|
33
|
+
summary.declared++;
|
|
34
|
+
}
|
|
35
|
+
return summary;
|
|
36
|
+
}
|
|
37
|
+
// ──────────────────────────────────────────────────────────────
|
|
38
|
+
// refreshFromGit
|
|
39
|
+
// ──────────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Run git analysis + stability classification and persist results.
|
|
42
|
+
* Preserves any existing `declared` records (declared-wins invariant).
|
|
43
|
+
*
|
|
44
|
+
* **Never throws** — empty repo produces empty cortex.
|
|
45
|
+
*/
|
|
46
|
+
export async function refreshFromGit(repoPath, mathaDir, options) {
|
|
47
|
+
try {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
// Run analysis pipeline
|
|
50
|
+
const analysis = await analyseRepository(repoPath, options);
|
|
51
|
+
const classification = classifyStability(analysis);
|
|
52
|
+
// Load existing stability records (to preserve declared)
|
|
53
|
+
const existing = await readJsonOrNull(stabilityPath(mathaDir));
|
|
54
|
+
const declaredMap = new Map();
|
|
55
|
+
if (existing && Array.isArray(existing)) {
|
|
56
|
+
for (const r of existing) {
|
|
57
|
+
if (r.classificationSource === 'declared') {
|
|
58
|
+
declaredMap.set(normalisePath(r.filepath), r);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Build new stability records
|
|
63
|
+
const records = [];
|
|
64
|
+
for (const c of classification.classifications) {
|
|
65
|
+
const fp = normalisePath(c.filepath);
|
|
66
|
+
const declared = declaredMap.get(fp);
|
|
67
|
+
if (declared) {
|
|
68
|
+
// Preserve declared, only update lastDerivedAt
|
|
69
|
+
records.push({
|
|
70
|
+
...declared,
|
|
71
|
+
filepath: fp,
|
|
72
|
+
lastDerivedAt: now,
|
|
73
|
+
});
|
|
74
|
+
declaredMap.delete(fp); // mark as handled
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
records.push({
|
|
78
|
+
filepath: fp,
|
|
79
|
+
stability: c.stability,
|
|
80
|
+
confidence: c.confidence,
|
|
81
|
+
reason: c.reason,
|
|
82
|
+
classificationSource: 'derived',
|
|
83
|
+
changeCount: c.changeCount,
|
|
84
|
+
coChangeCount: c.coChangeCount,
|
|
85
|
+
ageInDays: c.ageInDays,
|
|
86
|
+
daysSinceLastChange: c.daysSinceLastChange,
|
|
87
|
+
lastDerivedAt: now,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Keep any declared records for files not in current git analysis
|
|
92
|
+
for (const declared of declaredMap.values()) {
|
|
93
|
+
records.push({
|
|
94
|
+
...declared,
|
|
95
|
+
filepath: normalisePath(declared.filepath),
|
|
96
|
+
lastDerivedAt: now,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Write stability.json
|
|
100
|
+
await writeAtomic(stabilityPath(mathaDir), records, { overwrite: true });
|
|
101
|
+
// Write co-changes.json
|
|
102
|
+
await writeAtomic(coChangesPath(mathaDir), analysis.coChanges, { overwrite: true });
|
|
103
|
+
const snapshot = {
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
repoPath,
|
|
106
|
+
commitCount: analysis.commitCount,
|
|
107
|
+
fileCount: records.length,
|
|
108
|
+
stability: records,
|
|
109
|
+
coChanges: analysis.coChanges,
|
|
110
|
+
summary: buildSummary(records),
|
|
111
|
+
};
|
|
112
|
+
return snapshot;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
const empty = {
|
|
116
|
+
updatedAt: new Date().toISOString(),
|
|
117
|
+
repoPath,
|
|
118
|
+
commitCount: 0,
|
|
119
|
+
fileCount: 0,
|
|
120
|
+
stability: [],
|
|
121
|
+
coChanges: [],
|
|
122
|
+
summary: { frozen: 0, stable: 0, volatile: 0, disposable: 0, declared: 0 },
|
|
123
|
+
};
|
|
124
|
+
return empty;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ──────────────────────────────────────────────────────────────
|
|
128
|
+
// getStability
|
|
129
|
+
// ──────────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Look up stability records for specific files.
|
|
132
|
+
* Returns null for any filepath not found in stability.json.
|
|
133
|
+
*
|
|
134
|
+
* **Never throws.**
|
|
135
|
+
*/
|
|
136
|
+
export async function getStability(mathaDir, filepaths) {
|
|
137
|
+
try {
|
|
138
|
+
const records = await readJsonOrNull(stabilityPath(mathaDir));
|
|
139
|
+
const result = {};
|
|
140
|
+
const recordMap = new Map();
|
|
141
|
+
if (records && Array.isArray(records)) {
|
|
142
|
+
for (const r of records) {
|
|
143
|
+
recordMap.set(normalisePath(r.filepath), r);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const fp of filepaths) {
|
|
147
|
+
const normalised = normalisePath(fp);
|
|
148
|
+
result[normalised] = recordMap.get(normalised) ?? null;
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
const result = {};
|
|
154
|
+
for (const fp of filepaths) {
|
|
155
|
+
result[normalisePath(fp)] = null;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ──────────────────────────────────────────────────────────────
|
|
161
|
+
// overrideStability
|
|
162
|
+
// ──────────────────────────────────────────────────────────────
|
|
163
|
+
/**
|
|
164
|
+
* Set a human override for a file's stability classification.
|
|
165
|
+
* Creates the record if the file is not already in stability.json.
|
|
166
|
+
*
|
|
167
|
+
* **Never throws.**
|
|
168
|
+
*/
|
|
169
|
+
export async function overrideStability(mathaDir, filepath, stability, reason, declaredBy) {
|
|
170
|
+
try {
|
|
171
|
+
const fp = normalisePath(filepath);
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
let records = await readJsonOrNull(stabilityPath(mathaDir));
|
|
174
|
+
if (!records || !Array.isArray(records)) {
|
|
175
|
+
records = [];
|
|
176
|
+
}
|
|
177
|
+
const idx = records.findIndex(r => normalisePath(r.filepath) === fp);
|
|
178
|
+
const declaredRecord = {
|
|
179
|
+
filepath: fp,
|
|
180
|
+
stability,
|
|
181
|
+
confidence: idx >= 0 ? records[idx].confidence : 'low',
|
|
182
|
+
reason,
|
|
183
|
+
classificationSource: 'declared',
|
|
184
|
+
declaredBy,
|
|
185
|
+
declaredAt: now,
|
|
186
|
+
changeCount: idx >= 0 ? records[idx].changeCount : 0,
|
|
187
|
+
coChangeCount: idx >= 0 ? records[idx].coChangeCount : 0,
|
|
188
|
+
ageInDays: idx >= 0 ? records[idx].ageInDays : 0,
|
|
189
|
+
daysSinceLastChange: idx >= 0 ? records[idx].daysSinceLastChange : 0,
|
|
190
|
+
lastDerivedAt: idx >= 0 ? records[idx].lastDerivedAt : undefined,
|
|
191
|
+
};
|
|
192
|
+
if (idx >= 0) {
|
|
193
|
+
records[idx] = declaredRecord;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
records.push(declaredRecord);
|
|
197
|
+
}
|
|
198
|
+
await writeAtomic(stabilityPath(mathaDir), records, { overwrite: true });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Never throw
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ──────────────────────────────────────────────────────────────
|
|
205
|
+
// getCoChanges
|
|
206
|
+
// ──────────────────────────────────────────────────────────────
|
|
207
|
+
/**
|
|
208
|
+
* Read co-change pairs, optionally filtered to a specific file.
|
|
209
|
+
*
|
|
210
|
+
* **Never throws** — returns empty array if file missing.
|
|
211
|
+
*/
|
|
212
|
+
export async function getCoChanges(mathaDir, filepath) {
|
|
213
|
+
try {
|
|
214
|
+
const pairs = await readJsonOrNull(coChangesPath(mathaDir));
|
|
215
|
+
if (!pairs || !Array.isArray(pairs))
|
|
216
|
+
return [];
|
|
217
|
+
if (!filepath)
|
|
218
|
+
return pairs;
|
|
219
|
+
const fp = normalisePath(filepath);
|
|
220
|
+
return pairs.filter(p => normalisePath(p.fileA) === fp || normalisePath(p.fileB) === fp);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ──────────────────────────────────────────────────────────────
|
|
227
|
+
// getSnapshot
|
|
228
|
+
// ──────────────────────────────────────────────────────────────
|
|
229
|
+
/**
|
|
230
|
+
* Read persisted cortex data and assemble a CortexSnapshot.
|
|
231
|
+
* Returns null if stability.json does not exist.
|
|
232
|
+
*
|
|
233
|
+
* **Never throws.**
|
|
234
|
+
*/
|
|
235
|
+
export async function getSnapshot(mathaDir) {
|
|
236
|
+
try {
|
|
237
|
+
const records = await readJsonOrNull(stabilityPath(mathaDir));
|
|
238
|
+
if (!records || !Array.isArray(records))
|
|
239
|
+
return null;
|
|
240
|
+
const pairs = await readJsonOrNull(coChangesPath(mathaDir));
|
|
241
|
+
const coChanges = pairs && Array.isArray(pairs) ? pairs : [];
|
|
242
|
+
// Try to read shape.json for metadata
|
|
243
|
+
const shapePath = path.join(mathaDir, 'cortex', 'shape.json');
|
|
244
|
+
const shape = await readJsonOrNull(shapePath);
|
|
245
|
+
return {
|
|
246
|
+
updatedAt: new Date().toISOString(),
|
|
247
|
+
repoPath: shape?.project_root ?? '',
|
|
248
|
+
commitCount: 0, // Not stored in stability.json — would need separate metadata
|
|
249
|
+
fileCount: records.length,
|
|
250
|
+
stability: records,
|
|
251
|
+
coChanges,
|
|
252
|
+
summary: buildSummary(records),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|