@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.
@@ -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
+ }