@imenam/mcp-feature-analyzer 1.0.2 → 1.0.4
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/README.md +336 -295
- package/dist/shared/schemas/analysis.schema.d.ts +61 -0
- package/dist/shared/schemas/analysis.schema.js +26 -0
- package/dist/shared/schemas/api.schema.d.ts +13 -0
- package/dist/shared/schemas/api.schema.js +8 -0
- package/dist/shared/schemas/ipc.schema.d.ts +1 -1
- package/dist/shared/schemas/ipc.schema.js +1 -0
- package/dist/src/core/agent-repo.d.ts +4 -0
- package/dist/src/core/agent-repo.js +172 -0
- package/dist/src/core/analysis-store.js +1 -0
- package/dist/src/core/gateway-exec.d.ts +27 -0
- package/dist/src/core/gateway-exec.js +115 -0
- package/dist/src/core/git.d.ts +27 -2
- package/dist/src/core/git.js +48 -34
- package/dist/src/core/ids.d.ts +1 -0
- package/dist/src/core/ids.js +1 -0
- package/dist/src/core/invariants.d.ts +3 -1
- package/dist/src/core/invariants.js +27 -1
- package/dist/src/core/locations.d.ts +22 -4
- package/dist/src/core/locations.js +88 -4
- package/dist/src/core/migrate.d.ts +2 -0
- package/dist/src/core/migrate.js +3 -1
- package/dist/src/core/refresh.d.ts +8 -2
- package/dist/src/core/refresh.js +19 -1
- package/dist/src/core/review-actions.d.ts +6 -1
- package/dist/src/core/review-actions.js +20 -0
- package/dist/src/core/role.d.ts +11 -0
- package/dist/src/core/role.js +9 -0
- package/dist/src/gui-worker.js +7 -1
- package/dist/src/index.js +139 -19
- package/dist/src/tools/add-explanations.d.ts +9 -0
- package/dist/src/tools/add-explanations.js +73 -0
- package/dist/src/tools/create-analysis.d.ts +3 -2
- package/dist/src/tools/create-analysis.js +5 -5
- package/dist/src/tools/delete-explanations.d.ts +4 -0
- package/dist/src/tools/delete-explanations.js +23 -0
- package/dist/src/tools/format.d.ts +3 -1
- package/dist/src/tools/format.js +5 -0
- package/dist/src/tools/get-analysis.js +6 -1
- package/dist/src/tools/get-diff.js +2 -1
- package/dist/src/tools/inputs.d.ts +14 -0
- package/dist/src/tools/inputs.js +20 -2
- package/dist/src/tools/refresh-analysis.d.ts +4 -3
- package/dist/src/tools/refresh-analysis.js +13 -12
- package/dist/src/tools/update-explanation.d.ts +7 -0
- package/dist/src/tools/update-explanation.js +57 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/gui/dist/assets/index-BLyQW-EW.js +72 -0
- package/gui/dist/assets/index-CmSZOGwc.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/gui/dist/assets/index-Db7Hu3tM.js +0 -72
- package/gui/dist/assets/index-Du5eL78F.css +0 -1
package/dist/src/core/git.js
CHANGED
|
@@ -57,9 +57,7 @@ async function assertRepoRoot(repoPath) {
|
|
|
57
57
|
}
|
|
58
58
|
/** Résout une ref en sha de commit ; erreur explicite si elle est inconnue. */
|
|
59
59
|
async function resolveCommit(repoPath, ref, role) {
|
|
60
|
-
|
|
61
|
-
throw new GitError(`Invalid ${role} ref "${ref}": a ref cannot start with "-".`);
|
|
62
|
-
}
|
|
60
|
+
assertRefShape(ref, role);
|
|
63
61
|
try {
|
|
64
62
|
return (await runGit(repoPath, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], "utf-8")).trim();
|
|
65
63
|
}
|
|
@@ -67,7 +65,7 @@ async function resolveCommit(repoPath, ref, role) {
|
|
|
67
65
|
throw new GitError(`Unknown ${role} ref "${ref}" in ${repoPath}. Use a branch name, a tag or a commit sha that exists in this repository.`);
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
|
-
const DIFF_ARGS = [
|
|
68
|
+
export const DIFF_ARGS = [
|
|
71
69
|
"-c",
|
|
72
70
|
"diff.noprefix=false",
|
|
73
71
|
"-c",
|
|
@@ -82,7 +80,7 @@ const DIFF_ARGS = [
|
|
|
82
80
|
"--dst-prefix=b/",
|
|
83
81
|
];
|
|
84
82
|
/** Vrai si le contenu contient un octet nul dans ses 8000 premiers octets (même règle que git). */
|
|
85
|
-
function looksBinary(content) {
|
|
83
|
+
export function looksBinary(content) {
|
|
86
84
|
return content.subarray(0, 8000).includes(0);
|
|
87
85
|
}
|
|
88
86
|
/** Hunk unique d'un fichier ajouté, toutes lignes en ajout. */
|
|
@@ -108,9 +106,9 @@ function readWorkingFile(repoPath, filePath) {
|
|
|
108
106
|
return Buffer.from(toPosixPath(fs.readlinkSync(absolute)), "utf-8");
|
|
109
107
|
return fs.readFileSync(absolute);
|
|
110
108
|
}
|
|
111
|
-
/** Texte conservé dans le snapshot, ou null quand le contenu dépasse MAX_SNAPSHOT_FILE_BYTES. */
|
|
109
|
+
/** Texte conservé dans le snapshot, ou null quand le contenu est absent ou dépasse MAX_SNAPSHOT_FILE_BYTES. */
|
|
112
110
|
function snapshotText(content) {
|
|
113
|
-
if (content.length > MAX_SNAPSHOT_FILE_BYTES)
|
|
111
|
+
if (content == null || content.length > MAX_SNAPSHOT_FILE_BYTES)
|
|
114
112
|
return null;
|
|
115
113
|
return content.toString("utf-8");
|
|
116
114
|
}
|
|
@@ -172,33 +170,49 @@ async function readCommitFiles(repoPath, commit, paths) {
|
|
|
172
170
|
});
|
|
173
171
|
return contents;
|
|
174
172
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
/** Accès direct au dépôt, sur la machine de ce serveur. */
|
|
174
|
+
export function localRepo(repoPath) {
|
|
175
|
+
return {
|
|
176
|
+
assertRepoRoot: () => assertRepoRoot(repoPath),
|
|
177
|
+
resolveCommit: (ref, role) => resolveCommit(repoPath, ref, role),
|
|
178
|
+
diff: (range) => runGit(repoPath, [...DIFF_ARGS, ...range, "--"], "utf-8"),
|
|
179
|
+
readCommitFiles: (commit, paths) => readCommitFiles(repoPath, commit, paths),
|
|
180
|
+
untrackedFiles: async () => (await runGit(repoPath, ["ls-files", "--others", "--exclude-standard", "-z"], "utf-8"))
|
|
181
|
+
.split("\0")
|
|
182
|
+
.filter((entry) => entry !== "")
|
|
183
|
+
.map(toPosixPath),
|
|
184
|
+
readWorkingFiles: async (paths) => new Map(paths.map((filePath) => [filePath, readWorkingFile(repoPath, filePath)])),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Vérifie une ref fournie par l'agent avant de la passer à git. */
|
|
188
|
+
export function assertRefShape(ref, role) {
|
|
189
|
+
if (ref.startsWith("-")) {
|
|
190
|
+
throw new GitError(`Invalid ${role} ref "${ref}": a ref cannot start with "-".`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function branchSnapshot(repo, base, head) {
|
|
194
|
+
const baseCommit = await repo.resolveCommit(base, "base");
|
|
195
|
+
const headCommit = await repo.resolveCommit(head, "head");
|
|
196
|
+
const output = await repo.diff([`${baseCommit}...${headCommit}`]);
|
|
179
197
|
const parsedFiles = parseUnifiedDiff(output);
|
|
180
198
|
const withContent = parsedFiles.filter((parsed) => parsed.status !== "deleted" && !parsed.binary).map((parsed) => parsed.path);
|
|
181
|
-
const contents = await readCommitFiles(
|
|
182
|
-
const results = parsedFiles.map((parsed) =>
|
|
183
|
-
const content = contents.get(parsed.path);
|
|
184
|
-
return toEntry(parsed, content === undefined ? null : snapshotText(content));
|
|
185
|
-
});
|
|
199
|
+
const contents = await repo.readCommitFiles(headCommit, withContent);
|
|
200
|
+
const results = parsedFiles.map((parsed) => toEntry(parsed, snapshotText(contents.get(parsed.path))));
|
|
186
201
|
return { baseCommit, headCommit, results };
|
|
187
202
|
}
|
|
188
|
-
async function workingTreeSnapshot(
|
|
189
|
-
const baseCommit = await resolveCommit(
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const untracked = (await runGit(repoPath, ["ls-files", "--others", "--exclude-standard", "-z"], "utf-8"))
|
|
197
|
-
.split("\0")
|
|
198
|
-
.filter((entry) => entry !== "")
|
|
199
|
-
.map(toPosixPath);
|
|
203
|
+
async function workingTreeSnapshot(repo) {
|
|
204
|
+
const baseCommit = await repo.resolveCommit("HEAD", "base");
|
|
205
|
+
const parsedFiles = parseUnifiedDiff(await repo.diff(["HEAD"]));
|
|
206
|
+
const withContent = parsedFiles.filter((parsed) => parsed.status !== "deleted" && !parsed.binary).map((parsed) => parsed.path);
|
|
207
|
+
const contents = await repo.readWorkingFiles(withContent);
|
|
208
|
+
const results = parsedFiles.map((parsed) => toEntry(parsed, snapshotText(contents.get(parsed.path))));
|
|
209
|
+
const untracked = await repo.untrackedFiles();
|
|
210
|
+
const untrackedContents = await repo.readWorkingFiles(untracked);
|
|
200
211
|
for (const filePath of untracked) {
|
|
201
|
-
const raw =
|
|
212
|
+
const raw = untrackedContents.get(filePath);
|
|
213
|
+
if (raw == null) {
|
|
214
|
+
throw new GitError(`The untracked file "${filePath}" is too large to be transferred from the repository's machine. Add it to .gitignore or remove it, then try again.`);
|
|
215
|
+
}
|
|
202
216
|
const binary = looksBinary(raw);
|
|
203
217
|
const content = binary ? null : snapshotText(raw);
|
|
204
218
|
const lines = binary ? [] : splitLines(raw.toString("utf-8"));
|
|
@@ -208,15 +222,15 @@ async function workingTreeSnapshot(repoPath) {
|
|
|
208
222
|
return { baseCommit, headCommit: null, results };
|
|
209
223
|
}
|
|
210
224
|
/**
|
|
211
|
-
* Calcule le snapshot d'une analyse avec git.
|
|
225
|
+
* Calcule le snapshot d'une analyse avec git, par `repo` (accès local par défaut).
|
|
212
226
|
* - `branch` : diff `base...head` (head vaut `HEAD` par défaut), contenu lu dans le commit de tête ;
|
|
213
227
|
* - `working_tree` : diff de HEAD vers la copie de travail, index compris, fichiers non suivis
|
|
214
228
|
* présentés comme ajoutés, contenu lu sur disque.
|
|
215
229
|
* Les fichiers sont triés par chemin ; `reviewed` vaut false partout.
|
|
216
230
|
*/
|
|
217
|
-
export async function computeSnapshot(request) {
|
|
231
|
+
export async function computeSnapshot(request, repo = localRepo(request.repoPath)) {
|
|
218
232
|
const { repoPath, mode } = request;
|
|
219
|
-
await assertRepoRoot(
|
|
233
|
+
await repo.assertRepoRoot();
|
|
220
234
|
let base;
|
|
221
235
|
let head;
|
|
222
236
|
let computed;
|
|
@@ -226,7 +240,7 @@ export async function computeSnapshot(request) {
|
|
|
226
240
|
throw new GitError(`"base" is required in branch mode: give the ref the feature branched from.`);
|
|
227
241
|
base = requestedBase;
|
|
228
242
|
head = request.head?.trim() || DEFAULT_HEAD_REF;
|
|
229
|
-
computed = await branchSnapshot(
|
|
243
|
+
computed = await branchSnapshot(repo, base, head);
|
|
230
244
|
}
|
|
231
245
|
else if (mode === "working_tree") {
|
|
232
246
|
if (request.head != null && request.head.trim() !== "") {
|
|
@@ -237,7 +251,7 @@ export async function computeSnapshot(request) {
|
|
|
237
251
|
}
|
|
238
252
|
base = "HEAD";
|
|
239
253
|
head = null;
|
|
240
|
-
computed = await workingTreeSnapshot(
|
|
254
|
+
computed = await workingTreeSnapshot(repo);
|
|
241
255
|
}
|
|
242
256
|
else {
|
|
243
257
|
throw new GitError(`Unknown mode "${String(mode)}": use "branch" or "working_tree".`);
|
package/dist/src/core/ids.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare function analysisId(title: string): string;
|
|
|
7
7
|
/** Préfixe des identifiants générés, par type d'élément. */
|
|
8
8
|
export declare const ITEM_ID_PREFIXES: {
|
|
9
9
|
readonly finding: "f";
|
|
10
|
+
readonly explanation: "x";
|
|
10
11
|
readonly note: "n";
|
|
11
12
|
readonly diagram: "d";
|
|
12
13
|
readonly node: "nd";
|
package/dist/src/core/ids.js
CHANGED
|
@@ -3,12 +3,14 @@ import type { Analysis } from "../../shared/schemas/analysis.schema.js";
|
|
|
3
3
|
* Liste les violations des règles de cohérence d'une analyse :
|
|
4
4
|
* - refs cohérentes avec le mode : `branch` a une ref et un commit de tête ; `working_tree`
|
|
5
5
|
* compare à HEAD, sans ref ni commit de tête ;
|
|
6
|
-
* - identifiants uniques (fichiers, constats, remarques, schémas, nœuds d'un schéma) ;
|
|
6
|
+
* - identifiants uniques (fichiers, constats, explications, remarques, schémas, nœuds d'un schéma) ;
|
|
7
7
|
* - un constat `issue` a un emplacement, un `requirement_gap` peut ne pas en avoir ;
|
|
8
8
|
* - un emplacement de constat porte un `anchorText` ; sans emplacement, ni `anchorText` ni `suggestion` ;
|
|
9
9
|
* - un constat `open` vise un fichier de l'analyse dont le contenu est disponible, avec
|
|
10
10
|
* 1 ≤ startLine ≤ endLine ≤ nombre de lignes ; un constat `ignored` ou `outdated` garde
|
|
11
11
|
* l'emplacement qu'il avait, même si le snapshot a changé depuis ;
|
|
12
|
+
* - une explication `current` vise un fichier de l'analyse ; côté `new`, son contenu est
|
|
13
|
+
* disponible et endLine ≤ nombre de lignes ;
|
|
12
14
|
* - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
|
|
13
15
|
* déclarée dans `layers`, sans doublon ;
|
|
14
16
|
* - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
|
|
@@ -14,12 +14,14 @@ function duplicates(ids) {
|
|
|
14
14
|
* Liste les violations des règles de cohérence d'une analyse :
|
|
15
15
|
* - refs cohérentes avec le mode : `branch` a une ref et un commit de tête ; `working_tree`
|
|
16
16
|
* compare à HEAD, sans ref ni commit de tête ;
|
|
17
|
-
* - identifiants uniques (fichiers, constats, remarques, schémas, nœuds d'un schéma) ;
|
|
17
|
+
* - identifiants uniques (fichiers, constats, explications, remarques, schémas, nœuds d'un schéma) ;
|
|
18
18
|
* - un constat `issue` a un emplacement, un `requirement_gap` peut ne pas en avoir ;
|
|
19
19
|
* - un emplacement de constat porte un `anchorText` ; sans emplacement, ni `anchorText` ni `suggestion` ;
|
|
20
20
|
* - un constat `open` vise un fichier de l'analyse dont le contenu est disponible, avec
|
|
21
21
|
* 1 ≤ startLine ≤ endLine ≤ nombre de lignes ; un constat `ignored` ou `outdated` garde
|
|
22
22
|
* l'emplacement qu'il avait, même si le snapshot a changé depuis ;
|
|
23
|
+
* - une explication `current` vise un fichier de l'analyse ; côté `new`, son contenu est
|
|
24
|
+
* disponible et endLine ≤ nombre de lignes ;
|
|
23
25
|
* - les liens d'un schéma relient des nœuds existants ; en `layers`, chaque nœud a une couche
|
|
24
26
|
* déclarée dans `layers`, sans doublon ;
|
|
25
27
|
* - la revue ne sélectionne que des constats et remarques existants, et son état est cohérent.
|
|
@@ -36,6 +38,7 @@ export function findInvariantViolations(analysis) {
|
|
|
36
38
|
for (const [label, ids] of [
|
|
37
39
|
["file path", analysis.files.map((file) => file.path)],
|
|
38
40
|
["finding id", analysis.findings.map((finding) => finding.id)],
|
|
41
|
+
["explanation id", analysis.explanations.map((explanation) => explanation.id)],
|
|
39
42
|
["note id", analysis.notes.map((note) => note.id)],
|
|
40
43
|
["diagram id", analysis.diagrams.map((diagram) => diagram.id)],
|
|
41
44
|
]) {
|
|
@@ -75,6 +78,29 @@ export function findInvariantViolations(analysis) {
|
|
|
75
78
|
`which has ${file.lineCount} line(s)`);
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
for (const explanation of analysis.explanations) {
|
|
82
|
+
const where = `explanation "${explanation.id}"`;
|
|
83
|
+
const location = explanation.location;
|
|
84
|
+
if (location.startLine > location.endLine) {
|
|
85
|
+
problems.push(`${where} has startLine ${location.startLine} after endLine ${location.endLine}`);
|
|
86
|
+
}
|
|
87
|
+
if (explanation.status !== "current")
|
|
88
|
+
continue;
|
|
89
|
+
const file = files.get(location.path);
|
|
90
|
+
if (!file) {
|
|
91
|
+
problems.push(`${where} describes "${location.path}", which is not a changed file of this analysis`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (location.side !== "new")
|
|
95
|
+
continue;
|
|
96
|
+
if (!file.contentAvailable) {
|
|
97
|
+
problems.push(`${where} describes new-side lines of "${location.path}", whose content is not available`);
|
|
98
|
+
}
|
|
99
|
+
else if (location.endLine > file.lineCount) {
|
|
100
|
+
problems.push(`${where} describes lines ${location.startLine}-${location.endLine} of "${location.path}", ` +
|
|
101
|
+
`which has ${file.lineCount} line(s)`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
78
104
|
for (const diagram of analysis.diagrams) {
|
|
79
105
|
const where = `diagram "${diagram.id}"`;
|
|
80
106
|
const nodeIds = new Set(diagram.nodes.map((node) => node.id));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { FileEntry } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
-
import type { DiffSnapshot } from "../../shared/schemas/diff.schema.js";
|
|
1
|
+
import type { ExplanationLocation, ExplanationSide, FileEntry } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
import type { DiffSnapshot, FileDiff } from "../../shared/schemas/diff.schema.js";
|
|
3
3
|
export interface LocationRequest {
|
|
4
4
|
path: string;
|
|
5
5
|
startLine: number;
|
|
@@ -17,9 +17,27 @@ export interface ResolvedLocation {
|
|
|
17
17
|
* Vérifie un emplacement (lignes du côté « nouveau ») contre le snapshot et
|
|
18
18
|
* renvoie le texte des lignes visées. Erreur explicite si le fichier n'est pas
|
|
19
19
|
* dans l'analyse, si son contenu n'est pas disponible ou si les lignes sortent
|
|
20
|
-
* du fichier.
|
|
20
|
+
* du fichier ; `unavailableHint` complète le message d'un contenu indisponible.
|
|
21
21
|
*/
|
|
22
|
-
export declare function resolveLocation(snapshot: DiffSnapshot, files: FileEntry[], request: LocationRequest): ResolvedLocation;
|
|
22
|
+
export declare function resolveLocation(snapshot: DiffSnapshot, files: FileEntry[], request: LocationRequest, unavailableHint?: string): ResolvedLocation;
|
|
23
|
+
/**
|
|
24
|
+
* Texte actuel des lignes décrites par une explication, ou null quand elles ne
|
|
25
|
+
* sont plus lisibles : côté `new`, dans le contenu conservé du fichier ; côté
|
|
26
|
+
* `old`, dans les lignes du côté « ancien » que montre le diff.
|
|
27
|
+
*/
|
|
28
|
+
export declare function explanationText(diff: FileDiff | undefined, location: ExplanationLocation): string | null;
|
|
29
|
+
export interface ExplanationLocationRequest extends LocationRequest {
|
|
30
|
+
side: ExplanationSide;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Vérifie l'emplacement d'une explication et renvoie le texte des lignes décrites.
|
|
34
|
+
* - côté `new` : mêmes règles qu'un constat ;
|
|
35
|
+
* - côté `old` : toutes les lignes doivent figurer dans le diff (numéros de la
|
|
36
|
+
* première colonne de get_diff), et au moins une doit être supprimée.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveExplanationLocation(snapshot: DiffSnapshot, files: FileEntry[], request: ExplanationLocationRequest): ExplanationLocation & {
|
|
39
|
+
anchorText: string;
|
|
40
|
+
};
|
|
23
41
|
/**
|
|
24
42
|
* Vérifie l'emplacement d'une remarque : un fichier de l'analyse, et une ligne
|
|
25
43
|
* du côté « nouveau » quand `line` est fournie.
|
|
@@ -14,13 +14,15 @@ function unavailableReason(file) {
|
|
|
14
14
|
return "it is binary";
|
|
15
15
|
return "it is larger than the snapshot size limit";
|
|
16
16
|
}
|
|
17
|
+
/** Indication donnée à un constat dont le fichier n'a pas de contenu conservé. */
|
|
18
|
+
const FINDING_UNAVAILABLE_HINT = "Anchor the point on lines of another changed file, or omit the location (allowed for requirement_gap findings).";
|
|
17
19
|
/**
|
|
18
20
|
* Vérifie un emplacement (lignes du côté « nouveau ») contre le snapshot et
|
|
19
21
|
* renvoie le texte des lignes visées. Erreur explicite si le fichier n'est pas
|
|
20
22
|
* dans l'analyse, si son contenu n'est pas disponible ou si les lignes sortent
|
|
21
|
-
* du fichier.
|
|
23
|
+
* du fichier ; `unavailableHint` complète le message d'un contenu indisponible.
|
|
22
24
|
*/
|
|
23
|
-
export function resolveLocation(snapshot, files, request) {
|
|
25
|
+
export function resolveLocation(snapshot, files, request, unavailableHint = FINDING_UNAVAILABLE_HINT) {
|
|
24
26
|
const endLine = request.endLine ?? request.startLine;
|
|
25
27
|
const { path, startLine } = request;
|
|
26
28
|
const file = files.find((entry) => entry.path === path);
|
|
@@ -32,8 +34,7 @@ export function resolveLocation(snapshot, files, request) {
|
|
|
32
34
|
}
|
|
33
35
|
const diff = snapshot.files.find((entry) => entry.path === path);
|
|
34
36
|
if (!file.contentAvailable || !diff || diff.newContent === null) {
|
|
35
|
-
throw new AnalysisError(`Cannot anchor on lines of "${path}": its content is not available because ${unavailableReason(file)}. `
|
|
36
|
-
`Anchor the point on lines of another changed file, or omit the location (allowed for requirement_gap findings).`);
|
|
37
|
+
throw new AnalysisError(`Cannot anchor on lines of "${path}": its content is not available because ${unavailableReason(file)}. ${unavailableHint}`);
|
|
37
38
|
}
|
|
38
39
|
const lines = splitLines(diff.newContent);
|
|
39
40
|
if (endLine > lines.length) {
|
|
@@ -42,6 +43,89 @@ export function resolveLocation(snapshot, files, request) {
|
|
|
42
43
|
}
|
|
43
44
|
return { path, startLine, endLine, anchorText: lines.slice(startLine - 1, endLine).join("\n") };
|
|
44
45
|
}
|
|
46
|
+
/** Texte des lignes du côté « ancien » que montre le diff (lignes supprimées et contexte), par numéro. */
|
|
47
|
+
function oldSideLines(diff) {
|
|
48
|
+
const lines = new Map();
|
|
49
|
+
for (const hunk of diff.hunks) {
|
|
50
|
+
for (const line of hunk.lines) {
|
|
51
|
+
if (line.oldNo !== null)
|
|
52
|
+
lines.set(line.oldNo, { text: line.text, removed: line.type === "del" });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Texte actuel des lignes décrites par une explication, ou null quand elles ne
|
|
59
|
+
* sont plus lisibles : côté `new`, dans le contenu conservé du fichier ; côté
|
|
60
|
+
* `old`, dans les lignes du côté « ancien » que montre le diff.
|
|
61
|
+
*/
|
|
62
|
+
export function explanationText(diff, location) {
|
|
63
|
+
if (!diff || location.startLine > location.endLine)
|
|
64
|
+
return null;
|
|
65
|
+
if (location.side === "new") {
|
|
66
|
+
if (diff.newContent === null)
|
|
67
|
+
return null;
|
|
68
|
+
const lines = splitLines(diff.newContent);
|
|
69
|
+
if (location.startLine < 1 || location.endLine > lines.length)
|
|
70
|
+
return null;
|
|
71
|
+
return lines.slice(location.startLine - 1, location.endLine).join("\n");
|
|
72
|
+
}
|
|
73
|
+
const old = oldSideLines(diff);
|
|
74
|
+
const texts = [];
|
|
75
|
+
for (let line = location.startLine; line <= location.endLine; line++) {
|
|
76
|
+
const entry = old.get(line);
|
|
77
|
+
if (!entry)
|
|
78
|
+
return null;
|
|
79
|
+
texts.push(entry.text);
|
|
80
|
+
}
|
|
81
|
+
return texts.join("\n");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Vérifie l'emplacement d'une explication et renvoie le texte des lignes décrites.
|
|
85
|
+
* - côté `new` : mêmes règles qu'un constat ;
|
|
86
|
+
* - côté `old` : toutes les lignes doivent figurer dans le diff (numéros de la
|
|
87
|
+
* première colonne de get_diff), et au moins une doit être supprimée.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveExplanationLocation(snapshot, files, request) {
|
|
90
|
+
if (request.side === "new") {
|
|
91
|
+
const resolved = resolveLocation(snapshot, files, request, `Explain its removed lines on the old side ("side": "old"), or explain lines of another changed file.`);
|
|
92
|
+
return { path: resolved.path, side: "new", startLine: resolved.startLine, endLine: resolved.endLine, anchorText: resolved.anchorText };
|
|
93
|
+
}
|
|
94
|
+
const endLine = request.endLine ?? request.startLine;
|
|
95
|
+
const { path, startLine } = request;
|
|
96
|
+
if (!files.some((entry) => entry.path === path)) {
|
|
97
|
+
throw new AnalysisError(`File "${path}" is not part of this analysis (${describeFiles(files)}).`);
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
|
|
100
|
+
throw new AnalysisError(`Invalid line range ${startLine}-${endLine} for "${path}": lines are 1-based integers and end_line must be >= start_line.`);
|
|
101
|
+
}
|
|
102
|
+
const diff = snapshot.files.find((entry) => entry.path === path);
|
|
103
|
+
const old = diff ? oldSideLines(diff) : new Map();
|
|
104
|
+
const missing = [];
|
|
105
|
+
let removed = false;
|
|
106
|
+
for (let line = startLine; line <= endLine; line++) {
|
|
107
|
+
const entry = old.get(line);
|
|
108
|
+
if (!entry)
|
|
109
|
+
missing.push(line);
|
|
110
|
+
else if (entry.removed)
|
|
111
|
+
removed = true;
|
|
112
|
+
}
|
|
113
|
+
if (missing.length > 0) {
|
|
114
|
+
throw new AnalysisError(`Old-side lines ${startLine}-${endLine} of "${path}" are not all in the diff (missing: ${lineList(missing)}). ` +
|
|
115
|
+
`Old-side numbers are those of the first column of get_diff; an old-side explanation covers removed lines and their context.`);
|
|
116
|
+
}
|
|
117
|
+
if (!removed) {
|
|
118
|
+
throw new AnalysisError(`Old-side lines ${startLine}-${endLine} of "${path}" contain no removed line. ` +
|
|
119
|
+
`The old side is for removed code: explain kept or added code on the new side ("side": "new").`);
|
|
120
|
+
}
|
|
121
|
+
const location = { path, side: "old", startLine, endLine };
|
|
122
|
+
return { ...location, anchorText: explanationText(diff, location) };
|
|
123
|
+
}
|
|
124
|
+
/** `3, 4, 9` ; au-delà de dix numéros, les dix premiers suivis de `…`. */
|
|
125
|
+
function lineList(lines) {
|
|
126
|
+
const shown = lines.slice(0, 10).join(", ");
|
|
127
|
+
return lines.length > 10 ? `${shown}, …` : shown;
|
|
128
|
+
}
|
|
45
129
|
/**
|
|
46
130
|
* Vérifie l'emplacement d'une remarque : un fichier de l'analyse, et une ligne
|
|
47
131
|
* du côté « nouveau » quand `line` est fournie.
|
|
@@ -12,6 +12,8 @@ export declare function repoFolderName(repoPath: string): string;
|
|
|
12
12
|
export declare const addProject: MigrationStep;
|
|
13
13
|
/** Ajoute `overview` à null : l'analyse n'a pas encore de vue d'ensemble. */
|
|
14
14
|
export declare const addOverview: MigrationStep;
|
|
15
|
+
/** Ajoute `explanations` vide : l'analyse n'a pas encore d'explication. */
|
|
16
|
+
export declare const addExplanations: MigrationStep;
|
|
15
17
|
/** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
|
|
16
18
|
export declare const ANALYSIS_MIGRATIONS: readonly MigrationStep[];
|
|
17
19
|
/** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
|
package/dist/src/core/migrate.js
CHANGED
|
@@ -24,8 +24,10 @@ export const addProject = (doc) => {
|
|
|
24
24
|
};
|
|
25
25
|
/** Ajoute `overview` à null : l'analyse n'a pas encore de vue d'ensemble. */
|
|
26
26
|
export const addOverview = (doc) => (doc.overview !== undefined ? null : { ...doc, overview: null });
|
|
27
|
+
/** Ajoute `explanations` vide : l'analyse n'a pas encore d'explication. */
|
|
28
|
+
export const addExplanations = (doc) => (doc.explanations !== undefined ? null : { ...doc, explanations: [] });
|
|
27
29
|
/** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
|
|
28
|
-
export const ANALYSIS_MIGRATIONS = [addProject, addOverview];
|
|
30
|
+
export const ANALYSIS_MIGRATIONS = [addProject, addOverview, addExplanations];
|
|
29
31
|
/** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
|
|
30
32
|
export const SNAPSHOT_MIGRATIONS = [];
|
|
31
33
|
/** Applique `steps` à la suite. Renvoie null si aucune étape n'a modifié le document. */
|
|
@@ -14,9 +14,13 @@ export interface RefreshStats {
|
|
|
14
14
|
findingsOutdated: number;
|
|
15
15
|
/** Constats `outdated` revenus à `open`. */
|
|
16
16
|
findingsRestored: number;
|
|
17
|
+
/** Explications `current` passées à `outdated`. */
|
|
18
|
+
explanationsOutdated: number;
|
|
19
|
+
/** Explications `outdated` revenues à `current`. */
|
|
20
|
+
explanationsRestored: number;
|
|
17
21
|
}
|
|
18
22
|
export interface RefreshResult {
|
|
19
|
-
fields: Pick<Analysis, "baseCommit" | "headCommit" | "snapshotAt" | "files" | "findings">;
|
|
23
|
+
fields: Pick<Analysis, "baseCommit" | "headCommit" | "snapshotAt" | "files" | "findings" | "explanations">;
|
|
20
24
|
stats: RefreshStats;
|
|
21
25
|
}
|
|
22
26
|
/**
|
|
@@ -25,6 +29,8 @@ export interface RefreshResult {
|
|
|
25
29
|
* - un constat `open` dont les lignes ne correspondent plus à `anchorText`, ou dont le
|
|
26
30
|
* fichier a quitté le diff, passe à `outdated` ;
|
|
27
31
|
* - un constat `outdated` dont les lignes correspondent de nouveau revient à `open` ;
|
|
28
|
-
* - un constat `ignored` reste `ignored`, un constat sans emplacement ne change pas
|
|
32
|
+
* - un constat `ignored` reste `ignored`, un constat sans emplacement ne change pas ;
|
|
33
|
+
* - une explication dont les lignes ne correspondent plus à `anchorText` passe à
|
|
34
|
+
* `outdated`, et revient à `current` quand elles correspondent de nouveau.
|
|
29
35
|
*/
|
|
30
36
|
export declare function applyRefresh(analysis: Analysis, oldSnapshot: DiffSnapshot, next: ComputedSnapshot): RefreshResult;
|
package/dist/src/core/refresh.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { splitLines } from "../../shared/text.js";
|
|
2
2
|
import { AnalysisError } from "./errors.js";
|
|
3
|
+
import { explanationText } from "./locations.js";
|
|
3
4
|
function hunksEqual(a, b) {
|
|
4
5
|
if (a.length !== b.length)
|
|
5
6
|
return false;
|
|
@@ -48,7 +49,9 @@ function currentText(finding, diffs) {
|
|
|
48
49
|
* - un constat `open` dont les lignes ne correspondent plus à `anchorText`, ou dont le
|
|
49
50
|
* fichier a quitté le diff, passe à `outdated` ;
|
|
50
51
|
* - un constat `outdated` dont les lignes correspondent de nouveau revient à `open` ;
|
|
51
|
-
* - un constat `ignored` reste `ignored`, un constat sans emplacement ne change pas
|
|
52
|
+
* - un constat `ignored` reste `ignored`, un constat sans emplacement ne change pas ;
|
|
53
|
+
* - une explication dont les lignes ne correspondent plus à `anchorText` passe à
|
|
54
|
+
* `outdated`, et revient à `current` quand elles correspondent de nouveau.
|
|
52
55
|
*/
|
|
53
56
|
export function applyRefresh(analysis, oldSnapshot, next) {
|
|
54
57
|
if (next.repoPath !== analysis.repoPath || next.mode !== analysis.mode || next.base !== analysis.base || next.head !== analysis.head) {
|
|
@@ -65,6 +68,8 @@ export function applyRefresh(analysis, oldSnapshot, next) {
|
|
|
65
68
|
reviewedReset: 0,
|
|
66
69
|
findingsOutdated: 0,
|
|
67
70
|
findingsRestored: 0,
|
|
71
|
+
explanationsOutdated: 0,
|
|
72
|
+
explanationsRestored: 0,
|
|
68
73
|
};
|
|
69
74
|
const files = next.files.map((entry) => {
|
|
70
75
|
const previous = oldEntries.get(entry.path);
|
|
@@ -100,6 +105,18 @@ export function applyRefresh(analysis, oldSnapshot, next) {
|
|
|
100
105
|
}
|
|
101
106
|
return finding;
|
|
102
107
|
});
|
|
108
|
+
const explanations = analysis.explanations.map((explanation) => {
|
|
109
|
+
const matches = explanationText(newDiffs.get(explanation.location.path), explanation.location) === explanation.anchorText;
|
|
110
|
+
if (explanation.status === "current" && !matches) {
|
|
111
|
+
stats.explanationsOutdated++;
|
|
112
|
+
return { ...explanation, status: "outdated" };
|
|
113
|
+
}
|
|
114
|
+
if (explanation.status === "outdated" && matches) {
|
|
115
|
+
stats.explanationsRestored++;
|
|
116
|
+
return { ...explanation, status: "current" };
|
|
117
|
+
}
|
|
118
|
+
return explanation;
|
|
119
|
+
});
|
|
103
120
|
return {
|
|
104
121
|
fields: {
|
|
105
122
|
baseCommit: next.baseCommit,
|
|
@@ -107,6 +124,7 @@ export function applyRefresh(analysis, oldSnapshot, next) {
|
|
|
107
124
|
snapshotAt: next.snapshotAt,
|
|
108
125
|
files,
|
|
109
126
|
findings,
|
|
127
|
+
explanations,
|
|
110
128
|
},
|
|
111
129
|
stats,
|
|
112
130
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Analysis } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
-
import type { AddNoteBody, DeleteNoteBody, SetFileReviewedBody, SetFindingStatusBody, SubmitReviewBody } from "../../shared/schemas/api.schema.js";
|
|
2
|
+
import type { AddNoteBody, DeleteNoteBody, SetFileReviewedBody, SetFindingStatusBody, SubmitReviewBody, UpdateNoteBody } from "../../shared/schemas/api.schema.js";
|
|
3
3
|
import type { AnalysisStore } from "./analysis-store.js";
|
|
4
4
|
/**
|
|
5
5
|
* Modifications faites par le relecteur depuis la GUI. Chacune passe par
|
|
@@ -15,6 +15,11 @@ export declare function setFileReviewed(store: AnalysisStore, analysisId: string
|
|
|
15
15
|
export declare function setFindingStatus(store: AnalysisStore, analysisId: string, findingId: string, body: SetFindingStatusBody): Analysis;
|
|
16
16
|
/** Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier. */
|
|
17
17
|
export declare function addNote(store: AnalysisStore, analysisId: string, body: AddNoteBody): Analysis;
|
|
18
|
+
/**
|
|
19
|
+
* Remplace le texte d'une remarque. Quand elle fait partie d'une revue soumise,
|
|
20
|
+
* le prompt enregistré est recalculé avec le nouveau texte.
|
|
21
|
+
*/
|
|
22
|
+
export declare function updateNote(store: AnalysisStore, analysisId: string, noteId: string, body: UpdateNoteBody): Analysis;
|
|
18
23
|
/**
|
|
19
24
|
* Supprime une remarque. Quand elle fait partie d'une revue soumise, elle est
|
|
20
25
|
* retirée de la sélection et le prompt enregistré est recalculé sans elle.
|
|
@@ -47,6 +47,26 @@ export function addNote(store, analysisId, body) {
|
|
|
47
47
|
});
|
|
48
48
|
}, { baseRevision: body.baseRevision });
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Remplace le texte d'une remarque. Quand elle fait partie d'une revue soumise,
|
|
52
|
+
* le prompt enregistré est recalculé avec le nouveau texte.
|
|
53
|
+
*/
|
|
54
|
+
export function updateNote(store, analysisId, noteId, body) {
|
|
55
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
56
|
+
const note = draft.notes.find((entry) => entry.id === noteId);
|
|
57
|
+
if (!note)
|
|
58
|
+
throw new NotFoundError(`Note "${noteId}" not found in analysis "${analysisId}".`);
|
|
59
|
+
note.text = body.text;
|
|
60
|
+
const review = draft.review;
|
|
61
|
+
if (review.state === "submitted" && review.selectedNoteIds.includes(noteId)) {
|
|
62
|
+
review.prompt = buildAgentPrompt(draft, {
|
|
63
|
+
findingIds: review.selectedFindingIds,
|
|
64
|
+
noteIds: review.selectedNoteIds,
|
|
65
|
+
decision: review.decision,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}, { baseRevision: body.baseRevision });
|
|
69
|
+
}
|
|
50
70
|
/**
|
|
51
71
|
* Supprime une remarque. Quand elle fait partie d'une revue soumise, elle est
|
|
52
72
|
* retirée de la sélection et le prompt enregistré est recalculé sans elle.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rôle du serveur, fixé par la variable MCP_FEATURE_ANALYZER_ROLE.
|
|
3
|
+
*
|
|
4
|
+
* - `standalone` (défaut) : le serveur tourne sur la machine qui porte les dépôts et y
|
|
5
|
+
* exécute git lui-même.
|
|
6
|
+
* - `remote` : le serveur tourne derrière mcp-http-gateway, sur une autre machine ; git
|
|
7
|
+
* et la lecture des fichiers s'exécutent sur la machine de l'agent, par le relais agent
|
|
8
|
+
* (`mcp-http-gateway --mcp`, route listée dans GATEWAY_EXEC_ROUTES).
|
|
9
|
+
*/
|
|
10
|
+
export type Role = "standalone" | "remote";
|
|
11
|
+
export declare function resolveRole(): Role;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const ROLES = ["standalone", "remote"];
|
|
2
|
+
export function resolveRole() {
|
|
3
|
+
const raw = process.env.MCP_FEATURE_ANALYZER_ROLE?.trim();
|
|
4
|
+
if (!raw)
|
|
5
|
+
return "standalone";
|
|
6
|
+
if (ROLES.includes(raw))
|
|
7
|
+
return raw;
|
|
8
|
+
throw new Error(`MCP_FEATURE_ANALYZER_ROLE="${raw}" is invalid (expected: ${ROLES.join(" | ")}).`);
|
|
9
|
+
}
|
package/dist/src/gui-worker.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from "path";
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import { IPCMessageSchema, PUSHED_EVENT_TYPES } from "../shared/schemas/ipc.schema.js";
|
|
10
|
-
import { AddNoteBodySchema, DeleteNoteBodySchema, SetFileReviewedBodySchema, SetFindingStatusBodySchema, SubmitReviewBodySchema, } from "../shared/schemas/api.schema.js";
|
|
10
|
+
import { AddNoteBodySchema, DeleteNoteBodySchema, SetFileReviewedBodySchema, SetFindingStatusBodySchema, SubmitReviewBodySchema, UpdateNoteBodySchema, } from "../shared/schemas/api.schema.js";
|
|
11
11
|
import { resolveLogDir } from "./core/log-dir.js";
|
|
12
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
function findProjectRoot(startDir) {
|
|
@@ -256,6 +256,12 @@ app.post("/api/analyses/:id/notes", async (c) => {
|
|
|
256
256
|
return read.response;
|
|
257
257
|
return forward(c, "ADD_NOTE", { analysisId: c.req.param("id"), body: read.body });
|
|
258
258
|
});
|
|
259
|
+
app.patch("/api/analyses/:id/notes/:noteId", async (c) => {
|
|
260
|
+
const read = await readBody(c, UpdateNoteBodySchema);
|
|
261
|
+
if ("response" in read)
|
|
262
|
+
return read.response;
|
|
263
|
+
return forward(c, "UPDATE_NOTE", { analysisId: c.req.param("id"), noteId: c.req.param("noteId"), body: read.body });
|
|
264
|
+
});
|
|
259
265
|
app.delete("/api/analyses/:id/notes/:noteId", async (c) => {
|
|
260
266
|
const read = await readBody(c, DeleteNoteBodySchema);
|
|
261
267
|
if ("response" in read)
|