@imenam/mcp-feature-analyzer 1.0.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 (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +295 -0
  3. package/dist/shared/diagram-geometry.d.ts +45 -0
  4. package/dist/shared/diagram-geometry.js +120 -0
  5. package/dist/shared/diagram-layout.d.ts +98 -0
  6. package/dist/shared/diagram-layout.js +1156 -0
  7. package/dist/shared/diagram-quality.d.ts +30 -0
  8. package/dist/shared/diagram-quality.js +26 -0
  9. package/dist/shared/labels.d.ts +31 -0
  10. package/dist/shared/labels.js +53 -0
  11. package/dist/shared/mindmap-tree.d.ts +28 -0
  12. package/dist/shared/mindmap-tree.js +62 -0
  13. package/dist/shared/prompt.d.ts +17 -0
  14. package/dist/shared/prompt.js +97 -0
  15. package/dist/shared/review-state.d.ts +10 -0
  16. package/dist/shared/review-state.js +17 -0
  17. package/dist/shared/schemas/analysis.schema.d.ts +372 -0
  18. package/dist/shared/schemas/analysis.schema.js +152 -0
  19. package/dist/shared/schemas/api.schema.d.ts +92 -0
  20. package/dist/shared/schemas/api.schema.js +46 -0
  21. package/dist/shared/schemas/diff.schema.d.ts +91 -0
  22. package/dist/shared/schemas/diff.schema.js +37 -0
  23. package/dist/shared/schemas/ipc.schema.d.ts +29 -0
  24. package/dist/shared/schemas/ipc.schema.js +30 -0
  25. package/dist/shared/severity.d.ts +11 -0
  26. package/dist/shared/severity.js +17 -0
  27. package/dist/shared/text.d.ts +12 -0
  28. package/dist/shared/text.js +20 -0
  29. package/dist/src/cli/setup-mcp.d.ts +24 -0
  30. package/dist/src/cli/setup-mcp.js +145 -0
  31. package/dist/src/core/analysis-store.d.ts +95 -0
  32. package/dist/src/core/analysis-store.js +387 -0
  33. package/dist/src/core/data-dir.d.ts +5 -0
  34. package/dist/src/core/data-dir.js +11 -0
  35. package/dist/src/core/diff-parser.d.ts +22 -0
  36. package/dist/src/core/diff-parser.js +225 -0
  37. package/dist/src/core/errors.d.ts +16 -0
  38. package/dist/src/core/errors.js +28 -0
  39. package/dist/src/core/git.d.ts +35 -0
  40. package/dist/src/core/git.js +257 -0
  41. package/dist/src/core/ids.d.ts +16 -0
  42. package/dist/src/core/ids.js +41 -0
  43. package/dist/src/core/invariants.d.ts +24 -0
  44. package/dist/src/core/invariants.js +157 -0
  45. package/dist/src/core/locations.d.ts +30 -0
  46. package/dist/src/core/locations.js +66 -0
  47. package/dist/src/core/log-dir.d.ts +6 -0
  48. package/dist/src/core/log-dir.js +13 -0
  49. package/dist/src/core/migrate.d.ts +24 -0
  50. package/dist/src/core/migrate.js +53 -0
  51. package/dist/src/core/refresh.d.ts +30 -0
  52. package/dist/src/core/refresh.js +113 -0
  53. package/dist/src/core/request-guard.d.ts +32 -0
  54. package/dist/src/core/request-guard.js +72 -0
  55. package/dist/src/core/review-actions.d.ts +27 -0
  56. package/dist/src/core/review-actions.js +99 -0
  57. package/dist/src/gui-worker.d.ts +1 -0
  58. package/dist/src/gui-worker.js +401 -0
  59. package/dist/src/index.d.ts +2 -0
  60. package/dist/src/index.js +658 -0
  61. package/dist/src/tools/add-findings.d.ts +9 -0
  62. package/dist/src/tools/add-findings.js +106 -0
  63. package/dist/src/tools/create-analysis.d.ts +4 -0
  64. package/dist/src/tools/create-analysis.js +36 -0
  65. package/dist/src/tools/delete-analysis.d.ts +4 -0
  66. package/dist/src/tools/delete-analysis.js +11 -0
  67. package/dist/src/tools/delete-diagram.d.ts +4 -0
  68. package/dist/src/tools/delete-diagram.js +22 -0
  69. package/dist/src/tools/delete-findings.d.ts +7 -0
  70. package/dist/src/tools/delete-findings.js +27 -0
  71. package/dist/src/tools/format.d.ts +27 -0
  72. package/dist/src/tools/format.js +92 -0
  73. package/dist/src/tools/get-analysis.d.ts +4 -0
  74. package/dist/src/tools/get-analysis.js +87 -0
  75. package/dist/src/tools/get-diff.d.ts +4 -0
  76. package/dist/src/tools/get-diff.js +67 -0
  77. package/dist/src/tools/get-review-feedback.d.ts +4 -0
  78. package/dist/src/tools/get-review-feedback.js +71 -0
  79. package/dist/src/tools/inputs.d.ts +14 -0
  80. package/dist/src/tools/inputs.js +40 -0
  81. package/dist/src/tools/list-analyses.d.ts +8 -0
  82. package/dist/src/tools/list-analyses.js +71 -0
  83. package/dist/src/tools/refresh-analysis.d.ts +7 -0
  84. package/dist/src/tools/refresh-analysis.js +39 -0
  85. package/dist/src/tools/set-diagram.d.ts +16 -0
  86. package/dist/src/tools/set-diagram.js +207 -0
  87. package/dist/src/tools/types.d.ts +52 -0
  88. package/dist/src/tools/types.js +133 -0
  89. package/dist/src/tools/update-analysis.d.ts +4 -0
  90. package/dist/src/tools/update-analysis.js +121 -0
  91. package/dist/src/tools/update-finding.d.ts +8 -0
  92. package/dist/src/tools/update-finding.js +91 -0
  93. package/dist/src/version.d.ts +1 -0
  94. package/dist/src/version.js +2 -0
  95. package/gui/dist/assets/index-CU9pNYDp.js +72 -0
  96. package/gui/dist/assets/index-Du5eL78F.css +1 -0
  97. package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  98. package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  99. package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  100. package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  101. package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  102. package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  103. package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  104. package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  105. package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  106. package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  107. package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  108. package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  109. package/gui/dist/index.html +13 -0
  110. package/package.json +56 -0
  111. package/scripts/generate-version.js +20 -0
@@ -0,0 +1,387 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { ANALYSIS_ID_PATTERN, AnalysisSchema, } from "../../shared/schemas/analysis.schema.js";
5
+ import { DiffSnapshotSchema } from "../../shared/schemas/diff.schema.js";
6
+ import { reviewProgress } from "../../shared/review-state.js";
7
+ import { emptySeverityCounts } from "../../shared/severity.js";
8
+ import { AnalysisError, NotFoundError, RevisionConflictError } from "./errors.js";
9
+ import { analysisId } from "./ids.js";
10
+ import { assertInvariants, assertSnapshotFieldsUnchanged } from "./invariants.js";
11
+ import { migrateAnalysis, migrateSnapshot } from "./migrate.js";
12
+ import { applyRefresh } from "./refresh.js";
13
+ /** Revue vierge : en attente, sans décision, sélection ni prompt. */
14
+ export function pendingReview() {
15
+ return { state: "pending", decision: null, selectedFindingIds: [], selectedNoteIds: [], prompt: null, submittedAt: null };
16
+ }
17
+ /** Au-delà de ce délai, un verrou est réputé abandonné par un processus arrêté en cours d'écriture. */
18
+ const LOCK_STALE_MS = 10_000;
19
+ const LOCK_RETRY_MS = 25;
20
+ const LOCK_TIMEOUT_MS = 5_000;
21
+ /** Pause bloquante : le store est synchrone de bout en bout. */
22
+ function sleepSync(ms) {
23
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
24
+ }
25
+ function formatIssues(issues) {
26
+ return issues.map((issue) => `${issue.path.map(String).join(".") || "(root)"} ${issue.message}`).join("; ");
27
+ }
28
+ function stampOf(file) {
29
+ try {
30
+ const stat = fs.statSync(file);
31
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
32
+ }
33
+ catch (err) {
34
+ if (err.code === "ENOENT")
35
+ return null;
36
+ throw err;
37
+ }
38
+ }
39
+ /** Écriture atomique : fichier temporaire unique puis `rename`, JSON indenté. */
40
+ function writeJsonAtomic(target, value) {
41
+ const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
42
+ try {
43
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), "utf-8");
44
+ fs.renameSync(tmp, target);
45
+ }
46
+ catch (err) {
47
+ fs.rmSync(tmp, { force: true });
48
+ throw err;
49
+ }
50
+ }
51
+ /**
52
+ * Persistance des analyses : `<dataDir>/analyses/<id>.json` pour l'analyse et
53
+ * `<dataDir>/diffs/<id>.json` pour son snapshot de diff. Plusieurs processus
54
+ * peuvent partager le même répertoire : chaque cycle lire-modifier-écrire est
55
+ * sérialisé par un verrou fichier, et le cache mémoire est invalidé dès que la
56
+ * signature (mtime, taille) du fichier change.
57
+ */
58
+ export class AnalysisStore {
59
+ dataDir;
60
+ analysesDir;
61
+ diffsDir;
62
+ analysisCache = new Map();
63
+ snapshotCache = new Map();
64
+ constructor(dataDir) {
65
+ this.dataDir = dataDir;
66
+ this.analysesDir = path.join(dataDir, "analyses");
67
+ this.diffsDir = path.join(dataDir, "diffs");
68
+ fs.mkdirSync(this.analysesDir, { recursive: true });
69
+ fs.mkdirSync(this.diffsDir, { recursive: true });
70
+ }
71
+ analysisPath(id) {
72
+ return path.join(this.analysesDir, `${id}.json`);
73
+ }
74
+ snapshotPath(id) {
75
+ return path.join(this.diffsDir, `${id}.json`);
76
+ }
77
+ /** Refuse un identifiant qui ne peut pas désigner une analyse (et donc un nom de fichier). */
78
+ assertId(id) {
79
+ if (typeof id !== "string" || !ANALYSIS_ID_PATTERN.test(id)) {
80
+ throw new AnalysisError(`"${id}" is not a valid analysis id. Use list_analyses to see the available analyses.`);
81
+ }
82
+ }
83
+ notFound(id) {
84
+ return new NotFoundError(`Analysis "${id}" not found. Use list_analyses to see the available analyses.`);
85
+ }
86
+ /**
87
+ * Sérialise l'accès en écriture à une analyse entre processus : `wx` échoue si
88
+ * le verrou existe déjà. Non réentrant.
89
+ */
90
+ withLock(id, fn) {
91
+ const lockPath = `${this.analysisPath(id)}.lock`;
92
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
93
+ let fd;
94
+ for (;;) {
95
+ try {
96
+ fd = fs.openSync(lockPath, "wx");
97
+ break;
98
+ }
99
+ catch (err) {
100
+ if (err.code !== "EEXIST")
101
+ throw err;
102
+ const lockStamp = stampOf(lockPath);
103
+ // Verrou disparu entre l'ouverture et la lecture de sa date : nouvel essai immédiat.
104
+ if (lockStamp === null)
105
+ continue;
106
+ if (Date.now() - lockStamp.mtimeMs > LOCK_STALE_MS) {
107
+ fs.rmSync(lockPath, { force: true });
108
+ continue;
109
+ }
110
+ if (Date.now() > deadline) {
111
+ throw new AnalysisError(`Analysis "${id}" is being written by another editor and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
112
+ }
113
+ sleepSync(LOCK_RETRY_MS);
114
+ }
115
+ }
116
+ try {
117
+ return fn();
118
+ }
119
+ finally {
120
+ fs.closeSync(fd);
121
+ // Le verrou a pu être cassé pour obsolescence par un autre processus.
122
+ fs.rmSync(lockPath, { force: true });
123
+ }
124
+ }
125
+ /**
126
+ * Lit, migre et valide un document JSON. Un document migré est réécrit sur
127
+ * disque. `parse` renvoie le document typé ou lève une erreur. Renvoie null
128
+ * si le fichier n'existe pas.
129
+ */
130
+ readDocument(file, label, cache, id, migrate, parse) {
131
+ const stamp = stampOf(file);
132
+ if (stamp === null) {
133
+ cache.delete(id);
134
+ return null;
135
+ }
136
+ const cached = cache.get(id);
137
+ if (cached && cached.stamp.mtimeMs === stamp.mtimeMs && cached.stamp.size === stamp.size) {
138
+ return cached.value;
139
+ }
140
+ cache.delete(id);
141
+ let raw;
142
+ try {
143
+ raw = JSON.parse(fs.readFileSync(file, "utf-8"));
144
+ }
145
+ catch (err) {
146
+ throw new AnalysisError(`${label} is not readable (${file}): ${err.message}`);
147
+ }
148
+ const migrated = migrate(raw);
149
+ const value = parse(migrated ?? raw);
150
+ if (migrated)
151
+ writeJsonAtomic(file, value);
152
+ const fresh = stampOf(file);
153
+ if (fresh)
154
+ cache.set(id, { value, stamp: fresh });
155
+ return value;
156
+ }
157
+ parseAnalysis(id, raw) {
158
+ const parsed = AnalysisSchema.safeParse(raw);
159
+ if (!parsed.success) {
160
+ throw new AnalysisError(`Analysis "${id}" does not match the expected schema (${this.analysisPath(id)}): ${formatIssues(parsed.error.issues)}`);
161
+ }
162
+ if (parsed.data.id !== id) {
163
+ throw new AnalysisError(`Analysis file ${this.analysisPath(id)} contains the id "${parsed.data.id}" instead of "${id}".`);
164
+ }
165
+ return parsed.data;
166
+ }
167
+ parseSnapshot(id, raw) {
168
+ const parsed = DiffSnapshotSchema.safeParse(raw);
169
+ if (!parsed.success) {
170
+ throw new AnalysisError(`Diff snapshot of analysis "${id}" does not match the expected schema (${this.snapshotPath(id)}): ${formatIssues(parsed.error.issues)}`);
171
+ }
172
+ if (parsed.data.analysisId !== id) {
173
+ throw new AnalysisError(`Diff snapshot ${this.snapshotPath(id)} belongs to analysis "${parsed.data.analysisId}" instead of "${id}".`);
174
+ }
175
+ return parsed.data;
176
+ }
177
+ writeAnalysis(analysis) {
178
+ const file = this.analysisPath(analysis.id);
179
+ writeJsonAtomic(file, analysis);
180
+ const stamp = stampOf(file);
181
+ if (stamp)
182
+ this.analysisCache.set(analysis.id, { value: analysis, stamp });
183
+ }
184
+ writeSnapshot(snapshot) {
185
+ const file = this.snapshotPath(snapshot.analysisId);
186
+ writeJsonAtomic(file, snapshot);
187
+ const stamp = stampOf(file);
188
+ if (stamp)
189
+ this.snapshotCache.set(snapshot.analysisId, { value: snapshot, stamp });
190
+ }
191
+ /** Valide une analyse complète (schéma puis invariants) avant écriture. */
192
+ validate(analysis) {
193
+ const parsed = AnalysisSchema.safeParse(analysis);
194
+ if (!parsed.success) {
195
+ throw new AnalysisError(`Resulting analysis is invalid: ${formatIssues(parsed.error.issues)}`);
196
+ }
197
+ assertInvariants(parsed.data);
198
+ return parsed.data;
199
+ }
200
+ has(id) {
201
+ return ANALYSIS_ID_PATTERN.test(id) && fs.existsSync(this.analysisPath(id));
202
+ }
203
+ /** Résumés des analyses, de la plus récemment modifiée à la plus ancienne. */
204
+ list() {
205
+ const analyses = [];
206
+ const unreadable = [];
207
+ const ids = fs
208
+ .readdirSync(this.analysesDir)
209
+ .filter((file) => file.endsWith(".json"))
210
+ .map((file) => file.slice(0, -".json".length));
211
+ for (const id of ids) {
212
+ let analysis;
213
+ try {
214
+ this.assertId(id);
215
+ analysis = this.get(id);
216
+ }
217
+ catch (err) {
218
+ unreadable.push({ id, error: err.message });
219
+ continue;
220
+ }
221
+ analyses.push(summarize(analysis));
222
+ }
223
+ analyses.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
224
+ return { analyses, unreadable };
225
+ }
226
+ get(id) {
227
+ this.assertId(id);
228
+ const analysis = this.readDocument(this.analysisPath(id), `Analysis "${id}"`, this.analysisCache, id, migrateAnalysis, (raw) => this.parseAnalysis(id, raw));
229
+ if (analysis === null)
230
+ throw this.notFound(id);
231
+ return analysis;
232
+ }
233
+ getSnapshot(id) {
234
+ this.assertId(id);
235
+ if (!fs.existsSync(this.analysisPath(id)))
236
+ throw this.notFound(id);
237
+ const snapshot = this.readDocument(this.snapshotPath(id), `Diff snapshot of analysis "${id}"`, this.snapshotCache, id, migrateSnapshot, (raw) => this.parseSnapshot(id, raw));
238
+ if (snapshot === null) {
239
+ throw new AnalysisError(`Diff snapshot of analysis "${id}" is missing (${this.snapshotPath(id)}). ` +
240
+ `Delete the analysis with delete_analysis and create it again.`);
241
+ }
242
+ return snapshot;
243
+ }
244
+ /** Crée une analyse à partir d'un snapshot calculé ; le snapshot est écrit avant l'analyse. */
245
+ create(input, snapshot) {
246
+ const title = input.title?.trim();
247
+ if (!title)
248
+ throw new AnalysisError(`"title" is required to create an analysis.`);
249
+ const project = input.project?.trim();
250
+ if (!project)
251
+ throw new AnalysisError(`"project" is required to create an analysis.`);
252
+ let id = analysisId(title);
253
+ while (fs.existsSync(this.analysisPath(id)))
254
+ id = analysisId(title);
255
+ const now = new Date().toISOString();
256
+ const analysis = this.validate({
257
+ id,
258
+ title,
259
+ project,
260
+ repoPath: snapshot.repoPath,
261
+ mode: snapshot.mode,
262
+ base: snapshot.base,
263
+ head: snapshot.head,
264
+ baseCommit: snapshot.baseCommit,
265
+ headCommit: snapshot.headCommit,
266
+ snapshotAt: snapshot.snapshotAt,
267
+ request: input.request ?? null,
268
+ overview: null,
269
+ summary: input.summary ?? [],
270
+ files: snapshot.files.map((file) => ({ ...file, reviewed: false })),
271
+ findings: [],
272
+ notes: [],
273
+ diagrams: [],
274
+ review: pendingReview(),
275
+ revision: 0,
276
+ createdAt: now,
277
+ updatedAt: now,
278
+ lastEditor: "agent",
279
+ });
280
+ const diff = this.buildSnapshot(id, snapshot);
281
+ return this.withLock(id, () => {
282
+ this.writeSnapshot(diff);
283
+ this.writeAnalysis(analysis);
284
+ return analysis;
285
+ });
286
+ }
287
+ buildSnapshot(id, snapshot) {
288
+ const parsed = DiffSnapshotSchema.safeParse({ analysisId: id, files: snapshot.diff });
289
+ if (!parsed.success) {
290
+ throw new AnalysisError(`Computed diff snapshot is invalid: ${formatIssues(parsed.error.issues)}`);
291
+ }
292
+ const diffPaths = parsed.data.files.map((file) => file.path).join("\n");
293
+ const entryPaths = snapshot.files.map((file) => file.path).join("\n");
294
+ if (diffPaths !== entryPaths) {
295
+ throw new AnalysisError(`Computed snapshot is inconsistent: its file list and its diff list differ.`);
296
+ }
297
+ return parsed.data;
298
+ }
299
+ /**
300
+ * Point unique de modification : applique `fn` à une copie de l'analyse,
301
+ * vérifie schéma et invariants, incrémente la révision puis écrit. Les champs
302
+ * issus du snapshot ne peuvent pas changer ici (voir `replaceSnapshot`).
303
+ */
304
+ mutate(id, editor, fn, options = {}) {
305
+ this.assertId(id);
306
+ return this.withLock(id, () => {
307
+ const current = this.get(id);
308
+ this.assertBaseRevision(current, options);
309
+ const draft = structuredClone(current);
310
+ fn(draft);
311
+ assertSnapshotFieldsUnchanged(current, draft);
312
+ draft.revision = current.revision + 1;
313
+ draft.updatedAt = new Date().toISOString();
314
+ draft.lastEditor = editor;
315
+ const next = this.validate(draft);
316
+ this.writeAnalysis(next);
317
+ return next;
318
+ });
319
+ }
320
+ assertBaseRevision(current, options) {
321
+ if (options.baseRevision !== undefined && options.baseRevision !== current.revision) {
322
+ throw new RevisionConflictError(`Analysis "${current.id}" has changed since it was loaded: the edit is based on revision ${options.baseRevision}, ` +
323
+ `the stored analysis is at revision ${current.revision}. Reload it and apply the change again.`);
324
+ }
325
+ }
326
+ /**
327
+ * Remplace le snapshot de diff d'une analyse par `snapshot`, recalculé avec
328
+ * les mêmes dépôt, mode et refs, met à jour fichiers et constats selon
329
+ * `applyRefresh` et remet la revue à l'état vierge, dans la même écriture.
330
+ */
331
+ replaceSnapshot(id, editor, snapshot, options = {}) {
332
+ this.assertId(id);
333
+ return this.withLock(id, () => {
334
+ const current = this.get(id);
335
+ this.assertBaseRevision(current, options);
336
+ const oldSnapshot = this.getSnapshot(id);
337
+ const { fields, stats } = applyRefresh(current, oldSnapshot, snapshot);
338
+ const next = this.validate({
339
+ ...structuredClone(current),
340
+ ...fields,
341
+ review: pendingReview(),
342
+ revision: current.revision + 1,
343
+ updatedAt: new Date().toISOString(),
344
+ lastEditor: editor,
345
+ });
346
+ const diff = this.buildSnapshot(id, snapshot);
347
+ this.writeSnapshot(diff);
348
+ this.writeAnalysis(next);
349
+ return { analysis: next, stats, previousReview: current.review };
350
+ });
351
+ }
352
+ /** Supprime l'analyse et son snapshot de diff. */
353
+ delete(id) {
354
+ this.assertId(id);
355
+ this.withLock(id, () => {
356
+ if (!fs.existsSync(this.analysisPath(id)))
357
+ throw this.notFound(id);
358
+ fs.rmSync(this.analysisPath(id));
359
+ fs.rmSync(this.snapshotPath(id), { force: true });
360
+ this.analysisCache.delete(id);
361
+ this.snapshotCache.delete(id);
362
+ });
363
+ }
364
+ }
365
+ /** Vue légère d'une analyse. */
366
+ export function summarize(analysis) {
367
+ const openFindings = emptySeverityCounts();
368
+ for (const finding of analysis.findings) {
369
+ if (finding.status === "open")
370
+ openFindings[finding.severity]++;
371
+ }
372
+ return {
373
+ id: analysis.id,
374
+ title: analysis.title,
375
+ project: analysis.project,
376
+ mode: analysis.mode,
377
+ base: analysis.base,
378
+ head: analysis.head,
379
+ fileCount: analysis.files.length,
380
+ reviewedCount: analysis.files.filter((file) => file.reviewed).length,
381
+ openFindings,
382
+ diagramCount: analysis.diagrams.length,
383
+ progress: reviewProgress(analysis),
384
+ updatedAt: analysis.updatedAt,
385
+ revision: analysis.revision,
386
+ };
387
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Répertoire des données : MCP_FEATURE_ANALYZER_DATA_DIR, puis MCP_DATA_DIR,
3
+ * puis <racine du package>/.feature-analyzer-data.
4
+ */
5
+ export declare function resolveDataDir(rootDir: string): string;
@@ -0,0 +1,11 @@
1
+ import path from "path";
2
+ /**
3
+ * Répertoire des données : MCP_FEATURE_ANALYZER_DATA_DIR, puis MCP_DATA_DIR,
4
+ * puis <racine du package>/.feature-analyzer-data.
5
+ */
6
+ export function resolveDataDir(rootDir) {
7
+ const configured = process.env.MCP_FEATURE_ANALYZER_DATA_DIR?.trim() || process.env.MCP_DATA_DIR?.trim();
8
+ if (configured)
9
+ return path.resolve(configured);
10
+ return path.join(rootDir, ".feature-analyzer-data");
11
+ }
@@ -0,0 +1,22 @@
1
+ import type { FileStatus } from "../../shared/schemas/analysis.schema.js";
2
+ import type { Hunk } from "../../shared/schemas/diff.schema.js";
3
+ /** Diff d'un fichier tel que lu dans la sortie de `git diff`. */
4
+ export interface ParsedFileDiff {
5
+ /** Chemin côté « nouveau » ; chemin supprimé pour un fichier supprimé. */
6
+ path: string;
7
+ /** Ancien chemin d'un fichier renommé ; null sinon. */
8
+ oldPath: string | null;
9
+ status: FileStatus;
10
+ binary: boolean;
11
+ hunks: Hunk[];
12
+ additions: number;
13
+ deletions: number;
14
+ }
15
+ /** Remplace les antislashs par des slashs. */
16
+ export declare function toPosixPath(value: string): string;
17
+ /**
18
+ * Lit la sortie de `git diff` (format unifié, préfixes `a/` et `b/`) et renvoie
19
+ * un diff par fichier. Les lignes `` sont écartées ;
20
+ * les `\r` de fin de ligne sont retirés du texte des lignes.
21
+ */
22
+ export declare function parseUnifiedDiff(output: string): ParsedFileDiff[];
@@ -0,0 +1,225 @@
1
+ import { GitError } from "./errors.js";
2
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
3
+ /** Retire le `\r` final d'une ligne lue dans une sortie découpée sur `\n`. */
4
+ function stripCr(line) {
5
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
6
+ }
7
+ /** Remplace les antislashs par des slashs. */
8
+ export function toPosixPath(value) {
9
+ return value.replace(/\\/g, "/");
10
+ }
11
+ const ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
12
+ /**
13
+ * Décode un chemin entre guillemets tel que git l'écrit (échappements C et
14
+ * octets en octal). `quoted` commence par `"` ; renvoie le chemin et la
15
+ * position qui suit le guillemet fermant.
16
+ */
17
+ function readQuoted(quoted, start) {
18
+ const bytes = [];
19
+ let i = start + 1;
20
+ while (i < quoted.length) {
21
+ const char = quoted[i];
22
+ if (char === '"') {
23
+ return { value: Buffer.from(bytes).toString("utf-8"), end: i + 1 };
24
+ }
25
+ if (char === "\\") {
26
+ const next = quoted[i + 1];
27
+ if (next !== undefined && /[0-7]/.test(next)) {
28
+ bytes.push(parseInt(quoted.slice(i + 1, i + 4), 8));
29
+ i += 4;
30
+ continue;
31
+ }
32
+ if (next !== undefined && ESCAPES[next] !== undefined) {
33
+ bytes.push(ESCAPES[next]);
34
+ i += 2;
35
+ continue;
36
+ }
37
+ throw new GitError(`Unexpected escape sequence in git path: ${quoted}`);
38
+ }
39
+ bytes.push(...Buffer.from(char, "utf-8"));
40
+ i += 1;
41
+ }
42
+ throw new GitError(`Unterminated quoted path in git output: ${quoted}`);
43
+ }
44
+ /** Chemin d'une ligne `--- a/x`, `+++ b/x`, `rename from x` : décode les guillemets, retire le préfixe. */
45
+ function readPathField(raw, prefix) {
46
+ // git ajoute une tabulation après un chemin contenant une espace.
47
+ const field = raw.endsWith("\t") ? raw.slice(0, -1) : raw;
48
+ if (field === "/dev/null")
49
+ return null;
50
+ const value = field.startsWith('"') ? readQuoted(field, 0).value : field;
51
+ if (prefix === null)
52
+ return value;
53
+ if (!value.startsWith(prefix)) {
54
+ throw new GitError(`Unexpected path "${value}" in git diff output: expected the "${prefix}" prefix.`);
55
+ }
56
+ return value.slice(prefix.length);
57
+ }
58
+ /** Chemins ancien et nouveau de la ligne `diff --git a/x b/y`, quand elle est décodable sans ambiguïté. */
59
+ function readGitHeader(rest) {
60
+ if (rest.startsWith('"')) {
61
+ const first = readQuoted(rest, 0);
62
+ const second = rest.slice(first.end + 1);
63
+ const newPath = second.startsWith('"') ? readQuoted(second, 0).value : second;
64
+ return strip(first.value, newPath);
65
+ }
66
+ const quotedSecond = rest.indexOf(' "b/');
67
+ if (quotedSecond !== -1 && rest.endsWith('"')) {
68
+ return strip(rest.slice(0, quotedSecond), readQuoted(rest, quotedSecond + 1).value);
69
+ }
70
+ // Sans guillemets, seul le cas symétrique « a/P b/P » se découpe sans ambiguïté.
71
+ if ((rest.length - 5) % 2 !== 0)
72
+ return null;
73
+ const length = (rest.length - 5) / 2;
74
+ const candidate = rest.slice(2, 2 + length);
75
+ if (rest !== `a/${candidate} b/${candidate}`)
76
+ return null;
77
+ return { oldPath: candidate, newPath: candidate };
78
+ function strip(oldValue, newValue) {
79
+ if (!oldValue.startsWith("a/") || !newValue.startsWith("b/"))
80
+ return null;
81
+ return { oldPath: oldValue.slice(2), newPath: newValue.slice(2) };
82
+ }
83
+ }
84
+ function finish(block) {
85
+ let oldPath = block.renameFrom ?? (block.minusPath === undefined ? undefined : block.minusPath);
86
+ let newPath = block.renameTo ?? (block.plusPath === undefined ? undefined : block.plusPath);
87
+ if (oldPath === undefined)
88
+ oldPath = block.isNew ? null : block.header?.oldPath;
89
+ if (newPath === undefined)
90
+ newPath = block.isDeleted ? null : block.header?.newPath;
91
+ if (oldPath === undefined || newPath === undefined) {
92
+ throw new GitError(`Cannot determine the file paths of this git diff entry: ${block.headerLine}`);
93
+ }
94
+ let status;
95
+ if (block.isNew || oldPath === null)
96
+ status = "added";
97
+ else if (block.isDeleted || newPath === null)
98
+ status = "deleted";
99
+ else if (block.renameFrom !== null || oldPath !== newPath)
100
+ status = "renamed";
101
+ else
102
+ status = "modified";
103
+ const path = status === "deleted" ? oldPath : newPath;
104
+ if (path === null) {
105
+ throw new GitError(`Cannot determine the file path of this git diff entry: ${block.headerLine}`);
106
+ }
107
+ let additions = 0;
108
+ let deletions = 0;
109
+ for (const hunk of block.hunks) {
110
+ for (const line of hunk.lines) {
111
+ if (line.type === "add")
112
+ additions++;
113
+ else if (line.type === "del")
114
+ deletions++;
115
+ }
116
+ }
117
+ return {
118
+ path: toPosixPath(path),
119
+ oldPath: status === "renamed" && oldPath !== null ? toPosixPath(oldPath) : null,
120
+ status,
121
+ binary: block.binary,
122
+ hunks: block.hunks,
123
+ additions,
124
+ deletions,
125
+ };
126
+ }
127
+ /**
128
+ * Lit la sortie de `git diff` (format unifié, préfixes `a/` et `b/`) et renvoie
129
+ * un diff par fichier. Les lignes `` sont écartées ;
130
+ * les `\r` de fin de ligne sont retirés du texte des lignes.
131
+ */
132
+ export function parseUnifiedDiff(output) {
133
+ const lines = output.split("\n");
134
+ if (lines[lines.length - 1] === "")
135
+ lines.pop();
136
+ const files = [];
137
+ let block = null;
138
+ let i = 0;
139
+ while (i < lines.length) {
140
+ const line = stripCr(lines[i]);
141
+ if (line.startsWith("diff --git ")) {
142
+ if (block)
143
+ files.push(finish(block));
144
+ block = {
145
+ header: readGitHeader(line.slice("diff --git ".length)),
146
+ headerLine: line,
147
+ minusPath: undefined,
148
+ plusPath: undefined,
149
+ renameFrom: null,
150
+ renameTo: null,
151
+ isNew: false,
152
+ isDeleted: false,
153
+ binary: false,
154
+ hunks: [],
155
+ };
156
+ i++;
157
+ continue;
158
+ }
159
+ if (!block) {
160
+ throw new GitError(`Unexpected line before the first "diff --git" header: ${line}`);
161
+ }
162
+ const hunkMatch = HUNK_HEADER.exec(line);
163
+ if (hunkMatch) {
164
+ const oldStart = Number(hunkMatch[1]);
165
+ const oldLines = hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]);
166
+ const newStart = Number(hunkMatch[3]);
167
+ const newLines = hunkMatch[4] === undefined ? 1 : Number(hunkMatch[4]);
168
+ const hunkLines = [];
169
+ let oldNo = oldStart;
170
+ let newNo = newStart;
171
+ let oldLeft = oldLines;
172
+ let newLeft = newLines;
173
+ i++;
174
+ while (i < lines.length && (oldLeft > 0 || newLeft > 0 || lines[i].startsWith("\\"))) {
175
+ const raw = lines[i];
176
+ const marker = raw[0];
177
+ const text = stripCr(raw.slice(1));
178
+ if (marker === " ") {
179
+ hunkLines.push({ type: "context", oldNo, newNo, text });
180
+ oldNo++;
181
+ newNo++;
182
+ oldLeft--;
183
+ newLeft--;
184
+ }
185
+ else if (marker === "-") {
186
+ hunkLines.push({ type: "del", oldNo, newNo: null, text });
187
+ oldNo++;
188
+ oldLeft--;
189
+ }
190
+ else if (marker === "+") {
191
+ hunkLines.push({ type: "add", oldNo: null, newNo, text });
192
+ newNo++;
193
+ newLeft--;
194
+ }
195
+ else if (marker !== "\\") {
196
+ throw new GitError(`Malformed hunk in the diff of ${block.headerLine}: unexpected line "${raw}".`);
197
+ }
198
+ i++;
199
+ }
200
+ if (oldLeft !== 0 || newLeft !== 0) {
201
+ throw new GitError(`Truncated hunk "${line}" in the diff of ${block.headerLine}.`);
202
+ }
203
+ block.hunks.push({ header: line, oldStart, oldLines, newStart, newLines, lines: hunkLines });
204
+ continue;
205
+ }
206
+ if (line.startsWith("--- "))
207
+ block.minusPath = readPathField(line.slice(4), "a/");
208
+ else if (line.startsWith("+++ "))
209
+ block.plusPath = readPathField(line.slice(4), "b/");
210
+ else if (line.startsWith("rename from "))
211
+ block.renameFrom = readPathField(line.slice(12), null);
212
+ else if (line.startsWith("rename to "))
213
+ block.renameTo = readPathField(line.slice(10), null);
214
+ else if (line.startsWith("new file mode "))
215
+ block.isNew = true;
216
+ else if (line.startsWith("deleted file mode "))
217
+ block.isDeleted = true;
218
+ else if (line.startsWith("Binary files ") || line === "GIT binary patch")
219
+ block.binary = true;
220
+ i++;
221
+ }
222
+ if (block)
223
+ files.push(finish(block));
224
+ return files;
225
+ }
@@ -0,0 +1,16 @@
1
+ /** Erreur métier dont le message s'adresse directement à l'agent ou à la GUI. */
2
+ export declare class AnalysisError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Analyse, constat ou remarque introuvable. */
6
+ export declare class NotFoundError extends AnalysisError {
7
+ constructor(message: string);
8
+ }
9
+ /** Modification basée sur une révision qui n'est plus la révision enregistrée. */
10
+ export declare class RevisionConflictError extends AnalysisError {
11
+ constructor(message: string);
12
+ }
13
+ /** Échec d'accès au dépôt git : chemin invalide, dépôt absent, ref inconnue, commande en erreur. */
14
+ export declare class GitError extends Error {
15
+ constructor(message: string);
16
+ }