@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.
- package/LICENSE +15 -0
- package/README.md +295 -0
- package/dist/shared/diagram-geometry.d.ts +45 -0
- package/dist/shared/diagram-geometry.js +120 -0
- package/dist/shared/diagram-layout.d.ts +98 -0
- package/dist/shared/diagram-layout.js +1156 -0
- package/dist/shared/diagram-quality.d.ts +30 -0
- package/dist/shared/diagram-quality.js +26 -0
- package/dist/shared/labels.d.ts +31 -0
- package/dist/shared/labels.js +53 -0
- package/dist/shared/mindmap-tree.d.ts +28 -0
- package/dist/shared/mindmap-tree.js +62 -0
- package/dist/shared/prompt.d.ts +17 -0
- package/dist/shared/prompt.js +97 -0
- package/dist/shared/review-state.d.ts +10 -0
- package/dist/shared/review-state.js +17 -0
- package/dist/shared/schemas/analysis.schema.d.ts +372 -0
- package/dist/shared/schemas/analysis.schema.js +152 -0
- package/dist/shared/schemas/api.schema.d.ts +92 -0
- package/dist/shared/schemas/api.schema.js +46 -0
- package/dist/shared/schemas/diff.schema.d.ts +91 -0
- package/dist/shared/schemas/diff.schema.js +37 -0
- package/dist/shared/schemas/ipc.schema.d.ts +29 -0
- package/dist/shared/schemas/ipc.schema.js +30 -0
- package/dist/shared/severity.d.ts +11 -0
- package/dist/shared/severity.js +17 -0
- package/dist/shared/text.d.ts +12 -0
- package/dist/shared/text.js +20 -0
- package/dist/src/cli/setup-mcp.d.ts +24 -0
- package/dist/src/cli/setup-mcp.js +145 -0
- package/dist/src/core/analysis-store.d.ts +95 -0
- package/dist/src/core/analysis-store.js +387 -0
- package/dist/src/core/data-dir.d.ts +5 -0
- package/dist/src/core/data-dir.js +11 -0
- package/dist/src/core/diff-parser.d.ts +22 -0
- package/dist/src/core/diff-parser.js +225 -0
- package/dist/src/core/errors.d.ts +16 -0
- package/dist/src/core/errors.js +28 -0
- package/dist/src/core/git.d.ts +35 -0
- package/dist/src/core/git.js +257 -0
- package/dist/src/core/ids.d.ts +16 -0
- package/dist/src/core/ids.js +41 -0
- package/dist/src/core/invariants.d.ts +24 -0
- package/dist/src/core/invariants.js +157 -0
- package/dist/src/core/locations.d.ts +30 -0
- package/dist/src/core/locations.js +66 -0
- package/dist/src/core/log-dir.d.ts +6 -0
- package/dist/src/core/log-dir.js +13 -0
- package/dist/src/core/migrate.d.ts +24 -0
- package/dist/src/core/migrate.js +53 -0
- package/dist/src/core/refresh.d.ts +30 -0
- package/dist/src/core/refresh.js +113 -0
- package/dist/src/core/request-guard.d.ts +32 -0
- package/dist/src/core/request-guard.js +72 -0
- package/dist/src/core/review-actions.d.ts +27 -0
- package/dist/src/core/review-actions.js +99 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +401 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +658 -0
- package/dist/src/tools/add-findings.d.ts +9 -0
- package/dist/src/tools/add-findings.js +106 -0
- package/dist/src/tools/create-analysis.d.ts +4 -0
- package/dist/src/tools/create-analysis.js +36 -0
- package/dist/src/tools/delete-analysis.d.ts +4 -0
- package/dist/src/tools/delete-analysis.js +11 -0
- package/dist/src/tools/delete-diagram.d.ts +4 -0
- package/dist/src/tools/delete-diagram.js +22 -0
- package/dist/src/tools/delete-findings.d.ts +7 -0
- package/dist/src/tools/delete-findings.js +27 -0
- package/dist/src/tools/format.d.ts +27 -0
- package/dist/src/tools/format.js +92 -0
- package/dist/src/tools/get-analysis.d.ts +4 -0
- package/dist/src/tools/get-analysis.js +87 -0
- package/dist/src/tools/get-diff.d.ts +4 -0
- package/dist/src/tools/get-diff.js +67 -0
- package/dist/src/tools/get-review-feedback.d.ts +4 -0
- package/dist/src/tools/get-review-feedback.js +71 -0
- package/dist/src/tools/inputs.d.ts +14 -0
- package/dist/src/tools/inputs.js +40 -0
- package/dist/src/tools/list-analyses.d.ts +8 -0
- package/dist/src/tools/list-analyses.js +71 -0
- package/dist/src/tools/refresh-analysis.d.ts +7 -0
- package/dist/src/tools/refresh-analysis.js +39 -0
- package/dist/src/tools/set-diagram.d.ts +16 -0
- package/dist/src/tools/set-diagram.js +207 -0
- package/dist/src/tools/types.d.ts +52 -0
- package/dist/src/tools/types.js +133 -0
- package/dist/src/tools/update-analysis.d.ts +4 -0
- package/dist/src/tools/update-analysis.js +121 -0
- package/dist/src/tools/update-finding.d.ts +8 -0
- package/dist/src/tools/update-finding.js +91 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +2 -0
- package/gui/dist/assets/index-CU9pNYDp.js +72 -0
- package/gui/dist/assets/index-Du5eL78F.css +1 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +56 -0
- package/scripts/generate-version.js +20 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { resolveDataDir } from "./data-dir.js";
|
|
3
|
+
/**
|
|
4
|
+
* Répertoire des logs : MCP_LOG_DIR s'il est renseigné, sinon <dataDir>/logs.
|
|
5
|
+
* Lu depuis process.env au démarrage, avant le chargement du .env du package :
|
|
6
|
+
* la variable vient donc de la déclaration du serveur dans le client MCP.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveLogDir(rootDir) {
|
|
9
|
+
const configured = process.env.MCP_LOG_DIR?.trim();
|
|
10
|
+
if (configured)
|
|
11
|
+
return path.resolve(configured);
|
|
12
|
+
return path.join(resolveDataDir(rootDir), "logs");
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mises à niveau des fichiers de données, appliquées à la lecture avant la
|
|
3
|
+
* validation de schéma. Chaque étape est idempotente : elle renvoie null quand
|
|
4
|
+
* le document est déjà dans le format qu'elle produit.
|
|
5
|
+
*/
|
|
6
|
+
export type RawDocument = Record<string, unknown>;
|
|
7
|
+
/** Étape de migration : document mis à niveau, ou null si elle n'a rien à changer. */
|
|
8
|
+
export type MigrationStep = (raw: RawDocument) => RawDocument | null;
|
|
9
|
+
/** Dernier segment d'un chemin POSIX ou Windows, séparateurs finaux ignorés ; chaîne vide pour une racine. */
|
|
10
|
+
export declare function repoFolderName(repoPath: string): string;
|
|
11
|
+
/** Ajoute `project` : le nom du dossier du dépôt (`basename(repoPath)`). */
|
|
12
|
+
export declare const addProject: MigrationStep;
|
|
13
|
+
/** Ajoute `overview` à null : l'analyse n'a pas encore de vue d'ensemble. */
|
|
14
|
+
export declare const addOverview: MigrationStep;
|
|
15
|
+
/** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
|
|
16
|
+
export declare const ANALYSIS_MIGRATIONS: readonly MigrationStep[];
|
|
17
|
+
/** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
|
|
18
|
+
export declare const SNAPSHOT_MIGRATIONS: readonly MigrationStep[];
|
|
19
|
+
/** Applique `steps` à la suite. Renvoie null si aucune étape n'a modifié le document. */
|
|
20
|
+
export declare function applyMigrations(raw: unknown, steps: readonly MigrationStep[]): RawDocument | null;
|
|
21
|
+
/** Met à niveau une analyse brute ; null si elle est déjà à jour. */
|
|
22
|
+
export declare function migrateAnalysis(raw: unknown): RawDocument | null;
|
|
23
|
+
/** Met à niveau un snapshot de diff brut ; null s'il est déjà à jour. */
|
|
24
|
+
export declare function migrateSnapshot(raw: unknown): RawDocument | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mises à niveau des fichiers de données, appliquées à la lecture avant la
|
|
3
|
+
* validation de schéma. Chaque étape est idempotente : elle renvoie null quand
|
|
4
|
+
* le document est déjà dans le format qu'elle produit.
|
|
5
|
+
*/
|
|
6
|
+
import { AnalysisError } from "./errors.js";
|
|
7
|
+
/** Dernier segment d'un chemin POSIX ou Windows, séparateurs finaux ignorés ; chaîne vide pour une racine. */
|
|
8
|
+
export function repoFolderName(repoPath) {
|
|
9
|
+
const parts = repoPath.split(/[\\/]+/).filter((part) => part !== "");
|
|
10
|
+
const last = parts[parts.length - 1] ?? "";
|
|
11
|
+
return /^[A-Za-z]:$/.test(last) ? "" : last.trim();
|
|
12
|
+
}
|
|
13
|
+
/** Ajoute `project` : le nom du dossier du dépôt (`basename(repoPath)`). */
|
|
14
|
+
export const addProject = (doc) => {
|
|
15
|
+
if (doc.project !== undefined)
|
|
16
|
+
return null;
|
|
17
|
+
const repoPath = doc.repoPath;
|
|
18
|
+
const project = typeof repoPath === "string" ? repoFolderName(repoPath) : "";
|
|
19
|
+
if (project === "") {
|
|
20
|
+
throw new AnalysisError(`Cannot derive the project of analysis "${String(doc.id)}" from its repoPath ${JSON.stringify(repoPath)}: ` +
|
|
21
|
+
`set "project" in the analysis file, or delete the analysis with delete_analysis and create it again.`);
|
|
22
|
+
}
|
|
23
|
+
return { ...doc, project };
|
|
24
|
+
};
|
|
25
|
+
/** Ajoute `overview` à null : l'analyse n'a pas encore de vue d'ensemble. */
|
|
26
|
+
export const addOverview = (doc) => (doc.overview !== undefined ? null : { ...doc, overview: null });
|
|
27
|
+
/** Étapes appliquées aux analyses (`analyses/<id>.json`), dans l'ordre. */
|
|
28
|
+
export const ANALYSIS_MIGRATIONS = [addProject, addOverview];
|
|
29
|
+
/** Étapes appliquées aux snapshots de diff (`diffs/<id>.json`), dans l'ordre. */
|
|
30
|
+
export const SNAPSHOT_MIGRATIONS = [];
|
|
31
|
+
/** Applique `steps` à la suite. Renvoie null si aucune étape n'a modifié le document. */
|
|
32
|
+
export function applyMigrations(raw, steps) {
|
|
33
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
34
|
+
return null;
|
|
35
|
+
let current = raw;
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const step of steps) {
|
|
38
|
+
const next = step(current);
|
|
39
|
+
if (next !== null) {
|
|
40
|
+
current = next;
|
|
41
|
+
changed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return changed ? current : null;
|
|
45
|
+
}
|
|
46
|
+
/** Met à niveau une analyse brute ; null si elle est déjà à jour. */
|
|
47
|
+
export function migrateAnalysis(raw) {
|
|
48
|
+
return applyMigrations(raw, ANALYSIS_MIGRATIONS);
|
|
49
|
+
}
|
|
50
|
+
/** Met à niveau un snapshot de diff brut ; null s'il est déjà à jour. */
|
|
51
|
+
export function migrateSnapshot(raw) {
|
|
52
|
+
return applyMigrations(raw, SNAPSHOT_MIGRATIONS);
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Analysis } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
import type { DiffSnapshot } from "../../shared/schemas/diff.schema.js";
|
|
3
|
+
import type { ComputedSnapshot } from "./git.js";
|
|
4
|
+
/** Bilan d'un recalcul, pour le message renvoyé à l'agent. */
|
|
5
|
+
export interface RefreshStats {
|
|
6
|
+
fileCount: number;
|
|
7
|
+
filesAdded: number;
|
|
8
|
+
filesRemoved: number;
|
|
9
|
+
/** Fichiers revus dont le diff est inchangé : ils restent revus. */
|
|
10
|
+
reviewedKept: number;
|
|
11
|
+
/** Fichiers revus toujours présents dont le diff a changé : ils repassent à « non revu ». */
|
|
12
|
+
reviewedReset: number;
|
|
13
|
+
/** Constats ouverts passés à `outdated`. */
|
|
14
|
+
findingsOutdated: number;
|
|
15
|
+
/** Constats `outdated` revenus à `open`. */
|
|
16
|
+
findingsRestored: number;
|
|
17
|
+
}
|
|
18
|
+
export interface RefreshResult {
|
|
19
|
+
fields: Pick<Analysis, "baseCommit" | "headCommit" | "snapshotAt" | "files" | "findings">;
|
|
20
|
+
stats: RefreshStats;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Calcule les champs d'une analyse après recalcul de son snapshot :
|
|
24
|
+
* - `reviewed` n'est conservé que pour les fichiers dont le diff est inchangé ;
|
|
25
|
+
* - un constat `open` dont les lignes ne correspondent plus à `anchorText`, ou dont le
|
|
26
|
+
* fichier a quitté le diff, passe à `outdated` ;
|
|
27
|
+
* - un constat `outdated` dont les lignes correspondent de nouveau revient à `open` ;
|
|
28
|
+
* - un constat `ignored` reste `ignored`, un constat sans emplacement ne change pas.
|
|
29
|
+
*/
|
|
30
|
+
export declare function applyRefresh(analysis: Analysis, oldSnapshot: DiffSnapshot, next: ComputedSnapshot): RefreshResult;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { splitLines } from "../../shared/text.js";
|
|
2
|
+
import { AnalysisError } from "./errors.js";
|
|
3
|
+
function hunksEqual(a, b) {
|
|
4
|
+
if (a.length !== b.length)
|
|
5
|
+
return false;
|
|
6
|
+
return a.every((hunk, index) => {
|
|
7
|
+
const other = b[index];
|
|
8
|
+
if (hunk.header !== other.header ||
|
|
9
|
+
hunk.oldStart !== other.oldStart ||
|
|
10
|
+
hunk.oldLines !== other.oldLines ||
|
|
11
|
+
hunk.newStart !== other.newStart ||
|
|
12
|
+
hunk.newLines !== other.newLines ||
|
|
13
|
+
hunk.lines.length !== other.lines.length) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return hunk.lines.every((line, i) => {
|
|
17
|
+
const o = other.lines[i];
|
|
18
|
+
return line.type === o.type && line.oldNo === o.oldNo && line.newNo === o.newNo && line.text === o.text;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Vrai quand le diff d'un fichier est identique d'un snapshot à l'autre. Un
|
|
24
|
+
* fichier binaire n'a pas de hunks comparables : son diff est toujours tenu
|
|
25
|
+
* pour changé.
|
|
26
|
+
*/
|
|
27
|
+
function sameFileDiff(oldEntry, oldDiff, newEntry, newDiff) {
|
|
28
|
+
if (!oldDiff || oldEntry.binary || newEntry.binary)
|
|
29
|
+
return false;
|
|
30
|
+
return oldEntry.status === newEntry.status && oldEntry.oldPath === newEntry.oldPath && hunksEqual(oldDiff.hunks, newDiff.hunks);
|
|
31
|
+
}
|
|
32
|
+
/** Texte actuel des lignes visées par un constat, ou null si elles ne sont plus lisibles. */
|
|
33
|
+
function currentText(finding, diffs) {
|
|
34
|
+
const location = finding.location;
|
|
35
|
+
if (!location)
|
|
36
|
+
return null;
|
|
37
|
+
const content = diffs.get(location.path)?.newContent;
|
|
38
|
+
if (content === null || content === undefined)
|
|
39
|
+
return null;
|
|
40
|
+
const lines = splitLines(content);
|
|
41
|
+
if (location.startLine < 1 || location.endLine > lines.length || location.startLine > location.endLine)
|
|
42
|
+
return null;
|
|
43
|
+
return lines.slice(location.startLine - 1, location.endLine).join("\n");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Calcule les champs d'une analyse après recalcul de son snapshot :
|
|
47
|
+
* - `reviewed` n'est conservé que pour les fichiers dont le diff est inchangé ;
|
|
48
|
+
* - un constat `open` dont les lignes ne correspondent plus à `anchorText`, ou dont le
|
|
49
|
+
* fichier a quitté le diff, passe à `outdated` ;
|
|
50
|
+
* - 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
|
+
*/
|
|
53
|
+
export function applyRefresh(analysis, oldSnapshot, next) {
|
|
54
|
+
if (next.repoPath !== analysis.repoPath || next.mode !== analysis.mode || next.base !== analysis.base || next.head !== analysis.head) {
|
|
55
|
+
throw new AnalysisError(`The new snapshot does not match analysis "${analysis.id}": compute it with the repository, mode and refs of the analysis.`);
|
|
56
|
+
}
|
|
57
|
+
const oldEntries = new Map(analysis.files.map((file) => [file.path, file]));
|
|
58
|
+
const oldDiffs = new Map(oldSnapshot.files.map((file) => [file.path, file]));
|
|
59
|
+
const newDiffs = new Map(next.diff.map((file) => [file.path, file]));
|
|
60
|
+
const stats = {
|
|
61
|
+
fileCount: next.files.length,
|
|
62
|
+
filesAdded: 0,
|
|
63
|
+
filesRemoved: 0,
|
|
64
|
+
reviewedKept: 0,
|
|
65
|
+
reviewedReset: 0,
|
|
66
|
+
findingsOutdated: 0,
|
|
67
|
+
findingsRestored: 0,
|
|
68
|
+
};
|
|
69
|
+
const files = next.files.map((entry) => {
|
|
70
|
+
const previous = oldEntries.get(entry.path);
|
|
71
|
+
if (!previous) {
|
|
72
|
+
stats.filesAdded++;
|
|
73
|
+
return { ...entry, reviewed: false };
|
|
74
|
+
}
|
|
75
|
+
if (!previous.reviewed)
|
|
76
|
+
return { ...entry, reviewed: false };
|
|
77
|
+
const newDiff = newDiffs.get(entry.path);
|
|
78
|
+
if (!newDiff)
|
|
79
|
+
throw new AnalysisError(`The new snapshot has no diff for "${entry.path}".`);
|
|
80
|
+
if (sameFileDiff(previous, oldDiffs.get(entry.path), entry, newDiff)) {
|
|
81
|
+
stats.reviewedKept++;
|
|
82
|
+
return { ...entry, reviewed: true };
|
|
83
|
+
}
|
|
84
|
+
stats.reviewedReset++;
|
|
85
|
+
return { ...entry, reviewed: false };
|
|
86
|
+
});
|
|
87
|
+
const newPaths = new Set(next.files.map((file) => file.path));
|
|
88
|
+
stats.filesRemoved = analysis.files.filter((file) => !newPaths.has(file.path)).length;
|
|
89
|
+
const findings = analysis.findings.map((finding) => {
|
|
90
|
+
if (finding.location === null || finding.status === "ignored")
|
|
91
|
+
return finding;
|
|
92
|
+
const matches = currentText(finding, newDiffs) === finding.anchorText;
|
|
93
|
+
if (finding.status === "open" && !matches) {
|
|
94
|
+
stats.findingsOutdated++;
|
|
95
|
+
return { ...finding, status: "outdated" };
|
|
96
|
+
}
|
|
97
|
+
if (finding.status === "outdated" && matches) {
|
|
98
|
+
stats.findingsRestored++;
|
|
99
|
+
return { ...finding, status: "open" };
|
|
100
|
+
}
|
|
101
|
+
return finding;
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
fields: {
|
|
105
|
+
baseCommit: next.baseCommit,
|
|
106
|
+
headCommit: next.headCommit,
|
|
107
|
+
snapshotAt: next.snapshotAt,
|
|
108
|
+
files,
|
|
109
|
+
findings,
|
|
110
|
+
},
|
|
111
|
+
stats,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Interface d'écoute du GUI worker : la boucle locale uniquement. */
|
|
2
|
+
export declare const LISTEN_HOST = "127.0.0.1";
|
|
3
|
+
/** Refus d'une requête : code HTTP et message renvoyé au client. */
|
|
4
|
+
export interface GuardRejection {
|
|
5
|
+
status: 403 | 415;
|
|
6
|
+
error: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Nom d'hôte d'un en-tête `Host` ou d'une URL d'origine, sans port ni crochets,
|
|
10
|
+
* en minuscules. Renvoie null quand la valeur n'est pas exploitable.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hostnameOf(hostHeader: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Noms d'hôte sous lesquels la GUI est légitimement appelée : boucle locale,
|
|
15
|
+
* nom et adresses de la machine (accès par le proxy depuis le réseau, qui
|
|
16
|
+
* transmet l'en-tête `Host` d'origine) et hôte du proxy.
|
|
17
|
+
*/
|
|
18
|
+
export declare function allowedHostnames(proxyUrl: string | undefined): Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Contrôle d'une requête entrante.
|
|
21
|
+
* - L'en-tête `Host` doit désigner un hôte autorisé : une page tierce qui fait
|
|
22
|
+
* pointer son domaine vers 127.0.0.1 (DNS rebinding) est refusée.
|
|
23
|
+
* - Une requête de modification exige `Content-Type: application/json`, qu'un
|
|
24
|
+
* navigateur ne peut envoyer vers une autre origine sans requête préalable
|
|
25
|
+
* CORS, refusée ici ; une origine différente de l'hôte appelé est refusée.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkRequest(request: {
|
|
28
|
+
method: string;
|
|
29
|
+
host: string | undefined;
|
|
30
|
+
origin: string | undefined;
|
|
31
|
+
contentType: string | undefined;
|
|
32
|
+
}, allowed: Set<string>): GuardRejection | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
/** Interface d'écoute du GUI worker : la boucle locale uniquement. */
|
|
3
|
+
export const LISTEN_HOST = "127.0.0.1";
|
|
4
|
+
/** Méthodes HTTP sans effet de bord, exemptées du contrôle des modifications. */
|
|
5
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
6
|
+
/**
|
|
7
|
+
* Nom d'hôte d'un en-tête `Host` ou d'une URL d'origine, sans port ni crochets,
|
|
8
|
+
* en minuscules. Renvoie null quand la valeur n'est pas exploitable.
|
|
9
|
+
*/
|
|
10
|
+
export function hostnameOf(hostHeader) {
|
|
11
|
+
const value = hostHeader.trim().toLowerCase();
|
|
12
|
+
if (value === "")
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return new URL(`http://${value}`).hostname.replace(/^\[(.*)\]$/, "$1");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Noms d'hôte sous lesquels la GUI est légitimement appelée : boucle locale,
|
|
23
|
+
* nom et adresses de la machine (accès par le proxy depuis le réseau, qui
|
|
24
|
+
* transmet l'en-tête `Host` d'origine) et hôte du proxy.
|
|
25
|
+
*/
|
|
26
|
+
export function allowedHostnames(proxyUrl) {
|
|
27
|
+
const names = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
28
|
+
names.add(os.hostname().toLowerCase());
|
|
29
|
+
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
30
|
+
for (const address of addresses ?? [])
|
|
31
|
+
names.add(address.address.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
if (proxyUrl) {
|
|
34
|
+
const proxyHost = hostnameOf(new URL(proxyUrl).host);
|
|
35
|
+
if (proxyHost !== null)
|
|
36
|
+
names.add(proxyHost);
|
|
37
|
+
}
|
|
38
|
+
return names;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Contrôle d'une requête entrante.
|
|
42
|
+
* - L'en-tête `Host` doit désigner un hôte autorisé : une page tierce qui fait
|
|
43
|
+
* pointer son domaine vers 127.0.0.1 (DNS rebinding) est refusée.
|
|
44
|
+
* - Une requête de modification exige `Content-Type: application/json`, qu'un
|
|
45
|
+
* navigateur ne peut envoyer vers une autre origine sans requête préalable
|
|
46
|
+
* CORS, refusée ici ; une origine différente de l'hôte appelé est refusée.
|
|
47
|
+
*/
|
|
48
|
+
export function checkRequest(request, allowed) {
|
|
49
|
+
const hostname = request.host === undefined ? null : hostnameOf(request.host);
|
|
50
|
+
if (hostname === null || !allowed.has(hostname)) {
|
|
51
|
+
return { status: 403, error: `Host "${request.host ?? ""}" is not allowed: open the GUI through the proxy or http://localhost.` };
|
|
52
|
+
}
|
|
53
|
+
if (SAFE_METHODS.has(request.method.toUpperCase()))
|
|
54
|
+
return null;
|
|
55
|
+
const mediaType = request.contentType?.split(";")[0].trim().toLowerCase();
|
|
56
|
+
if (mediaType !== "application/json") {
|
|
57
|
+
return { status: 415, error: `Content-Type must be application/json, got "${request.contentType ?? ""}".` };
|
|
58
|
+
}
|
|
59
|
+
if (request.origin === undefined)
|
|
60
|
+
return null;
|
|
61
|
+
let originHost;
|
|
62
|
+
try {
|
|
63
|
+
originHost = new URL(request.origin).host.toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { status: 403, error: `Cross-origin request refused: origin "${request.origin}" is not a valid URL.` };
|
|
67
|
+
}
|
|
68
|
+
if (originHost !== request.host.trim().toLowerCase()) {
|
|
69
|
+
return { status: 403, error: `Cross-origin request refused: origin "${request.origin}" does not match host "${request.host}".` };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Analysis } from "../../shared/schemas/analysis.schema.js";
|
|
2
|
+
import type { AddNoteBody, DeleteNoteBody, SetFileReviewedBody, SetFindingStatusBody, SubmitReviewBody } from "../../shared/schemas/api.schema.js";
|
|
3
|
+
import type { AnalysisStore } from "./analysis-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Modifications faites par le relecteur depuis la GUI. Chacune passe par
|
|
6
|
+
* `store.mutate(id, "user", …, { baseRevision })` et renvoie l'analyse écrite.
|
|
7
|
+
*/
|
|
8
|
+
/** Marque un fichier de l'analyse comme revu, ou le repasse à revoir. */
|
|
9
|
+
export declare function setFileReviewed(store: AnalysisStore, analysisId: string, body: SetFileReviewedBody): Analysis;
|
|
10
|
+
/**
|
|
11
|
+
* Ignore ou rouvre un constat. `outdated` est posé uniquement par le recalcul
|
|
12
|
+
* du snapshot, et un constat obsolète ne change de statut que lorsque l'agent
|
|
13
|
+
* le réancre.
|
|
14
|
+
*/
|
|
15
|
+
export declare function setFindingStatus(store: AnalysisStore, analysisId: string, findingId: string, body: SetFindingStatusBody): Analysis;
|
|
16
|
+
/** Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier. */
|
|
17
|
+
export declare function addNote(store: AnalysisStore, analysisId: string, body: AddNoteBody): Analysis;
|
|
18
|
+
/**
|
|
19
|
+
* Supprime une remarque. Quand elle fait partie d'une revue soumise, elle est
|
|
20
|
+
* retirée de la sélection et le prompt enregistré est recalculé sans elle.
|
|
21
|
+
*/
|
|
22
|
+
export declare function deleteNote(store: AnalysisStore, analysisId: string, noteId: string, body: DeleteNoteBody): Analysis;
|
|
23
|
+
/**
|
|
24
|
+
* Soumet la revue : décision, points retenus et prompt calculé ici. Une
|
|
25
|
+
* nouvelle soumission remplace la précédente.
|
|
26
|
+
*/
|
|
27
|
+
export declare function submitReview(store: AnalysisStore, analysisId: string, body: SubmitReviewBody): Analysis;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { buildAgentPrompt } from "../../shared/prompt.js";
|
|
2
|
+
import { AnalysisError, NotFoundError } from "./errors.js";
|
|
3
|
+
import { itemId } from "./ids.js";
|
|
4
|
+
import { assertNoteLocation } from "./locations.js";
|
|
5
|
+
/**
|
|
6
|
+
* Modifications faites par le relecteur depuis la GUI. Chacune passe par
|
|
7
|
+
* `store.mutate(id, "user", …, { baseRevision })` et renvoie l'analyse écrite.
|
|
8
|
+
*/
|
|
9
|
+
/** Marque un fichier de l'analyse comme revu, ou le repasse à revoir. */
|
|
10
|
+
export function setFileReviewed(store, analysisId, body) {
|
|
11
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
12
|
+
const file = draft.files.find((entry) => entry.path === body.path);
|
|
13
|
+
if (!file)
|
|
14
|
+
throw new NotFoundError(`File "${body.path}" is not part of analysis "${analysisId}".`);
|
|
15
|
+
file.reviewed = body.reviewed;
|
|
16
|
+
}, { baseRevision: body.baseRevision });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ignore ou rouvre un constat. `outdated` est posé uniquement par le recalcul
|
|
20
|
+
* du snapshot, et un constat obsolète ne change de statut que lorsque l'agent
|
|
21
|
+
* le réancre.
|
|
22
|
+
*/
|
|
23
|
+
export function setFindingStatus(store, analysisId, findingId, body) {
|
|
24
|
+
if (body.status === "outdated") {
|
|
25
|
+
throw new AnalysisError(`A finding cannot be marked outdated from the review: only refresh_analysis does it, when the targeted lines change. Choose "ignored" or "open".`);
|
|
26
|
+
}
|
|
27
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
28
|
+
const finding = draft.findings.find((entry) => entry.id === findingId);
|
|
29
|
+
if (!finding)
|
|
30
|
+
throw new NotFoundError(`Finding "${findingId}" not found in analysis "${analysisId}".`);
|
|
31
|
+
if (finding.status === "outdated") {
|
|
32
|
+
throw new AnalysisError(`Finding "${findingId}" is outdated: its lines changed since it was written. The agent must anchor it again with update_finding before it can be ignored or reopened.`);
|
|
33
|
+
}
|
|
34
|
+
finding.status = body.status;
|
|
35
|
+
}, { baseRevision: body.baseRevision });
|
|
36
|
+
}
|
|
37
|
+
/** Ajoute une remarque du relecteur sur une ligne du côté « nouveau » ou sur un fichier entier. */
|
|
38
|
+
export function addNote(store, analysisId, body) {
|
|
39
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
40
|
+
const location = { path: body.path, line: body.line ?? null };
|
|
41
|
+
assertNoteLocation(draft.files, location);
|
|
42
|
+
draft.notes.push({
|
|
43
|
+
id: itemId("note", new Set(draft.notes.map((note) => note.id))),
|
|
44
|
+
location,
|
|
45
|
+
text: body.text,
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
});
|
|
48
|
+
}, { baseRevision: body.baseRevision });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Supprime une remarque. Quand elle fait partie d'une revue soumise, elle est
|
|
52
|
+
* retirée de la sélection et le prompt enregistré est recalculé sans elle.
|
|
53
|
+
*/
|
|
54
|
+
export function deleteNote(store, analysisId, noteId, body) {
|
|
55
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
56
|
+
const index = draft.notes.findIndex((note) => note.id === noteId);
|
|
57
|
+
if (index === -1)
|
|
58
|
+
throw new NotFoundError(`Note "${noteId}" not found in analysis "${analysisId}".`);
|
|
59
|
+
draft.notes.splice(index, 1);
|
|
60
|
+
const review = draft.review;
|
|
61
|
+
if (!review.selectedNoteIds.includes(noteId))
|
|
62
|
+
return;
|
|
63
|
+
review.selectedNoteIds = review.selectedNoteIds.filter((id) => id !== noteId);
|
|
64
|
+
if (review.state === "submitted") {
|
|
65
|
+
review.prompt = buildAgentPrompt(draft, {
|
|
66
|
+
findingIds: review.selectedFindingIds,
|
|
67
|
+
noteIds: review.selectedNoteIds,
|
|
68
|
+
decision: review.decision,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}, { baseRevision: body.baseRevision });
|
|
72
|
+
}
|
|
73
|
+
function assertKnownIds(label, ids, known) {
|
|
74
|
+
const knownSet = new Set(known);
|
|
75
|
+
const unknown = ids.filter((id) => !knownSet.has(id));
|
|
76
|
+
if (unknown.length > 0)
|
|
77
|
+
throw new AnalysisError(`Unknown ${label} id(s) in the selection: ${unknown.join(", ")}.`);
|
|
78
|
+
const duplicated = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
79
|
+
if (duplicated.length > 0)
|
|
80
|
+
throw new AnalysisError(`The selection lists ${label} id(s) twice: ${[...new Set(duplicated)].join(", ")}.`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Soumet la revue : décision, points retenus et prompt calculé ici. Une
|
|
84
|
+
* nouvelle soumission remplace la précédente.
|
|
85
|
+
*/
|
|
86
|
+
export function submitReview(store, analysisId, body) {
|
|
87
|
+
return store.mutate(analysisId, "user", (draft) => {
|
|
88
|
+
assertKnownIds("finding", body.findingIds, draft.findings.map((finding) => finding.id));
|
|
89
|
+
assertKnownIds("note", body.noteIds, draft.notes.map((note) => note.id));
|
|
90
|
+
draft.review = {
|
|
91
|
+
state: "submitted",
|
|
92
|
+
decision: body.decision,
|
|
93
|
+
selectedFindingIds: [...body.findingIds],
|
|
94
|
+
selectedNoteIds: [...body.noteIds],
|
|
95
|
+
prompt: buildAgentPrompt(draft, { findingIds: body.findingIds, noteIds: body.noteIds, decision: body.decision }),
|
|
96
|
+
submittedAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}, { baseRevision: body.baseRevision });
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|